@springbrand/agent-runtime 0.2.0-alpha.18 → 0.2.0-alpha.20

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.
@@ -54,7 +54,8 @@ import {
54
54
  memoryPiToolCandidate,
55
55
  } from "./pi/tool/base";
56
56
  import {
57
- browserQuickActionPiToolCandidates,
57
+ BROWSER_EXECUTE_TOOL_NAME,
58
+ browserExecutionPiToolCandidate,
58
59
  codeExecutionPiToolCandidate,
59
60
  } from "./pi/tool/core";
60
61
  import { skillPiToolCandidates } from "./pi/tool/skill";
@@ -65,6 +66,7 @@ import {
65
66
  normalizeAgentTelemetryBinding,
66
67
  type AgentTelemetryBinding,
67
68
  } from "./telemetry/contract";
69
+ import { BUILT_IN_RUNTIME_SKILLS } from "./skills";
68
70
 
69
71
  /** Runtime Assembler:校验一份扁平输入并生成可原子提交的 Snapshot。 */
70
72
 
@@ -192,15 +194,23 @@ async function createToolSurface(
192
194
  const visible = (candidate: PiToolCandidate) =>
193
195
  !deny.has(candidate.tool.name) &&
194
196
  allowsTool?.(candidate.tool.name) !== false;
195
- const browserCandidates = input.platform.browser
196
- ? browserQuickActionPiToolCandidates(input.platform.browser)
197
+ // 平台没有浏览器能力时注册空集:宁可没有这个 Tool,也不注册一个必然失败的 Tool 误导模型。
198
+ // `create()` 推迟到确认这个名字真的可见之后,被 deny 的装配不白建一个浏览器连接器。
199
+ // `direct` 使它只走 Direct 调用,不再被并进 `execute` 的工具集——两个 Code Mode 类工具互相嵌套
200
+ // 会让「哪次执行被记录、被重放」变得无法解释,而它们的分工本就由 System Prompt 划清。
201
+ const browserVisible = !deny.has(BROWSER_EXECUTE_TOOL_NAME) &&
202
+ allowsTool?.(BROWSER_EXECUTE_TOOL_NAME) !== false;
203
+ const browserCandidates = input.platform.browser && browserVisible
204
+ ? [{
205
+ ...browserExecutionPiToolCandidate(input.platform.browser.create()),
206
+ direct: true as const,
207
+ }]
197
208
  : [];
198
209
  const baseCandidates = basePiToolCandidates(input.webSearch);
199
210
  const memoryCandidates = input.memory
200
211
  ? [memoryPiToolCandidate(input.memory.port, input.memory.profile)]
201
212
  : [];
202
213
  const scriptCandidates = [
203
- ...browserCandidates,
204
214
  ...input.hostTools,
205
215
  ...baseCandidates,
206
216
  ...memoryCandidates,
@@ -238,6 +248,9 @@ async function createToolSurface(
238
248
  }
239
249
 
240
250
  const finalized = [...tools.values()];
251
+ const directVisible = finalized.filter(
252
+ (candidate) => !candidate.codeExecutionOnly,
253
+ );
241
254
  if (tools.has("execute")) {
242
255
  throw new Error("SpringBrand reserved Runtime Tool name: execute");
243
256
  }
@@ -246,7 +259,7 @@ async function createToolSurface(
246
259
  deny.has("execute") ||
247
260
  allowsTool?.("execute") === false
248
261
  ) {
249
- return Object.freeze(finalized);
262
+ return Object.freeze(directVisible);
250
263
  }
251
264
  const mergeable = finalized.filter(
252
265
  (candidate) =>
@@ -264,14 +277,22 @@ async function createToolSurface(
264
277
  ),
265
278
  codeExecutionTools,
266
279
  }),
267
- ...finalized,
280
+ ...directVisible,
268
281
  ]);
269
282
  },
270
283
  }),
271
284
  extensions: input.extensions.filter(
272
285
  (extension) => allowsExtension?.(extension) !== false,
273
286
  ),
274
- degradations: [],
287
+ // 浏览器是可选能力:平台没给就只记诊断,不让整个装配失败。
288
+ // 被 policy deny 不算降级——那是有人主动关的,不是平台缺件。
289
+ degradations: input.platform.browser
290
+ ? []
291
+ : [{
292
+ capability: "browser" as const,
293
+ reason: "unavailable" as const,
294
+ detail: "browser_binding_missing",
295
+ }],
275
296
  };
