@springbrand/agent-runtime 0.2.0-alpha.56 → 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.56",
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,
@@ -45,6 +45,7 @@ import {
45
45
  } from "./assembly";
46
46
  import {
47
47
  isRecoverableAssistantError,
48
+ isOpenRouterServerToolTimeout,
48
49
  isModelStreamStallMessage,
49
50
  withProviderRetry,
50
51
  type PiGenerationLifecycleObserver,
@@ -490,7 +491,7 @@ export interface PiTurnDurability {
490
491
  export interface CreatePreparedPiTurnOptions {
491
492
  readonly prepared: PreparedPiRuntime;
492
493
  readonly pinnedDescriptor: string;
493
- /** 同一 Session 的所有 Submission 共用的稳定 Provider 路由身份。 */
494
+ /** Session 当前的稳定 Provider 路由身份;终态 Provider 故障可在下一 Submission 前旋转它。 */
494
495
  readonly modelSessionId: string;
495
496
  readonly submission: {
496
497
  readonly id: string;
@@ -609,6 +610,7 @@ export type PiCanonicalMessageCommit =
609
610
  export interface PiTurnTerminalIntent {
610
611
  readonly outcome: "succeeded" | "failed" | "aborted";
611
612
  readonly message?: string;
613
+ readonly resetModelSession?: boolean;
612
614
  }
613
615
 
614
616
  export interface PiPreparedTurnRunOptions {
@@ -1134,6 +1136,9 @@ export class PreparedPiTurnAdapter {
1134
1136
  );
1135
1137
  this.terminalIntent = {
1136
1138
  outcome: terminal.outcome,
1139
+ ...(isOpenRouterServerToolTimeout(authoritativeMessage)
1140
+ ? { resetModelSession: true }
1141
+ : {}),
1137
1142
  ...(authoritativeMessage.errorMessage
1138
1143
  ? { message: authoritativeMessage.errorMessage }
1139
1144
  : terminal.message
@@ -182,6 +182,32 @@ export function readModelStreamStallDetails(
182
182
  }
183
183
  }
184
184
 
185
+ export function isOpenRouterServerToolTimeout(
186
+ message: AssistantMessage,
187
+ ): boolean {
188
+ if (
189
+ message.stopReason !== "error" ||
190
+ !message.provider.startsWith("model-api-openrouter-messages-") ||
191
+ !message.errorMessage
192
+ ) return false;
193
+ try {
194
+ const payload = JSON.parse(message.errorMessage) as {
195
+ type?: unknown;
196
+ error?: {
197
+ type?: unknown;
198
+ message?: unknown;
199
+ error_type?: unknown;
200
+ };
201
+ };
202
+ return payload.type === "error" &&
203
+ payload.error?.type === "timeout_error" &&
204
+ payload.error.message === "Server tool request failed" &&
205
+ payload.error.error_type === "timeout";
206
+ } catch {
207
+ return false;
208
+ }
209
+ }
210
+
185
211
  export function isRecoverableAssistantError(
186
212
  message: AssistantMessage,
187
213
  ): boolean {
@@ -189,6 +215,7 @@ export function isRecoverableAssistantError(
189
215
  message.stopReason === "error" &&
190
216
  /thought[_ ]signature/i.test(message.errorMessage ?? "")
191
217
  ) return false;
218
+ if (isOpenRouterServerToolTimeout(message)) return false;
192
219
  return isRetryableAssistantError(message) ||
193
220
  (message.stopReason === "error" &&
194
221
  /upstream http\/2 stream failed/i.test(message.errorMessage ?? ""));
@@ -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
@@ -134,6 +134,7 @@ import {
134
134
  const SCHEDULED_STABLE_TIMEOUT_MS = 30_000;
135
135
  const TURN_EVENT_RETRY_SECONDS = 10;
136
136
  const RUNTIME_EVENT_MAX_ATTEMPTS = 5;
137
+ const MODEL_SESSION_ID_STORAGE_KEY = "universal-agent:model-session-id";
137
138
 
138
139
  // 计划续跑丢失多久后由看门狗接手。必须显著长于一个正常执行片的时长,
139
140
  // 否则会在健康的长执行片上误判并派发出重复的一片。
@@ -404,6 +405,13 @@ interface PendingTemporaryAgentApproval {
404
405
  resolve(decision: TemporaryAgentApprovalDecision): void;
405
406
  }
406
407
 
408
+ export interface ToolInteractionResponsePreparation {
409
+ readonly toolCallId: string;
410
+ readonly toolName: string;
411
+ readonly toolInput: unknown;
412
+ readonly response: unknown;
413
+ }
414
+
407
415
  // #endregion
408
416
 
409
417
  /**
@@ -431,6 +439,13 @@ export abstract class AgentRuntimeKernel<
431
439
 
432
440
  protected abstract ensureRuntimeReady(): Promise<void>;
433
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
+
434
449
  private activeRuntime?: ActiveRuntime;
435
450
  private telemetryCoordinator?: RuntimeTelemetryCoordinator;
436
451
  private piAdapter?: PiRuntimeAdapter;
@@ -442,6 +457,8 @@ export abstract class AgentRuntimeKernel<
442
457
  private readonly approvals: ApprovalLifecycle<StoredSubmission>;
443
458
  private readonly interactions: InteractionLifecycle<StoredSubmission>;
444
459
  private runtimeLoadTracker?: RuntimeLoadTracker;
460
+ private modelSessionId?: string;
461
+ private readonly modelSessionIdReady?: Promise<void>;
445
462
  private runtimeEventDrainTail?: Promise<void>;
446
463
  private readonly streamBySubmission = new Map<string, string>();
447
464
  private migratedSubmissionIds?: Set<string>;
@@ -521,6 +538,11 @@ export abstract class AgentRuntimeKernel<
521
538
  snapshotKey: "__cfPiFiberSnapshot",
522
539
  snapshotKind: "pi-turn",
523
540
  });
541
+ this.modelSessionIdReady = ctx.storage.get<string>(
542
+ MODEL_SESSION_ID_STORAGE_KEY,
543
+ ).then((stored) => {
544
+ this.modelSessionId = stored;
545
+ });
524
546
  this.piAdapter = pi;
525
547
  this.db = new RuntimeDatabase(
526
548
  this.sql.bind(this),
@@ -734,6 +756,7 @@ export abstract class AgentRuntimeKernel<
734
756
  }
735
757
 
736
758
  protected async initCandidate(candidate: RuntimeCandidate): Promise<void> {
759
+ await this.modelSessionIdReady;
737
760
  const previous = this.activeRuntime;
738
761
  const candidateSnapshot = candidate.snapshot;
739
762
  this.runtimeLoad.advance("assembly");
@@ -870,6 +893,7 @@ export abstract class AgentRuntimeKernel<
870
893
  terminal: {
871
894
  outcome: "succeeded" | "failed" | "aborted";
872
895
  message?: string;
896
+ resetModelSession?: boolean;
873
897
  },
874
898
  ) => void | Promise<void>;
875
899
  } = {},
@@ -909,7 +933,7 @@ export abstract class AgentRuntimeKernel<
909
933
  return this.pi.createTurn({
910
934
  prepared: this.preparedPi(),
911
935
  pinnedDescriptor: submission.assemblyDescriptor,
912
- modelSessionId: this.name,
936
+ modelSessionId: this.modelSessionId ?? this.name,
913
937
  submission: {
914
938
  id: submission.submissionId,
915
939
  requestId: submission.requestId,
@@ -3038,6 +3062,7 @@ export abstract class AgentRuntimeKernel<
3038
3062
  | {
3039
3063
  outcome: "succeeded" | "failed" | "aborted";
3040
3064
  message?: string;
3065
+ resetModelSession?: boolean;
3041
3066
  }
3042
3067
  | undefined;
3043
3068
  const startedAt = ready.createdAt;
@@ -3050,13 +3075,20 @@ export abstract class AgentRuntimeKernel<
3050
3075
  this.migratedSubmissions.has(submissionId)
3051
3076
  ? undefined
3052
3077
  : this.persistAndBroadcastRecord(turn, record),
3053
- onTerminal: (intent) => {
3078
+ onTerminal: async (intent) => {
3054
3079
  terminalIntent = intent;
3055
3080
  this.appendTerminalIntent(
3056
3081
  submission,
3057
3082
  intent.outcome,
3058
3083
  intent.message,
3059
3084
  );
3085
+ if (intent.resetModelSession) {
3086
+ this.modelSessionId = crypto.randomUUID();
3087
+ await this.ctx.storage.put(
3088
+ MODEL_SESSION_ID_STORAGE_KEY,
3089
+ this.modelSessionId,
3090
+ );
3091
+ }
3060
3092
  },
3061
3093
  });
3062
3094
  for (const pending of this.db.steers.listForSubmission(submissionId)) {
@@ -4265,10 +4297,19 @@ export abstract class AgentRuntimeKernel<
4265
4297
 
4266
4298
  const input = safeParseJson(pending.inputJson);
4267
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
+ }
4268
4309
  const settled = spec.settle
4269
- ? spec.settle(input, response)
4270
- : defaultInteractionResult(response);
4271
- 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);
4272
4313
  }
4273
4314
 
4274
4315
  /**