@springbrand/agent-runtime 0.2.0-alpha.57 → 0.2.0-alpha.58

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.57",
3
+ "version": "0.2.0-alpha.58",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
package/src/index.ts CHANGED
@@ -50,6 +50,7 @@ export * from "./kernel/runtime-config";
50
50
  export * from "./telemetry";
51
51
  export type { RuntimeAssemblyView } from "./kernel/runtime-assembly-view";
52
52
  export * from "./runtime-agent";
53
+ export type { ToolInteractionResponsePreparation } from "./runtime";
53
54
  export * from "./kernel/state";
54
55
  export * from "./workspace-versioning";
55
56
  export * from "./lib/execution-level";
@@ -17,8 +17,13 @@ function timestampOf(message: UIMessage): number {
17
17
  return typeof createdAt === "number" ? createdAt : Date.now();
18
18
  }
19
19
 
20
- function attachmentText(
21
- part: Extract<UIMessage["parts"][number], { type: "file" }>,
20
+ export function modelAttachmentText(
21
+ part: {
22
+ readonly url: string;
23
+ readonly filename?: string;
24
+ readonly mediaType: string;
25
+ readonly providerMetadata?: unknown;
26
+ },
22
27
  ): string {
23
28
  const name = part.filename?.trim() || "attachment";
24
29
  const springbrand = recordOf(recordOf(part.providerMetadata).springbrand);
@@ -66,7 +71,7 @@ function userContent(
66
71
  });
67
72
  } else {
68
73
  content.push(
69
- { type: "text", text: attachmentText(part) },
74
+ { type: "text", text: modelAttachmentText(part) },
70
75
  {
71
76
  type: "file",
72
77
  url: part.url,
@@ -12,11 +12,12 @@ import { estimateStringTokens } from "agents/experimental/memory/utils";
12
12
  import type { RuntimeMemoryPort } from "../../kernel/bindings";
13
13
  import type { RuntimeMemoryProfile } from "../../kernel/profile";
14
14
  import { serializeOutput } from "../../lib/artifacts";
15
+ import { modelAttachmentText } from "../message/conversion";
15
16
  import type { PiToolCandidate } from "./compiler";
16
17
  import { webSearchPiToolCandidate } from "./web-search";
17
18
  import type { WebSearch } from "./web-search/api";
18
19
 
19
- const askUserQuestion = Type.Object({
20
+ const askUserOrdinaryQuestion = Type.Object({
20
21
  question: Type.String({
21
22
  minLength: 1,
22
23
  description: "The question to put to the user.",
@@ -38,12 +39,31 @@ const askUserQuestion = Type.Object({
38
39
  })),
39
40
  }, { additionalProperties: false });
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", {
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
+
41
58
  const askUserParameters = Type.Object({
42
59
  questions: Type.Array(askUserQuestion, {
43
60
  minItems: 1,
44
61
  maxItems: 10,
62
+ contains: askUserAttachmentQuestion,
63
+ minContains: 0,
64
+ maxContains: 1,
45
65
  description:
46
- "All user decisions needed to continue. Ask them together in one call.",
66
+ "All user decisions and files needed to continue. Ask them together in one call. At most one question may have kind attachment.",
47
67
  }),
48
68
  }, { additionalProperties: false });
49
69
 
@@ -53,15 +73,20 @@ const askUserParameters = Type.Object({
53
73
  * `answers` 与 Tool 输入的 `questions` 按下标一一对应。
54
74
  * 刻意用结构化数组而不是拼好的字符串 —— 选项本身可能含分隔符,拼了再拆是有损的。
55
75
  */
56
- const askUserResponse = Type.Object({
57
- answers: Type.Array(Type.Object({
58
- selections: Type.Array(Type.String()),
59
- text: Type.Optional(Type.String()),
60
- }, { additionalProperties: false })),
61
- }, { additionalProperties: false });
62
-
63
76
  type AskUserInput = Static<typeof askUserParameters>;
64
- type AskUserResponse = Static<typeof askUserResponse>;
77
+ type AskUserFile = {
78
+ readonly type: "file";
79
+ readonly url: string;
80
+ readonly filename: string;
81
+ readonly mediaType: string;
82
+ readonly providerMetadata?: unknown;
83
+ };
84
+ type AskUserResponse = {
85
+ readonly answers: ReadonlyArray<
86
+ | { readonly selections: readonly string[]; readonly text?: string }
87
+ | { readonly attachments: readonly AskUserFile[] }
88
+ >;
89
+ };
65
90
 
66
91
  function recordOf(value: unknown): Record<string, unknown> | null {
67
92
  return typeof value === "object" && value !== null && !Array.isArray(value)
@@ -74,11 +99,23 @@ function questionsOf(value: unknown): AskUserInput["questions"] | null {
74
99
  if (!Array.isArray(questions) || questions.length < 1 || questions.length > 10) {
75
100
  return null;
76
101
  }
102
+ let attachmentQuestions = 0;
77
103
  for (const value of questions) {
78
104
  const question = recordOf(value);
79
105
  if (!question || typeof question.question !== "string" || !question.question) {
80
106
  return null;
81
107
  }
108
+ if ("kind" in question && question.kind === "attachment") {
109
+ attachmentQuestions += 1;
110
+ if (
111
+ attachmentQuestions > 1 ||
112
+ question.options !== undefined ||
113
+ question.multiSelect !== undefined ||
114
+ question.allowCustom !== undefined
115
+ ) return null;
116
+ continue;
117
+ }
118
+ if (question.kind !== undefined) return null;
82
119
  if (
83
120
  question.multiSelect !== undefined &&
84
121
  typeof question.multiSelect !== "boolean"
@@ -113,7 +150,35 @@ function isAskUserResponse(input: unknown, value: unknown): boolean {
113
150
 
114
151
  return questions.every((question, index) => {
115
152
  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
+ }
116
178
  if (!answer || !Array.isArray(answer.selections)) return false;
179
+ if (Object.keys(answer).some((key) => key !== "selections" && key !== "text")) {
180
+ return false;
181
+ }
117
182
  if (!answer.selections.every((item) => typeof item === "string")) {
118
183
  return false;
119
184
  }
@@ -121,15 +186,16 @@ function isAskUserResponse(input: unknown, value: unknown): boolean {
121
186
  return false;
122
187
  }
123
188
 
124
- const options = question.options ?? [];
189
+ const ordinary = question as Static<typeof askUserOrdinaryQuestion>;
190
+ const options = ordinary.options ?? [];
125
191
  const text = typeof answer.text === "string" ? answer.text : "";
126
192
  if (text.length > 2_000) return false;
127
193
  if (answer.selections.some((selection) => !options.includes(selection))) {
128
194
  return false;
129
195
  }
130
196
  if (new Set(answer.selections).size !== answer.selections.length) return false;
131
- if (!question.multiSelect && answer.selections.length > 1) return false;
132
- const allowCustom = options.length === 0 || question.allowCustom === true;
197
+ if (!ordinary.multiSelect && answer.selections.length > 1) return false;
198
+ const allowCustom = options.length === 0 || ordinary.allowCustom === true;
133
199
  if (!allowCustom && text.trim()) return false;
134
200
  return answer.selections.length > 0 || text.trim().length > 0;
135
201
  });
@@ -256,7 +322,7 @@ export function basePiToolCandidates(
256
322
  name: "ask_user",
257
323
  label: "Ask user",
258
324
  description:
259
- "Ask all 1-10 user questions needed to continue in one call. 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. Do not make one call per question or write plain text like 'please pick A / B / C'. The answers return as this tool's result, so continue the same turn after calling it.",
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.",
260
326
  parameters: askUserParameters,
261
327
  // 永远不会被调用:执行链在 interaction 闸就 park 住了。留一个失败关闭的
262
328
  // 实现,是为了万一哪次改动绕过了那道闸,能立刻炸出来而不是静默返回空答案。
@@ -273,6 +339,42 @@ export function basePiToolCandidates(
273
339
  const responseAnswers = (response as AskUserResponse).answers;
274
340
  const answers = questions.map((question, index) => {
275
341
  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
+ }
276
378
  return {
277
379
  question: question.question,
278
380
  selections: answer.selections,
@@ -283,6 +385,13 @@ export function basePiToolCandidates(
283
385
  content: [{
284
386
  type: "text",
285
387
  text: `The user answered:\n${answers.map((answer, index) => {
388
+ if ("attachments" in answer) {
389
+ const responseAnswer = responseAnswers[index]!;
390
+ if (!("attachments" in responseAnswer)) return "";
391
+ return `${index + 1}. ${answer.question}\n${responseAnswer.attachments
392
+ .map((file) => ` - ${modelAttachmentText(file)}`)
393
+ .join("\n")}`;
394
+ }
286
395
  const parts = [
287
396
  ...answer.selections,
288
397
  ...(answer.text ? [answer.text] : []),
package/src/runtime.ts CHANGED
@@ -405,6 +405,13 @@ interface PendingTemporaryAgentApproval {
405
405
  resolve(decision: TemporaryAgentApprovalDecision): void;
406
406
  }
407
407
 
408
+ export interface ToolInteractionResponsePreparation {
409
+ readonly toolCallId: string;
410
+ readonly toolName: string;
411
+ readonly toolInput: unknown;
412
+ readonly response: unknown;
413
+ }
414
+
408
415
  // #endregion
409
416
 
410
417
  /**
@@ -432,6 +439,13 @@ export abstract class AgentRuntimeKernel<
432
439
 
433
440
  protected abstract ensureRuntimeReady(): Promise<void>;
434
441
 
442
+ /** Let the Host prepare a validated client response before Tool settlement. */
443
+ protected prepareToolInteractionResponse(
444
+ input: ToolInteractionResponsePreparation,
445
+ ): Promise<unknown | null> {
446
+ return Promise.resolve(input.response);
447
+ }
448
+
435
449
  private activeRuntime?: ActiveRuntime;
436
450
  private telemetryCoordinator?: RuntimeTelemetryCoordinator;
437
451
  private piAdapter?: PiRuntimeAdapter;
@@ -4283,10 +4297,19 @@ export abstract class AgentRuntimeKernel<
4283
4297
 
4284
4298
  const input = safeParseJson(pending.inputJson);
4285
4299
  if (!spec.validateResponse(input, response)) return { ok: false };
4300
+ const prepared = await this.prepareToolInteractionResponse({
4301
+ toolCallId,
4302
+ toolName: pending.toolName,
4303
+ toolInput: input,
4304
+ response,
4305
+ });
4306
+ if (prepared === null || !spec.validateResponse(input, prepared)) {
4307
+ return { ok: false };
4308
+ }
4286
4309
  const settled = spec.settle
4287
- ? spec.settle(input, response)
4288
- : defaultInteractionResult(response);
4289
- return this.interactions.respond(pending.interactionId, response, settled);
4310
+ ? spec.settle(input, prepared)
4311
+ : defaultInteractionResult(prepared);
4312
+ return this.interactions.respond(pending.interactionId, prepared, settled);
4290
4313
  }
4291
4314
 
4292
4315
  /**