@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.
@@ -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 }) => ({ messages: messages.map((message): Message => {
89
- if (message.role !== "toolResult" || exemptions.has(message.toolName)) return message;
90
- const next = projectToolOutputForModel({ content: message.content, details: message.details }) as Pick<typeof message, "content" | "details">;
91
- return { ...message, ...next };
92
- }) }),
93
- afterResponse: options.afterResponse,
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 receipt = await runtime.submission?.status(hookContext);
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: options.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
- const names = options.toolsToActivate?.(call.name, { content: [...result.content ?? []], details: result.details }, result.isError === true) ?? [];
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: { stream: options.streamOptions, compaction: options.compaction, toolExecution: options.toolExecution, retry: options.retry ?? { maxRetries: 4 } },
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)) ?? [] }, context);
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);
@@ -537,14 +537,15 @@ export function modelRequestUrl(model: Model<Api>): string {
537
537
  */
538
538
  export function withProviderBoundary(
539
539
  streamFn: ProviderBoundStream,
540
- defaultSessionId?: string,
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 ?? defaultSessionId;
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 entries = rows.map(({ body }) => normalizeLegacyAskUserEntry(JSON.parse(body)) as LegacyEntry);
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
- const last = entries.at(-1);
120
- let oldTip: string | null = tipValue ? JSON.parse(tipValue.body) : last?.type === "leaf" ? last.targetId as string | null : last?.id ?? null;
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 result = operationId ? read<{ status?: string }>("pi.result", operationId) : undefined;
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
- const hostStatus = operationId && hasTable(sql, "pi_submissions")
127
- ? sql.exec<{ status: string }>("SELECT status FROM pi_submissions WHERE submission_id=?", operationId).toArray()[0]?.status
128
- : undefined;
129
- const terminal = hostStatus && ["completed", "failed", "aborted", "skipped"].includes(hostStatus);
130
- const active = operationId && result === undefined && !terminal ? operationId : legacyRunning[0]?.submission_id;
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" ? { role: "toolResult" as const, toolCallId: call.toolCallId, toolName: call.toolName, content: [{ type: "text" as const, text: "Interrupted legacy effect; its outcome is unknown. Reconcile it before repeating it." }], isError: true, timestamp: Date.now() } : undefined);
167
- if (!outcome) continue;
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
- const operation = active ? read<{ at: string; batch?: { assistantEntryId: string; turnId: string; calls: Array<{ status: string; sourceIndex: number; resultEntryId: string; replay?: string }> } }>("pi.op.state", active) : undefined;
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 assistant = byId.get(operation.batch!.assistantEntryId)?.message;
175
- if (assistant?.role !== "assistant") throw new Error("Historical Tool batch has no canonical assistant");
176
- for (const call of operation.batch!.calls) {
177
- const tool = assistant.content[call.sourceIndex];
178
- if (tool?.type !== "toolCall") throw new Error("Historical Tool batch index is invalid");
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
- const staged = read<{ type?: string; payload?: Message }>("pi.pending.entry", call.resultEntryId);
181
- let outcome = staged?.payload;
182
- if (call.status === "outcome_ready" && outcome?.role !== "toolResult") throw new Error("Historical Tool outcome is missing");
183
- if (call.status === "effect_pending" && call.replay !== "safe") {
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
- if (outcome.role !== "toolResult" || outcome.toolCallId !== tool.id || outcome.toolName !== tool.name) throw new Error("Historical Tool outcome conflicts with its call");
188
- const entry: LegacyEntry = { id: call.resultEntryId, parentId: oldTip, type: "message", message: outcome, timestamp: outcome.timestamp };
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
- } else if (active && operation && !["starting", "checkpoint", "assistant.ready", "assistant.effect_pending", "assistant.retry_wait"].includes(operation.at)) {
193
- throw new Error(`Historical operation ${operation.at} needs an explicit migration before it can run`);
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 ? converted.get(entry.parentId) : undefined;
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" ? {} : { model: [message] }),
222
- data: { displayId: entry.id, createdAt: timestamp(entry), ...(role === "custom" ? { message: message as unknown as Record<string, never> } : {}), ...(role === "toolResult" ? { diagnostics: [] } : {}) },
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 = converted.get(oldTip);
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
- if (active) {
275
- const chain: LegacyEntry[] = [];
276
- for (let id = oldTip; id; id = byId.get(id)?.parentId ?? null) {
277
- const entry = byId.get(id);
278
- if (!entry) throw new Error("Active historical operation has an invalid branch");
279
- chain.unshift(entry);
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
- const owned = chain.filter((entry) => owners.get(entry.id) === active);
282
- const prompt = owned.find((entry) => entry.message?.role === "user");
283
- if (!prompt) throw new Error("Active historical operation has no canonical user input");
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
+ }