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

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.60",
3
+ "version": "0.2.0-alpha.62",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -11,6 +11,7 @@ export type SubmissionStatus =
11
11
  | "error";
12
12
  export type SubmissionRecoveryReason =
13
13
  | "no_meaningful_model_progress"
14
+ | "server_tool_timeout"
14
15
  | "transient_model_error"
15
16
  | "context_overflow";
16
17
 
@@ -62,7 +62,10 @@ export interface RuntimeTurnState {
62
62
  activeMessageId?: string;
63
63
  recoveryAttempt?: number;
64
64
  recoveryMax?: number;
65
- recoveryReason?: "no_meaningful_model_progress" | "transient_model_error";
65
+ recoveryReason?:
66
+ | "no_meaningful_model_progress"
67
+ | "server_tool_timeout"
68
+ | "transient_model_error";
66
69
  steerable: boolean;
67
70
  hasPendingSteer: boolean;
68
71
  queued: RuntimeQueuedSubmission[];
package/src/lib/prompt.ts CHANGED
@@ -111,8 +111,8 @@ export const INTERACTION =
111
111
  "Interaction: When progress requires user decisions, put all 1-10 necessary questions in one ask_user call " +
112
112
  "instead of asking each question separately or writing 'please choose A/B/C' as text. Every question must set exactly one responseType. " +
113
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
+ "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; " +
116
116
  "continue the same turn when its result arrives. " +
117
117
  "Don't use it when a sensible default lets you proceed. " +
118
118
  "After completing a substantive task (report, analysis, multi-step job), finish all work and other Tool calls, " +
@@ -83,6 +83,8 @@ const MAX_WRAP_UP_TURNS = 2;
83
83
 
84
84
  export class RetryableModelError extends Error {}
85
85
 
86
+ export class RetryableModelSessionError extends RetryableModelError {}
87
+
86
88
  export class ContextOverflowError extends RetryableModelError {}
87
89
 
88
90
  function normalizePlanToolCalls(message: AgentMessage): void {
@@ -610,7 +612,6 @@ export type PiCanonicalMessageCommit =
610
612
  export interface PiTurnTerminalIntent {
611
613
  readonly outcome: "succeeded" | "failed" | "aborted";
612
614
  readonly message?: string;
613
- readonly resetModelSession?: boolean;
614
615
  }
615
616
 
616
617
  export interface PiPreparedTurnRunOptions {
@@ -1058,7 +1059,8 @@ export class PreparedPiTurnAdapter {
1058
1059
  ) || isModelStreamStallMessage(
1059
1060
  event.assistantMessageEvent.error.errorMessage,
1060
1061
  ) ||
1061
- isRecoverableAssistantError(event.assistantMessageEvent.error))
1062
+ isRecoverableAssistantError(event.assistantMessageEvent.error) ||
1063
+ isOpenRouterServerToolTimeout(event.assistantMessageEvent.error))
1062
1064
  ) {
1063
1065
  return;
1064
1066
  }
@@ -1073,10 +1075,11 @@ export class PreparedPiTurnAdapter {
1073
1075
  }
1074
1076
  const stalled = message.stopReason === "error" &&
1075
1077
  isModelStreamStallMessage(message.errorMessage);
1078
+ const resetModelSession = isOpenRouterServerToolTimeout(message);
1076
1079
  const retryable = isRecoverableAssistantError(message);
