@springbrand/agent-runtime 0.2.0-alpha.59 → 0.2.0-alpha.60

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.60",
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 required free text; use responseType: \"attachment\" when the user must upload one or more files. At most one question may " +
115
+ "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,41 @@ 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
47
  }, { additionalProperties: false });
41
48
 
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", {
48
- description:
49
- "Use attachment when the user must upload one or more files to continue.",
50
- }),
51
- }, { additionalProperties: false });
52
-
53
- const askUserQuestion = Type.Union([
54
- askUserOrdinaryQuestion,
55
- askUserAttachmentQuestion,
56
- ]);
57
-
58
49
  const askUserParameters = Type.Object({
59
50
  questions: Type.Array(askUserQuestion, {
60
51
  minItems: 1,
61
52
  maxItems: 10,
62
- contains: askUserAttachmentQuestion,
63
- minContains: 0,
64
- maxContains: 1,
65
53
  description:
66
- "All user decisions and files needed to continue. Ask them together in one call. At most one question may have kind attachment.",
54
+ "All user decisions and files needed to continue. Every question requires responseType. At most one question may use attachment.",
67
55
  }),
68
56
  }, { additionalProperties: false });
69
57
 
@@ -82,10 +70,11 @@ type AskUserFile = {
82
70
  readonly providerMetadata?: unknown;
83
71
  };
84
72
  type AskUserResponse = {
85
- readonly answers: ReadonlyArray<
86
- | { readonly selections: readonly string[]; readonly text?: string }
87
- | { readonly attachments: readonly AskUserFile[] }
88
- >;
73
+ readonly answers: ReadonlyArray<{
74
+ readonly selections: readonly string[];
75
+ readonly text: string;
76
+ readonly attachments: readonly AskUserFile[];
77
+ }>;
89
78
  };
90
79
 
