@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,805 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyRecoveredPiApprovalDecision,
|
|
3
|
+
commitPiRecoveryContinuation,
|
|
4
|
+
encodePiToolRecoveryMilestone,
|
|
5
|
+
planPiToolRecovery,
|
|
6
|
+
replayPiToolRecovery,
|
|
7
|
+
stagePiRecoveryContinuation,
|
|
8
|
+
type PiToolApproval,
|
|
9
|
+
type PiToolRecoveryMilestone,
|
|
10
|
+
type PiToolRecoveryPlan,
|
|
11
|
+
type PiToolRecoveryState,
|
|
12
|
+
} from "../turn";
|
|
13
|
+
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
|
14
|
+
import {
|
|
15
|
+
aiSdkRecoveryCodec,
|
|
16
|
+
shouldCreditStreamProgress,
|
|
17
|
+
type StreamProgressCreditThrottle,
|
|
18
|
+
} from "agents/chat";
|
|
19
|
+
import type { PiToolInputRecord } from "./execution";
|
|
20
|
+
import type {
|
|
21
|
+
RuntimeRecoveryPort,
|
|
22
|
+
RuntimeRecoveryResult,
|
|
23
|
+
} from "../../kernel/recoverable-chat-agent";
|
|
24
|
+
|
|
25
|
+
export interface PiRecoveryIdentity {
|
|
26
|
+
readonly turnId: string;
|
|
27
|
+
readonly assemblyRevision: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PiRecoveredToolSettlement {
|
|
31
|
+
readonly toolCallId: string;
|
|
32
|
+
readonly toolName: string;
|
|
33
|
+
readonly args: unknown;
|
|
34
|
+
readonly result: {
|
|
35
|
+
readonly content: ToolResultMessage["content"];
|
|
36
|
+
readonly details: unknown;
|
|
37
|
+
};
|
|
38
|
+
readonly isError: boolean;
|
|
39
|
+
readonly createdAt: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type PiRecoveryCommand =
|
|
43
|
+
| { readonly kind: "inspect" }
|
|
44
|
+
| {
|
|
45
|
+
readonly kind: "approval-decision";
|
|
46
|
+
readonly executionId: string;
|
|
47
|
+
readonly approved: boolean;
|
|
48
|
+
readonly reason?: string;
|
|
49
|
+
}
|
|
50
|
+
| {
|
|
51
|
+
readonly kind: "record-tool-input";
|
|
52
|
+
readonly record: PiToolInputRecord;
|
|
53
|
+
}
|
|
54
|
+
| {
|
|
55
|
+
readonly kind: "record-approval";
|
|
56
|
+
readonly approval: PiToolApproval;
|
|
57
|
+
}
|
|
58
|
+
| {
|
|
59
|
+
readonly kind: "record-tool-result";
|
|
60
|
+
readonly toolCallId: string;
|
|
61
|
+
readonly toolName: string;
|
|
62
|
+
readonly result: {
|
|
63
|
+
readonly content: ToolResultMessage["content"];
|
|
64
|
+
readonly details: unknown;
|
|
65
|
+
};
|
|
66
|
+
readonly isError: boolean;
|
|
67
|
+
readonly timestamp: number;
|
|
68
|
+
readonly needsContinuation?: boolean;
|
|
69
|
+
}
|
|
70
|
+
| {
|
|
71
|
+
readonly kind: "record-terminal";
|
|
72
|
+
readonly phase: "intent" | "committed";
|
|
73
|
+
readonly outcome: "succeeded" | "failed" | "aborted";
|
|
74
|
+
readonly message?: string;
|
|
75
|
+
}
|
|
76
|
+
| {
|
|
77
|
+
readonly kind: "commit-continuation";
|
|
78
|
+
readonly continuationKey: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export interface PiRecoveryInput {
|
|
82
|
+
readonly milestoneBodies: readonly string[];
|
|
83
|
+
readonly identity?: PiRecoveryIdentity;
|
|
84
|
+
readonly command: PiRecoveryCommand;
|
|
85
|
+
readonly now: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface PiRecoveryDecision {
|
|
89
|
+
readonly applied: boolean;
|
|
90
|
+
readonly mutations: readonly PiDurableMutation[];
|
|
91
|
+
readonly effect: PiRecoveryEffect;
|
|
92
|
+
readonly recoveredToolSettlements: readonly PiRecoveredToolSettlement[];
|
|
93
|
+
readonly terminal?: {
|
|
94
|
+
readonly outcome: "succeeded" | "failed" | "aborted";
|
|
95
|
+
readonly message?: string;
|
|
96
|
+
readonly committed: boolean;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type PiDurableMutation =
|
|
101
|
+
| {
|
|
102
|
+
readonly kind: "append-milestone";
|
|
103
|
+
readonly key: string;
|
|
104
|
+
readonly body: string;
|
|
105
|
+
}
|
|
106
|
+
| {
|
|
107
|
+
readonly kind: "decide-approval";
|
|
108
|
+
readonly executionId: string;
|
|
109
|
+
readonly status: "approved" | "rejected";
|
|
110
|
+
readonly decidedAt: number;
|
|
111
|
+
readonly reason?: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export type PiRecoveryEffect =
|
|
115
|
+
| {
|
|
116
|
+
readonly kind: "retry-tool";
|
|
117
|
+
readonly toolCallId: string;
|
|
118
|
+
readonly toolName: string;
|
|
119
|
+
readonly input: unknown;
|
|
120
|
+
}
|
|
121
|
+
| {
|
|
122
|
+
readonly kind: "schedule-continuation";
|
|
123
|
+
readonly continuationKey: string;
|
|
124
|
+
readonly approvalExecutionId: string;
|
|
125
|
+
readonly idempotencyKey: string;
|
|
126
|
+
}
|
|
127
|
+
| {
|
|
128
|
+
readonly kind: "finish";
|
|
129
|
+
readonly outcome: "succeeded" | "failed" | "aborted";
|
|
130
|
+
readonly message?: string;
|
|
131
|
+
}
|
|
132
|
+
| {
|
|
133
|
+
readonly kind: "wait";
|
|
134
|
+
readonly reason?: "approval" | "uncertain-tool" | "complete";
|
|
135
|
+
}
|
|
136
|
+
| { readonly kind: "resume-turn" };
|
|
137
|
+
|
|
138
|
+
// #region Recovery state and plan helpers
|
|
139
|
+
|
|
140
|
+
// 把一个恢复里程碑包装成 Runtime 可以持久化的追加操作。
|
|
141
|
+
// decidePiRecovery 在生成工具、审批、续跑和终态记录时统一调用它。
|
|
142
|
+
// 编码集中在这里可保证写入键与恢复重放使用同一种格式。
|
|
143
|
+
function milestoneMutation(
|
|
144
|
+
key: string,
|
|
145
|
+
milestone: PiToolRecoveryMilestone,
|
|
146
|
+
): Extract<PiDurableMutation, { kind: "append-milestone" }> {
|
|
147
|
+
return {
|
|
148
|
+
kind: "append-milestone",
|
|
149
|
+
key,
|
|
150
|
+
body: encodePiToolRecoveryMilestone(milestone),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 取出当前 Turn 与装配版本,供新里程碑标明自己属于哪次执行。
|
|
155
|
+
// decidePiRecovery 只在需要追加里程碑的命令路径调用它。
|
|
156
|
+
// 缺少身份时立即失败,避免把无归属的记录混入另一次 Turn 的恢复日志。
|
|
157
|
+
function recoveryIdentity(input: PiRecoveryInput) {
|
|
158
|
+
if (!input.identity) {
|
|
159
|
+
throw new Error("Pi recovery command is missing its Turn revision");
|
|
160
|
+
}
|
|
161
|
+
return { version: 1 as const, ...input.identity };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 把重放得到的规范工具结果还原成持久化 settlement 所需的完整形状。
|
|
165
|
+
// decidePiRecovery 每次返回决策时调用它,Runtime 随后补齐可能漏写的 settlement 和 transcript。
|
|
166
|
+
// 输入优先取 tool-input 里程碑;若记录中只有审批,则从其 inputJson 回退,兼容两种已存在的数据来源。
|
|
167
|
+
function recoveredToolSettlements(
|
|
168
|
+
state: PiToolRecoveryState,
|
|
169
|
+
): PiRecoveredToolSettlement[] {
|
|
170
|
+
return state.canonicalToolResults.map((toolResult) => {
|
|
171
|
+
const tool = state.toolCalls[toolResult.toolCallId];
|
|
172
|
+
const approval = Object.values(state.approvals).find(
|
|
173
|
+
(item) => item.toolCallId === toolResult.toolCallId,
|
|
174
|
+
);
|
|
175
|
+
return {
|
|
176
|
+
toolCallId: toolResult.toolCallId,
|
|
177
|
+
toolName: toolResult.toolName,
|
|
178
|
+
args:
|
|
179
|
+
tool?.input ??
|
|
180
|
+
(approval ? JSON.parse(approval.inputJson) : {}),
|
|
181
|
+
result: {
|
|
182
|
+
content: toolResult.content,
|
|
183
|
+
details: toolResult.details,
|
|
184
|
+
},
|
|
185
|
+
isError: toolResult.isError,
|
|
186
|
+
createdAt: toolResult.timestamp,
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 从审批续跑键中读出原审批执行编号。
|
|
192
|
+
// fromDispatch 用它判断续跑应交给审批调度器还是直接恢复 Turn。
|
|
193
|
+
// 严格匹配 approve 和 reject 两种既有键形状,普通工具续跑键不会被误当成审批。
|
|
194
|
+
function approvalExecutionId(continuationKey: string): string | null {
|
|
195
|
+
const match = /^approval:(.+):(approve|reject)$/.exec(
|
|
196
|
+
continuationKey,
|
|
197
|
+
);
|
|
198
|
+
return match?.[1] ?? null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 把纯恢复计划翻译成持久化操作和 Runtime 下一步动作。
|
|
202
|
+
// decidePiRecovery 在命令应用并重放最新状态后调用它。
|
|
203
|
+
// pending 续跑必须先形成里程碑并重放,再允许 dispatch,避免外部动作早于可恢复状态落盘。
|
|
204
|
+
function fromPlan(
|
|
205
|
+
state: PiToolRecoveryState,
|
|
206
|
+
plan: PiToolRecoveryPlan,
|
|
207
|
+
now: number,
|
|
208
|
+
bodies: readonly string[],
|
|
209
|
+
): Pick<PiRecoveryDecision, "mutations" | "effect"> {
|
|
210
|
+
switch (plan.kind) {
|
|
211
|
+
case "retry-tool":
|
|
212
|
+
return {
|
|
213
|
+
mutations: [],
|
|
214
|
+
effect: {
|
|
215
|
+
kind: "retry-tool",
|
|
216
|
+
toolCallId: plan.toolCallId,
|
|
217
|
+
toolName: plan.toolName,
|
|
218
|
+
input: plan.input,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
case "parked-approval":
|
|
222
|
+
return {
|
|
223
|
+
mutations: [],
|
|
224
|
+
effect: { kind: "wait", reason: "approval" },
|
|
225
|
+
};
|
|
226
|
+
case "park-uncertain-tool":
|
|
227
|
+
return {
|
|
228
|
+
mutations: [],
|
|
229
|
+
effect: { kind: "wait", reason: "uncertain-tool" },
|
|
230
|
+
};
|
|
231
|
+
case "persist-terminal":
|
|
232
|
+
return {
|
|
233
|
+
mutations: [],
|
|
234
|
+
effect: {
|
|
235
|
+
kind: "finish",
|
|
236
|
+
outcome: plan.outcome,
|
|
237
|
+
...(plan.message === undefined
|
|
238
|
+
? {}
|
|
239
|
+
: { message: plan.message }),
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
case "complete":
|
|
243
|
+
return {
|
|
244
|
+
mutations: [],
|
|
245
|
+
effect: { kind: "wait", reason: "complete" },
|
|
246
|
+
};
|
|
247
|
+
case "idle":
|
|
248
|
+
return { mutations: [], effect: { kind: "resume-turn" } };
|
|
249
|
+
case "persist-continuation": {
|
|
250
|
+
const staged = stagePiRecoveryContinuation(state, plan, now);
|
|
251
|
+
if (!staged) {
|
|
252
|
+
return { mutations: [], effect: { kind: "wait" } };
|
|
253
|
+
}
|
|
254
|
+
const pending = milestoneMutation(
|
|
255
|
+
`continuation:${plan.continuationKey}:pending`,
|
|
256
|
+
staged,
|
|
257
|
+
);
|
|
258
|
+
const nextState = replayPiToolRecovery([
|
|
259
|
+
...bodies,
|
|
260
|
+
pending.body,
|
|
261
|
+
]);
|
|
262
|
+
const dispatch = planPiToolRecovery(nextState);
|
|
263
|
+
if (dispatch.kind !== "dispatch-continuation") {
|
|
264
|
+
return { mutations: [pending], effect: { kind: "wait" } };
|
|
265
|
+
}
|
|
266
|
+
return fromDispatch(nextState, dispatch, now, [pending]);
|
|
267
|
+
}
|
|
268
|
+
case "dispatch-continuation":
|
|
269
|
+
return fromDispatch(state, plan, now, []);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 把已进入 dispatch 阶段的续跑计划交给正确的执行通道。
|
|
274
|
+
// fromPlan 处理新暂存的续跑和已暂存的续跑时都会调用它。
|
|
275
|
+
// 审批续跑保留给幂等调度;普通工具续跑先生成 committed 里程碑再返回 resume-turn,二者不能合并。
|
|
276
|
+
function fromDispatch(
|
|
277
|
+
state: PiToolRecoveryState,
|
|
278
|
+
plan: Extract<
|
|
279
|
+
PiToolRecoveryPlan,
|
|
280
|
+
{ kind: "dispatch-continuation" }
|
|
281
|
+
>,
|
|
282
|
+
now: number,
|
|
283
|
+
mutations: PiDurableMutation[],
|
|
284
|
+
): Pick<PiRecoveryDecision, "mutations" | "effect"> {
|
|
285
|
+
const executionId = approvalExecutionId(plan.continuationKey);
|
|
286
|
+
if (executionId) {
|
|
287
|
+
return {
|
|
288
|
+
mutations,
|
|
289
|
+
effect: {
|
|
290
|
+
kind: "schedule-continuation",
|
|
291
|
+
continuationKey: plan.continuationKey,
|
|
292
|
+
approvalExecutionId: executionId,
|
|
293
|
+
idempotencyKey: plan.idempotencyKey,
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const committed = commitPiRecoveryContinuation(state, plan, now);
|
|
298
|
+
return {
|
|
299
|
+
mutations: committed
|
|
300
|
+
? [
|
|
301
|
+
...mutations,
|
|
302
|
+
milestoneMutation(
|
|
303
|
+
`continuation:${plan.continuationKey}:committed`,
|
|
304
|
+
committed,
|
|
305
|
+
),
|
|
306
|
+
]
|
|
307
|
+
: mutations,
|
|
308
|
+
effect: { kind: "resume-turn" },
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// #endregion
|
|
313
|
+
|
|
314
|
+
// #region Recovery decision
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 重放持久化里程碑,应用一条恢复命令,并算出下一批写入和后续动作。
|
|
318
|
+
*
|
|
319
|
+
* `PiRuntimeAdapter.decideRecovery` 在记录工具、审批、终态、续跑以及启动检查时调用它。
|
|
320
|
+
*
|
|
321
|
+
* 这里保持纯计算并只返回 mutations 和 effect;调用方必须先持久化 mutations,再执行 effect,不能把 I/O 搬进来破坏可重放性。
|
|
322
|
+
*/
|
|
323
|
+
export function decidePiRecovery(
|
|
324
|
+
input: PiRecoveryInput,
|
|
325
|
+
): PiRecoveryDecision {
|
|
326
|
+
let bodies = [...input.milestoneBodies];
|
|
327
|
+
let state = replayPiToolRecovery(bodies);
|
|
328
|
+
const mutations: PiDurableMutation[] = [];
|
|
329
|
+
let applied = false;
|
|
330
|
+
|
|
331
|
+
if (input.command.kind === "approval-decision") {
|
|
332
|
+
const command = input.command;
|
|
333
|
+
const decision = applyRecoveredPiApprovalDecision(state, {
|
|
334
|
+
executionId: command.executionId,
|
|
335
|
+
decision: command.approved ? "approve" : "reject",
|
|
336
|
+
decidedAt: input.now,
|
|
337
|
+
reason: command.reason,
|
|
338
|
+
});
|
|
339
|
+
applied = decision.outcome.kind !== "noop";
|
|
340
|
+
if (applied) {
|
|
341
|
+
const approval = decision.milestones.find(
|
|
342
|
+
(milestone) => milestone.type === "approval",
|
|
343
|
+
);
|
|
344
|
+
if (!approval || approval.type !== "approval") {
|
|
345
|
+
throw new Error("Pi approval decision milestone is missing");
|
|
346
|
+
}
|
|
347
|
+
mutations.push({
|
|
348
|
+
kind: "decide-approval",
|
|
349
|
+
executionId: approval.approval.executionId,
|
|
350
|
+
status: approval.approval.status as "approved" | "rejected",
|
|
351
|
+
decidedAt: approval.approval.decidedAt ?? input.now,
|
|
352
|
+
...(approval.approval.reason === undefined
|
|
353
|
+
? {}
|
|
354
|
+
: { reason: approval.approval.reason }),
|
|
355
|
+
});
|
|
356
|
+
decision.milestones.forEach((milestone, index) => {
|
|
357
|
+
mutations.push(milestoneMutation(
|
|
358
|
+
milestone.type === "approval"
|
|
359
|
+
? `approval:${command.executionId}:${approval.approval.status}`
|
|
360
|
+
: `approval-result:${command.executionId}:${index}`,
|
|
361
|
+
milestone,
|
|
362
|
+
));
|
|
363
|
+
});
|
|
364
|
+
bodies = [
|
|
365
|
+
...bodies,
|
|
366
|
+
...decision.milestones.map(encodePiToolRecoveryMilestone),
|
|
367
|
+
];
|
|
368
|
+
state = replayPiToolRecovery(bodies);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (input.command.kind === "record-tool-input") {
|
|
372
|
+
const record = input.command.record;
|
|
373
|
+
const existing = state.toolCalls[record.toolCallId];
|
|
374
|
+
if (existing) {
|
|
375
|
+
if (
|
|
376
|
+
existing.toolName !== record.toolName ||
|
|
377
|
+
existing.retry !== record.retry ||
|
|
378
|
+
JSON.stringify(existing.input) !== JSON.stringify(record.input)
|
|
379
|
+
) {
|
|
380
|
+
throw new Error(
|
|
381
|
+
`Conflicting Pi Tool input for ${record.toolCallId}`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
} else {
|
|
385
|
+
const mutation = milestoneMutation(
|
|
386
|
+
`tool-input:${record.toolCallId}`,
|
|
387
|
+
{
|
|
388
|
+
...recoveryIdentity(input),
|
|
389
|
+
type: "tool-input",
|
|
390
|
+
...record,
|
|
391
|
+
},
|
|
392
|
+
);
|
|
393
|
+
mutations.push(mutation);
|
|
394
|
+
bodies = [...bodies, mutation.body];
|
|
395
|
+
state = replayPiToolRecovery(bodies);
|
|
396
|
+
applied = true;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (input.command.kind === "record-approval") {
|
|
400
|
+
const approval = input.command.approval;
|
|
401
|
+
const existing = state.approvals[approval.executionId];
|
|
402
|
+
if (existing) {
|
|
403
|
+
if (
|
|
404
|
+
existing.requestId !== approval.requestId ||
|
|
405
|
+
existing.toolCallId !== approval.toolCallId ||
|
|
406
|
+
existing.toolName !== approval.toolName ||
|
|
407
|
+
existing.inputJson !== approval.inputJson
|
|
408
|
+
) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`Conflicting Pi Tool approval: ${approval.executionId}`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
} else {
|
|
414
|
+
const mutation = milestoneMutation(
|
|
415
|
+
`approval:${approval.executionId}:${approval.status}`,
|
|
416
|
+
{
|
|
417
|
+
...recoveryIdentity(input),
|
|
418
|
+
type: "approval",
|
|
419
|
+
approval,
|
|
420
|
+
},
|
|
421
|
+
);
|
|
422
|
+
mutations.push(mutation);
|
|
423
|
+
bodies = [...bodies, mutation.body];
|
|
424
|
+
state = replayPiToolRecovery(bodies);
|
|
425
|
+
applied = true;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (input.command.kind === "record-tool-result") {
|
|
429
|
+
const command = input.command;
|
|
430
|
+
const toolResult: ToolResultMessage = {
|
|
431
|
+
role: "toolResult",
|
|
432
|
+
toolCallId: command.toolCallId,
|
|
433
|
+
toolName: command.toolName,
|
|
434
|
+
content: command.result.content,
|
|
435
|
+
details: command.result.details,
|
|
436
|
+
isError: command.isError,
|
|
437
|
+
timestamp: command.timestamp,
|
|
438
|
+
};
|
|
439
|
+
const existing = state.toolCalls[command.toolCallId]?.result;
|
|
440
|
+
if (existing) {
|
|
441
|
+
if (JSON.stringify(existing) !== JSON.stringify(toolResult)) {
|
|
442
|
+
throw new Error(
|
|
443
|
+
`Conflicting Pi Tool result for ${command.toolCallId}`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
} else {
|
|
447
|
+
const mutation = milestoneMutation(
|
|
448
|
+
`tool-result:${command.toolCallId}`,
|
|
449
|
+
{
|
|
450
|
+
...recoveryIdentity(input),
|
|
451
|
+
type: "tool-result",
|
|
452
|
+
toolResult,
|
|
453
|
+
...(command.needsContinuation === undefined
|
|
454
|
+
? {}
|
|
455
|
+
: { needsContinuation: command.needsContinuation }),
|
|
456
|
+
},
|
|
457
|
+
);
|
|
458
|
+
mutations.push(mutation);
|
|
459
|
+
bodies = [...bodies, mutation.body];
|
|
460
|
+
state = replayPiToolRecovery(bodies);
|
|
461
|
+
applied = true;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (input.command.kind === "record-terminal") {
|
|
465
|
+
const command = input.command;
|
|
466
|
+
const milestone: PiToolRecoveryMilestone = {
|
|
467
|
+
...recoveryIdentity(input),
|
|
468
|
+
type: "terminal",
|
|
469
|
+
phase: command.phase,
|
|
470
|
+
outcome: command.outcome,
|
|
471
|
+
...(command.message === undefined
|
|
472
|
+
? {}
|
|
473
|
+
: { message: command.message }),
|
|
474
|
+
timestamp: input.now,
|
|
475
|
+
};
|
|
476
|
+
const alreadyRecorded = state.terminal &&
|
|
477
|
+
state.terminal.outcome === command.outcome &&
|
|
478
|
+
state.terminal.message === command.message &&
|
|
479
|
+
(command.phase === "intent" || state.terminal.committed);
|
|
480
|
+
if (!alreadyRecorded) {
|
|
481
|
+
const mutation = milestoneMutation(
|
|
482
|
+
`terminal:${command.phase}`,
|
|
483
|
+
milestone,
|
|
484
|
+
);
|
|
485
|
+
mutations.push(mutation);
|
|
486
|
+
bodies = [...bodies, mutation.body];
|
|
487
|
+
state = replayPiToolRecovery(bodies);
|
|
488
|
+
applied = true;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (input.command.kind === "commit-continuation") {
|
|
492
|
+
const plan = planPiToolRecovery(state);
|
|
493
|
+
if (
|
|
494
|
+
plan.kind !== "dispatch-continuation" ||
|
|
495
|
+
plan.continuationKey !== input.command.continuationKey
|
|
496
|
+
) {
|
|
497
|
+
throw new Error(
|
|
498
|
+
`Pi continuation is not ready: ${input.command.continuationKey}`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
const committed = commitPiRecoveryContinuation(
|
|
502
|
+
state,
|
|
503
|
+
plan,
|
|
504
|
+
input.now,
|
|
505
|
+
);
|
|
506
|
+
if (!committed) {
|
|
507
|
+
throw new Error(
|
|
508
|
+
`Pi continuation could not be committed: ${plan.continuationKey}`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
const mutation = milestoneMutation(
|
|
512
|
+
`continuation:${plan.continuationKey}:committed`,
|
|
513
|
+
committed,
|
|
514
|
+
);
|
|
515
|
+
mutations.push(mutation);
|
|
516
|
+
bodies = [...bodies, mutation.body];
|
|
517
|
+
state = replayPiToolRecovery(bodies);
|
|
518
|
+
applied = true;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const planned = fromPlan(
|
|
522
|
+
state,
|
|
523
|
+
planPiToolRecovery(state),
|
|
524
|
+
input.now,
|
|
525
|
+
bodies,
|
|
526
|
+
);
|
|
527
|
+
return {
|
|
528
|
+
applied,
|
|
529
|
+
mutations: [...mutations, ...planned.mutations],
|
|
530
|
+
effect: planned.effect,
|
|
531
|
+
recoveredToolSettlements: recoveredToolSettlements(state),
|
|
532
|
+
...(state.terminal ? { terminal: state.terminal } : {}),
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// #endregion
|
|
537
|
+
|
|
538
|
+
// #region Stream recovery bridges
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* 把已保存的 Pi 流记录还原成通用聊天恢复内核能读取的部分输出。
|
|
542
|
+
*
|
|
543
|
+
* `PiRuntimeRecoveryAdapter.readPartial` 在孤儿流检查、进度恢复和恢复耗尽处理前调用它。
|
|
544
|
+
*
|
|
545
|
+
* 解析规则复用 Agents SDK 的 AI SDK codec,避免再维护一套 chunk reducer。
|
|
546
|
+
*/
|
|
547
|
+
export function readPiRecoveryPartial(chunks: readonly string[]) {
|
|
548
|
+
return aiSdkRecoveryCodec.toRecoveryPartial([...chunks]);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// #endregion
|
|
552
|
+
|
|
553
|
+
export interface PiChatRecoveryData {
|
|
554
|
+
readonly submissionId: string;
|
|
555
|
+
readonly requestId: string;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export interface PiDurableRecoveryState extends PiChatRecoveryData {
|
|
559
|
+
readonly terminal: boolean;
|
|
560
|
+
readonly milestoneBodies: readonly string[];
|
|
561
|
+
readonly settledToolCount: number;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export interface PiRecoveryHost {
|
|
565
|
+
/**
|
|
566
|
+
* 读取请求对应的持久恢复状态。
|
|
567
|
+
*
|
|
568
|
+
* 恢复适配器在分类请求和合并部分输出时调用它。
|
|
569
|
+
*
|
|
570
|
+
* Host 必须返回最新持久化状态,适配器不会缓存副本。
|
|
571
|
+
*/
|
|
572
|
+
readRecoveryState(requestId: string): PiDurableRecoveryState | null;
|
|
573
|
+
/**
|
|
574
|
+
* 重试指定 Submission。
|
|
575
|
+
*
|
|
576
|
+
* 通用恢复内核执行 retry 决策时调用它。
|
|
577
|
+
*
|
|
578
|
+
* 实际 Submission 生命周期留在 Runtime,恢复适配器只转交结果。
|
|
579
|
+
*/
|
|
580
|
+
retryTurn(submissionId: string): Promise<RuntimeRecoveryResult>;
|
|
581
|
+
/**
|
|
582
|
+
* 把指定 Submission 结束为失败。
|
|
583
|
+
*
|
|
584
|
+
* 通用恢复内核无法继续恢复或耗尽预算时调用它。
|
|
585
|
+
*
|
|
586
|
+
* 终态写入留在 Runtime,避免恢复适配器复制提交规则。
|
|
587
|
+
*/
|
|
588
|
+
failTurn(submissionId: string, message: string): Promise<void>;
|
|
589
|
+
/**
|
|
590
|
+
* 判断是否仍有待用户处理的审批。
|
|
591
|
+
*
|
|
592
|
+
* 恢复内核决定等待还是继续调度时调用它。
|
|
593
|
+
*
|
|
594
|
+
* 调用方应直接读取持久审批状态,避免内存缓存跨唤醒后过期。
|
|
595
|
+
*/
|
|
596
|
+
hasPendingInteraction(): boolean;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// #region Generic recovery port adapter
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* 把 Pi 的里程碑恢复协议接到通用聊天恢复内核。
|
|
603
|
+
*
|
|
604
|
+
* `PiRuntimeAdapter.createRecovery` 在 Runtime 初始化恢复端口时创建它,内核随后通过 `RuntimeRecoveryPort` 调用各方法。
|
|
605
|
+
*
|
|
606
|
+
* 适配器只翻译状态和委托 Host 执行 I/O;Pi 决策仍集中在 `decidePiRecovery`,避免内核路径产生第二套恢复规则。
|
|
607
|
+
*/
|
|
608
|
+
export class PiRuntimeRecoveryAdapter
|
|
609
|
+
implements RuntimeRecoveryPort<PiChatRecoveryData> {
|
|
610
|
+
/**
|
|
611
|
+
* 保存 Runtime 提供的恢复 I/O 边界。
|
|
612
|
+
*
|
|
613
|
+
* `PiRuntimeAdapter.createRecovery` 每次创建恢复端口时调用这个构造函数。
|
|
614
|
+
*
|
|
615
|
+
* Host 以单一依赖注入,便于该类保持为协议适配层而不直接访问 Runtime 数据库。
|
|
616
|
+
*/
|
|
617
|
+
constructor(private readonly host: PiRecoveryHost) {}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* 判断一个中断请求应该重试、等待、失败还是忽略。
|
|
621
|
+
*
|
|
622
|
+
* 通用恢复内核在唤醒恢复任务和检查孤儿流时调用它。
|
|
623
|
+
*
|
|
624
|
+
* 分类同时查看 Submission 终态和 Pi 里程碑计划;不能只看流是否结束,否则会越过待审批或不确定的非幂等工具。
|
|
625
|
+
*/
|
|
626
|
+
async classify(requestId: string) {
|
|
627
|
+
const durable = this.host.readRecoveryState(requestId);
|
|
628
|
+
if (!durable) {
|
|
629
|
+
return {
|
|
630
|
+
kind: "fail",
|
|
631
|
+
reason: "Recovered Pi submission is missing",
|
|
632
|
+
} as const;
|
|
633
|
+
}
|
|
634
|
+
if (durable.terminal) {
|
|
635
|
+
return {
|
|
636
|
+
kind: "ignore",
|
|
637
|
+
reason: "Pi submission is already terminal",
|
|
638
|
+
} as const;
|
|
639
|
+
}
|
|
640
|
+
const decision = decidePiRecovery({
|
|
641
|
+
milestoneBodies: durable.milestoneBodies,
|
|
642
|
+
command: { kind: "inspect" },
|
|
643
|
+
now: Date.now(),
|
|
644
|
+
});
|
|
645
|
+
if (decision.effect.kind === "wait") {
|
|
646
|
+
if (decision.effect.reason === "approval") {
|
|
647
|
+
return {
|
|
648
|
+
kind: "park",
|
|
649
|
+
reason: "Pi Turn is waiting for Tool approval",
|
|
650
|
+
} as const;
|
|
651
|
+
}
|
|
652
|
+
if (decision.effect.reason === "uncertain-tool") {
|
|
653
|
+
return {
|
|
654
|
+
kind: "park",
|
|
655
|
+
reason:
|
|
656
|
+
"Pi Turn is parked on an uncertain non-idempotent Tool",
|
|
657
|
+
incidentStatus: "failed",
|
|
658
|
+
} as const;
|
|
659
|
+
}
|
|
660
|
+
if (decision.effect.reason === "complete") {
|
|
661
|
+
return {
|
|
662
|
+
kind: "fail",
|
|
663
|
+
reason:
|
|
664
|
+
"Pi recovery is complete but the Submission is not terminal",
|
|
665
|
+
} as const;
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
kind: "park",
|
|
669
|
+
reason: "Pi recovery is waiting for durable input",
|
|
670
|
+
} as const;
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
kind: "retry",
|
|
674
|
+
data: {
|
|
675
|
+
submissionId: durable.submissionId,
|
|
676
|
+
requestId: durable.requestId,
|
|
677
|
+
},
|
|
678
|
+
} as const;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* 执行 classify 返回的 Pi Turn 重试。
|
|
683
|
+
*
|
|
684
|
+
* 通用恢复内核只在决策为 retry 时调用它,并把 classify 返回的数据原样传回。
|
|
685
|
+
*
|
|
686
|
+
* 实际恢复留给 Host 的 Submission 生命周期,适配器不复制重试流程。
|
|
687
|
+
*/
|
|
688
|
+
retry(data: PiChatRecoveryData): Promise<RuntimeRecoveryResult> {
|
|
689
|
+
return this.host.retryTurn(data.submissionId);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* 在恢复已无法继续时把对应 Submission 结束为失败。
|
|
694
|
+
*
|
|
695
|
+
* 通用恢复内核处理 fail 决策或恢复预算耗尽时调用它。
|
|
696
|
+
*
|
|
697
|
+
* 写入前重新读取状态并跳过已终止项,避免重复终态操作覆盖既有结果。
|
|
698
|
+
*/
|
|
699
|
+
async terminalize(requestId: string, message: string): Promise<void> {
|
|
700
|
+
const durable = this.host.readRecoveryState(requestId);
|
|
701
|
+
if (durable && !durable.terminal) {
|
|
702
|
+
await this.host.failTurn(durable.submissionId, message);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* 合并流 chunk 里的部分输出与数据库中已结算的工具结果信号。
|
|
708
|
+
*
|
|
709
|
+
* 通用恢复内核检查孤儿流、展示恢复进度和处理恢复耗尽时调用它。
|
|
710
|
+
*
|
|
711
|
+
* settlement 不在流 codec 内,因此单独并入 `hasSettledToolResults`,否则无文本但工具已执行的 Turn 会被误判为空。
|
|
712
|
+
*/
|
|
713
|
+
readPartial(input: {
|
|
714
|
+
readonly requestId: string;
|
|
715
|
+
readonly chunks: readonly string[];
|
|
716
|
+
}) {
|
|
717
|
+
const partial = readPiRecoveryPartial(input.chunks);
|
|
718
|
+
const durable = this.host.readRecoveryState(input.requestId);
|
|
719
|
+
return {
|
|
720
|
+
...partial,
|
|
721
|
+
hasSettledToolResults:
|
|
722
|
+
partial.hasSettledToolResults ||
|
|
723
|
+
(durable?.settledToolCount ?? 0) > 0,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* 判断一个原始流 chunk 是否应该刷新持久恢复进度。
|
|
729
|
+
*
|
|
730
|
+
* `RecoverableChatAgent.appendRecoverableChunk` 保存 chunk 后调用它。
|
|
731
|
+
*
|
|
732
|
+
* JSON 解析失败返回 false,让损坏记录留给完整重放检测,而不是让进度计数路径抛错中断写流。
|
|
733
|
+
*/
|
|
734
|
+
shouldCreditProgress(input: {
|
|
735
|
+
readonly body: string;
|
|
736
|
+
readonly throttle: StreamProgressCreditThrottle;
|
|
737
|
+
readonly now: number;
|
|
738
|
+
}): boolean {
|
|
739
|
+
try {
|
|
740
|
+
const chunk = JSON.parse(input.body) as { type?: unknown };
|
|
741
|
+
return shouldCreditStreamProgress({
|
|
742
|
+
codec: aiSdkRecoveryCodec,
|
|
743
|
+
type: typeof chunk.type === "string" ? chunk.type : undefined,
|
|
744
|
+
throttle: input.throttle,
|
|
745
|
+
now: input.now,
|
|
746
|
+
});
|
|
747
|
+
} catch {
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* 从 Pi 消息片段中提取恢复耗尽事件允许暴露的文本和推理内容。
|
|
754
|
+
*
|
|
755
|
+
* 通用恢复内核准备 `chat:recovery:exhausted` 事件时调用它。
|
|
756
|
+
*
|
|
757
|
+
* 这里显式筛选运行时形状,只转发 text 和 thinking,避免把未知或工具片段错误强制转换成公开事件内容。
|
|
758
|
+
*/
|
|
759
|
+
toExhaustionParts(parts: readonly unknown[]): Array<{
|
|
760
|
+
type: "text" | "reasoning";
|
|
761
|
+
text: string;
|
|
762
|
+
}> {
|
|
763
|
+
const output: Array<{
|
|
764
|
+
type: "text" | "reasoning";
|
|
765
|
+
text: string;
|
|
766
|
+
}> = [];
|
|
767
|
+
for (const part of parts) {
|
|
768
|
+
if (
|
|
769
|
+
part !== null &&
|
|
770
|
+
typeof part === "object" &&
|
|
771
|
+
"type" in part &&
|
|
772
|
+
part.type === "text" &&
|
|
773
|
+
"text" in part &&
|
|
774
|
+
typeof part.text === "string"
|
|
775
|
+
) {
|
|
776
|
+
output.push({ type: "text", text: part.text });
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
if (
|
|
780
|
+
part !== null &&
|
|
781
|
+
typeof part === "object" &&
|
|
782
|
+
"type" in part &&
|
|
783
|
+
part.type === "reasoning" &&
|
|
784
|
+
"text" in part &&
|
|
785
|
+
typeof part.text === "string"
|
|
786
|
+
) {
|
|
787
|
+
output.push({ type: "reasoning", text: part.text });
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return output;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* 判断 Runtime 当前是否在等待用户审批。
|
|
795
|
+
*
|
|
796
|
+
* 通用恢复引擎决定是否继续安排恢复工作时调用它。
|
|
797
|
+
*
|
|
798
|
+
* 真值由 Host 的持久审批状态提供,适配器不缓存副本,避免恢复调度看到过期交互状态。
|
|
799
|
+
*/
|
|
800
|
+
isAwaitingInteraction(): boolean {
|
|
801
|
+
return this.host.hasPendingInteraction();
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// #endregion
|