1077
- if (stalled || retryable) {
1080
+ if (stalled || retryable || resetModelSession) {
1078
1081
  const content = visibleAssistantContent(message);
1079
- if (content.length > 0) {
1082
+ if (!resetModelSession && content.length > 0) {
1080
1083
  this.assistantOrdinal += 1;
1081
1084
  await this.options.onCanonicalMessage({
1082
1085
  kind: "commit-turn",
@@ -1090,6 +1093,9 @@ export class PreparedPiTurnAdapter {
1090
1093
  if (stalled) {
1091
1094
  throw new ChatStreamStalledError(message.errorMessage!);
1092
1095
  }
1096
+ if (resetModelSession) {
1097
+ throw new RetryableModelSessionError(message.errorMessage);
1098
+ }
1093
1099
  throw new RetryableModelError(message.errorMessage);
1094
1100
  }
1095
1101
  }
@@ -1136,9 +1142,6 @@ export class PreparedPiTurnAdapter {
1136
1142
  );
1137
1143
  this.terminalIntent = {
1138
1144
  outcome: terminal.outcome,
1139
- ...(isOpenRouterServerToolTimeout(authoritativeMessage)
1140
- ? { resetModelSession: true }
1141
- : {}),
1142
1145
  ...(authoritativeMessage.errorMessage
1143
1146
  ? { message: authoritativeMessage.errorMessage }
1144
1147
  : terminal.message
@@ -2,7 +2,11 @@ import {
2
2
  PreparedPiTurnAdapter,
3
3
  type CreatePreparedPiTurnOptions,
4
4
  } from "./execution";
5
- export { ContextOverflowError, RetryableModelError } from "./execution";
5
+ export {
6
+ ContextOverflowError,
7
+ RetryableModelError,
8
+ RetryableModelSessionError,
9
+ } from "./execution";
6
10
  import {
7
11
  uiUserMessageToPi,
8
12
  } from "../message";
@@ -215,6 +215,7 @@ export function isRecoverableAssistantError(
215
215
  message.stopReason === "error" &&
216
216
  /thought[_ ]signature/i.test(message.errorMessage ?? "")
217
217
  ) return false;
218
+ // Runtime resets the model Session and gives this timeout its own one-retry budget.
218
219
  if (isOpenRouterServerToolTimeout(message)) return false;
219
220
  return isRetryableAssistantError(message) ||
220
221
  (message.stopReason === "error" &&
@@ -44,6 +44,11 @@ const askUserQuestion = Type.Object({
44
44
  description:
45
45
  "For single_select or multi_select, whether the user may also add free text. Defaults to false.",
46
46
  })),
47
+ required: Type.Optional(Type.Boolean({
48
+ default: false,
49
+ description:
50
+ "Whether the user must answer this question to submit. Defaults to false.",
51
+ })),
47
52
  }, { additionalProperties: false });
48
53
 
49
54
  const askUserParameters = Type.Object({
@@ -98,6 +103,10 @@ function questionsOf(
98
103
  if (!question || typeof question.question !== "string" || !question.question) {
99
104
  return null;
100
105
  }
106
+ if (
107
+ question.required !== undefined && typeof question.required !== "boolean"
108
+ ) return null;
109
+ const required = question.required === true;
101
110
  let responseType = question.responseType;
102
111
  if (
103
112
  typeof responseType !== "string" ||
@@ -118,6 +127,11 @@ function questionsOf(
118
127
  return null;
119
128
  }
120
129
  }
130
+ const normalizedQuestion = {
131
+ question: question.question,
132
+ responseType: responseType as typeof askUserResponseTypes[number],
133
+ required,
134
+ };
121
135
  if (responseType === "attachment") {
122
136
  attachmentQuestions += 1;
123
137
  if (
@@ -126,7 +140,7 @@ function questionsOf(
126
140
  question.allowCustom !== undefined ||
127
141
  (!allowLegacy && question.multiSelect !== undefined)
128
142
  ) return null;
129
- normalized.push({ question: question.question, responseType });
143
+ normalized.push(normalizedQuestion);
130
144
  continue;
131
145
  }
132
146
  if (responseType === "text") {
@@ -137,7 +151,7 @@ function questionsOf(
137
151
  ) {
138
152
  return null;
139
153
  }
140
- normalized.push({ question: question.question, responseType });
154
+ normalized.push(normalizedQuestion);
141
155
  continue;
142
156
  }
143
157
  if (
@@ -156,8 +170,7 @@ function questionsOf(
156
170
  (question.kind !== undefined || question.multiSelect !== undefined)
157
171
  ) return null;
158
172
  normalized.push({
159
- question: question.question,
160
- responseType: responseType as typeof askUserResponseTypes[number],
173
+ ...normalizedQuestion,
161
174
  options: question.options,
162
175
  ...(question.allowCustom === undefined
163
176
  ? {}
@@ -223,6 +236,12 @@ function isAskUserResponse(input: unknown, value: unknown): boolean {
223
236
  );
224
237
  })) return false;
225
238
 
239
+ if (
240
+ answer.selections.length === 0 &&
241
+ !answer.text.trim() &&
242
+ answer.attachments.length === 0
243
+ ) return question.required !== true;
244
+
226
245
  if (question.responseType === "attachment") {
227
246
  return answer.selections.length === 0 && !answer.text &&
228
247
  answer.attachments.length >= 1 && answer.attachments.length <= 8;
@@ -367,7 +386,7 @@ export function basePiToolCandidates(
367
386
  name: "ask_user",
368
387
  label: "Ask user",
369
388
  description:
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.",
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.",
371
390
  parameters: askUserParameters,
372
391
  prepareArguments: prepareAskUserArguments,
373
392
  // 永远不会被调用:执行链在 interaction 闸就 park 住了。留一个失败关闭的
package/src/runtime.ts CHANGED
@@ -71,6 +71,7 @@ import {
71
71
  MODEL_STREAM_STALL_TIMEOUT_MS,
72
72
  readModelStreamStallDetails,
73
73
  RetryableModelError,
74
+ RetryableModelSessionError,
74
75
  type PiCanonicalUserInput,
75
76
  type PiCanonicalTranscriptSnapshot,
76
77
  type PiChatRecoveryData,
@@ -145,6 +146,7 @@ const CONTINUATION_WATCHDOG_SECONDS = 300;
145
146
  // Host 侧的一次抖动会把 Session 永久卡在「队列已满」。
146
147
  const ADMISSION_ATTEMPT_WINDOW_MS = 60_000;
147
148
  export const CHAT_RECOVERY_MAX_ATTEMPTS = 5;
149
+ const SERVER_TOOL_TIMEOUT_MAX_ATTEMPTS = 2;
148
150
  // stall 单独收窄。理由不是「stall 更不值得救」,而是它的重试**期望值和别的错不一样**:
149
151
  // 瞬时错(5xx / 断流)重跑一次往往就好了;stall 的重跑是拿同一份 transcript 让模型
150
152
  // 重新想同样久,如果它本来就超预算,再跑几次也一样超。把看门狗放宽到 240s 之后,
@@ -177,10 +179,7 @@ interface StoredSubmission extends SubmissionReceipt {
177
179
  userMessageId?: string | null;
178
180
  regenerateMessageId?: string | null;
179
181
  recoveryErrorCount: number;
180
- recoveryReason?:
181
- | "no_meaningful_model_progress"
182
- | "transient_model_error"
183
- | "context_overflow";
182
+ recoveryReason?: SubmissionRecoveryReason;
184
183
  runId: string | null;
185
184
  accountId: string | null;
186
185
  rateVersion: number | null;
@@ -893,7 +892,6 @@ export abstract class AgentRuntimeKernel<
893
892
  terminal: {
894
893
  outcome: "succeeded" | "failed" | "aborted";
895
894
  message?: string;
896
- resetModelSession?: boolean;
897
895
  },
898
896
  ) => void | Promise<void>;
899
897
  } = {},
@@ -3062,7 +3060,6 @@ export abstract class AgentRuntimeKernel<
3062
3060
  | {
3063
3061
  outcome: "succeeded" | "failed" | "aborted";
3064
3062
  message?: string;
3065
- resetModelSession?: boolean;
3066
3063
  }
3067
3064
  | undefined;
3068
3065
  const startedAt = ready.createdAt;
@@ -3082,13 +3079,6 @@ export abstract class AgentRuntimeKernel<
3082
3079
  intent.outcome,
3083
3080
  intent.message,
3084
3081
  );
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
- }
3092
3082
  },
3093
3083
  });
3094
3084
  for (const pending of this.db.steers.listForSubmission(submissionId)) {
@@ -3212,13 +3202,23 @@ export abstract class AgentRuntimeKernel<
3212
3202
  }
3213
3203
  const stalled = error instanceof ChatStreamStalledError;
3214
3204
  const contextOverflow = error instanceof ContextOverflowError;
3205
+ const resetModelSession = error instanceof RetryableModelSessionError;
3215
3206
  const abortReason = this.readSubmission(submissionId)?.abortReason;
3216
3207
  if (!abortReason && (stalled || error instanceof RetryableModelError)) {
3208
+ if (resetModelSession) {
3209
+ this.modelSessionId = crypto.randomUUID();
3210
+ await this.ctx.storage.put(
3211
+ MODEL_SESSION_ID_STORAGE_KEY,
3212
+ this.modelSessionId,
3213
+ );
3214
+ }
3217
3215
  const recoveryReason: SubmissionRecoveryReason = stalled
3218
3216
  ? "no_meaningful_model_progress"
3219
- : contextOverflow
3220
- ? "context_overflow"
3221
- : "transient_model_error";
3217
+ : resetModelSession
3218
+ ? "server_tool_timeout"
3219
+ : contextOverflow
3220
+ ? "context_overflow"
3221
+ : "transient_model_error";
3222
3222
  let contextOverflowRecoveryClaimed = false;
3223
3223
  const recoveryErrorCount = this.db.transaction(() => {
3224
3224
  if (contextOverflow) {
@@ -3253,7 +3253,11 @@ export abstract class AgentRuntimeKernel<
3253
3253
  ? contextOverflowRecoveryClaimed &&
3254
3254
  recoveryErrorCount < CHAT_RECOVERY_MAX_ATTEMPTS
3255
3255
  : recoveryErrorCount <
3256
- (stalled ? CHAT_STALL_MAX_ATTEMPTS : CHAT_RECOVERY_MAX_ATTEMPTS);
3256
+ (stalled
3257
+ ? CHAT_STALL_MAX_ATTEMPTS
3258
+ : resetModelSession
3259
+ ? SERVER_TOOL_TIMEOUT_MAX_ATTEMPTS
3260
+ : CHAT_RECOVERY_MAX_ATTEMPTS);
3257
3261
  if (
3258
3262
  !stoppedDuringRecovery &&
3259
3263
  retryAllowed
@@ -4710,7 +4714,9 @@ export abstract class AgentRuntimeKernel<
4710
4714
  : current.recoveryReason ===
4711
4715
  "no_meaningful_model_progress"
4712
4716
  ? CHAT_STALL_MAX_ATTEMPTS
4713
- : CHAT_RECOVERY_MAX_ATTEMPTS,
4717
+ : current.recoveryReason === "server_tool_timeout"
4718
+ ? SERVER_TOOL_TIMEOUT_MAX_ATTEMPTS
4719
+ : CHAT_RECOVERY_MAX_ATTEMPTS,
4714
4720
  ...(current.recoveryReason &&
4715
4721
  current.recoveryReason !== "context_overflow"
4716
4722
  ? { recoveryReason: current.recoveryReason }