@springbrand/agent-runtime 0.1.3-alpha.1 → 0.1.3-alpha.3

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.1.3-alpha.1",
3
+ "version": "0.1.3-alpha.3",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
package/src/db/schema.ts CHANGED
@@ -21,7 +21,9 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
21
21
  assembly_revision TEXT NOT NULL,
22
22
  assembly_descriptor TEXT NOT NULL,
23
23
  assistant_message_id TEXT NOT NULL,
24
- abort_reason TEXT
24
+ abort_reason TEXT,
25
+ recovery_error_count INTEGER NOT NULL DEFAULT 0,
26
+ recovery_reason TEXT
25
27
  )`;
26
28
  const submissionColumns = new Set(
27
29
  sql<{ name: string }>`PRAGMA table_info(pi_submissions)`
@@ -39,6 +41,13 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
39
41
  if (!submissionColumns.has("regenerate_message_id")) {
40
42
  sql`ALTER TABLE pi_submissions ADD COLUMN regenerate_message_id TEXT`;
41
43
  }
44
+ if (!submissionColumns.has("recovery_error_count")) {
45
+ sql`ALTER TABLE pi_submissions
46
+ ADD COLUMN recovery_error_count INTEGER NOT NULL DEFAULT 0`;
47
+ }
48
+ if (!submissionColumns.has("recovery_reason")) {
49
+ sql`ALTER TABLE pi_submissions ADD COLUMN recovery_reason TEXT`;
50
+ }
42
51
  sql`CREATE UNIQUE INDEX IF NOT EXISTS pi_submissions_one_running
43
52
  ON pi_submissions(status)
44
53
  WHERE status = 'running'`;
@@ -9,6 +9,9 @@ export type SubmissionStatus =
9
9
  | "aborted"
10
10
  | "skipped"
11
11
  | "error";
12
+ export type SubmissionRecoveryReason =
13
+ | "no_meaningful_model_progress"
14
+ | "transient_model_error";
12
15
 
13
16
  const TERMINAL_SUBMISSION_STATUSES: ReadonlySet<SubmissionStatus> = new Set([
14
17
  "completed",
@@ -43,6 +46,8 @@ export interface StoredSubmission {
43
46
  queuedUiMessageJson: string | null;
44
47
  userMessageId: string | null;
45
48
  regenerateMessageId: string | null;
49
+ recoveryErrorCount: number;
50
+ recoveryReason: SubmissionRecoveryReason | null;
46
51
  }
47
52
 
48
53
  export interface NewSubmission {
@@ -76,6 +81,8 @@ type SubmissionRow = {
76
81
  queued_ui_message_json: string | null;
77
82
  user_message_id: string | null;
78
83
  regenerate_message_id: string | null;
84
+ recovery_error_count: number;
85
+ recovery_reason: string | null;
79
86
  };
80
87
 
81
88
  // #endregion
@@ -101,6 +108,8 @@ function mapRow(row: SubmissionRow): StoredSubmission {
101
108
  queuedUiMessageJson: row.queued_ui_message_json,
102
109
  userMessageId: row.user_message_id,
103
110
  regenerateMessageId: row.regenerate_message_id,
111
+ recoveryErrorCount: row.recovery_error_count,
112
+ recoveryReason: row.recovery_reason as SubmissionRecoveryReason | null,
104
113
  };
105
114
  }
106
115
 
@@ -121,7 +130,7 @@ export class SubmissionRepository {
121
130
  error, created_at, completed_at, assembly_revision,
122
131
  assembly_descriptor, assistant_message_id, abort_reason,
123
132
  queued_input_json, queued_ui_message_json, user_message_id,
124
- regenerate_message_id
133
+ regenerate_message_id, recovery_error_count, recovery_reason
125
134
  FROM pi_submissions
126
135
  WHERE submission_id = ${id}
127
136
  `[0];
