@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/agent-runtime",
3
- "version": "0.2.0-alpha.59",
3
+ "version": "0.2.0-alpha.61",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
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. Use 2-6 options for " +
113
- "a bounded choice; for required free text, omit the options field entirely — never pass an empty or single-item options array; " +
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, " +
@@ -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 askUserOrdinaryQuestion = Type.Object({
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
- "For a bounded choice, provide 2-6 mutually distinct options. For required free text, omit this field entirely; never pass an empty or single-item array.",
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
- "Whether the user may add free text. Defaults to false with options and true without options.",
45
+ "For single_select or multi_select, whether the user may also add free text. Defaults to false.",
39
46
  })),
40
- }, { additionalProperties: false });
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
- "Use attachment when the user must upload one or more files to continue.",
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. Ask them together in one call. At most one question may have kind attachment.",
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
- | { readonly selections: readonly string[]; readonly text?: string }
87
- | { readonly attachments: readonly AskUserFile[] }
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(value: unknown): AskUserInput["questions"] | null {
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 ("kind" in question && question.kind === "attachment") {
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.multiSelect !== undefined ||
114
- question.allowCustom !== undefined
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.multiSelect !== undefined &&
121
- typeof question.multiSelect !== "boolean"
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
- question.allowCustom !== undefined &&
125
- typeof question.allowCustom !== "boolean"
169
+ !allowLegacy &&
170
+ (question.kind !== undefined || question.multiSelect !== undefined)
126
171
  ) return null;
127
- if (question.options !== undefined) {
128
- if (
129
- !Array.isArray(question.options) ||
130
- question.options.length < 2 ||
131
- question.options.length > 6 ||
132
- !question.options.every((option) =>
133
- typeof option === "string" && option.length > 0
134
- ) ||
135
- new Set(question.options).size !== question.options.length
136
- ) return null;
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
- return questions as AskUserInput["questions"];
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 ("kind" in question && question.kind === "attachment") {
154
- const attachments = answer?.attachments;
155
- if (
156
- !answer ||
157
- !Array.isArray(attachments) ||
158
- attachments.length < 1 ||
159
- attachments.length > 8 ||
160
- Object.keys(answer).some((key) => key !== "attachments")
161
- ) return false;
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 !== undefined && typeof answer.text !== "string") {
186
- return false;
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 ordinary = question as Static<typeof askUserOrdinaryQuestion>;
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 (!ordinary.multiSelect && answer.selections.length > 1) return false;
198
- const allowCustom = options.length === 0 || ordinary.allowCustom === true;
199
- if (!allowCustom && text.trim()) return false;
200
- return answer.selections.length > 0 || text.trim().length > 0;
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. For files, use one question with kind attachment; it accepts multiple files, so do not ask for each file separately or tell the user to use the normal chat composer. For a bounded choice, provide 2-6 distinct options. For required free text, omit the options field entirely; never pass an empty, single-item, or more-than-6-item options array. The answers return as this tool's result, so continue the same turn after calling it.",
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 as AskUserInput).questions;
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
- ...(answer.text !== undefined ? { text: answer.text.trim() } : {}),
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 ("attachments" in answer) {
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")}`;