@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/agent-runtime",
3
- "version": "0.3.0-alpha.17",
3
+ "version": "0.3.0-alpha.19",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -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.resume(connection);
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 that must survive model retries. */
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
- const watch = await runtime.watch(context);
152
- watch.start(async (events) => {
153
- for (const event of events) {
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
- async resume(connection: Connection, probeId?: string): Promise<void> {
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
- await this.send(connection, { type: MessageType.CF_AGENT_CHAT_MESSAGES, messages: await this.host.history.browserMessages() });
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") return this.encoder.encode({ type: "tool_execution_start", toolCallId: block.id, toolName: block.name, args: block.arguments } as LoopEvent);
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
- event(event: AgentEvent): UIMessageChunk[] {
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
- ...(custom ? {} : { model: [message] }),
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.withSession((session) => session.commit(async (tx) => {
423
- const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
424
- (await tx.doc(RuntimeConversation)).id = conversation.id;
425
- }, BACKGROUND_CONTEXT));
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
+ }
@@ -7,3 +7,4 @@ export type {
7
7
  SessionRunRequest,
8
8
  } from "./session-harness";
9
9
  export { prepareSubmissionOperation } from "./submission-operation";
10
+ export { ModelRecovery } from "./governance";