@springbrand/agent-runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/package.json +28 -0
  2. package/src/db/approval.repo.ts +291 -0
  3. package/src/db/ext-context.repo.ts +34 -0
  4. package/src/db/index.ts +83 -0
  5. package/src/db/message-ui.repo.ts +39 -0
  6. package/src/db/milestone.repo.ts +96 -0
  7. package/src/db/runtime-event-outbox.repo.ts +89 -0
  8. package/src/db/schema.ts +164 -0
  9. package/src/db/settlement.repo.ts +104 -0
  10. package/src/db/steer.repo.ts +73 -0
  11. package/src/db/submission.repo.ts +323 -0
  12. package/src/index.ts +133 -0
  13. package/src/kernel/approval-lifecycle.ts +552 -0
  14. package/src/kernel/bindings.ts +898 -0
  15. package/src/kernel/degradation.ts +15 -0
  16. package/src/kernel/extensions.ts +108 -0
  17. package/src/kernel/profile.ts +116 -0
  18. package/src/kernel/public-contracts.ts +17 -0
  19. package/src/kernel/receipts.ts +124 -0
  20. package/src/kernel/recoverable-chat-agent.ts +899 -0
  21. package/src/kernel/state.ts +76 -0
  22. package/src/kernel/submission-lifecycle.ts +600 -0
  23. package/src/layers/context/budget/gate.ts +88 -0
  24. package/src/layers/orchestration/subagents/agent-types/contract.ts +78 -0
  25. package/src/layers/orchestration/subagents/agent-types/extract/index.ts +47 -0
  26. package/src/layers/orchestration/subagents/agent-types/fanout/index.ts +53 -0
  27. package/src/layers/orchestration/subagents/agent-types/registry.ts +16 -0
  28. package/src/layers/orchestration/temporary-agent/core.ts +152 -0
  29. package/src/layers/orchestration/temporary-agent/runner.ts +133 -0
  30. package/src/layers/orchestration/temporary-agent/workspace.ts +154 -0
  31. package/src/lib/artifacts.ts +54 -0
  32. package/src/lib/egress.ts +44 -0
  33. package/src/lib/execution-level.ts +27 -0
  34. package/src/lib/extension-name.ts +18 -0
  35. package/src/lib/host-actions.ts +57 -0
  36. package/src/lib/mcp.ts +86 -0
  37. package/src/lib/model-catalog.ts +7 -0
  38. package/src/lib/prompt.ts +139 -0
  39. package/src/lib/telemetry-dev.ts +44 -0
  40. package/src/pi/assembly/context.ts +510 -0
  41. package/src/pi/assembly/extensions.ts +661 -0
  42. package/src/pi/assembly/index.ts +19 -0
  43. package/src/pi/assembly/snapshot.ts +200 -0
  44. package/src/pi/message/contract.ts +8 -0
  45. package/src/pi/message/conversion.ts +73 -0
  46. package/src/pi/message/index.ts +3 -0
  47. package/src/pi/message/projection.ts +604 -0
  48. package/src/pi/runtime-adapter/assembly.ts +552 -0
  49. package/src/pi/runtime-adapter/execution.ts +683 -0
  50. package/src/pi/runtime-adapter/index.ts +232 -0
  51. package/src/pi/runtime-adapter/models.ts +243 -0
  52. package/src/pi/runtime-adapter/recovery.ts +805 -0
  53. package/src/pi/runtime-adapter/transcript.ts +825 -0
  54. package/src/pi/session/index.ts +24 -0
  55. package/src/pi/session/storage.ts +353 -0
  56. package/src/pi/tool/ai-adapter.ts +100 -0
  57. package/src/pi/tool/base.ts +110 -0
  58. package/src/pi/tool/compiler.ts +444 -0
  59. package/src/pi/tool/core-host.ts +48 -0
  60. package/src/pi/tool/core.ts +251 -0
  61. package/src/pi/tool/index.ts +32 -0
  62. package/src/pi/tool/mcp.ts +319 -0
  63. package/src/pi/tool/schedule.ts +198 -0
  64. package/src/pi/tool/skill.ts +455 -0
  65. package/src/pi/tool/subagent.ts +148 -0
  66. package/src/pi/tool/web-search/api.ts +1292 -0
  67. package/src/pi/tool/web-search/index.ts +2 -0
  68. package/src/pi/tool/web-search/web-search.ts +127 -0
  69. package/src/pi/tool/workspace-sandbox.ts +664 -0
  70. package/src/pi/turn/approval.ts +181 -0
  71. package/src/pi/turn/index.ts +62 -0
  72. package/src/pi/turn/tool-recovery.ts +792 -0
  73. package/src/plugins.ts +1024 -0
  74. package/src/runtime-agent.ts +654 -0
  75. package/src/runtime.ts +2880 -0
