@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.
- package/package.json +28 -0
- package/src/db/approval.repo.ts +291 -0
- package/src/db/ext-context.repo.ts +34 -0
- package/src/db/index.ts +83 -0
- package/src/db/message-ui.repo.ts +39 -0
- package/src/db/milestone.repo.ts +96 -0
- package/src/db/runtime-event-outbox.repo.ts +89 -0
- package/src/db/schema.ts +164 -0
- package/src/db/settlement.repo.ts +104 -0
- package/src/db/steer.repo.ts +73 -0
- package/src/db/submission.repo.ts +323 -0
- package/src/index.ts +133 -0
- package/src/kernel/approval-lifecycle.ts +552 -0
- package/src/kernel/bindings.ts +898 -0
- package/src/kernel/degradation.ts +15 -0
- package/src/kernel/extensions.ts +108 -0
- package/src/kernel/profile.ts +116 -0
- package/src/kernel/public-contracts.ts +17 -0
- package/src/kernel/receipts.ts +124 -0
- package/src/kernel/recoverable-chat-agent.ts +899 -0
- package/src/kernel/state.ts +76 -0
- package/src/kernel/submission-lifecycle.ts +600 -0
- package/src/layers/context/budget/gate.ts +88 -0
- package/src/layers/orchestration/subagents/agent-types/contract.ts +78 -0
- package/src/layers/orchestration/subagents/agent-types/extract/index.ts +47 -0
- package/src/layers/orchestration/subagents/agent-types/fanout/index.ts +53 -0
- package/src/layers/orchestration/subagents/agent-types/registry.ts +16 -0
- package/src/layers/orchestration/temporary-agent/core.ts +152 -0
- package/src/layers/orchestration/temporary-agent/runner.ts +133 -0
- package/src/layers/orchestration/temporary-agent/workspace.ts +154 -0
- package/src/lib/artifacts.ts +54 -0
- package/src/lib/egress.ts +44 -0
- package/src/lib/execution-level.ts +27 -0
- package/src/lib/extension-name.ts +18 -0
- package/src/lib/host-actions.ts +57 -0
- package/src/lib/mcp.ts +86 -0
- package/src/lib/model-catalog.ts +7 -0
- package/src/lib/prompt.ts +139 -0
- package/src/lib/telemetry-dev.ts +44 -0
- package/src/pi/assembly/context.ts +510 -0
- package/src/pi/assembly/extensions.ts +661 -0
- package/src/pi/assembly/index.ts +19 -0
- package/src/pi/assembly/snapshot.ts +200 -0
- package/src/pi/message/contract.ts +8 -0
- package/src/pi/message/conversion.ts +73 -0
- package/src/pi/message/index.ts +3 -0
- package/src/pi/message/projection.ts +604 -0
- package/src/pi/runtime-adapter/assembly.ts +552 -0
- package/src/pi/runtime-adapter/execution.ts +683 -0
- package/src/pi/runtime-adapter/index.ts +232 -0
- package/src/pi/runtime-adapter/models.ts +243 -0
- package/src/pi/runtime-adapter/recovery.ts +805 -0
- package/src/pi/runtime-adapter/transcript.ts +825 -0
- package/src/pi/session/index.ts +24 -0
- package/src/pi/session/storage.ts +353 -0
- package/src/pi/tool/ai-adapter.ts +100 -0
- package/src/pi/tool/base.ts +110 -0
- package/src/pi/tool/compiler.ts +444 -0
- package/src/pi/tool/core-host.ts +48 -0
- package/src/pi/tool/core.ts +251 -0
- package/src/pi/tool/index.ts +32 -0
- package/src/pi/tool/mcp.ts +319 -0
- package/src/pi/tool/schedule.ts +198 -0
- package/src/pi/tool/skill.ts +455 -0
- package/src/pi/tool/subagent.ts +148 -0
- package/src/pi/tool/web-search/api.ts +1292 -0
- package/src/pi/tool/web-search/index.ts +2 -0
- package/src/pi/tool/web-search/web-search.ts +127 -0
- package/src/pi/tool/workspace-sandbox.ts +664 -0
- package/src/pi/turn/approval.ts +181 -0
- package/src/pi/turn/index.ts +62 -0
- package/src/pi/turn/tool-recovery.ts +792 -0
- package/src/plugins.ts +1024 -0
- package/src/runtime-agent.ts +654 -0
- package/src/runtime.ts +2880 -0
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import { EXECUTION_LEVELS } from "../../lib/execution-level";
|
|
3
|
+
import {
|
|
4
|
+
decidePiToolApproval,
|
|
5
|
+
type PiApprovalOutcome,
|
|
6
|
+
type PiToolApproval,
|
|
7
|
+
} from "./approval";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 本文件实现 Pi Tool、审批、续跑和终态里程碑的纯恢复状态机。
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* 核心术语见本目录入口 `index.ts`;这里负责校验、重放和规划,不直接执行持久化或调度。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// #region 持久恢复契约
|
|
17
|
+
|
|
18
|
+
type RecoveryIdentity = {
|
|
19
|
+
version: 1;
|
|
20
|
+
turnId: string;
|
|
21
|
+
assemblyRevision: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 描述一个 Pi Turn 在恢复时可以重放的持久事件。
|
|
26
|
+
*
|
|
27
|
+
* Runtime 在 Tool 输入、结算、审批、续跑和终态边界写入对应变体。
|
|
28
|
+
*
|
|
29
|
+
* 每条记录都携带 Turn 与装配 revision,恢复时不能把不同配置下产生的事件拼在一起。
|
|
30
|
+
*/
|
|
31
|
+
export type PiToolRecoveryMilestone =
|
|
32
|
+
| (RecoveryIdentity & {
|
|
33
|
+
type: "tool-input";
|
|
34
|
+
toolCallId: string;
|
|
35
|
+
toolName: string;
|
|
36
|
+
input: unknown;
|
|
37
|
+
retry: "idempotent" | "non-idempotent";
|
|
38
|
+
})
|
|
39
|
+
| (RecoveryIdentity & {
|
|
40
|
+
type: "tool-result";
|
|
41
|
+
toolResult: ToolResultMessage;
|
|
42
|
+
needsContinuation?: boolean;
|
|
43
|
+
})
|
|
44
|
+
| (RecoveryIdentity & {
|
|
45
|
+
type: "tool-error";
|
|
46
|
+
toolCallId: string;
|
|
47
|
+
toolName: string;
|
|
48
|
+
error: string;
|
|
49
|
+
timestamp: number;
|
|
50
|
+
})
|
|
51
|
+
| (RecoveryIdentity & {
|
|
52
|
+
type: "approval";
|
|
53
|
+
approval: PiToolApproval;
|
|
54
|
+
})
|
|
55
|
+
| (RecoveryIdentity & {
|
|
56
|
+
type: "continuation";
|
|
57
|
+
continuationKey: string;
|
|
58
|
+
phase: "pending" | "committed";
|
|
59
|
+
timestamp: number;
|
|
60
|
+
})
|
|
61
|
+
| (RecoveryIdentity & {
|
|
62
|
+
type: "terminal";
|
|
63
|
+
phase: "intent" | "committed";
|
|
64
|
+
outcome: "succeeded" | "failed" | "aborted";
|
|
65
|
+
message?: string;
|
|
66
|
+
timestamp: number;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
interface RecoveredToolCall {
|
|
70
|
+
toolCallId: string;
|
|
71
|
+
toolName: string;
|
|
72
|
+
input?: unknown;
|
|
73
|
+
retry?: "idempotent" | "non-idempotent";
|
|
74
|
+
result?: ToolResultMessage;
|
|
75
|
+
needsContinuation: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface RecoveredTerminal {
|
|
79
|
+
outcome: "succeeded" | "failed" | "aborted";
|
|
80
|
+
message?: string;
|
|
81
|
+
committed: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 表示按顺序重放里程碑后得到的 Pi Tool 恢复状态。
|
|
86
|
+
*
|
|
87
|
+
* Runtime 恢复适配器把它交给 `planPiToolRecovery`,也用权威 ToolResult 重建持久结算和 transcript。
|
|
88
|
+
*
|
|
89
|
+
* 这些集合是里程碑的派生视图,不应由调用方绕过重放器自行修改。
|
|
90
|
+
*/
|
|
91
|
+
export interface PiToolRecoveryState {
|
|
92
|
+
turnId?: string;
|
|
93
|
+
assemblyRevision?: string;
|
|
94
|
+
toolCalls: Readonly<Record<string, RecoveredToolCall>>;
|
|
95
|
+
approvals: Readonly<Record<string, PiToolApproval>>;
|
|
96
|
+
continuations: Readonly<
|
|
97
|
+
Record<string, "pending" | "committed">
|
|
98
|
+
>;
|
|
99
|
+
canonicalToolResults: readonly ToolResultMessage[];
|
|
100
|
+
terminal?: RecoveredTerminal;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 描述恢复状态机建议 Runtime 执行的唯一下一步。
|
|
105
|
+
*
|
|
106
|
+
* 恢复适配器每次重放后消费一个计划,完成持久化或外部动作后再重放并重新规划。
|
|
107
|
+
*
|
|
108
|
+
* `chargeRecoveryBudget` 明确区分真正的 Tool 重试和等待、提交等可重复维护动作。
|
|
109
|
+
*/
|
|
110
|
+
export type PiToolRecoveryPlan =
|
|
111
|
+
| {
|
|
112
|
+
kind: "parked-approval";
|
|
113
|
+
executionId: string;
|
|
114
|
+
chargeRecoveryBudget: false;
|
|
115
|
+
}
|
|
116
|
+
| {
|
|
117
|
+
kind: "retry-tool";
|
|
118
|
+
toolCallId: string;
|
|
119
|
+
toolName: string;
|
|
120
|
+
input: unknown;
|
|
121
|
+
chargeRecoveryBudget: true;
|
|
122
|
+
}
|
|
123
|
+
| {
|
|
124
|
+
kind: "park-uncertain-tool";
|
|
125
|
+
toolCallId: string;
|
|
126
|
+
toolName: string;
|
|
127
|
+
chargeRecoveryBudget: true;
|
|
128
|
+
}
|
|
129
|
+
| {
|
|
130
|
+
kind: "persist-continuation";
|
|
131
|
+
turnId: string;
|
|
132
|
+
assemblyRevision: string;
|
|
133
|
+
continuationKey: string;
|
|
134
|
+
chargeRecoveryBudget: false;
|
|
135
|
+
}
|
|
136
|
+
| {
|
|
137
|
+
kind: "dispatch-continuation";
|
|
138
|
+
turnId: string;
|
|
139
|
+
assemblyRevision: string;
|
|
140
|
+
continuationKey: string;
|
|
141
|
+
idempotencyKey: string;
|
|
142
|
+
chargeRecoveryBudget: false;
|
|
143
|
+
}
|
|
144
|
+
| {
|
|
145
|
+
kind: "persist-terminal";
|
|
146
|
+
turnId: string;
|
|
147
|
+
assemblyRevision: string;
|
|
148
|
+
outcome: RecoveredTerminal["outcome"];
|
|
149
|
+
message: string | undefined;
|
|
150
|
+
chargeRecoveryBudget: false;
|
|
151
|
+
}
|
|
152
|
+
| {
|
|
153
|
+
kind: "complete";
|
|
154
|
+
outcome: RecoveredTerminal["outcome"];
|
|
155
|
+
chargeRecoveryBudget: false;
|
|
156
|
+
}
|
|
157
|
+
| { kind: "idle"; chargeRecoveryBudget: false };
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 表示恢复路径应用审批决定后需要追加的里程碑和审批动作。
|
|
161
|
+
*
|
|
162
|
+
* Runtime 收到 approve 或 reject 命令时同时持久化 `milestones` 并处理 `outcome`。
|
|
163
|
+
*
|
|
164
|
+
* 把两者绑定返回可防止审批状态与 ToolResult 或续跑动作只完成一半。
|
|
165
|
+
*/
|
|
166
|
+
export interface RecoveredPiApprovalDecision {
|
|
167
|
+
milestones: PiToolRecoveryMilestone[];
|
|
168
|
+
outcome: PiApprovalOutcome;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// #endregion
|
|
172
|
+
|
|
173
|
+
// #region 里程碑编码与校验
|
|
174
|
+
|
|
175
|
+
// 判断未知值是不是可按字段读取的普通 JSON 对象。
|
|
176
|
+
// 所有里程碑校验器在读取属性前先调用它。
|
|
177
|
+
// 排除 null 和数组,可避免后续字段判断接受错误的 JSON 容器。
|
|
178
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
179
|
+
return (
|
|
180
|
+
value !== null &&
|
|
181
|
+
typeof value === "object" &&
|
|
182
|
+
!Array.isArray(value)
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 校验里程碑共同携带的版本和 Turn revision 身份。
|
|
187
|
+
// 顶层解码器在检查具体事件字段前调用它。
|
|
188
|
+
// 版本门和非空身份让未知格式失败关闭,也为跨 revision 检查提供可靠键值。
|
|
189
|
+
function isIdentity(
|
|
190
|
+
value: Record<string, unknown>,
|
|
191
|
+
): value is Record<string, unknown> & RecoveryIdentity {
|
|
192
|
+
return (
|
|
193
|
+
value.version === 1 &&
|
|
194
|
+
typeof value.turnId === "string" &&
|
|
195
|
+
value.turnId.length > 0 &&
|
|
196
|
+
typeof value.assemblyRevision === "string" &&
|
|
197
|
+
value.assemblyRevision.length > 0
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 判断未知值是不是当前 Pi ToolResult 支持的文本或图片内容块。
|
|
202
|
+
// ToolResult 校验器会对 content 数组中的每一项调用它。
|
|
203
|
+
// 内容类型与已安装的 pi-ai 契约保持一致;依赖新增内容类型时这里也必须显式升级。
|
|
204
|
+
function isContentBlock(value: unknown): boolean {
|
|
205
|
+
return (
|
|
206
|
+
isRecord(value) &&
|
|
207
|
+
((value.type === "text" && typeof value.text === "string") ||
|
|
208
|
+
(value.type === "image" &&
|
|
209
|
+
typeof value.data === "string" &&
|
|
210
|
+
typeof value.mimeType === "string"))
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 校验从持久 JSON 读出的值是否满足 Pi ToolResult 的恢复字段。
|
|
215
|
+
// `decodePiToolRecoveryMilestone` 处理 tool-result 事件时调用它。
|
|
216
|
+
// 在断言类型前逐字段检查,避免损坏记录进入 canonical ToolResult 集合。
|
|
217
|
+
function isToolResultMessage(
|
|
218
|
+
value: unknown,
|
|
219
|
+
): value is ToolResultMessage {
|
|
220
|
+
return (
|
|
221
|
+
isRecord(value) &&
|
|
222
|
+
value.role === "toolResult" &&
|
|
223
|
+
typeof value.toolCallId === "string" &&
|
|
224
|
+
typeof value.toolName === "string" &&
|
|
225
|
+
Array.isArray(value.content) &&
|
|
226
|
+
value.content.every(isContentBlock) &&
|
|
227
|
+
typeof value.isError === "boolean" &&
|
|
228
|
+
typeof value.timestamp === "number"
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 校验从持久 JSON 读出的值是否是完整的 Pi Tool 审批记录。
|
|
233
|
+
// 里程碑解码器处理 approval 事件时调用它。
|
|
234
|
+
// 状态、执行档位和来源采用封闭枚举,未知协议值会让本次重放停在可信前缀。
|
|
235
|
+
function isApproval(value: unknown): value is PiToolApproval {
|
|
236
|
+
return (
|
|
237
|
+
isRecord(value) &&
|
|
238
|
+
typeof value.executionId === "string" &&
|
|
239
|
+
typeof value.requestId === "string" &&
|
|
240
|
+
(value.source === "action" || value.source === "codemode") &&
|
|
241
|
+
typeof value.toolCallId === "string" &&
|
|
242
|
+
typeof value.toolName === "string" &&
|
|
243
|
+
typeof value.summary === "string" &&
|
|
244
|
+
typeof value.inputJson === "string" &&
|
|
245
|
+
typeof value.createdAt === "number" &&
|
|
246
|
+
EXECUTION_LEVELS.some((level) => level === value.executionLevel) &&
|
|
247
|
+
EXECUTION_LEVELS.some((level) =>
|
|
248
|
+
level === value.requiredExecutionLevel
|
|
249
|
+
) &&
|
|
250
|
+
(value.decidedAt === undefined ||
|
|
251
|
+
typeof value.decidedAt === "number") &&
|
|
252
|
+
(value.reason === undefined || typeof value.reason === "string") &&
|
|
253
|
+
(value.status === "pending" ||
|
|
254
|
+
value.status === "approved" ||
|
|
255
|
+
value.status === "rejected")
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 把一个 Pi Tool 恢复里程碑编码成可持久化的 JSON 正文。
|
|
261
|
+
*
|
|
262
|
+
* Runtime 恢复适配器在生成 `append-milestone` 变更时调用它。
|
|
263
|
+
*
|
|
264
|
+
* 编码保持为无包装的版本化 JSON;解码器在信任字段前会重新校验完整协议。
|
|
265
|
+
*/
|
|
266
|
+
export function encodePiToolRecoveryMilestone(
|
|
267
|
+
milestone: PiToolRecoveryMilestone,
|
|
268
|
+
): string {
|
|
269
|
+
return JSON.stringify(milestone);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 把一条持久正文解码为经过校验的 Pi Tool 恢复里程碑。
|
|
274
|
+
*
|
|
275
|
+
* 重放器按存储顺序逐条调用它;无效或未知版本返回 null,由重放器保留此前的可信前缀。
|
|
276
|
+
*
|
|
277
|
+
* 每个联合类型分支单独验证所需字段,不能用直接类型断言跳过持久数据边界。
|
|
278
|
+
*/
|
|
279
|
+
export function decodePiToolRecoveryMilestone(
|
|
280
|
+
body: string,
|
|
281
|
+
): PiToolRecoveryMilestone | null {
|
|
282
|
+
let value: unknown;
|
|
283
|
+
try {
|
|
284
|
+
value = JSON.parse(body);
|
|
285
|
+
} catch {
|
|
286
|
+
// JSON 损坏属于可识别的 torn 尾部,不应在这里升级为 Runtime 异常。
|
|
287
|
+
// replay 会在 null 处停止,并保留已经成功重放的记录。
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
if (!isRecord(value) || !isIdentity(value)) return null;
|
|
291
|
+
|
|
292
|
+
switch (value.type) {
|
|
293
|
+
case "tool-input":
|
|
294
|
+
return typeof value.toolCallId === "string" &&
|
|
295
|
+
typeof value.toolName === "string" &&
|
|
296
|
+
Object.hasOwn(value, "input") &&
|
|
297
|
+
(value.retry === "idempotent" ||
|
|
298
|
+
value.retry === "non-idempotent")
|
|
299
|
+
? (value as PiToolRecoveryMilestone)
|
|
300
|
+
: null;
|
|
301
|
+
case "tool-result":
|
|
302
|
+
return isToolResultMessage(value.toolResult) &&
|
|
303
|
+
(value.needsContinuation === undefined ||
|
|
304
|
+
typeof value.needsContinuation === "boolean")
|
|
305
|
+
? (value as PiToolRecoveryMilestone)
|
|
306
|
+
: null;
|
|
307
|
+
case "tool-error":
|
|
308
|
+
return typeof value.toolCallId === "string" &&
|
|
309
|
+
typeof value.toolName === "string" &&
|
|
310
|
+
typeof value.error === "string" &&
|
|
311
|
+
typeof value.timestamp === "number"
|
|
312
|
+
? (value as PiToolRecoveryMilestone)
|
|
313
|
+
: null;
|
|
314
|
+
case "approval":
|
|
315
|
+
return isApproval(value.approval)
|
|
316
|
+
? (value as PiToolRecoveryMilestone)
|
|
317
|
+
: null;
|
|
318
|
+
case "continuation":
|
|
319
|
+
return typeof value.continuationKey === "string" &&
|
|
320
|
+
(value.phase === "pending" ||
|
|
321
|
+
value.phase === "committed") &&
|
|
322
|
+
typeof value.timestamp === "number"
|
|
323
|
+
? (value as PiToolRecoveryMilestone)
|
|
324
|
+
: null;
|
|
325
|
+
case "terminal":
|
|
326
|
+
return (value.phase === "intent" ||
|
|
327
|
+
value.phase === "committed") &&
|
|
328
|
+
(value.outcome === "succeeded" ||
|
|
329
|
+
value.outcome === "failed" ||
|
|
330
|
+
value.outcome === "aborted") &&
|
|
331
|
+
(value.message === undefined ||
|
|
332
|
+
typeof value.message === "string") &&
|
|
333
|
+
typeof value.timestamp === "number"
|
|
334
|
+
? (value as PiToolRecoveryMilestone)
|
|
335
|
+
: null;
|
|
336
|
+
default:
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// #endregion
|
|
342
|
+
|
|
343
|
+
// #region 里程碑重放与冲突保护
|
|
344
|
+
|
|
345
|
+
// 确认一条里程碑属于当前正在重放的 Turn revision。
|
|
346
|
+
// replay 在接受首条记录后的每一步调用它。
|
|
347
|
+
// 身份冲突直接失败关闭,避免配置或 Tool surface 已变化时拼出一个不存在的历史 Turn。
|
|
348
|
+
function assertIdentity(
|
|
349
|
+
state: PiToolRecoveryState,
|
|
350
|
+
milestone: PiToolRecoveryMilestone,
|
|
351
|
+
): void {
|
|
352
|
+
if (
|
|
353
|
+
(state.turnId && state.turnId !== milestone.turnId) ||
|
|
354
|
+
(state.assemblyRevision &&
|
|
355
|
+
state.assemblyRevision !== milestone.assemblyRevision)
|
|
356
|
+
) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
"Pi recovery milestone does not belong to the active Turn revision",
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// 判断重复的 tool-input 里程碑是否描述完全相同的调用。
|
|
364
|
+
// replay 在同一 toolCallId 再次出现时调用它,兼容幂等追加但拒绝冲突内容。
|
|
365
|
+
// Tool 名称、重试策略和输入都属于恢复安全边界,任何一项变化都不能静默覆盖。
|
|
366
|
+
function sameToolInput(
|
|
367
|
+
previous: RecoveredToolCall,
|
|
368
|
+
milestone: Extract<
|
|
369
|
+
PiToolRecoveryMilestone,
|
|
370
|
+
{ type: "tool-input" }
|
|
371
|
+
>,
|
|
372
|
+
): boolean {
|
|
373
|
+
// 待确认:JSON.stringify 对对象键顺序敏感;当前写入链会保留输入顺序,重建输入的调用方可能产生语义相同但顺序不同的对象。
|
|
374
|
+
return (
|
|
375
|
+
previous.toolName === milestone.toolName &&
|
|
376
|
+
previous.retry === milestone.retry &&
|
|
377
|
+
JSON.stringify(previous.input) === JSON.stringify(milestone.input)
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* 按持久顺序重放 Pi Tool 恢复里程碑并还原当前状态。
|
|
383
|
+
*
|
|
384
|
+
* Runtime 在检查、写入命令后和每轮恢复规划前调用它;传入顺序必须与持久存储一致。
|
|
385
|
+
*
|
|
386
|
+
* 重放采用单调规则:首个 ToolResult 成为权威结果,已提交续跑不回退,审批和终态不能翻转。
|
|
387
|
+
*/
|
|
388
|
+
export function replayPiToolRecovery(
|
|
389
|
+
bodies: readonly string[],
|
|
390
|
+
): PiToolRecoveryState {
|
|
391
|
+
const tools: Record<string, RecoveredToolCall> = {};
|
|
392
|
+
const approvals: Record<string, PiToolApproval> = {};
|
|
393
|
+
const continuations: Record<
|
|
394
|
+
string,
|
|
395
|
+
"pending" | "committed"
|
|
396
|
+
> = {};
|
|
397
|
+
const results: ToolResultMessage[] = [];
|
|
398
|
+
let state: PiToolRecoveryState = {
|
|
399
|
+
toolCalls: tools,
|
|
400
|
+
approvals,
|
|
401
|
+
continuations,
|
|
402
|
+
canonicalToolResults: results,
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
for (const body of bodies) {
|
|
406
|
+
const milestone = decodePiToolRecoveryMilestone(body);
|
|
407
|
+
// 无法解码的记录截断剩余日志,避免越过损坏位置组合不连续状态。
|
|
408
|
+
// 已经重放出的前缀仍可供恢复诊断和安全规划使用。
|
|
409
|
+
if (!milestone) break;
|
|
410
|
+
assertIdentity(state, milestone);
|
|
411
|
+
state = {
|
|
412
|
+
...state,
|
|
413
|
+
turnId: state.turnId ?? milestone.turnId,
|
|
414
|
+
assemblyRevision:
|
|
415
|
+
state.assemblyRevision ?? milestone.assemblyRevision,
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
switch (milestone.type) {
|
|
419
|
+
case "tool-input": {
|
|
420
|
+
const previous = tools[milestone.toolCallId];
|
|
421
|
+
if (previous && !sameToolInput(previous, milestone)) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
`Conflicting Pi Tool input for ${milestone.toolCallId}`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
tools[milestone.toolCallId] = previous ?? {
|
|
427
|
+
toolCallId: milestone.toolCallId,
|
|
428
|
+
toolName: milestone.toolName,
|
|
429
|
+
input: milestone.input,
|
|
430
|
+
retry: milestone.retry,
|
|
431
|
+
needsContinuation: true,
|
|
432
|
+
};
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
case "tool-result": {
|
|
436
|
+
const result = milestone.toolResult;
|
|
437
|
+
const previous = tools[result.toolCallId];
|
|
438
|
+
// 重复结算只复用第一次持久结果,绝不让后来的记录改写已完成副作用。
|
|
439
|
+
// canonicalToolResults 同样只追加一次,供 transcript 恢复使用。
|
|
440
|
+
if (!previous?.result) results.push(result);
|
|
441
|
+
tools[result.toolCallId] = {
|
|
442
|
+
toolCallId: result.toolCallId,
|
|
443
|
+
toolName: result.toolName,
|
|
444
|
+
input: previous?.input,
|
|
445
|
+
retry: previous?.retry,
|
|
446
|
+
result: previous?.result ?? result,
|
|
447
|
+
needsContinuation:
|
|
448
|
+
previous?.result !== undefined
|
|
449
|
+
? previous.needsContinuation
|
|
450
|
+
: milestone.needsContinuation ?? true,
|
|
451
|
+
};
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
case "tool-error": {
|
|
455
|
+
const previous = tools[milestone.toolCallId];
|
|
456
|
+
const result: ToolResultMessage = {
|
|
457
|
+
role: "toolResult",
|
|
458
|
+
toolCallId: milestone.toolCallId,
|
|
459
|
+
toolName: milestone.toolName,
|
|
460
|
+
content: [{ type: "text", text: milestone.error }],
|
|
461
|
+
details: { code: "tool_error" },
|
|
462
|
+
isError: true,
|
|
463
|
+
timestamp: milestone.timestamp,
|
|
464
|
+
};
|
|
465
|
+
// Tool 错误也是已经结算的结果;重复记录仍保留第一次看到的版本。
|
|
466
|
+
if (!previous?.result) results.push(result);
|
|
467
|
+
tools[milestone.toolCallId] = {
|
|
468
|
+
toolCallId: milestone.toolCallId,
|
|
469
|
+
toolName: milestone.toolName,
|
|
470
|
+
input: previous?.input,
|
|
471
|
+
retry: previous?.retry,
|
|
472
|
+
result: previous?.result ?? result,
|
|
473
|
+
needsContinuation: previous?.needsContinuation ?? true,
|
|
474
|
+
};
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
case "approval": {
|
|
478
|
+
const previous = approvals[milestone.approval.executionId];
|
|
479
|
+
// pending 可以前进到一个决定,但已决定状态不能被相反决定覆盖。
|
|
480
|
+
// 同状态重复记录允许刷新完整持久快照,同时保持结果不翻转。
|
|
481
|
+
if (
|
|
482
|
+
!previous ||
|
|
483
|
+
previous.status === "pending" ||
|
|
484
|
+
previous.status === milestone.approval.status
|
|
485
|
+
) {
|
|
486
|
+
approvals[milestone.approval.executionId] =
|
|
487
|
+
milestone.approval;
|
|
488
|
+
}
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
case "continuation":
|
|
492
|
+
// committed 是单调终态,迟到的 pending 记录不能让续跑再次被调度。
|
|
493
|
+
if (
|
|
494
|
+
continuations[milestone.continuationKey] !== "committed" ||
|
|
495
|
+
milestone.phase === "committed"
|
|
496
|
+
) {
|
|
497
|
+
continuations[milestone.continuationKey] =
|
|
498
|
+
milestone.phase;
|
|
499
|
+
}
|
|
500
|
+
break;
|
|
501
|
+
case "terminal": {
|
|
502
|
+
const terminal = state.terminal;
|
|
503
|
+
// 第一个终态意图决定 outcome,之后只允许同一 outcome 从 intent 前进到 committed。
|
|
504
|
+
// 重复 wake 因此不能制造第二个或相反的 Submission 终态。
|
|
505
|
+
if (!terminal) {
|
|
506
|
+
state = {
|
|
507
|
+
...state,
|
|
508
|
+
terminal: {
|
|
509
|
+
outcome: milestone.outcome,
|
|
510
|
+
message: milestone.message,
|
|
511
|
+
committed: milestone.phase === "committed",
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
} else if (
|
|
515
|
+
terminal.outcome === milestone.outcome &&
|
|
516
|
+
milestone.phase === "committed"
|
|
517
|
+
) {
|
|
518
|
+
state = {
|
|
519
|
+
...state,
|
|
520
|
+
terminal: { ...terminal, committed: true },
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
...state,
|
|
530
|
+
toolCalls: tools,
|
|
531
|
+
approvals,
|
|
532
|
+
continuations,
|
|
533
|
+
canonicalToolResults: results,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// #endregion
|
|
538
|
+
|
|
539
|
+
// #region 恢复动作规划
|
|
540
|
+
|
|
541
|
+
// 把一个续跑键的持久阶段转换成下一步恢复计划。
|
|
542
|
+
// 主规划器在审批已决定或 ToolResult 需要继续原 Turn 时调用它。
|
|
543
|
+
// 未记录时先要求持久化 pending,已有 pending 才允许派发,committed 则不再产生动作。
|
|
544
|
+
function continuationPlan(
|
|
545
|
+
state: PiToolRecoveryState,
|
|
546
|
+
continuationKey: string,
|
|
547
|
+
): PiToolRecoveryPlan | null {
|
|
548
|
+
if (!state.turnId || !state.assemblyRevision) {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
const phase = state.continuations[continuationKey];
|
|
552
|
+
if (phase === "committed") return null;
|
|
553
|
+
if (phase === "pending") {
|
|
554
|
+
return {
|
|
555
|
+
kind: "dispatch-continuation",
|
|
556
|
+
turnId: state.turnId,
|
|
557
|
+
assemblyRevision: state.assemblyRevision,
|
|
558
|
+
continuationKey,
|
|
559
|
+
idempotencyKey: `${state.turnId}:${continuationKey}`,
|
|
560
|
+
chargeRecoveryBudget: false,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
kind: "persist-continuation",
|
|
565
|
+
turnId: state.turnId,
|
|
566
|
+
assemblyRevision: state.assemblyRevision,
|
|
567
|
+
continuationKey,
|
|
568
|
+
chargeRecoveryBudget: false,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* 根据已重放状态选择 Pi Tool 恢复的唯一下一步。
|
|
574
|
+
*
|
|
575
|
+
* Runtime 恢复适配器在每次状态变化后调用它,并只执行返回计划对应的一个动作。
|
|
576
|
+
*
|
|
577
|
+
* 优先级固定为终态、已决定审批、待审批、已结算 Tool、未结算 Tool,不能调换到越过终态或用户许可。
|
|
578
|
+
*/
|
|
579
|
+
export function planPiToolRecovery(
|
|
580
|
+
state: PiToolRecoveryState,
|
|
581
|
+
): PiToolRecoveryPlan {
|
|
582
|
+
// 终态优先于任何 Tool 或审批动作,避免已经结束的 Turn 被再次续跑。
|
|
583
|
+
if (state.terminal) {
|
|
584
|
+
if (state.terminal.committed) {
|
|
585
|
+
return {
|
|
586
|
+
kind: "complete",
|
|
587
|
+
outcome: state.terminal.outcome,
|
|
588
|
+
chargeRecoveryBudget: false,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
if (!state.turnId || !state.assemblyRevision) {
|
|
592
|
+
return { kind: "idle", chargeRecoveryBudget: false };
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
kind: "persist-terminal",
|
|
596
|
+
turnId: state.turnId,
|
|
597
|
+
assemblyRevision: state.assemblyRevision,
|
|
598
|
+
outcome: state.terminal.outcome,
|
|
599
|
+
message: state.terminal.message,
|
|
600
|
+
chargeRecoveryBudget: false,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// 已决定审批先完成其续跑 outbox,确保决定持久化后只调度一次原 Turn。
|
|
605
|
+
for (const approval of Object.values(state.approvals)) {
|
|
606
|
+
if (approval.status === "pending") continue;
|
|
607
|
+
const key = `approval:${approval.executionId}:${
|
|
608
|
+
approval.status === "approved" ? "approve" : "reject"
|
|
609
|
+
}`;
|
|
610
|
+
const plan = continuationPlan(state, key);
|
|
611
|
+
if (plan) return plan;
|
|
612
|
+
}
|
|
613
|
+
// 仍待用户决定的审批保持 parked,不消耗普通恢复预算,也不执行其 Tool。
|
|
614
|
+
for (const approval of Object.values(state.approvals)) {
|
|
615
|
+
if (approval.status !== "pending") continue;
|
|
616
|
+
return {
|
|
617
|
+
kind: "parked-approval",
|
|
618
|
+
executionId: approval.executionId,
|
|
619
|
+
chargeRecoveryBudget: false,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// 已结算 Tool 先补齐续跑;未结算 Tool 只有声明幂等时才自动重试。
|
|
624
|
+
for (const tool of Object.values(state.toolCalls)) {
|
|
625
|
+
if (tool.result) {
|
|
626
|
+
const key = `tool:${tool.toolCallId}:settled`;
|
|
627
|
+
const plan = tool.needsContinuation
|
|
628
|
+
? continuationPlan(state, key)
|
|
629
|
+
: null;
|
|
630
|
+
if (plan) return plan;
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (tool.retry === "idempotent") {
|
|
634
|
+
return {
|
|
635
|
+
kind: "retry-tool",
|
|
636
|
+
toolCallId: tool.toolCallId,
|
|
637
|
+
toolName: tool.toolName,
|
|
638
|
+
input: tool.input,
|
|
639
|
+
chargeRecoveryBudget: true,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
if (tool.retry === "non-idempotent") {
|
|
643
|
+
return {
|
|
644
|
+
kind: "park-uncertain-tool",
|
|
645
|
+
toolCallId: tool.toolCallId,
|
|
646
|
+
toolName: tool.toolName,
|
|
647
|
+
chargeRecoveryBudget: true,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return { kind: "idle", chargeRecoveryBudget: false };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// #endregion
|
|
656
|
+
|
|
657
|
+
// #region 续跑两阶段提交
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* 为一次尚未登记的续跑创建 `pending` 里程碑。
|
|
661
|
+
*
|
|
662
|
+
* Runtime 处理 `persist-continuation` 计划时先调用它,持久化成功后才能重新规划为派发动作。
|
|
663
|
+
*
|
|
664
|
+
* 状态和 Turn revision 必须仍与计划一致;过期或重复计划返回 null,避免写出第二份 outbox 意图。
|
|
665
|
+
*/
|
|
666
|
+
export function stagePiRecoveryContinuation(
|
|
667
|
+
state: PiToolRecoveryState,
|
|
668
|
+
plan: Extract<
|
|
669
|
+
PiToolRecoveryPlan,
|
|
670
|
+
{ kind: "persist-continuation" }
|
|
671
|
+
>,
|
|
672
|
+
timestamp: number,
|
|
673
|
+
): PiToolRecoveryMilestone | null {
|
|
674
|
+
if (
|
|
675
|
+
state.continuations[plan.continuationKey] !== undefined ||
|
|
676
|
+
state.turnId !== plan.turnId ||
|
|
677
|
+
state.assemblyRevision !== plan.assemblyRevision
|
|
678
|
+
) {
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
return {
|
|
682
|
+
version: 1,
|
|
683
|
+
type: "continuation",
|
|
684
|
+
turnId: plan.turnId,
|
|
685
|
+
assemblyRevision: plan.assemblyRevision,
|
|
686
|
+
continuationKey: plan.continuationKey,
|
|
687
|
+
phase: "pending",
|
|
688
|
+
timestamp,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* 为一次已经登记的续跑创建 `committed` 里程碑。
|
|
694
|
+
*
|
|
695
|
+
* Runtime 确认 `dispatch-continuation` 计划仍有效时调用它,随后把提交记录与对应恢复动作按宿主顺序处理。
|
|
696
|
+
*
|
|
697
|
+
* 只允许 `pending` 且 revision 匹配的计划提交,使重复 wake 或重复调度不会重新打开同一续跑。
|
|
698
|
+
*/
|
|
699
|
+
export function commitPiRecoveryContinuation(
|
|
700
|
+
state: PiToolRecoveryState,
|
|
701
|
+
plan: Extract<
|
|
702
|
+
PiToolRecoveryPlan,
|
|
703
|
+
{ kind: "dispatch-continuation" }
|
|
704
|
+
>,
|
|
705
|
+
timestamp: number,
|
|
706
|
+
): PiToolRecoveryMilestone | null {
|
|
707
|
+
if (
|
|
708
|
+
state.continuations[plan.continuationKey] !== "pending" ||
|
|
709
|
+
state.turnId !== plan.turnId ||
|
|
710
|
+
state.assemblyRevision !== plan.assemblyRevision
|
|
711
|
+
) {
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
version: 1,
|
|
716
|
+
type: "continuation",
|
|
717
|
+
turnId: plan.turnId,
|
|
718
|
+
assemblyRevision: plan.assemblyRevision,
|
|
719
|
+
continuationKey: plan.continuationKey,
|
|
720
|
+
phase: "committed",
|
|
721
|
+
timestamp,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// #endregion
|
|
726
|
+
|
|
727
|
+
// #region 恢复审批决定
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* 在恢复状态上应用一个用户审批决定,并生成需要持久化的里程碑。
|
|
731
|
+
*
|
|
732
|
+
* Runtime 收到冷重建后的 approve 或 reject 命令时调用它,再按返回结果更新审批表和恢复日志。
|
|
733
|
+
*
|
|
734
|
+
* approve 只记录决定并安排续跑;reject 额外写入稳定 ToolResult,且重复决定返回空里程碑。
|
|
735
|
+
*/
|
|
736
|
+
export function applyRecoveredPiApprovalDecision(
|
|
737
|
+
state: PiToolRecoveryState,
|
|
738
|
+
input: {
|
|
739
|
+
executionId: string;
|
|
740
|
+
decision: "approve" | "reject";
|
|
741
|
+
decidedAt: number;
|
|
742
|
+
reason?: string;
|
|
743
|
+
},
|
|
744
|
+
): RecoveredPiApprovalDecision {
|
|
745
|
+
const approval = state.approvals[input.executionId];
|
|
746
|
+
if (!approval) {
|
|
747
|
+
throw new Error(`Unknown Pi Tool approval ${input.executionId}`);
|
|
748
|
+
}
|
|
749
|
+
const transition = decidePiToolApproval(
|
|
750
|
+
approval,
|
|
751
|
+
input.decision === "approve"
|
|
752
|
+
? { decision: "allow_once", decidedAt: input.decidedAt }
|
|
753
|
+
: {
|
|
754
|
+
decision: "deny",
|
|
755
|
+
decidedAt: input.decidedAt,
|
|
756
|
+
reason: input.reason,
|
|
757
|
+
},
|
|
758
|
+
);
|
|
759
|
+
// decidePiToolApproval 已处理的审批只返回 noop;这里不应再追加任何持久记录。
|
|
760
|
+
if (transition.outcome.kind === "noop") {
|
|
761
|
+
return { milestones: [], outcome: transition.outcome };
|
|
762
|
+
}
|
|
763
|
+
if (!state.turnId || !state.assemblyRevision) {
|
|
764
|
+
throw new Error("Pi Tool approval is missing its Turn revision");
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const identity: RecoveryIdentity = {
|
|
768
|
+
version: 1,
|
|
769
|
+
turnId: state.turnId,
|
|
770
|
+
assemblyRevision: state.assemblyRevision,
|
|
771
|
+
};
|
|
772
|
+
const milestones: PiToolRecoveryMilestone[] = [
|
|
773
|
+
{
|
|
774
|
+
...identity,
|
|
775
|
+
type: "approval",
|
|
776
|
+
approval: transition.approval,
|
|
777
|
+
},
|
|
778
|
+
];
|
|
779
|
+
if (transition.outcome.kind === "denied") {
|
|
780
|
+
// 拒绝直接结算成 ToolResult,且不再安排 Tool settled continuation。
|
|
781
|
+
// 审批决定本身仍有独立 continuation,保持 approve/reject 的恢复调度一致。
|
|
782
|
+
milestones.push({
|
|
783
|
+
...identity,
|
|
784
|
+
type: "tool-result",
|
|
785
|
+
toolResult: transition.outcome.toolResult,
|
|
786
|
+
needsContinuation: false,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
return { milestones, outcome: transition.outcome };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// #endregion
|