@springbrand/agent-runtime 0.3.0-alpha.17 → 0.3.0-alpha.18
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 +1 -1
- package/src/adapter/cloudflare/agent.ts +15 -1
- package/src/adapter/cloudflare/subagent/definition.ts +10 -0
- package/src/adapter/cloudflare/subagent/runner.ts +31 -11
- package/src/config/harness-runtime.ts +1 -1
- package/src/conversation/harness-stream.ts +22 -6
- package/src/conversation/history.ts +40 -5
- package/src/harness/governance.ts +206 -0
- package/src/harness/index.ts +1 -0
- package/src/harness/session-harness.ts +97 -14
- package/src/models/index.ts +11 -2
- package/src/session/cloudflare/migration.ts +225 -49
- package/src/submission/finalization.ts +30 -3
- package/src/tools/runtime/compiler.ts +8 -1
package/package.json
CHANGED
|
@@ -71,7 +71,7 @@ import type {
|
|
|
71
71
|
} from "@earendil-works/pi-ai";
|
|
72
72
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
73
73
|
import { openCloudflareSession } from "../../session";
|
|
74
|
-
import { openSessionHarness, prepareSubmissionOperation } from "../../harness";
|
|
74
|
+
import { ModelRecovery, openSessionHarness, prepareSubmissionOperation } from "../../harness";
|
|
75
75
|
import type { SessionHarness } from "../../harness";
|
|
76
76
|
import {
|
|
77
77
|
compileHarnessTools,
|
|
@@ -789,10 +789,13 @@ export abstract class AgentRuntimeKernel<
|
|
|
789
789
|
(key) => this.ensureRuntimeVersion(key),
|
|
790
790
|
);
|
|
791
791
|
const activeRuntime = this.requireRuntime();
|
|
792
|
+
// A fresh provider Session per Submission keeps one failing upstream route from pinning later turns.
|
|
793
|
+
const recovery = new ModelRecovery(submissionId);
|
|
792
794
|
const harnessInput = this.pi.harnessInputs(
|
|
793
795
|
activeRuntime.pi,
|
|
794
796
|
submission.assemblyDescriptor,
|
|
795
797
|
{
|
|
798
|
+
modelSessionId: () => recovery.sessionId(),
|
|
796
799
|
assertRequestAllowed: () => {
|
|
797
800
|
const current = this.db.submissions.find(submissionId);
|
|
798
801
|
if (current?.abortReason || current?.unattendedFailure) throw new Error(current.abortReason ?? current.unattendedFailure!);
|
|
@@ -875,6 +878,7 @@ export abstract class AgentRuntimeKernel<
|
|
|
875
878
|
streamOptions: { maxRetries: 0 },
|
|
876
879
|
compaction: harnessInput.compaction,
|
|
877
880
|
toolExecution: "parallel",
|
|
881
|
+
recovery,
|
|
878
882
|
beforeTool: (call, _api, context) => gate.beforeTool({ toolName: call.name, toolCallId: call.id, args: call.arguments }, context),
|
|
879
883
|
afterResponse: (message) => {
|
|
880
884
|
for (const part of message.content) if (part.type === "toolCall") part.arguments = prepareToolInput(part.name, part.arguments) as JsonObject;
|
|
@@ -883,6 +887,7 @@ export abstract class AgentRuntimeKernel<
|
|
|
883
887
|
this.activeHarness = runtime;
|
|
884
888
|
let stream: Awaited<ReturnType<typeof bindHarnessStream>> | undefined;
|
|
885
889
|
let deactivate = () => {};
|
|
890
|
+
let released = false;
|
|
886
891
|
try {
|
|
887
892
|
|
|
888
893
|
if ((submission.abortReason || submission.unattendedFailure) && runtime.openOperation) {
|
|
@@ -985,6 +990,7 @@ export abstract class AgentRuntimeKernel<
|
|
|
985
990
|
idempotent: true,
|
|
986
991
|
});
|
|
987
992
|
}
|
|
993
|
+
released = true;
|
|
988
994
|
return this.db.submissions.find(submissionId)!;
|
|
989
995
|
}
|
|
990
996
|
const record = outcome.outcome;
|
|
@@ -1025,6 +1031,14 @@ export abstract class AgentRuntimeKernel<
|
|
|
1025
1031
|
deactivate();
|
|
1026
1032
|
if (this.activeHarness === runtime) this.activeHarness = undefined;
|
|
1027
1033
|
await runtime.close(BACKGROUND_CONTEXT).catch(() => undefined);
|
|
1034
|
+
if (released) {
|
|
1035
|
+
// Pi closes the Harness on release and drops undelivered stream frames; the
|
|
1036
|
+
// canonical transcript gives a paused question or approval its complete input.
|
|
1037
|
+
this.broadcast(json({
|
|
1038
|
+
type: MessageType.CF_AGENT_CHAT_MESSAGES,
|
|
1039
|
+
messages: await this.getMessages(),
|
|
1040
|
+
}));
|
|
1041
|
+
}
|
|
1028
1042
|
}
|
|
1029
1043
|
}
|
|
1030
1044
|
|
|
@@ -424,6 +424,16 @@ export abstract class CloudflareSubAgent<
|
|
|
424
424
|
const progress = progressFor(event);
|
|
425
425
|
if (progress) this.persistProgress(runId, progress);
|
|
426
426
|
},
|
|
427
|
+
// Live frames can stop short of the last response; the canonical entries are the billed total.
|
|
428
|
+
onEntries: (entries) => {
|
|
429
|
+
const canonical = emptySubagentUsage();
|
|
430
|
+
for (const entry of entries) {
|
|
431
|
+
const message = entry.model?.[0];
|
|
432
|
+
if (message?.role === "assistant") addSubagentUsage(canonical, message.usage);
|
|
433
|
+
}
|
|
434
|
+
Object.assign(usage, canonical);
|
|
435
|
+
this.persistUsage(runId, usage);
|
|
436
|
+
},
|
|
427
437
|
});
|
|
428
438
|
publish(projection.finish({ status: "completed", endedAt: Date.now() }));
|
|
429
439
|
this.completeRun(runId, output, usage);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
|
2
|
-
import type { AgentEvent as HarnessEvent, Storage as Session, ToolRegistration } from "@earendil-works/pi-durable";
|
|
2
|
+
import type { AgentEvent as HarnessEvent, AgentEventStream, EntryRecord, Storage as Session, ToolRegistration } from "@earendil-works/pi-durable";
|
|
3
3
|
import {
|
|
4
4
|
awaitWithContext,
|
|
5
5
|
BACKGROUND_CONTEXT,
|
|
@@ -38,8 +38,21 @@ export interface CloudflareSubAgentRun {
|
|
|
38
38
|
tools?: ToolRegistration<any, any>[];
|
|
39
39
|
signal?: AbortSignal;
|
|
40
40
|
timeoutMs?: number;
|
|
41
|
-
/** Native operation events for projections
|
|
41
|
+
/** Native operation events for live projections; they may stop short of the final frame. */
|
|
42
42
|
onEvent?: (event: HarnessEvent, signal: AbortSignal) => void | Promise<void>;
|
|
43
|
+
/** This operation's canonical entries after each settle or release, the source of its final usage. */
|
|
44
|
+
onEntries?: (entries: readonly EntryRecord[]) => void | Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The first failed Tool result among an operation's canonical entries. */
|
|
48
|
+
function toolFailureOf(entries: readonly EntryRecord[]): { name: string; error: string } | undefined {
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
const result = entry.model?.[0];
|
|
51
|
+
if (result?.role !== "toolResult" || !result.isError) continue;
|
|
52
|
+
const diagnostics = (entry.data as { diagnostics?: Array<{ severity: string; message: string }> } | undefined)?.diagnostics;
|
|
53
|
+
return { name: result.toolName, error: diagnostics?.find((item) => item.severity === "error")?.message ?? toolErrorText(result) };
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
43
56
|
}
|
|
44
57
|
|
|
45
58
|
function textOf(message: AssistantMessage): string {
|
|
@@ -129,7 +142,13 @@ export async function runCloudflareSubAgent(run: CloudflareSubAgentRun): Promise
|
|
|
129
142
|
compaction: { enabled: false, reserveTokens: 1, keepRecentTokens: 1 },
|
|
130
143
|
} satisfies Omit<SessionHarnessOptions, "storage">;
|
|
131
144
|
let runtime: SessionHarness | undefined;
|
|
145
|
+
let events: AgentEventStream | undefined;
|
|
132
146
|
let toolFailure: { name: string; error: string } | undefined;
|
|
147
|
+
const operationEntries = async (active: SessionHarness): Promise<EntryRecord[]> => {
|
|
148
|
+
const submission = await active.submissionFor(operationId, BACKGROUND_CONTEXT);
|
|
149
|
+
const entries = await active.entries(BACKGROUND_CONTEXT);
|
|
150
|
+
return submission?.entry === undefined ? [] : entries.filter((entry) => entry.id >= submission.entry!);
|
|
151
|
+
};
|
|
133
152
|
|
|
134
153
|
let timedOut = false;
|
|
135
154
|
const timeout = setTimeout(() => {
|
|
@@ -148,15 +167,9 @@ export async function runCloudflareSubAgent(run: CloudflareSubAgentRun): Promise
|
|
|
148
167
|
if (runtime.openOperation && runtime.openOperation.operationId !== operationId) {
|
|
149
168
|
throw new Error("SubAgent Session has an unrelated open operation");
|
|
150
169
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
for (const event of
|
|
154
|
-
await run.onEvent?.(event, signal);
|
|
155
|
-
if (event.type === "tool_execution_end") {
|
|
156
|
-
const result = event.entry?.model?.[0];
|
|
157
|
-
if (result?.role === "toolResult" && result.isError) toolFailure ??= { name: event.toolName, error: ((event.entry?.data as { diagnostics?: Array<{ severity: string; message: string }> } | undefined)?.diagnostics?.find((item) => item.severity === "error")?.message ?? toolErrorText(result)) };
|
|
158
|
-
}
|
|
159
|
-
}
|
|
170
|
+
events = await runtime.watch(context);
|
|
171
|
+
events.start(async (batch) => {
|
|
172
|
+
for (const event of batch) await run.onEvent?.(event, signal);
|
|
160
173
|
});
|
|
161
174
|
}
|
|
162
175
|
const result = await runtime.run(
|
|
@@ -168,12 +181,18 @@ export async function runCloudflareSubAgent(run: CloudflareSubAgentRun): Promise
|
|
|
168
181
|
: undefined,
|
|
169
182
|
context,
|
|
170
183
|
);
|
|
184
|
+
await events?.stop();
|
|
185
|
+
events = undefined;
|
|
171
186
|
if (result.kind === "released") {
|
|
172
187
|
await runtime.close(BACKGROUND_CONTEXT);
|
|
173
188
|
runtime = undefined;
|
|
174
189
|
continue;
|
|
175
190
|
}
|
|
176
191
|
settled = result;
|
|
192
|
+
// Pi's stop may drop the last frames; failures and usage come from the canonical record.
|
|
193
|
+
const owned = await operationEntries(runtime);
|
|
194
|
+
toolFailure = toolFailureOf(owned);
|
|
195
|
+
await run.onEntries?.(owned);
|
|
177
196
|
break;
|
|
178
197
|
}
|
|
179
198
|
if (timedOut) throw new Error("SubAgent run timed out");
|
|
@@ -215,6 +234,7 @@ export async function runCloudflareSubAgent(run: CloudflareSubAgentRun): Promise
|
|
|
215
234
|
await runtime.abort(BACKGROUND_CONTEXT).catch(() => undefined);
|
|
216
235
|
await runtime.run(undefined, BACKGROUND_CONTEXT).catch(() => undefined);
|
|
217
236
|
}
|
|
237
|
+
await events?.stop();
|
|
218
238
|
await runtime?.close(BACKGROUND_CONTEXT).catch(() => undefined);
|
|
219
239
|
}
|
|
220
240
|
}
|
|
@@ -91,7 +91,7 @@ export class HarnessRuntimeConfig {
|
|
|
91
91
|
prepared: PreparedPiRuntime,
|
|
92
92
|
pinnedDescriptor: string,
|
|
93
93
|
request: {
|
|
94
|
-
readonly modelSessionId?: string;
|
|
94
|
+
readonly modelSessionId?: string | (() => string);
|
|
95
95
|
readonly onGeneration?: PiGenerationLifecycleObserver;
|
|
96
96
|
readonly assertRequestAllowed?: () => void;
|
|
97
97
|
readonly onCompactionGeneration?: PiGenerationLifecycleObserver;
|
|
@@ -22,6 +22,7 @@ export class HarnessStreamProjection {
|
|
|
22
22
|
private readonly encoder: PiChunkEncoder;
|
|
23
23
|
private terminal?: AssistantMessage;
|
|
24
24
|
private readonly shown = new Map<number, string>();
|
|
25
|
+
private readonly toolInputStarted = new Set<string>();
|
|
25
26
|
private readonly toolOutput = new Map<string, string>();
|
|
26
27
|
constructor(options: HarnessStreamOptions) { this.encoder = new PiChunkEncoder(options); }
|
|
27
28
|
restore(message: UIMessage, streaming: boolean, snapshot?: SnapshotEvent): UIMessageChunk[] {
|
|
@@ -33,9 +34,15 @@ export class HarnessStreamProjection {
|
|
|
33
34
|
for (const tool of snapshot?.tools ?? []) if (tool.output !== undefined) this.toolOutput.set(tool.callId, tool.output);
|
|
34
35
|
return this.encoder.restore(message, streaming);
|
|
35
36
|
}
|
|
36
|
-
private block(block: AssistantMessage["content"][number], contentIndex: number): UIMessageChunk[] {
|
|
37
|
+
private block(block: AssistantMessage["content"][number], contentIndex: number, settled = false): UIMessageChunk[] {
|
|
37
38
|
if (block.type === "toolCall" && (!block.id || !block.name)) return [];
|
|
38
|
-
if (block.type === "toolCall")
|
|
39
|
+
if (block.type === "toolCall") {
|
|
40
|
+
// The encoder keeps the first input it sees, so only a settled message may publish arguments.
|
|
41
|
+
if (settled) return this.encoder.encode({ type: "tool_execution_start", toolCallId: block.id, toolName: block.name, args: block.arguments } as LoopEvent);
|
|
42
|
+
if (this.toolInputStarted.has(block.id)) return [];
|
|
43
|
+
this.toolInputStarted.add(block.id);
|
|
44
|
+
return [{ type: "tool-input-start", toolCallId: block.id, toolName: block.name, dynamic: true }];
|
|
45
|
+
}
|
|
39
46
|
if (block.type !== "text" && block.type !== "thinking") return [];
|
|
40
47
|
const start = block.type === "thinking"
|
|
41
48
|
? this.encoder.encode({ type: "message_update", assistantMessageEvent: { type: "thinking_start", contentIndex } } as LoopEvent)
|
|
@@ -47,7 +54,15 @@ export class HarnessStreamProjection {
|
|
|
47
54
|
if (!delta) return start;
|
|
48
55
|
return [...start, ...this.encoder.encode({ type: "message_update", assistantMessageEvent: { type: block.type === "text" ? "text_delta" : "thinking_delta", contentIndex, delta } } as LoopEvent)];
|
|
49
56
|
}
|
|
50
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Pi emits `run_start` only when the live run changes, so a stream bound to a continued run (after an approval or a
|
|
59
|
+
* slice release) sees tool or message frames first. The encoder's start is idempotent; lead the first frames with it.
|
|
60
|
+
*/
|
|
61
|
+
private started(chunks: UIMessageChunk[]): UIMessageChunk[] {
|
|
62
|
+
return chunks.length === 0 ? chunks : [...this.encoder.start(), ...chunks];
|
|
63
|
+
}
|
|
64
|
+
event(event: AgentEvent): UIMessageChunk[] { return this.started(this.frames(event)); }
|
|
65
|
+
private frames(event: AgentEvent): UIMessageChunk[] {
|
|
51
66
|
switch (event.type) {
|
|
52
67
|
case "run_start": return this.encoder.start();
|
|
53
68
|
case "turn_start": this.terminal = undefined; this.shown.clear(); return this.encoder.encode({ type: "turn_start" } as LoopEvent);
|
|
@@ -74,14 +89,14 @@ export class HarnessStreamProjection {
|
|
|
74
89
|
const message = event.entry.model?.[0];
|
|
75
90
|
if (message?.role !== "assistant") return [];
|
|
76
91
|
this.terminal = message;
|
|
77
|
-
return [...message.content.flatMap((block, index) => this.block(block, index)), ...this.encoder.encode({ type: "message_end", message }, { terminal: false })];
|
|
92
|
+
return [...message.content.flatMap((block, index) => this.block(block, index, true)), ...this.encoder.encode({ type: "message_end", message }, { terminal: false })];
|
|
78
93
|
}
|
|
79
94
|
default: return [];
|
|
80
95
|
}
|
|
81
96
|
}
|
|
82
|
-
approvalRequested(toolCallId: string, approvalId: string): UIMessageChunk[] { return this.encoder.approvalRequested(toolCallId, approvalId); }
|
|
97
|
+
approvalRequested(toolCallId: string, approvalId: string): UIMessageChunk[] { return this.started(this.encoder.approvalRequested(toolCallId, approvalId)); }
|
|
83
98
|
finish(result: SessionRunOutcome): UIMessageChunk[] {
|
|
84
|
-
return this.encoder.finish({ stopReason: result.status === "aborted" ? "aborted" : result.status === "failed" ? "error" : this.terminal?.stopReason === "length" ? "length" : "stop", timestamp: result.endedAt, ...(result.error ? { errorMessage: result.error.message } : {}) });
|
|
99
|
+
return this.started(this.encoder.finish({ stopReason: result.status === "aborted" ? "aborted" : result.status === "failed" ? "error" : this.terminal?.stopReason === "length" ? "length" : "stop", timestamp: result.endedAt, ...(result.error ? { errorMessage: result.error.message } : {}) }));
|
|
85
100
|
}
|
|
86
101
|
}
|
|
87
102
|
|
|
@@ -92,6 +107,7 @@ export async function bindHarnessStream(runtime: SessionHarness, options: Harnes
|
|
|
92
107
|
watch.start((events) => delivery = (async () => { for (const event of events) await options.publish(projection.event(event)); })());
|
|
93
108
|
return {
|
|
94
109
|
publishApprovalRequest: (toolCallId: string, approvalId: string) => options.publish(projection.approvalRequested(toolCallId, approvalId)),
|
|
110
|
+
// The Host follows the terminal frame with the canonical transcript, which completes any frame Pi's stop dropped.
|
|
95
111
|
flushTerminal: async (result: SessionRunOutcome) => { await watch.stop(); await delivery; await options.publish(projection.finish(result)); },
|
|
96
112
|
unsubscribe: async () => { await watch.stop(); await delivery; },
|
|
97
113
|
};
|
|
@@ -18,6 +18,30 @@ import {
|
|
|
18
18
|
type UIUserSidecar,
|
|
19
19
|
} from "./message";
|
|
20
20
|
import { openCloudflareSession, RuntimeConversation } from "../session";
|
|
21
|
+
import { dropLegacyPiTables } from "../session/cloudflare/migration";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Pi Durable 1.1 has no public API that deletes a conversation; `Tx` can only create, fork and retire documents.
|
|
25
|
+
* Clear therefore deletes every record outside the newly selected conversation from the official SQLite schema
|
|
26
|
+
* version 1. A different schema version keeps the records rather than guessing at its layout.
|
|
27
|
+
*/
|
|
28
|
+
function deleteConversationsExcept(sql: SqlStorage, keep: number): void {
|
|
29
|
+
const schema = sql.exec<{ version: number }>("SELECT version FROM durable_schema WHERE singleton=1").toArray()[0]?.version;
|
|
30
|
+
if (schema !== 1) {
|
|
31
|
+
console.warn("[conversation-clear:degraded]", `Unsupported Pi Durable SQLite schema ${String(schema)}; previous records kept`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const documents = `SELECT id FROM documents WHERE (scope_kind='conversation' AND owner_id<>?1)
|
|
35
|
+
OR (scope_kind='task' AND owner_id IN (SELECT id FROM tasks WHERE conversation_id<>?1))`;
|
|
36
|
+
sql.exec(`DELETE FROM document_revisions WHERE document_id IN (${documents})`, keep);
|
|
37
|
+
sql.exec(`DELETE FROM record_ids WHERE id IN (${documents}
|
|
38
|
+
UNION SELECT id FROM submissions WHERE conversation_id<>?1 UNION SELECT id FROM tasks WHERE conversation_id<>?1
|
|
39
|
+
UNION SELECT id FROM entries WHERE conversation_id<>?1 UNION SELECT id FROM conversations WHERE id<>?1)`, keep);
|
|
40
|
+
sql.exec(`DELETE FROM documents WHERE id IN (${documents})`, keep);
|
|
41
|
+
for (const [table, column] of [["submissions", "conversation_id"], ["tasks", "conversation_id"], ["entries", "conversation_id"], ["conversations", "id"]]) {
|
|
42
|
+
sql.exec(`DELETE FROM ${table} WHERE ${column}<>?`, keep);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
21
45
|
|
|
22
46
|
/**
|
|
23
47
|
* 本文件负责保存 Pi 的规范消息,并按浏览器或 Extension Host 的需要投影历史。
|
|
@@ -264,7 +288,8 @@ export class ConversationHistory {
|
|
|
264
288
|
const custom = message.role === "custom";
|
|
265
289
|
const entry = await tx.appendEntry(conversationId!, {
|
|
266
290
|
kind: custom ? "springbrand.custom" : message.role === "toolResult" ? "pi.tool-result" : `pi.${message.role}`,
|
|
267
|
-
|
|
291
|
+
// Custom messages reach the model as user context, as Pi's convertToLlm did before the native harness.
|
|
292
|
+
model: [custom ? { role: "user", content: [...message.content], timestamp: message.timestamp } : message],
|
|
268
293
|
data: JSON.parse(JSON.stringify({ displayId: id, createdAt: message.timestamp, ...(custom ? { message } : {}), ...(options.submissionId ? { submissionId: options.submissionId } : {}), ...(message.role === "toolResult" ? { diagnostics: [] } : {}) })),
|
|
269
294
|
});
|
|
270
295
|
if (options.userMessage) this.durability.upsertUserSidecar(String(entry.id), JSON.stringify({ id: options.userMessage.id, ...captureUIUserSidecar(options.userMessage) }));
|
|
@@ -419,10 +444,18 @@ export class ConversationHistory {
|
|
|
419
444
|
* 两类 Transcript 数据必须一起删除,否则复用消息 ID 时可能重新读到旧附件或 UI 元数据。
|
|
420
445
|
*/
|
|
421
446
|
async clear(): Promise<void> {
|
|
422
|
-
await this.
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
447
|
+
await this.storage.transaction(() => this.withSession(async (session) => {
|
|
448
|
+
const next = await session.commit(async (tx) => {
|
|
449
|
+
const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
|
|
450
|
+
(await tx.doc(RuntimeConversation)).id = conversation.id;
|
|
451
|
+
return conversation.id;
|
|
452
|
+
}, BACKGROUND_CONTEXT);
|
|
453
|
+
deleteConversationsExcept(this.storage.sql, next);
|
|
454
|
+
await dropLegacyPiTables(this.storage, session);
|
|
455
|
+
// Entry associations name records that no longer exist.
|
|
456
|
+
this.storage.sql.exec("CREATE TABLE IF NOT EXISTS pi_session_entry_submission (entry_id TEXT PRIMARY KEY, submission_id TEXT NOT NULL)");
|
|
457
|
+
this.storage.sql.exec("DELETE FROM pi_session_entry_submission");
|
|
458
|
+
}));
|
|
426
459
|
this.durability.clearUserSidecars();
|
|
427
460
|
}
|
|
428
461
|
|
|
@@ -563,6 +596,8 @@ export class ConversationHistory {
|
|
|
563
596
|
entry.message.role === "custom" &&
|
|
564
597
|
entry.message.customType === "turn-aborted"
|
|
565
598
|
) {
|
|
599
|
+
// The marker is this stop's terminal; the no-answer fallback would add a second one.
|
|
600
|
+
if (pendingFailedSubmissionId === entry.submissionId) pendingFailedSubmissionId = undefined;
|
|
566
601
|
const submission = this.durability.findSubmissionProjection(
|
|
567
602
|
entry.submissionId,
|
|
568
603
|
);
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { AssistantMessage, Message, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import { isRetryableAssistantError } from "@earendil-works/pi-ai/utils/retry";
|
|
3
|
+
import type { EntryRecord } from "@earendil-works/pi-durable";
|
|
4
|
+
import type { ArtifactRef } from "../lib/artifacts";
|
|
5
|
+
import {
|
|
6
|
+
isModelStreamStallMessage,
|
|
7
|
+
isOpenRouterServerToolTimeout,
|
|
8
|
+
isRecoverableAssistantError,
|
|
9
|
+
} from "../models";
|
|
10
|
+
|
|
11
|
+
export const MAX_TOOL_RETRIES = 3;
|
|
12
|
+
export const TURN_FAILURE_CEILING = 8;
|
|
13
|
+
export const DEFAULT_MAX_WRAP_UP_MODEL_TURNS = 2;
|
|
14
|
+
export const TOOL_FAILURE_WRAP_UP_INSTRUCTION =
|
|
15
|
+
"This turn reached its tool failure limit. Stop calling tools now. Reply with a final message that explains what failed and the exact next step the user should take. Do not start new work.";
|
|
16
|
+
const RECOVERY_MAX_FAILURES = 5;
|
|
17
|
+
const STALL_MAX_FAILURES = 3;
|
|
18
|
+
const SERVER_TOOL_TIMEOUT_MAX_FAILURES = 2;
|
|
19
|
+
const ARTIFACT_MANIFEST_MAX_CHARS = 12 * 1024;
|
|
20
|
+
/** Harness diagnostics for calls that never crossed the Tool effect boundary. */
|
|
21
|
+
const UNEXECUTED_CODES = new Set(["blocked", "tool_unavailable", "invalid_arguments", "interrupted", "aborted"]);
|
|
22
|
+
|
|
23
|
+
function canonicalJson(value: unknown): string {
|
|
24
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
25
|
+
if (value !== null && typeof value === "object") {
|
|
26
|
+
return `{${Object.entries(value as Record<string, unknown>)
|
|
27
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
28
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`)
|
|
29
|
+
.join(",")}}`;
|
|
30
|
+
}
|
|
31
|
+
return JSON.stringify(value) ?? "null";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function toolCallKey(toolName: string, args: unknown): string {
|
|
35
|
+
return `${toolName}\0${canonicalJson(args)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resultCodes(entry: EntryRecord): string[] {
|
|
39
|
+
const diagnostics = (entry.data as { diagnostics?: { code?: unknown }[] } | undefined)?.diagnostics;
|
|
40
|
+
return Array.isArray(diagnostics)
|
|
41
|
+
? diagnostics.flatMap((item) => typeof item?.code === "string" ? [item.code] : [])
|
|
42
|
+
: [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Per-Submission Tool failure budget: an exact call that keeps failing is blocked, and too many failures in one
|
|
47
|
+
* Submission switch it to a text-only wrap-up. Counts are rebuilt from canonical entries after a restart.
|
|
48
|
+
*/
|
|
49
|
+
export class ToolGovernance {
|
|
50
|
+
private readonly failures = new Map<string, number>();
|
|
51
|
+
private total = 0;
|
|
52
|
+
wrapUp = false;
|
|
53
|
+
/** Completed model turns when the wrap-up began; the wrap-up then has a bounded number of text turns. */
|
|
54
|
+
wrapUpAt: number | undefined;
|
|
55
|
+
|
|
56
|
+
restore(entries: readonly EntryRecord[]): void {
|
|
57
|
+
this.failures.clear();
|
|
58
|
+
this.total = 0;
|
|
59
|
+
this.wrapUp = false;
|
|
60
|
+
this.wrapUpAt = undefined;
|
|
61
|
+
const calls = new Map<string, ToolCall>();
|
|
62
|
+
let turns = 0;
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
const message = entry.model?.[0];
|
|
65
|
+
if (message?.role === "assistant") {
|
|
66
|
+
turns += 1;
|
|
67
|
+
for (const part of message.content) if (part.type === "toolCall") calls.set(part.id, part);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (message?.role !== "toolResult") continue;
|
|
71
|
+
const call = calls.get(message.toolCallId);
|
|
72
|
+
if (!call) continue;
|
|
73
|
+
const codes = resultCodes(entry);
|
|
74
|
+
if (codes.some((code) => UNEXECUTED_CODES.has(code))) {
|
|
75
|
+
if (codes.includes("blocked") && this.exhausted(call)) this.startWrapUp(turns);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
this.record(call, message.isError, turns);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
exhausted(call: Pick<ToolCall, "name" | "arguments">): boolean {
|
|
83
|
+
return (this.failures.get(toolCallKey(call.name, call.arguments)) ?? 0) >= MAX_TOOL_RETRIES;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Returns a block reason, or undefined when the call may run. */
|
|
87
|
+
admit(call: Pick<ToolCall, "name" | "arguments">, turns: number): string | undefined {
|
|
88
|
+
if (this.wrapUp) return "This turn reached its tool failure limit. Stop calling tools now.";
|
|
89
|
+
if (!this.exhausted(call)) return undefined;
|
|
90
|
+
this.startWrapUp(turns);
|
|
91
|
+
return `This exact ${call.name} call has already failed ${MAX_TOOL_RETRIES} times — change the input or approach instead of retrying it verbatim.`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
record(call: Pick<ToolCall, "name" | "arguments">, isError: boolean, turns: number): void {
|
|
95
|
+
const key = toolCallKey(call.name, call.arguments);
|
|
96
|
+
if (!isError) {
|
|
97
|
+
this.failures.delete(key);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
this.failures.set(key, (this.failures.get(key) ?? 0) + 1);
|
|
101
|
+
this.total += 1;
|
|
102
|
+
if (this.total >= TURN_FAILURE_CEILING) this.startWrapUp(turns);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private startWrapUp(turns: number): void {
|
|
106
|
+
this.wrapUp = true;
|
|
107
|
+
this.wrapUpAt ??= turns;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Provider-error recovery the durable Harness cannot express by itself: per-class budgets, refusing deterministic
|
|
113
|
+
* failures, and a fresh provider Session after an OpenRouter server Tool timeout so retries leave a failing route.
|
|
114
|
+
* Pi only retries messages its own classifier accepts, so recoverable failures it does not recognize are labelled.
|
|
115
|
+
*/
|
|
116
|
+
export class ModelRecovery {
|
|
117
|
+
private failures = 0;
|
|
118
|
+
private stalls = 0;
|
|
119
|
+
private serverToolTimeouts = 0;
|
|
120
|
+
private allowed = true;
|
|
121
|
+
|
|
122
|
+
constructor(private readonly baseSessionId: string) {}
|
|
123
|
+
|
|
124
|
+
/** Provider Session identity for the next request. */
|
|
125
|
+
sessionId(): string {
|
|
126
|
+
return this.serverToolTimeouts === 0
|
|
127
|
+
? this.baseSessionId
|
|
128
|
+
: `${this.baseSessionId}:server-tool-recovery:${this.serverToolTimeouts}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Whether the Harness may retry the response last passed to {@link classify}. */
|
|
132
|
+
get retryAllowed(): boolean {
|
|
133
|
+
return this.allowed;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Classify one terminal provider response before the Harness decides whether to retry it. */
|
|
137
|
+
classify(message: AssistantMessage): void {
|
|
138
|
+
if (message.stopReason !== "error") {
|
|
139
|
+
this.allowed = true;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this.failures += 1;
|
|
143
|
+
const stalled = isModelStreamStallMessage(message.errorMessage);
|
|
144
|
+
const serverToolTimeout = isOpenRouterServerToolTimeout(message);
|
|
145
|
+
if (stalled) this.stalls += 1;
|
|
146
|
+
if (serverToolTimeout) this.serverToolTimeouts += 1;
|
|
147
|
+
const recoverable = stalled || serverToolTimeout || isRecoverableAssistantError(message);
|
|
148
|
+
this.allowed = recoverable && this.failures < RECOVERY_MAX_FAILURES &&
|
|
149
|
+
(!stalled || this.stalls < STALL_MAX_FAILURES) &&
|
|
150
|
+
(!serverToolTimeout || this.serverToolTimeouts < SERVER_TOOL_TIMEOUT_MAX_FAILURES);
|
|
151
|
+
if (this.allowed && !isRetryableAssistantError(message)) {
|
|
152
|
+
// Pi's classifier does not know our stall watchdog or OpenRouter's HTTP/2 upstream failure. Only a retried
|
|
153
|
+
// attempt carries the label; a final failure keeps the provider text.
|
|
154
|
+
message.errorMessage = `network error: ${message.errorMessage ?? "recoverable provider failure"}`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function artifactRefFromMessage(message: Message | Pick<ToolResultMessage, "role" | "details">): ArtifactRef | undefined {
|
|
160
|
+
if (message.role !== "toolResult" || message.details === null ||
|
|
161
|
+
typeof message.details !== "object" ||
|
|
162
|
+
(message.details as { kind?: unknown }).kind !== "artifact_ref") {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
const ref = message.details as Partial<ArtifactRef>;
|
|
166
|
+
if (typeof ref.path !== "string" || typeof ref.hash !== "string" ||
|
|
167
|
+
typeof ref.bytes !== "number" || typeof ref.preview !== "string" ||
|
|
168
|
+
typeof ref.note !== "string") return undefined;
|
|
169
|
+
return {
|
|
170
|
+
kind: "artifact_ref",
|
|
171
|
+
artifactId: typeof ref.artifactId === "string" ? ref.artifactId : `artifact_${ref.hash.slice(0, 24)}`,
|
|
172
|
+
path: ref.path,
|
|
173
|
+
bytes: ref.bytes,
|
|
174
|
+
hash: ref.hash,
|
|
175
|
+
workspaceRevision: typeof ref.workspaceRevision === "string" ? ref.workspaceRevision : "unknown",
|
|
176
|
+
availability: ref.availability === "unavailable" ? "unavailable" : "available",
|
|
177
|
+
preview: ref.preview,
|
|
178
|
+
note: ref.note,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Lightweight pointers to spilled Tool output, so the model can reread evidence after compaction. */
|
|
183
|
+
export function artifactManifestMessage(refs: ReadonlyMap<string, ArtifactRef>): Message | undefined {
|
|
184
|
+
if (refs.size === 0) return undefined;
|
|
185
|
+
const lines = [
|
|
186
|
+
"<workspace_artifact_manifest>",
|
|
187
|
+
"These are lightweight references to prior Tool output. Do not treat previews as complete. Read an available artifact with the Workspace readFile/readFilePage tool only when the current request needs its evidence.",
|
|
188
|
+
];
|
|
189
|
+
let omitted = 0;
|
|
190
|
+
for (const ref of refs.values()) {
|
|
191
|
+
const preview = ref.preview.replace(/[\r\n]+/g, " ").slice(0, 160);
|
|
192
|
+
const line = [
|
|
193
|
+
`path=${ref.path}`,
|
|
194
|
+
...(ref.availability === "unavailable" ? ["unavailable"] : []),
|
|
195
|
+
`preview=${preview}`,
|
|
196
|
+
].join(" ");
|
|
197
|
+
if ([...lines, line, "</workspace_artifact_manifest>"].join("\n").length > ARTIFACT_MANIFEST_MAX_CHARS) {
|
|
198
|
+
omitted += 1;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
lines.push(line);
|
|
202
|
+
}
|
|
203
|
+
if (omitted > 0) lines.push(`omitted=${omitted} additional artifact references due to manifest budget`);
|
|
204
|
+
lines.push("</workspace_artifact_manifest>");
|
|
205
|
+
return { role: "user", content: lines.join("\n"), timestamp: 1 };
|
|
206
|
+
}
|
package/src/harness/index.ts
CHANGED
|
@@ -13,10 +13,19 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
|
13
13
|
import type { Api, Message, Model, Models, ModelThinkingLevel, UserMessage } from "@earendil-works/pi-ai";
|
|
14
14
|
import { RuntimeConversation } from "../session";
|
|
15
15
|
import { projectToolOutputForModel } from "../context/memory/budget/gate";
|
|
16
|
+
import type { ArtifactRef } from "../lib/artifacts";
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_MAX_WRAP_UP_MODEL_TURNS, TOOL_FAILURE_WRAP_UP_INSTRUCTION, ModelRecovery, ToolGovernance,
|
|
19
|
+
artifactManifestMessage, artifactRefFromMessage,
|
|
20
|
+
} from "./governance";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_WRAP_UP_INSTRUCTION = "Finish this request now without using tools. Summarize the completed work and any remaining limitation.";
|
|
16
23
|
|
|
17
24
|
export interface SessionHarnessLimits {
|
|
18
25
|
modelTurnsPerSlice?: number;
|
|
19
26
|
maxModelTurns?: number;
|
|
27
|
+
/** Text turns allowed after the working or Tool failure budget before the Submission is stopped. */
|
|
28
|
+
maxWrapUpModelTurns?: number;
|
|
20
29
|
wrapUpInstruction?: string;
|
|
21
30
|
}
|
|
22
31
|
export interface SessionHarnessOptions {
|
|
@@ -35,6 +44,8 @@ export interface SessionHarnessOptions {
|
|
|
35
44
|
retry?: Partial<ConversationRetryPolicy>;
|
|
36
45
|
toolExecution?: "parallel" | "sequential";
|
|
37
46
|
limits?: SessionHarnessLimits;
|
|
47
|
+
/** Per-Submission provider error classification and provider Session identity. */
|
|
48
|
+
recovery?: ModelRecovery;
|
|
38
49
|
beforeTool?: ToolHooks["beforeTool"];
|
|
39
50
|
afterResponse?: GenerationHooks["afterResponse"];
|
|
40
51
|
}
|
|
@@ -63,6 +74,11 @@ export class SessionHarness {
|
|
|
63
74
|
private requestId?: string;
|
|
64
75
|
private release!: (reason: "approval" | "interaction" | "slice") => void;
|
|
65
76
|
private turnsAtOpen = 0;
|
|
77
|
+
private governance = new ToolGovernance();
|
|
78
|
+
private readonly artifactRefs = new Map<string, ArtifactRef>();
|
|
79
|
+
private wrapUpStarted = false;
|
|
80
|
+
/** Completed model turns of the current Submission. */
|
|
81
|
+
private ownedTurns = 0;
|
|
66
82
|
private readonly released = new Promise<"approval" | "interaction" | "slice">((resolve) => { this.release = resolve; });
|
|
67
83
|
private constructor(
|
|
68
84
|
readonly harness: NativeHarness,
|
|
@@ -85,26 +101,59 @@ export class SessionHarness {
|
|
|
85
101
|
sections: [section("system", () => options.systemPrompt, { tag: false })],
|
|
86
102
|
hooks: [
|
|
87
103
|
hook(GenerationTask, {
|
|
88
|
-
beforeRequest: ({ messages }) =>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
104
|
+
beforeRequest: ({ messages }) => {
|
|
105
|
+
const projected = messages.map((message): Message => {
|
|
106
|
+
if (message.role !== "toolResult" || exemptions.has(message.toolName)) return message;
|
|
107
|
+
const next = projectToolOutputForModel({ content: message.content, details: message.details }) as Pick<typeof message, "content" | "details">;
|
|
108
|
+
return { ...message, ...next };
|
|
109
|
+
});
|
|
110
|
+
const manifest = artifactManifestMessage(runtime.artifactRefs);
|
|
111
|
+
const instruction = runtime.governance.wrapUp
|
|
112
|
+
? TOOL_FAILURE_WRAP_UP_INSTRUCTION
|
|
113
|
+
: runtime.wrapUpStarted ? options.limits?.wrapUpInstruction ?? DEFAULT_WRAP_UP_INSTRUCTION : undefined;
|
|
114
|
+
return { messages: [
|
|
115
|
+
...projected,
|
|
116
|
+
...(manifest ? [manifest] : []),
|
|
117
|
+
...(instruction ? [{ role: "user" as const, content: instruction, timestamp: 1 }] : []),
|
|
118
|
+
] };
|
|
119
|
+
},
|
|
120
|
+
afterResponse: async (message, api, hookContext) => {
|
|
121
|
+
options.recovery?.classify(message);
|
|
122
|
+
await options.afterResponse?.(message, api, hookContext);
|
|
123
|
+
},
|
|
94
124
|
afterTools: async (_assistant, _results, _api, hookContext) => {
|
|
95
125
|
const entries = await runtime.entries(hookContext);
|
|
96
|
-
const
|
|
97
|
-
const owned = receipt?.entry ? entries.filter((entry) => entry.id >= receipt.entry!) : entries;
|
|
98
|
-
const turns = owned.filter((entry) => entry.kind === "pi.assistant").length;
|
|
126
|
+
const turns = runtime.ownedTurns = (await runtime.ownedEntries(entries, hookContext)).filter((entry) => entry.kind === "pi.assistant").length;
|
|
99
127
|
const total = entries.filter((entry) => entry.kind === "pi.assistant").length;
|
|
128
|
+
const maxTurns = options.limits?.maxModelTurns ?? 300;
|
|
129
|
+
const wrapUpAt = Math.min(maxTurns, runtime.governance.wrapUpAt ?? Infinity);
|
|
130
|
+
if (turns >= wrapUpAt + (options.limits?.maxWrapUpModelTurns ?? DEFAULT_MAX_WRAP_UP_MODEL_TURNS)) {
|
|
131
|
+
// The wrap-up is still calling Tools it no longer has; end the Submission instead of looping.
|
|
132
|
+
void runtime.conversation.abort(BACKGROUND_CONTEXT).catch(() => {});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (turns >= wrapUpAt && !runtime.wrapUpStarted) {
|
|
136
|
+
runtime.wrapUpStarted = true;
|
|
137
|
+
// Tools return on the next Submission's configure; the instruction is per request, never stored.
|
|
138
|
+
await runtime.conversation.configure({ tools: [] }, hookContext);
|
|
139
|
+
}
|
|
100
140
|
if (total - runtime.turnsAtOpen >= (options.limits?.modelTurnsPerSlice ?? 30)) runtime.release("slice");
|
|
101
|
-
if (turns >= (options.limits?.maxModelTurns ?? 300)) await runtime.conversation.configure({ tools: [], instructions: options.limits?.wrapUpInstruction ?? "Finish this request now without using tools. Summarize the completed work and any remaining limitation." }, hookContext);
|
|
102
141
|
},
|
|
103
142
|
}),
|
|
104
143
|
hook(ToolTask, {
|
|
105
|
-
beforeTool:
|
|
144
|
+
beforeTool: async (call, api, hookContext) => {
|
|
145
|
+
// Calls belong to the assistant turn after the last completed Tool round.
|
|
146
|
+
const block = runtime.governance.admit(call, runtime.ownedTurns + 1);
|
|
147
|
+
if (block !== undefined) return { block };
|
|
148
|
+
return options.beforeTool?.(call, api, hookContext);
|
|
149
|
+
},
|
|
106
150
|
afterTool: async (call, result) => {
|
|
107
|
-
|
|
151
|
+
runtime.governance.record(call, result.isError === true, runtime.ownedTurns + 1);
|
|
152
|
+
const ref = artifactRefFromMessage({ role: "toolResult", details: result.details });
|
|
153
|
+
if (ref) runtime.artifactRefs.set(ref.artifactId, ref);
|
|
154
|
+
const compiled = new Set(options.tools?.map((tool) => tool.name) ?? []);
|
|
155
|
+
const names = (options.toolsToActivate?.(call.name, { content: [...result.content ?? []], details: result.details }, result.isError === true) ?? [])
|
|
156
|
+
.filter((name) => compiled.has(name));
|
|
108
157
|
return names.length ? { ...result, control: { ...result.control, addTools: names } } : undefined;
|
|
109
158
|
},
|
|
110
159
|
}),
|
|
@@ -113,7 +162,14 @@ export class SessionHarness {
|
|
|
113
162
|
registry.install(extension);
|
|
114
163
|
const harness = await Harness.open(storage, {
|
|
115
164
|
models: options.models, registry,
|
|
116
|
-
settings: {
|
|
165
|
+
settings: {
|
|
166
|
+
stream: options.streamOptions, compaction: options.compaction, toolExecution: options.toolExecution,
|
|
167
|
+
// Read for every retry decision, after afterResponse has classified that response.
|
|
168
|
+
get retry() {
|
|
169
|
+
const retry = { maxRetries: 4, ...options.retry };
|
|
170
|
+
return options.recovery ? { ...retry, enabled: (retry.enabled ?? true) && options.recovery.retryAllowed } : retry;
|
|
171
|
+
},
|
|
172
|
+
},
|
|
117
173
|
}, context);
|
|
118
174
|
const pointer = await harness.snapshot(RuntimeConversation, context);
|
|
119
175
|
const conversation = pointer?.id ? await harness.conversation(pointer.id, context) : await harness.root(context);
|
|
@@ -127,6 +183,7 @@ export class SessionHarness {
|
|
|
127
183
|
runtime.submission = await harness.submission(unfinished[0].id, context);
|
|
128
184
|
runtime.requestId = unfinished[0].requestId;
|
|
129
185
|
}
|
|
186
|
+
await runtime.restoreGovernance(context);
|
|
130
187
|
return runtime;
|
|
131
188
|
}
|
|
132
189
|
|
|
@@ -142,6 +199,29 @@ export class SessionHarness {
|
|
|
142
199
|
} while (cursor);
|
|
143
200
|
return entries;
|
|
144
201
|
}
|
|
202
|
+
/** Entries of the active Submission, from its input onward. */
|
|
203
|
+
async ownedEntries(entries: readonly EntryRecord[], context: Context): Promise<EntryRecord[]> {
|
|
204
|
+
const receipt = await this.submission?.status(context);
|
|
205
|
+
return receipt?.entry ? entries.filter((entry) => entry.id >= receipt.entry!) : [...entries];
|
|
206
|
+
}
|
|
207
|
+
private async restoreGovernance(context: Context): Promise<void> {
|
|
208
|
+
const entries = await this.entries(context);
|
|
209
|
+
this.artifactRefs.clear();
|
|
210
|
+
for (const entry of entries) {
|
|
211
|
+
const message = entry.model?.[0];
|
|
212
|
+
const ref = message ? artifactRefFromMessage(message) : undefined;
|
|
213
|
+
if (ref) this.artifactRefs.set(ref.artifactId, ref);
|
|
214
|
+
}
|
|
215
|
+
this.governance = new ToolGovernance();
|
|
216
|
+
this.wrapUpStarted = false;
|
|
217
|
+
this.ownedTurns = 0;
|
|
218
|
+
if (!this.submission) return;
|
|
219
|
+
const owned = await this.ownedEntries(entries, context);
|
|
220
|
+
this.governance.restore(owned);
|
|
221
|
+
this.ownedTurns = owned.filter((entry) => entry.kind === "pi.assistant").length;
|
|
222
|
+
const wrapUpAt = Math.min(this.options.limits?.maxModelTurns ?? 300, this.governance.wrapUpAt ?? Infinity);
|
|
223
|
+
this.wrapUpStarted = this.ownedTurns >= wrapUpAt;
|
|
224
|
+
}
|
|
145
225
|
watch(context: Context): Promise<AgentEventStream> { return watchEvents(this.harness, this.conversation.id, context); }
|
|
146
226
|
requestHumanRelease(reason: "approval" | "interaction"): void { this.release(reason); }
|
|
147
227
|
async fork(at: EntryId | null, context: Context): Promise<void> {
|
|
@@ -167,10 +247,13 @@ export class SessionHarness {
|
|
|
167
247
|
const names = new Set(this.options.activeToolNames ?? this.options.tools?.map((tool) => tool.name) ?? []);
|
|
168
248
|
const previous = await this.harness.snapshot(AgentDoc, this.conversation.id, context);
|
|
169
249
|
if (Array.isArray(previous?.tools)) for (const name of previous.tools) if (this.options.onDemandToolNames?.includes(name)) names.add(name);
|
|
170
|
-
await this.conversation.configure({ model: { provider: this.options.model.provider, modelId: this.options.model.id }, thinkingLevel: this.options.thinkingLevel ?? "off", tools: this.options.tools?.filter((tool) => names.has(tool.name)) ?? []
|
|
250
|
+
await this.conversation.configure({ model: { provider: this.options.model.provider, modelId: this.options.model.id }, thinkingLevel: this.options.thinkingLevel ?? "off", tools: this.options.tools?.filter((tool) => names.has(tool.name)) ?? [],
|
|
251
|
+
// Earlier runtimes stored the wrap-up instruction in the agent; it applies to one Submission only.
|
|
252
|
+
instructions: null }, context);
|
|
171
253
|
this.requestId = request.operationId;
|
|
172
254
|
this.submission = await this.conversation.submit({ type: "input", requestId: request.operationId, content: typeof request.prompt === "string" ? request.prompt : request.prompt.content, whenBusy: "reject" }, context);
|
|
173
255
|
}
|
|
256
|
+
await this.restoreGovernance(context);
|
|
174
257
|
}
|
|
175
258
|
async run(request: SessionRunRequest | undefined, context: Context): Promise<SessionHarnessRunResult> {
|
|
176
259
|
if (request) await this.admit(request, context);
|
package/src/models/index.ts
CHANGED
|
@@ -537,14 +537,15 @@ export function modelRequestUrl(model: Model<Api>): string {
|
|
|
537
537
|
*/
|
|
538
538
|
export function withProviderBoundary(
|
|
539
539
|
streamFn: ProviderBoundStream,
|
|
540
|
-
|
|
540
|
+
/** Host-owned provider Session; it overrides the durable Harness's per-conversation identity. */
|
|
541
|
+
hostSessionId?: string | (() => string),
|
|
541
542
|
onGeneration?: PiGenerationLifecycleObserver,
|
|
542
543
|
): ProviderBoundStream {
|
|
543
544
|
return (model, context, options) =>
|
|
544
545
|
lazyStream(model, async () => {
|
|
545
546
|
const requestId = crypto.randomUUID();
|
|
546
547
|
const startedAt = Date.now();
|
|
547
|
-
const sessionId = options?.sessionId
|
|
548
|
+
const sessionId = (typeof hostSessionId === "function" ? hostSessionId() : hostSessionId) ?? options?.sessionId;
|
|
548
549
|
const url = modelRequestUrl(model);
|
|
549
550
|
const systemPrompt = "systemPrompt" in context && typeof context.systemPrompt === "string"
|
|
550
551
|
? context.systemPrompt
|
|
@@ -910,6 +911,11 @@ function configuredModel(
|
|
|
910
911
|
...(endpoint.protocol === "openrouter-messages"
|
|
911
912
|
? {
|
|
912
913
|
...anthropicMessagesCompat,
|
|
914
|
+
// The direct Anthropic catalog enables positional system and Tool updates, but
|
|
915
|
+
// OpenRouter Messages rejects `tool_addition` definitions. Fold them into the
|
|
916
|
+
// leading system and top-level tools so deferred Tool decoration still applies.
|
|
917
|
+
supportsMidConvoSystemMessages: false,
|
|
918
|
+
supportsMidConvoToolChanges: false,
|
|
913
919
|
supportsMidConvoEffort: false,
|
|
914
920
|
supportsEagerToolInputStreaming: false,
|
|
915
921
|
supportsStrictTools:
|
|
@@ -922,6 +928,9 @@ function configuredModel(
|
|
|
922
928
|
: endpoint.protocol === "openrouter-chat"
|
|
923
929
|
? {
|
|
924
930
|
...catalogCompat,
|
|
931
|
+
// OpenRouter Chat has no verified positional system or Tool update support.
|
|
932
|
+
supportsMidConvoSystemMessages: false,
|
|
933
|
+
supportsMidConvoToolChanges: false,
|
|
925
934
|
thinkingFormat: "openrouter" as const,
|
|
926
935
|
maxTokensField: "max_tokens" as const,
|
|
927
936
|
supportsStore: false,
|
|
@@ -92,6 +92,89 @@ function normalizeLegacyAskUserEntry(value: unknown): unknown {
|
|
|
92
92
|
return value;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
const TERMINAL_HOST_STATUSES = new Set(["completed", "failed", "aborted", "skipped", "error"]);
|
|
96
|
+
/** Legacy run phases whose state maps onto a native generation checkpoint without guessing. */
|
|
97
|
+
const RESUMABLE_PHASES = new Set(["starting", "checkpoint", "assistant.ready", "assistant.effect_pending", "assistant.retry_wait"]);
|
|
98
|
+
const MESSAGE_ROLES = new Set(["user", "assistant", "toolResult", "custom", "bashExecution", "branchSummary", "compactionSummary"]);
|
|
99
|
+
const UNKNOWN_EFFECT = "Tool execution was interrupted and its external outcome is unknown. Do not repeat the effect without reconciling it.";
|
|
100
|
+
const INTERRUPTED = "This request was interrupted by a runtime upgrade and did not finish. Send it again to continue.";
|
|
101
|
+
|
|
102
|
+
type LegacyResult = { status?: string; error?: { code?: string; message?: string } };
|
|
103
|
+
type ToolBatch = { assistantEntryId: string; turnId: string; calls: Array<{ status: string; sourceIndex: number; resultEntryId: string; replay?: string }> };
|
|
104
|
+
|
|
105
|
+
function warn(message: string): void {
|
|
106
|
+
console.warn("[pi-session-migration]", message);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Custom messages reach the model as user context, matching `ConversationHistory.append` (e.g. turn-aborted markers). */
|
|
110
|
+
function customModel(message: Message, createdAt: number): { model?: Message[] } {
|
|
111
|
+
const content = (message as unknown as { content?: unknown }).content;
|
|
112
|
+
if (typeof content === "string") return { model: [{ role: "user", content, timestamp: createdAt }] };
|
|
113
|
+
if (!Array.isArray(content) || !content.length) return {};
|
|
114
|
+
return { model: [{ role: "user", content: [...content] as Extract<Message, { role: "user" }>["content"], timestamp: createdAt }] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function unknownOutcome(call: { id: string; name: string }, text = UNKNOWN_EFFECT): Message {
|
|
118
|
+
return { role: "toolResult", toolCallId: call.id, toolName: call.name, content: [{ type: "text", text }], isError: true, timestamp: Date.now() };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Normalizes historical records onto the native shapes and drops records that can never be valid model context.
|
|
123
|
+
* Dropped and branch-pointer records resolve to their nearest kept ancestor, so descendants stay on their branch.
|
|
124
|
+
*/
|
|
125
|
+
function repairLegacyEntries(raw: LegacyEntry[]): { entries: LegacyEntry[]; resolve: (id: string | null) => string | null } {
|
|
126
|
+
const kept = new Map<string, LegacyEntry>();
|
|
127
|
+
const alias = new Map<string, string | null>();
|
|
128
|
+
const resolve = (id: string | null): string | null => {
|
|
129
|
+
for (let seen = 0; id !== null && !kept.has(id) && alias.has(id); seen++) {
|
|
130
|
+
if (seen > alias.size) throw new Error(`Cyclic historical Pi reference: ${id}`);
|
|
131
|
+
id = alias.get(id)!;
|
|
132
|
+
}
|
|
133
|
+
return id;
|
|
134
|
+
};
|
|
135
|
+
const entries: LegacyEntry[] = [];
|
|
136
|
+
for (const source of raw) {
|
|
137
|
+
const parentId = resolve(source.parentId);
|
|
138
|
+
const skip = (reason: string) => {
|
|
139
|
+
warn(`Skipped historical entry ${source.id}: ${reason}`);
|
|
140
|
+
alias.set(source.id, parentId);
|
|
141
|
+
};
|
|
142
|
+
if (source.type === "leaf") {
|
|
143
|
+
alias.set(source.id, resolve(typeof source.targetId === "string" ? source.targetId : source.targetId === null ? null : parentId));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
let entry: LegacyEntry = { ...source, parentId };
|
|
147
|
+
if (source.type === "custom_message") {
|
|
148
|
+
if (typeof source.customType !== "string") { skip("custom message has no type"); continue; }
|
|
149
|
+
entry = {
|
|
150
|
+
id: source.id, parentId, type: "message", timestamp: source.timestamp,
|
|
151
|
+
message: { role: "custom", customType: source.customType, content: source.content, details: source.details, display: source.display, timestamp: timestamp(source) } as unknown as Message,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (entry.type === "message") {
|
|
155
|
+
const message = entry.message as unknown as Record<string, unknown> | undefined;
|
|
156
|
+
if (!message || typeof message !== "object" || !MESSAGE_ROLES.has(message.role as string)) { skip("unknown message"); continue; }
|
|
157
|
+
if (message.role === "assistant" && message.stopReason === "pending") { skip("unsettled assistant"); continue; }
|
|
158
|
+
if (message.role === "toolResult") {
|
|
159
|
+
let call: { name: string } | undefined;
|
|
160
|
+
let duplicate = false;
|
|
161
|
+
for (let cursor = parentId; cursor && !call && !duplicate; cursor = kept.get(cursor)?.parentId ?? null) {
|
|
162
|
+
const ancestor = kept.get(cursor)?.message;
|
|
163
|
+
if (ancestor?.role === "toolResult" && ancestor.toolCallId === message.toolCallId) duplicate = true;
|
|
164
|
+
if (ancestor?.role === "assistant") {
|
|
165
|
+
call = ancestor.content.find((part) => part.type === "toolCall" && part.id === message.toolCallId) as { name: string } | undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (duplicate) { skip(`duplicate Tool result ${String(message.toolCallId)}`); continue; }
|
|
169
|
+
if (!call || call.name !== message.toolName) { skip(`orphan Tool result ${String(message.toolCallId)}`); continue; }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
kept.set(entry.id, entry);
|
|
173
|
+
entries.push(entry);
|
|
174
|
+
}
|
|
175
|
+
return { entries, resolve };
|
|
176
|
+
}
|
|
177
|
+
|
|
95
178
|
/** Converts records once; historical tables are retained until the upgrade is accepted. */
|
|
96
179
|
export async function migratePiSession(storage: DurableObjectStorage, session: Session): Promise<void> {
|
|
97
180
|
if ((await session.snapshot(Migration, BACKGROUND_CONTEXT))?.completed) return;
|
|
@@ -105,9 +188,11 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
105
188
|
const rows = current || older
|
|
106
189
|
? sql.exec<{ body: string }>(`SELECT body FROM ${current ? "pi_entries" : "pi_session_entries"} ORDER BY seq`).toArray()
|
|
107
190
|
: [];
|
|
108
|
-
const
|
|
191
|
+
const raw = rows.map(({ body }) => normalizeLegacyAskUserEntry(JSON.parse(body)) as LegacyEntry);
|
|
192
|
+
if (new Set(raw.map((entry) => entry.id)).size !== raw.length) throw new Error("Historical Pi entry IDs are not unique");
|
|
193
|
+
const repaired = repairLegacyEntries(raw);
|
|
194
|
+
const entries = repaired.entries;
|
|
109
195
|
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
110
|
-
if (byId.size !== entries.length) throw new Error("Historical Pi entry IDs are not unique");
|
|
111
196
|
const values = current && hasTable(sql, "pi_values")
|
|
112
197
|
? sql.exec<{ namespace: string; key: string; body: string }>("SELECT namespace,key,body FROM pi_values").toArray()
|
|
113
198
|
: [];
|
|
@@ -116,18 +201,25 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
116
201
|
return row ? JSON.parse(row.body) as T : undefined;
|
|
117
202
|
};
|
|
118
203
|
const tipValue = values.find((row) => row.namespace === "pi.branch.tip" && row.key === "main");
|
|
119
|
-
|
|
120
|
-
|
|
204
|
+
let oldTip: string | null = repaired.resolve(tipValue ? JSON.parse(tipValue.body) as string | null : raw.at(-1)?.id ?? null);
|
|
205
|
+
const hostRows = hasTable(sql, "pi_submissions")
|
|
206
|
+
? sql.exec<{ submission_id: string; status: string }>("SELECT submission_id,status FROM pi_submissions").toArray()
|
|
207
|
+
: [];
|
|
208
|
+
const hostStatus = new Map(hostRows.map((row) => [row.submission_id, row.status]));
|
|
121
209
|
const lane = read<{ currentOperationId?: string | null }>("pi.lane.state", "main");
|
|
122
210
|
const operationId = lane?.currentOperationId;
|
|
123
|
-
const
|
|
124
|
-
const legacyRunning = older && hasTable(sql, "pi_submissions") ? sql.exec<{ submission_id: string }>("SELECT submission_id FROM pi_submissions WHERE status='running'").toArray() : [];
|
|
211
|
+
const legacyRunning = older ? hostRows.filter((row) => row.status === "running") : [];
|
|
125
212
|
if (legacyRunning.length > 1) throw new Error("Multiple legacy operations are active in one conversation");
|
|
126
|
-
|
|
127
|
-
?
|
|
128
|
-
:
|
|
129
|
-
|
|
130
|
-
|
|
213
|
+
let active: string | undefined = operationId && read<LegacyResult>("pi.result", operationId) === undefined && !TERMINAL_HOST_STATUSES.has(hostStatus.get(operationId) ?? "")
|
|
214
|
+
? operationId
|
|
215
|
+
: legacyRunning[0]?.submission_id;
|
|
216
|
+
// Navigation and standalone compaction never committed their target. The Host re-admits the owning
|
|
217
|
+
// Submission from its queued input, so the native runtime performs the whole step itself.
|
|
218
|
+
const intent = active ? read<{ intent?: { kind?: string } }>("pi.op.meta", active)?.intent?.kind ?? (active.endsWith(":regenerate") ? "navigation" : "run") : undefined;
|
|
219
|
+
if (active && intent !== "run") {
|
|
220
|
+
warn(`Dropped unfinished legacy ${intent} operation ${active}`);
|
|
221
|
+
active = undefined;
|
|
222
|
+
}
|
|
131
223
|
const links = hasTable(sql, "pi_session_entry_submission")
|
|
132
224
|
? sql.exec<{ entry_id: string; submission_id: string }>("SELECT entry_id,submission_id FROM pi_session_entry_submission").toArray()
|
|
133
225
|
: hasTable(sql, "pi_session_message_submission") ? sql.exec<{ entry_id: string; submission_id: string }>("SELECT message_id AS entry_id,submission_id FROM pi_session_message_submission").toArray() : [];
|
|
@@ -156,6 +248,10 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
156
248
|
}
|
|
157
249
|
}
|
|
158
250
|
}
|
|
251
|
+
const commitResult = (id: string, outcome: Message) => {
|
|
252
|
+
const entry: LegacyEntry = { id, parentId: oldTip, type: "message", message: outcome, timestamp: outcome.timestamp };
|
|
253
|
+
entries.push(entry); byId.set(entry.id, entry); owners.set(entry.id, active!); oldTip = entry.id;
|
|
254
|
+
};
|
|
159
255
|
if (older && active && hasTable(sql, "pi_recovery_milestones")) {
|
|
160
256
|
const milestones = sql.exec<{ body: string }>("SELECT body FROM pi_recovery_milestones WHERE submission_id=? ORDER BY seq", active).toArray();
|
|
161
257
|
const recovered = replayPiToolRecovery(milestones.map((row) => row.body));
|
|
@@ -163,36 +259,68 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
163
259
|
for (const call of Object.values(recovered.toolCalls)) {
|
|
164
260
|
if (existing.has(call.toolCallId)) continue;
|
|
165
261
|
const pending = Object.values(recovered.approvals).some((row) => row.toolCallId === call.toolCallId && row.status === "pending") || Object.values(recovered.interactions).some((row) => row.toolCallId === call.toolCallId && row.status === "pending");
|
|
166
|
-
const outcome = call.result ?? (!pending && call.retry === "non-idempotent" ? {
|
|
167
|
-
if (
|
|
168
|
-
const entry: LegacyEntry = { id: `legacy-result:${active}:${call.toolCallId}`, parentId: oldTip, type: "message", message: outcome, timestamp: outcome.timestamp };
|
|
169
|
-
entries.push(entry); byId.set(entry.id, entry); owners.set(entry.id, active); oldTip = entry.id;
|
|
262
|
+
const outcome = call.result ?? (!pending && call.retry === "non-idempotent" ? unknownOutcome({ id: call.toolCallId, name: call.toolName }, "Interrupted legacy effect; its outcome is unknown. Reconcile it before repeating it.") : undefined);
|
|
263
|
+
if (outcome) commitResult(`legacy-result:${active}:${call.toolCallId}`, outcome);
|
|
170
264
|
}
|
|
171
265
|
}
|
|
172
|
-
|
|
266
|
+
// An operation that cannot be resumed is settled as interrupted. It never replays an uncertain effect.
|
|
267
|
+
let interrupted: string | undefined;
|
|
268
|
+
const operation = active ? read<{ at: string; batch?: ToolBatch }>("pi.op.state", active) : undefined;
|
|
173
269
|
if (active && operation?.at === "tools") {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
270
|
+
const message = operation.batch ? byId.get(operation.batch.assistantEntryId)?.message : undefined;
|
|
271
|
+
const assistant = message?.role === "assistant" ? message : undefined;
|
|
272
|
+
if (!assistant) interrupted = "Historical Tool batch has no canonical assistant";
|
|
273
|
+
for (const call of assistant ? operation.batch!.calls : []) {
|
|
274
|
+
const part = assistant!.content[call.sourceIndex];
|
|
275
|
+
if (typeof part !== "object" || part?.type !== "toolCall") { interrupted = "Historical Tool batch index is invalid"; continue; }
|
|
276
|
+
const tool = part;
|
|
179
277
|
if (call.status === "completed") continue;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
outcome = { role: "toolResult", toolCallId: tool.id, toolName: tool.name, content: [{ type: "text", text: "Tool execution was interrupted and its external outcome is unknown. Do not repeat the effect without reconciling it." }], isError: true, timestamp: Date.now() };
|
|
278
|
+
let outcome = read<{ type?: string; payload?: Message }>("pi.pending.entry", call.resultEntryId)?.payload;
|
|
279
|
+
if (outcome && (outcome.role !== "toolResult" || outcome.toolCallId !== tool.id || outcome.toolName !== tool.name)) {
|
|
280
|
+
warn(`Historical Tool outcome ${call.resultEntryId} conflicts with its call`);
|
|
281
|
+
outcome = unknownOutcome(tool);
|
|
185
282
|
}
|
|
186
|
-
if (outcome) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
entries.push(entry); byId.set(entry.id, entry); owners.set(entry.id, active); oldTip = entry.id;
|
|
283
|
+
if (call.status === "outcome_ready" && outcome?.role !== "toolResult") {
|
|
284
|
+
warn(`Historical Tool outcome ${call.resultEntryId} is missing`);
|
|
285
|
+
outcome = unknownOutcome(tool);
|
|
190
286
|
}
|
|
287
|
+
if (call.status === "effect_pending" && call.replay !== "safe") outcome = unknownOutcome(tool);
|
|
288
|
+
if (outcome) commitResult(call.resultEntryId, outcome);
|
|
289
|
+
}
|
|
290
|
+
} else if (active && operation && !RESUMABLE_PHASES.has(operation.at)) {
|
|
291
|
+
interrupted = `Historical operation ${operation.at} cannot resume natively`;
|
|
292
|
+
}
|
|
293
|
+
const branch = (tip: string | null): LegacyEntry[] => {
|
|
294
|
+
const chain: LegacyEntry[] = [];
|
|
295
|
+
for (let id = tip; id; id = byId.get(id)?.parentId ?? null) {
|
|
296
|
+
const entry = byId.get(id);
|
|
297
|
+
if (!entry) throw new Error("Historical branch is invalid");
|
|
298
|
+
chain.unshift(entry);
|
|
191
299
|
}
|
|
192
|
-
|
|
193
|
-
|
|
300
|
+
return chain;
|
|
301
|
+
};
|
|
302
|
+
let owned = active ? branch(oldTip).filter((entry) => owners.get(entry.id) === active) : [];
|
|
303
|
+
let prompt = owned.find((entry) => entry.message?.role === "user");
|
|
304
|
+
if (active && !prompt) interrupted ??= "Active historical operation has no canonical user input";
|
|
305
|
+
if (active && interrupted) {
|
|
306
|
+
warn(`Settled legacy operation ${active} as interrupted: ${interrupted}`);
|
|
307
|
+
const assistant = [...owned].reverse().find((entry) => entry.message?.role === "assistant")?.message;
|
|
308
|
+
const settled = new Set(owned.flatMap((entry) => entry.message?.role === "toolResult" ? [entry.message.toolCallId] : []));
|
|
309
|
+
for (const part of assistant?.role === "assistant" ? assistant.content : []) {
|
|
310
|
+
if (part.type === "toolCall" && !settled.has(part.id)) commitResult(`legacy-interrupted:${active}:${part.id}`, unknownOutcome(part));
|
|
311
|
+
}
|
|
312
|
+
owned = branch(oldTip).filter((entry) => owners.get(entry.id) === active);
|
|
313
|
+
prompt = owned.find((entry) => entry.message?.role === "user");
|
|
194
314
|
}
|
|
315
|
+
// Pi finished these operations before the Host recorded their terminal; settle them instead of rerunning input.
|
|
316
|
+
const finished = hostRows.flatMap((row) => {
|
|
317
|
+
if (row.submission_id === active || !["pending", "running"].includes(row.status)) return [];
|
|
318
|
+
const result = read<LegacyResult>("pi.result", row.submission_id);
|
|
319
|
+
return result ? [{ id: row.submission_id, result }] : [];
|
|
320
|
+
});
|
|
195
321
|
const converted = new Map<string, EntryRecord>();
|
|
322
|
+
// Where a child continues; a compaction's retained context belongs to every branch through it.
|
|
323
|
+
const anchors = new Map<string, EntryRecord>();
|
|
196
324
|
const conversationByEntry = new Map<string, ConversationId>();
|
|
197
325
|
const tips = new Map<ConversationId, string>();
|
|
198
326
|
const copiedUi: Array<{ id: string; body: string }> = [];
|
|
@@ -206,7 +334,7 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
206
334
|
if (!entry.id || (entry.parentId !== null && !byId.has(entry.parentId))) {
|
|
207
335
|
throw new Error(`Historical Pi entry has a missing parent: ${entry.id}`);
|
|
208
336
|
}
|
|
209
|
-
const parent = entry.parentId ?
|
|
337
|
+
const parent = entry.parentId ? anchors.get(entry.parentId) : undefined;
|
|
210
338
|
if (entry.parentId && !parent) throw new Error(`Historical Pi entry is out of order: ${entry.id}`);
|
|
211
339
|
let conversationId = parent ? conversationByEntry.get(entry.parentId!)! : initial.id;
|
|
212
340
|
if (parent && tips.get(conversationId) !== entry.parentId) {
|
|
@@ -214,15 +342,16 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
214
342
|
}
|
|
215
343
|
const message = entry.message;
|
|
216
344
|
let record: EntryRecord;
|
|
345
|
+
let anchor: EntryRecord;
|
|
217
346
|
if (entry.type === "message" && message) {
|
|
218
347
|
const role = (message as { role: string }).role;
|
|
219
|
-
record = await tx.appendEntry(conversationId, {
|
|
348
|
+
record = anchor = await tx.appendEntry(conversationId, {
|
|
220
349
|
kind: role === "toolResult" ? "pi.tool-result" : role === "custom" ? "springbrand.custom" : `pi.${role}`,
|
|
221
|
-
...(role === "custom" ?
|
|
222
|
-
data: { displayId: entry.id, createdAt: timestamp(entry), ...(role === "custom" ? { message
|
|
350
|
+
...(role === "custom" ? customModel(message, timestamp(entry)) : { model: [message] }),
|
|
351
|
+
data: JSON.parse(JSON.stringify({ displayId: entry.id, createdAt: timestamp(entry), ...(role === "custom" ? { message } : {}), ...(role === "toolResult" ? { diagnostics: [] } : {}) })),
|
|
223
352
|
});
|
|
224
353
|
} else if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
225
|
-
record = await tx.appendEntry(conversationId, {
|
|
354
|
+
record = anchor = await tx.appendEntry(conversationId, {
|
|
226
355
|
kind: "pi.compaction", model: [{ role: "user", content: `<summary>\n${entry.summary ?? ""}\n</summary>`, timestamp: timestamp(entry) }],
|
|
227
356
|
head: "self", data: { reason: "manual", displayId: entry.id, createdAt: timestamp(entry) },
|
|
228
357
|
});
|
|
@@ -241,12 +370,13 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
241
370
|
retainedTail = tail;
|
|
242
371
|
}
|
|
243
372
|
for (const retained of retainedTail ?? []) {
|
|
244
|
-
await tx.appendEntry(conversationId, { kind: "springbrand.retained-context", model: [retained] });
|
|
373
|
+
anchor = await tx.appendEntry(conversationId, { kind: "springbrand.retained-context", model: [retained] });
|
|
245
374
|
}
|
|
246
375
|
} else {
|
|
247
|
-
record = await tx.appendEntry(conversationId, { kind: "springbrand.history", data: { displayId: entry.id, createdAt: timestamp(entry), record: entry as unknown as Record<string, never> } });
|
|
376
|
+
record = anchor = await tx.appendEntry(conversationId, { kind: "springbrand.history", data: { displayId: entry.id, createdAt: timestamp(entry), record: entry as unknown as Record<string, never> } });
|
|
248
377
|
}
|
|
249
378
|
converted.set(entry.id, record);
|
|
379
|
+
anchors.set(entry.id, anchor);
|
|
250
380
|
conversationByEntry.set(entry.id, conversationId);
|
|
251
381
|
tips.set(conversationId, entry.id);
|
|
252
382
|
if (entry.id === oldTip) selected = conversationId;
|
|
@@ -257,7 +387,7 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
257
387
|
if (oldTip === null && entries.length) {
|
|
258
388
|
selected = (await session.commit((tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), BACKGROUND_CONTEXT)).id;
|
|
259
389
|
} else if (oldTip && tips.get(selected) !== oldTip) {
|
|
260
|
-
const tip =
|
|
390
|
+
const tip = anchors.get(oldTip);
|
|
261
391
|
if (!tip) throw new Error("Historical branch tip is missing");
|
|
262
392
|
selected = (await session.commit((tx) => tx.forkConversation(tip.conversationId, tip.id, { ownership: { kind: "ownerless" } }), BACKGROUND_CONTEXT)).id;
|
|
263
393
|
}
|
|
@@ -271,16 +401,29 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
271
401
|
if (typeof config.thinkingLevel === "string") agent.thinkingLevel = config.thinkingLevel as typeof agent.thinkingLevel;
|
|
272
402
|
if (Array.isArray(config.activeToolNames)) agent.tools = config.activeToolNames as string[];
|
|
273
403
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
404
|
+
const selectedBranch = branch(oldTip);
|
|
405
|
+
for (const { id, result } of finished) {
|
|
406
|
+
const ownedBySubmission = selectedBranch.filter((entry) => owners.get(entry.id) === id);
|
|
407
|
+
const input = ownedBySubmission.find((entry) => entry.message?.role === "user");
|
|
408
|
+
const reply = [...ownedBySubmission].reverse().find((entry) => entry.message?.role === "assistant");
|
|
409
|
+
const entry = input ? converted.get(input.id)!.id : undefined;
|
|
410
|
+
if ((result.status === "completed" || result.status === "declined") && entry !== undefined && reply) {
|
|
411
|
+
await tx.createSubmission({ conversationId: selected, requestId: id, type: "input", status: "done", entry, answer: converted.get(reply.id)!.id });
|
|
412
|
+
} else {
|
|
413
|
+
await tx.createSubmission({
|
|
414
|
+
conversationId: selected, requestId: id, type: "input", status: "unanswered",
|
|
415
|
+
reason: result.status === "aborted" ? "aborted" : result.status ?? "failed",
|
|
416
|
+
detail: result.error?.message ?? (result.status === "aborted" ? "Cancelled" : "The legacy request ended without an answer."),
|
|
417
|
+
...(entry !== undefined ? { entry } : {}),
|
|
418
|
+
});
|
|
280
419
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
420
|
+
}
|
|
421
|
+
if (active && interrupted) {
|
|
422
|
+
await tx.createSubmission({
|
|
423
|
+
conversationId: selected, requestId: active, type: "input", status: "unanswered", reason: "interrupted", detail: INTERRUPTED,
|
|
424
|
+
...(prompt ? { entry: converted.get(prompt.id)!.id } : {}),
|
|
425
|
+
});
|
|
426
|
+
} else if (active && prompt) {
|
|
284
427
|
const promptEntry = converted.get(prompt.id)!;
|
|
285
428
|
const submission = await tx.createSubmission({ conversationId: selected, requestId: active, type: "input", status: "placed", entry: promptEntry.id });
|
|
286
429
|
const assistant = [...owned].reverse().find((entry) => entry.message?.role === "assistant");
|
|
@@ -302,13 +445,46 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
302
445
|
sql.exec("CREATE TABLE IF NOT EXISTS pi_session_entry_submission (entry_id TEXT PRIMARY KEY, submission_id TEXT NOT NULL)");
|
|
303
446
|
for (const link of copiedLinks) sql.exec("INSERT OR IGNORE INTO pi_session_entry_submission VALUES (?,?)", link.id, link.submissionId);
|
|
304
447
|
for (const sidecar of copiedUi) sql.exec("INSERT OR IGNORE INTO pi_message_ui VALUES (?,?)", sidecar.id, sidecar.body);
|
|
448
|
+
if (hasTable(sql, "pi_pending_steers")) {
|
|
449
|
+
// Old Pi committed these Steers but the Host receipt survived; a native requeue would duplicate them.
|
|
450
|
+
const steers = sql.exec<{ message_id: string; harness_entry_id: string | null; ui_message_json: string | null }>("SELECT message_id,harness_entry_id,ui_message_json FROM pi_pending_steers").toArray();
|
|
451
|
+
for (const steer of steers) {
|
|
452
|
+
const record = converted.get(steer.harness_entry_id ?? "") ?? converted.get(steer.message_id);
|
|
453
|
+
if (!record) continue;
|
|
454
|
+
if (steer.ui_message_json) {
|
|
455
|
+
sql.exec("CREATE TABLE IF NOT EXISTS pi_message_ui (message_id TEXT PRIMARY KEY, body TEXT NOT NULL)");
|
|
456
|
+
sql.exec("INSERT OR IGNORE INTO pi_message_ui VALUES (?,?)", String(record.id), steer.ui_message_json);
|
|
457
|
+
}
|
|
458
|
+
sql.exec("DELETE FROM pi_pending_steers WHERE message_id=?", steer.message_id);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
305
461
|
for (const table of ["pi_approvals", "pi_tool_interactions"]) {
|
|
306
462
|
if (!hasTable(sql, table)) continue;
|
|
307
463
|
const rows = sql.exec<{ rowid: number; input_json: string }>(`SELECT rowid,input_json FROM ${table} WHERE tool_name='ask_user'`).toArray();
|
|
308
464
|
for (const row of rows) sql.exec(`UPDATE ${table} SET input_json=? WHERE rowid=?`, JSON.stringify(normalizeLegacyAskUserInput(JSON.parse(row.input_json))), row.rowid);
|
|
465
|
+
// An interrupted operation can no longer continue its parked call, so its decision must not park Host recovery.
|
|
466
|
+
if (active && interrupted && hasColumns(sql, table, ["submission_id", "status"])) {
|
|
467
|
+
sql.exec(`UPDATE ${table} SET status=? WHERE submission_id=? AND status='pending'`, table === "pi_approvals" ? "rejected" : "cancelled", active);
|
|
468
|
+
}
|
|
309
469
|
}
|
|
310
470
|
if (hasTable(sql, "cf_agents_schedules")) {
|
|
311
471
|
for (const callback of ["_cleanupStreamBuffers", "_piPlannedContinuation", "_piContinuationWatchdog", "_piApprovalContinuation", "_chatRecoveryRetry"]) sql.exec("DELETE FROM cf_agents_schedules WHERE callback=?", callback);
|
|
312
472
|
}
|
|
313
473
|
});
|
|
314
474
|
}
|
|
475
|
+
|
|
476
|
+
function hasColumns(sql: SqlStorage, table: string, names: string[]): boolean {
|
|
477
|
+
const columns = new Set(sql.exec<{ name: string }>(`PRAGMA table_info(${table})`).toArray().map((row) => row.name));
|
|
478
|
+
return names.every((name) => columns.has(name));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const LEGACY_TABLES = [
|
|
482
|
+
"pi_entries", "pi_values", "pi_lists", "pi_usage", "pi_storage_state", "pi_session_entries", "pi_session_metadata",
|
|
483
|
+
"pi_legacy_entries_backup", "pi_recovery_milestones", "pi_session_message_submission", "pi_tool_settlements",
|
|
484
|
+
];
|
|
485
|
+
|
|
486
|
+
/** Drops the inert pre-upgrade baseline once the migrated history it backs up is deleted. */
|
|
487
|
+
export async function dropLegacyPiTables(storage: DurableObjectStorage, session: Session): Promise<void> {
|
|
488
|
+
if (!(await session.snapshot(Migration, BACKGROUND_CONTEXT))?.completed) throw new Error("Legacy Pi tables are still the migration source");
|
|
489
|
+
for (const table of LEGACY_TABLES) storage.sql.exec(`DROP TABLE IF EXISTS ${table}`);
|
|
490
|
+
}
|
|
@@ -13,12 +13,14 @@ import type { SteerRepository } from "./steer-repository";
|
|
|
13
13
|
|
|
14
14
|
/** Commits the Submission outcome and human/Host facts after accepted steering is preserved. */
|
|
15
15
|
export class SubmissionFinalizer {
|
|
16
|
+
/** User-stop markers appended after their terminal commit; `finish()` awaits its own. */
|
|
17
|
+
private readonly stopMarkers = new Map<string, Promise<unknown>>();
|
|
16
18
|
constructor(private readonly options: {
|
|
17
19
|
submissions: SubmissionRepository;
|
|
18
20
|
steers: SteerRepository;
|
|
19
21
|
messageUi: MessageUiRepository;
|
|
20
22
|
transaction<T>(run: () => T): T;
|
|
21
|
-
conversation: Pick<ConversationHistory, "append" | "storedMessages">;
|
|
23
|
+
conversation: Pick<ConversationHistory, "append" | "appendTurnAbortedMarker" | "storedMessages">;
|
|
22
24
|
decisions: Pick<HarnessHumanDecisions, "cancelForSubmission">;
|
|
23
25
|
events: Pick<RuntimeTurnEvents, "submissionTerminal">;
|
|
24
26
|
telemetry: Pick<RuntimeTelemetryCoordinator, "capture">;
|
|
@@ -53,7 +55,13 @@ export class SubmissionFinalizer {
|
|
|
53
55
|
const output = (await this.options.conversation.storedMessages())
|
|
54
56
|
.filter((entry) => entry.submissionId === submissionId && entry.message.role === "assistant")
|
|
55
57
|
.at(-1)?.message;
|
|
56
|
-
|
|
58
|
+
const completed = this.commit(submissionId, outcome, message, output?.role === "assistant" ? output : undefined);
|
|
59
|
+
const marker = this.stopMarkers.get(submissionId);
|
|
60
|
+
if (marker) {
|
|
61
|
+
this.stopMarkers.delete(submissionId);
|
|
62
|
+
await marker;
|
|
63
|
+
}
|
|
64
|
+
return completed;
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
/** Shares atomic terminal facts with an admitted handoff cancelled before execution. */
|
|
@@ -63,7 +71,8 @@ export class SubmissionFinalizer {
|
|
|
63
71
|
message?: string,
|
|
64
72
|
output?: AssistantMessage,
|
|
65
73
|
): StoredSubmission {
|
|
66
|
-
|
|
74
|
+
let stoppedByUser = false;
|
|
75
|
+
const completed = this.options.transaction(() => {
|
|
67
76
|
// Host admission and history reads can await while an explicit stop wins.
|
|
68
77
|
const latest = this.read(submissionId);
|
|
69
78
|
if (isTerminalSubmissionStatus(latest.status)) return latest;
|
|
@@ -79,6 +88,10 @@ export class SubmissionFinalizer {
|
|
|
79
88
|
const status = effectiveOutcome === "succeeded" ? "completed" : effectiveOutcome === "aborted" ? "aborted" : "error";
|
|
80
89
|
this.options.submissions.updateTerminal(submissionId, status, reason, Date.now());
|
|
81
90
|
const completed = this.read(submissionId);
|
|
91
|
+
stoppedByUser = effectiveOutcome === "aborted" && reason === USER_STOP_REASON;
|
|
92
|
+
if (stoppedByUser && completed.completedAt === null) {
|
|
93
|
+
throw new Error(`Aborted SpringBrand submission has no completion time: ${submissionId}`);
|
|
94
|
+
}
|
|
82
95
|
this.options.events.submissionTerminal(completed);
|
|
83
96
|
this.options.telemetry.capture("turnFinished", {
|
|
84
97
|
submissionId,
|
|
@@ -90,6 +103,20 @@ export class SubmissionFinalizer {
|
|
|
90
103
|
});
|
|
91
104
|
return completed;
|
|
92
105
|
});
|
|
106
|
+
if (stoppedByUser) this.appendStopMarker(submissionId, completed.completedAt!);
|
|
107
|
+
return completed;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The browser projects `interruptedByUser` from this marker and the next model request sees it as user context.
|
|
112
|
+
* Pi history writes are async, so the marker follows the synchronous terminal commit.
|
|
113
|
+
*/
|
|
114
|
+
private appendStopMarker(submissionId: string, completedAt: number): void {
|
|
115
|
+
const marker = this.options.conversation.appendTurnAbortedMarker(submissionId, completedAt).catch((error: unknown) => {
|
|
116
|
+
console.warn("[runtime-stop-marker:failed]", submissionId, error instanceof Error ? error.message : String(error));
|
|
117
|
+
});
|
|
118
|
+
this.stopMarkers.set(submissionId, marker);
|
|
119
|
+
this.options.waitUntil(marker);
|
|
93
120
|
}
|
|
94
121
|
|
|
95
122
|
private read(submissionId: string): StoredSubmission {
|
|
@@ -168,8 +168,15 @@ export function compileHarnessTools(
|
|
|
168
168
|
...(candidate.deferLoading ? { deferLoading: true as const } : {}),
|
|
169
169
|
async execute(args, api, context) {
|
|
170
170
|
const toolCallId = api.callId;
|
|
171
|
+
// AgentTool progress is a replacing snapshot (web_search sends its accumulated text), but Pi output only
|
|
172
|
+
// appends and has no replace operation: append a grown snapshot's suffix, and start a new line otherwise.
|
|
173
|
+
let shown = "";
|
|
171
174
|
const onUpdate = (update: AgentToolResult<unknown>) => {
|
|
172
|
-
|
|
175
|
+
const text = update.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("");
|
|
176
|
+
if (text && text !== shown) {
|
|
177
|
+
api.output(text.startsWith(shown) ? text.slice(shown.length) : shown ? `\n${text}` : text);
|
|
178
|
+
shown = text;
|
|
179
|
+
}
|
|
173
180
|
if (update.details !== undefined) void api.details(update.details, context).catch(() => {});
|
|
174
181
|
};
|
|
175
182
|
const signal = context.abortSignal ?? new AbortController().signal;
|