@springbrand/agent-runtime 0.1.0

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.
Files changed (75) hide show
  1. package/package.json +28 -0
  2. package/src/db/approval.repo.ts +291 -0
  3. package/src/db/ext-context.repo.ts +34 -0
  4. package/src/db/index.ts +83 -0
  5. package/src/db/message-ui.repo.ts +39 -0
  6. package/src/db/milestone.repo.ts +96 -0
  7. package/src/db/runtime-event-outbox.repo.ts +89 -0
  8. package/src/db/schema.ts +164 -0
  9. package/src/db/settlement.repo.ts +104 -0
  10. package/src/db/steer.repo.ts +73 -0
  11. package/src/db/submission.repo.ts +323 -0
  12. package/src/index.ts +133 -0
  13. package/src/kernel/approval-lifecycle.ts +552 -0
  14. package/src/kernel/bindings.ts +898 -0
  15. package/src/kernel/degradation.ts +15 -0
  16. package/src/kernel/extensions.ts +108 -0
  17. package/src/kernel/profile.ts +116 -0
  18. package/src/kernel/public-contracts.ts +17 -0
  19. package/src/kernel/receipts.ts +124 -0
  20. package/src/kernel/recoverable-chat-agent.ts +899 -0
  21. package/src/kernel/state.ts +76 -0
  22. package/src/kernel/submission-lifecycle.ts +600 -0
  23. package/src/layers/context/budget/gate.ts +88 -0
  24. package/src/layers/orchestration/subagents/agent-types/contract.ts +78 -0
  25. package/src/layers/orchestration/subagents/agent-types/extract/index.ts +47 -0
  26. package/src/layers/orchestration/subagents/agent-types/fanout/index.ts +53 -0
  27. package/src/layers/orchestration/subagents/agent-types/registry.ts +16 -0
  28. package/src/layers/orchestration/temporary-agent/core.ts +152 -0
  29. package/src/layers/orchestration/temporary-agent/runner.ts +133 -0
  30. package/src/layers/orchestration/temporary-agent/workspace.ts +154 -0
  31. package/src/lib/artifacts.ts +54 -0
  32. package/src/lib/egress.ts +44 -0
  33. package/src/lib/execution-level.ts +27 -0
  34. package/src/lib/extension-name.ts +18 -0
  35. package/src/lib/host-actions.ts +57 -0
  36. package/src/lib/mcp.ts +86 -0
  37. package/src/lib/model-catalog.ts +7 -0
  38. package/src/lib/prompt.ts +139 -0
  39. package/src/lib/telemetry-dev.ts +44 -0
  40. package/src/pi/assembly/context.ts +510 -0
  41. package/src/pi/assembly/extensions.ts +661 -0
  42. package/src/pi/assembly/index.ts +19 -0
  43. package/src/pi/assembly/snapshot.ts +200 -0
  44. package/src/pi/message/contract.ts +8 -0
  45. package/src/pi/message/conversion.ts +73 -0
  46. package/src/pi/message/index.ts +3 -0
  47. package/src/pi/message/projection.ts +604 -0
  48. package/src/pi/runtime-adapter/assembly.ts +552 -0
  49. package/src/pi/runtime-adapter/execution.ts +683 -0
  50. package/src/pi/runtime-adapter/index.ts +232 -0
  51. package/src/pi/runtime-adapter/models.ts +243 -0
  52. package/src/pi/runtime-adapter/recovery.ts +805 -0
  53. package/src/pi/runtime-adapter/transcript.ts +825 -0
  54. package/src/pi/session/index.ts +24 -0
  55. package/src/pi/session/storage.ts +353 -0
  56. package/src/pi/tool/ai-adapter.ts +100 -0
  57. package/src/pi/tool/base.ts +110 -0
  58. package/src/pi/tool/compiler.ts +444 -0
  59. package/src/pi/tool/core-host.ts +48 -0
  60. package/src/pi/tool/core.ts +251 -0
  61. package/src/pi/tool/index.ts +32 -0
  62. package/src/pi/tool/mcp.ts +319 -0
  63. package/src/pi/tool/schedule.ts +198 -0
  64. package/src/pi/tool/skill.ts +455 -0
  65. package/src/pi/tool/subagent.ts +148 -0
  66. package/src/pi/tool/web-search/api.ts +1292 -0
  67. package/src/pi/tool/web-search/index.ts +2 -0
  68. package/src/pi/tool/web-search/web-search.ts +127 -0
  69. package/src/pi/tool/workspace-sandbox.ts +664 -0
  70. package/src/pi/turn/approval.ts +181 -0
  71. package/src/pi/turn/index.ts +62 -0
  72. package/src/pi/turn/tool-recovery.ts +792 -0
  73. package/src/plugins.ts +1024 -0
  74. package/src/runtime-agent.ts +654 -0
  75. package/src/runtime.ts +2880 -0
