@springbrand/agent-runtime 0.3.0-alpha.17 → 0.3.0-alpha.19
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 +16 -2
- 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-resume.ts +26 -10
- package/src/conversation/harness-stream.ts +22 -6
- package/src/conversation/history.ts +40 -5
- package/src/conversation/message/projection.ts +6 -0
- 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 +36 -4
- 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
|
|
|
@@ -1058,7 +1072,7 @@ export abstract class AgentRuntimeKernel<
|
|
|
1058
1072
|
/** Start the native resume handshake from Pi's current lane snapshot. */
|
|
1059
1073
|
async onConnect(connection: Connection): Promise<void> {
|
|
1060
1074
|
this.conversation.disconnect(connection);
|
|
1061
|
-
await this.conversation.
|
|
1075
|
+
await this.conversation.connect(connection);
|
|
1062
1076
|
}
|
|
1063
1077
|
|
|
1064
1078
|
async onClose(connection: Connection, code: number, reason: string, wasClean: boolean): Promise<void> {
|
|
@@ -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;
|
|
@@ -54,21 +54,28 @@ export class HarnessConversationBridge {
|
|
|
54
54
|
await this.send(session.connection, { type: MessageType.CF_AGENT_USE_CHAT_RESPONSE, id: session.source.requestId, body: JSON.stringify(chunk), done: false, ...(replay ? { replay: true, continuation: false } : {}) });
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
-
|
|
57
|
+
/**
|
|
58
|
+
* A socket just opened: offer the live stream if one exists, otherwise only
|
|
59
|
+
* sync messages. Like Cloudflare's AIChatAgent, an idle connect sends no
|
|
60
|
+
* STREAM_RESUME_NONE. The client answers its own resume probe from the
|
|
61
|
+
* reply that carries its probeId; an unsolicited, probe-less "none" arriving
|
|
62
|
+
* first consumes that pending probe without clearing the client's
|
|
63
|
+
* server-streaming state, which leaves a reconnected Chat stuck streaming
|
|
64
|
+
* after a Turn that ended while it was offline.
|
|
65
|
+
*/
|
|
66
|
+
connect(connection: Connection): Promise<void> {
|
|
67
|
+
return this.resume(connection, undefined, false);
|
|
68
|
+
}
|
|
69
|
+
/** Answer a client's stream-resume request; `announceIdle` is false only for {@link connect}. */
|
|
70
|
+
async resume(connection: Connection, probeId?: string, announceIdle = true): Promise<void> {
|
|
58
71
|
this.disconnect(connection);
|
|
59
72
|
const source = this.active;
|
|
60
|
-
if (!source)
|
|
61
|
-
await this.send(connection, { type: MessageType.CF_AGENT_CHAT_MESSAGES, messages: await this.host.history.browserMessages() });
|
|
62
|
-
await this.send(connection, { type: MessageType.CF_AGENT_STREAM_RESUME_NONE, reason: STREAM_RESUME_NONE_REASONS.IDLE, ...(probeId ? { probeId } : {}) });
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
73
|
+
if (!source) return this.idle(connection, probeId, announceIdle);
|
|
65
74
|
const watch = await source.runtime.watch(BACKGROUND_CONTEXT);
|
|
66
|
-
if (this.active !== source) { await watch.stop(); return this.resume(connection, probeId); }
|
|
75
|
+
if (this.active !== source) { await watch.stop(); return this.resume(connection, probeId, announceIdle); }
|
|
67
76
|
if (!watch.snapshot.run) {
|
|
68
77
|
await watch.stop();
|
|
69
|
-
|
|
70
|
-
await this.send(connection, { type: MessageType.CF_AGENT_STREAM_RESUME_NONE, reason: STREAM_RESUME_NONE_REASONS.IDLE, ...(probeId ? { probeId } : {}) });
|
|
71
|
-
return;
|
|
78
|
+
return this.idle(connection, probeId, announceIdle);
|
|
72
79
|
}
|
|
73
80
|
const messages = await this.host.history.browserSnapshot(watch.snapshot, source.submissionId);
|
|
74
81
|
const replay = this.replayMessage(messages, source.submissionId);
|
|
@@ -77,6 +84,15 @@ export class HarnessConversationBridge {
|
|
|
77
84
|
await this.send(connection, { type: MessageType.CF_AGENT_CHAT_MESSAGES, messages: replay ? messages.filter((message) => message.id !== replay.id) : messages });
|
|
78
85
|
await this.send(connection, { type: MessageType.CF_AGENT_STREAM_RESUMING, id: source.requestId, ...(probeId ? { probeId } : {}) });
|
|
79
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* No Turn is running. "None" goes first: while the client still observes the
|
|
89
|
+
* stream it lost, it keeps its own partial answer over any snapshot that has
|
|
90
|
+
* no more parts, so the settled answer must arrive after it stops observing.
|
|
91
|
+
*/
|
|
92
|
+
private async idle(connection: Connection, probeId: string | undefined, announceIdle: boolean): Promise<void> {
|
|
93
|
+
if (announceIdle) await this.send(connection, { type: MessageType.CF_AGENT_STREAM_RESUME_NONE, reason: STREAM_RESUME_NONE_REASONS.IDLE, ...(probeId ? { probeId } : {}) });
|
|
94
|
+
await this.send(connection, { type: MessageType.CF_AGENT_CHAT_MESSAGES, messages: await this.host.history.browserMessages() });
|
|
95
|
+
}
|
|
80
96
|
async acknowledge(connection: Connection, requestId: string): Promise<void> {
|
|
81
97
|
const session = this.connections.get(connection.id);
|
|
82
98
|
if (!session || session.source.requestId !== requestId || session.started) return;
|
|
@@ -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
|
);
|
|
@@ -595,6 +595,12 @@ export class PiChunkEncoder {
|
|
|
595
595
|
turnDurationMs: Math.max(0, message.timestamp - this.startedAt),
|
|
596
596
|
turnStatus: terminal.turnStatus,
|
|
597
597
|
...(this.turnId ? { turnId: this.turnId } : {}),
|
|
598
|
+
// The browser keeps this stream copy over a history snapshot that
|
|
599
|
+
// arrives while it still observes the stream, so it must carry the
|
|
600
|
+
// user-stop marker the snapshot projects from `turn-aborted`.
|
|
601
|
+
...(terminal.outcome === "aborted" && message.errorMessage === USER_STOP_REASON
|
|
602
|
+
? { interruptedByUser: true }
|
|
603
|
+
: {}),
|
|
598
604
|
...((publicError ?? terminal.message) &&
|
|
599
605
|
(terminal.outcome !== "aborted" ||
|
|
600
606
|
message.errorMessage !== USER_STOP_REASON)
|
|
@@ -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