@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
package/src/runtime.ts ADDED
@@ -0,0 +1,2880 @@
1
+ import type { Connection } from "agents";
2
+ import {
3
+ MessageType,
4
+ clearChatTerminal,
5
+ parseProtocolMessage,
6
+ recordChatTerminal,
7
+ sendIfOpen,
8
+ } from "agents/chat";
9
+ import { RecoverableChatAgent } from "./kernel/recoverable-chat-agent";
10
+ import {
11
+ ApprovalLifecycle,
12
+ type ApprovalContinuationData,
13
+ } from "./kernel/approval-lifecycle";
14
+ import {
15
+ SubmissionLifecycle,
16
+ SubmissionQueueFullError,
17
+ type SubmissionInput,
18
+ type SubmissionStore,
19
+ type SubmissionHandle,
20
+ } from "./kernel/submission-lifecycle";
21
+ import {
22
+ USER_STOP_REASON,
23
+ type ApprovalDecision,
24
+ type ApprovalReceipt,
25
+ type MessageDelivery,
26
+ type MessageDispatchReceipt,
27
+ type SubmissionReceipt,
28
+ } from "./kernel/receipts";
29
+ import type { ExecutionLevel } from "./lib/execution-level";
30
+ import type {
31
+ RuntimeExtensionPermissions,
32
+ } from "./kernel/extensions";
33
+ import type {
34
+ RuntimeActivity,
35
+ RuntimeLoadPhase,
36
+ RuntimeLoadState,
37
+ RuntimeState,
38
+ RuntimeTurnState,
39
+ } from "./kernel/state";
40
+ import {
41
+ initializeRuntimeConfig,
42
+ type AgentConfig,
43
+ type RuntimeSnapshot,
44
+ } from "./plugins";
45
+ import { connectConfiguredMcpServers } from "./lib/mcp";
46
+ import { installConsoleSink } from "./lib/telemetry-dev";
47
+ import {
48
+ PiRuntimeAdapter,
49
+ type PiCanonicalUserInput,
50
+ type PiCanonicalTranscriptSnapshot,
51
+ type PiChatRecoveryData,
52
+ type UIChatRequestBody,
53
+ type PiDurableMutation,
54
+ type PiRecoveryCommand,
55
+ type PiRecoveryDecision,
56
+ type PiRuntimeTranscript,
57
+ type PiToolInputRecord,
58
+ type PiStoredToolSettlement,
59
+ type PiToolSettlement,
60
+ type PreparedPiRuntime,
61
+ type PreparedPiTurnAdapter,
62
+ } from "./pi/runtime-adapter";
63
+ import type { UIMessage, UIMessageChunk } from "ai";
64
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
65
+ import type {
66
+ RuntimeModelUsageEvent,
67
+ RuntimeToolSettlementEvent,
68
+ } from "./kernel/bindings";
69
+ import {
70
+ isTerminalSubmissionStatus,
71
+ RuntimeDatabase,
72
+ type SubmissionStatus,
73
+ } from "./db/index";
74
+ import {
75
+ TemporaryAgentCoordinator,
76
+ type TemporaryAgentApprovalDecision,
77
+ type TemporaryAgentApprovalRequest,
78
+ type TemporaryAgentExecutor,
79
+ type TemporaryAgentRequest,
80
+ type TemporaryAgentRunContext,
81
+ } from "./layers/orchestration/temporary-agent/core";
82
+
83
+ /**
84
+ * 本文件负责把 Cloudflare Agent 的通信与持久化能力接到 Pi Turn。
85
+ *
86
+ * @remarks
87
+ * 核心术语见包入口 `index.ts`。
88
+ */
89
+
90
+ // #region Runtime 内部数据与基础工具
91
+
92
+ const SCHEDULED_STABLE_TIMEOUT_MS = 30_000;
93
+ const TURN_EVENT_RETRY_SECONDS = 10;
94
+
95
+ type RuntimeEventOutboxPayload =
96
+ | { readonly type: "model-usage"; readonly event: RuntimeModelUsageEvent }
97
+ | {
98
+ readonly type: "tool-settlement";
99
+ readonly event: RuntimeToolSettlementEvent;
100
+ };
101
+ interface StoredSubmission extends SubmissionReceipt {
102
+ status: SubmissionStatus;
103
+ requestId: string;
104
+ idempotencyKey?: string;
105
+ assemblyRevision: string;
106
+ assemblyDescriptor: string;
107
+ assistantMessageId: string;
108
+ abortReason?: string;
109
+ queuedInputJson?: string | null;
110
+ queuedUiMessageJson?: string | null;
111
+ userMessageId?: string | null;
112
+ regenerateMessageId?: string | null;
113
+ }
114
+
115
+ interface SubmitMessageOptions {
116
+ requestId: string;
117
+ idempotencyKey?: string;
118
+ userMessageId?: string;
119
+ userMessage?: UIMessage & { role: "user" };
120
+ regenerate?: boolean;
121
+ }
122
+
123
+ interface ActiveTurn {
124
+ submissionId: string;
125
+ requestId: string;
126
+ messageId: string;
127
+ startedAt: number;
128
+ continuation: boolean;
129
+ /** Execution adapter — use abort()/steer() instead of touching Pi directly. */
130
+ agent: PreparedPiTurnAdapter;
131
+ }
132
+
133
+ // 作用:把任意异常整理成可以持久化或发给客户端的文字。
134
+ // 调用:Turn 执行、恢复和业务投影捕获 `unknown` 异常时调用。
135
+ // 原因:错误边界不能假定抛出值一定是 `Error`。
136
+ function errorText(error: unknown): string {
137
+ return error instanceof Error ? error.message : String(error);
138
+ }
139
+
140
+ // 作用:用同一种 JSON 序列化方式生成持久化或传输文本。
141
+ // 调用:本文件写里程碑、WebSocket 帧和比较投影时调用。
142
+ // 原因:收口序列化入口,避免同一数据在不同路径产生不同文本。
143
+ function json(value: unknown): string {
144
+ return JSON.stringify(value);
145
+ }
146
+
147
+ function assistantUsageEvent(
148
+ eventId: string,
149
+ submissionId: string,
150
+ message: AssistantMessage,
151
+ ): RuntimeModelUsageEvent {
152
+ return {
153
+ eventId,
154
+ submissionId,
155
+ kind: "assistant",
156
+ api: message.api,
157
+ provider: message.provider,
158
+ model: message.model,
159
+ ...(message.responseModel ? { responseModel: message.responseModel } : {}),
160
+ stopReason: message.stopReason,
161
+ usage: message.usage,
162
+ };
163
+ }
164
+
165
+ // 作用:把用户消息内容变成可比较的稳定形状。
166
+ // 调用:重新生成回答时,提交入口用它核对客户端与已存用户消息。
167
+ // 原因:字符串和分段内容必须先归一化,否则语义相同的消息会被误判为不同。
168
+ function userContentKey(message: PiCanonicalUserInput): string {
169
+ return json(
170
+ typeof message.content === "string"
171
+ ? [{ type: "text", text: message.content }]
172
+ : message.content,
173
+ );
174
+ }
175
+
176
+ function submissionNeedsInput(
177
+ messages: readonly UIMessage[],
178
+ assistantMessageId: string,
179
+ ): boolean {
180
+ const assistant = messages.find(
181
+ (message) =>
182
+ message.id === assistantMessageId && message.role === "assistant",
183
+ );
184
+ return assistant?.parts.some(
185
+ (part) =>
186
+ part.type === "dynamic-tool" &&
187
+ part.toolName === "ask_user" &&
188
+ part.state === "output-available",
189
+ ) ?? false;
190
+ }
191
+
192
+ interface PendingTemporaryAgentApproval {
193
+ receipt: ApprovalReceipt;
194
+ resolve(decision: TemporaryAgentApprovalDecision): void;
195
+ }
196
+
197
+ // #endregion
198
+
199
+ /**
200
+ * 这是 UniversalAgent 唯一的生产 Runtime Kernel。
201
+ *
202
+ * @remarks
203
+ * Cloudflare Agent 负责 Durable Object 生命周期、SQLite、facet、
204
+ * WebSocket、定时任务和 fiber。
205
+ *
206
+ * Pi 负责模型与 Tool 循环和 canonical transcript。
207
+ *
208
+ * 生产 Agent 由 `defineRuntimeAgent` 生成子类,应用不直接实例化本类。
209
+ */
210
+ export abstract class AgentRuntimeKernel<
211
+ Env extends Cloudflare.Env = Cloudflare.Env,
212
+ > extends RecoverableChatAgent<Env, RuntimeState, PiChatRecoveryData> {
213
+ initialState: RuntimeState = {
214
+ runtimeLoad: { status: "idle", available: false },
215
+ };
216
+
217
+ protected abstract ensureRuntimeReady(): Promise<void>;
218
+
219
+ private runtimeSnapshot?: RuntimeSnapshot;
220
+ private runtimeRevision?: string;
221
+ private runtimePi?: PreparedPiRuntime;
222
+ private piAdapter?: PiRuntimeAdapter;
223
+ private readonly transcript: PiRuntimeTranscript;
224
+ private readonly submissions: SubmissionLifecycle<
225
+ StoredSubmission,
226
+ ActiveTurn
227
+ >;
228
+ private readonly approvals: ApprovalLifecycle<StoredSubmission>;
229
+ private readonly streamBySubmission = new Map<string, string>();
230
+ private db!: RuntimeDatabase;
231
+ private readonly temporaryAgents = new TemporaryAgentCoordinator();
232
+ private readonly temporaryAgentApprovals =
233
+ new Map<string, PendingTemporaryAgentApproval>();
234
+
235
+ // #region 构造、装配与启动
236
+
237
+ // 作用:返回当前 Kernel 唯一的 Pi 适配器。
238
+ // 调用:构造和后续所有 Pi 装配、Turn 与恢复路径都通过它访问。
239
+ // 原因:允许构造时注入适配器,同时为未注入场景保留惰性单例。
240
+ private get pi(): PiRuntimeAdapter {
241
+ return this.piAdapter ??= new PiRuntimeAdapter();
242
+ }
243
+
244
+ /**
245
+ * 为一个 Cloudflare Durable Object 实例建立持久化与执行协作器。
246
+ *
247
+ * @remarks
248
+ * Cloudflare Runtime 创建由 `defineRuntimeAgent` 生成的类时,间接调用。
249
+ *
250
+ * 数据库 schema、Submission、Transcript 和 Approval 在同一构造路径绑定,
251
+ * 避免它们引用不同的事务边界或 Pi 适配器。
252
+ */
253
+ constructor(
254
+ ctx: DurableObjectState,
255
+ env: Env,
256
+ pi: PiRuntimeAdapter = new PiRuntimeAdapter(),
257
+ ) {
258
+ super(ctx, env, {
259
+ fiberPrefix: "__cf_internal_pi_turn:",
260
+ snapshotKey: "__cfPiFiberSnapshot",
261
+ snapshotKind: "pi-turn",
262
+ });
263
+ this.piAdapter = pi;
264
+ this.db = new RuntimeDatabase(
265
+ this.sql.bind(this),
266
+ (fn) => this.ctx.storage.transactionSync(fn),
267
+ );
268
+ this.db.initializeSchema();
269
+ const submissionStore: SubmissionStore<StoredSubmission> = {
270
+ transaction: (run) => this.db.transaction(run),
271
+ find: (submissionId) => this.readSubmission(submissionId),
272
+ findByRequestId: (requestId) =>
273
+ this.findSubmissionByRequest(requestId),
274
+ findByIdempotencyKey: (key) => this.findSubmissionByKey(key),
275
+ countUnfinished: () =>
276
+ this.db.submissions.countUnfinished(),
277
+ countPending: () => this.db.submissions.countPending(),
278
+ findRunning: () =>
279
+ this.db.submissions.findRunning() as StoredSubmission | null,
280
+ findNextPending: () =>
281
+ this.db.submissions.findNextPending() as StoredSubmission | null,
282
+ updateAbortReason: (submissionId, reason) =>
283
+ this.db.submissions.updateAbortReason(submissionId, reason),
284
+ };
285
+ this.submissions = new SubmissionLifecycle({
286
+ store: submissionStore,
287
+ clearTerminal: () => clearChatTerminal(this.ctx.storage),
288
+ execute: (submissionId, recovery) =>
289
+ this.executeSubmission(submissionId, recovery),
290
+ appendAbortIntent: (submission, reason) =>
291
+ this.appendTerminalIntent(submission, "aborted", reason),
292
+ commitTerminal: (submission, outcome, message) =>
293
+ this.commitTerminalOutcome(submission, outcome, message),
294
+ abortActive: (turn) => turn.agent.abort(),
295
+ });
296
+ this.transcript = this.pi.createTranscript({
297
+ sql: this.sql.bind(this),
298
+ durability: {
299
+ transaction: (run) => this.db.transaction(run),
300
+ upsertUserSidecar: (id, body) =>
301
+ this.db.messageUi.upsert(id, body),
302
+ readUserSidecar: (id) => this.db.messageUi.get(id),
303
+ clearUserSidecars: () => this.db.messageUi.clear(),
304
+ listMessageSubmissionLinks: () =>
305
+ this.db.submissions.listMessageSubmissionLinks(),
306
+ findSubmissionProjection: (submissionId) =>
307
+ this.readSubmission(submissionId),
308
+ listApprovalViews: (submissionId) =>
309
+ this.db.approvals.listApprovalViews(submissionId),
310
+ },
311
+ hasActiveTurn: () => this.submissions.isBusy(),
312
+ });
313
+ this.approvals = new ApprovalLifecycle({
314
+ db: this.db,
315
+ pi: this.pi,
316
+ findSubmission: (submissionId) =>
317
+ this.readSubmission(submissionId),
318
+ applyRecoveryMutations: (submission, mutations) =>
319
+ this.applyPiRecoveryMutations(submission, mutations),
320
+ materializeRecoveredToolResults: (submission) =>
321
+ this.materializeRecoveredToolResults(submission),
322
+ scheduleContinuation: async (data) => {
323
+ await this.schedule(
324
+ 0,
325
+ "_piApprovalContinuation",
326
+ data,
327
+ { idempotent: true },
328
+ );
329
+ },
330
+ onApprovalsChanged: () => this.broadcastApprovals(),
331
+ });
332
+ }
333
+
334
+ // 作用:把通用聊天恢复协议接到本 Runtime 的 Submission 和 Pi 里程碑。
335
+ // 调用:`RecoverableChatAgent` 第一次需要分类或重试中断 Turn 时惰性调用。
336
+ // 原因:传输层不应认识 Pi 数据库,所以用窄端口隔开通用恢复与领域决策。
337
+ protected createRecoveryPort() {
338
+ return this.pi.createRecovery({
339
+ readRecoveryState: (requestId) => {
340
+ const submission = this.findSubmissionByRequest(requestId);
341
+ if (!submission) return null;
342
+ return {
343
+ submissionId: submission.submissionId,
344
+ requestId: submission.requestId,
345
+ terminal: isTerminalSubmissionStatus(submission.status),
346
+ milestoneBodies: this.recoveryMilestoneBodies(
347
+ submission.submissionId,
348
+ ),
349
+ settledToolCount: this.db.settlements.countForSubmission(
350
+ submission.submissionId,
351
+ ),
352
+ };
353
+ },
354
+ retryTurn: async (submissionId) => {
355
+ const receipt = await this.submissions.recover(submissionId);
356
+ return receipt.status === "completed"
357
+ ? { status: "completed" as const }
358
+ : {
359
+ status: "failed" as const,
360
+ error:
361
+ receipt.error ??
362
+ `Pi recovery ended with status ${receipt.status}`,
363
+ };
364
+ },
365
+ failTurn: async (submissionId, message) => {
366
+ const submission = this.readSubmission(submissionId);
367
+ if (submission) {
368
+ await this.submissions.finish(submission, "failed", message);
369
+ }
370
+ },
371
+ hasPendingInteraction: () => this.approvals.countPending() > 0,
372
+ });
373
+ }
374
+
375
+ // 作用:准备并原子切换一份新的 Runtime 配置。
376
+ // 调用:生成 Agent 在首次启动或显式重载 Runtime key 时调用。
377
+ // 原因:先完整 prepare 再替换 Snapshot,且活跃 Turn 期间禁止换 revision,可避免半装配和恢复时能力漂移。
378
+ protected async initConfig(config: AgentConfig): Promise<void> {
379
+ const previous = this.runtimeSnapshot;
380
+ let next: RuntimeSnapshot | undefined;
381
+ this.advanceRuntimeLoad("plugins");
382
+ await initializeRuntimeConfig(config, (snapshot) => {
383
+ next = snapshot;
384
+ });
385
+ if (!next) throw new Error("Runtime assembly did not produce a snapshot");
386
+ const candidateSnapshot = next;
387
+
388
+ this.advanceRuntimeLoad("mcp");
389
+ await connectConfiguredMcpServers(
390
+ this,
391
+ candidateSnapshot.profile.mcpServers,
392
+ );
393
+ this.advanceRuntimeLoad("pi");
394
+ const prepared = await this.pi.prepare({
395
+ snapshot: candidateSnapshot,
396
+ mcpHost: this,
397
+ createExtensionHostBinding: (permissions, ownContextLabels) =>
398
+ this.createExtensionHostBinding(
399
+ permissions,
400
+ ownContextLabels,
401
+ ),
402
+ });
403
+ const revision = await this.hash(prepared.revisionDescriptor);
404
+ if (
405
+ previous &&
406
+ revision !== this.runtimeRevision &&
407
+ this.submissions.isBusy()
408
+ ) {
409
+ throw new Error(
410
+ "Cannot reload Runtime while a revision-pinned Pi Turn is active",
411
+ );
412
+ }
413
+ this.pi.activate(candidateSnapshot);
414
+ this.runtimeSnapshot = candidateSnapshot;
415
+ this.runtimeRevision = revision;
416
+ this.runtimePi = prepared;
417
+
418
+ if (candidateSnapshot.bindings.platform.telemetryConsole) {
419
+ installConsoleSink();
420
+ }
421
+ const degradations = prepared.degradations;
422
+ if (degradations.length > 0) {
423
+ console.warn(
424
+ "[runtime-load:degraded]",
425
+ json({ degradations }),
426
+ );
427
+ }
428
+ }
429
+
430
+ // 作用:为某个已准入 Submission 建立一次 Pi Turn 执行适配器。
431
+ // 调用:新 Turn、恢复 Turn 和审批后重试 Tool 都在开始 Pi 执行前调用。
432
+ // 原因:在这里一次性固定 revision、持久化回调与终态回调,防止各执行路径绕过同一耐久性规则。
433
+ private createSubmissionExecutionAdapter(
434
+ submission: StoredSubmission,
435
+ output: {
436
+ readonly startedAt?: number;
437
+ readonly continuation?: boolean;
438
+ readonly assistantOrdinal?: number;
439
+ readonly onRecord?: (
440
+ chunk: UIMessageChunk,
441
+ ) => void | Promise<void>;
442
+ readonly onTerminal?: (
443
+ terminal: {
444
+ outcome: "succeeded" | "failed" | "aborted";
445
+ message?: string;
446
+ },
447
+ ) => void | Promise<void>;
448
+ } = {},
449
+ ): PreparedPiTurnAdapter {
450
+ const startedAt = output.startedAt ?? Date.now();
451
+ return this.pi.createTurn({
452
+ prepared: this.preparedPi(),
453
+ baseRevision: this.baseRevision(),
454
+ pinnedDescriptor: submission.assemblyDescriptor,
455
+ submission: {
456
+ id: submission.submissionId,
457
+ requestId: submission.requestId,
458
+ messageId: submission.assistantMessageId,
459
+ startedAt,
460
+ continuation: output.continuation ?? false,
461
+ assistantOrdinal: output.assistantOrdinal ?? 0,
462
+ },
463
+ canonicalMessages: () => this.transcript.canonicalMessages(),
464
+ durability: {
465
+ findToolSettlement: (toolCallId) =>
466
+ this.readToolSettlement(submission.submissionId, toolCallId),
467
+ requestToolApproval: (approval, signal, onCreated) =>
468
+ this.approvals.request(
469
+ submission,
470
+ approval,
471
+ signal,
472
+ async (created) => {
473
+ await onCreated?.();
474
+ try {
475
+ await this.assembly().bindings.turnEvents?.onApproval?.({
476
+ submissionId: submission.submissionId,
477
+ approvalExecutionId: created.executionId,
478
+ });
479
+ } catch (error) {
480
+ console.warn(
481
+ "[runtime-approval-projection:degraded]",
482
+ json({ error: errorText(error) }),
483
+ );
484
+ }
485
+ },
486
+ ),
487
+ appendToolInput: (input) =>
488
+ this.appendToolInput(submission, input),
489
+ settleTool: (call) =>
490
+ this.settleTool(submission.submissionId, call),
491
+ },
492
+ onToolTelemetry: (event) => {
493
+ this._emit("ua:tool" as never, { ...event });
494
+ },
495
+ transformContext: (messages, signal) =>
496
+ this.transformPiContext(submission.submissionId, messages, signal),
497
+ abortReason: () =>
498
+ this.readSubmission(submission.submissionId)?.abortReason,
499
+ onRecord: output.onRecord ?? (() => undefined),
500
+ onCanonicalMessage: async (commit) => {
501
+ let consumedSteer = false;
502
+ this.db.transaction(() => {
503
+ if (commit.kind !== "append-user") {
504
+ const persisted = this.transcript.commitAdapterMessage(
505
+ submission.submissionId,
506
+ commit,
507
+ );
508
+ if (persisted.inserted && commit.message.role === "assistant") {
509
+ const event = assistantUsageEvent(
510
+ persisted.id,
511
+ submission.submissionId,
512
+ commit.message,
513
+ );
514
+ this.db.runtimeEvents.insert({
515
+ eventId: event.eventId,
516
+ body: json({ type: "model-usage", event }),
517
+ createdAt: commit.message.timestamp,
518
+ });
519
+ }
520
+ return;
521
+ }
522
+ const pending = this.db.steers.findByMessageId(commit.id);
523
+ if (!pending) {
524
+ this.transcript.commitAdapterMessage(
525
+ submission.submissionId,
526
+ commit,
527
+ );
528
+ return;
529
+ }
530
+ if (pending.submissionId !== submission.submissionId) {
531
+ throw new Error(
532
+ `Steer ${pending.steerId} belongs to another submission`,
533
+ );
534
+ }
535
+ const uiMessage = pending.uiMessageJson
536
+ ? JSON.parse(pending.uiMessageJson) as UIMessage & {
537
+ role: "user";
538
+ }
539
+ : undefined;
540
+ this.transcript.append(commit.id, commit.message, {
541
+ submissionId: submission.submissionId,
542
+ createdAt: commit.message.timestamp,
543
+ ...(uiMessage ? { userMessage: uiMessage } : {}),
544
+ });
545
+ this.db.steers.deleteByMessageId(commit.id);
546
+ consumedSteer = true;
547
+ });
548
+ await this.drainRuntimeEvents();
549
+ if (consumedSteer) await this.broadcastApprovals();
550
+ },
551
+ onTerminal: output.onTerminal ?? (() => undefined),
552
+ });
553
+ }
554
+
555
+ /**
556
+ * 在 Agent 实例启动或从休眠唤醒后恢复 Session 本地后台状态。
557
+ *
558
+ * @remarks
559
+ * Cloudflare Agents SDK 在任何 HTTP、WebSocket 或 RPC 调用前调用它。
560
+ *
561
+ * 先恢复审批续跑,再分类未完 Submission,
562
+ * 可避免把正在等待人的 Turn 当成普通中断直接重试。
563
+ *
564
+ * 待确认:Cloudflare 当前文档说 `DurableObjectState.waitUntil`
565
+ * 不会延长 Durable Object 生命;本文件仍在三处用它启动后台任务。
566
+ */
567
+ async onStart(): Promise<void> {
568
+ this.publishRuntimeLoad({ status: "idle", available: false });
569
+ if (this.db.runtimeEvents.hasPending()) {
570
+ this.ctx.waitUntil(
571
+ this.ensureRuntimeReady().then(() => this.drainRuntimeEvents()),
572
+ );
573
+ }
574
+ await this.broadcastApprovals();
575
+ await this.approvals.dispatchPendingContinuations();
576
+ const running = this.db.submissions.findRunning() as StoredSubmission | null;
577
+ if (running) {
578
+ const effect = this.decidePiRecovery(running).effect;
579
+ if (
580
+ effect.kind === "wait" &&
581
+ (effect.reason === "approval" ||
582
+ effect.reason === "uncertain-tool")
583
+ ) {
584
+ return;
585
+ }
586
+ }
587
+ this.ctx.waitUntil(
588
+ this.submissions.recoverHead().then(() => undefined),
589
+ );
590
+ }
591
+
592
+ private async drainRuntimeEvents(idempotentRetry = true): Promise<void> {
593
+ const turnEvents = this.runtimeSnapshot?.bindings.turnEvents;
594
+ if (!turnEvents) return;
595
+
596
+ let failed = false;
597
+ for (const row of this.db.runtimeEvents.listPending()) {
598
+ try {
599
+ const payload = JSON.parse(row.body) as RuntimeEventOutboxPayload;
600
+ if (payload.type === "model-usage") {
601
+ if (!turnEvents.onModelUsage) continue;
602
+ await turnEvents.onModelUsage(payload.event);
603
+ } else {
604
+ if (!turnEvents.onToolSettled) continue;
605
+ await turnEvents.onToolSettled(payload.event);
606
+ }
607
+ this.db.transaction(() =>
608
+ this.db.runtimeEvents.markDelivered(row.eventId, Date.now())
609
+ );
610
+ } catch (error) {
611
+ failed = true;
612
+ console.warn(
613
+ "[runtime-turn-event:degraded]",
614
+ json({ eventId: row.eventId, error: errorText(error) }),
615
+ );
616
+ }
617
+ }
618
+ if (!failed) return;
619
+
620
+ try {
621
+ await this.schedule(
622
+ TURN_EVENT_RETRY_SECONDS,
623
+ "_drainRuntimeTurnEvents",
624
+ undefined,
625
+ { idempotent: idempotentRetry },
626
+ );
627
+ } catch (error) {
628
+ console.warn(
629
+ "[runtime-turn-event-retry:degraded]",
630
+ json({ error: errorText(error) }),
631
+ );
632
+ }
633
+ }
634
+
635
+ async _drainRuntimeTurnEvents(): Promise<void> {
636
+ await this.ensureRuntimeReady();
637
+ await this.drainRuntimeEvents(false);
638
+ }
639
+
640
+ // 作用:重新读取当前 Snapshot 以刷新动态 Context 依赖。
641
+ // 调用:生成 Agent 的 `refreshMemoryContext` 在确保配置已加载后调用。
642
+ // 原因:统一经过 `assembly` 的已初始化检查,不再引入第二份配置状态。
643
+ async refreshContext(): Promise<void> {
644
+ this.assembly();
645
+ }
646
+
647
+ // 作用:在当前 Session 内启动一个临时 Agent 执行。
648
+ // 调用:临时 Agent Tool 通过 `RuntimeAgentConfigContext` 带着本次信号调用。
649
+ // 原因:协调器统一跟踪运行和取消,不把临时子执行塞进持久 Submission 模型。
650
+ protected runTemporaryAgent(
651
+ request: TemporaryAgentRequest,
652
+ context: TemporaryAgentRunContext,
653
+ execute: TemporaryAgentExecutor,
654
+ ): Promise<string> {
655
+ return this.temporaryAgents.run(request, execute, context.signal);
656
+ }
657
+
658
+ /**
659
+ * 登记一条临时 Agent 的 Tool 审批并等待用户决定。
660
+ *
661
+ * @remarks
662
+ * Temporary Agent Host 在子执行需要人工授权时调用。
663
+ *
664
+ * 它与持久 Pi Tool 审批共用同一客户端投影,但只保存本次执行的 Promise,
665
+ * 因为临时 Agent 本身不参与 Session Turn 恢复。
666
+ */
667
+ async requestTemporaryAgentApproval(
668
+ request: TemporaryAgentApprovalRequest,
669
+ ): Promise<TemporaryAgentApprovalDecision> {
670
+ if (this.temporaryAgentApprovals.has(request.executionId)) {
671
+ throw new Error(
672
+ `temporary agent approval already pending: ${request.executionId}`,
673
+ );
674
+ }
675
+ return new Promise((resolve, reject) => {
676
+ this.temporaryAgentApprovals.set(request.executionId, {
677
+ receipt: {
678
+ executionId: request.executionId,
679
+ source: "temporary-agent",
680
+ action: request.toolName,
681
+ summary: `${request.subagentName} requests ${request.toolName}`,
682
+ executionLevel: request.executionLevel,
683
+ requiredExecutionLevel: request.requiredExecutionLevel,
684
+ inputJson: JSON.stringify(request.input ?? null),
685
+ requestId: request.requestId,
686
+ },
687
+ resolve,
688
+ });
689
+ void this.broadcastApprovals().catch((error) => {
690
+ this.temporaryAgentApprovals.delete(request.executionId);
691
+ reject(error);
692
+ });
693
+ });
694
+ }
695
+
696
+ /**
697
+ * 取消一条仍在等待的临时 Agent 审批。
698
+ *
699
+ * @remarks
700
+ * Temporary Agent Host 在子执行停止或超时时调用。
701
+ *
702
+ * 先从内存表删除再解决 Promise,避免并发的批准请求再次命中已取消项。
703
+ */
704
+ async cancelTemporaryAgentApproval(
705
+ executionId: string,
706
+ reason = "temporary agent was cancelled",
707
+ ): Promise<{ ok: boolean }> {
708
+ const pending = this.temporaryAgentApprovals.get(executionId);
709
+ if (!pending) return { ok: false };
710
+ this.temporaryAgentApprovals.delete(executionId);
711
+ pending.resolve({ approved: false, reason });
712
+ await this.broadcastApprovals();
713
+ return { ok: true };
714
+ }
715
+
716
+ // 作用:返回已生效的 Runtime Snapshot。
717
+ // 调用:所有需要模型、工作区、扩展或投影绑定的运行时路径调用。
718
+ // 原因:未初始化时立即失败,比在深层路径触发空值更容易定位。
719
+ private assembly(): RuntimeSnapshot {
720
+ if (!this.runtimeSnapshot) {
721
+ throw new Error(
722
+ "AgentConfig must be initialized before using the Runtime",
723
+ );
724
+ }
725
+ return this.runtimeSnapshot;
726
+ }
727
+
728
+ // 作用:发布一次 Runtime 装载尝试的公开状态。
729
+ // 调用:生成 Agent 的首次加载/重载入口,以及本类的 Plugin、MCP、Pi 装配边界。
730
+ // 原因:复用 Agent state 的现有同步协议,让浏览器无需理解内部 Loader 实现。
731
+ protected publishRuntimeLoad(runtimeLoad: RuntimeLoadState): void {
732
+ this.setState({ ...this.state, runtimeLoad });
733
+ }
734
+
735
+ // 作用:开始一轮新的 Runtime 装载尝试。
736
+ // 调用:生成 Agent 在调用应用 createConfig 前调用。
737
+ // 原因:config 阶段可能包含身份、D1 和 Resource 解析,必须在首个慢请求前可见。
738
+ protected beginRuntimeLoad(): void {
739
+ const now = Date.now();
740
+ this.publishRuntimeLoad({
741
+ status: "loading",
742
+ phase: "config",
743
+ available: Boolean(this.runtimeSnapshot),
744
+ startedAt: now,
745
+ updatedAt: now,
746
+ });
747
+ }
748
+
749
+ // 作用:推进当前 Runtime 装载尝试的阶段。
750
+ // 调用:initConfig 在进入 Plugin、MCP 和 Pi 边界时调用。
751
+ // 原因:保持一个稳定的粗粒度协议,不向前端泄漏具体 Plugin 实现和并发细节。
752
+ private advanceRuntimeLoad(phase: RuntimeLoadPhase): void {
753
+ const current = this.state.runtimeLoad;
754
+ const now = Date.now();
755
+ this.publishRuntimeLoad({
756
+ status: "loading",
757
+ phase,
758
+ available: Boolean(this.runtimeSnapshot),
759
+ startedAt:
760
+ current?.status === "loading" ? current.startedAt : now,
761
+ updatedAt: now,
762
+ });
763
+ }
764
+
765
+ // 作用:把成功提交的 Runtime 装载尝试标记为可用。
766
+ // 调用:生成 Agent 在 initConfig 和 Runtime key 提交完成后调用。
767
+ // 原因:只有完整原子提交后才能向客户端承诺 ready。
768
+ protected completeRuntimeLoad(): void {
769
+ const current = this.state.runtimeLoad;
770
+ const now = Date.now();
771
+ this.publishRuntimeLoad({
772
+ status: "ready",
773
+ available: true,
774
+ startedAt:
775
+ current?.status === "loading" ? current.startedAt : now,
776
+ completedAt: now,
777
+ });
778
+ }
779
+
780
+ // 作用:记录 Runtime 装载失败,同时保留旧 Runtime 是否仍可用的信息。
781
+ // 调用:生成 Agent 收口 createConfig 或 initConfig 的异常时调用。
782
+ // 原因:前端需要状态但不应接收可能包含存储细节的底层错误文本。
783
+ protected failRuntimeLoad(): void {
784
+ const current = this.state.runtimeLoad;
785
+ const now = Date.now();
786
+ this.publishRuntimeLoad({
787
+ status: "error",
788
+ phase: current?.status === "loading" ? current.phase : "config",
789
+ available: Boolean(this.runtimeSnapshot),
790
+ startedAt:
791
+ current?.status === "loading" ? current.startedAt : now,
792
+ failedAt: now,
793
+ });
794
+ }
795
+
796
+ // 作用:为 Runtime Extension 创建只包含授权能力的 Worker 回环绑定。
797
+ // 调用:Pi 准备扩展时,按每个扩展的权限和 Context label 调用。
798
+ // 原因:`ctx.exports` 是 Cloudflare 提供的 Worker 内回环入口,在边界上裁剪权限可避免扩展接触整个 Agent。
799
+ private createExtensionHostBinding(
800
+ permissions: RuntimeExtensionPermissions,
801
+ ownContextLabels: readonly string[],
802
+ ): Fetcher {
803
+ const exports = this.ctx.exports as unknown as {
804
+ HostBridgeLoopback(input: {
805
+ props: {
806
+ agentClassName: string;
807
+ agentId: string;
808
+ permissions: RuntimeExtensionPermissions;
809
+ ownContextLabels: string[];
810
+ };
811
+ }): Fetcher;
812
+ };
813
+ return exports.HostBridgeLoopback({
814
+ props: {
815
+ agentClassName: this.constructor.name,
816
+ agentId: this.ctx.id.toString(),
817
+ permissions,
818
+ ownContextLabels: [...ownContextLabels],
819
+ },
820
+ });
821
+ }
822
+
823
+ // 作用:计算一段描述文本的 SHA-256 十六进制指纹。
824
+ // 调用:配置激活、Submission 准入和恢复校验在比较 revision 时调用。
825
+ // 原因:只持久化稳定短指纹,即可验证完整描述没有在 Turn 中途改变。
826
+ private async hash(value: string): Promise<string> {
827
+ const digest = await crypto.subtle.digest(
828
+ "SHA-256",
829
+ new TextEncoder().encode(value),
830
+ );
831
+ return [...new Uint8Array(digest)]
832
+ .map((byte) => byte.toString(16).padStart(2, "0"))
833
+ .join("");
834
+ }
835
+
836
+ // 作用:返回当前已激活配置的 revision。
837
+ // 调用:创建 Pi Turn 适配器时调用。
838
+ // 原因:把初始化不变式收口在一处,避免执行层带着空 revision 继续。
839
+ private baseRevision(): string {
840
+ if (!this.runtimeRevision) {
841
+ throw new Error("Runtime revision is not initialized");
842
+ }
843
+ return this.runtimeRevision;
844
+ }
845
+
846
+ // 作用:返回当前已准备好的 Pi Runtime。
847
+ // 调用:创建 Turn 适配器或准入固定装配时调用。
848
+ // 原因:明确区分“已有 Snapshot”和“Pi 已完成准备”,防止半初始化状态进入 Turn。
849
+ private preparedPi(): PreparedPiRuntime {
850
+ if (!this.runtimePi) {
851
+ throw new Error(
852
+ "Pi Runtime must be prepared before starting a Turn",
853
+ );
854
+ }
855
+ return this.runtimePi;
856
+ }
857
+
858
+ // #endregion
859
+
860
+ // #region Submission 准入与配置固定
861
+
862
+ // 作用:为新 Submission 生成包含当前 Context 的不可变装配描述。
863
+ // 调用:Submission 通过去重和忙碌检查后,在真正写入前调用。
864
+ // 原因:把动态 Context 和执行档位固定在准入时,恢复时才能按原配置继续。
865
+ private async admissionPin(): Promise<{
866
+ revision: string;
867
+ descriptor: string;
868
+ }> {
869
+ await this.ensureRuntimeReady();
870
+ await this.drainRuntimeEvents();
871
+ if (!this.runtimeRevision) {
872
+ throw new Error("Runtime revision is not initialized");
873
+ }
874
+ const pinned = await this.pi.pin({
875
+ prepared: this.preparedPi(),
876
+ baseRevision: this.runtimeRevision,
877
+ readExtensionContext: ({ label }) =>
878
+ this._hostGetContext(label),
879
+ });
880
+ if (pinned.degradations.length > 0) {
881
+ console.warn(
882
+ "[runtime-context:degraded]",
883
+ json({ degradations: pinned.degradations }),
884
+ );
885
+ }
886
+ return {
887
+ revision: await this.hash(pinned.descriptor),
888
+ descriptor: pinned.descriptor,
889
+ };
890
+ }
891
+
892
+ private async pinSubmissionAssembly(
893
+ submission: StoredSubmission,
894
+ ): Promise<StoredSubmission> {
895
+ if (submission.assemblyRevision && submission.assemblyDescriptor) {
896
+ return submission;
897
+ }
898
+ if (submission.assemblyRevision || submission.assemblyDescriptor) {
899
+ throw new Error("Submission assembly pin is incomplete");
900
+ }
901
+ const { revision, descriptor } = await this.admissionPin();
902
+ this.db.submissions.pinAssembly(
903
+ submission.submissionId,
904
+ revision,
905
+ descriptor,
906
+ );
907
+ const pinned = this.readSubmission(submission.submissionId);
908
+ if (!pinned?.assemblyRevision || !pinned.assemblyDescriptor) {
909
+ throw new Error("Submission ended before Runtime assembly was pinned");
910
+ }
911
+ return pinned;
912
+ }
913
+
914
+ // 作用:检查 Submission 保存的装配描述与 revision 仍然匹配。
915
+ // 调用:普通恢复和审批续跑在重建 Pi Turn 前调用。
916
+ // 原因:若持久描述已变,继续执行会把同一 Turn 切成两套能力语义。
917
+ private async assertPinnedSubmission(
918
+ submission: StoredSubmission,
919
+ ): Promise<void> {
920
+ if (
921
+ submission.assemblyRevision !==
922
+ await this.hash(submission.assemblyDescriptor)
923
+ ) {
924
+ throw new Error(
925
+ "Pinned Runtime revision is unavailable for Pi recovery",
926
+ );
927
+ }
928
+ }
929
+
930
+ // 作用:按当前内存预算和模型压缩 Pi 上下文。
931
+ // 调用:Pi Turn 适配器在发模型请求前通过 `transformContext` 回调。
932
+ // 原因:压缩必须使用本 Snapshot 的模型和密钥,不能脱离已固定的 Runtime 配置。
933
+ private async transformPiContext(
934
+ submissionId: string,
935
+ messages: Parameters<PiRuntimeTranscript["compactContext"]>[0],
936
+ signal?: AbortSignal,
937
+ ): ReturnType<PiRuntimeTranscript["compactContext"]> {
938
+ const snapshot = this.assembly();
939
+ const compacted = await this.transcript.compactContext(messages, {
940
+ compactAfterTokens:
941
+ snapshot.profile.memory.compactAfterTokens,
942
+ model: snapshot.pi.model,
943
+ apiKey: this.pi.resolveApiKey(
944
+ snapshot.bindings.provider,
945
+ snapshot.pi.model.id,
946
+ ),
947
+ signal,
948
+ submissionId,
949
+ onCompactionPersisted: (event) =>
950
+ this.db.runtimeEvents.insert({
951
+ eventId: event.eventId,
952
+ body: json({ type: "model-usage", event }),
953
+ createdAt: Date.now(),
954
+ }),
955
+ });
956
+ await this.drainRuntimeEvents();
957
+ return compacted;
958
+ }
959
+
960
+ // 作用:按 Submission ID 读取一条 Runtime 提交记录。
961
+ // 调用:执行、恢复、审批和终态路径需要当前持久状态时调用。
962
+ // 原因:在 Kernel 边界统一收窄为 `StoredSubmission`,避免上层分散重复转型。
963
+ private readSubmission(
964
+ submissionId: string,
965
+ ): StoredSubmission | null {
966
+ return this.db.submissions.find(submissionId) as StoredSubmission | null;
967
+ }
968
+
969
+ // 作用:按客户端 request ID 找到对应 Submission。
970
+ // 调用:聊天取消、恢复分类和提交去重时调用。
971
+ // 原因:request ID 是传输层身份,在此集中完成它到领域 Submission 的映射。
972
+ private findSubmissionByRequest(
973
+ requestId: string,
974
+ ): StoredSubmission | null {
975
+ return this.db.submissions.findByRequestId(requestId) as StoredSubmission | null;
976
+ }
977
+
978
+ // 作用:按业务幂等键找到已接收的 Submission。
979
+ // 调用:SubmissionLifecycle 在新提交准入前调用。
980
+ // 原因:去重必须查持久层,只看内存无法覆盖 Durable Object 重启。
981
+ private findSubmissionByKey(
982
+ idempotencyKey: string,
983
+ ): StoredSubmission | null {
984
+ return this.db.submissions.findByIdempotencyKey(idempotencyKey) as StoredSubmission | null;
985
+ }
986
+
987
+ // 作用:组装一个可由 SubmissionLifecycle 原子准入的用户消息。
988
+ // 调用:即时提交和等待稳定的定时提交共用。
989
+ // 原因:延迟真正写入到事务内,可让去重、忙碌检查、revision 固定和 transcript 更新一起成功或失败。
990
+ private submissionInput(
991
+ userMessage: PiCanonicalUserInput,
992
+ options: SubmitMessageOptions,
993
+ ): SubmissionInput<StoredSubmission> {
994
+ return {
995
+ requestId: options.requestId,
996
+ ...(options.idempotencyKey
997
+ ? { idempotencyKey: options.idempotencyKey }
998
+ : {}),
999
+ prepareAdmission: async () => {
1000
+ const regenerateEntry =
1001
+ options.regenerate && options.userMessageId
1002
+ ? await this.transcript.findUserMessage(options.userMessageId)
1003
+ : undefined;
1004
+ return () => {
1005
+ const submissionId = crypto.randomUUID();
1006
+ const assistantMessageId = crypto.randomUUID();
1007
+ const createdAt = userMessage.timestamp;
1008
+ const userMessageId =
1009
+ options.userMessageId ?? crypto.randomUUID();
1010
+ if (options.regenerate) {
1011
+ if (
1012
+ !regenerateEntry ||
1013
+ userContentKey(regenerateEntry) !==
1014
+ userContentKey(userMessage)
1015
+ ) {
1016
+ throw new Error(
1017
+ "Regenerate request must match an existing user message",
1018
+ );
1019
+ }
1020
+ }
1021
+ this.db.submissions.insert({
1022
+ submissionId,
1023
+ requestId: options.requestId,
1024
+ idempotencyKey: options.idempotencyKey ?? null,
1025
+ createdAt,
1026
+ assemblyRevision: "",
1027
+ assemblyDescriptor: "",
1028
+ assistantMessageId,
1029
+ queuedInputJson: json(userMessage),
1030
+ queuedUiMessageJson: options.userMessage
1031
+ ? json(options.userMessage)
1032
+ : null,
1033
+ userMessageId,
1034
+ regenerateMessageId: options.regenerate
1035
+ ? options.userMessageId ?? null
1036
+ : null,
1037
+ });
1038
+ return this.readSubmission(submissionId)!;
1039
+ };
1040
+ },
1041
+ };
1042
+ }
1043
+
1044
+ // 作用:立即尝试准入一条用户消息并返回执行句柄。
1045
+ // 调用:WebSocket 聊天、RPC prompt 和其他同步入口在收到消息时调用。
1046
+ // 原因:所有即时入口收口到同一 SubmissionLifecycle,才会共享去重和单 Turn 约束。
1047
+ private submitMessage(
1048
+ userMessage: PiCanonicalUserInput,
1049
+ options: SubmitMessageOptions,
1050
+ ): Promise<SubmissionHandle<StoredSubmission>> {
1051
+ return this.submitMessageAndBroadcast(userMessage, options);
1052
+ }
1053
+
1054
+ private async submitMessageAndBroadcast(
1055
+ userMessage: PiCanonicalUserInput,
1056
+ options: SubmitMessageOptions,
1057
+ ): Promise<SubmissionHandle<StoredSubmission>> {
1058
+ const submitted = await this.submissions.submit(
1059
+ this.submissionInput(userMessage, options),
1060
+ );
1061
+ await this.broadcastApprovals();
1062
+ return submitted;
1063
+ }
1064
+
1065
+ // 作用:等待 Runtime 稳定后再准入一条用户消息。
1066
+ // 调用:定时任务在不应打断正在运行的 Turn 时调用。
1067
+ // 原因:有界等待比无限排队更适合定时任务,超时可返回明确的 skipped 回执。
1068
+ private async submitMessageWhenStable(
1069
+ userMessage: PiCanonicalUserInput,
1070
+ options: SubmitMessageOptions,
1071
+ timeoutMs: number,
1072
+ ): Promise<SubmissionHandle<StoredSubmission> | null> {
1073
+ const submitted = await this.submissions.submitWhenStable(
1074
+ this.submissionInput(userMessage, options),
1075
+ timeoutMs,
1076
+ );
1077
+ if (submitted) await this.broadcastApprovals();
1078
+ return submitted;
1079
+ }
1080
+
1081
+ // #endregion
1082
+
1083
+ // #region 持久化、恢复与终态提交
1084
+
1085
+ // 作用:更新 Submission 状态并返回数据库中的最新记录。
1086
+ // 调用:终态提交在决定 completed、aborted 或 error 后调用。
1087
+ // 原因:完成时间只能跟终态一起写入,然后必须回读以防止返回过期对象。
1088
+ private updateSubmission(
1089
+ submissionId: string,
1090
+ status: SubmissionStatus,
1091
+ error?: string,
1092
+ ): StoredSubmission {
1093
+ const completedAt = isTerminalSubmissionStatus(status) ? Date.now() : null;
1094
+ this.db.submissions.updateTerminal(
1095
+ submissionId,
1096
+ status,
1097
+ error,
1098
+ completedAt ?? undefined,
1099
+ );
1100
+ const submission = this.readSubmission(submissionId);
1101
+ if (!submission) {
1102
+ throw new Error(`Unknown Pi submission: ${submissionId}`);
1103
+ }
1104
+ return submission;
1105
+ }
1106
+
1107
+ // 作用:读取某个 Submission 已持久的全部 Pi 恢复里程碑。
1108
+ // 调用:恢复决策器每次检查或应用命令前调用。
1109
+ // 原因:决策只依赖持久事实,重启后才会得到同样结果。
1110
+ private recoveryMilestoneBodies(submissionId: string): string[] {
1111
+ return this.db.milestones.listBodies(submissionId);
1112
+ }
1113
+
1114
+ // 作用:让 Pi 根据已持久里程碑决定下一个恢复动作。
1115
+ // 调用:Tool 输入与结果、审批、终态和唤醒恢复路径调用。
1116
+ // 原因:决策集中在 Pi 状态机,Kernel 只提供身份和事实,避免两份恢复规则漂移。
1117
+ private decidePiRecovery(
1118
+ submission: StoredSubmission,
1119
+ command: PiRecoveryCommand = { kind: "inspect" },
1120
+ now = Date.now(),
1121
+ ): PiRecoveryDecision {
1122
+ return this.pi.decideRecovery({
1123
+ milestoneBodies: this.recoveryMilestoneBodies(
1124
+ submission.submissionId,
1125
+ ),
1126
+ identity: {
1127
+ turnId: submission.submissionId,
1128
+ assemblyRevision: submission.assemblyRevision,
1129
+ },
1130
+ command,
1131
+ now,
1132
+ });
1133
+ }
1134
+
1135
+ // 作用:把 Pi 恢复决策产生的变更写入 Runtime 数据库。
1136
+ // 调用:记录 Tool、终态、审批或恢复步骤时,通常在事务内调用。
1137
+ // 原因:upsert 保证重放幂等,而 terminal intent 允许后来的更强结果更新早期意图。
1138
+ private applyPiRecoveryMutations(
1139
+ submission: StoredSubmission,
1140
+ mutations: readonly PiDurableMutation[],
1141
+ ): boolean {
1142
+ let appended = false;
1143
+ for (const mutation of mutations) {
1144
+ if (mutation.kind === "decide-approval") {
1145
+ this.db.approvals.decide(
1146
+ mutation.executionId,
1147
+ mutation.status as any,
1148
+ mutation.decidedAt,
1149
+ mutation.reason,
1150
+ );
1151
+ continue;
1152
+ }
1153
+ const inserted = this.db.milestones.upsert(
1154
+ submission.submissionId,
1155
+ mutation.key,
1156
+ mutation.body,
1157
+ );
1158
+ appended ||= inserted;
1159
+ if (!inserted && mutation.key === "terminal:intent") {
1160
+ this.db.milestones.updateTerminalIntent(
1161
+ submission.submissionId,
1162
+ mutation.body,
1163
+ );
1164
+ }
1165
+ }
1166
+ return appended;
1167
+ }
1168
+
1169
+ // 作用:在 Tool 执行前持久记录它的名称和输入。
1170
+ // 调用:Pi Turn 适配器通过 durability 回调在每个 Tool 开始时调用。
1171
+ // 原因:先留下 durable intent,中断后才能区分“未执行”和“结果不确定”。
1172
+ private appendToolInput(
1173
+ submission: StoredSubmission,
1174
+ input: PiToolInputRecord,
1175
+ ): boolean {
1176
+ const decision = this.decidePiRecovery(submission, {
1177
+ kind: "record-tool-input",
1178
+ record: input,
1179
+ });
1180
+ return this.db.transaction(() =>
1181
+ this.applyPiRecoveryMutations(
1182
+ submission,
1183
+ decision.mutations,
1184
+ ),
1185
+ );
1186
+ }
1187
+
1188
+ // 作用:持久化一次 Tool 结果,并推进相应的恢复里程碑。
1189
+ // 调用:正常 Tool 执行和恢复 Tool 结果落地时调用。
1190
+ // 原因:同一 `toolCallId` 只允许内容完全相同的重放,冲突结果必须立即失败以保护恢复确定性。
1191
+ private settleTool(
1192
+ submissionId: string,
1193
+ call: PiToolSettlement & { readonly createdAt?: number },
1194
+ ): Promise<void> {
1195
+ this.db.transaction(() => this.settleToolSync(submissionId, call));
1196
+ return this.drainRuntimeEvents();
1197
+ }
1198
+
1199
+ // 终态事务用这个同步内核先补齐 ToolResult,再在同一事务末尾写 marker。
1200
+ private settleToolSync(
1201
+ submissionId: string,
1202
+ call: PiToolSettlement & { readonly createdAt?: number },
1203
+ ): void {
1204
+ const submission = this.readSubmission(submissionId);
1205
+ if (!submission) {
1206
+ throw new Error(`Unknown Pi submission: ${submissionId}`);
1207
+ }
1208
+ const existing = this.db.settlements.find(submissionId, call.toolCallId);
1209
+ const args = json(call.args);
1210
+ const result = json(call.result);
1211
+ const createdAt = existing?.createdAt ?? call.createdAt ?? Date.now();
1212
+ if (existing) {
1213
+ if (
1214
+ existing.toolName !== call.toolName ||
1215
+ existing.args !== args ||
1216
+ existing.result !== result ||
1217
+ existing.isError !== call.isError
1218
+ ) {
1219
+ throw new Error(
1220
+ `Conflicting durable Tool settlement: ${call.toolCallId}`,
1221
+ );
1222
+ }
1223
+ } else {
1224
+ this.db.settlements.insert({
1225
+ toolCallId: call.toolCallId,
1226
+ submissionId,
1227
+ toolName: call.toolName,
1228
+ args,
1229
+ result,
1230
+ isError: call.isError,
1231
+ createdAt,
1232
+ });
1233
+ const event: RuntimeToolSettlementEvent = {
1234
+ eventId: `${submissionId}:tool:${call.toolCallId}`,
1235
+ submissionId,
1236
+ toolCallId: call.toolCallId,
1237
+ toolName: call.toolName,
1238
+ status: call.isError ? "error" : "success",
1239
+ };
1240
+ this.db.runtimeEvents.insert({
1241
+ eventId: event.eventId,
1242
+ body: json({ type: "tool-settlement", event }),
1243
+ createdAt,
1244
+ });
1245
+ }
1246
+ const decision = this.decidePiRecovery(submission, {
1247
+ kind: "record-tool-result",
1248
+ toolCallId: call.toolCallId,
1249
+ toolName: call.toolName,
1250
+ result: call.result,
1251
+ isError: call.isError,
1252
+ timestamp: createdAt,
1253
+ });
1254
+ this.applyPiRecoveryMutations(submission, decision.mutations);
1255
+ this.approvals.commitContinuation(submission, call.toolCallId);
1256
+ }
1257
+
1258
+ // 作用:按 Submission 和 Tool Call ID 读取已持久结果。
1259
+ // 调用:Pi 执行去重、恢复物化和审批续跑在重做 Tool 前调用。
1260
+ // 原因:只在边界解析 JSON,数据库仍保留可精确比较的原始文本。
1261
+ private readToolSettlement(
1262
+ submissionId: string,
1263
+ toolCallId: string,
1264
+ ): {
1265
+ toolName: string;
1266
+ args: unknown;
1267
+ result: PiStoredToolSettlement["result"];
1268
+ isError: boolean;
1269
+ createdAt: number;
1270
+ } | null {
1271
+ const row = this.db.settlements.find(submissionId, toolCallId);
1272
+ return row
1273
+ ? {
1274
+ toolName: row.toolName,
1275
+ args: JSON.parse(row.args) as unknown,
1276
+ result: JSON.parse(
1277
+ row.result,
1278
+ ) as PiStoredToolSettlement["result"],
1279
+ isError: row.isError,
1280
+ createdAt: row.createdAt,
1281
+ }
1282
+ : null;
1283
+ }
1284
+
1285
+ // 作用:把恢复状态机已知的 Tool 结果补全到 settlement 和 transcript 投影。
1286
+ // 调用:Turn 恢复、审批决定和终态收尾在继续前调用。
1287
+ // 原因:恢复记录可能比浏览器 transcript 更新,先去重落库再按 canonical 顺序投影才不会丢失或重复 ToolResult。
1288
+ private materializeRecoveredToolResultsSync(
1289
+ submission: StoredSubmission,
1290
+ ): PiRecoveryDecision {
1291
+ let decision = this.decidePiRecovery(submission);
1292
+ for (const settlement of decision.recoveredToolSettlements) {
1293
+ if (
1294
+ this.readToolSettlement(
1295
+ submission.submissionId,
1296
+ settlement.toolCallId,
1297
+ )
1298
+ ) {
1299
+ continue;
1300
+ }
1301
+ this.settleToolSync(submission.submissionId, settlement);
1302
+ decision = this.decidePiRecovery(submission);
1303
+ }
1304
+ const results = this.transcript.orderRecoveredToolSettlements(
1305
+ decision.recoveredToolSettlements,
1306
+ );
1307
+ for (const recovered of results) {
1308
+ this.transcript.appendRecoveredToolSettlement(
1309
+ submission.submissionId,
1310
+ recovered,
1311
+ );
1312
+ }
1313
+ return decision;
1314
+ }
1315
+
1316
+ private async materializeRecoveredToolResults(
1317
+ submission: StoredSubmission,
1318
+ ): Promise<PiRecoveryDecision> {
1319
+ const decision = this.db.transaction(() =>
1320
+ this.materializeRecoveredToolResultsSync(submission)
1321
+ );
1322
+ await this.drainRuntimeEvents();
1323
+ return decision;
1324
+ }
1325
+
1326
+ // 作用:在改写 Submission 终态前持久化 Turn 的结束意图。
1327
+ // 调用:模型终止回调、取消和最终提交都会调用。
1328
+ // 原因:intent 早于状态行落地,即使两步之间中断,恢复也能重建权威结果。
1329
+ private appendTerminalIntent(
1330
+ submission: StoredSubmission,
1331
+ outcome: "succeeded" | "failed" | "aborted",
1332
+ message?: string,
1333
+ ): void {
1334
+ const decision = this.decidePiRecovery(submission, {
1335
+ kind: "record-terminal",
1336
+ phase: "intent",
1337
+ outcome,
1338
+ ...(message === undefined ? {} : { message }),
1339
+ });
1340
+ this.applyPiRecoveryMutations(submission, decision.mutations);
1341
+ }
1342
+
1343
+ // 作用:把 Turn 的权威结果一次提交到 Submission、恢复里程碑和聊天续传标记。
1344
+ // 调用:SubmissionLifecycle 完成成功、失败或取消时调用。
1345
+ // 原因:已持久的 abort 和 terminal intent 优先于调用方参数,可避免并发收尾覆盖更早的真实结果。
1346
+ private async commitTerminalOutcome(
1347
+ submission: StoredSubmission,
1348
+ outcome: "succeeded" | "failed" | "aborted",
1349
+ message?: string,
1350
+ ): Promise<StoredSubmission> {
1351
+ const latest = this.readSubmission(submission.submissionId);
1352
+ if (!latest) {
1353
+ throw new Error(
1354
+ `Unknown Pi submission: ${submission.submissionId}`,
1355
+ );
1356
+ }
1357
+ if (isTerminalSubmissionStatus(latest.status)) return latest;
1358
+
1359
+ const durableIntent = this.decidePiRecovery(latest).terminal;
1360
+ const effectiveOutcome = latest.abortReason
1361
+ ? "aborted"
1362
+ : durableIntent?.outcome ?? outcome;
1363
+ const effectiveMessage =
1364
+ latest.abortReason ?? durableIntent?.message ?? message;
1365
+ const terminal = this.db.transaction(() => {
1366
+ this.appendTerminalIntent(
1367
+ latest,
1368
+ effectiveOutcome,
1369
+ effectiveMessage,
1370
+ );
1371
+ const closedApprovals = this.approvals.rejectPending(
1372
+ latest,
1373
+ effectiveMessage ?? "Turn ended before approval",
1374
+ );
1375
+ if (closedApprovals) {
1376
+ this.materializeRecoveredToolResultsSync(latest);
1377
+ }
1378
+ const status =
1379
+ effectiveOutcome === "succeeded"
1380
+ ? "completed"
1381
+ : effectiveOutcome === "aborted"
1382
+ ? "aborted"
1383
+ : "error";
1384
+ for (const steer of this.db.steers.listForSubmission(
1385
+ latest.submissionId,
1386
+ )) {
1387
+ if (!this.db.submissions.findByRequestId(steer.messageId)) {
1388
+ this.db.submissions.insert({
1389
+ submissionId: steer.steerId,
1390
+ requestId: steer.messageId,
1391
+ idempotencyKey: steer.messageId,
1392
+ createdAt: steer.createdAt,
1393
+ assemblyRevision: latest.assemblyRevision,
1394
+ assemblyDescriptor: latest.assemblyDescriptor,
1395
+ assistantMessageId: crypto.randomUUID(),
1396
+ queuedInputJson: steer.canonicalJson,
1397
+ queuedUiMessageJson: steer.uiMessageJson,
1398
+ userMessageId: steer.messageId,
1399
+ regenerateMessageId: null,
1400
+ });
1401
+ }
1402
+ this.db.steers.deleteByMessageId(steer.messageId);
1403
+ }
1404
+ const terminal = this.updateSubmission(
1405
+ latest.submissionId,
1406
+ status,
1407
+ effectiveMessage,
1408
+ );
1409
+ if (
1410
+ effectiveOutcome === "aborted" &&
1411
+ effectiveMessage === USER_STOP_REASON
1412
+ ) {
1413
+ if (terminal.completedAt == null) {
1414
+ throw new Error(
1415
+ `Aborted Pi submission has no completion time: ${latest.submissionId}`,
1416
+ );
1417
+ }
1418
+ this.transcript.appendTurnAbortedMarker(
1419
+ latest.submissionId,
1420
+ terminal.completedAt,
1421
+ );
1422
+ }
1423
+ const committed = this.decidePiRecovery(latest, {
1424
+ kind: "record-terminal",
1425
+ phase: "committed",
1426
+ outcome: effectiveOutcome,
1427
+ ...(effectiveMessage === undefined
1428
+ ? {}
1429
+ : { message: effectiveMessage }),
1430
+ });
1431
+ this.applyPiRecoveryMutations(
1432
+ latest,
1433
+ committed.mutations,
1434
+ );
1435
+ return terminal;
1436
+ });
1437
+ await this.drainRuntimeEvents();
1438
+ let inactiveActivity: RuntimeActivity = "idle";
1439
+ if (terminal.status === "completed") {
1440
+ try {
1441
+ if (submissionNeedsInput(
1442
+ await this.getMessages(),
1443
+ terminal.assistantMessageId,
1444
+ )) {
1445
+ inactiveActivity = "needs-input";
1446
+ }
1447
+ } catch {
1448
+ // Transcript projection is retried by the normal terminal path.
1449
+ }
1450
+ }
1451
+ await this.broadcastApprovals(inactiveActivity);
1452
+ try {
1453
+ if (
1454
+ effectiveOutcome === "failed" ||
1455
+ (effectiveOutcome === "aborted" &&
1456
+ effectiveMessage !== USER_STOP_REASON)
1457
+ ) {
1458
+ await recordChatTerminal(
1459
+ this.ctx.storage,
1460
+ latest.requestId,
1461
+ effectiveMessage ?? "",
1462
+ );
1463
+ } else {
1464
+ await clearChatTerminal(this.ctx.storage);
1465
+ }
1466
+ } catch (error) {
1467
+ console.error("[pi-terminal] resume marker failed", error);
1468
+ }
1469
+ return terminal;
1470
+ }
1471
+
1472
+ // 作用:按持久事实把一个中断 Turn 推进到可继续或已收尾状态。
1473
+ // 调用:`executeNonTerminalSubmission` 以 recovery 模式重起 Pi Turn 前调用。
1474
+ // 原因:用有界循环执行状态机效果,既允许 Tool 重试产生新事实,也防止错误规则无限自旋。
1475
+ private async prepareRecoveredTurn(
1476
+ submission: StoredSubmission,
1477
+ adapter: PreparedPiTurnAdapter,
1478
+ ): Promise<boolean> {
1479
+ let decision = await this.materializeRecoveredToolResults(submission);
1480
+ for (let step = 0; step < 32; step += 1) {
1481
+ this.db.transaction(() => {
1482
+ this.applyPiRecoveryMutations(
1483
+ submission,
1484
+ decision.mutations,
1485
+ );
1486
+ });
1487
+ switch (decision.effect.kind) {
1488
+ case "wait":
1489
+ return false;
1490
+ case "retry-tool": {
1491
+ try {
1492
+ if (
1493
+ !await adapter.retryTool({
1494
+ toolName: decision.effect.toolName,
1495
+ toolCallId: decision.effect.toolCallId,
1496
+ input: decision.effect.input,
1497
+ })
1498
+ ) {
1499
+ return false;
1500
+ }
1501
+ } catch {
1502
+ // The governed Tool persisted its bounded error ToolResult.
1503
+ }
1504
+ decision = await this.materializeRecoveredToolResults(
1505
+ submission,
1506
+ );
1507
+ continue;
1508
+ }
1509
+ case "schedule-continuation":
1510
+ await this.approvals.dispatchContinuation(
1511
+ submission,
1512
+ decision.effect.approvalExecutionId,
1513
+ );
1514
+ return false;
1515
+ case "finish":
1516
+ await this.submissions.finish(
1517
+ submission,
1518
+ decision.effect.outcome,
1519
+ decision.effect.message,
1520
+ );
1521
+ return false;
1522
+ case "resume-turn": {
1523
+ const last = (await this.transcript.canonicalMessages()).at(-1);
1524
+ return last?.role === "user" || last?.role === "toolResult";
1525
+ }
1526
+ }
1527
+ }
1528
+ throw new Error("Pi recovery plan did not converge");
1529
+ }
1530
+
1531
+ // #endregion
1532
+
1533
+ // #region Turn 执行、流式记录与完成投影
1534
+
1535
+ private async activateQueuedSubmission(
1536
+ submission: StoredSubmission,
1537
+ ): Promise<StoredSubmission> {
1538
+ if (!submission.queuedInputJson) {
1539
+ this.db.submissions.transition(
1540
+ submission.submissionId,
1541
+ "running",
1542
+ ["pending"],
1543
+ );
1544
+ const activated = this.readSubmission(submission.submissionId)!;
1545
+ await this.broadcastApprovals();
1546
+ return activated;
1547
+ }
1548
+
1549
+ const userMessage = JSON.parse(
1550
+ submission.queuedInputJson,
1551
+ ) as PiCanonicalUserInput;
1552
+ if (
1553
+ userMessage?.role !== "user" ||
1554
+ typeof userMessage.timestamp !== "number" ||
1555
+ !submission.userMessageId
1556
+ ) {
1557
+ throw new Error("Queued Pi user message is invalid");
1558
+ }
1559
+ const uiMessage = submission.queuedUiMessageJson
1560
+ ? JSON.parse(submission.queuedUiMessageJson) as UIMessage & {
1561
+ role: "user";
1562
+ }
1563
+ : undefined;
1564
+ if (uiMessage && uiMessage.role !== "user") {
1565
+ throw new Error("Queued UI user message is invalid");
1566
+ }
1567
+ if (submission.regenerateMessageId) {
1568
+ const regenerateEntry = await this.transcript.findUserMessage(
1569
+ submission.regenerateMessageId,
1570
+ );
1571
+ if (
1572
+ !regenerateEntry ||
1573
+ userContentKey(regenerateEntry) !== userContentKey(userMessage)
1574
+ ) {
1575
+ throw new Error(
1576
+ "Regenerate request must match an existing user message",
1577
+ );
1578
+ }
1579
+ }
1580
+
1581
+ const activated = this.db.transaction(() => {
1582
+ const latest = this.readSubmission(submission.submissionId);
1583
+ if (!latest || latest.status !== "pending") {
1584
+ if (!latest) {
1585
+ throw new Error(
1586
+ `Unknown Pi submission: ${submission.submissionId}`,
1587
+ );
1588
+ }
1589
+ return latest;
1590
+ }
1591
+ const running = this.db.submissions.findRunning();
1592
+ if (running) {
1593
+ throw new Error(
1594
+ `Pi submission ${running.submissionId} is already running`,
1595
+ );
1596
+ }
1597
+ if (submission.regenerateMessageId) {
1598
+ this.transcript.moveLeaf(submission.regenerateMessageId);
1599
+ } else {
1600
+ const appended = this.transcript.append(
1601
+ submission.userMessageId!,
1602
+ userMessage,
1603
+ {
1604
+ submissionId: submission.submissionId,
1605
+ createdAt: userMessage.timestamp,
1606
+ ...(uiMessage ? { userMessage: uiMessage } : {}),
1607
+ },
1608
+ );
1609
+ if (!appended) {
1610
+ throw new Error(
1611
+ `Pi Session message ${submission.userMessageId} already exists`,
1612
+ );
1613
+ }
1614
+ }
1615
+ if (!this.db.submissions.transition(
1616
+ submission.submissionId,
1617
+ "running",
1618
+ ["pending"],
1619
+ )) {
1620
+ throw new Error(
1621
+ `Pi submission ${submission.submissionId} could not activate`,
1622
+ );
1623
+ }
1624
+ this.db.submissions.clearQueuedPayload(
1625
+ submission.submissionId,
1626
+ );
1627
+ return this.readSubmission(submission.submissionId)!;
1628
+ });
1629
+ this.broadcast(json({
1630
+ type: MessageType.CF_AGENT_CHAT_MESSAGES,
1631
+ messages: await this.getMessages(),
1632
+ }));
1633
+ await this.broadcastApprovals();
1634
+ return activated;
1635
+ }
1636
+
1637
+ // 作用:执行一条 Submission,并保证任何异常都被收口为持久终态。
1638
+ // 调用:SubmissionLifecycle 启动新提交或恢复未完提交时调用。
1639
+ // 原因:把失败帧、可续传流和 pre-stream 清理放在最外层,避免内部分支遗留半开传输状态。
1640
+ private async executeSubmission(
1641
+ submissionId: string,
1642
+ recovery = false,
1643
+ ): Promise<StoredSubmission> {
1644
+ const submission = this.readSubmission(submissionId);
1645
+ if (!submission) {
1646
+ throw new Error(`Unknown Pi submission: ${submissionId}`);
1647
+ }
1648
+ if (isTerminalSubmissionStatus(submission.status)) {
1649
+ return submission;
1650
+ }
1651
+ if (submission.abortReason) {
1652
+ return this.submissions.finish(
1653
+ submission,
1654
+ "aborted",
1655
+ submission.abortReason,
1656
+ );
1657
+ }
1658
+ if (recovery) await this.ensureRuntimeReady();
1659
+ try {
1660
+ return await this.executeNonTerminalSubmission(
1661
+ submission,
1662
+ recovery,
1663
+ );
1664
+ } catch (error) {
1665
+ const failed = await this.submissions.finish(
1666
+ submission,
1667
+ "failed",
1668
+ errorText(error),
1669
+ );
1670
+ const streamId =
1671
+ this.streamBySubmission.get(submission.submissionId);
1672
+ if (streamId) {
1673
+ this.failRecoverableStream(streamId);
1674
+ }
1675
+ this.sendChatTerminal(
1676
+ submission.requestId,
1677
+ failed.status === "error"
1678
+ ? failed.error ?? "Pi turn failed"
1679
+ : undefined,
1680
+ );
1681
+ return failed;
1682
+ } finally {
1683
+ const streamId =
1684
+ this.streamBySubmission.get(submission.submissionId);
1685
+ if (streamId) {
1686
+ this.streamBySubmission.delete(submission.submissionId);
1687
+ }
1688
+ this.settlePreStream(submission.requestId);
1689
+ }
1690
+ }
1691
+
1692
+ // 作用:运行一条未结束 Submission 的完整 Pi Turn。
1693
+ // 调用:外层 `executeSubmission` 确认记录存在且未终态后调用。
1694
+ // 原因:恢复补全、状态迁移、fiber、流式记录与终态投影必须保持固定顺序,否则唤醒后会重复执行或丢失回答。
1695
+ private async executeNonTerminalSubmission(
1696
+ submission: StoredSubmission,
1697
+ recovery: boolean,
1698
+ ): Promise<StoredSubmission> {
1699
+ const submissionId = submission.submissionId;
1700
+ if (submission.abortReason) {
1701
+ return this.submissions.finish(
1702
+ submission,
1703
+ "aborted",
1704
+ submission.abortReason,
1705
+ );
1706
+ }
1707
+ submission = await this.pinSubmissionAssembly(submission);
1708
+ await this.assertPinnedSubmission(submission);
1709
+ if (submission.status === "pending" && !recovery) {
1710
+ submission = await this.activateQueuedSubmission(submission);
1711
+ }
1712
+ const recoveryAdapter = this.createSubmissionExecutionAdapter(submission);
1713
+ if (recovery) {
1714
+ const ready = await this.prepareRecoveredTurn(
1715
+ submission,
1716
+ recoveryAdapter,
1717
+ );
1718
+ if (!ready) return this.readSubmission(submissionId)!;
1719
+ } else {
1720
+ await this.materializeRecoveredToolResults(submission);
1721
+ }
1722
+
1723
+ const ready = this.readSubmission(submissionId);
1724
+ if (!ready) {
1725
+ throw new Error(`Unknown Pi submission: ${submissionId}`);
1726
+ }
1727
+ if (isTerminalSubmissionStatus(ready.status)) return ready;
1728
+ if (ready.abortReason) {
1729
+ return this.submissions.finish(
1730
+ ready,
1731
+ "aborted",
1732
+ ready.abortReason,
1733
+ );
1734
+ }
1735
+ if (ready.status === "pending") {
1736
+ this.db.submissions.transition(submissionId, "running", ["pending"]);
1737
+ }
1738
+ const storedMessages = await this.transcript.storedMessages();
1739
+ const assistantOrdinal = storedMessages.filter(
1740
+ (entry) =>
1741
+ entry.submissionId === submissionId &&
1742
+ entry.message.role === "assistant",
1743
+ ).length;
1744
+ let terminalIntent:
1745
+ | {
1746
+ outcome: "succeeded" | "failed" | "aborted";
1747
+ message?: string;
1748
+ }
1749
+ | undefined;
1750
+ const startedAt = ready.createdAt;
1751
+ let turn!: ActiveTurn;
1752
+ const adapter = this.createSubmissionExecutionAdapter(submission, {
1753
+ startedAt,
1754
+ continuation: recovery,
1755
+ assistantOrdinal,
1756
+ onRecord: (record) =>
1757
+ this.persistAndBroadcastRecord(turn, record),
1758
+ onTerminal: (intent) => {
1759
+ terminalIntent = intent;
1760
+ this.appendTerminalIntent(
1761
+ submission,
1762
+ intent.outcome,
1763
+ intent.message,
1764
+ );
1765
+ },
1766
+ });
1767
+ for (const pending of this.db.steers.listForSubmission(submissionId)) {
1768
+ const message = JSON.parse(
1769
+ pending.canonicalJson,
1770
+ ) as PiCanonicalUserInput;
1771
+ if (message?.role !== "user") {
1772
+ throw new Error(`Pending steer ${pending.steerId} is invalid`);
1773
+ }
1774
+ adapter.steer(message, pending.messageId);
1775
+ }
1776
+ turn = {
1777
+ submissionId,
1778
+ requestId: submission.requestId,
1779
+ messageId: submission.assistantMessageId,
1780
+ startedAt,
1781
+ continuation: recovery,
1782
+ agent: adapter,
1783
+ };
1784
+ let streamId: string | undefined;
1785
+
1786
+ const deactivate = this.submissions.activate(submission, turn);
1787
+ await this.broadcastApprovals();
1788
+ try {
1789
+ await this.runRecoverableChatFiber(
1790
+ {
1791
+ requestId: submission.requestId,
1792
+ messageId: submission.assistantMessageId,
1793
+ continuation: recovery,
1794
+ messages: await this.transcript.snapshotMessages(),
1795
+ recoveryData: {
1796
+ submissionId,
1797
+ requestId: submission.requestId,
1798
+ },
1799
+ },
1800
+ async (_fiber, activeStreamId) => {
1801
+ streamId = activeStreamId;
1802
+ this.streamBySubmission.set(submissionId, streamId);
1803
+ try {
1804
+ await adapter.run({});
1805
+ } finally {
1806
+ this.streamBySubmission.delete(submissionId);
1807
+ }
1808
+ },
1809
+ );
1810
+ const intent = terminalIntent ?? {
1811
+ outcome: "failed" as const,
1812
+ message:
1813
+ "Pi turn ended without an authoritative assistant message",
1814
+ };
1815
+ const terminal = await this.submissions.finish(
1816
+ submission,
1817
+ intent.outcome,
1818
+ intent.message,
1819
+ );
1820
+ if (streamId) this.settleRecoverableStream(streamId, terminal);
1821
+ await this.projectTerminal(turn, terminal);
1822
+ return terminal;
1823
+ } catch (error) {
1824
+ const latest = this.readSubmission(submissionId);
1825
+ const intent = terminalIntent ?? {
1826
+ outcome: latest?.abortReason
1827
+ ? ("aborted" as const)
1828
+ : ("failed" as const),
1829
+ message: errorText(error),
1830
+ };
1831
+ const failed = await this.submissions.finish(
1832
+ submission,
1833
+ intent.outcome,
1834
+ intent.message,
1835
+ );
1836
+ if (streamId) this.settleRecoverableStream(streamId, failed);
1837
+ await this.projectTerminal(turn, failed);
1838
+ return failed;
1839
+ } finally {
1840
+ deactivate();
1841
+ }
1842
+ }
1843
+
1844
+ private settleRecoverableStream(
1845
+ streamId: string,
1846
+ submission: StoredSubmission,
1847
+ ): void {
1848
+ if (
1849
+ submission.status === "completed" ||
1850
+ (submission.status === "aborted" &&
1851
+ submission.error === USER_STOP_REASON)
1852
+ ) {
1853
+ this.completeRecoverableStream(streamId);
1854
+ } else {
1855
+ this.failRecoverableStream(streamId);
1856
+ }
1857
+ }
1858
+
1859
+ // 作用:先持久化一条 Pi 流记录,再广播给在线客户端。
1860
+ // 调用:活跃 Turn 适配器每产生一条 stream record 时调用。
1861
+ // 原因:耐久写入早于 WebSocket 发送,断线客户端才能从同一序列续传。
1862
+ private async persistAndBroadcastRecord(
1863
+ turn: ActiveTurn,
1864
+ chunk: UIMessageChunk,
1865
+ ): Promise<void> {
1866
+ const streamId = this.streamBySubmission.get(turn.submissionId);
1867
+ const body = json(chunk);
1868
+ if (streamId) {
1869
+ await this.appendRecoverableChunk(streamId, body);
1870
+ }
1871
+ this.sendChatResponse(turn.requestId, body, false);
1872
+ }
1873
+
1874
+ private sendChatTerminal(requestId: string, error?: string): void {
1875
+ this.sendChatResponse(
1876
+ requestId,
1877
+ error
1878
+ ? json({ type: "error", errorText: error } satisfies UIMessageChunk)
1879
+ : "",
1880
+ true,
1881
+ );
1882
+ }
1883
+
1884
+ // 作用:把已提交的 Submission 终态投影到聊天协议、审批状态和可选业务事件。
1885
+ // 调用:非终态 Turn 成功收尾且可续传流已标记完成后调用。
1886
+ // 原因:Submission 是权威状态,其他都是后续投影;业务投影失败只降级记录,不能推翻已完成 Turn。
1887
+ private async projectTerminal(
1888
+ turn: ActiveTurn,
1889
+ submission: StoredSubmission,
1890
+ ): Promise<void> {
1891
+ this.sendChatTerminal(
1892
+ turn.requestId,
1893
+ submission.status === "error"
1894
+ ? submission.error ?? "Pi turn failed"
1895
+ : undefined,
1896
+ );
1897
+ const messages = await this.getMessages();
1898
+ await this.broadcastApprovals(
1899
+ submission.status === "completed" &&
1900
+ submissionNeedsInput(messages, submission.assistantMessageId)
1901
+ ? "needs-input"
1902
+ : "idle",
1903
+ );
1904
+ this.broadcast(
1905
+ json({
1906
+ type: MessageType.CF_AGENT_CHAT_MESSAGES,
1907
+ messages,
1908
+ }),
1909
+ );
1910
+ const events = this.assembly().bindings.turnEvents;
1911
+ if (events) {
1912
+ try {
1913
+ await events.onResponse(
1914
+ messages.flatMap((message) =>
1915
+ message.role === "user" || message.role === "assistant"
1916
+ ? [
1917
+ {
1918
+ role: message.role,
1919
+ text: message.parts
1920
+ .filter(
1921
+ (
1922
+ part,
1923
+ ): part is Extract<UIMessage["parts"][number], { type: "text" }> =>
1924
+ part.type === "text",
1925
+ )
1926
+ .map((part) => part.text)
1927
+ .join(""),
1928
+ },
1929
+ ]
1930
+ : [],
1931
+ ),
1932
+ );
1933
+ } catch (error) {
1934
+ console.warn(
1935
+ "[runtime-turn-projection:degraded]",
1936
+ json({ error: errorText(error) }),
1937
+ );
1938
+ }
1939
+ try {
1940
+ if (
1941
+ submission.status === "completed" ||
1942
+ submission.status === "aborted" ||
1943
+ submission.status === "skipped" ||
1944
+ submission.status === "error"
1945
+ ) {
1946
+ await events.onSubmissionTerminal?.({
1947
+ submissionId: submission.submissionId,
1948
+ status: submission.status,
1949
+ ...(submission.error ? { error: submission.error } : {}),
1950
+ resultMessageId: submission.assistantMessageId,
1951
+ });
1952
+ }
1953
+ } catch (error) {
1954
+ console.warn(
1955
+ "[runtime-terminal-projection:degraded]",
1956
+ json({ error: errorText(error) }),
1957
+ );
1958
+ }
1959
+ }
1960
+ }
1961
+
1962
+ // #endregion
1963
+
1964
+ // #region Cloudflare Agent 通信生命周期
1965
+
1966
+ /**
1967
+ * 在新 WebSocket 建立后向该连接发送当前聊天快照。
1968
+ *
1969
+ * @remarks
1970
+ * Cloudflare Agents SDK 为每条新 WebSocket 连接调用。
1971
+ *
1972
+ * 先发送 transcript,再让恢复基类通知正在续传的流,
1973
+ * 使普通历史和恢复握手保持各自的协议帧。
1974
+ */
1975
+ async onConnect(connection: Connection): Promise<void> {
1976
+ sendIfOpen(
1977
+ connection,
1978
+ json({
1979
+ type: MessageType.CF_AGENT_CHAT_MESSAGES,
1980
+ messages: await this.getMessages(),
1981
+ }),
1982
+ );
1983
+ await super.onConnect(connection);
1984
+ }
1985
+
1986
+ /**
1987
+ * 处理直接发给 Agent 实例的 HTTP 请求。
1988
+ *
1989
+ * @remarks
1990
+ * Cloudflare Agents SDK 在每个非 WebSocket HTTP 请求到达时调用。
1991
+ *
1992
+ * Kernel 只拦截 `get-messages` 读取口,其余路由基类处理,
1993
+ * 避免在这里复制 Agents SDK 的默认协议行为。
1994
+ */
1995
+ async onRequest(request: Request): Promise<Response> {
1996
+ if (new URL(request.url).pathname.endsWith("/get-messages")) {
1997
+ return Response.json(await this.getMessages());
1998
+ }
1999
+ return super.onRequest(request);
2000
+ }
2001
+
2002
+ /**
2003
+ * 验证并处理一条来自聊天 WebSocket 的协议消息。
2004
+ *
2005
+ * @remarks
2006
+ * Cloudflare Agents SDK 在已连接 WebSocket 收到消息时调用。
2007
+ *
2008
+ * 提交、取消、清空、续传和 Tool 审批都在这一信任边界先验证再分流,
2009
+ * 这样领域层不会接到半解析的客户端输入。
2010
+ */
2011
+ async onMessage(
2012
+ connection: Connection,
2013
+ raw: string | ArrayBuffer,
2014
+ ): Promise<void> {
2015
+ if (typeof raw !== "string") return;
2016
+ const event = parseProtocolMessage(raw);
2017
+ if (!event) return;
2018
+
2019
+ if (event.type === "chat-request") {
2020
+ let body: UIChatRequestBody;
2021
+ try {
2022
+ body = JSON.parse(
2023
+ String(event.init.body ?? "{}"),
2024
+ ) as UIChatRequestBody;
2025
+ } catch {
2026
+ this.sendChatTerminal(event.id, "Chat request body is invalid JSON");
2027
+ return;
2028
+ }
2029
+ const latest = Array.isArray(body.messages)
2030
+ ? [...body.messages].reverse().find(
2031
+ (message): message is UIMessage & { role: "user" } =>
2032
+ message?.role === "user",
2033
+ )
2034
+ : undefined;
2035
+ if (
2036
+ !latest ||
2037
+ latest.role !== "user" ||
2038
+ typeof latest.id !== "string" ||
2039
+ !Array.isArray(latest.parts) ||
2040
+ !latest.parts.every((part) => {
2041
+ if (part === null || typeof part !== "object") return false;
2042
+ return part.type === "text"
2043
+ ? typeof part.text === "string"
2044
+ : part.type === "file" &&
2045
+ typeof part.mediaType === "string" &&
2046
+ typeof part.url === "string" &&
2047
+ (part.filename === undefined || typeof part.filename === "string");
2048
+ }) ||
2049
+ (body.trigger !== "submit-message" &&
2050
+ body.trigger !== "regenerate-message")
2051
+ ) {
2052
+ this.sendChatTerminal(event.id, "Chat request user message is invalid");
2053
+ return;
2054
+ }
2055
+ let submitted: Awaited<
2056
+ ReturnType<AgentRuntimeKernel["submitMessage"]>
2057
+ >;
2058
+ try {
2059
+ const normalized = this.pi.normalizeUserInput(latest);
2060
+ submitted = await this.submitMessage(normalized, {
2061
+ requestId: event.id,
2062
+ idempotencyKey: event.id,
2063
+ userMessageId: latest.id,
2064
+ userMessage: latest,
2065
+ regenerate: body.trigger === "regenerate-message",
2066
+ });
2067
+ } catch (error) {
2068
+ this.sendChatTerminal(event.id, errorText(error));
2069
+ return;
2070
+ }
2071
+ if (submitted.receipt.accepted) {
2072
+ this.beginPreStream(event.id);
2073
+ }
2074
+ await submitted.completion;
2075
+ return;
2076
+ }
2077
+
2078
+ if (event.type === "cancel") {
2079
+ const submission = this.findSubmissionByRequest(event.id);
2080
+ if (submission) {
2081
+ await this.cancelSubmissionById(
2082
+ submission.submissionId,
2083
+ "Client cancelled",
2084
+ );
2085
+ }
2086
+ return;
2087
+ }
2088
+
2089
+ if (event.type === "clear") {
2090
+ if (this.submissions.isBusy()) return;
2091
+ this.db.transaction(() => {
2092
+ this.transcript.clear();
2093
+ this.db.clearAll();
2094
+ this.clearRecoverableStreamStorage();
2095
+ });
2096
+ this.clearRecoverableChatState();
2097
+ await this.broadcastApprovals();
2098
+ this.broadcast(json({ type: MessageType.CF_AGENT_CHAT_CLEAR }));
2099
+ return;
2100
+ }
2101
+
2102
+ if (event.type === "stream-resume-request") {
2103
+ await this.handleResumeRequest(
2104
+ connection,
2105
+ event.probeId,
2106
+ );
2107
+ return;
2108
+ }
2109
+
2110
+ if (event.type === "stream-resume-ack") {
2111
+ await this.handleResumeAck(
2112
+ connection,
2113
+ event.id,
2114
+ );
2115
+ return;
2116
+ }
2117
+
2118
+ if (event.type === "tool-approval") {
2119
+ const executionId = this.approvals.findPendingExecution(
2120
+ event.toolCallId,
2121
+ );
2122
+ if (!executionId) return;
2123
+ await this.decideApproval(
2124
+ executionId,
2125
+ event.approved
2126
+ ? { decision: "allow_once" }
2127
+ : { decision: "deny", reason: "User denied the Tool" },
2128
+ );
2129
+ }
2130
+ }
2131
+
2132
+ /**
2133
+ * 返回当前 canonical transcript 的浏览器投影。
2134
+ *
2135
+ * @remarks
2136
+ * 新连接、HTTP 读取、Turn 完成投影和 RPC 调用方需要消息快照时调用。
2137
+ *
2138
+ * 统一从 transcript 生成完整视图;活跃 assistant 也必须随首帧恢复,后续流按相同消息 ID 接管。
2139
+ */
2140
+ async getMessages(): Promise<UIMessage[]> {
2141
+ return this.transcript.browserMessages();
2142
+ }
2143
+
2144
+ // #endregion
2145
+
2146
+ // #region Runtime Extension Host 回环端口
2147
+
2148
+ // 作用:代表扩展从当前 Runtime Workspace 读取文件。
2149
+ // 调用:已获文件读权限的 HostBridgeLoopback 在处理扩展请求时调用。
2150
+ // 原因:扩展只经过已裁剪的 Host 端口访问工作区,不直接持有平台绑定。
2151
+ private async runtimeWorkspace() {
2152
+ await this.ensureRuntimeReady();
2153
+ const workspace = this.assembly().bindings.workspace;
2154
+ if (!workspace) throw new Error("Runtime Workspace is unavailable");
2155
+ return workspace;
2156
+ }
2157
+
2158
+ async _hostReadFile(path: string): Promise<string | null> {
2159
+ return (await this.runtimeWorkspace()).readFile(path);
2160
+ }
2161
+
2162
+ // 作用:代表扩展把文本写入当前 Runtime Workspace。
2163
+ // 调用:已获文件写权限的 HostBridgeLoopback 在处理扩展请求时调用。
2164
+ // 原因:统一在 Session 边界检查 Workspace 是否可用,避免扩展自行解析平台能力。
2165
+ async _hostWriteFile(
2166
+ path: string,
2167
+ content: string,
2168
+ ): Promise<void> {
2169
+ await (await this.runtimeWorkspace()).writeFile(path, content);
2170
+ }
2171
+
2172
+ // 作用:代表扩展删除当前 Runtime Workspace 中的路径。
2173
+ // 调用:已获文件删除权限的 HostBridgeLoopback 在处理扩展请求时调用。
2174
+ // 原因:Host 契约用布尔值表示删除结果,所以 Workspace 抛错在这一边界被收窄为 `false`。
2175
+ async _hostDeleteFile(path: string): Promise<boolean> {
2176
+ const workspace = await this.runtimeWorkspace();
2177
+ try {
2178
+ await workspace.rm(path);
2179
+ return true;
2180
+ } catch {
2181
+ return false;
2182
+ }
2183
+ }
2184
+
2185
+ // 作用:代表扩展列出当前 Runtime Workspace 目录。
2186
+ // 调用:已获目录读权限的 HostBridgeLoopback 在处理扩展请求时调用。
2187
+ // 原因:只投影稳定的名称、类型、大小和路径,不把 Workspace 的内部目录对象泄漏给扩展。
2188
+ async _hostListFiles(
2189
+ dir: string,
2190
+ ): Promise<
2191
+ Array<{ name: string; type: string; size: number; path: string }>
2192
+ > {
2193
+ const workspace = await this.runtimeWorkspace();
2194
+ return (await workspace.readDir(dir)).map((entry) => ({
2195
+ name: entry.name,
2196
+ type: entry.type,
2197
+ size: entry.size,
2198
+ path: entry.path,
2199
+ }));
2200
+ }
2201
+
2202
+ // 作用:读取一份按 label 保存的 Runtime Extension Context。
2203
+ // 调用:已获对应 label 读权限的 HostBridgeLoopback 在扩展运行期调用。
2204
+ // 原因:Context 与 Durable Object 共享 SQLite 持久性,唤醒后不需要从扩展重建。
2205
+ async _hostGetContext(label: string): Promise<string | null> {
2206
+ return this.db.extContext.get(label) ?? null;
2207
+ }
2208
+
2209
+ // 作用:按 label 写入一份 Runtime Extension Context。
2210
+ // 调用:已获对应 label 写权限的 HostBridgeLoopback 在扩展运行期调用。
2211
+ // 原因:由 Host 集中选择持久存储,扩展不必获得任意 SQL 访问权。
2212
+ async _hostSetContext(
2213
+ label: string,
2214
+ content: string,
2215
+ ): Promise<void> {
2216
+ this.db.extContext.set(label, content);
2217
+ }
2218
+
2219
+ // 作用:为扩展返回精简的 Session 消息历史。
2220
+ // 调用:已获消息读权限的 HostBridgeLoopback 按需传入数量上限时调用。
2221
+ // 原因:使用 transcript 的 Host 投影,避免把 Pi 专用 part 和持久化细节暴露给扩展。
2222
+ async _hostGetMessages(
2223
+ limit?: number,
2224
+ ): Promise<
2225
+ Array<{ id: string; role: string; content: string }>
2226
+ > {
2227
+ return this.transcript.hostMessages(limit);
2228
+ }
2229
+
2230
+ // 作用:把扩展生成的文本作为用户消息送入当前 Session。
2231
+ // 调用:已获消息写权限的 HostBridgeLoopback 在扩展需要追加或 steer 时调用。
2232
+ // 原因:有活跃 Turn 时交给它的 adapter steer,空闲时才直接写 transcript,从而保持单一执行流。
2233
+ async _hostSendMessage(content: string): Promise<void> {
2234
+ if (typeof content !== "string" || content.trim().length === 0) {
2235
+ throw new Error("Extension message content is required");
2236
+ }
2237
+ const message: PiCanonicalUserInput = {
2238
+ role: "user",
2239
+ content: [{ type: "text", text: content }],
2240
+ timestamp: Date.now(),
2241
+ };
2242
+ const active = this.submissions.currentActive();
2243
+ if (active) {
2244
+ const messageId = crypto.randomUUID();
2245
+ this.db.transaction(() => {
2246
+ this.db.steers.insert({
2247
+ steerId: crypto.randomUUID(),
2248
+ submissionId: active.submissionId,
2249
+ messageId,
2250
+ canonicalJson: json(message),
2251
+ uiMessageJson: null,
2252
+ createdAt: message.timestamp,
2253
+ });
2254
+ });
2255
+ active.agent.steer(message, messageId);
2256
+ await this.broadcastApprovals();
2257
+ return;
2258
+ }
2259
+ this.transcript.append(crypto.randomUUID(), message, {
2260
+ createdAt: message.timestamp,
2261
+ });
2262
+ }
2263
+
2264
+ // 作用:返回扩展可见的当前 Session 摘要。
2265
+ // 调用:HostBridgeLoopback 在扩展请求 Session 信息时调用。
2266
+ // 原因:先只提供真实需要的消息数,不暴露 Durable Object 身份或数据库结构。
2267
+ async _hostGetSessionInfo(): Promise<{ messageCount: number }> {
2268
+ return {
2269
+ messageCount: (await this._hostGetMessages()).length,
2270
+ };
2271
+ }
2272
+
2273
+ // #endregion
2274
+
2275
+ // #region Session 消息与 Submission 公开控制面
2276
+
2277
+ /**
2278
+ * 把一组已有消息导入当前 canonical transcript。
2279
+ *
2280
+ * @remarks
2281
+ * Inbox 只在真实 fork 或 import 会话历史时调用。
2282
+ *
2283
+ * 导入统一交给 transcript 并传入当前模型,
2284
+ * 避免外部调用方自行拼接 Pi 的持久化形式。
2285
+ */
2286
+ async getCanonicalSnapshot(
2287
+ throughMessageId: string,
2288
+ ): Promise<PiCanonicalTranscriptSnapshot> {
2289
+ return this.transcript.exportSnapshot(throughMessageId);
2290
+ }
2291
+
2292
+ async addCanonicalSnapshot(
2293
+ snapshot: PiCanonicalTranscriptSnapshot,
2294
+ ): Promise<void> {
2295
+ this.transcript.importSnapshot(snapshot);
2296
+ }
2297
+
2298
+ // 作用:把一段纯文本包装成标准用户消息并提交。
2299
+ // 调用:公开 `submitPrompt` 为 RPC 调用创建 request ID 时调用。
2300
+ // 原因:RPC 只需提供文本,内部仍走与 WebSocket 相同的 canonical admission。
2301
+ private submitText(
2302
+ text: string,
2303
+ options?: { idempotencyKey?: string },
2304
+ ): Promise<SubmissionHandle<StoredSubmission>> {
2305
+ const requestId = crypto.randomUUID();
2306
+ const userMessage: PiCanonicalUserInput = {
2307
+ role: "user",
2308
+ content: [{ type: "text", text }],
2309
+ timestamp: Date.now(),
2310
+ };
2311
+ return this.submitMessage(userMessage, {
2312
+ requestId,
2313
+ idempotencyKey: options?.idempotencyKey,
2314
+ });
2315
+ }
2316
+
2317
+ /**
2318
+ * 可靠提交一段用户文本,并立即返回可查询回执。
2319
+ *
2320
+ * @remarks
2321
+ * Inbox、子任务或其他 RPC 调用方需要启动新 Turn 时调用。
2322
+ *
2323
+ * 接口不等待模型完成,而是把同一 completion 留给 Durable Object 继续处理,
2324
+ * 调用方用 Submission ID 查询状态。
2325
+ */
2326
+ async submitPrompt(
2327
+ text: string,
2328
+ options?: { idempotencyKey?: string },
2329
+ ): Promise<SubmissionReceipt> {
2330
+ const submitted = await this.submitText(text, options);
2331
+ this.ctx.waitUntil(submitted.completion);
2332
+ return submitted.receipt;
2333
+ }
2334
+
2335
+ async dispatchMessage(
2336
+ message: UIMessage,
2337
+ delivery: MessageDelivery,
2338
+ ): Promise<MessageDispatchReceipt> {
2339
+ if (
2340
+ message.role !== "user" ||
2341
+ !message.id ||
2342
+ !Array.isArray(message.parts) ||
2343
+ (delivery !== "steer" && delivery !== "enqueue")
2344
+ ) {
2345
+ return {
2346
+ kind: "rejected",
2347
+ code: "invalid_message",
2348
+ message: "A valid user message is required",
2349
+ };
2350
+ }
2351
+
2352
+ const userMessage = this.pi.normalizeUserInput(
2353
+ message as UIMessage & { role: "user" },
2354
+ );
2355
+ if (delivery === "steer") {
2356
+ const active = this.submissions.currentActive();
2357
+ const target = active ??
2358
+ this.db.submissions.findRunning() ??
2359
+ this.db.submissions.findNextPending();
2360
+ if (target) {
2361
+ if (
2362
+ this.db.approvals.listPendingForSubmission(
2363
+ target.submissionId,
2364
+ ).length > 0
2365
+ ) {
2366
+ return {
2367
+ kind: "rejected",
2368
+ code: "not_steerable",
2369
+ message: "The current Turn is waiting for approval",
2370
+ };
2371
+ }
2372
+ const inserted = this.db.transaction(() => {
2373
+ if (this.db.steers.findByMessageId(message.id)) return false;
2374
+ this.db.steers.insert({
2375
+ steerId: crypto.randomUUID(),
2376
+ submissionId: target.submissionId,
2377
+ messageId: message.id,
2378
+ canonicalJson: json(userMessage),
2379
+ uiMessageJson: json(message),
2380
+ createdAt: userMessage.timestamp,
2381
+ });
2382
+ return true;
2383
+ });
2384
+ if (inserted) {
2385
+ if (active?.submissionId === target.submissionId) {
2386
+ active.agent.steer(userMessage, message.id);
2387
+ }
2388
+ await this.broadcastApprovals();
2389
+ }
2390
+ return {
2391
+ kind: "accepted",
2392
+ submissionId: target.submissionId,
2393
+ messageId: message.id,
2394
+ };
2395
+ }
2396
+ }
2397
+
2398
+ try {
2399
+ const submitted = await this.submitMessage(userMessage, {
2400
+ requestId: message.id,
2401
+ idempotencyKey: message.id,
2402
+ userMessageId: message.id,
2403
+ userMessage: message as UIMessage & { role: "user" },
2404
+ });
2405
+ this.ctx.waitUntil(submitted.completion);
2406
+ const position = this.db.submissions.listPending()
2407
+ .findIndex((item) =>
2408
+ item.submissionId === submitted.receipt.submissionId
2409
+ ) + 1;
2410
+ return position > 0
2411
+ ? {
2412
+ kind: "queued",
2413
+ submission: submitted.receipt,
2414
+ position,
2415
+ }
2416
+ : {
2417
+ kind: "accepted",
2418
+ submissionId: submitted.receipt.submissionId,
2419
+ messageId: message.id,
2420
+ };
2421
+ } catch (error) {
2422
+ if (error instanceof SubmissionQueueFullError) {
2423
+ return {
2424
+ kind: "rejected",
2425
+ code: "queue_full",
2426
+ message: error.message,
2427
+ };
2428
+ }
2429
+ throw error;
2430
+ }
2431
+ }
2432
+
2433
+ async steerQueuedSubmission(
2434
+ submissionId: string,
2435
+ ): Promise<MessageDispatchReceipt> {
2436
+ const active = this.submissions.currentActive();
2437
+ const target = active ??
2438
+ this.db.submissions.findRunning() ??
2439
+ this.db.submissions.findNextPending();
2440
+ if (
2441
+ !target ||
2442
+ this.db.approvals.listPendingForSubmission(
2443
+ target.submissionId,
2444
+ ).length > 0
2445
+ ) {
2446
+ return {
2447
+ kind: "rejected",
2448
+ code: "not_steerable",
2449
+ message: "The current Turn is not accepting steering",
2450
+ };
2451
+ }
2452
+
2453
+ const queued = this.readSubmission(submissionId);
2454
+ if (
2455
+ !queued ||
2456
+ queued.submissionId === target.submissionId ||
2457
+ queued.status !== "pending" ||
2458
+ !queued.queuedInputJson ||
2459
+ !queued.userMessageId
2460
+ ) {
2461
+ return {
2462
+ kind: "rejected",
2463
+ code: "invalid_message",
2464
+ message: "The queued message is no longer available",
2465
+ };
2466
+ }
2467
+ const message = JSON.parse(
2468
+ queued.queuedInputJson,
2469
+ ) as PiCanonicalUserInput;
2470
+ if (message?.role !== "user") {
2471
+ return {
2472
+ kind: "rejected",
2473
+ code: "invalid_message",
2474
+ message: "The queued message is invalid",
2475
+ };
2476
+ }
2477
+ const uiMessage = queued.queuedUiMessageJson
2478
+ ? JSON.parse(queued.queuedUiMessageJson) as UIMessage
2479
+ : undefined;
2480
+ if (
2481
+ uiMessage &&
2482
+ (uiMessage.role !== "user" || uiMessage.id !== queued.userMessageId)
2483
+ ) {
2484
+ return {
2485
+ kind: "rejected",
2486
+ code: "invalid_message",
2487
+ message: "The queued message is invalid",
2488
+ };
2489
+ }
2490
+
2491
+ const moved = this.db.transaction(() => {
2492
+ const latest = this.readSubmission(submissionId);
2493
+ const current = this.db.submissions.findRunning() ??
2494
+ this.db.submissions.findNextPending();
2495
+ if (
2496
+ !latest ||
2497
+ latest.status !== "pending" ||
2498
+ current?.submissionId !== target.submissionId ||
2499
+ this.db.steers.findByMessageId(queued.userMessageId!)
2500
+ ) {
2501
+ return false;
2502
+ }
2503
+ this.db.steers.insert({
2504
+ steerId: crypto.randomUUID(),
2505
+ submissionId: target.submissionId,
2506
+ messageId: queued.userMessageId!,
2507
+ canonicalJson: queued.queuedInputJson!,
2508
+ uiMessageJson: queued.queuedUiMessageJson ?? null,
2509
+ createdAt: queued.createdAt,
2510
+ });
2511
+ const reason = "Moved to active Turn";
2512
+ this.appendTerminalIntent(latest, "aborted", reason);
2513
+ this.db.submissions.updateAbortReason(submissionId, reason);
2514
+ this.updateSubmission(submissionId, "aborted", reason);
2515
+ return true;
2516
+ });
2517
+ if (!moved) {
2518
+ return {
2519
+ kind: "rejected",
2520
+ code: "invalid_message",
2521
+ message: "The queued message is no longer available",
2522
+ };
2523
+ }
2524
+
2525
+ try {
2526
+ if (active?.submissionId === target.submissionId) {
2527
+ active.agent.steer(message, queued.userMessageId);
2528
+ }
2529
+ } finally {
2530
+ await this.broadcastApprovals();
2531
+ }
2532
+ return {
2533
+ kind: "steered",
2534
+ submissionId: target.submissionId,
2535
+ messageId: queued.userMessageId,
2536
+ ...(uiMessage ? { message: uiMessage } : {}),
2537
+ };
2538
+ }
2539
+
2540
+ /**
2541
+ * 等待 Session 稳定后提交定时提示,并在准入后立即返回。
2542
+ *
2543
+ * @remarks
2544
+ * Agent 定时回调在不应与人工 Turn 并发时调用。
2545
+ *
2546
+ * 最多等待固定的稳定窗口;超时返回 skipped,
2547
+ * 避免定时任务无限占住调用链。
2548
+ */
2549
+ async submitScheduledPrompt(
2550
+ prompt: string,
2551
+ options?: { idempotencyKey?: string },
2552
+ ): Promise<SubmissionReceipt> {
2553
+ const requestId = crypto.randomUUID();
2554
+ const submitted = await this.submitMessageWhenStable(
2555
+ {
2556
+ role: "user",
2557
+ content: [{ type: "text", text: prompt }],
2558
+ timestamp: Date.now(),
2559
+ },
2560
+ {
2561
+ requestId,
2562
+ idempotencyKey: options?.idempotencyKey,
2563
+ },
2564
+ SCHEDULED_STABLE_TIMEOUT_MS,
2565
+ );
2566
+ if (!submitted) {
2567
+ return {
2568
+ submissionId: crypto.randomUUID(),
2569
+ status: "skipped",
2570
+ accepted: false,
2571
+ error: "Runtime not stable within timeout",
2572
+ createdAt: Date.now(),
2573
+ };
2574
+ }
2575
+ this.ctx.waitUntil(submitted.completion.then(() => undefined));
2576
+ return submitted.receipt;
2577
+ }
2578
+
2579
+ /**
2580
+ * 按 Submission ID 读取当前回执。
2581
+ *
2582
+ * @remarks
2583
+ * 提交后的 RPC 调用方用它轮询或核对终态。
2584
+ *
2585
+ * 直接读取 SubmissionLifecycle 的持久存储,不用内存执行状态猜测结果。
2586
+ */
2587
+ async getSubmission(
2588
+ submissionId: string,
2589
+ ): Promise<SubmissionReceipt | null> {
2590
+ return this.submissions.get(submissionId);
2591
+ }
2592
+
2593
+ /**
2594
+ * 取消一条指定 Submission。
2595
+ *
2596
+ * @remarks
2597
+ * Inbox 或调度清理路径在已知 Submission ID 时调用。
2598
+ *
2599
+ * 取消交给 SubmissionLifecycle 先持久化 abort intent 再停活跃 adapter,
2600
+ * 避免重启后丢失取消决定。
2601
+ */
2602
+ async cancelSubmissionById(
2603
+ submissionId: string,
2604
+ reason?: string,
2605
+ ): Promise<{ ok: boolean }> {
2606
+ const result = await this.submissions.cancel(
2607
+ submissionId,
2608
+ reason ?? "Cancelled",
2609
+ );
2610
+ await this.broadcastApprovals();
2611
+ return result;
2612
+ }
2613
+
2614
+ /**
2615
+ * 按 request ID 停止 Turn,未给 ID 时停止所有未完成提交。
2616
+ *
2617
+ * @remarks
2618
+ * Inbox 的停止控制面或 Agent 内部清理在传输请求结束前调用。
2619
+ *
2620
+ * 复用 SubmissionLifecycle 的批量选择与取消语义,
2621
+ * 避免再从活跃 Pi adapter 反向推断持久记录。
2622
+ */
2623
+ async stopTurn(
2624
+ requestId?: string,
2625
+ reason?: string,
2626
+ ): Promise<{ ok: boolean }> {
2627
+ const result = await this.submissions.stop(
2628
+ requestId,
2629
+ reason ?? "Stopped",
2630
+ );
2631
+ await this.broadcastApprovals();
2632
+ return result;
2633
+ }
2634
+
2635
+ /** 返回当前已装配 User Agent 的执行档位,不提供 Session 覆盖。 */
2636
+ executionLevel(): ExecutionLevel {
2637
+ return this.assembly().profile.executionLevel;
2638
+ }
2639
+
2640
+ /**
2641
+ * 读取一条仍待决定的 Tool 或临时 Agent 执行。
2642
+ *
2643
+ * @remarks
2644
+ * Host 在处理 `allow_level` 前读取可信的 Tool 所需档位,客户端不能提交目标档位。
2645
+ */
2646
+ getPendingApproval(executionId: string): ApprovalReceipt | null {
2647
+ const temporary = this.temporaryAgentApprovals.get(executionId);
2648
+ if (temporary) return temporary.receipt;
2649
+ const approval = this.approvals.read(executionId);
2650
+ if (!approval || approval.status !== "pending") return null;
2651
+ return {
2652
+ executionId: approval.executionId,
2653
+ source: approval.source,
2654
+ action: approval.toolName,
2655
+ summary: approval.summary,
2656
+ executionLevel: approval.executionLevel,
2657
+ requiredExecutionLevel: approval.requiredExecutionLevel,
2658
+ inputJson: approval.inputJson,
2659
+ requestId: approval.requestId,
2660
+ };
2661
+ }
2662
+
2663
+ /**
2664
+ * 对一条待处理执行应用一次允许或拒绝决定。
2665
+ *
2666
+ * @remarks
2667
+ * `allow_level` 必须由 Host 先更新 User Agent,再以 `allow_once` 续跑当前固定 Turn。
2668
+ */
2669
+ async decideApproval(
2670
+ executionId: string,
2671
+ decision: ApprovalDecision,
2672
+ ): Promise<{ ok: boolean }> {
2673
+ if (decision.decision === "allow_level") {
2674
+ throw new Error(
2675
+ "allow_level must be applied by the User Agent Host before resuming the Tool",
2676
+ );
2677
+ }
2678
+ const temporary = this.temporaryAgentApprovals.get(executionId);
2679
+ if (temporary) {
2680
+ this.temporaryAgentApprovals.delete(executionId);
2681
+ temporary.resolve(
2682
+ decision.decision === "allow_once"
2683
+ ? { approved: true }
2684
+ : { approved: false, reason: decision.reason },
2685
+ );
2686
+ await this.broadcastApprovals();
2687
+ return { ok: true };
2688
+ }
2689
+ return this.approvals.decide(executionId, decision);
2690
+ }
2691
+
2692
+ // 作用:在用户做出持久 Tool 审批决定后续跑对应 Submission。
2693
+ // 调用:Cloudflare Agents SDK 按 `schedule` 保存的回调名唤醒 Agent 时调用。
2694
+ // 原因:审批等待可跨 Durable Object 休眠,因此续跑必须重读持久决定、去重 Tool 结果并重进恢复入口。
2695
+ async _piApprovalContinuation(
2696
+ data?: ApprovalContinuationData,
2697
+ ): Promise<void> {
2698
+ if (!data?.submissionId || !data.approvalExecutionId) return;
2699
+ const submission = this.readSubmission(data.submissionId);
2700
+ if (!submission || isTerminalSubmissionStatus(submission.status)) {
2701
+ return;
2702
+ }
2703
+ const approval = this.approvals.read(data.approvalExecutionId);
2704
+ if (!approval) {
2705
+ await this.submissions.finish(
2706
+ submission,
2707
+ "failed",
2708
+ `Approval continuation is missing: ${data.approvalExecutionId}`,
2709
+ );
2710
+ return;
2711
+ }
2712
+ await this.ensureRuntimeReady();
2713
+ try {
2714
+ await this.assertPinnedSubmission(submission);
2715
+ let decision = await this.materializeRecoveredToolResults(
2716
+ submission,
2717
+ );
2718
+ if (approval.status === "approved") {
2719
+ const settled = this.readToolSettlement(
2720
+ submission.submissionId,
2721
+ approval.toolCallId,
2722
+ );
2723
+ if (!settled) {
2724
+ const adapter = this.createSubmissionExecutionAdapter(submission);
2725
+ let available = true;
2726
+ try {
2727
+ available = await adapter.retryTool({
2728
+ toolName: approval.toolName,
2729
+ toolCallId: approval.toolCallId,
2730
+ input: JSON.parse(approval.inputJson),
2731
+ });
2732
+ } catch {
2733
+ // The governed Tool persisted its bounded error ToolResult.
2734
+ }
2735
+ if (!available) {
2736
+ throw new Error(
2737
+ `Approved Tool is denied: ${approval.toolName}`,
2738
+ );
2739
+ }
2740
+ if (!this.readToolSettlement(
2741
+ submission.submissionId,
2742
+ approval.toolCallId,
2743
+ )) {
2744
+ throw new Error(
2745
+ `Approved Tool produced no durable settlement: ${approval.toolName}`,
2746
+ );
2747
+ }
2748
+ }
2749
+ decision = await this.materializeRecoveredToolResults(
2750
+ submission,
2751
+ );
2752
+ }
2753
+ if (approval.status === "rejected") {
2754
+ decision = await this.materializeRecoveredToolResults(
2755
+ submission,
2756
+ );
2757
+ }
2758
+ const result = decision.recoveredToolSettlements.find(
2759
+ (item) => item.toolCallId === approval.toolCallId,
2760
+ );
2761
+ if (!result) {
2762
+ throw new Error(
2763
+ `Approval continuation has no ToolResult: ${approval.toolCallId}`,
2764
+ );
2765
+ }
2766
+ await this.submissions.recover(submission.submissionId);
2767
+ } catch (error) {
2768
+ await this.submissions.finish(
2769
+ submission,
2770
+ "failed",
2771
+ errorText(error),
2772
+ );
2773
+ }
2774
+ }
2775
+
2776
+ // 作用:把审批、队列和 Session 活动投影到 Agent 可广播状态。
2777
+ // 调用:启动、准入、审批变化和 Turn 完成时调用。
2778
+ // 原因:这些都是可重建的 UI 投影,相同内容不重复 `setState`,投影失败也不能阻断执行。
2779
+ private async broadcastApprovals(
2780
+ inactiveActivity?: RuntimeActivity,
2781
+ ): Promise<void> {
2782
+ try {
2783
+ const approvals = [
2784
+ ...this.approvals.list(),
2785
+ ...[...this.temporaryAgentApprovals.values()].map(
2786
+ ({ receipt }) => receipt,
2787
+ ),
2788
+ ];
2789
+ const running = this.db.submissions.findRunning();
2790
+ const pending = this.db.submissions.listPending();
2791
+ const current = running ?? pending[0];
2792
+ const queued = (running ? pending : pending.slice(1)).map(
2793
+ (submission, index) => {
2794
+ let preview = "Queued message";
2795
+ if (submission.queuedInputJson) {
2796
+ try {
2797
+ const message = JSON.parse(
2798
+ submission.queuedInputJson,
2799
+ ) as PiCanonicalUserInput;
2800
+ preview = (typeof message.content === "string"
2801
+ ? message.content
2802
+ : message.content
2803
+ .flatMap((part) =>
2804
+ part.type === "text" ? [part.text] : []
2805
+ )
2806
+ .join(" ")
2807
+ ).trim().slice(0, 160) || preview;
2808
+ } catch {
2809
+ // Corrupt payload is surfaced when the queue pump activates it.
2810
+ }
2811
+ }
2812
+ return {
2813
+ submissionId: submission.submissionId,
2814
+ messageId:
2815
+ submission.userMessageId ?? submission.submissionId,
2816
+ preview,
2817
+ position: index + 1,
2818
+ createdAt: submission.createdAt,
2819
+ };
2820
+ },
2821
+ );
2822
+ const turn: RuntimeTurnState = {
2823
+ ...(current
2824
+ ? { activeSubmissionId: current.submissionId }
2825
+ : {}),
2826
+ steerable: Boolean(
2827
+ current &&
2828
+ this.db.approvals.listPendingForSubmission(
2829
+ current.submissionId,
2830
+ ).length === 0
2831
+ ),
2832
+ hasPendingSteer: Boolean(
2833
+ current &&
2834
+ this.db.steers.listForSubmission(current.submissionId).length > 0
2835
+ ),
2836
+ queued,
2837
+ };
2838
+ const activity: RuntimeActivity = approvals.length > 0
2839
+ ? "needs-input"
2840
+ : current || queued.length > 0
2841
+ ? "working"
2842
+ : inactiveActivity ??
2843
+ (this.state.activity?.activity === "needs-input"
2844
+ ? "needs-input"
2845
+ : "idle");
2846
+ const currentActivity = this.state.activity;
2847
+ const nextActivity = currentActivity?.activity === activity
2848
+ ? currentActivity
2849
+ : {
2850
+ activity,
2851
+ revision: (currentActivity?.revision ?? 0) + 1,
2852
+ };
2853
+ if (
2854
+ this.state?.approvals === undefined ||
2855
+ json(this.state.approvals) !== json(approvals) ||
2856
+ json(this.state.turn) !== json(turn) ||
2857
+ nextActivity !== currentActivity
2858
+ ) {
2859
+ this.setState({
2860
+ ...this.state,
2861
+ activity: nextActivity,
2862
+ approvals,
2863
+ turn,
2864
+ });
2865
+ }
2866
+ const projection = this.runtimeSnapshot?.bindings.turnEvents
2867
+ ?.onActivityChanged?.(nextActivity);
2868
+ if (projection) {
2869
+ this.ctx.waitUntil(
2870
+ projection.catch(() => undefined),
2871
+ );
2872
+ }
2873
+ } catch {
2874
+ // Approval projection is best effort and must not block execution.
2875
+ }
2876
+ }
2877
+
2878
+ // #endregion
2879
+
2880
+ }