@@ -0,0 +1,825 @@
1
+ import type { SnapshotMessage, SqlTaggedTemplate } from "agents/chat";
2
+ import type {
3
+ AgentMessage,
4
+ CustomMessage,
5
+ } from "@earendil-works/pi-agent-core";
6
+ import type {
7
+ Api,
8
+ AssistantMessage,
9
+ Model,
10
+ Models,
11
+ ToolResultMessage,
12
+ UserMessage,
13
+ } from "@earendil-works/pi-ai";
14
+ import type { UIMessage } from "ai";
15
+ import { compactPiContext } from "../assembly";
16
+ import {
17
+ applyPiToolResult,
18
+ captureUIUserSidecar,
19
+ piAssistantToUIMessage,
20
+ type PiToolApprovalView,
21
+ type UIUserSidecar,
22
+ } from "../message";
23
+ import {
24
+ appendSessionMessageSync,
25
+ clearSessionSync,
26
+ PiTranscriptStore,
27
+ moveSessionLeafSync,
28
+ } from "../session";
29
+ import type { PiCanonicalMessageCommit } from "./execution";
30
+ import type { PiRecoveredToolSettlement } from "./recovery";
31
+ import type { RuntimeModelUsageEvent } from "../../kernel/bindings";
32
+
33
+ /**
34
+ * 本文件负责保存 Pi 的规范消息,并按浏览器或 Extension Host 的需要投影历史。
35
+ *
36
+ * @remarks
37
+ * Runtime、Pi、Submission、Turn 和 transcript 的统一定义见本目录入口 `./index.ts`。
38
+ */
39
+
40
+ // #region Transcript 持久化契约
41
+
42
+ /**
43
+ * 定义 Transcript 读写持久层所需的最小能力。
44
+ *
45
+ * @remarks
46
+ * `PiRuntimeAdapter.createTranscript` 接收该端口,`AgentRuntimeKernel` 在构造时用 Runtime 数据库实现它。
47
+ *
48
+ * 端口只暴露投影和旁路数据所需的操作,避免 Transcript 直接依赖具体 Repository。
49
+ */
50
+ export interface PiTranscriptDurability {
51
+ /**
52
+ * 在一个同步事务中执行一组 Transcript 写入。
53
+ *
54
+ * `importMessages` 用它保证整批导入共享同一个持久化边界;回调必须保持同步。
55
+ */
56
+ transaction<T>(run: () => T): T;
57
+ /**
58
+ * 保存一条用户消息的浏览器补充数据。
59
+ *
60
+ * `append` 仅在规范消息首次插入成功时调用,避免重复 ID 改写原来的用户视图。
61
+ */
62
+ upsertUserSidecar(id: string, body: string): void;
63
+ /**
64
+ * 读取一条用户消息的浏览器补充数据。
65
+ *
66
+ * `browserMessages` 重建用户视图时调用;没有补充数据时返回 `null` 并回退到规范消息。
67
+ */
68
+ readUserSidecar(id: string): string | null;
69
+ /**
70
+ * 删除全部用户消息补充数据。
71
+ *
72
+ * `clear` 与规范 Session 一起清理它,避免清空聊天后残留旧附件或元数据。
73
+ */
74
+ clearUserSidecars(): void;
75
+ /**
76
+ * 读取当前消息 ID 到 Submission ID 的关联。
77
+ *
78
+ * `storedMessages` 用它补齐 Pi Session 本身不保存的 Runtime Submission 归属。
79
+ */
80
+ listMessageSubmissionLinks(): Map<string, string>;
81
+ /**
82
+ * 读取一个 Submission 的浏览器投影字段。
83
+ *
84
+ * `browserMessages` 投影助手消息时调用,以复用稳定的消息 ID、时间和终态。
85
+ */
86
+ findSubmissionProjection(submissionId: string): {
87
+ assistantMessageId: string;
88
+ createdAt: number;
89
+ completedAt?: number | null;
90
+ status?: unknown;
91
+ error?: string | null;
92
+ } | null;
93
+ /**
94
+ * 读取一个 Submission 下的工具审批视图。
95
+ *
96
+ * `approvalViews` 在投影助手工具调用时调用,并只把已知决策映射成布尔值。
97
+ */
98
+ listApprovalViews(submissionId: string): readonly {
99
+ toolCallId: string;
100
+ executionId: string;
101
+ status: string;
102
+ }[];
103
+ }
104
+
105
+ /**
106
+ * 表示当前 Pi 分支上一条带 Runtime 关联信息的规范消息。
107
+ *
108
+ * @remarks
109
+ * `storedMessages` 生成该结构,浏览器投影、Host 历史和 Turn 启动逻辑读取它。
110
+ *
111
+ * Submission 关联和解析后的时间集中放在这里,避免每个读取方重复查询和回退。
112
+ */
113
+ export interface StoredTranscriptMessage {
114
+ id: string;
115
+ submissionId: string | null;
116
+ message: AgentMessage;
117
+ createdAt: number;
118
+ }
119
+
120
+ export interface PiCanonicalTranscriptSnapshotEntry {
121
+ readonly id: string;
122
+ readonly message: AgentMessage;
123
+ readonly createdAt: number;
124
+ readonly userSidecar?: UIUserSidecar;
125
+ }
126
+
127
+ export interface PiCanonicalTranscriptSnapshot {
128
+ readonly entries: readonly PiCanonicalTranscriptSnapshotEntry[];
129
+ }
130
+
131
+ // #endregion
132
+
133
+ // #region 浏览器与 Host 投影辅助函数
134
+
135
+ // 作用:把一条 Pi 用户消息还原成浏览器使用的消息视图。
136
+ // 调用:browserMessages 遇到用户消息时调用,并传入持久化 ID、时间和可选 sidecar。
137
+ // 原因:sidecar 优先保留附件和 UI 元数据;待确认:timestamp 为 0 时是否应保留而不是回退到 createdAt。
138
+ function userUIMessage(
139
+ message: UserMessage,
140
+ options: {
141
+ id: string;
142
+ createdAt: number;
143
+ sidecar?: UIUserSidecar;
144
+ },
145
+ ): UIMessage {
146
+ const parts: UIMessage["parts"] = options.sidecar
147
+ ? [...options.sidecar.parts]
148
+ : typeof message.content === "string"
149
+ ? [{ type: "text", text: message.content }]
150
+ : message.content.flatMap(
151
+ (part): Array<UIMessage["parts"][number]> => {
152
+ if (part.type === "text") return [{ type: "text", text: part.text }];
153
+ if (part.type === "image") {
154
+ return [{
155
+ type: "file",
156
+ mediaType: part.mimeType,
157
+ url: `data:${part.mimeType};base64,${part.data}`,
158
+ }];
159
+ }
160
+ return [];
161
+ },
162
+ );
163
+ return {
164
+ id: options.id,
165
+ role: "user",
166
+ parts,
167
+ metadata: options.sidecar?.metadata ?? {
168
+ createdAt: message.timestamp || options.createdAt,
169
+ },
170
+ };
171
+ }
172
+
173
+ // 作用:从用户或助手消息里取出 Extension Host 可以读取的纯文本。
174
+ // 调用:hostMessages 为每条用户或助手历史调用,调用方只会收到一个字符串。
175
+ // 原因:thinking、工具调用和媒体不是 Host 文本契约的一部分;文本片段直接拼接可保留原始分段内容。
176
+ function hostText(message: UserMessage | AssistantMessage): string {
177
+ return typeof message.content === "string"
178
+ ? message.content
179
+ : message.content
180
+ .flatMap((part) => part.type === "text" ? [part.text] : [])
181
+ .join("");
182
+ }
183
+
184
+ // #endregion
185
+
186
+ // #region Transcript 适配器
187
+
188
+ /**
189
+ * 管理一个 Runtime 实例的 Pi 规范 Transcript 及其对外投影。
190
+ *
191
+ * @remarks
192
+ * `PiRuntimeAdapter.createTranscript` 在 `AgentRuntimeKernel` 构造期间创建它,Runtime 随后把所有消息提交、恢复、压缩和历史读取收口到这里。
193
+ *
194
+ * Pi Session 保存规范分支,Runtime 数据库保存 Submission、审批和用户 sidecar;分开保存可避免把 UI 字段写进模型上下文。
195
+ */
196
+ export class PiRuntimeTranscript {
197
+ private readonly store: PiTranscriptStore;
198
+
199
+ /**
200
+ * 用当前 Agent 的 SQLite、Runtime 持久化端口和 Pi 模型表创建 Transcript。
201
+ *
202
+ * `PiRuntimeAdapter.createTranscript` 每个 Runtime Kernel 构造时调用一次;`hasActiveTurn` 必须反映当前 Submission 生命周期。
203
+ *
204
+ * Session 统一包住 `DoSqliteSessionStorage`,模型表只用于压缩,活动 Turn 检查只用于阻止并发导入。
205
+ */
206
+ constructor(
207
+ private readonly sql: SqlTaggedTemplate,
208
+ private readonly durability: PiTranscriptDurability,
209
+ private readonly models: Models,
210
+ private readonly hasActiveTurn: () => boolean,
211
+ ) {
212
+ this.store = new PiTranscriptStore(sql);
213
+ }
214
+
215
+ // #region 规范消息写入
216
+
217
+ /**
218
+ * 向当前 Pi 分支追加一条规范消息,并按需保存 Submission 关联和用户 sidecar。
219
+ *
220
+ * Runtime 接收新用户消息、Extension Host 追加消息以及本类的 Turn 提交辅助方法都会调用它;返回 `false` 表示该 ID 已存在。
221
+ *
222
+ * sidecar 只随首次成功插入写入,使消息 ID 同时承担幂等键,重复请求不能覆盖原视图。
223
+ */
224
+ append(
225
+ id: string,
226
+ message: AgentMessage,
227
+ options: {
228
+ submissionId?: string;
229
+ createdAt?: number;
230
+ userMessage?: UIMessage & { role: "user" };
231
+ } = {},
232
+ ): boolean {
233
+ const inserted = appendSessionMessageSync(this.sql, {
234
+ id,
235
+ message,
236
+ ...(options.submissionId
237
+ ? { submissionId: options.submissionId }
238
+ : {}),
239
+ ...(options.createdAt === undefined
240
+ ? {}
241
+ : { createdAt: options.createdAt }),
242
+ });
243
+ if (inserted && options.userMessage) {
244
+ const sidecar = captureUIUserSidecar(options.userMessage);
245
+ this.durability.upsertUserSidecar(id, JSON.stringify(sidecar));
246
+ }
247
+ return inserted;
248
+ }
249
+
250
+ /**
251
+ * 保存一次 Turn 产生的助手消息或工具结果。
252
+ *
253
+ * `commitAdapterMessage` 在执行适配器发出 `commit-turn` 时调用,并传入同一 Submission 内递增的助手序号。
254
+ *
255
+ * 工具结果用 toolCallId 去重,助手步骤用 ordinal 区分;这两种 ID 形状还被恢复写入复用,不能单边修改。
256
+ */
257
+ commitTurnMessage(
258
+ submissionId: string,
259
+ message: AssistantMessage | ToolResultMessage,
260
+ ordinal: number,
261
+ ): { id: string; inserted: boolean } {
262
+ const id = message.role === "toolResult"
263
+ ? `${submissionId}:tool:${message.toolCallId}`
264
+ : `${submissionId}:assistant:${ordinal}`;
265
+ return {
266
+ id,
267
+ inserted: this.append(id, message, {
268
+ submissionId,
269
+ createdAt: message.timestamp,
270
+ }),
271
+ };
272
+ }
273
+
274
+ /**
275
+ * 把执行适配器发出的规范提交路由到对应写入路径。
276
+ *
277
+ * `AgentRuntimeKernel` 的 `onCanonicalMessage` 回调在数据库事务内调用它;用户 steering 使用自带 ID,其余 Turn 消息使用统一生成规则。
278
+ *
279
+ * 分流集中在这里可让实时执行和 Transcript 的消息身份规则保持一致。
280
+ */
281
+ commitAdapterMessage(
282
+ submissionId: string,
283
+ commit: PiCanonicalMessageCommit,
284
+ ): { id: string; inserted: boolean } {
285
+ return commit.kind === "append-user"
286
+ ? {
287
+ id: commit.id,
288
+ inserted: this.append(commit.id, commit.message, {
289
+ submissionId,
290
+ createdAt: commit.message.timestamp,
291
+ }),
292
+ }
293
+ : this.commitTurnMessage(
294
+ submissionId,
295
+ commit.message,
296
+ commit.ordinal,
297
+ );
298
+ }
299
+
300
+ /** Stable identity makes terminal recovery replay idempotent. */
301
+ appendTurnAbortedMarker(
302
+ submissionId: string,
303
+ timestamp: number,
304
+ ): boolean {
305
+ return this.append(`${submissionId}:turn-aborted`, {
306
+ role: "custom",
307
+ customType: "turn-aborted",
308
+ content: [{
309
+ type: "text",
310
+ text: [
311
+ "<turn_aborted>",
312
+ "The previous turn was interrupted by the user.",
313
+ "</turn_aborted>",
314
+ ].join("\n"),
315
+ }],
316
+ display: true,
317
+ timestamp,
318
+ } satisfies CustomMessage, { submissionId, createdAt: timestamp });
319
+ }
320
+
321
+ /**
322
+ * 把恢复流程重建出的工具终态补写进规范 Transcript。
323
+ *
324
+ * `materializeRecoveredToolResults` 在恢复决策补齐 settlement 后调用,并先按原工具调用顺序排序。
325
+ *
326
+ * ID 与实时工具结果使用相同的 `submissionId + toolCallId` 形状,因此重复恢复只会命中 `append` 的幂等检查。
327
+ */
328
+ appendRecoveredToolSettlement(
329
+ submissionId: string,
330
+ settlement: PiRecoveredToolSettlement,
331
+ ): boolean {
332
+ return this.append(
333
+ `${submissionId}:tool:${settlement.toolCallId}`,
334
+ {
335
+ role: "toolResult",
336
+ toolCallId: settlement.toolCallId,
337
+ toolName: settlement.toolName,
338
+ content: settlement.result.content,
339
+ details: settlement.result.details,
340
+ isError: settlement.isError,
341
+ timestamp: settlement.createdAt,
342
+ },
343
+ {
344
+ submissionId,
345
+ createdAt: settlement.createdAt,
346
+ },
347
+ );
348
+ }
349
+
350
+ /**
351
+ * 按当前规范分支中工具调用出现的顺序排列恢复结果。
352
+ *
353
+ * `materializeRecoveredToolResults` 在批量补写前调用;调用方应使用返回的新数组,不依赖输入数组被修改。
354
+ *
355
+ * 顺序从助手消息里的 toolCall ID 推导,避免恢复里程碑的读取顺序改变模型看到的工具结果顺序。
356
+ */
357
+ orderRecoveredToolSettlements(
358
+ settlements: readonly PiRecoveredToolSettlement[],
359
+ ): PiRecoveredToolSettlement[] {
360
+ const callOrder = new Map<string, number>();
361
+ for (const entry of this.store.branch()) {
362
+ if (entry.type !== "message") continue;
363
+ const message = entry.message;
364
+ if (message.role !== "assistant") continue;
365
+ for (const part of message.content) {
366
+ if (part.type === "toolCall" && !callOrder.has(part.id)) {
367
+ callOrder.set(part.id, callOrder.size);
368
+ }
369
+ }
370
+ }
371
+ return [...settlements].sort(
372
+ (left, right) =>
373
+ (callOrder.get(left.toolCallId) ?? Number.MAX_SAFE_INTEGER) -
374
+ (callOrder.get(right.toolCallId) ?? Number.MAX_SAFE_INTEGER),
375
+ );
376
+ }
377
+
378
+ /**
379
+ * 读取当前 Pi 分支上的全部规范消息。
380
+ *
381
+ * Turn 执行器在创建 PiCore 初始状态时调用,压缩一致性检查也以同一分支为准。
382
+ *
383
+ * 这里只返回 `message` entry;compaction 等 Session entry 由上下文转换阶段处理,不能混入原始消息列表。
384
+ */
385
+ async canonicalMessages(): Promise<AgentMessage[]> {
386
+ return this.store.branch().flatMap((entry) =>
387
+ entry.type === "message" ? [entry.message] : []
388
+ );
389
+ }
390
+
391
+ /**
392
+ * 生成可恢复流只需要的消息 ID 与角色快照。
393
+ *
394
+ * `executeSubmission` 启动 `runRecoverableChatFiber` 前调用,并把结果写进 Fiber 快照。
395
+ *
396
+ * 快照不复制消息正文,正文仍以耐久 Transcript 为准,避免维护第二份完整历史。
397
+ */
398
+ async snapshotMessages(): Promise<SnapshotMessage[]> {
399
+ return (await this.storedMessages()).map((entry) => ({
400
+ id: entry.id,
401
+ role: entry.message.role,
402
+ }));
403
+ }
404
+
405
+ /**
406
+ * 按 ID 查找一条规范用户消息。
407
+ *
408
+ * regenerate 的 admission 检查在移动分支叶子前调用,并核对请求内容是否与原用户消息一致。
409
+ *
410
+ * 同时检查 entry 类型和消息角色,防止其他 Session entry 或助手消息被当作 regenerate 起点。
411
+ */
412
+ async findUserMessage(id: string): Promise<UserMessage | undefined> {
413
+ const entry = this.store.entry(id);
414
+ return entry?.type === "message" && entry.message.role === "user"
415
+ ? entry.message
416
+ : undefined;
417
+ }
418
+
419
+ // #endregion
420
+
421
+ // #region 分支、压缩与导入
422
+
423
+ /**
424
+ * 把当前 Pi 分支叶子移动到指定 Session entry。
425
+ *
426
+ * regenerate admission 在验证原用户消息后调用,使后续写入从该用户消息继续。
427
+ *
428
+ * 这里委托同步 Session 存储辅助函数写入叶子标记,不删除原分支,保留 Pi 的分支结构。
429
+ */
430
+ moveLeaf(id: string): void {
431
+ moveSessionLeafSync(this.sql, id);
432
+ }
433
+
434
+ /**
435
+ * 在 Pi 请求模型前按 Runtime 阈值压缩当前上下文,并把压缩 entry 写回 Session。
436
+ *
437
+ * `AgentRuntimeKernel.transformPiContext` 作为 Pi 的 `transformContext` 调用它,并提供本次固定的模型、密钥和取消信号。
438
+ *
439
+ * 方法先逐条核对调用参数与耐久分支;不一致或压缩不可用时保留原消息,避免把漂移的内存上下文提交成新的规范分支。
440
+ */
441
+ async compactContext(
442
+ messages: AgentMessage[],
443
+ options: {
444
+ compactAfterTokens: number;
445
+ model: Model<Api>;
446
+ apiKey: string;
447
+ signal?: AbortSignal;
448
+ submissionId?: string;
449
+ onCompactionPersisted?: (event: RuntimeModelUsageEvent) => void;
450
+ },
451
+ ): Promise<AgentMessage[]> {
452
+ const branch = this.store.branch();
453
+ const current = branch.flatMap((entry) =>
454
+ entry.type === "message" ? [entry.message] : []
455
+ );
456
+ if (
457
+ current.length !== messages.length ||
458
+ current.some((message, index) =>
459
+ JSON.stringify(message) !== JSON.stringify(messages[index])
460
+ )
461
+ ) {
462
+ console.warn(
463
+ "[runtime-compaction:degraded]",
464
+ JSON.stringify({ reason: "canonical-message-mismatch" }),
465
+ );
466
+ return messages;
467
+ }
468
+ const result = await compactPiContext({
469
+ branch,
470
+ compactAfterTokens: options.compactAfterTokens,
471
+ models: this.models,
472
+ model: options.model,
473
+ apiKey: options.apiKey,
474
+ signal: options.signal,
475
+ });
476
+ if (result.compactionEntry) {
477
+ // 整对象写入:assembly 侧已经造好了完整的 compaction 条目,这里不再拆成位置
478
+ // 参数。旧写法拆 5 个参数喂 pi 的 Session.appendCompaction,pi 0.81 把它加到
479
+ // 7 个参数(usage / retainedTail)之后,多出来的会被静默丢掉且编译器不报。
480
+ this.durability.transaction(() => {
481
+ const eventId = this.store.appendCompaction(result.compactionEntry!);
482
+ if (
483
+ options.submissionId &&
484
+ options.onCompactionPersisted &&
485
+ result.modelUsage
486
+ ) {
487
+ options.onCompactionPersisted({
488
+ eventId,
489
+ submissionId: options.submissionId,
490
+ kind: "compaction",
491
+ ...result.modelUsage,
492
+ });
493
+ }
494
+ });
495
+ }
496
+ if (result.degraded) {
497
+ console.warn(
498
+ "[runtime-compaction:degraded]",
499
+ JSON.stringify({ reason: "compaction-unavailable" }),
500
+ );
501
+ }
502
+ return result.messages;
503
+ }
504
+
505
+ /**
506
+ * 把一组浏览器消息一次性转换并导入规范 Transcript。
507
+ *
508
+ * `AgentRuntimeKernel.addMessages` 只在真实 fork 或 import 场景调用;活动 Turn 期间会直接拒绝,调用方应等当前 Submission 稳定后再导入。
509
+ *
510
+ * 整批写入使用同一个同步事务,用户消息用 import ID 开启投影分组,后续助手步骤沿用该分组并保留首条用户 sidecar。
511
+ *
512
+ * 待确认:当前代码没有拒绝以助手消息开头的历史,而这类消息没有 Submission 关联,会被浏览器投影略过。
513
+ */
514
+ importSnapshot(snapshot: PiCanonicalTranscriptSnapshot): void {
515
+ if (this.hasActiveTurn()) {
516
+ throw new Error("Cannot import messages while a Pi Turn is active");
517
+ }
518
+ this.durability.transaction(() => {
519
+ let currentSubmissionId: string | undefined;
520
+ for (const entry of snapshot.entries) {
521
+ if (entry.message.role === "user") {
522
+ currentSubmissionId = `import:${entry.id}`;
523
+ }
524
+ const appended = this.append(entry.id, entry.message, {
525
+ submissionId: currentSubmissionId,
526
+ createdAt: entry.createdAt,
527
+ ...(entry.message.role === "user" && entry.userSidecar
528
+ ? {
529
+ userMessage: {
530
+ id: entry.id,
531
+ role: "user",
532
+ parts: [...entry.userSidecar.parts],
533
+ ...(entry.userSidecar.metadata === undefined
534
+ ? {}
535
+ : { metadata: entry.userSidecar.metadata }),
536
+ } satisfies UIMessage & { role: "user" },
537
+ }
538
+ : {}),
539
+ });
540
+ if (!appended) {
541
+ throw new Error(`Pi Session message "${entry.id}" already exists`);
542
+ }
543
+ }
544
+ });
545
+ }
546
+
547
+ /** Export canonical entries through one browser-visible message identity. */
548
+ async exportSnapshot(
549
+ throughMessageId: string,
550
+ ): Promise<PiCanonicalTranscriptSnapshot> {
551
+ const stored = await this.storedMessages();
552
+ let cutoff = stored.findIndex((entry) => entry.id === throughMessageId);
553
+ if (cutoff < 0) {
554
+ const targetSubmissionId = stored.find((entry) =>
555
+ entry.submissionId &&
556
+ this.durability.findSubmissionProjection(entry.submissionId)
557
+ ?.assistantMessageId === throughMessageId
558
+ )?.submissionId;
559
+ if (targetSubmissionId) {
560
+ for (let index = stored.length - 1; index >= 0; index -= 1) {
561
+ if (stored[index]?.submissionId === targetSubmissionId) {
562
+ cutoff = index;
563
+ break;
564
+ }
565
+ }
566
+ }
567
+ }
568
+ if (cutoff < 0) {
569
+ throw new Error(`message "${throughMessageId}" not found`);
570
+ }
571
+ return {
572
+ entries: stored.slice(0, cutoff + 1).map((entry) => {
573
+ if (entry.message.role !== "user") {
574
+ return {
575
+ id: entry.id,
576
+ message: entry.message,
577
+ createdAt: entry.createdAt,
578
+ };
579
+ }
580
+ const body = this.durability.readUserSidecar(entry.id);
581
+ return {
582
+ id: entry.id,
583
+ message: entry.message,
584
+ createdAt: entry.createdAt,
585
+ ...(body
586
+ ? { userSidecar: JSON.parse(body) as UIUserSidecar }
587
+ : {}),
588
+ };
589
+ }),
590
+ };
591
+ }
592
+
593
+ /**
594
+ * 清空规范 Session 和全部用户 sidecar。
595
+ *
596
+ * 客户端 clear 事件仅在没有活动 Submission 时调用,并在外层数据库事务中继续清理其他 Turn 数据。
597
+ *
598
+ * 两类 Transcript 数据必须一起删除,否则复用消息 ID 时可能重新读到旧附件或 UI 元数据。
599
+ */
600
+ clear(): void {
601
+ clearSessionSync(this.sql);
602
+ this.durability.clearUserSidecars();
603
+ }
604
+
605
+ // #endregion
606
+
607
+ // #region 对外历史投影
608
+
609
+ /**
610
+ * 把当前规范分支重建成浏览器使用的 Pi 消息视图。
611
+ *
612
+ * `AgentRuntimeKernel.getMessages` 和聊天广播读取该结果;用户消息恢复 sidecar,助手消息按 Submission 合并步骤并套用审批与工具结果。
613
+ *
614
+ * 多个助手步骤用现有的 `step-start` 保留边界,工具结果回填原 toolCall,避免浏览器维护另一套 Turn 拼装逻辑。
615
+ */
616
+ async browserMessages(): Promise<UIMessage[]> {
617
+ const result: UIMessage[] = [];
618
+ const seenAssistantSubmissions = new Set<string>();
619
+ let activeAssistant:
620
+ | { submissionId: string; index: number }
621
+ | undefined;
622
+ for (const entry of await this.storedMessages()) {
623
+ if (entry.message.role === "user") {
624
+ const body = this.durability.readUserSidecar(entry.id);
625
+ result.push(userUIMessage(entry.message, {
626
+ id: entry.id,
627
+ createdAt: entry.createdAt,
628
+ sidecar: body
629
+ ? JSON.parse(body) as UIUserSidecar
630
+ : undefined,
631
+ }));
632
+ activeAssistant = undefined;
633
+ continue;
634
+ }
635
+ if (!entry.submissionId) continue;
636
+ if (
637
+ entry.message.role === "custom" &&
638
+ entry.message.customType === "turn-aborted"
639
+ ) {
640
+ const submission = this.durability.findSubmissionProjection(
641
+ entry.submissionId,
642
+ );
643
+ const index = activeAssistant?.submissionId === entry.submissionId
644
+ ? activeAssistant.index
645
+ : undefined;
646
+ const createdAt = submission?.createdAt ?? entry.createdAt;
647
+ const completedAt = submission?.completedAt ?? entry.createdAt;
648
+ const metadata = {
649
+ createdAt,
650
+ completedAt,
651
+ turnDurationMs: Math.max(
652
+ 0,
653
+ completedAt - createdAt,
654
+ ),
655
+ turnStatus: "aborted",
656
+ interruptedByUser: true,
657
+ };
658
+ if (index === undefined) {
659
+ seenAssistantSubmissions.add(entry.submissionId);
660
+ result.push({
661
+ id: submission?.assistantMessageId ?? entry.id,
662
+ role: "assistant",
663
+ parts: [],
664
+ metadata,
665
+ });
666
+ } else {
667
+ const previous = result[index]!;
668
+ result[index] = {
669
+ ...previous,
670
+ metadata: {
671
+ ...(previous.metadata as Record<string, unknown> | undefined),
672
+ ...metadata,
673
+ },
674
+ };
675
+ }
676
+ activeAssistant = undefined;
677
+ continue;
678
+ }
679
+ if (entry.message.role === "assistant") {
680
+ const existingIndex =
681
+ activeAssistant?.submissionId === entry.submissionId
682
+ ? activeAssistant.index
683
+ : undefined;
684
+ const submission = this.durability.findSubmissionProjection(
685
+ entry.submissionId,
686
+ );
687
+ const terminal = submission?.status !== undefined &&
688
+ submission.status !== "pending" &&
689
+ submission.status !== "running";
690
+ const completedAt = terminal ? entry.message.timestamp : undefined;
691
+ const projected = piAssistantToUIMessage(entry.message, {
692
+ id: seenAssistantSubmissions.has(entry.submissionId)
693
+ ? entry.id
694
+ : submission?.assistantMessageId ?? entry.id,
695
+ metadata: {
696
+ createdAt: submission?.createdAt ?? entry.createdAt,
697
+ completedAt,
698
+ ...(completedAt === undefined
699
+ ? {}
700
+ : {
701
+ turnDurationMs: Math.max(
702
+ 0,
703
+ completedAt - (submission?.createdAt ?? entry.createdAt),
704
+ ),
705
+ }),
706
+ turnStatus: submission?.status,
707
+ ...(submission?.error ? { error: submission.error } : {}),
708
+ },
709
+ approvals: this.approvalViews(entry.submissionId),
710
+ });
711
+ if (existingIndex === undefined) {
712
+ activeAssistant = {
713
+ submissionId: entry.submissionId,
714
+ index: result.length,
715
+ };
716
+ seenAssistantSubmissions.add(entry.submissionId);
717
+ result.push({
718
+ ...projected,
719
+ parts: [{ type: "step-start" }, ...projected.parts],
720
+ });
721
+ } else {
722
+ const previous = result[existingIndex]!;
723
+ result[existingIndex] = {
724
+ ...previous,
725
+ parts: [
726
+ ...previous.parts,
727
+ { type: "step-start" },
728
+ ...projected.parts,
729
+ ],
730
+ metadata: projected.metadata,
731
+ };
732
+ }
733
+ continue;
734
+ }
735
+ if (entry.message.role === "toolResult") {
736
+ const index = activeAssistant?.submissionId === entry.submissionId
737
+ ? activeAssistant.index
738
+ : undefined;
739
+ if (index !== undefined) {
740
+ result[index] = applyPiToolResult(
741
+ result[index]!,
742
+ entry.message,
743
+ );
744
+ }
745
+ }
746
+ }
747
+ return result;
748
+ }
749
+
750
+ /**
751
+ * 生成 Extension Host 可读取的纯文本消息历史。
752
+ *
753
+ * `_hostGetMessages` 和 Session 信息统计调用它;`limit` 有值时只保留末尾指定数量,非正数返回空数组。
754
+ *
755
+ * 该端口只暴露用户和助手文本,不泄露 thinking、工具结果或浏览器附件,并在投影完成后统一截取最新记录。
756
+ */
757
+ async hostMessages(
758
+ limit?: number,
759
+ ): Promise<Array<{ id: string; role: string; content: string }>> {
760
+ const history = (await this.storedMessages()).flatMap((entry) => {
761
+ if (
762
+ entry.message.role !== "user" &&
763
+ entry.message.role !== "assistant"
764
+ ) {
765
+ return [];
766
+ }
767
+ return [{
768
+ id: entry.id,
769
+ role: entry.message.role,
770
+ content: hostText(entry.message),
771
+ }];
772
+ });
773
+ if (limit === undefined || limit === null) return history;
774
+ return limit <= 0 ? [] : history.slice(-limit);
775
+ }
776
+
777
+ // 作用:把一个 Submission 的审批行整理成按 toolCallId 索引的浏览器视图。
778
+ // 调用:browserMessages 每次投影助手消息时调用。
779
+ // 原因:只有 approved 和 rejected 能映射为布尔决策,pending 状态只保留 executionId,避免把未决审批误写成拒绝。
780
+ private approvalViews(
781
+ submissionId: string,
782
+ ): Record<string, PiToolApprovalView> {
783
+ return Object.fromEntries(
784
+ this.durability.listApprovalViews(submissionId).map((row) => [
785
+ row.toolCallId,
786
+ {
787
+ id: row.executionId,
788
+ ...(row.status === "approved"
789
+ ? { approved: true }
790
+ : row.status === "rejected"
791
+ ? { approved: false }
792
+ : {}),
793
+ },
794
+ ]),
795
+ );
796
+ }
797
+
798
+ /**
799
+ * 读取当前 Pi 分支,并为每条规范消息补齐 Submission 关联和创建时间。
800
+ *
801
+ * 浏览器投影、Host 历史、Snapshot 以及 Turn 启动时的助手序号计算都从这里读取,调用方无需再查关联表。
802
+ *
803
+ * Session entry 的 ISO 时间优先,解析失败才回退到消息时间;非消息 entry 在这一层统一排除。
804
+ */
805
+ async storedMessages(): Promise<StoredTranscriptMessage[]> {
806
+ const submissionByMessage =
807
+ this.durability.listMessageSubmissionLinks();
808
+ return this.store.branch().flatMap((entry) => {
809
+ if (entry.type !== "message") return [];
810
+ const parsedTimestamp = Date.parse(entry.timestamp);
811
+ return [{
812
+ id: entry.id,
813
+ submissionId: submissionByMessage.get(entry.id) ?? null,
814
+ message: entry.message,
815
+ createdAt: Number.isFinite(parsedTimestamp)
816
+ ? parsedTimestamp
817
+ : entry.message.timestamp,
818
+ }];
819
+ });
820
+ }
821
+
822
+ // #endregion
823
+ }
824
+
825
+ // #endregion