@springbrand/agent-runtime 0.2.0-alpha.59 → 0.2.0-alpha.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/lib/prompt.ts +4 -2
- package/src/pi/tool/base.ts +186 -133
package/package.json
CHANGED
package/src/lib/prompt.ts
CHANGED
|
@@ -109,8 +109,10 @@ export const FILES =
|
|
|
109
109
|
// User-interaction actions are called only when their UI semantics are useful.
|
|
110
110
|
export const INTERACTION =
|
|
111
111
|
"Interaction: When progress requires user decisions, put all 1-10 necessary questions in one ask_user call " +
|
|
112
|
-
"instead of asking each question separately or writing 'please choose A/B/C' as text.
|
|
113
|
-
"
|
|
112
|
+
"instead of asking each question separately or writing 'please choose A/B/C' as text. Every question must set exactly one responseType. " +
|
|
113
|
+
"Use responseType: \"single_select\" or responseType: \"multi_select\" with 2-6 options for choices; use responseType: \"text\" " +
|
|
114
|
+
"for free text; use responseType: \"attachment\" for one or more files. Set required: true only when progress cannot continue " +
|
|
115
|
+
"without that answer; required defaults to false. At most one question may request attachments. Do not add an accept field or tell the user to upload through the normal chat composer; " +
|
|
114
116
|
"continue the same turn when its result arrives. " +
|
|
115
117
|
"Don't use it when a sensible default lets you proceed. " +
|
|
116
118
|
"After completing a substantive task (report, analysis, multi-step job), finish all work and other Tool calls, " +
|
package/src/pi/tool/base.ts
CHANGED
|
@@ -17,53 +17,46 @@ import type { PiToolCandidate } from "./compiler";
|
|
|
17
17
|
import { webSearchPiToolCandidate } from "./web-search";
|
|
18
18
|
import type { WebSearch } from "./web-search/api";
|
|
19
19
|
|
|
20
|
-
const
|
|
20
|
+
const askUserResponseTypes = [
|
|
21
|
+
"text",
|
|
22
|
+
"single_select",
|
|
23
|
+
"multi_select",
|
|
24
|
+
"attachment",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
const askUserQuestion = Type.Object({
|
|
21
28
|
question: Type.String({
|
|
22
29
|
minLength: 1,
|
|
23
|
-
description: "The question to put to the user.",
|
|
30
|
+
description: "The question or file request to put to the user.",
|
|
31
|
+
}),
|
|
32
|
+
responseType: StringEnum(askUserResponseTypes, {
|
|
33
|
+
description:
|
|
34
|
+
"Required response UI: text, single_select, multi_select, or attachment.",
|
|
24
35
|
}),
|
|
25
36
|
options: Type.Optional(Type.Array(Type.String({ minLength: 1 }), {
|
|
26
37
|
minItems: 2,
|
|
27
38
|
maxItems: 6,
|
|
28
39
|
uniqueItems: true,
|
|
29
40
|
description:
|
|
30
|
-
"
|
|
31
|
-
})),
|
|
32
|
-
multiSelect: Type.Optional(Type.Boolean({
|
|
33
|
-
description:
|
|
34
|
-
"Whether the user may select more than one option. Defaults to false.",
|
|
41
|
+
"Required only for single_select or multi_select. Provide 2-6 distinct options; omit for text and attachment.",
|
|
35
42
|
})),
|
|
36
43
|
allowCustom: Type.Optional(Type.Boolean({
|
|
37
44
|
description:
|
|
38
|
-
"
|
|
45
|
+
"For single_select or multi_select, whether the user may also add free text. Defaults to false.",
|
|
39
46
|
})),
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const askUserAttachmentQuestion = Type.Object({
|
|
43
|
-
question: Type.String({
|
|
44
|
-
minLength: 1,
|
|
45
|
-
description: "The request for one or more user files.",
|
|
46
|
-
}),
|
|
47
|
-
kind: Type.Literal("attachment", {
|
|
47
|
+
required: Type.Optional(Type.Boolean({
|
|
48
|
+
default: false,
|
|
48
49
|
description:
|
|
49
|
-
"
|
|
50
|
-
}),
|
|
50
|
+
"Whether the user must answer this question to submit. Defaults to false.",
|
|
51
|
+
})),
|
|
51
52
|
}, { additionalProperties: false });
|
|
52
53
|
|
|
53
|
-
const askUserQuestion = Type.Union([
|
|
54
|
-
askUserOrdinaryQuestion,
|
|
55
|
-
askUserAttachmentQuestion,
|
|
56
|
-
]);
|
|
57
|
-
|
|
58
54
|
const askUserParameters = Type.Object({
|
|
59
55
|
questions: Type.Array(askUserQuestion, {
|
|
60
56
|
minItems: 1,
|
|
61
57
|
maxItems: 10,
|
|
62
|
-
contains: askUserAttachmentQuestion,
|
|
63
|
-
minContains: 0,
|
|
64
|
-
maxContains: 1,
|
|
65
58
|
description:
|
|
66
|
-
"All user decisions and files needed to continue.
|
|
59
|
+
"All user decisions and files needed to continue. Every question requires responseType. At most one question may use attachment.",
|
|
67
60
|
}),
|
|
68
61
|
}, { additionalProperties: false });
|
|
69
62
|
|
|
@@ -82,10 +75,11 @@ type AskUserFile = {
|
|
|
82
75
|
readonly providerMetadata?: unknown;
|
|
83
76
|
};
|
|
84
77
|
type AskUserResponse = {
|
|
85
|
-
readonly answers: ReadonlyArray<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
78
|
+
readonly answers: ReadonlyArray<{
|
|
79
|
+
readonly selections: readonly string[];
|
|
80
|
+
readonly text: string;
|
|
81
|
+
readonly attachments: readonly AskUserFile[];
|
|
82
|
+
}>;
|
|
89
83
|
};
|
|
90
84
|
|
|
91
85
|
function recordOf(value: unknown): Record<string, unknown> | null {
|
|
@@ -94,55 +88,118 @@ function recordOf(value: unknown): Record<string, unknown> | null {
|
|
|
94
88
|
: null;
|
|
95
89
|
}
|
|
96
90
|
|
|
97
|
-
function questionsOf(
|
|
91
|
+
function questionsOf(
|
|
92
|
+
value: unknown,
|
|
93
|
+
allowLegacy = false,
|
|
94
|
+
): AskUserInput["questions"] | null {
|
|
98
95
|
const questions = recordOf(value)?.questions;
|
|
99
96
|
if (!Array.isArray(questions) || questions.length < 1 || questions.length > 10) {
|
|
100
97
|
return null;
|
|
101
98
|
}
|
|
99
|
+
const normalized: AskUserInput["questions"] = [];
|
|
102
100
|
let attachmentQuestions = 0;
|
|
103
101
|
for (const value of questions) {
|
|
104
102
|
const question = recordOf(value);
|
|
105
103
|
if (!question || typeof question.question !== "string" || !question.question) {
|
|
106
104
|
return null;
|
|
107
105
|
}
|
|
108
|
-
if (
|
|
106
|
+
if (
|
|
107
|
+
question.required !== undefined && typeof question.required !== "boolean"
|
|
108
|
+
) return null;
|
|
109
|
+
const required = question.required === true;
|
|
110
|
+
let responseType = question.responseType;
|
|
111
|
+
if (
|
|
112
|
+
typeof responseType !== "string" ||
|
|
113
|
+
!askUserResponseTypes.includes(
|
|
114
|
+
responseType as typeof askUserResponseTypes[number],
|
|
115
|
+
)
|
|
116
|
+
) {
|
|
117
|
+
if (!allowLegacy || question.responseType !== undefined) return null;
|
|
118
|
+
if (question.kind === "attachment") {
|
|
119
|
+
responseType = "attachment";
|
|
120
|
+
} else if (question.kind === undefined) {
|
|
121
|
+
responseType = Array.isArray(question.options)
|
|
122
|
+
? question.multiSelect === true
|
|
123
|
+
? "multi_select"
|
|
124
|
+
: "single_select"
|
|
125
|
+
: "text";
|
|
126
|
+
} else {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const normalizedQuestion = {
|
|
131
|
+
question: question.question,
|
|
132
|
+
responseType: responseType as typeof askUserResponseTypes[number],
|
|
133
|
+
required,
|
|
134
|
+
};
|
|
135
|
+
if (responseType === "attachment") {
|
|
109
136
|
attachmentQuestions += 1;
|
|
110
137
|
if (
|
|
111
138
|
attachmentQuestions > 1 ||
|
|
112
139
|
question.options !== undefined ||
|
|
113
|
-
question.
|
|
114
|
-
question.
|
|
140
|
+
question.allowCustom !== undefined ||
|
|
141
|
+
(!allowLegacy && question.multiSelect !== undefined)
|
|
115
142
|
) return null;
|
|
143
|
+
normalized.push(normalizedQuestion);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (responseType === "text") {
|
|
147
|
+
if (
|
|
148
|
+
question.options !== undefined ||
|
|
149
|
+
(!allowLegacy &&
|
|
150
|
+
(question.allowCustom !== undefined || question.multiSelect !== undefined))
|
|
151
|
+
) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
normalized.push(normalizedQuestion);
|
|
116
155
|
continue;
|
|
117
156
|
}
|
|
118
|
-
if (question.kind !== undefined) return null;
|
|
119
157
|
if (
|
|
120
|
-
question.
|
|
121
|
-
|
|
158
|
+
!Array.isArray(question.options) ||
|
|
159
|
+
question.options.length < 2 ||
|
|
160
|
+
question.options.length > 6 ||
|
|
161
|
+
!question.options.every((option) =>
|
|
162
|
+
typeof option === "string" && option.length > 0
|
|
163
|
+
) ||
|
|
164
|
+
new Set(question.options).size !== question.options.length ||
|
|
165
|
+
(question.allowCustom !== undefined &&
|
|
166
|
+
typeof question.allowCustom !== "boolean")
|
|
122
167
|
) return null;
|
|
123
168
|
if (
|
|
124
|
-
|
|
125
|
-
|
|
169
|
+
!allowLegacy &&
|
|
170
|
+
(question.kind !== undefined || question.multiSelect !== undefined)
|
|
126
171
|
) return null;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
172
|
+
normalized.push({
|
|
173
|
+
...normalizedQuestion,
|
|
174
|
+
options: question.options,
|
|
175
|
+
...(question.allowCustom === undefined
|
|
176
|
+
? {}
|
|
177
|
+
: { allowCustom: question.allowCustom }),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return normalized;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function prepareAskUserArguments(input: unknown): AskUserInput {
|
|
184
|
+
const value = recordOf(input);
|
|
185
|
+
if (!value) throw new Error("Invalid ask_user questions");
|
|
186
|
+
let questions = value.questions;
|
|
187
|
+
if (typeof questions === "string") {
|
|
188
|
+
try {
|
|
189
|
+
questions = JSON.parse(questions);
|
|
190
|
+
} catch {
|
|
191
|
+
throw new Error("Invalid ask_user questions");
|
|
137
192
|
}
|
|
138
193
|
}
|
|
139
|
-
|
|
194
|
+
const prepared = { ...value, questions };
|
|
195
|
+
if (!questionsOf(prepared)) throw new Error("Invalid ask_user questions");
|
|
196
|
+
return prepared as AskUserInput;
|
|
140
197
|
}
|
|
141
198
|
|
|
142
199
|
// 手写校验而不是拉 TypeBox compiler:这里只需要判真假,
|
|
143
200
|
// 且校验位于 Worker 响应信任边界,不应为每次回答编译 schema。
|
|
144
201
|
function isAskUserResponse(input: unknown, value: unknown): boolean {
|
|
145
|
-
const questions = questionsOf(input);
|
|
202
|
+
const questions = questionsOf(input, true);
|
|
146
203
|
const answers = recordOf(value)?.answers;
|
|
147
204
|
if (!questions || !Array.isArray(answers) || answers.length !== questions.length) {
|
|
148
205
|
return false;
|
|
@@ -150,54 +207,61 @@ function isAskUserResponse(input: unknown, value: unknown): boolean {
|
|
|
150
207
|
|
|
151
208
|
return questions.every((question, index) => {
|
|
152
209
|
const answer = recordOf(answers[index]);
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return attachments.every((value) => {
|
|
163
|
-
const file = recordOf(value);
|
|
164
|
-
return Boolean(
|
|
165
|
-
file &&
|
|
166
|
-
file.type === "file" &&
|
|
167
|
-
typeof file.url === "string" && file.url.length > 0 &&
|
|
168
|
-
typeof file.filename === "string" && file.filename.length > 0 &&
|
|
169
|
-
typeof file.mediaType === "string" && file.mediaType.length > 0 &&
|
|
170
|
-
(file.providerMetadata === undefined || recordOf(file.providerMetadata)) &&
|
|
171
|
-
Object.keys(file).every((key) =>
|
|
172
|
-
key === "type" || key === "url" || key === "filename" ||
|
|
173
|
-
key === "mediaType" || key === "providerMetadata"
|
|
174
|
-
)
|
|
175
|
-
);
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
if (!answer || !Array.isArray(answer.selections)) return false;
|
|
179
|
-
if (Object.keys(answer).some((key) => key !== "selections" && key !== "text")) {
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
210
|
+
if (
|
|
211
|
+
!answer ||
|
|
212
|
+
!Array.isArray(answer.selections) ||
|
|
213
|
+
typeof answer.text !== "string" ||
|
|
214
|
+
!Array.isArray(answer.attachments) ||
|
|
215
|
+
Object.keys(answer).some((key) =>
|
|
216
|
+
key !== "selections" && key !== "text" && key !== "attachments"
|
|
217
|
+
)
|
|
218
|
+
) return false;
|
|
182
219
|
if (!answer.selections.every((item) => typeof item === "string")) {
|
|
183
220
|
return false;
|
|
184
221
|
}
|
|
185
|
-
if (answer.text
|
|
186
|
-
|
|
222
|
+
if (answer.text.length > 2_000) return false;
|
|
223
|
+
if (!answer.attachments.every((value) => {
|
|
224
|
+
const file = recordOf(value);
|
|
225
|
+
return Boolean(
|
|
226
|
+
file &&
|
|
227
|
+
file.type === "file" &&
|
|
228
|
+
typeof file.url === "string" && file.url.length > 0 &&
|
|
229
|
+
typeof file.filename === "string" && file.filename.length > 0 &&
|
|
230
|
+
typeof file.mediaType === "string" && file.mediaType.length > 0 &&
|
|
231
|
+
(file.providerMetadata === undefined || recordOf(file.providerMetadata)) &&
|
|
232
|
+
Object.keys(file).every((key) =>
|
|
233
|
+
key === "type" || key === "url" || key === "filename" ||
|
|
234
|
+
key === "mediaType" || key === "providerMetadata"
|
|
235
|
+
)
|
|
236
|
+
);
|
|
237
|
+
})) return false;
|
|
238
|
+
|
|
239
|
+
if (
|
|
240
|
+
answer.selections.length === 0 &&
|
|
241
|
+
!answer.text.trim() &&
|
|
242
|
+
answer.attachments.length === 0
|
|
243
|
+
) return question.required !== true;
|
|
244
|
+
|
|
245
|
+
if (question.responseType === "attachment") {
|
|
246
|
+
return answer.selections.length === 0 && !answer.text &&
|
|
247
|
+
answer.attachments.length >= 1 && answer.attachments.length <= 8;
|
|
248
|
+
}
|
|
249
|
+
if (answer.attachments.length > 0) return false;
|
|
250
|
+
if (question.responseType === "text") {
|
|
251
|
+
return answer.selections.length === 0 && answer.text.trim().length > 0;
|
|
187
252
|
}
|
|
188
253
|
|
|
189
|
-
const
|
|
190
|
-
const options = ordinary.options ?? [];
|
|
191
|
-
const text = typeof answer.text === "string" ? answer.text : "";
|
|
192
|
-
if (text.length > 2_000) return false;
|
|
254
|
+
const options = question.options ?? [];
|
|
193
255
|
if (answer.selections.some((selection) => !options.includes(selection))) {
|
|
194
256
|
return false;
|
|
195
257
|
}
|
|
196
258
|
if (new Set(answer.selections).size !== answer.selections.length) return false;
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
259
|
+
if (
|
|
260
|
+
question.responseType === "single_select" &&
|
|
261
|
+
answer.selections.length > 1
|
|
262
|
+
) return false;
|
|
263
|
+
if (!question.allowCustom && answer.text.trim()) return false;
|
|
264
|
+
return answer.selections.length > 0 || answer.text.trim().length > 0;
|
|
201
265
|
});
|
|
202
266
|
}
|
|
203
267
|
|
|
@@ -322,8 +386,9 @@ export function basePiToolCandidates(
|
|
|
322
386
|
name: "ask_user",
|
|
323
387
|
label: "Ask user",
|
|
324
388
|
description:
|
|
325
|
-
"Ask all 1-10 user questions needed to continue in one call.
|
|
389
|
+
"Ask all 1-10 user questions needed to continue in one call. Required input for every question: question and responseType; required defaults to false, so set it to true only when progress cannot continue without an answer. Use responseType text for free text, single_select or multi_select with 2-6 distinct options for choices, and attachment for files. At most one attachment question is allowed and it accepts multiple files. Do not add file-specific fields such as accept or tell the user to use the normal chat composer. The answers return as this tool's result, so continue the same turn after calling it.",
|
|
326
390
|
parameters: askUserParameters,
|
|
391
|
+
prepareArguments: prepareAskUserArguments,
|
|
327
392
|
// 永远不会被调用:执行链在 interaction 闸就 park 住了。留一个失败关闭的
|
|
328
393
|
// 实现,是为了万一哪次改动绕过了那道闸,能立刻炸出来而不是静默返回空答案。
|
|
329
394
|
async execute() {
|
|
@@ -335,59 +400,47 @@ export function basePiToolCandidates(
|
|
|
335
400
|
interaction: {
|
|
336
401
|
validateResponse: isAskUserResponse,
|
|
337
402
|
settle: (input, response) => {
|
|
338
|
-
const questions = (input
|
|
403
|
+
const questions = questionsOf(input, true)!;
|
|
339
404
|
const responseAnswers = (response as AskUserResponse).answers;
|
|
340
405
|
const answers = questions.map((question, index) => {
|
|
341
406
|
const answer = responseAnswers[index]!;
|
|
342
|
-
if (
|
|
343
|
-
"kind" in question && question.kind === "attachment" &&
|
|
344
|
-
"attachments" in answer
|
|
345
|
-
) {
|
|
346
|
-
return {
|
|
347
|
-
question: question.question,
|
|
348
|
-
attachments: answer.attachments.map((file) => {
|
|
349
|
-
const springbrand = recordOf(
|
|
350
|
-
recordOf(file.providerMetadata)?.springbrand,
|
|
351
|
-
);
|
|
352
|
-
return {
|
|
353
|
-
name: file.filename,
|
|
354
|
-
mediaType: file.mediaType,
|
|
355
|
-
size: typeof springbrand?.size === "number"
|
|
356
|
-
? springbrand.size
|
|
357
|
-
: 0,
|
|
358
|
-
cdnUrl: typeof springbrand?.cdnUrl === "string"
|
|
359
|
-
? springbrand.cdnUrl
|
|
360
|
-
: file.url,
|
|
361
|
-
workspacePath: typeof springbrand?.path === "string"
|
|
362
|
-
? springbrand.path
|
|
363
|
-
: "",
|
|
364
|
-
attachmentId: typeof springbrand?.attachmentId === "string"
|
|
365
|
-
? springbrand.attachmentId
|
|
366
|
-
: "",
|
|
367
|
-
contentVersion:
|
|
368
|
-
typeof springbrand?.contentVersion === "string"
|
|
369
|
-
? springbrand.contentVersion
|
|
370
|
-
: "",
|
|
371
|
-
};
|
|
372
|
-
}),
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
if (!("selections" in answer)) {
|
|
376
|
-
throw new Error("Invalid ask_user answer");
|
|
377
|
-
}
|
|
378
407
|
return {
|
|
379
408
|
question: question.question,
|
|
380
|
-
selections: answer.selections,
|
|
381
|
-
|
|
409
|
+
selections: [...answer.selections],
|
|
410
|
+
text: answer.text.trim(),
|
|
411
|
+
attachments: answer.attachments.map((file) => {
|
|
412
|
+
const springbrand = recordOf(
|
|
413
|
+
recordOf(file.providerMetadata)?.springbrand,
|
|
414
|
+
);
|
|
415
|
+
return {
|
|
416
|
+
name: file.filename,
|
|
417
|
+
mediaType: file.mediaType,
|
|
418
|
+
size: typeof springbrand?.size === "number"
|
|
419
|
+
? springbrand.size
|
|
420
|
+
: 0,
|
|
421
|
+
cdnUrl: typeof springbrand?.cdnUrl === "string"
|
|
422
|
+
? springbrand.cdnUrl
|
|
423
|
+
: file.url,
|
|
424
|
+
workspacePath: typeof springbrand?.path === "string"
|
|
425
|
+
? springbrand.path
|
|
426
|
+
: "",
|
|
427
|
+
attachmentId: typeof springbrand?.attachmentId === "string"
|
|
428
|
+
? springbrand.attachmentId
|
|
429
|
+
: "",
|
|
430
|
+
contentVersion:
|
|
431
|
+
typeof springbrand?.contentVersion === "string"
|
|
432
|
+
? springbrand.contentVersion
|
|
433
|
+
: "",
|
|
434
|
+
};
|
|
435
|
+
}),
|
|
382
436
|
};
|
|
383
437
|
});
|
|
384
438
|
return {
|
|
385
439
|
content: [{
|
|
386
440
|
type: "text",
|
|
387
441
|
text: `The user answered:\n${answers.map((answer, index) => {
|
|
388
|
-
if (
|
|
442
|
+
if (answer.attachments.length > 0) {
|
|
389
443
|
const responseAnswer = responseAnswers[index]!;
|
|
390
|
-
if (!("attachments" in responseAnswer)) return "";
|
|
391
444
|
return `${index + 1}. ${answer.question}\n${responseAnswer.attachments
|
|
392
445
|
.map((file) => ` - ${modelAttachmentText(file)}`)
|
|
393
446
|
.join("\n")}`;
|