@springbrand/agent-runtime 0.2.0-alpha.62 → 0.2.0-alpha.64
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 +2 -2
- package/src/db/milestone.repo.ts +8 -0
- package/src/kernel/approval-lifecycle.ts +23 -5
- package/src/kernel/interaction-lifecycle.ts +12 -3
- package/src/layers/context/budget/gate.ts +19 -4
- package/src/pi/runtime-adapter/execution.ts +2 -2
- package/src/pi/runtime-adapter/index.ts +9 -2
- package/src/pi/runtime-adapter/recovery.ts +20 -8
- package/src/pi/tool/compiler.ts +32 -8
- package/src/pi/tool/core.ts +15 -6
- package/src/pi/tool/workspace-sandbox.ts +4 -3
- package/src/runtime.ts +45 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@springbrand/agent-runtime",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.64",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -35,6 +35,6 @@
|
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"agents": "0.22.0",
|
|
38
|
-
"ai": "7.0.
|
|
38
|
+
"ai": "7.0.56"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/db/milestone.repo.ts
CHANGED
|
@@ -33,6 +33,14 @@ export class RecoveryMilestoneRepository {
|
|
|
33
33
|
// 恢复事实必须与 Submission 位于同一 Agent 存储中,不能用另一个 sql 入口拼接。
|
|
34
34
|
constructor(private readonly sql: SqlTaggedTemplate) {}
|
|
35
35
|
|
|
36
|
+
/** The existing unique index makes active Tool writes independent of Turn history size. */
|
|
37
|
+
findBody(submissionId: string, milestoneKey: string): string | null {
|
|
38
|
+
return this.sql<{ body: string }>`
|
|
39
|
+
SELECT body FROM pi_recovery_milestones
|
|
40
|
+
WHERE submission_id = ${submissionId} AND milestone_key = ${milestoneKey}
|
|
41
|
+
`[0]?.body ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
// 按写入顺序返回某次 Submission 的全部恢复内容。
|
|
37
45
|
// Runtime 和 ApprovalLifecycle 在计算下一个恢复决策前调用,调用方直接把结果交给 Pi 恢复器。
|
|
38
46
|
// ORDER BY seq 是重放契约,不能改成 milestoneKey 或无排序查询,否则决策顺序会变。
|
|
@@ -72,6 +72,7 @@ interface ApprovalLifecycleOptions<
|
|
|
72
72
|
readonly db: RuntimeDatabase;
|
|
73
73
|
readonly pi: PiRuntimeAdapter;
|
|
74
74
|
readonly findSubmission: (submissionId: string) => TSubmission | null;
|
|
75
|
+
readonly isSubmissionActive: (submissionId: string) => boolean;
|
|
75
76
|
readonly applyRecoveryMutations: (
|
|
76
77
|
submission: TSubmission,
|
|
77
78
|
mutations: readonly PiDurableMutation[],
|
|
@@ -227,11 +228,23 @@ export class ApprovalLifecycle<
|
|
|
227
228
|
}
|
|
228
229
|
return value;
|
|
229
230
|
});
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
231
|
+
// Install the waiter before publishing pending state. Otherwise an early
|
|
232
|
+
// decision can schedule a second executor while this one is still alive.
|
|
233
|
+
const waiting = this.wait(pending.approval, signal);
|
|
234
|
+
try {
|
|
235
|
+
await Promise.all([
|
|
236
|
+
waiting,
|
|
237
|
+
(async () => {
|
|
238
|
+
if (pending.created) {
|
|
239
|
+
await this.options.onApprovalsChanged();
|
|
240
|
+
await onApproval?.(pending.approval);
|
|
241
|
+
}
|
|
242
|
+
})(),
|
|
243
|
+
]);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
this.waiters.get(pending.approval.executionId)?.release();
|
|
246
|
+
throw error;
|
|
233
247
|
}
|
|
234
|
-
return this.wait(pending.approval, signal);
|
|
235
248
|
}
|
|
236
249
|
|
|
237
250
|
/**
|
|
@@ -362,7 +375,12 @@ export class ApprovalLifecycle<
|
|
|
362
375
|
return { ok: false };
|
|
363
376
|
}
|
|
364
377
|
if (stored.status !== "pending") {
|
|
365
|
-
|
|
378
|
+
// A released waiter does not mean its Tool has finished. Duplicate
|
|
379
|
+
// decisions must not start recovery alongside the live Submission.
|
|
380
|
+
if (
|
|
381
|
+
!this.waiters.has(stored.executionId) &&
|
|
382
|
+
!this.options.isSubmissionActive(submission.submissionId)
|
|
383
|
+
) {
|
|
366
384
|
await this.dispatchContinuation(
|
|
367
385
|
submission,
|
|
368
386
|
stored.executionId,
|
|
@@ -144,9 +144,18 @@ export class InteractionLifecycle<
|
|
|
144
144
|
}
|
|
145
145
|
return value.interaction;
|
|
146
146
|
});
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
// Register synchronously, then publish while waiting for the person. An
|
|
148
|
+
// early response must release this execution instead of starting recovery.
|
|
149
|
+
const waiting = this.wait(pending, signal);
|
|
150
|
+
try {
|
|
151
|
+
await Promise.all([
|
|
152
|
+
waiting,
|
|
153
|
+
(async () => { await this.options.onInteractionsChanged?.(); })(),
|
|
154
|
+
]);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
this.waiters.get(pending.interactionId)?.();
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
150
159
|
}
|
|
151
160
|
|
|
152
161
|
/**
|
|
@@ -186,10 +186,21 @@ export async function spillDurableToolOutput(
|
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
// Native media is budgeted by the model/provider, not by a text JSON limit.
|
|
190
|
+
// Counting base64 here would discard earlier images even though their bytes cannot be truncated.
|
|
191
|
+
function modelTextSize(value: unknown): number {
|
|
192
|
+
if (typeof value === "string") return value.length;
|
|
193
|
+
return JSON.stringify(value, (_key, entry: unknown) =>
|
|
194
|
+
entry !== null && typeof entry === "object" && isBinaryContentLike(entry)
|
|
195
|
+
? "[media]"
|
|
196
|
+
: entry
|
|
197
|
+
)?.length ?? 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
189
200
|
// Bounds leaves first, then removes oldest array entries until the view fits.
|
|
190
201
|
function structureView(output: unknown): unknown {
|
|
191
202
|
const capped = truncateStringLeaves(output, MODEL_LEAF_MAX_CHARS);
|
|
192
|
-
if (
|
|
203
|
+
if (modelTextSize(capped) <= MODEL_REPLAY_THRESHOLD) {
|
|
193
204
|
return capped;
|
|
194
205
|
}
|
|
195
206
|
if (typeof capped !== "object" || capped === null) return capped;
|
|
@@ -204,10 +215,14 @@ function structureView(output: unknown): unknown {
|
|
|
204
215
|
let dropped = 0;
|
|
205
216
|
while (
|
|
206
217
|
items.length > 1 &&
|
|
207
|
-
|
|
218
|
+
modelTextSize({ ...record, [arrayKey]: items }) >
|
|
208
219
|
MODEL_REPLAY_THRESHOLD
|
|
209
220
|
) {
|
|
210
|
-
items.
|
|
221
|
+
const removable = items.findIndex((item) =>
|
|
222
|
+
item === null || typeof item !== "object" || !isBinaryContentLike(item)
|
|
223
|
+
);
|
|
224
|
+
if (removable < 0) break;
|
|
225
|
+
items.splice(removable, 1);
|
|
211
226
|
dropped += 1;
|
|
212
227
|
}
|
|
213
228
|
if (dropped === 0) return capped;
|
|
@@ -224,7 +239,7 @@ function structureView(output: unknown): unknown {
|
|
|
224
239
|
/** Creates a non-destructive, smaller Tool-result projection for the model. */
|
|
225
240
|
export function projectToolOutputForModel(output: unknown): unknown {
|
|
226
241
|
try {
|
|
227
|
-
return
|
|
242
|
+
return modelTextSize(output) <= MODEL_REPLAY_THRESHOLD
|
|
228
243
|
? output
|
|
229
244
|
: structureView(output);
|
|
230
245
|
} catch {
|
|
@@ -867,8 +867,8 @@ export class PreparedPiTurnAdapter {
|
|
|
867
867
|
execute: (toolCallId, input, signal, onUpdate) => {
|
|
868
868
|
const runtimeCandidate = codeExecutionPiToolCandidate(
|
|
869
869
|
factory.create(state.codeExecutionCandidates.map((inner) =>
|
|
870
|
-
// Code Mode
|
|
871
|
-
//
|
|
870
|
+
// Code Mode records its own connector calls. Pi persists the
|
|
871
|
+
// non-idempotent parent execute attempt/result; recording an inner
|
|
872
872
|
// input without a Pi settlement creates an orphan recovery
|
|
873
873
|
// action that cannot be found on the direct Tool surface.
|
|
874
874
|
bindCandidate(inner, toolCallId, false)
|
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
} from "./assembly";
|
|
31
31
|
import {
|
|
32
32
|
decidePiRecovery,
|
|
33
|
+
recordPiToolMilestone,
|
|
34
|
+
type PiToolMilestoneInput,
|
|
33
35
|
PiRuntimeRecoveryAdapter,
|
|
34
36
|
type PiRecoveryHost,
|
|
35
37
|
type PiRecoveryInput,
|
|
@@ -180,15 +182,20 @@ export class PiRuntimeAdapter {
|
|
|
180
182
|
* 根据持久化 milestone 和一个命令算出恢复时要写什么、下一步做什么。
|
|
181
183
|
*
|
|
182
184
|
* @remarks
|
|
183
|
-
* 调用方:Runtime 和 approval
|
|
185
|
+
* 调用方:Runtime 和 approval 生命周期在检查恢复状态、处理审批或提交终态时调用。
|
|
184
186
|
*
|
|
185
187
|
* 实现理由:该方法只返回 durable mutations 和一个 effect,实际写库与调度仍由 Runtime 完成。
|
|
186
|
-
*
|
|
188
|
+
* 恢复规划保留在此入口;正常 Tool 写入复用同一命令 reducer,但不重复规划整个 Turn。
|
|
187
189
|
*/
|
|
188
190
|
decideRecovery(options: PiRecoveryInput) {
|
|
189
191
|
return decidePiRecovery(options);
|
|
190
192
|
}
|
|
191
193
|
|
|
194
|
+
/** Active execution records only the indexed Tool fact; cold recovery still replays the full log. */
|
|
195
|
+
recordToolMilestone(options: PiToolMilestoneInput) {
|
|
196
|
+
return recordPiToolMilestone(options);
|
|
197
|
+
}
|
|
198
|
+
|
|
192
199
|
/**
|
|
193
200
|
* 把 Runtime 提供的持久化和重试回调包装成通用聊天恢复端口。
|
|
194
201
|
*
|
|
@@ -369,15 +369,11 @@ function fromDispatch(
|
|
|
369
369
|
// #region Recovery decision
|
|
370
370
|
|
|
371
371
|
/**
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
* 这里保持纯计算并只返回 mutations 和 effect;调用方必须先持久化 mutations,再执行 effect,不能把 I/O 搬进来破坏可重放性。
|
|
372
|
+
* Apply a durable command using the same validation during execution and recovery.
|
|
373
|
+
* Active Tool recording supplies only its indexed milestone; recovery supplies
|
|
374
|
+
* the full log and then plans the next effect. Neither path performs I/O.
|
|
377
375
|
*/
|
|
378
|
-
|
|
379
|
-
input: PiRecoveryInput,
|
|
380
|
-
): PiRecoveryDecision {
|
|
376
|
+
function applyPiRecoveryCommand(input: PiRecoveryInput) {
|
|
381
377
|
let bodies = [...input.milestoneBodies];
|
|
382
378
|
let state = replayPiToolRecovery(bodies);
|
|
383
379
|
const mutations: PiDurableMutation[] = [];
|
|
@@ -677,6 +673,22 @@ export function decidePiRecovery(
|
|
|
677
673
|
applied = true;
|
|
678
674
|
}
|
|
679
675
|
|
|
676
|
+
return { state, bodies, mutations, applied };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export interface PiToolMilestoneInput extends PiRecoveryInput {
|
|
680
|
+
readonly command: Extract<PiRecoveryCommand, {
|
|
681
|
+
kind: "record-tool-input" | "record-tool-result";
|
|
682
|
+
}>;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Record a single Tool fact without planning a recovery of the active Turn. */
|
|
686
|
+
export function recordPiToolMilestone(input: PiToolMilestoneInput): readonly PiDurableMutation[] {
|
|
687
|
+
return applyPiRecoveryCommand(input).mutations;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export function decidePiRecovery(input: PiRecoveryInput): PiRecoveryDecision {
|
|
691
|
+
const { state, bodies, mutations, applied } = applyPiRecoveryCommand(input);
|
|
680
692
|
const planned = fromPlan(
|
|
681
693
|
state,
|
|
682
694
|
planPiToolRecovery(state),
|
package/src/pi/tool/compiler.ts
CHANGED
|
@@ -108,6 +108,10 @@ export interface SettledPiToolCall {
|
|
|
108
108
|
|
|
109
109
|
const MAX_TOOL_RETRIES = 3;
|
|
110
110
|
const TURN_FAILURE_CEILING = 8;
|
|
111
|
+
// DO SQLite rows also contain arguments and transcript/recovery envelopes.
|
|
112
|
+
// Keep each serialized result below half the 2 MiB row limit.
|
|
113
|
+
const DURABLE_TOOL_RESULT_MAX_BYTES = 1024 * 1024;
|
|
114
|
+
const MEDIA_CAPTION_MAX_CHARS = 4 * 1024;
|
|
111
115
|
|
|
112
116
|
interface PiToolGovernanceState {
|
|
113
117
|
readonly failures: Map<string, number>;
|
|
@@ -177,6 +181,15 @@ function artifactResult(
|
|
|
177
181
|
result: AgentToolResult<unknown>,
|
|
178
182
|
ref: ArtifactRef,
|
|
179
183
|
): AgentToolResult<unknown> {
|
|
184
|
+
let captionRemaining = MEDIA_CAPTION_MAX_CHARS;
|
|
185
|
+
const mediaContent = result.content.some((part) => part.type !== "text")
|
|
186
|
+
? result.content.flatMap((part): AgentToolResult<unknown>["content"] => {
|
|
187
|
+
if (part.type !== "text") return [part];
|
|
188
|
+
const text = part.text.slice(0, captionRemaining);
|
|
189
|
+
captionRemaining -= text.length;
|
|
190
|
+
return text ? [{ ...part, text }] : [];
|
|
191
|
+
})
|
|
192
|
+
: [];
|
|
180
193
|
return {
|
|
181
194
|
...result,
|
|
182
195
|
content: [{
|
|
@@ -187,7 +200,7 @@ function artifactResult(
|
|
|
187
200
|
`Bytes: ${ref.bytes}`,
|
|
188
201
|
`Preview:\n${ref.preview}`,
|
|
189
202
|
].join("\n"),
|
|
190
|
-
}],
|
|
203
|
+
}, ...mediaContent],
|
|
191
204
|
details: ref,
|
|
192
205
|
};
|
|
193
206
|
}
|
|
@@ -197,13 +210,24 @@ async function durableResult(
|
|
|
197
210
|
candidate: PiToolCandidate,
|
|
198
211
|
workspace: SpillWorkspace | undefined,
|
|
199
212
|
): Promise<AgentToolResult<unknown>> {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
213
|
+
const spill = (candidate.outputBudget ?? { kind: "spill" }).kind === "spill";
|
|
214
|
+
let ref = spill
|
|
215
|
+
? await spillDurableToolOutput(spillPayload(result), { workspace })
|
|
216
|
+
: null;
|
|
217
|
+
const bounded = ref
|
|
218
|
+
? artifactResult(result, ref)
|
|
219
|
+
: boundDurableToolOutput(result) as AgentToolResult<unknown>;
|
|
220
|
+
const bytes = new TextEncoder().encode(JSON.stringify(bounded)).byteLength;
|
|
221
|
+
if (bytes <= DURABLE_TOOL_RESULT_MAX_BYTES) return bounded;
|
|
222
|
+
|
|
223
|
+
// Binary blocks cannot be truncated, and this boundary has no image resizer.
|
|
224
|
+
// Preserve the original where possible and fail before any SQLite write.
|
|
225
|
+
if (!spill) ref = await spillDurableToolOutput(spillPayload(result), { workspace });
|
|
226
|
+
throw new Error(
|
|
227
|
+
`Tool output exceeds the 1 MiB durable result limit (${bytes} bytes). ` +
|
|
228
|
+
"Return a smaller result, such as lower-resolution images or fewer media items; the original cannot be included inline. " +
|
|
229
|
+
(ref ? `Original output saved to Workspace: ${ref.path}` : "The original output could not be saved to Workspace."),
|
|
230
|
+
);
|
|
207
231
|
}
|
|
208
232
|
|
|
209
233
|
async function untilAborted<T>(
|
package/src/pi/tool/core.ts
CHANGED
|
@@ -246,10 +246,17 @@ function runCodemode(
|
|
|
246
246
|
}), CODEMODE_EXECUTE_TIMEOUT_MS);
|
|
247
247
|
});
|
|
248
248
|
try {
|
|
249
|
-
|
|
249
|
+
const output = await Promise.race([
|
|
250
250
|
runtime.execute(input),
|
|
251
251
|
deadline.then(result),
|
|
252
|
-
])
|
|
252
|
+
]);
|
|
253
|
+
const details = output.details as { status?: unknown; error?: unknown } | null;
|
|
254
|
+
if (details?.status === "error") {
|
|
255
|
+
throw new Error(typeof details.error === "string"
|
|
256
|
+
? details.error
|
|
257
|
+
: `${label} failed`);
|
|
258
|
+
}
|
|
259
|
+
return project(output);
|
|
253
260
|
} finally {
|
|
254
261
|
if (timeout !== undefined) clearTimeout(timeout);
|
|
255
262
|
}
|
|
@@ -257,12 +264,12 @@ function runCodemode(
|
|
|
257
264
|
}
|
|
258
265
|
|
|
259
266
|
/**
|
|
260
|
-
* 把 Cloudflare Codemode
|
|
267
|
+
* 把 Cloudflare Codemode execution port 包装为 Pi 代码执行工具候选项。
|
|
261
268
|
*
|
|
262
269
|
* Tool Surface 收到 Host 已组装的 Code Execution Port 后调用,
|
|
263
270
|
* 模型再通过 `execute` 运行代码。
|
|
264
271
|
*
|
|
265
|
-
* Code Mode
|
|
272
|
+
* Code Mode 的外层执行声明为 safe;内部候选仍经过各自的执行档位与审批门禁。
|
|
266
273
|
*/
|
|
267
274
|
export function codeExecutionPiToolCandidate(
|
|
268
275
|
runtime: RuntimeCodeExecutionPort,
|
|
@@ -273,13 +280,15 @@ export function codeExecutionPiToolCandidate(
|
|
|
273
280
|
description: runtime.description,
|
|
274
281
|
parameters: executeParameters,
|
|
275
282
|
// Pi 工具循环在模型选择 `execute` 时调用,调用前允许 Turn 取消。
|
|
276
|
-
//
|
|
283
|
+
// 上游保留 connector 执行日志,但此调用会创建新 execution,不能把它当作旧执行的恢复。
|
|
277
284
|
execute: runCodemode(runtime, "Code Mode execute"),
|
|
278
285
|
};
|
|
279
286
|
return {
|
|
280
287
|
owner: "core:codemode",
|
|
281
288
|
requiredExecutionLevel: "safe",
|
|
282
|
-
|
|
289
|
+
// Each upstream execute starts a new execution ID. A lost parent result
|
|
290
|
+
// cannot safely replay its already-applied connector or Workspace writes.
|
|
291
|
+
retry: "non-idempotent",
|
|
283
292
|
outputBudget: { kind: "structure" },
|
|
284
293
|
source: "codemode",
|
|
285
294
|
summary: "Run JavaScript with network and configured connector access",
|
|
@@ -392,9 +392,10 @@ export function workspacePiToolCandidates(
|
|
|
392
392
|
if (typeof path !== "string") {
|
|
393
393
|
return removeTool.execute(toolCallId, params, signal, onUpdate);
|
|
394
394
|
}
|
|
395
|
-
return serializeWorkspaceMutation(workspace, path, () =>
|
|
396
|
-
|
|
397
|
-
|
|
395
|
+
return serializeWorkspaceMutation(workspace, path, () => {
|
|
396
|
+
signal?.throwIfAborted();
|
|
397
|
+
return removeTool.execute(toolCallId, params, signal, onUpdate);
|
|
398
|
+
});
|
|
398
399
|
},
|
|
399
400
|
};
|
|
400
401
|
|
package/src/runtime.ts
CHANGED
|
@@ -248,7 +248,7 @@ type RecoveredTurnPreparation =
|
|
|
248
248
|
// 原因:终态文本会直接呈现给用户,必须说明发生了什么以及能不能重试。
|
|
249
249
|
function unresumableWaitMessage(reason: string | undefined): string {
|
|
250
250
|
return reason === "uncertain-tool"
|
|
251
|
-
? "SpringBrand stopped this turn: a non-idempotent Tool was interrupted and its result cannot be confirmed, so it was not retried.
|
|
251
|
+
? "SpringBrand stopped this turn: a non-idempotent Tool was interrupted and its result cannot be confirmed, so it was not retried. Check whether the external action already happened before sending another request."
|
|
252
252
|
: "SpringBrand could not resume this turn after an interruption.";
|
|
253
253
|
}
|
|
254
254
|
|
|
@@ -600,6 +600,7 @@ export abstract class AgentRuntimeKernel<
|
|
|
600
600
|
pi: this.pi,
|
|
601
601
|
findSubmission: (submissionId) =>
|
|
602
602
|
this.readSubmission(submissionId),
|
|
603
|
+
isSubmissionActive: (submissionId) => this.submissions.isActive(submissionId),
|
|
603
604
|
applyRecoveryMutations: (submission, mutations) =>
|
|
604
605
|
this.applyPiRecoveryMutations(submission, mutations),
|
|
605
606
|
materializeRecoveredToolResults: (submission) =>
|
|
@@ -2142,6 +2143,27 @@ export abstract class AgentRuntimeKernel<
|
|
|
2142
2143
|
});
|
|
2143
2144
|
}
|
|
2144
2145
|
|
|
2146
|
+
private recordPiToolMilestone(
|
|
2147
|
+
submission: StoredSubmission,
|
|
2148
|
+
command: Extract<PiRecoveryCommand, {
|
|
2149
|
+
kind: "record-tool-input" | "record-tool-result";
|
|
2150
|
+
}>,
|
|
2151
|
+
): readonly PiDurableMutation[] {
|
|
2152
|
+
const key = command.kind === "record-tool-input"
|
|
2153
|
+
? `tool-input:${command.record.toolCallId}`
|
|
2154
|
+
: `tool-result:${command.toolCallId}`;
|
|
2155
|
+
const body = this.db.milestones.findBody(submission.submissionId, key);
|
|
2156
|
+
return this.pi.recordToolMilestone({
|
|
2157
|
+
milestoneBodies: body === null ? [] : [body],
|
|
2158
|
+
identity: {
|
|
2159
|
+
turnId: submission.submissionId,
|
|
2160
|
+
assemblyRevision: submission.assemblyRevision,
|
|
2161
|
+
},
|
|
2162
|
+
command,
|
|
2163
|
+
now: Date.now(),
|
|
2164
|
+
});
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2145
2167
|
// 作用:把 Pi 恢复决策产生的变更写入 Runtime 数据库。
|
|
2146
2168
|
// 调用:记录 Tool、终态、审批或恢复步骤时,通常在事务内调用。
|
|
2147
2169
|
// 原因:upsert 保证重放幂等,而 terminal intent 允许后来的更强结果更新早期意图。
|
|
@@ -2205,15 +2227,12 @@ export abstract class AgentRuntimeKernel<
|
|
|
2205
2227
|
submission: StoredSubmission,
|
|
2206
2228
|
input: PiToolInputRecord,
|
|
2207
2229
|
): boolean {
|
|
2208
|
-
const decision = this.decidePiRecovery(submission, {
|
|
2209
|
-
kind: "record-tool-input",
|
|
2210
|
-
record: input,
|
|
2211
|
-
});
|
|
2212
2230
|
const appended = this.db.transaction(() => {
|
|
2213
|
-
const
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
);
|
|
2231
|
+
const mutations = this.recordPiToolMilestone(submission, {
|
|
2232
|
+
kind: "record-tool-input",
|
|
2233
|
+
record: input,
|
|
2234
|
+
});
|
|
2235
|
+
const inserted = this.applyPiRecoveryMutations(submission, mutations);
|
|
2217
2236
|
if (inserted) {
|
|
2218
2237
|
this.telemetry.capture("toolStarted", {
|
|
2219
2238
|
submissionId: submission.submissionId,
|
|
@@ -2359,15 +2378,18 @@ export abstract class AgentRuntimeKernel<
|
|
|
2359
2378
|
occurredAt: createdAt,
|
|
2360
2379
|
});
|
|
2361
2380
|
}
|
|
2362
|
-
const
|
|
2381
|
+
const mutations = this.recordPiToolMilestone(submission, {
|
|
2363
2382
|
kind: "record-tool-result",
|
|
2364
2383
|
toolCallId: call.toolCallId,
|
|
2365
2384
|
toolName: call.toolName,
|
|
2366
2385
|
result: call.result,
|
|
2367
2386
|
isError: call.isError,
|
|
2368
2387
|
timestamp: createdAt,
|
|
2388
|
+
// The current execution is already continuing. After a crash, recovery
|
|
2389
|
+
// materializes this result before resuming; no per-Tool dispatch is needed.
|
|
2390
|
+
needsContinuation: false,
|
|
2369
2391
|
});
|
|
2370
|
-
this.applyPiRecoveryMutations(submission,
|
|
2392
|
+
this.applyPiRecoveryMutations(submission, mutations);
|
|
2371
2393
|
this.approvals.commitContinuation(submission, call.toolCallId);
|
|
2372
2394
|
}
|
|
2373
2395
|
|
|
@@ -3464,13 +3486,7 @@ export abstract class AgentRuntimeKernel<
|
|
|
3464
3486
|
* 使持久化历史和 resumable stream 只有一个生产者。
|
|
3465
3487
|
*/
|
|
3466
3488
|
async onConnect(connection: Connection): Promise<void> {
|
|
3467
|
-
|
|
3468
|
-
connection,
|
|
3469
|
-
json({
|
|
3470
|
-
type: MessageType.CF_AGENT_CHAT_MESSAGES,
|
|
3471
|
-
messages: await this.hydrationMessages(),
|
|
3472
|
-
}),
|
|
3473
|
-
);
|
|
3489
|
+
await this.sendHydrationSnapshot(connection);
|
|
3474
3490
|
await super.onConnect(connection);
|
|
3475
3491
|
}
|
|
3476
3492
|
|
|
@@ -3596,6 +3612,10 @@ export abstract class AgentRuntimeKernel<
|
|
|
3596
3612
|
}
|
|
3597
3613
|
|
|
3598
3614
|
if (event.type === "stream-resume-request") {
|
|
3615
|
+
// The client sends this only after its listener mounts; onConnect's snapshot
|
|
3616
|
+
// may have arrived while initial history suspended. Active replies still
|
|
3617
|
+
// come exclusively from CF replay, never from this history snapshot.
|
|
3618
|
+
await this.sendHydrationSnapshot(connection);
|
|
3599
3619
|
await this.handleResumeRequest(
|
|
3600
3620
|
connection,
|
|
3601
3621
|
event.probeId,
|
|
@@ -3637,6 +3657,13 @@ export abstract class AgentRuntimeKernel<
|
|
|
3637
3657
|
return this.transcript.browserMessages();
|
|
3638
3658
|
}
|
|
3639
3659
|
|
|
3660
|
+
private async sendHydrationSnapshot(connection: Connection): Promise<void> {
|
|
3661
|
+
sendIfOpen(connection, json({
|
|
3662
|
+
type: MessageType.CF_AGENT_CHAT_MESSAGES,
|
|
3663
|
+
messages: await this.hydrationMessages(),
|
|
3664
|
+
}));
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3640
3667
|
private async hydrationMessages(): Promise<UIMessage[]> {
|
|
3641
3668
|
const messages = await this.getMessages();
|
|
3642
3669
|
const running = this.db.submissions.findRunning();
|