276
297
  }
277
298
 
@@ -827,7 +848,10 @@ export async function assembleRuntimeSnapshot<
827
848
  ? { subagents: toolAssembly.bindings.subagents }
828
849
  : {}),
829
850
  hostTools,
830
- skills: resources.skills,
851
+ skills:
852
+ input.ctx.role === "primary" && toolAssembly.bindings?.workspace
853
+ ? [...BUILT_IN_RUNTIME_SKILLS, ...resources.skills]
854
+ : resources.skills,
831
855
  connectors: resources.connectors.servers,
832
856
  ...(resources.connectors.gateway
833
857
  ? { gateway: resources.connectors.gateway }
package/src/runtime.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  SubmissionLifecycle,
23
23
  MAX_PENDING_SUBMISSIONS,
24
24
  SubmissionQueueFullError,
25
+ type SubmissionExecutionMode,
25
26
  type SubmissionInput,
26
27
  type SubmissionStore,
27
28
  type SubmissionHandle,
@@ -128,6 +129,10 @@ import {
128
129
 
129
130
  const SCHEDULED_STABLE_TIMEOUT_MS = 30_000;
130
131
  const TURN_EVENT_RETRY_SECONDS = 10;
132
+
133
+ // 计划续跑丢失多久后由看门狗接手。必须显著长于一个正常执行片的时长,
134
+ // 否则会在健康的长执行片上误判并派发出重复的一片。
135
+ const CONTINUATION_WATCHDOG_SECONDS = 300;
131
136
  // 一次 Host admission 调用最多被视为「在途」多久。停在 pending 的准入行永久保留
132
137
  // —— 它固定了同一 identity 重投时必须复用的 runId,不能靠删行来让容量回收 ——
133
138
  // 但一次远端失败或崩溃留下的行超过这个窗口后就不再占用 pending 容量,否则
@@ -169,6 +174,10 @@ interface StoredSubmission extends SubmissionReceipt {
169
174
  accountId: string | null;
170
175
  rateVersion: number | null;
171
176
  slotIdentity: string | null;
177
+ /** 跨执行片累计的模型回合数,全局回合预算据此判断。 */
178
+ modelTurns: number;
179
+ /** 当前让步执行片的标识;没有未完成让步时为 null。 */
180
+ continuationId: string | null;
172
181
  }
173
182
 
174
183
  interface SubmitMessageOptions {
@@ -196,6 +205,35 @@ interface PlannedContinuationData {
196
205
  readonly continuationId: string;
197
206
  }
198
207
 
208
+ /**
209
+ * 说明一次恢复准备的结局。
210
+ *
211
+ * @remarks
212
+ * `prepareRecoveredTurn` 返回它,`executeNonTerminalSubmission` 据此决定重起 Turn、
213
+ * 保持停靠还是落终态。
214
+ *
215
+ * `parked` 与 `unresumable` 必须分开:前者有人(审批、客户端交互)或有已排的调度会来解开它,
216
+ * 后者没有任何东西会再唤醒 —— 把两者混成一个「不继续」,非终态记录就会永久停在 running。
217
+ */
218
+ type RecoveredTurnPreparation =
219
+ /** Transcript 完好,可以重起 Pi Turn。 */
220
+ | { readonly kind: "resume" }
221
+ /** 正在等人或等已排的续跑;保持非终态是正确的。 */
222
+ | { readonly kind: "parked" }
223
+ /** 准备过程自己已经写好了终态。 */
224
+ | { readonly kind: "settled" }
225
+ /** 续不下去,调用方必须落一条用户看得见的失败终态。 */
226
+ | { readonly kind: "unresumable"; readonly reason: string };
227
+
228
+ // 作用:为一个没人会来解开的 `wait` 生成用户能看懂的终态原因。
229
+ // 调用:`prepareRecoveredTurn` 判定 wait 不是 approval/interaction 时调用。
230
+ // 原因:终态文本会直接呈现给用户,必须说明发生了什么以及能不能重试。
231
+ function unresumableWaitMessage(reason: string | undefined): string {
232
+ return reason === "uncertain-tool"
233
+ ? "SpringBrand stopped this turn: a non-idempotent Tool was interrupted and its result cannot be confirmed, so it was not retried. Re-send the request if you want to try again."
234
+ : "SpringBrand could not resume this turn after an interruption.";
235
+ }
236
+
199
237
  // 作用:把任意异常整理成可以持久化或发给客户端的文字。
200
238
  // 调用:Turn 执行、恢复和业务投影捕获 `unknown` 异常时调用。
201
239
  // 原因:错误边界不能假定抛出值一定是 `Error`。
@@ -494,8 +532,8 @@ export abstract class AgentRuntimeKernel<
494
532
  this.submissions = new SubmissionLifecycle({
495
533
  store: submissionStore,
496
534
  clearTerminal: () => clearChatTerminal(this.ctx.storage),
497
- execute: (submissionId, recovery) =>
498
- this.executeSubmission(submissionId, recovery),
535
+ execute: (submissionId, mode) =>
536
+ this.executeSubmission(submissionId, mode),
499
537
  appendAbortIntent: (submission, reason) =>
500
538
  this.appendTerminalIntent(submission, "aborted", reason),
501
539
  commitTerminal: (submission, outcome, message) =>
@@ -853,6 +891,7 @@ export abstract class AgentRuntimeKernel<
853
891
  startedAt,
854
892
  continuation: output.continuation ?? false,
855
893
  assistantOrdinal: output.assistantOrdinal ?? 0,
894
+ modelTurnsConsumed: submission.modelTurns,
856
895
  },
857
896
  canonicalMessages: () => this.transcript.canonicalMessages(),
858
897
  durability: {
@@ -1037,11 +1076,7 @@ export abstract class AgentRuntimeKernel<
1037
1076
  const running = this.db.submissions.findRunning() as StoredSubmission | null;
1038
1077
  if (running) {
1039
1078
  const effect = this.decidePiRecovery(running).effect;
1040
- if (
1041
- effect.kind === "wait" &&
1042
- (effect.reason === "approval" ||
1043
- effect.reason === "uncertain-tool")
1044
- ) {
1079
+ if (effect.kind === "wait" && effect.reason === "approval") {
1045
1080
  return;
1046
1081
  }
1047
1082
  }
@@ -2422,21 +2457,26 @@ export abstract class AgentRuntimeKernel<
2422
2457
  // 作用:按持久事实把一个中断 Turn 推进到可继续或已收尾状态。
2423
2458
  // 调用:`executeNonTerminalSubmission` 以 recovery 模式重起 Pi Turn 前调用。
2424
2459
  // 原因:用有界循环执行状态机效果,既允许 Tool 重试产生新事实,也防止错误规则无限自旋。
2460
+ //
2461
+ // 返回值必须区分「在等人」和「续不下去」:以前两者都压成 false,调用方一律原样返回
2462
+ // 非终态记录,于是「续不下去」这一类会让 Submission 永久停在 running。
2425
2463
  private async prepareRecoveredTurn(
2426
2464
  submission: StoredSubmission,
2427
2465
  adapter: PreparedPiTurnAdapter,
2428
- ): Promise<boolean> {
2466
+ ): Promise<RecoveredTurnPreparation> {
2429
2467
  let decision = await this.materializeRecoveredToolResults(submission);
2430
2468
  for (let step = 0; step < 32; step += 1) {
2431
2469
  const latest = this.readSubmission(submission.submissionId);
2432
- if (!latest || isTerminalSubmissionStatus(latest.status)) return false;
2470
+ if (!latest || isTerminalSubmissionStatus(latest.status)) {
2471
+ return { kind: "settled" };
2472
+ }
2433
2473
  if (latest.abortReason) {
2434
2474
  await this.submissions.finish(
2435
2475
  latest,
2436
2476
  "aborted",
2437
2477
  latest.abortReason,
2438
2478
  );
2439
- return false;
2479
+ return { kind: "settled" };
2440
2480
  }
2441
2481
  this.db.transaction(() => {
2442
2482
  this.applyPiRecoveryMutations(
@@ -2446,7 +2486,19 @@ export abstract class AgentRuntimeKernel<
2446
2486
  });
2447
2487
  switch (decision.effect.kind) {
2448
2488
  case "wait":
2449
- return false;
2489
+ // 这里必须逐个 reason 判,不能按「不是 approval 就算续不下去」一刀切:
2490
+ // - approval / interaction:等人,用户迟早会点。
2491
+ // - complete:终态里程碑已提交,行状态同事务写入,上层的终态检查会收掉。
2492
+ // - undefined:续跑里程碑已暂存待派发,`dispatchPendingContinuations`
2493
+ // 会来接手 —— 判它失败等于杀掉一轮本来有人管的 Turn。
2494
+ // - uncertain-tool:**没有任何人会来解开**。非幂等工具结果不确定,恢复
2495
+ // 拒绝重放;停在这里就是永久挂死。
2496
+ return decision.effect.reason === "uncertain-tool"
2497
+ ? {
2498
+ kind: "unresumable",
2499
+ reason: unresumableWaitMessage(decision.effect.reason),
2500
+ }
2501
+ : { kind: "parked" };
2450
2502
  case "retry-tool": {
2451
2503
  try {
2452
2504
  if (
@@ -2456,7 +2508,11 @@ export abstract class AgentRuntimeKernel<
2456
2508
  input: decision.effect.input,
2457
2509
  })
2458
2510
  ) {
2459
- return false;
2511
+ return {
2512
+ kind: "unresumable",
2513
+ reason:
2514
+ `SpringBrand could not resume this turn: Tool "${decision.effect.toolName}" is no longer available.`,
2515
+ };
2460
2516
  }
2461
2517
  } catch {
2462
2518
  // The governed Tool persisted its bounded error ToolResult.
@@ -2471,17 +2527,25 @@ export abstract class AgentRuntimeKernel<
2471
2527
  submission,
2472
2528
  decision.effect.approvalExecutionId,
2473
2529
  );
2474
- return false;
2530
+ return { kind: "parked" };
2475
2531
  case "finish":
2476
2532
  await this.submissions.finish(
2477
2533
  submission,
2478
2534
  decision.effect.outcome,
2479
2535
  decision.effect.message,
2480
2536
  );
2481
- return false;
2537
+ return { kind: "settled" };
2482
2538
  case "resume-turn": {
2483
2539
  const last = (await this.transcript.canonicalMessages()).at(-1);
2484
- return last?.role === "user" || last?.role === "toolResult";
2540
+ // Pi agentLoopContinue 拒绝从 assistant 消息继续。悬空的尾部 assistant
2541
+ // 意味着这条 transcript 续不下去了 —— 判断出来就必须收尾,不能默默退场。
2542
+ return last?.role === "user" || last?.role === "toolResult"
2543
+ ? { kind: "resume" }
2544
+ : {
2545
+ kind: "unresumable",
2546
+ reason:
2547
+ "SpringBrand could not resume this turn: the transcript ends on an assistant message.",
2548
+ };
2485
2549
  }
2486
2550
  }
2487
2551
  }
@@ -2611,7 +2675,7 @@ export abstract class AgentRuntimeKernel<
2611
2675
  // 原因:把失败帧、可续传流和 pre-stream 清理放在最外层,避免内部分支遗留半开传输状态。
2612
2676
  private async executeSubmission(
2613
2677
  submissionId: string,
2614
- recovery = false,
2678
+ mode: SubmissionExecutionMode = "fresh",
2615
2679
  ): Promise<StoredSubmission> {
2616
2680
  const submission = this.readSubmission(submissionId);
2617
2681
  if (!submission) {
@@ -2627,7 +2691,9 @@ export abstract class AgentRuntimeKernel<
2627
2691
  submission.abortReason,
2628
2692
  );
2629
2693
  }
2630
- if (recovery) {
2694
+ if (mode === "recovery") {
2695
+ // 只有真中断才记恢复事实。计划让步也走这里的话,每个执行片都会伪造一条
2696
+ // `runtime_restart`,并且抹掉真实的 stall 原因 —— 真崩溃和主动让出在观测上就分不开了。
2631
2697
  this.telemetry.capture("turnRecovering", {
2632
2698
  submissionId,
2633
2699
  reason: submission.recoveryReason ?? "runtime_restart",
@@ -2635,16 +2701,16 @@ export abstract class AgentRuntimeKernel<
2635
2701
  });
2636
2702
  this.db.submissions.clearRecoveryReason(submissionId);
2637
2703
  await this.broadcastApprovals();
2638
- await this.ensureRuntimeReady();
2639
2704
  }
2705
+ if (mode !== "fresh") await this.ensureRuntimeReady();
2640
2706
  try {
2641
2707
  return await this.executeNonTerminalSubmission(
2642
2708
  submission,
2643
- recovery,
2709
+ mode,
2644
2710
  );
2645
2711
  } catch (error) {
2646
2712
  if (
2647
- recovery &&
2713
+ mode === "recovery" &&
2648
2714
  (error instanceof ChatStreamStalledError ||
2649
2715
  error instanceof RetryableModelError)
2650
2716
  ) {
@@ -2665,7 +2731,7 @@ export abstract class AgentRuntimeKernel<
2665
2731
  failed.status === "error"
2666
2732
  ? failed.error ?? "SpringBrand turn failed"
2667
2733
  : undefined,
2668
- recovery,
2734
+ mode !== "fresh",
2669
2735
  );
2670
2736
  return failed;
2671
2737
  } finally {
@@ -2683,8 +2749,11 @@ export abstract class AgentRuntimeKernel<
2683
2749
  // 原因:恢复补全、状态迁移、fiber、流式记录与终态投影必须保持固定顺序,否则唤醒后会重复执行或丢失回答。
2684
2750
  private async executeNonTerminalSubmission(
2685
2751
  submission: StoredSubmission,
2686
- recovery: boolean,
2752
+ mode: SubmissionExecutionMode,
2687
2753
  ): Promise<StoredSubmission> {
2754
+ // 续跑和恢复一样要给客户端打 continuation 标记(否则前端会另起一个空累加器,
2755
+ // 把已经渲染出来的半条回答整条换掉),但只有恢复需要重建持久事实。
2756
+ const continuation = mode !== "fresh";
2688
2757
  const submissionId = submission.submissionId;
2689
2758
  if (submission.abortReason) {
2690
2759
  return this.submissions.finish(
@@ -2695,11 +2764,16 @@ export abstract class AgentRuntimeKernel<
2695
2764
  }
2696
2765
  submission = await this.pinSubmissionAssembly(submission);
2697
2766
  await this.assertPinnedSubmission(submission);
2698
- if (submission.status === "pending" && !recovery) {
2767
+ if (submission.status === "pending" && mode === "fresh") {
2699
2768
  submission = await this.activateQueuedSubmission(submission);
2700
2769
  }
2701
2770
  const recoveryAdapter = this.createSubmissionExecutionAdapter(submission);
2702
- if (recovery) {
2771
+ let awaitingHumanInput = false;
2772
+ // 让步续跑跳过整套里程碑重放:这一片是主动让出的,transcript 完好、没有半路的
2773
+ // Tool 要重建。走恢复通道不只是白跑,重放成本还随让步次数增长 —— 第 N 片要
2774
+ // 重放前 N-1 片积累的全部里程碑。但调度回调可能跨过等人的停靠期,所以下面仍需检查
2775
+ // 恢复决策,不能把尾部 assistant Tool Use 直接交给 Pi 续跑。
2776
+ if (mode === "recovery") {
2703
2777
  const deactivatePreparation = this.submissions.activate(submission, {
2704
2778
  submissionId,
2705
2779
  requestId: submission.requestId,
@@ -2708,27 +2782,44 @@ export abstract class AgentRuntimeKernel<
2708
2782
  continuation: true,
2709
2783
  agent: recoveryAdapter,
2710
2784
  });
2711
- let ready: boolean;
2785
+ let preparation: RecoveredTurnPreparation;
2712
2786
  try {
2713
- ready = await this.prepareRecoveredTurn(
2787
+ preparation = await this.prepareRecoveredTurn(
2714
2788
  submission,
2715
2789
  recoveryAdapter,
2716
2790
  );
2717
2791
  } finally {
2718
2792
  deactivatePreparation();
2719
2793
  }
2720
- if (!ready) {
2794
+ if (preparation.kind !== "resume") {
2721
2795
  const latest = this.readSubmission(submissionId)!;
2722
- return latest.abortReason
2723
- ? this.submissions.finish(
2724
- latest,
2725
- "aborted",
2726
- latest.abortReason,
2727
- )
2728
- : latest;
2796
+ if (latest.abortReason) {
2797
+ return this.submissions.finish(
2798
+ latest,
2799
+ "aborted",
2800
+ latest.abortReason,
2801
+ );
2802
+ }
2803
+ if (isTerminalSubmissionStatus(latest.status)) return latest;
2804
+ // 非终态还想退场,只有「在等人」这一种是合法的。其余一律收成失败终态:
2805
+ // 停在 running 会让 `pump()` 的 `findRunning()` 永远有值,这个 Session
2806
+ // 之后所有消息都排不进去,而且没有任何东西会再唤醒它。
2807
+ if (preparation.kind === "parked") return latest;
2808
+ // `settled` 走到这里说明它没真的写成终态 —— 契约破了也不能放它挂着。
2809
+ return this.submissions.finish(
2810
+ latest,
2811
+ "failed",
2812
+ preparation.kind === "unresumable"
2813
+ ? preparation.reason
2814
+ : unresumableWaitMessage(undefined),
2815
+ );
2729
2816
  }
2730
2817
  } else {
2731
- await this.materializeRecoveredToolResults(submission);
2818
+ const decision = await this.materializeRecoveredToolResults(submission);
2819
+ awaitingHumanInput = mode === "continuation" &&
2820
+ decision.effect.kind === "wait" &&
2821
+ (decision.effect.reason === "approval" ||
2822
+ decision.effect.reason === "interaction");
2732
2823
  }
2733
2824
 
2734
2825
  const ready = this.readSubmission(submissionId);
@@ -2743,6 +2834,7 @@ export abstract class AgentRuntimeKernel<
2743
2834
  ready.abortReason,
2744
2835
  );
2745
2836
  }
2837
+ if (awaitingHumanInput) return ready;
2746
2838
  if (ready.status === "pending") {
2747
2839
  if (this.db.submissions.transition(submissionId, "running", ["pending"])) {
2748
2840
  this.telemetry.capture("turnStarted", {
@@ -2767,7 +2859,7 @@ export abstract class AgentRuntimeKernel<
2767
2859
  let turn!: ActiveTurn;
2768
2860
  const adapter = this.createSubmissionExecutionAdapter(submission, {
2769
2861
  startedAt,
2770
- continuation: recovery,
2862
+ continuation,
2771
2863
  assistantOrdinal,
2772
2864
  onRecord: (record) =>
2773
2865
  this.migratedSubmissions.has(submissionId)
@@ -2796,7 +2888,7 @@ export abstract class AgentRuntimeKernel<
2796
2888
  requestId: submission.requestId,
2797
2889
  messageId: submission.assistantMessageId,
2798
2890
  startedAt,
2799
- continuation: recovery,
2891
+ continuation,
2800
2892
  agent: adapter,
2801
2893
  };
2802
2894
  let streamId: string | undefined;
@@ -2809,7 +2901,7 @@ export abstract class AgentRuntimeKernel<
2809
2901
  {
2810
2902
  requestId: submission.requestId,
2811
2903
  messageId: submission.assistantMessageId,
2812
- continuation: recovery,
2904
+ continuation,
2813
2905
  messages: await this.transcript.snapshotMessages(),
2814
2906
  recoveryData: {
2815
2907
  submissionId,
@@ -2844,6 +2936,19 @@ export abstract class AgentRuntimeKernel<
2844
2936
  if (!streamId) {
2845
2937
  throw new Error("Yielded SpringBrand Turn has no recoverable stream");
2846
2938
  }
2939
+ // 回合数和续跑标识必须先落库、再排调度:调度一旦到期就会读这两个值,
2940
+ // 反过来写会让续跑看到上一片的状态。
2941
+ this.db.transaction(() =>
2942
+ this.db.submissions.recordYieldedSlice(
2943
+ submissionId,
2944
+ runResult!.modelTurns,
2945
+ streamId!,
2946
+ )
2947
+ );
2948
+ // 让步必须密封本切片的流,但不能发 done —— 这一轮还没有权威结果。
2949
+ // 不密封的话下一个切片的 start() 会直接顶掉 activeStreamId,旧行永远
2950
+ // 停在 streaming,元数据和 chunk 要等 abandoned 保留期才回收。
2951
+ this.sealRecoverableStreamSlice(streamId);
2847
2952
  await this.schedule(
2848
2953
  0,
2849
2954
  "_piPlannedContinuation",
@@ -2854,8 +2959,21 @@ export abstract class AgentRuntimeKernel<
2854
2959
  },
2855
2960
  { idempotent: true },
2856
2961
  );
2962
+ await this.scheduleContinuationWatchdog(
2963
+ submissionId,
2964
+ submission.requestId,
2965
+ streamId,
2966
+ );
2857
2967
  return latest;
2858
2968
  }
2969
+ if (runResult) {
2970
+ this.db.transaction(() =>
2971
+ this.db.submissions.recordModelTurns(
2972
+ submissionId,
2973
+ runResult!.modelTurns,
2974
+ )
2975
+ );
2976
+ }
2859
2977
  const intent = terminalIntent ?? {
2860
2978
  outcome: "failed" as const,
2861
2979
  message:
@@ -2929,7 +3047,11 @@ export abstract class AgentRuntimeKernel<
2929
3047
  recoveryOutcome !== "disabled" &&
2930
3048
  !this.readSubmission(submissionId)?.abortReason
2931
3049
  ) {
2932
- if (recovery && recoveryOutcome === "scheduled") throw error;
3050
+ // 只有恢复通道的调用方(`_chatRecoveryRetry`)靠这次重抛判定 "scheduled"
3051
+ // 让步续跑由 alarm 驱动,重抛只会把那次 alarm 变成一次失败。
3052
+ if (mode === "recovery" && recoveryOutcome === "scheduled") {
3053
+ throw error;
3054
+ }
2933
3055
  return this.readSubmission(submissionId)!;
2934
3056
  }
2935
3057
  } else if (!stoppedDuringRecovery) {
@@ -4024,19 +4146,104 @@ export abstract class AgentRuntimeKernel<
4024
4146
  }
4025
4147
  }
4026
4148
 
4027
- async _piPlannedContinuation(
4028
- data?: PlannedContinuationData,
4149
+ // 作用:为一个让步执行片排一次兜底唤醒。
4150
+ // 调用:`executeNonTerminalSubmission` 排出计划续跑之后立即调用。
4151
+ // 原因:计划续跑只有一次机会 —— schedule 抛错、alarm 回调抛错、实例中途被换掉,
4152
+ // 这一轮就再也没人唤醒了。看门狗是唯一能兜住未知丢失路径的东西;
4153
+ // 它按续跑标识判断有没有推进,所以正常情况下醒来即空转返回。
4154
+ private async scheduleContinuationWatchdog(
4155
+ submissionId: string,
4156
+ requestId: string,
4157
+ continuationId: string,
4029
4158
  ): Promise<void> {
4030
- if (!data?.submissionId || !data.requestId) return;
4159
+ try {
4160
+ await this.schedule(
4161
+ CONTINUATION_WATCHDOG_SECONDS,
4162
+ "_piContinuationWatchdog",
4163
+ { submissionId, requestId, continuationId },
4164
+ { idempotent: true },
4165
+ );
4166
+ } catch (error) {
4167
+ // 看门狗排不上不该反过来杀掉这一轮:计划续跑本身已经排好了。
4168
+ console.warn(
4169
+ "[pi-continuation-watchdog:degraded]",
4170
+ json({ submissionId, error: errorText(error) }),
4171
+ );
4172
+ }
4173
+ }
4174
+
4175
+ // 判断一条让步提交是否仍停在 `continuationId` 这一片上。
4176
+ // 计划续跑和看门狗都用它决定该不该接手,避免和更新的执行片重复派发。
4177
+ //
4178
+ // `allowUnrecorded` 给看门狗用:让步的落库万一没生效(记录已不在
4179
+ // pending/running),`continuationId` 会是 null,此时严格比对会把续跑和看门狗
4180
+ // 一起丢掉 —— 那正是这次要根除的静默挂死。计划续跑不放宽,它的职责就是去重。
4181
+ private stalledOnContinuation(
4182
+ data: PlannedContinuationData,
4183
+ { allowUnrecorded = false }: { allowUnrecorded?: boolean } = {},
4184
+ ): StoredSubmission | null {
4031
4185
  const submission = this.readSubmission(data.submissionId);
4032
4186
  if (
4033
4187
  !submission ||
4034
4188
  submission.requestId !== data.requestId ||
4035
4189
  isTerminalSubmissionStatus(submission.status)
4036
4190
  ) {
4037
- return;
4191
+ return null;
4192
+ }
4193
+ const onThisSlice = submission.continuationId === data.continuationId ||
4194
+ (allowUnrecorded && submission.continuationId === null);
4195
+ return onThisSlice ? submission : null;
4196
+ }
4197
+
4198
+ async _piPlannedContinuation(
4199
+ data?: PlannedContinuationData,
4200
+ ): Promise<void> {
4201
+ if (!data?.submissionId || !data.requestId || !data.continuationId) return;
4202
+ // 校验续跑标识:两条 alarm 同时到期时,晚到的那条对应的已经是上一片,
4203
+ // 放它进去会白跑一整个执行片。
4204
+ const submission = this.stalledOnContinuation(data);
4205
+ if (!submission) return;
4206
+ await this.submissions.continueAfterCurrent(submission.submissionId);
4207
+ }
4208
+
4209
+ /**
4210
+ * 在计划续跑丢失时接手一条让步提交。
4211
+ *
4212
+ * @remarks
4213
+ * Agents SDK 的调度器在 `scheduleContinuationWatchdog` 排的延时任务到期时按方法名调用。
4214
+ *
4215
+ * 正常情况下这一片早就推进了(`continuationId` 已换或已终态),此时直接返回。
4216
+ *
4217
+ * 走 `recover` 而不是 `continueAfterCurrent`,有两个都不能省的理由:
4218
+ * 一是续跑会丢通常就是实例中途没了,那属于崩溃,必须重放里程碑、补齐半路的
4219
+ * Tool,续跑模式恰恰跳过这些;二是这一轮可能正停在审批或客户端交互上等人
4220
+ * (被驱逐后内存里没有执行器,看起来就像「没人在跑」),只有恢复准备认得出
4221
+ * 这是合法停靠并原样放过 —— 续跑模式会直接在它上面重起一轮。
4222
+ *
4223
+ * `recover` 复用 `executions` 的实例内去重,因此和一片刚进序幕、还没登记执行器的
4224
+ * 竞态也一并合流,不会多跑一片。
4225
+ */
4226
+ async _piContinuationWatchdog(
4227
+ data?: PlannedContinuationData,
4228
+ ): Promise<void> {
4229
+ if (!data?.submissionId || !data.requestId || !data.continuationId) return;
4230
+ const submission = this.stalledOnContinuation(data, {
4231
+ allowUnrecorded: true,
4232
+ });
4233
+ if (!submission) return;
4234
+ try {
4235
+ await this.ensureRuntimeReady();
4236
+ await this.submissions.recover(submission.submissionId);
4237
+ } catch (error) {
4238
+ const latest = this.readSubmission(data.submissionId);
4239
+ if (latest && !isTerminalSubmissionStatus(latest.status)) {
4240
+ await this.submissions.finish(
4241
+ latest,
4242
+ "failed",
4243
+ `SpringBrand could not continue this turn: ${errorText(error)}`,
4244
+ );
4245
+ }
4038
4246
  }
4039
- await this.submissions.recoverAfterCurrent(submission.submissionId);
4040
4247
  }
4041
4248
 
4042
4249
  // 作用:Agent Tool 子运行开始后重算一次活动投影。
@@ -0,0 +1,14 @@
1
+ import { fromManifest } from "agents/skills";
2
+ import { SPRINGBRAND_WORKER_WEBSITE_SKILL } from "./springbrand-worker-website";
3
+
4
+ const source = fromManifest({
5
+ id: "springbrand-runtime-built-ins",
6
+ fingerprint: JSON.stringify(SPRINGBRAND_WORKER_WEBSITE_SKILL),
7
+ skills: [SPRINGBRAND_WORKER_WEBSITE_SKILL],
8
+ });
9
+
10
+ export const BUILT_IN_RUNTIME_SKILLS = Object.freeze([Object.freeze({
11
+ name: SPRINGBRAND_WORKER_WEBSITE_SKILL.name,
12
+ description: SPRINGBRAND_WORKER_WEBSITE_SKILL.description,
13
+ source,
14
+ })]);