@@ -0,0 +1,683 @@
1
+ import {
2
+ Agent as PiCore,
3
+ convertToLlm,
4
+ type AgentEvent,
5
+ type AgentMessage,
6
+ type AgentOptions,
7
+ type ThinkingLevel,
8
+ } from "@earendil-works/pi-agent-core";
9
+ import type {
10
+ Api,
11
+ AssistantMessage,
12
+ Model,
13
+ Models,
14
+ ToolResultMessage,
15
+ UserMessage,
16
+ } from "@earendil-works/pi-ai";
17
+ import { requiresPiToolApproval, parkPiToolApproval } from "../turn";
18
+ import { PiChunkEncoder } from "../message";
19
+ import type { UIMessageChunk } from "ai";
20
+ import {
21
+ compilePiTools,
22
+ createPiToolGovernance,
23
+ type PiToolCandidate,
24
+ type PiToolGovernance,
25
+ type PiToolTelemetry,
26
+ type SettledPiToolCall,
27
+ } from "../tool";
28
+ import {
29
+ piToolRetryPolicy,
30
+ readPinnedPiRuntime,
31
+ type PreparedPiRuntime,
32
+ } from "./assembly";
33
+ import { resolvePiApiKey, withProviderRetry } from "./models";
34
+
35
+ // #region Single-run Pi bridge
36
+
37
+ /**
38
+ * 把 Pi 的终止原因翻译成本仓的回合结局。
39
+ *
40
+ * 调用:终态提交(`onTerminal`)与流式记录的 `turnStatus` 共用这一处,
41
+ * 保证服务端只有一份判据 —— 两处各写一遍三元链正是它们悄悄分叉的原因。
42
+ *
43
+ * 为什么未知取值判失败而不是成功:`stopReason` 是上游会扩的联合体
44
+ * (0.83 就加了 `"pending"`)。把认不出的结局宣称成「成功」,等于让一次
45
+ * 异常结束伪装成正常完成 —— 这是本仓明确禁止的假完成,
46
+ * 也是 Pi 自己在 0.83 的选择(认不出的终止原因直接抛错而非压平成 stop)。
47
+ * 前端对未知取值取 `"running"`(未知即未定),最终由服务端这份权威判据校正。
48
+ */
49
+ function classifyPiStopReason(stopReason: string | undefined): {
50
+ outcome: PiTurnTerminalIntent["outcome"];
51
+ turnStatus: "completed" | "error" | "aborted";
52
+ message?: string;
53
+ } {
54
+ switch (stopReason) {
55
+ case "stop":
56
+ case "length":
57
+ return { outcome: "succeeded", turnStatus: "completed" };
58
+ case "aborted":
59
+ return { outcome: "aborted", turnStatus: "aborted" };
60
+ case "error":
61
+ return { outcome: "failed", turnStatus: "error" };
62
+ default:
63
+ return {
64
+ outcome: "failed",
65
+ turnStatus: "error",
66
+ message: `Pi ended the turn with an unrecognized stop reason: ${
67
+ stopReason ?? "(none)"
68
+ }`,
69
+ };
70
+ }
71
+ }
72
+
73
+ interface PiTurnAdapterOptions {
74
+ readonly pi: {
75
+ readonly model: Model<Api>;
76
+ readonly thinkingLevel?: ThinkingLevel;
77
+ };
78
+ readonly models: Models;
79
+ readonly apiKey: string;
80
+ // 读取这次执行真正要交给 Pi 的 canonical transcript。
81
+ // PiTurnAdapter.run 在创建 PiCore 前调用它,宿主应返回当时最新的 transcript。
82
+ // 这里保留为延迟读取,是为了避免适配器创建后新增的恢复消息被旧快照漏掉。
83
+ readonly canonicalMessages: () => Promise<readonly AgentMessage[]>;
84
+ readonly denyTools?: readonly string[];
85
+ // 把工具的最终结果交回宿主持久化。
86
+ // 共享工具编译器在向 Pi 返回成功或规范化失败前调用它。
87
+ // 这个先落库再发布结果的顺序是恢复边界,不能改成事后异步补写。
88
+ readonly settle: (
89
+ call: SettledPiToolCall,
90
+ ) => void | Promise<void>;
91
+ // 上报工具执行耗时、结果大小和成败。
92
+ // 工具治理层在每次执行结束时调用它,宿主可选择不提供。
93
+ // 该回调只负责观测,工具治理层会吞掉它的异常以免改变执行结果。
94
+ readonly onToolTelemetry?: (event: PiToolTelemetry) => void;
95
+ // 在模型调用前调整 Pi 的消息上下文。
96
+ // PiCore 通过 AgentOptions.transformContext 调用它,Runtime 用它接入现有上下文处理。
97
+ // 直接复用 Pi 的回调类型可避免这里维护另一套上下文转换约定。
98
+ readonly transformContext: NonNullable<AgentOptions["transformContext"]>;
99
+ }
100
+
101
+ class PiTurnAdapter {
102
+ private piInstance?: PiCore;
103
+ private abortRequested = false;
104
+ private readonly pendingSteers: UserMessage[] = [];
105
+ private readonly governance: PiToolGovernance;
106
+
107
+ // 创建一个 Turn 所需的短生命周期 Pi 执行器状态。
108
+ // PreparedPiTurnAdapter 只构造一次,并把运行、重试、转向和中止都交给它。
109
+ // 工具治理对象必须在这些路径间复用,因为它保存本 Turn 的重试次数和熔断状态。
110
+ constructor(private readonly opts: PiTurnAdapterOptions) {
111
+ this.governance = createPiToolGovernance(opts.onToolTelemetry);
112
+ }
113
+
114
+ // 把 Tool candidate 变成 Pi 可以执行的受治理 Tool。
115
+ // 正常运行会编译 Pinned Runtime 的整份列表,恢复时只编译指定 Tool candidate。
116
+ // 拒绝规则、重名检查、输出限额和持久化结算统一走共享编译器,避免恢复另开旁路。
117
+ private compile(candidates: readonly PiToolCandidate[]) {
118
+ return compilePiTools(candidates, {
119
+ deny: this.opts.denyTools,
120
+ governance: this.governance,
121
+ settle: this.opts.settle,
122
+ });
123
+ }
124
+
125
+ // 用正常 Turn 的受治理路径重新执行一次已知工具调用。
126
+ // recovery effect 和审批续跑在找到原调用记录对应的 Tool candidate 后调用它。
127
+ // Tool candidate 被过滤时返回 false,让调用方安全停止;实际执行仍保留结算和重试上限。
128
+ async retryTool(
129
+ candidate: PiToolCandidate,
130
+ toolCallId: string,
131
+ input: unknown,
132
+ ): Promise<boolean> {
133
+ const [tool] = this.compile([candidate]);
134
+ if (!tool) return false;
135
+ await tool.execute(toolCallId, input);
136
+ return true;
137
+ }
138
+
139
+ // 在 Pi 运行实例可用后尽快停止这个 Turn。
140
+ // Runtime 可能在 run() 之前或期间发出取消,因此尚未创建 PiCore 时先记住请求。
141
+ // canonical transcript 读取是异步的,其间没有可中止的 Pi 实例,所以不能删掉启动前标记。
142
+ abort(): void {
143
+ if (this.piInstance) this.piInstance.abort();
144
+ else this.abortRequested = true;
145
+ }
146
+
147
+ // 把一条用户消息排入当前 Turn。
148
+ // 提交仍在运行时收到扩展宿主消息,Runtime 会调用这里。
149
+ // PiCore 创建前先缓冲消息,可覆盖异步启动窗口,同时不引入第二份 transcript 来源。
150
+ steer(message: UserMessage): void {
151
+ if (this.piInstance) this.piInstance.steer(message);
152
+ else this.pendingSteers.push(message);
153
+ }
154
+
155
+ // 从当前 canonical transcript 继续运行 Pi,并把生命周期事件交给监听器。
156
+ // Submission 的首次执行或恢复执行取得 Pinned Runtime 后,PreparedPiTurnAdapter 调用这里。
157
+ // 每次新建 PiCore 可隔离运行状态;等待订阅回调并在 finally 清理可保住 Pi 的事件屏障语义。
158
+ async run(
159
+ opts: {
160
+ readonly systemPrompt: string;
161
+ readonly tools: readonly PiToolCandidate[];
162
+ readonly signal?: AbortSignal;
163
+ },
164
+ listener: (event: AgentEvent) => void | Promise<void>,
165
+ ): Promise<void> {
166
+ const { systemPrompt, signal } = opts;
167
+ signal?.throwIfAborted();
168
+ const canonicalMessages = await this.opts.canonicalMessages();
169
+ const tools = this.compile(opts.tools);
170
+ const pi = new PiCore({
171
+ convertToLlm,
172
+ streamFn: withProviderRetry(
173
+ this.opts.models.streamSimple.bind(this.opts.models),
174
+ ),
175
+ getApiKey: () => this.opts.apiKey,
176
+ transformContext: this.opts.transformContext,
177
+ afterToolCall: this.governance.afterToolCall,
178
+ initialState: {
179
+ model: this.opts.pi.model,
180
+ systemPrompt,
181
+ messages: [...canonicalMessages],
182
+ tools: [...tools],
183
+ ...(this.opts.pi.thinkingLevel !== undefined
184
+ ? { thinkingLevel: this.opts.pi.thinkingLevel }
185
+ : {}),
186
+ },
187
+ });
188
+
189
+ this.piInstance = pi;
190
+ for (const message of this.pendingSteers.splice(0)) {
191
+ pi.steer(message);
192
+ }
193
+ const unsubscribe = pi.subscribe(listener);
194
+ // 把调用方的 AbortSignal 转发给只存在于本方法内的 Pi 运行实例。
195
+ // continue() 尚未结束时信号监听器会调用它,所有退出路径都在 finally 中移除监听。
196
+ // addEventListener 和 removeEventListener 必须使用同一函数引用,因此这里保留稳定的局部函数。
197
+ const abort = (): void => pi.abort();
198
+ signal?.addEventListener("abort", abort, { once: true });
199
+ try {
200
+ const running = pi.continue();
201
+ if (this.abortRequested) {
202
+ this.abortRequested = false;
203
+ pi.abort();
204
+ }
205
+ await running;
206
+ } finally {
207
+ signal?.removeEventListener("abort", abort);
208
+ unsubscribe();
209
+ if (this.piInstance === pi) this.piInstance = undefined;
210
+ }
211
+ }
212
+ }
213
+
214
+ // #endregion
215
+
216
+ // #region Public prepared-Turn contracts
217
+
218
+ /** 表示可直接复用、无需再次执行工具的持久化结果。 */
219
+ export interface PiStoredToolSettlement {
220
+ readonly result: import("@earendil-works/pi-agent-core").AgentToolResult<unknown>;
221
+ readonly isError: boolean;
222
+ }
223
+
224
+ export type PiCanonicalUserInput = UserMessage;
225
+ export type PiToolSettlement = SettledPiToolCall;
226
+
227
+ /** 表示工具真正执行前写入的持久化输入证据。 */
228
+ export interface PiToolInputRecord {
229
+ readonly toolCallId: string;
230
+ readonly toolName: string;
231
+ readonly input: unknown;
232
+ readonly retry: "idempotent" | "non-idempotent";
233
+ }
234
+
235
+ /**
236
+ * 提供 Pi Turn 执行期间需要的持久化读写能力。
237
+ *
238
+ * @remarks
239
+ * Runtime 为每个 Submission 实现这个端口,PreparedPiTurnAdapter 会在审批和 Tool 执行前后调用它。
240
+ * 持久化留在 PiCore 之外,恢复流程才能从 Runtime 数据库重建决定,而不是依赖已丢失的内存状态。
241
+ */
242
+ export interface PiTurnDurability {
243
+ /**
244
+ * 查找某次工具调用已经保存的结果。
245
+ *
246
+ * @remarks
247
+ * PreparedPiTurnAdapter 会在审批前查询一次,并在审批可能生成拒绝结果后再查询一次。
248
+ * 两次读取分别防止重复执行已完成工作,以及漏掉 Turn 暂停期间产生的结果。
249
+ */
250
+ findToolSettlement(toolCallId: string): PiStoredToolSettlement | null;
251
+ /**
252
+ * 保存工具审批请求并等待决定。
253
+ *
254
+ * @remarks
255
+ * Pinned Runtime 的 approval mode 和 Tool candidate 风险要求审批时,PreparedPiTurnAdapter 才会调用它;onCreated 在记录创建后发布回执。
256
+ * 通过 onCreated 发布可保证持久化请求早于浏览器事件,调用方必须保留这个顺序。
257
+ */
258
+ requestToolApproval(
259
+ approval: import("../turn").PiToolApproval,
260
+ signal?: AbortSignal,
261
+ onCreated?: () => void | Promise<void>,
262
+ ): Promise<void>;
263
+ /**
264
+ * 记录工具输入,并返回它是否是第一次持久化尝试。
265
+ *
266
+ * @remarks
267
+ * Tool candidate 没有已存结算结果时,PreparedPiTurnAdapter 会在真正执行前调用它。
268
+ * 恢复逻辑会把返回值和重试策略一起用于阻止结果不确定的非幂等重放,因此不能混淆 true 与 false。
269
+ */
270
+ appendToolInput(input: PiToolInputRecord): boolean;
271
+ /**
272
+ * 保存受治理工具调用经过限额处理后的最终结果。
273
+ *
274
+ * @remarks
275
+ * compilePiTools 会在返回成功或向 Pi 发布规范化失败前调用它。
276
+ * 这个先写结果的边界让后续重试能看到原始结局,因此必须留在受治理包装器内部。
277
+ */
278
+ settleTool(call: PiToolSettlement): void | Promise<void>;
279
+ }
280
+
281
+ /**
282
+ * 描述把一个 Submission 的 Pinned Runtime 接入 Pi 执行所需的全部依赖。
283
+ *
284
+ * @remarks
285
+ * Submission 首次执行或恢复时,Runtime 把它传给 PiRuntimeAdapter.createTurn。
286
+ * 这些回调让 transcript、持久化状态、流投影和终态仍由 Runtime 负责,避免在 PiCore 内复制一份。
287
+ * Runtime、Submission、Turn、Prepared Runtime、Pinned Runtime、Tool candidate 和 transcript 沿用 `./index.ts` 的统一定义。
288
+ */
289
+ export interface CreatePreparedPiTurnOptions {
290
+ readonly prepared: PreparedPiRuntime;
291
+ readonly baseRevision: string;
292
+ readonly pinnedDescriptor: string;
293
+ readonly submission: {
294
+ readonly id: string;
295
+ readonly requestId: string;
296
+ readonly messageId: string;
297
+ readonly startedAt: number;
298
+ readonly continuation: boolean;
299
+ readonly assistantOrdinal: number;
300
+ };
301
+ /**
302
+ * 在 Pi 启动前读取最新 canonical transcript。
303
+ *
304
+ * PiTurnAdapter.run 在创建 PiCore 前调用它,Runtime 应返回 transcript 当时已提交的消息。
305
+ * 延迟读取可纳入适配器创建后才物化的恢复结果,不能提前固化为数组快照。
306
+ */
307
+ readonly canonicalMessages: () => Promise<readonly AgentMessage[]>;
308
+ readonly durability: PiTurnDurability;
309
+ /**
310
+ * 上报受治理工具的耗时、结果大小和成败。
311
+ *
312
+ * 工具治理层在执行结束时调用它,Runtime 可用它发出工具遥测事件。
313
+ * 该回调只负责观测,工具治理层会隔离它的异常,避免遥测改变工具结果。
314
+ */
315
+ readonly onToolTelemetry?: (event: PiToolTelemetry) => void;
316
+ /**
317
+ * 调整 Pi 即将发送给模型的消息上下文。
318
+ *
319
+ * PiCore 在每次模型调用前使用它,Runtime 通过这里接入现有上下文处理。
320
+ * 直接沿用 AgentOptions 的类型可确保参数和取消信号与 Pi 保持一致。
321
+ */
322
+ readonly transformContext: NonNullable<AgentOptions["transformContext"]>;
323
+ /**
324
+ * 在提交终止助手消息前读取已保存的取消原因。
325
+ *
326
+ * handleEvent 收到非工具调用的助手终止消息时调用它,Runtime 可省略该能力。
327
+ * 以持久化原因为准可覆盖模型随后到达的普通完成结果,避免已取消提交被记为成功。
328
+ */
329
+ readonly abortReason?: () => string | undefined;
330
+ /**
331
+ * 保存并广播一条投影后的 Pi 流 chunk。
332
+ *
333
+ * 工具审批创建和 handleEvent 生成可见记录时会调用它,Runtime 决定具体存储与广播方式。
334
+ * 适配器会等待该回调,以免 Pi 的后续事件越过尚未落地的前一条记录。
335
+ */
336
+ readonly onRecord: (
337
+ chunk: UIMessageChunk,
338
+ ) => void | Promise<void>;
339
+ /**
340
+ * 把一条完成的 Pi 消息提交到 Runtime 的 transcript。
341
+ *
342
+ * handleEvent 收到 user、assistant 或 toolResult 的 message_end 时调用它。
343
+ * 只在 message_end 提交可避免把流式中间态当成恢复依据,调用方应保持幂等写入。
344
+ */
345
+ readonly onCanonicalMessage: (
346
+ commit: PiCanonicalMessageCommit,
347
+ ) => void | Promise<void>;
348
+ /**
349
+ * 记录终止助手消息给出的权威 Turn 结果。
350
+ *
351
+ * handleEvent 处理非 toolUse 的助手 message_end 时调用它,Runtime 用它推进提交终态。
352
+ * 工具调用消息不是 Turn 终点,因此不能在 stopReason 为 toolUse 时调用。
353
+ */
354
+ readonly onTerminal: (
355
+ terminal: PiTurnTerminalIntent,
356
+ ) => void | Promise<void>;
357
+ }
358
+
359
+ export type PiCanonicalMessageCommit =
360
+ | {
361
+ readonly kind: "append-user";
362
+ readonly id: string;
363
+ readonly message: UserMessage;
364
+ }
365
+ | {
366
+ readonly kind: "commit-turn";
367
+ readonly ordinal: number;
368
+ readonly message: AssistantMessage | ToolResultMessage;
369
+ };
370
+
371
+ export interface PiTurnTerminalIntent {
372
+ readonly outcome: "succeeded" | "failed" | "aborted";
373
+ readonly message?: string;
374
+ }
375
+
376
+ export interface PiPreparedTurnRunOptions {
377
+ readonly signal?: AbortSignal;
378
+ }
379
+
380
+ // #endregion
381
+
382
+ // #region Durable prepared-Turn execution
383
+
384
+ /**
385
+ * 执行一个 Submission 的 Pinned Runtime,同时把持久化职责交给 Runtime。
386
+ *
387
+ * @remarks
388
+ * PiRuntimeAdapter 为实时执行、恢复和审批续跑创建本类,调用方随后使用 run()、retryTool()、steer() 或 abort()。
389
+ * 构造时会核对 Prepared Runtime 的归属和 revision descriptor,避免执行过程悄悄切换到新的提示词、模型或 Tool candidate 集合。
390
+ * 核心术语沿用 `./index.ts` 的统一定义。
391
+ */
392
+ export class PreparedPiTurnAdapter {
393
+ private readonly turn: PiTurnAdapter;
394
+ private readonly candidates: readonly PiToolCandidate[];
395
+ private assistantOrdinal: number;
396
+ private readonly encoder: PiChunkEncoder;
397
+ private readonly steerMessageIds: string[] = [];
398
+ private terminalIntent?: PiTurnTerminalIntent;
399
+
400
+ /**
401
+ * 把 Prepared Runtime 和 Submission 持久化端口绑定成一个可执行 Turn。
402
+ *
403
+ * @remarks
404
+ * PiRuntimeAdapter.createTurn 是预期调用方,它会传入私有归属令牌和已配置的模型注册表。
405
+ * 每个 Tool candidate 会先被包装,确保平台门禁、已有结算、审批和不确定重试检查都早于原始 execute 函数。
406
+ */
407
+ constructor(
408
+ options: CreatePreparedPiTurnOptions,
409
+ dependencies: {
410
+ readonly owner: object;
411
+ readonly models: Models;
412
+ },
413
+ ) {
414
+ const { state, descriptor } = readPinnedPiRuntime(
415
+ options.prepared,
416
+ dependencies.owner,
417
+ options.pinnedDescriptor,
418
+ options.baseRevision,
419
+ );
420
+ this.assistantOrdinal = options.submission.assistantOrdinal;
421
+ this.encoder = new PiChunkEncoder({
422
+ messageId: options.submission.messageId,
423
+ startedAt: options.submission.startedAt,
424
+ });
425
+ this.candidates = state.candidates.map((candidate) => ({
426
+ ...candidate,
427
+ tool: {
428
+ ...candidate.tool,
429
+ // 只在 Runtime 的持久化和策略门禁都放行后执行一个 Tool candidate。
430
+ // 实时运行由 PiCore 调用它,恢复或审批续跑则由 retryTool 进入同一路径。
431
+ // 顺序不能随便调整:已有结果优先,审批可能生成结果,不确定的非幂等工作不能重放。
432
+ execute: async (toolCallId, input, signal, onUpdate) => {
433
+ await state.snapshot.bindings.platform.gateTool?.({
434
+ toolCallId,
435
+ toolName: candidate.tool.name,
436
+ input,
437
+ requiredExecutionLevel: candidate.requiredExecutionLevel,
438
+ signal: signal ?? new AbortController().signal,
439
+ });
440
+ const settled = options.durability.findToolSettlement(toolCallId);
441
+ if (settled) {
442
+ if (settled.isError) {
443
+ const text = settled.result.content
444
+ .flatMap((part) => part.type === "text" ? [part.text] : [])
445
+ .join("");
446
+ throw new Error(text || "Tool execution failed");
447
+ }
448
+ return settled.result;
449
+ }
450
+ if (requiresPiToolApproval({
451
+ executionLevel: descriptor.executionLevel,
452
+ requiredExecutionLevel: candidate.requiredExecutionLevel,
453
+ })) {
454
+ const approval = parkPiToolApproval({
455
+ executionId: `${options.submission.id}:${toolCallId}`,
456
+ requestId: options.submission.requestId,
457
+ source: candidate.source ?? "action",
458
+ toolCallId,
459
+ toolName: candidate.tool.name,
460
+ summary:
461
+ candidate.summary ??
462
+ candidate.tool.label ??
463
+ candidate.tool.name,
464
+ executionLevel: descriptor.executionLevel,
465
+ requiredExecutionLevel: candidate.requiredExecutionLevel,
466
+ inputJson: JSON.stringify(input),
467
+ createdAt: Date.now(),
468
+ }).approval;
469
+ await options.durability.requestToolApproval(
470
+ approval,
471
+ signal,
472
+ () => this.publishChunks(
473
+ this.encoder.approvalRequested(
474
+ toolCallId,
475
+ approval.executionId,
476
+ ),
477
+ ),
478
+ );
479
+ const approvalSettlement = options.durability
480
+ .findToolSettlement(toolCallId);
481
+ await this.publishChunks(this.encoder.approvalResponded(
482
+ toolCallId,
483
+ approval.executionId,
484
+ !approvalSettlement?.isError,
485
+ approvalSettlement?.isError
486
+ ? approvalSettlement.result.content
487
+ .flatMap((part) => part.type === "text" ? [part.text] : [])
488
+ .join("") || "Tool execution denied"
489
+ : undefined,
490
+ ));
491
+ }
492
+ const afterApproval = options.durability
493
+ .findToolSettlement(toolCallId);
494
+ if (afterApproval) return afterApproval.result;
495
+ const retry = piToolRetryPolicy(candidate);
496
+ const firstAttempt = options.durability.appendToolInput({
497
+ toolCallId,
498
+ toolName: candidate.tool.name,
499
+ input,
500
+ retry,
501
+ });
502
+ if (!firstAttempt && retry === "non-idempotent") {
503
+ throw new Error(
504
+ `Non-idempotent Tool outcome is uncertain after recovery: ${candidate.tool.name}`,
505
+ );
506
+ }
507
+ return candidate.tool.execute(
508
+ toolCallId,
509
+ input,
510
+ signal,
511
+ onUpdate,
512
+ );
513
+ },
514
+ },
515
+ }));
516
+ this.turn = new PiTurnAdapter({
517
+ pi: {
518
+ model: state.snapshot.pi.model,
519
+ thinkingLevel: state.snapshot.pi.thinkingLevel,
520
+ },
521
+ models: dependencies.models,
522
+ apiKey: resolvePiApiKey(
523
+ state.snapshot.bindings.provider,
524
+ state.snapshot.pi.model.id,
525
+ ),
526
+ canonicalMessages: options.canonicalMessages,
527
+ denyTools: descriptor.deny,
528
+ settle: options.durability.settleTool,
529
+ onToolTelemetry: options.onToolTelemetry,
530
+ transformContext: options.transformContext,
531
+ });
532
+ this.systemPrompt = descriptor.systemPrompt;
533
+ this.options = options;
534
+ }
535
+
536
+ private readonly systemPrompt: string;
537
+ private readonly options: CreatePreparedPiTurnOptions;
538
+
539
+ /**
540
+ * 请求取消这个 Turn。
541
+ *
542
+ * @remarks
543
+ * 活动提交被中止时 Runtime 会调用它,包括仍在异步启动的阶段。
544
+ * 转交给 PiTurnAdapter 可保留启动前取消标记;run() 之前还没有可直接调用的 PiCore。
545
+ */
546
+ abort(): void {
547
+ this.turn.abort();
548
+ }
549
+
550
+ /**
551
+ * 把一条用户消息加入当前 Turn。
552
+ *
553
+ * @remarks
554
+ * 当前提交仍活动时收到消息,Runtime 的扩展宿主会调用它。
555
+ * 转交后,启动前缓冲和 PiCore 转向队列共用一条路径;只有没有活动 Turn 时调用方才应直接追加 transcript。
556
+ */
557
+ steer(
558
+ message: UserMessage,
559
+ messageId: string = crypto.randomUUID(),
560
+ ): void {
561
+ this.steerMessageIds.push(messageId);
562
+ this.turn.steer(message);
563
+ }
564
+
565
+ /**
566
+ * 按记录的名称重新执行 Pinned Runtime 中的 Tool candidate。
567
+ *
568
+ * @remarks
569
+ * recovery effect 和审批通过后的续跑会传入原始调用标识与输入;返回 false 表示 Tool candidate 不存在或已被拒绝。
570
+ * 先查 Pinned Runtime 的 Tool candidate 再走正常编译器,可防止恢复绕过授权、治理或持久化结算。
571
+ */
572
+ async retryTool(request: {
573
+ readonly toolName: string;
574
+ readonly toolCallId: string;
575
+ readonly input: unknown;
576
+ }): Promise<boolean> {
577
+ const candidate = this.candidates.find(
578
+ (item) => item.tool.name === request.toolName,
579
+ );
580
+ return candidate
581
+ ? this.turn.retryTool(
582
+ candidate,
583
+ request.toolCallId,
584
+ request.input,
585
+ )
586
+ : false;
587
+ }
588
+
589
+ /**
590
+ * 运行这个 Turn,直到 PiCore 和所有等待中的事件回调结束。
591
+ *
592
+ * @remarks
593
+ * Submission 首次执行和续跑时,Runtime 都会在可恢复聊天任务中调用它。
594
+ * 适配器只传入 Pinned Runtime 的 system prompt 和 Tool candidate,再让所有事件经过 handleEvent,以保持 transcript 与流状态的顺序。
595
+ */
596
+ async run(
597
+ options: PiPreparedTurnRunOptions,
598
+ ): Promise<void> {
599
+ this.terminalIntent = undefined;
600
+ await this.turn.run(
601
+ {
602
+ systemPrompt: this.systemPrompt,
603
+ tools: this.candidates,
604
+ signal: options.signal,
605
+ },
606
+ (event) => this.handleEvent(event),
607
+ );
608
+ if (this.terminalIntent) {
609
+ await this.options.onTerminal(this.terminalIntent);
610
+ }
611
+ }
612
+
613
+ // 提交完成的 Pi 消息、推导终态意图,并把事件投影给可恢复流。
614
+ // PiTurnAdapter 会对每个 AgentEvent 调用它,PiCore 会等待该 Promise 后再越过订阅事件屏障。
615
+ // canonical message 先于流投影提交,恢复才不会依赖仅供浏览器消费的记录;调整顺序必须复核 recovery effect。
616
+ private async handleEvent(event: AgentEvent): Promise<void> {
617
+ let projectedEvent = event;
618
+ if (event.type === "message_end") {
619
+ const message = event.message;
620
+ if (message.role === "user") {
621
+ await this.options.onCanonicalMessage({
622
+ kind: "append-user",
623
+ id: this.steerMessageIds.shift() ?? crypto.randomUUID(),
624
+ message,
625
+ });
626
+ } else if (
627
+ message.role === "assistant" ||
628
+ message.role === "toolResult"
629
+ ) {
630
+ const abortReason = this.options.abortReason?.();
631
+ const authoritativeMessage =
632
+ message.role === "assistant" &&
633
+ message.stopReason !== "toolUse" &&
634
+ abortReason
635
+ ? {
636
+ ...message,
637
+ content: [],
638
+ stopReason: "aborted" as const,
639
+ errorMessage: abortReason,
640
+ }
641
+ : message;
642
+ if (authoritativeMessage.role === "assistant") {
643
+ this.assistantOrdinal += 1;
644
+ }
645
+ await this.options.onCanonicalMessage({
646
+ kind: "commit-turn",
647
+ ordinal: this.assistantOrdinal,
648
+ message: authoritativeMessage,
649
+ });
650
+ projectedEvent = {
651
+ ...event,
652
+ message: authoritativeMessage,
653
+ };
654
+ if (
655
+ authoritativeMessage.role === "assistant" &&
656
+ authoritativeMessage.stopReason !== "toolUse"
657
+ ) {
658
+ const terminal = classifyPiStopReason(
659
+ authoritativeMessage.stopReason,
660
+ );
661
+ this.terminalIntent = {
662
+ outcome: terminal.outcome,
663
+ ...(authoritativeMessage.errorMessage
664
+ ? { message: authoritativeMessage.errorMessage }
665
+ : terminal.message
666
+ ? { message: terminal.message }
667
+ : {}),
668
+ };
669
+ }
670
+ }
671
+ }
672
+
673
+ await this.publishChunks(this.encoder.encode(projectedEvent));
674
+ }
675
+
676
+ private async publishChunks(chunks: readonly UIMessageChunk[]): Promise<void> {
677
+ for (const chunk of chunks) {
678
+ await this.options.onRecord(chunk);
679
+ }
680
+ }
681
+ }
682
+
683
+ // #endregion