91
80
  function recordOf(value: unknown): Record<string, unknown> | null {
@@ -94,55 +83,110 @@ function recordOf(value: unknown): Record<string, unknown> | null {
94
83
  : null;
95
84
  }
96
85
 
97
- function questionsOf(value: unknown): AskUserInput["questions"] | null {
86
+ function questionsOf(
87
+ value: unknown,
88
+ allowLegacy = false,
89
+ ): AskUserInput["questions"] | null {
98
90
  const questions = recordOf(value)?.questions;
99
91
  if (!Array.isArray(questions) || questions.length < 1 || questions.length > 10) {
100
92
  return null;
101
93
  }
94
+ const normalized: AskUserInput["questions"] = [];
102
95
  let attachmentQuestions = 0;
103
96
  for (const value of questions) {
104
97
  const question = recordOf(value);
105
98
  if (!question || typeof question.question !== "string" || !question.question) {
106
99
  return null;
107
100
  }
108
- if ("kind" in question && question.kind === "attachment") {
101
+ let responseType = question.responseType;
102
+ if (
103
+ typeof responseType !== "string" ||
104
+ !askUserResponseTypes.includes(
105
+ responseType as typeof askUserResponseTypes[number],
106
+ )
107
+ ) {
108
+ if (!allowLegacy || question.responseType !== undefined) return null;
109
+ if (question.kind === "attachment") {
110
+ responseType = "attachment";
111
+ } else if (question.kind === undefined) {
112
+ responseType = Array.isArray(question.options)
113
+ ? question.multiSelect === true
114
+ ? "multi_select"
115
+ : "single_select"
116
+ : "text";
117
+ } else {
118
+ return null;
119
+ }
120
+ }
121
+ if (responseType === "attachment") {
109
122
  attachmentQuestions += 1;
110
123
  if (
111
124
  attachmentQuestions > 1 ||
112
125
  question.options !== undefined ||
113
- question.multiSelect !== undefined ||
114
- question.allowCustom !== undefined
126
+ question.allowCustom !== undefined ||
127
+ (!allowLegacy && question.multiSelect !== undefined)
115
128
  ) return null;
129
+ normalized.push({ question: question.question, responseType });
130
+ continue;
131
+ }
132
+ if (responseType === "text") {
133
+ if (
134
+ question.options !== undefined ||
135
+ (!allowLegacy &&
136
+ (question.allowCustom !== undefined || question.multiSelect !== undefined))
137
+ ) {
138
+ return null;
139
+ }
140
+ normalized.push({ question: question.question, responseType });
116
141
  continue;
117
142
  }
118
- if (question.kind !== undefined) return null;
119
143
  if (
120
- question.multiSelect !== undefined &&
121
- typeof question.multiSelect !== "boolean"
144
+ !Array.isArray(question.options) ||
145
+ question.options.length < 2 ||
146
+ question.options.length > 6 ||
147
+ !question.options.every((option) =>
148
+ typeof option === "string" && option.length > 0
149
+ ) ||
150
+ new Set(question.options).size !== question.options.length ||
151
+ (question.allowCustom !== undefined &&
152
+ typeof question.allowCustom !== "boolean")
122
153
  ) return null;
123
154
  if (
124
- question.allowCustom !== undefined &&
125
- typeof question.allowCustom !== "boolean"
155
+ !allowLegacy &&
156
+ (question.kind !== undefined || question.multiSelect !== undefined)
126
157
  ) 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;
158
+ normalized.push({
159
+ question: question.question,
160
+ responseType: responseType as typeof askUserResponseTypes[number],
161
+ options: question.options,
162
+ ...(question.allowCustom === undefined
163
+ ? {}
164
+ : { allowCustom: question.allowCustom }),
165
+ });
166
+ }
167
+ return normalized;
168
+ }
169
+
170
+ function prepareAskUserArguments(input: unknown): AskUserInput {
171
+ const value = recordOf(input);
172
+ if (!value) throw new Error("Invalid ask_user questions");
173
+ let questions = value.questions;
174
+ if (typeof questions === "string") {
175
+ try {
176
+ questions = JSON.parse(questions);
177
+ } catch {
178
+ throw new Error("Invalid ask_user questions");
137
179
  }
138
180
  }
139
- return questions as AskUserInput["questions"];
181
+ const prepared = { ...value, questions };
182
+ if (!questionsOf(prepared)) throw new Error("Invalid ask_user questions");
183
+ return prepared as AskUserInput;
140
184
  }
141
185
 
142
186
  // 手写校验而不是拉 TypeBox compiler:这里只需要判真假,
143
187
  // 且校验位于 Worker 响应信任边界,不应为每次回答编译 schema。
144
188
  function isAskUserResponse(input: unknown, value: unknown): boolean {
145
- const questions = questionsOf(input);
189
+ const questions = questionsOf(input, true);
146
190
  const answers = recordOf(value)?.answers;
147
191
  if (!questions || !Array.isArray(answers) || answers.length !== questions.length) {
148
192
  return false;
@@ -150,54 +194,55 @@ function isAskUserResponse(input: unknown, value: unknown): boolean {
150
194
 
151
195
  return questions.every((question, index) => {
152
196
  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
- }
197
+ if (
198
+ !answer ||
199
+ !Array.isArray(answer.selections) ||
200
+ typeof answer.text !== "string" ||
201
+ !Array.isArray(answer.attachments) ||
202
+ Object.keys(answer).some((key) =>
203
+ key !== "selections" && key !== "text" && key !== "attachments"
204
+ )
205
+ ) return false;
182
206
  if (!answer.selections.every((item) => typeof item === "string")) {
183
207
  return false;
184
208
  }
185
- if (answer.text !== undefined && typeof answer.text !== "string") {
186
- return false;
209
+ if (answer.text.length > 2_000) return false;
210
+ if (!answer.attachments.every((value) => {
211
+ const file = recordOf(value);
212
+ return Boolean(
213
+ file &&
214
+ file.type === "file" &&
215
+ typeof file.url === "string" && file.url.length > 0 &&
216
+ typeof file.filename === "string" && file.filename.length > 0 &&
217
+ typeof file.mediaType === "string" && file.mediaType.length > 0 &&
218
+ (file.providerMetadata === undefined || recordOf(file.providerMetadata)) &&
219
+ Object.keys(file).every((key) =>
220
+ key === "type" || key === "url" || key === "filename" ||
221
+ key === "mediaType" || key === "providerMetadata"
222
+ )
223
+ );
224
+ })) return false;
225
+
226
+ if (question.responseType === "attachment") {
227
+ return answer.selections.length === 0 && !answer.text &&
228
+ answer.attachments.length >= 1 && answer.attachments.length <= 8;
229
+ }
230
+ if (answer.attachments.length > 0) return false;
231
+ if (question.responseType === "text") {
232
+ return answer.selections.length === 0 && answer.text.trim().length > 0;
187
233
  }
188
234
 
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;
235
+ const options = question.options ?? [];
193
236
  if (answer.selections.some((selection) => !options.includes(selection))) {
194
237
  return false;
195
238
  }
196
239
  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;
240
+ if (
241
+ question.responseType === "single_select" &&
242
+ answer.selections.length > 1
243
+ ) return false;
244
+ if (!question.allowCustom && answer.text.trim()) return false;
245
+ return answer.selections.length > 0 || answer.text.trim().length > 0;
201
246
  });
202
247
  }
203
248
 
@@ -322,8 +367,9 @@ export function basePiToolCandidates(
322
367
  name: "ask_user",
323
368
  label: "Ask user",
324
369
  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.",
370
+ "Ask all 1-10 user questions needed to continue in one call. Required input for every question: question and responseType. Use responseType text for free text, single_select or multi_select with 2-6 distinct options for choices, and attachment when the user must upload 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
371
  parameters: askUserParameters,
372
+ prepareArguments: prepareAskUserArguments,
327
373
  // 永远不会被调用:执行链在 interaction 闸就 park 住了。留一个失败关闭的
328
374
  // 实现,是为了万一哪次改动绕过了那道闸,能立刻炸出来而不是静默返回空答案。
329
375
  async execute() {
@@ -335,59 +381,47 @@ export function basePiToolCandidates(
335
381
  interaction: {
336
382
  validateResponse: isAskUserResponse,
337
383
  settle: (input, response) => {
338
- const questions = (input as AskUserInput).questions;
384
+ const questions = questionsOf(input, true)!;
339
385
  const responseAnswers = (response as AskUserResponse).answers;
340
386
  const answers = questions.map((question, index) => {
341
387
  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
388
  return {
379
389
  question: question.question,
380
- selections: answer.selections,
381
- ...(answer.text !== undefined ? { text: answer.text.trim() } : {}),
390
+ selections: [...answer.selections],
391
+ text: answer.text.trim(),
392
+ attachments: answer.attachments.map((file) => {
393
+ const springbrand = recordOf(
394
+ recordOf(file.providerMetadata)?.springbrand,
395
+ );
396
+ return {
397
+ name: file.filename,
398
+ mediaType: file.mediaType,
399
+ size: typeof springbrand?.size === "number"
400
+ ? springbrand.size
401
+ : 0,
402
+ cdnUrl: typeof springbrand?.cdnUrl === "string"
403
+ ? springbrand.cdnUrl
404
+ : file.url,
405
+ workspacePath: typeof springbrand?.path === "string"
406
+ ? springbrand.path
407
+ : "",
408
+ attachmentId: typeof springbrand?.attachmentId === "string"
409
+ ? springbrand.attachmentId
410
+ : "",
411
+ contentVersion:
412
+ typeof springbrand?.contentVersion === "string"
413
+ ? springbrand.contentVersion
414
+ : "",
415
+ };
416
+ }),
382
417
  };
383
418
  });
384
419
  return {
385
420
  content: [{
386
421
  type: "text",
387
422
  text: `The user answered:\n${answers.map((answer, index) => {
388
- if ("attachments" in answer) {
423
+ if (answer.attachments.length > 0) {
389
424
  const responseAnswer = responseAnswers[index]!;
390
- if (!("attachments" in responseAnswer)) return "";
391
425
  return `${index + 1}. ${answer.question}\n${responseAnswer.attachments
392
426
  .map((file) => ` - ${modelAttachmentText(file)}`)
393
427
  .join("\n")}`;