@@ -309,6 +318,30 @@ export class SubmissionRepository {
309
318
  `;
310
319
  }
311
320
 
321
+ // chatRecovery 遇到 partial 会重置 attempt;Submission 计数保证同一 Turn 的可恢复模型错误仍绝对封顶。
322
+ incrementRecoveryErrorCount(
323
+ id: string,
324
+ reason: SubmissionRecoveryReason,
325
+ ): number {
326
+ return this.sql<{ recovery_error_count: number }>`
327
+ UPDATE pi_submissions
328
+ SET recovery_error_count = recovery_error_count + 1,
329
+ recovery_reason = ${reason}
330
+ WHERE submission_id = ${id}
331
+ AND status IN ('pending', 'running')
332
+ RETURNING recovery_error_count
333
+ `[0]?.recovery_error_count ?? 0;
334
+ }
335
+
336
+ clearRecoveryReason(id: string): void {
337
+ this.sql`
338
+ UPDATE pi_submissions
339
+ SET recovery_reason = NULL
340
+ WHERE submission_id = ${id}
341
+ AND status IN ('pending', 'running')
342
+ `;
343
+ }
344
+
312
345
  // 返回所有标准会话消息到 Submission 的关联映射。
313
346
  // Transcript.storedMessages 读取 Pi Session 分支时调用,它用 Map 为每条消息补回 submissionId。
314
347
  // 关联表由 Pi Session 存储拥有,本方法只读不写;不能在 RuntimeDatabase.clearAll 中单独删它而破坏 Transcript 的所有权。
package/src/index.ts CHANGED
@@ -126,6 +126,7 @@ export {
126
126
  } from "./layers/orchestration/temporary-agent/workspace";
127
127
  export {
128
128
  createPiModels,
129
+ modelRequestUrl,
129
130
  resolvePiApiKey,
130
131
  resolvePiModel,
131
132
  } from "./pi/runtime-adapter/models";
@@ -294,9 +294,7 @@ export interface RuntimeMemoryPort {
294
294
  * 按标签覆盖一块热记忆。
295
295
  *
296
296
  * @remarks
297
- * Host 实现提供该能力,但当前 `packages/agent-runtime/src` 没有调用点。
298
- *
299
- * TODO(待确认): 确认后续写入方是否仍需通过这个公开 Port 提交热记忆。
297
+ * `set_context` 在校验标签和 token 预算后调用,Host 负责按当前 Agent 与会话持久化。
300
298
  */
301
299
  set(label: string, content: string): Promise<void>;
302
300
  }
@@ -73,15 +73,15 @@ export type RuntimeRecoveryDecision<Data> =
73
73
  | { readonly kind: "ignore"; readonly reason: string };
74
74
 
75
75
  /**
76
- * 记录一次已执行恢复的最终结果。
76
+ * 记录一次已执行恢复的结果,或表明该恢复已接力给下一次持久调度。
77
77
  *
78
78
  * @remarks
79
- * `_chatRecoveryRetry` 在调用端口的 `retry` 后读取该结果,并用它关闭对应的恢复事故。
79
+ * `_chatRecoveryRetry` 在调用端口的 `retry` 后读取该结果;终态关闭事故,`scheduled` 保持事故活跃。
80
80
  *
81
81
  * 只有失败时才需要 `error`;结果不承载流内容,避免在调度数据与流缓冲区之间复制状态。
82
82
  */
83
83
  export interface RuntimeRecoveryResult {
84
- readonly status: "completed" | "failed";
84
+ readonly status: "completed" | "scheduled" | "failed";
85
85
  readonly error?: string;
86
86
  }
87
87
 
@@ -103,7 +103,7 @@ export interface RuntimeRecoveryPort<Data> {
103
103
  /**
104
104
  * 使用已分类的业务数据重放一次对话。
105
105
  *
106
- * @remarks 持久调度回调会调用它;实现应返回权威终态,而不是仅表示重试已启动。
106
+ * @remarks 持久调度回调会调用它;实现应返回权威终态,或明确说明下一次恢复已经持久调度。
107
107
  */
108
108
  retry(data: Data): Promise<RuntimeRecoveryResult>;
109
109
  /**
@@ -544,6 +544,7 @@ export abstract class RecoverableChatAgent<
544
544
  this.activeRecoveryRootRequestId = data.requestId;
545
545
  try {
546
546
  const result = await this.recoveryPort().retry(data);
547
+ if (result.status === "scheduled") return;
547
548
  await this.engine().updateIncident(
548
549
  data.incidentId,
549
550
  result.status === "completed" ? "completed" : "failed",
@@ -563,6 +564,83 @@ export abstract class RecoverableChatAgent<
563
564
  }
564
565
  }
565
566
 
567
+ /**
568
+ * 把活跃进程内检测到的模型流停滞交给同一套持久恢复预算。
569
+ *
570
+ * @remarks
571
+ * 子类在确认当前 Turn 可以从 durable checkpoint 重放后调用;`beforeSchedule`
572
+ * 必须先结束旧流并恢复权威消息快照,随后引擎才广播 recovering 状态。
573
+ */
574
+ protected async scheduleChatRecoveryRetry(
575
+ data: RecoveryData,
576
+ beforeSchedule: () => void | Promise<void>,
577
+ ): Promise<"disabled" | "scheduled" | "exhausted"> {
578
+ if (!resolveChatRecoveryConfig(this.chatRecovery).enabled) {
579
+ return "disabled";
580
+ }
581
+ const recoveryRootRequestId =
582
+ this.activeRecoveryRootRequestId ?? data.requestId;
583
+ const { incident, exhausted } = await this.engine().beginIncident({
584
+ requestId: data.requestId,
585
+ recoveryRootRequestId,
586
+ recoveryKind: "retry",
587
+ });
588
+ await beforeSchedule();
589
+ if (exhausted) {
590
+ await this.engine().exhaustRecoveryGiveUp({
591
+ callback: "_chatRecoveryRetry",
592
+ data: {
593
+ incidentId: incident.incidentId,
594
+ originalRequestId: recoveryRootRequestId,
595
+ },
596
+ reason: incident.reason ?? "max_attempts_exceeded",
597
+ });
598
+ return "exhausted";
599
+ }
600
+ await this.engine().scheduleRecovery({
601
+ incident,
602
+ recoveryKind: "retry",
603
+ callback: "_chatRecoveryRetry",
604
+ data: {
605
+ ...data,
606
+ incidentId: incident.incidentId,
607
+ },
608
+ reason: this.activeRecoveryRootRequestId
609
+ ? "stable_timeout_retry"
610
+ : "initial",
611
+ });
612
+ return "scheduled";
613
+ }
614
+
615
+ /** 终态化一个 Turn 时取消同请求的恢复调度,并关闭恢复事故与客户端状态。 */
616
+ protected async settleChatRecovery(
617
+ requestId: string,
618
+ status: "completed" | "skipped" | "failed",
619
+ reason?: string,
620
+ ): Promise<void> {
621
+ const incidentIds = new Set<string>();
622
+ try {
623
+ for (const schedule of await this.listSchedules()) {
624
+ if (schedule.callback !== "_chatRecoveryRetry") continue;
625
+ const payload = schedule.payload;
626
+ if (!payload || typeof payload !== "object") continue;
627
+ const recovery = payload as Partial<
628
+ RecoveryScheduleData<RecoveryData>
629
+ >;
630
+ if (recovery.requestId !== requestId) continue;
631
+ if (typeof recovery.incidentId === "string") {
632
+ incidentIds.add(recovery.incidentId);
633
+ }
634
+ await this.cancelSchedule(schedule.id);
635
+ }
636
+ for (const incidentId of incidentIds) {
637
+ await this.engine().updateIncident(incidentId, status, reason);
638
+ }
639
+ } finally {
640
+ await this.setRecovering(false, requestId);
641
+ }
642
+ }
643
+
566
644
  /**
567
645
  * 把 Agents SDK 发现的孤立 Fiber 交给聊天恢复引擎。
568
646
  *
@@ -51,6 +51,10 @@ export interface RuntimeQueuedSubmission {
51
51
 
52
52
  export interface RuntimeTurnState {
53
53
  activeSubmissionId?: string;
54
+ activeRequestId?: string;
55
+ recoveryAttempt?: number;
56
+ recoveryMax?: number;
57
+ recoveryReason?: "no_meaningful_model_progress" | "transient_model_error";
54
58
  steerable: boolean;
55
59
  hasPendingSteer: boolean;
56
60
  queued: RuntimeQueuedSubmission[];
@@ -587,13 +587,14 @@ export class SubmissionLifecycle<
587
587
  return submission ? [submission.submissionId] : [];
588
588
  })()
589
589
  : (() => {
590
- const submission = this.options.store.findRunning();
590
+ const submission = this.options.store.findRunning() ??
591
+ this.options.store.findNextPending();
591
592
  return submission ? [submission.submissionId] : [];
592
593
  })();
593
594
  for (const submissionId of submissionIds) {
594
595
  await this.cancel(submissionId, reason);
595
596
  }
596
- return { ok: true };
597
+ return { ok: submissionIds.length > 0 };
597
598
  }
598
599
 
599
600
  // #endregion
package/src/lib/prompt.ts CHANGED
@@ -46,7 +46,7 @@ export const BEHAVIOR =
46
46
  export const PLANNING =
47
47
  "Planning: For work with 3 or more distinct steps, or any non-trivial / multi-file change, call " +
48
48
  "update_plan with the complete plan (it fully replaces the previous one) so the user sees live " +
49
- "progress. Mark a step in_progress before starting it and completed the moment it's done; keep only " +
49
+ "progress. Mark a step in_progress before starting it and done the moment it's done; keep only " +
50
50
  "one step in_progress at a time and don't batch completions. Do not make a plan for a single trivial " +
51
51
  "step or a purely conversational reply — just do it.";
52
52
 
@@ -1,9 +1,12 @@
1
1
  /**
2
- * dev-only 观测降级:把 observability 事件打到 console,让 `wrangler dev` /
3
- * `wrangler tail` 里能实时看到埋点(否则 publish 到零订阅 channel = 静默 no-op)。
2
+ * 观测降级:把 observability 事件打到 console,让 `wrangler dev` / `wrangler tail` 里能实时看到埋点,
3
+ * 并且(生产上开了 `observability.logs` 时)进 Workers Logs 供事后查询。
4
+ * 不装它,事件就 publish 到零订阅 channel = 静默 no-op。
4
5
  *
5
- * 仅在 env `TELEMETRY_CONSOLE==="1"` 时由 onStart 装载(`.dev.vars` 里开,prod 不设 自动关)。
6
- * 生产不靠它:prod 所有 channel 事件自动转发 Tail Worker
6
+ * onStart 在 env `TELEMETRY_CONSOLE==="1"` 时装载 —— `.dev.vars` `wrangler.jsonc` vars 里都开着。
7
+ * 注意:上游注释宣称"生产上所有 channel 事件自动转发 Tail Worker",那条路径需要 `tail_consumers`,
8
+ * 本仓没有配,所以生产**不能**指望它;这个 console sink 就是生产的唯一消费面(2026-08-06 实测:
9
+ * 未开启前 Observability API 查 universal-agent 24h 事件 count=0)。
7
10
  *
8
11
  * 用 `agents/observability` 的类型化 `subscribe`(按 channel key 订阅),避免引 `node:diagnostics_channel`
9
12
  * 与 `@types/node`(tsconfig 只带 workers-types)。
@@ -3,6 +3,7 @@ import type {
3
3
  ThinkingLevel,
4
4
  } from "@earendil-works/pi-agent-core";
5
5
  import type { Api, Model } from "@earendil-works/pi-ai";
6
+ import { clampReasoning } from "@earendil-works/pi-ai/api/simple-options";
6
7
  import type {
7
8
  RuntimeMcpServer,
8
9
  RuntimeProfile,
@@ -79,11 +80,13 @@ function freezeModel(model: Model<Api>): Model<Api> {
79
80
 
80
81
  // 作用:把 Runtime 的思考强度名称转成 Pi Agent Core 接受的名称。
81
82
  // 调用:创建 Runtime 装配快照时对 profile 中的 `thinking` 调用。
82
- // 原因:只有 `none` Pi 的 `off` 命名不同,其他级别应保持原值。
83
+ // 原因:`none` 映射成 `off`;xhigh/max 用 clampReasoning 夹到 API 真正支持的最高档,
84
+ // 避免超出范围的等级在 provider 侧静默失败。
83
85
  function toPiThinkingLevel(
84
86
  thinking: ThinkingEffort,
85
87
  ): ThinkingLevel {
86
- return thinking === "none" ? "off" : thinking;
88
+ if (thinking === "none") return "off";
89
+ return clampReasoning(thinking) ?? "off";
87
90
  }
88
91
 
89
92
  // 作用:复制并冻结一个 Extension 配置的 manifest 和权限集合。
@@ -160,7 +160,7 @@ export interface PinnedPiRuntime {
160
160
  * @remarks
161
161
  * `PreparedPiTurnAdapter` 在重建 Turn 前读取这个 JSON 结构。
162
162
  *
163
- * 版本号和基础版本校验用于阻止恢复落到不兼容的 Runtime 状态。
163
+ * 基础版本作为准入时元数据保留,创建 Turn 时不与当前 Runtime 比较。
164
164
  */
165
165
  export interface PinnedPiRuntimeDescriptor {
166
166
  readonly version: 1;
@@ -200,6 +200,8 @@ const IDEMPOTENT_TOOL_NAMES = new Set([
200
200
  "browser_markdown",
201
201
  "browser_scrape",
202
202
  "bind_resource",
203
+ "delete",
204
+ "edit",
203
205
  "find",
204
206
  "get_time",
205
207
  "grep",
@@ -211,6 +213,7 @@ const IDEMPOTENT_TOOL_NAMES = new Set([
211
213
  "read_skill_resource",
212
214
  "sandbox_process_logs",
213
215
  "web_search",
216
+ "write",
214
217
  "unbind_resource",
215
218
  ]);
216
219
 
@@ -251,7 +254,7 @@ function describeTools(candidates: readonly PiToolCandidate[]) {
251
254
 
252
255
  // 把本模块认定会影响版本的准备结果序列化出来。
253
256
  // preparePiRuntime 创建一次,Runtime 接受快照前再对结果求哈希。
254
- // 集合排序保证等价输入稳定;增删字段会改变版本哈希和恢复兼容性。
257
+ // 集合排序保证等价输入稳定;增删字段会改变版本哈希。
255
258
  function describeRuntime(
256
259
  snapshot: RuntimeSnapshot,
257
260
  candidates: readonly PiToolCandidate[],
@@ -334,18 +337,17 @@ export function readPreparedPiRuntime(
334
337
  }
335
338
 
336
339
  /**
337
- * 校验已存的固定描述是否仍与当前准备好的 Runtime 兼容。
340
+ * 读取已存的固定描述。
338
341
  *
339
342
  * @remarks
340
343
  * `PreparedPiTurnAdapter` 在新建或恢复 Pi Turn 前调用它。
341
344
  *
342
- * 只有固定阶段写入的提示词允许不同,其余准备字段及基础版本必须匹配。
345
+ * Prepared Runtime 提供当前执行能力,descriptor 仅保留 Submission 固定的 Turn 字段。
343
346
  */
344
347
  export function readPinnedPiRuntime(
345
348
  prepared: PreparedPiRuntime,
346
349
  owner: object,
347
350
  descriptorBody: string,
348
- baseRevision: string,
349
351
  ): {
350
352
  readonly state: PreparedPiRuntimeState;
351
353
  readonly descriptor: PinnedPiRuntimeDescriptor;
@@ -361,24 +363,11 @@ export function readPinnedPiRuntime(
361
363
  throw new Error("Pinned Runtime assembly descriptor is invalid");
362
364
  }
363
365
  const descriptor = value as PinnedPiRuntimeDescriptor;
364
- const expected = JSON.parse(prepared.revisionDescriptor) as Record<
365
- string,
366
- unknown
367
- >;
368
- const actual = descriptor as unknown as Record<string, unknown>;
369
- const incompatible = Object.keys(expected).some((key) =>
370
- key !== "systemPrompt" &&
371
- JSON.stringify(actual[key]) !== JSON.stringify(expected[key])
372
- );
373
366
  if (
374
367
  descriptor.version !== 1 ||
375
- descriptor.baseRevision !== baseRevision ||
376
- typeof descriptor.systemPrompt !== "string" ||
377
- incompatible
368
+ typeof descriptor.systemPrompt !== "string"
378
369
  ) {
379
- throw new Error(
380
- "Pinned Runtime revision is unavailable for Pi recovery",
381
- );
370
+ throw new Error("Pinned Runtime assembly descriptor is invalid");
382
371
  }
383
372
  return { state, descriptor };
384
373
  }
@@ -9,17 +9,21 @@ import {
9
9
  import type {
10
10
  Api,
11
11
  AssistantMessage,
12
+ Message,
12
13
  Model,
13
14
  Models,
14
15
  ToolResultMessage,
15
16
  UserMessage,
16
17
  } from "@earendil-works/pi-ai";
18
+ import { transformMessages } from "@earendil-works/pi-ai/api/transform-messages";
17
19
  import { requiresPiToolApproval, parkPiToolApproval } from "../turn";
18
20
  import { PiChunkEncoder } from "../message";
19
21
  import type { UIMessageChunk } from "ai";
22
+ import { ChatStreamStalledError } from "agents/chat";
20
23
  import {
21
24
  compilePiTools,
22
25
  createPiToolGovernance,
26
+ normalizeUpdatePlanArguments,
23
27
  type PiToolCandidate,
24
28
  type PiToolGovernance,
25
29
  type PiToolTelemetry,
@@ -30,12 +34,28 @@ import {
30
34
  readPinnedPiRuntime,
31
35
  type PreparedPiRuntime,
32
36
  } from "./assembly";
33
- import { resolvePiApiKey, withProviderRetry } from "./models";
37
+ import {
38
+ isRecoverableAssistantError,
39
+ isModelStreamStallMessage,
40
+ resolvePiApiKey,
41
+ withProviderRetry,
42
+ } from "./models";
34
43
 
35
44
  // #region Single-run Pi bridge
36
45
 
37
46
  const MAX_MODEL_TURNS = 30;
38
47
 
48
+ export class RetryableModelError extends Error {}
49
+
50
+ function normalizePlanToolCalls(message: AgentMessage): void {
51
+ if (message.role !== "assistant") return;
52
+ for (const part of message.content) {
53
+ if (part.type === "toolCall" && part.name === "update_plan") {
54
+ part.arguments = normalizeUpdatePlanArguments(part.arguments);
55
+ }
56
+ }
57
+ }
58
+
39
59
  /**
40
60
  * 把 Pi 的终止原因翻译成本仓的回合结局。
41
61
  *
@@ -72,6 +92,16 @@ function classifyPiStopReason(stopReason: string | undefined): {
72
92
  }
73
93
  }
74
94
 
95
+ function visibleAssistantContent(message: AssistantMessage) {
96
+ return message.content.filter((part) =>
97
+ part.type === "text"
98
+ ? part.text.trim()
99
+ : part.type === "thinking"
100
+ ? part.thinking.trim()
101
+ : false
102
+ );
103
+ }
104
+
75
105
  interface PiTurnAdapterOptions {
76
106
  readonly pi: {
77
107
  readonly model: Model<Api>;
@@ -79,6 +109,7 @@ interface PiTurnAdapterOptions {
79
109
  };
80
110
  readonly models: Models;
81
111
  readonly apiKey: string;
112
+ readonly modelSessionId: string;
82
113
  // 读取这次执行真正要交给 Pi 的 canonical transcript。
83
114
  // PiTurnAdapter.run 在创建 PiCore 前调用它,宿主应返回当时最新的 transcript。
84
115
  // 这里保留为延迟读取,是为了避免适配器创建后新增的恢复消息被旧快照漏掉。
@@ -129,10 +160,12 @@ class PiTurnAdapter {
129
160
  candidate: PiToolCandidate,
130
161
  toolCallId: string,
131
162
  input: unknown,
163
+ signal?: AbortSignal,
132
164
  ): Promise<boolean> {
133
165
  const [tool] = this.compile([candidate]);
134
166
  if (!tool) return false;
135
- await tool.execute(toolCallId, input);
167
+ signal?.throwIfAborted();
168
+ await tool.execute(toolCallId, input, signal);
136
169
  return true;
137
170
  }
138
171
 
@@ -173,9 +206,17 @@ class PiTurnAdapter {
173
206
  convertToLlm,
174
207
  streamFn: withProviderRetry(
175
208
  this.opts.models.streamSimple.bind(this.opts.models),
209
+ 0,
210
+ this.opts.modelSessionId,
176
211
  ),
177
212
  getApiKey: () => this.opts.apiKey,
178
- transformContext: this.opts.transformContext,
213
+ // transformMessages 在宿主上下文变换之后运行,确保跨 provider 的 tool call ID 格式兼容。
214
+ // OpenAI Responses API 生成含 `|` 的 450+ 字符 ID,Anthropic 只接受 ^[a-zA-Z0-9_-]+$(64 字符上限);
215
+ // 切换 provider 或消息跨 provider 回放时若不规范化,provider 会静默拒绝。
216
+ transformContext: async (messages, signal) => {
217
+ const ctx = await this.opts.transformContext(messages, signal);
218
+ return transformMessages(ctx as Message[], this.opts.pi.model) as AgentMessage[];
219
+ },
179
220
  afterToolCall: this.governance.afterToolCall,
180
221
  shouldStopAfterTurn: () => {
181
222
  reachedModelTurnLimit = ++modelTurns >= MAX_MODEL_TURNS;
@@ -295,7 +336,6 @@ export interface PiTurnDurability {
295
336
  */
296
337
  export interface CreatePreparedPiTurnOptions {
297
338
  readonly prepared: PreparedPiRuntime;
298
- readonly baseRevision: string;
299
339
  readonly pinnedDescriptor: string;
300
340
  readonly submission: {
301
341
  readonly id: string;
@@ -398,6 +438,7 @@ export interface PiPreparedTurnRunOptions {
398
438
  */
399
439
  export class PreparedPiTurnAdapter {
400
440
  private readonly turn: PiTurnAdapter;
441
+ private readonly abortController = new AbortController();
401
442
  private readonly candidates: readonly PiToolCandidate[];
402
443
  private assistantOrdinal: number;
403
444
  private readonly encoder: PiChunkEncoder;
@@ -422,7 +463,6 @@ export class PreparedPiTurnAdapter {
422
463
  options.prepared,
423
464
  dependencies.owner,
424
465
  options.pinnedDescriptor,
425
- options.baseRevision,
426
466
  );
427
467
  this.assistantOrdinal = options.submission.assistantOrdinal;
428
468
  this.encoder = new PiChunkEncoder({
@@ -530,6 +570,7 @@ export class PreparedPiTurnAdapter {
530
570
  state.snapshot.bindings.provider,
531
571
  state.snapshot.pi.model.id,
532
572
  ),
573
+ modelSessionId: options.submission.requestId,
533
574
  canonicalMessages: options.canonicalMessages,
534
575
  settle: options.durability.settleTool,
535
576
  onToolTelemetry: options.onToolTelemetry,
@@ -550,6 +591,7 @@ export class PreparedPiTurnAdapter {
550
591
  * 转交给 PiTurnAdapter 可保留启动前取消标记;run() 之前还没有可直接调用的 PiCore。
551
592
  */
552
593
  abort(): void {
594
+ this.abortController.abort();
553
595
  this.turn.abort();
554
596
  }
555
597
 
@@ -588,6 +630,7 @@ export class PreparedPiTurnAdapter {
588
630
  candidate,
589
631
  request.toolCallId,
590
632
  request.input,
633
+ this.abortController.signal,
591
634
  )
592
635
  : false;
593
636
  }
@@ -626,9 +669,49 @@ export class PreparedPiTurnAdapter {
626
669
  // PiTurnAdapter 会对每个 AgentEvent 调用它,PiCore 会等待该 Promise 后再越过订阅事件屏障。
627
670
  // canonical message 先于流投影提交,恢复才不会依赖仅供浏览器消费的记录;调整顺序必须复核 recovery effect。
628
671
  private async handleEvent(event: AgentEvent): Promise<void> {
672
+ if (
673
+ event.type === "message_start" ||
674
+ event.type === "message_update" ||
675
+ event.type === "message_end"
676
+ ) {
677
+ normalizePlanToolCalls(event.message);
678
+ }
679
+ if (
680
+ event.type === "message_update" &&
681
+ event.assistantMessageEvent.type === "error" &&
682
+ (isModelStreamStallMessage(
683
+ event.assistantMessageEvent.error.errorMessage,
684
+ ) ||
685
+ isRecoverableAssistantError(event.assistantMessageEvent.error))
686
+ ) {
687
+ return;
688
+ }
629
689
  let projectedEvent = event;
630
690
  if (event.type === "message_end") {
631
691
  const message = event.message;
692
+ if (message.role === "assistant") {
693
+ const stalled = message.stopReason === "error" &&
694
+ isModelStreamStallMessage(message.errorMessage);
695
+ const retryable = isRecoverableAssistantError(message);
696
+ if (stalled || retryable) {
697
+ const content = visibleAssistantContent(message);
698
+ if (content.length > 0) {
699
+ this.assistantOrdinal += 1;
700
+ await this.options.onCanonicalMessage({
701
+ kind: "commit-turn",
702
+ ordinal: this.assistantOrdinal,
703
+ message: {
704
+ ...message,
705
+ content,
706
+ },
707
+ });
708
+ }
709
+ if (stalled) {
710
+ throw new ChatStreamStalledError(message.errorMessage!);
711
+ }
712
+ throw new RetryableModelError(message.errorMessage);
713
+ }
714
+ }
632
715
  if (message.role === "user") {
633
716
  await this.options.onCanonicalMessage({
634
717
  kind: "append-user",
@@ -646,7 +729,7 @@ export class PreparedPiTurnAdapter {
646
729
  abortReason
647
730
  ? {
648
731
  ...message,
649
- content: [],
732
+ content: visibleAssistantContent(message),
650
733
  stopReason: "aborted" as const,
651
734
  errorMessage: abortReason,
652
735
  }
@@ -2,13 +2,19 @@ import {
2
2
  PreparedPiTurnAdapter,
3
3
  type CreatePreparedPiTurnOptions,
4
4
  } from "./execution";
5
+ export { RetryableModelError } from "./execution";
5
6
  import {
6
7
  uiUserMessageToPi,
7
8
  } from "../message";
8
9
  import type { UIMessage } from "ai";
9
10
  import { createModels, type MutableModels } from "@earendil-works/pi-ai";
10
11
  import { configurePiModels, resolvePiApiKey } from "./models";
11
- export { withProviderRetry } from "./models";
12
+ export {
13
+ MODEL_STREAM_STALL_TIMEOUT_MS,
14
+ modelRequestUrl,
15
+ readModelStreamStallDetails,
16
+ withProviderRetry,
17
+ } from "./models";
12
18
  import {
13
19
  pinPiRuntime,
14
20
  preparePiRuntime,
@@ -148,7 +154,7 @@ export class PiRuntimeAdapter {
148
154
  * @remarks
149
155
  * 调用方:Runtime 的准入流程为每个新 Submission 调用,并把 descriptor 与其哈希一起持久化。
150
156
  *
151
- * 实现理由:memory、workspace 和 Extension context 可能随时间变化,恢复必须使用准入时的描述符检查兼容性。
157
+ * 实现理由:memory、workspace 和 Extension context 可能随时间变化,所以把准入时的 Turn 字段保存在 descriptor 中。
152
158
  * 不要把 pin 推迟到 Turn 启动后,否则已持久化的 Submission 将失去可验证的执行基线。
153
159
  */
154
160
  pin(options: PinPiRuntimeOptions): Promise<PinnedPiRuntime> {
@@ -187,7 +193,7 @@ export class PiRuntimeAdapter {
187
193
  * @remarks
188
194
  * 调用方:Runtime 在首次执行和恢复继续时各创建一个新 Turn,并提供 canonical messages、durable Tool 回调和流事件回调。
189
195
  *
190
- * 实现理由:构造时会校验 Prepared Runtime 的 ownerpinned descriptor base revision,再建立本 Turn 独享的 Tool governance 与执行状态。
196
+ * 实现理由:构造时会校验 Prepared Runtime 的 ownerpinned descriptor 格式,再建立本 Turn 独享的 Tool governance 与执行状态。
191
197
  * 不要跨 Turn 复用返回对象;abort、steer、assistant ordinal 和 Tool governance 都是单次执行状态。
192
198
  */
193
199
  createTurn(