@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
|
@@ -13,10 +13,19 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
|
13
13
|
import type { Api, Message, Model, Models, ModelThinkingLevel, UserMessage } from "@earendil-works/pi-ai";
|
|
14
14
|
import { RuntimeConversation } from "../session";
|
|
15
15
|
import { projectToolOutputForModel } from "../context/memory/budget/gate";
|
|
16
|
+
import type { ArtifactRef } from "../lib/artifacts";
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_MAX_WRAP_UP_MODEL_TURNS, TOOL_FAILURE_WRAP_UP_INSTRUCTION, ModelRecovery, ToolGovernance,
|
|
19
|
+
artifactManifestMessage, artifactRefFromMessage,
|
|
20
|
+
} from "./governance";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_WRAP_UP_INSTRUCTION = "Finish this request now without using tools. Summarize the completed work and any remaining limitation.";
|
|
16
23
|
|
|
17
24
|
export interface SessionHarnessLimits {
|
|
18
25
|
modelTurnsPerSlice?: number;
|
|
19
26
|
maxModelTurns?: number;
|
|
27
|
+
/** Text turns allowed after the working or Tool failure budget before the Submission is stopped. */
|
|
28
|
+
maxWrapUpModelTurns?: number;
|
|
20
29
|
wrapUpInstruction?: string;
|
|
21
30
|
}
|
|
22
31
|
export interface SessionHarnessOptions {
|
|
@@ -35,6 +44,8 @@ export interface SessionHarnessOptions {
|
|
|
35
44
|
retry?: Partial<ConversationRetryPolicy>;
|
|
36
45
|
toolExecution?: "parallel" | "sequential";
|
|
37
46
|
limits?: SessionHarnessLimits;
|
|
47
|
+
/** Per-Submission provider error classification and provider Session identity. */
|
|
48
|
+
recovery?: ModelRecovery;
|
|
38
49
|
beforeTool?: ToolHooks["beforeTool"];
|
|
39
50
|
afterResponse?: GenerationHooks["afterResponse"];
|
|
40
51
|
}
|
|
@@ -63,6 +74,11 @@ export class SessionHarness {
|
|
|
63
74
|
private requestId?: string;
|
|
64
75
|
private release!: (reason: "approval" | "interaction" | "slice") => void;
|
|
65
76
|
private turnsAtOpen = 0;
|
|
77
|
+
private governance = new ToolGovernance();
|
|
78
|
+
private readonly artifactRefs = new Map<string, ArtifactRef>();
|
|
79
|
+
private wrapUpStarted = false;
|
|
80
|
+
/** Completed model turns of the current Submission. */
|
|
81
|
+
private ownedTurns = 0;
|
|
66
82
|
private readonly released = new Promise<"approval" | "interaction" | "slice">((resolve) => { this.release = resolve; });
|
|
67
83
|
private constructor(
|
|
68
84
|
readonly harness: NativeHarness,
|
|
@@ -85,26 +101,59 @@ export class SessionHarness {
|
|
|
85
101
|
sections: [section("system", () => options.systemPrompt, { tag: false })],
|
|
86
102
|
hooks: [
|
|
87
103
|
hook(GenerationTask, {
|
|
88
|
-
beforeRequest: ({ messages }) =>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
104
|
+
beforeRequest: ({ messages }) => {
|
|
105
|
+
const projected = messages.map((message): Message => {
|
|
106
|
+
if (message.role !== "toolResult" || exemptions.has(message.toolName)) return message;
|
|
107
|
+
const next = projectToolOutputForModel({ content: message.content, details: message.details }) as Pick<typeof message, "content" | "details">;
|
|
108
|
+
return { ...message, ...next };
|
|
109
|
+
});
|
|
110
|
+
const manifest = artifactManifestMessage(runtime.artifactRefs);
|
|
111
|
+
const instruction = runtime.governance.wrapUp
|
|
112
|
+
? TOOL_FAILURE_WRAP_UP_INSTRUCTION
|
|
113
|
+
: runtime.wrapUpStarted ? options.limits?.wrapUpInstruction ?? DEFAULT_WRAP_UP_INSTRUCTION : undefined;
|
|
114
|
+
return { messages: [
|
|
115
|
+
...projected,
|
|
116
|
+
...(manifest ? [manifest] : []),
|
|
117
|
+
...(instruction ? [{ role: "user" as const, content: instruction, timestamp: 1 }] : []),
|
|
118
|
+
] };
|
|
119
|
+
},
|
|
120
|
+
afterResponse: async (message, api, hookContext) => {
|
|
121
|
+
options.recovery?.classify(message);
|
|
122
|
+
await options.afterResponse?.(message, api, hookContext);
|
|
123
|
+
},
|
|
94
124
|
afterTools: async (_assistant, _results, _api, hookContext) => {
|
|
95
125
|
const entries = await runtime.entries(hookContext);
|
|
96
|
-
const
|
|
97
|
-
const owned = receipt?.entry ? entries.filter((entry) => entry.id >= receipt.entry!) : entries;
|
|
98
|
-
const turns = owned.filter((entry) => entry.kind === "pi.assistant").length;
|
|
126
|
+
const turns = runtime.ownedTurns = (await runtime.ownedEntries(entries, hookContext)).filter((entry) => entry.kind === "pi.assistant").length;
|
|
99
127
|
const total = entries.filter((entry) => entry.kind === "pi.assistant").length;
|
|
128
|
+
const maxTurns = options.limits?.maxModelTurns ?? 300;
|
|
129
|
+
const wrapUpAt = Math.min(maxTurns, runtime.governance.wrapUpAt ?? Infinity);
|
|
130
|
+
if (turns >= wrapUpAt + (options.limits?.maxWrapUpModelTurns ?? DEFAULT_MAX_WRAP_UP_MODEL_TURNS)) {
|
|
131
|
+
// The wrap-up is still calling Tools it no longer has; end the Submission instead of looping.
|
|
132
|
+
void runtime.conversation.abort(BACKGROUND_CONTEXT).catch(() => {});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (turns >= wrapUpAt && !runtime.wrapUpStarted) {
|
|
136
|
+
runtime.wrapUpStarted = true;
|
|
137
|
+
// Tools return on the next Submission's configure; the instruction is per request, never stored.
|
|
138
|
+
await runtime.conversation.configure({ tools: [] }, hookContext);
|
|
139
|
+
}
|
|
100
140
|
if (total - runtime.turnsAtOpen >= (options.limits?.modelTurnsPerSlice ?? 30)) runtime.release("slice");
|
|
101
|
-
if (turns >= (options.limits?.maxModelTurns ?? 300)) await runtime.conversation.configure({ tools: [], instructions: options.limits?.wrapUpInstruction ?? "Finish this request now without using tools. Summarize the completed work and any remaining limitation." }, hookContext);
|
|
102
141
|
},
|
|
103
142
|
}),
|
|
104
143
|
hook(ToolTask, {
|
|
105
|
-
beforeTool:
|
|
144
|
+
beforeTool: async (call, api, hookContext) => {
|
|
145
|
+
// Calls belong to the assistant turn after the last completed Tool round.
|
|
146
|
+
const block = runtime.governance.admit(call, runtime.ownedTurns + 1);
|
|
147
|
+
if (block !== undefined) return { block };
|
|
148
|
+
return options.beforeTool?.(call, api, hookContext);
|
|
149
|
+
},
|
|
106
150
|
afterTool: async (call, result) => {
|
|
107
|
-
|
|
151
|
+
runtime.governance.record(call, result.isError === true, runtime.ownedTurns + 1);
|
|
152
|
+
const ref = artifactRefFromMessage({ role: "toolResult", details: result.details });
|
|
153
|
+
if (ref) runtime.artifactRefs.set(ref.artifactId, ref);
|
|
154
|
+
const compiled = new Set(options.tools?.map((tool) => tool.name) ?? []);
|
|
155
|
+
const names = (options.toolsToActivate?.(call.name, { content: [...result.content ?? []], details: result.details }, result.isError === true) ?? [])
|
|
156
|
+
.filter((name) => compiled.has(name));
|
|
108
157
|
return names.length ? { ...result, control: { ...result.control, addTools: names } } : undefined;
|
|
109
158
|
},
|
|
110
159
|
}),
|
|
@@ -113,7 +162,14 @@ export class SessionHarness {
|
|
|
113
162
|
registry.install(extension);
|
|
114
163
|
const harness = await Harness.open(storage, {
|
|
115
164
|
models: options.models, registry,
|
|
116
|
-
settings: {
|
|
165
|
+
settings: {
|
|
166
|
+
stream: options.streamOptions, compaction: options.compaction, toolExecution: options.toolExecution,
|
|
167
|
+
// Read for every retry decision, after afterResponse has classified that response.
|
|
168
|
+
get retry() {
|
|
169
|
+
const retry = { maxRetries: 4, ...options.retry };
|
|
170
|
+
return options.recovery ? { ...retry, enabled: (retry.enabled ?? true) && options.recovery.retryAllowed } : retry;
|
|
171
|
+
},
|
|
172
|
+
},
|
|
117
173
|
}, context);
|
|
118
174
|
const pointer = await harness.snapshot(RuntimeConversation, context);
|
|
119
175
|
const conversation = pointer?.id ? await harness.conversation(pointer.id, context) : await harness.root(context);
|
|
@@ -127,6 +183,7 @@ export class SessionHarness {
|
|
|
127
183
|
runtime.submission = await harness.submission(unfinished[0].id, context);
|
|
128
184
|
runtime.requestId = unfinished[0].requestId;
|
|
129
185
|
}
|
|
186
|
+
await runtime.restoreGovernance(context);
|
|
130
187
|
return runtime;
|
|
131
188
|
}
|
|
132
189
|
|
|
@@ -142,6 +199,29 @@ export class SessionHarness {
|
|
|
142
199
|
} while (cursor);
|
|
143
200
|
return entries;
|
|
144
201
|
}
|
|
202
|
+
/** Entries of the active Submission, from its input onward. */
|
|
203
|
+
async ownedEntries(entries: readonly EntryRecord[], context: Context): Promise<EntryRecord[]> {
|
|
204
|
+
const receipt = await this.submission?.status(context);
|
|
205
|
+
return receipt?.entry ? entries.filter((entry) => entry.id >= receipt.entry!) : [...entries];
|
|
206
|
+
}
|
|
207
|
+
private async restoreGovernance(context: Context): Promise<void> {
|
|
208
|
+
const entries = await this.entries(context);
|
|
209
|
+
this.artifactRefs.clear();
|
|
210
|
+
for (const entry of entries) {
|
|
211
|
+
const message = entry.model?.[0];
|
|
212
|
+
const ref = message ? artifactRefFromMessage(message) : undefined;
|
|
213
|
+
if (ref) this.artifactRefs.set(ref.artifactId, ref);
|
|
214
|
+
}
|
|
215
|
+
this.governance = new ToolGovernance();
|
|
216
|
+
this.wrapUpStarted = false;
|
|
217
|
+
this.ownedTurns = 0;
|
|
218
|
+
if (!this.submission) return;
|
|
219
|
+
const owned = await this.ownedEntries(entries, context);
|
|
220
|
+
this.governance.restore(owned);
|
|
221
|
+
this.ownedTurns = owned.filter((entry) => entry.kind === "pi.assistant").length;
|
|
222
|
+
const wrapUpAt = Math.min(this.options.limits?.maxModelTurns ?? 300, this.governance.wrapUpAt ?? Infinity);
|
|
223
|
+
this.wrapUpStarted = this.ownedTurns >= wrapUpAt;
|
|
224
|
+
}
|
|
145
225
|
watch(context: Context): Promise<AgentEventStream> { return watchEvents(this.harness, this.conversation.id, context); }
|
|
146
226
|
requestHumanRelease(reason: "approval" | "interaction"): void { this.release(reason); }
|
|
147
227
|
async fork(at: EntryId | null, context: Context): Promise<void> {
|
|
@@ -167,10 +247,13 @@ export class SessionHarness {
|
|
|
167
247
|
const names = new Set(this.options.activeToolNames ?? this.options.tools?.map((tool) => tool.name) ?? []);
|
|
168
248
|
const previous = await this.harness.snapshot(AgentDoc, this.conversation.id, context);
|
|
169
249
|
if (Array.isArray(previous?.tools)) for (const name of previous.tools) if (this.options.onDemandToolNames?.includes(name)) names.add(name);
|
|
170
|
-
await this.conversation.configure({ model: { provider: this.options.model.provider, modelId: this.options.model.id }, thinkingLevel: this.options.thinkingLevel ?? "off", tools: this.options.tools?.filter((tool) => names.has(tool.name)) ?? []
|
|
250
|
+
await this.conversation.configure({ model: { provider: this.options.model.provider, modelId: this.options.model.id }, thinkingLevel: this.options.thinkingLevel ?? "off", tools: this.options.tools?.filter((tool) => names.has(tool.name)) ?? [],
|
|
251
|
+
// Earlier runtimes stored the wrap-up instruction in the agent; it applies to one Submission only.
|
|
252
|
+
instructions: null }, context);
|
|
171
253
|
this.requestId = request.operationId;
|
|
172
254
|
this.submission = await this.conversation.submit({ type: "input", requestId: request.operationId, content: typeof request.prompt === "string" ? request.prompt : request.prompt.content, whenBusy: "reject" }, context);
|
|
173
255
|
}
|
|
256
|
+
await this.restoreGovernance(context);
|
|
174
257
|
}
|
|
175
258
|
async run(request: SessionRunRequest | undefined, context: Context): Promise<SessionHarnessRunResult> {
|
|
176
259
|
if (request) await this.admit(request, context);
|
package/src/models/index.ts
CHANGED
|
@@ -537,14 +537,15 @@ export function modelRequestUrl(model: Model<Api>): string {
|
|
|
537
537
|
*/
|
|
538
538
|
export function withProviderBoundary(
|
|
539
539
|
streamFn: ProviderBoundStream,
|
|
540
|
-
|
|
540
|
+
/** Host-owned provider Session; it overrides the durable Harness's per-conversation identity. */
|
|
541
|
+
hostSessionId?: string | (() => string),
|
|
541
542
|
onGeneration?: PiGenerationLifecycleObserver,
|
|
542
543
|
): ProviderBoundStream {
|
|
543
544
|
return (model, context, options) =>
|
|
544
545
|
lazyStream(model, async () => {
|
|
545
546
|
const requestId = crypto.randomUUID();
|
|
546
547
|
const startedAt = Date.now();
|
|
547
|
-
const sessionId = options?.sessionId
|
|
548
|
+
const sessionId = (typeof hostSessionId === "function" ? hostSessionId() : hostSessionId) ?? options?.sessionId;
|
|
548
549
|
const url = modelRequestUrl(model);
|
|
549
550
|
const systemPrompt = "systemPrompt" in context && typeof context.systemPrompt === "string"
|
|
550
551
|
? context.systemPrompt
|
|
@@ -910,6 +911,11 @@ function configuredModel(
|
|
|
910
911
|
...(endpoint.protocol === "openrouter-messages"
|
|
911
912
|
? {
|
|
912
913
|
...anthropicMessagesCompat,
|
|
914
|
+
// The direct Anthropic catalog enables positional system and Tool updates, but
|
|
915
|
+
// OpenRouter Messages rejects `tool_addition` definitions. Fold them into the
|
|
916
|
+
// leading system and top-level tools so deferred Tool decoration still applies.
|
|
917
|
+
supportsMidConvoSystemMessages: false,
|
|
918
|
+
supportsMidConvoToolChanges: false,
|
|
913
919
|
supportsMidConvoEffort: false,
|
|
914
920
|
supportsEagerToolInputStreaming: false,
|
|
915
921
|
supportsStrictTools:
|
|
@@ -922,6 +928,9 @@ function configuredModel(
|
|
|
922
928
|
: endpoint.protocol === "openrouter-chat"
|
|
923
929
|
? {
|
|
924
930
|
...catalogCompat,
|
|
931
|
+
// OpenRouter Chat has no verified positional system or Tool update support.
|
|
932
|
+
supportsMidConvoSystemMessages: false,
|
|
933
|
+
supportsMidConvoToolChanges: false,
|
|
925
934
|
thinkingFormat: "openrouter" as const,
|
|
926
935
|
maxTokensField: "max_tokens" as const,
|
|
927
936
|
supportsStore: false,
|
|
@@ -92,6 +92,89 @@ function normalizeLegacyAskUserEntry(value: unknown): unknown {
|
|
|
92
92
|
return value;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
const TERMINAL_HOST_STATUSES = new Set(["completed", "failed", "aborted", "skipped", "error"]);
|
|
96
|
+
/** Legacy run phases whose state maps onto a native generation checkpoint without guessing. */
|
|
97
|
+
const RESUMABLE_PHASES = new Set(["starting", "checkpoint", "assistant.ready", "assistant.effect_pending", "assistant.retry_wait"]);
|
|
98
|
+
const MESSAGE_ROLES = new Set(["user", "assistant", "toolResult", "custom", "bashExecution", "branchSummary", "compactionSummary"]);
|
|
99
|
+
const UNKNOWN_EFFECT = "Tool execution was interrupted and its external outcome is unknown. Do not repeat the effect without reconciling it.";
|
|
100
|
+
const INTERRUPTED = "This request was interrupted by a runtime upgrade and did not finish. Send it again to continue.";
|
|
101
|
+
|
|
102
|
+
type LegacyResult = { status?: string; error?: { code?: string; message?: string } };
|
|
103
|
+
type ToolBatch = { assistantEntryId: string; turnId: string; calls: Array<{ status: string; sourceIndex: number; resultEntryId: string; replay?: string }> };
|
|
104
|
+
|
|
105
|
+
function warn(message: string): void {
|
|
106
|
+
console.warn("[pi-session-migration]", message);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Custom messages reach the model as user context, matching `ConversationHistory.append` (e.g. turn-aborted markers). */
|
|
110
|
+
function customModel(message: Message, createdAt: number): { model?: Message[] } {
|
|
111
|
+
const content = (message as unknown as { content?: unknown }).content;
|
|
112
|
+
if (typeof content === "string") return { model: [{ role: "user", content, timestamp: createdAt }] };
|
|
113
|
+
if (!Array.isArray(content) || !content.length) return {};
|
|
114
|
+
return { model: [{ role: "user", content: [...content] as Extract<Message, { role: "user" }>["content"], timestamp: createdAt }] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function unknownOutcome(call: { id: string; name: string }, text = UNKNOWN_EFFECT): Message {
|
|
118
|
+
return { role: "toolResult", toolCallId: call.id, toolName: call.name, content: [{ type: "text", text }], isError: true, timestamp: Date.now() };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Normalizes historical records onto the native shapes and drops records that can never be valid model context.
|
|
123
|
+
* Dropped and branch-pointer records resolve to their nearest kept ancestor, so descendants stay on their branch.
|
|
124
|
+
*/
|
|
125
|
+
function repairLegacyEntries(raw: LegacyEntry[]): { entries: LegacyEntry[]; resolve: (id: string | null) => string | null } {
|
|
126
|
+
const kept = new Map<string, LegacyEntry>();
|
|
127
|
+
const alias = new Map<string, string | null>();
|
|
128
|
+
const resolve = (id: string | null): string | null => {
|
|
129
|
+
for (let seen = 0; id !== null && !kept.has(id) && alias.has(id); seen++) {
|
|
130
|
+
if (seen > alias.size) throw new Error(`Cyclic historical Pi reference: ${id}`);
|
|
131
|
+
id = alias.get(id)!;
|
|
132
|
+
}
|
|
133
|
+
return id;
|
|
134
|
+
};
|
|
135
|
+
const entries: LegacyEntry[] = [];
|
|
136
|
+
for (const source of raw) {
|
|
137
|
+
const parentId = resolve(source.parentId);
|
|
138
|
+
const skip = (reason: string) => {
|
|
139
|
+
warn(`Skipped historical entry ${source.id}: ${reason}`);
|
|
140
|
+
alias.set(source.id, parentId);
|
|
141
|
+
};
|
|
142
|
+
if (source.type === "leaf") {
|
|
143
|
+
alias.set(source.id, resolve(typeof source.targetId === "string" ? source.targetId : source.targetId === null ? null : parentId));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
let entry: LegacyEntry = { ...source, parentId };
|
|
147
|
+
if (source.type === "custom_message") {
|
|
148
|
+
if (typeof source.customType !== "string") { skip("custom message has no type"); continue; }
|
|
149
|
+
entry = {
|
|
150
|
+
id: source.id, parentId, type: "message", timestamp: source.timestamp,
|
|
151
|
+
message: { role: "custom", customType: source.customType, content: source.content, details: source.details, display: source.display, timestamp: timestamp(source) } as unknown as Message,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (entry.type === "message") {
|
|
155
|
+
const message = entry.message as unknown as Record<string, unknown> | undefined;
|
|
156
|
+
if (!message || typeof message !== "object" || !MESSAGE_ROLES.has(message.role as string)) { skip("unknown message"); continue; }
|
|
157
|
+
if (message.role === "assistant" && message.stopReason === "pending") { skip("unsettled assistant"); continue; }
|
|
158
|
+
if (message.role === "toolResult") {
|
|
159
|
+
let call: { name: string } | undefined;
|
|
160
|
+
let duplicate = false;
|
|
161
|
+
for (let cursor = parentId; cursor && !call && !duplicate; cursor = kept.get(cursor)?.parentId ?? null) {
|
|
162
|
+
const ancestor = kept.get(cursor)?.message;
|
|
163
|
+
if (ancestor?.role === "toolResult" && ancestor.toolCallId === message.toolCallId) duplicate = true;
|
|
164
|
+
if (ancestor?.role === "assistant") {
|
|
165
|
+
call = ancestor.content.find((part) => part.type === "toolCall" && part.id === message.toolCallId) as { name: string } | undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (duplicate) { skip(`duplicate Tool result ${String(message.toolCallId)}`); continue; }
|
|
169
|
+
if (!call || call.name !== message.toolName) { skip(`orphan Tool result ${String(message.toolCallId)}`); continue; }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
kept.set(entry.id, entry);
|
|
173
|
+
entries.push(entry);
|
|
174
|
+
}
|
|
175
|
+
return { entries, resolve };
|
|
176
|
+
}
|
|
177
|
+
|
|
95
178
|
/** Converts records once; historical tables are retained until the upgrade is accepted. */
|
|
96
179
|
export async function migratePiSession(storage: DurableObjectStorage, session: Session): Promise<void> {
|
|
97
180
|
if ((await session.snapshot(Migration, BACKGROUND_CONTEXT))?.completed) return;
|
|
@@ -105,9 +188,11 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
105
188
|
const rows = current || older
|
|
106
189
|
? sql.exec<{ body: string }>(`SELECT body FROM ${current ? "pi_entries" : "pi_session_entries"} ORDER BY seq`).toArray()
|
|
107
190
|
: [];
|
|
108
|
-
const
|
|
191
|
+
const raw = rows.map(({ body }) => normalizeLegacyAskUserEntry(JSON.parse(body)) as LegacyEntry);
|
|
192
|
+
if (new Set(raw.map((entry) => entry.id)).size !== raw.length) throw new Error("Historical Pi entry IDs are not unique");
|
|
193
|
+
const repaired = repairLegacyEntries(raw);
|
|
194
|
+
const entries = repaired.entries;
|
|
109
195
|
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
110
|
-
if (byId.size !== entries.length) throw new Error("Historical Pi entry IDs are not unique");
|
|
111
196
|
const values = current && hasTable(sql, "pi_values")
|
|
112
197
|
? sql.exec<{ namespace: string; key: string; body: string }>("SELECT namespace,key,body FROM pi_values").toArray()
|
|
113
198
|
: [];
|
|
@@ -116,18 +201,25 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
116
201
|
return row ? JSON.parse(row.body) as T : undefined;
|
|
117
202
|
};
|
|
118
203
|
const tipValue = values.find((row) => row.namespace === "pi.branch.tip" && row.key === "main");
|
|
119
|
-
|
|
120
|
-
|
|
204
|
+
let oldTip: string | null = repaired.resolve(tipValue ? JSON.parse(tipValue.body) as string | null : raw.at(-1)?.id ?? null);
|
|
205
|
+
const hostRows = hasTable(sql, "pi_submissions")
|
|
206
|
+
? sql.exec<{ submission_id: string; status: string }>("SELECT submission_id,status FROM pi_submissions").toArray()
|
|
207
|
+
: [];
|
|
208
|
+
const hostStatus = new Map(hostRows.map((row) => [row.submission_id, row.status]));
|
|
121
209
|
const lane = read<{ currentOperationId?: string | null }>("pi.lane.state", "main");
|
|
122
210
|
const operationId = lane?.currentOperationId;
|
|
123
|
-
const
|
|
124
|
-
const legacyRunning = older && hasTable(sql, "pi_submissions") ? sql.exec<{ submission_id: string }>("SELECT submission_id FROM pi_submissions WHERE status='running'").toArray() : [];
|
|
211
|
+
const legacyRunning = older ? hostRows.filter((row) => row.status === "running") : [];
|
|
125
212
|
if (legacyRunning.length > 1) throw new Error("Multiple legacy operations are active in one conversation");
|
|
126
|
-
|
|
127
|
-
?
|
|
128
|
-
:
|
|
129
|
-
|
|
130
|
-
|
|
213
|
+
let active: string | undefined = operationId && read<LegacyResult>("pi.result", operationId) === undefined && !TERMINAL_HOST_STATUSES.has(hostStatus.get(operationId) ?? "")
|
|
214
|
+
? operationId
|
|
215
|
+
: legacyRunning[0]?.submission_id;
|
|
216
|
+
// Navigation and standalone compaction never committed their target. The Host re-admits the owning
|
|
217
|
+
// Submission from its queued input, so the native runtime performs the whole step itself.
|
|
218
|
+
const intent = active ? read<{ intent?: { kind?: string } }>("pi.op.meta", active)?.intent?.kind ?? (active.endsWith(":regenerate") ? "navigation" : "run") : undefined;
|
|
219
|
+
if (active && intent !== "run") {
|
|
220
|
+
warn(`Dropped unfinished legacy ${intent} operation ${active}`);
|
|
221
|
+
active = undefined;
|
|
222
|
+
}
|
|
131
223
|
const links = hasTable(sql, "pi_session_entry_submission")
|
|
132
224
|
? sql.exec<{ entry_id: string; submission_id: string }>("SELECT entry_id,submission_id FROM pi_session_entry_submission").toArray()
|
|
133
225
|
: hasTable(sql, "pi_session_message_submission") ? sql.exec<{ entry_id: string; submission_id: string }>("SELECT message_id AS entry_id,submission_id FROM pi_session_message_submission").toArray() : [];
|
|
@@ -156,6 +248,10 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
156
248
|
}
|
|
157
249
|
}
|
|
158
250
|
}
|
|
251
|
+
const commitResult = (id: string, outcome: Message) => {
|
|
252
|
+
const entry: LegacyEntry = { id, parentId: oldTip, type: "message", message: outcome, timestamp: outcome.timestamp };
|
|
253
|
+
entries.push(entry); byId.set(entry.id, entry); owners.set(entry.id, active!); oldTip = entry.id;
|
|
254
|
+
};
|
|
159
255
|
if (older && active && hasTable(sql, "pi_recovery_milestones")) {
|
|
160
256
|
const milestones = sql.exec<{ body: string }>("SELECT body FROM pi_recovery_milestones WHERE submission_id=? ORDER BY seq", active).toArray();
|
|
161
257
|
const recovered = replayPiToolRecovery(milestones.map((row) => row.body));
|
|
@@ -163,36 +259,68 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
163
259
|
for (const call of Object.values(recovered.toolCalls)) {
|
|
164
260
|
if (existing.has(call.toolCallId)) continue;
|
|
165
261
|
const pending = Object.values(recovered.approvals).some((row) => row.toolCallId === call.toolCallId && row.status === "pending") || Object.values(recovered.interactions).some((row) => row.toolCallId === call.toolCallId && row.status === "pending");
|
|
166
|
-
const outcome = call.result ?? (!pending && call.retry === "non-idempotent" ? {
|
|
167
|
-
if (
|
|
168
|
-
const entry: LegacyEntry = { id: `legacy-result:${active}:${call.toolCallId}`, parentId: oldTip, type: "message", message: outcome, timestamp: outcome.timestamp };
|
|
169
|
-
entries.push(entry); byId.set(entry.id, entry); owners.set(entry.id, active); oldTip = entry.id;
|
|
262
|
+
const outcome = call.result ?? (!pending && call.retry === "non-idempotent" ? unknownOutcome({ id: call.toolCallId, name: call.toolName }, "Interrupted legacy effect; its outcome is unknown. Reconcile it before repeating it.") : undefined);
|
|
263
|
+
if (outcome) commitResult(`legacy-result:${active}:${call.toolCallId}`, outcome);
|
|
170
264
|
}
|
|
171
265
|
}
|
|
172
|
-
|
|
266
|
+
// An operation that cannot be resumed is settled as interrupted. It never replays an uncertain effect.
|
|
267
|
+
let interrupted: string | undefined;
|
|
268
|
+
const operation = active ? read<{ at: string; batch?: ToolBatch }>("pi.op.state", active) : undefined;
|
|
173
269
|
if (active && operation?.at === "tools") {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
270
|
+
const message = operation.batch ? byId.get(operation.batch.assistantEntryId)?.message : undefined;
|
|
271
|
+
const assistant = message?.role === "assistant" ? message : undefined;
|
|
272
|
+
if (!assistant) interrupted = "Historical Tool batch has no canonical assistant";
|
|
273
|
+
for (const call of assistant ? operation.batch!.calls : []) {
|
|
274
|
+
const part = assistant!.content[call.sourceIndex];
|
|
275
|
+
if (typeof part !== "object" || part?.type !== "toolCall") { interrupted = "Historical Tool batch index is invalid"; continue; }
|
|
276
|
+
const tool = part;
|
|
179
277
|
if (call.status === "completed") continue;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
outcome = { role: "toolResult", toolCallId: tool.id, toolName: tool.name, content: [{ type: "text", text: "Tool execution was interrupted and its external outcome is unknown. Do not repeat the effect without reconciling it." }], isError: true, timestamp: Date.now() };
|
|
278
|
+
let outcome = read<{ type?: string; payload?: Message }>("pi.pending.entry", call.resultEntryId)?.payload;
|
|
279
|
+
if (outcome && (outcome.role !== "toolResult" || outcome.toolCallId !== tool.id || outcome.toolName !== tool.name)) {
|
|
280
|
+
warn(`Historical Tool outcome ${call.resultEntryId} conflicts with its call`);
|
|
281
|
+
outcome = unknownOutcome(tool);
|
|
185
282
|
}
|
|
186
|
-
if (outcome) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
entries.push(entry); byId.set(entry.id, entry); owners.set(entry.id, active); oldTip = entry.id;
|
|
283
|
+
if (call.status === "outcome_ready" && outcome?.role !== "toolResult") {
|
|
284
|
+
warn(`Historical Tool outcome ${call.resultEntryId} is missing`);
|
|
285
|
+
outcome = unknownOutcome(tool);
|
|
190
286
|
}
|
|
287
|
+
if (call.status === "effect_pending" && call.replay !== "safe") outcome = unknownOutcome(tool);
|
|
288
|
+
if (outcome) commitResult(call.resultEntryId, outcome);
|
|
289
|
+
}
|
|
290
|
+
} else if (active && operation && !RESUMABLE_PHASES.has(operation.at)) {
|
|
291
|
+
interrupted = `Historical operation ${operation.at} cannot resume natively`;
|
|
292
|
+
}
|
|
293
|
+
const branch = (tip: string | null): LegacyEntry[] => {
|
|
294
|
+
const chain: LegacyEntry[] = [];
|
|
295
|
+
for (let id = tip; id; id = byId.get(id)?.parentId ?? null) {
|
|
296
|
+
const entry = byId.get(id);
|
|
297
|
+
if (!entry) throw new Error("Historical branch is invalid");
|
|
298
|
+
chain.unshift(entry);
|
|
191
299
|
}
|
|
192
|
-
|
|
193
|
-
|
|
300
|
+
return chain;
|
|
301
|
+
};
|
|
302
|
+
let owned = active ? branch(oldTip).filter((entry) => owners.get(entry.id) === active) : [];
|
|
303
|
+
let prompt = owned.find((entry) => entry.message?.role === "user");
|
|
304
|
+
if (active && !prompt) interrupted ??= "Active historical operation has no canonical user input";
|
|
305
|
+
if (active && interrupted) {
|
|
306
|
+
warn(`Settled legacy operation ${active} as interrupted: ${interrupted}`);
|
|
307
|
+
const assistant = [...owned].reverse().find((entry) => entry.message?.role === "assistant")?.message;
|
|
308
|
+
const settled = new Set(owned.flatMap((entry) => entry.message?.role === "toolResult" ? [entry.message.toolCallId] : []));
|
|
309
|
+
for (const part of assistant?.role === "assistant" ? assistant.content : []) {
|
|
310
|
+
if (part.type === "toolCall" && !settled.has(part.id)) commitResult(`legacy-interrupted:${active}:${part.id}`, unknownOutcome(part));
|
|
311
|
+
}
|
|
312
|
+
owned = branch(oldTip).filter((entry) => owners.get(entry.id) === active);
|
|
313
|
+
prompt = owned.find((entry) => entry.message?.role === "user");
|
|
194
314
|
}
|
|
315
|
+
// Pi finished these operations before the Host recorded their terminal; settle them instead of rerunning input.
|
|
316
|
+
const finished = hostRows.flatMap((row) => {
|
|
317
|
+
if (row.submission_id === active || !["pending", "running"].includes(row.status)) return [];
|
|
318
|
+
const result = read<LegacyResult>("pi.result", row.submission_id);
|
|
319
|
+
return result ? [{ id: row.submission_id, result }] : [];
|
|
320
|
+
});
|
|
195
321
|
const converted = new Map<string, EntryRecord>();
|
|
322
|
+
// Where a child continues; a compaction's retained context belongs to every branch through it.
|
|
323
|
+
const anchors = new Map<string, EntryRecord>();
|
|
196
324
|
const conversationByEntry = new Map<string, ConversationId>();
|
|
197
325
|
const tips = new Map<ConversationId, string>();
|
|
198
326
|
const copiedUi: Array<{ id: string; body: string }> = [];
|
|
@@ -206,7 +334,7 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
206
334
|
if (!entry.id || (entry.parentId !== null && !byId.has(entry.parentId))) {
|
|
207
335
|
throw new Error(`Historical Pi entry has a missing parent: ${entry.id}`);
|
|
208
336
|
}
|
|
209
|
-
const parent = entry.parentId ?
|
|
337
|
+
const parent = entry.parentId ? anchors.get(entry.parentId) : undefined;
|
|
210
338
|
if (entry.parentId && !parent) throw new Error(`Historical Pi entry is out of order: ${entry.id}`);
|
|
211
339
|
let conversationId = parent ? conversationByEntry.get(entry.parentId!)! : initial.id;
|
|
212
340
|
if (parent && tips.get(conversationId) !== entry.parentId) {
|
|
@@ -214,15 +342,16 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
214
342
|
}
|
|
215
343
|
const message = entry.message;
|
|
216
344
|
let record: EntryRecord;
|
|
345
|
+
let anchor: EntryRecord;
|
|
217
346
|
if (entry.type === "message" && message) {
|
|
218
347
|
const role = (message as { role: string }).role;
|
|
219
|
-
record = await tx.appendEntry(conversationId, {
|
|
348
|
+
record = anchor = await tx.appendEntry(conversationId, {
|
|
220
349
|
kind: role === "toolResult" ? "pi.tool-result" : role === "custom" ? "springbrand.custom" : `pi.${role}`,
|
|
221
|
-
...(role === "custom" ?
|
|
222
|
-
data: { displayId: entry.id, createdAt: timestamp(entry), ...(role === "custom" ? { message
|
|
350
|
+
...(role === "custom" ? customModel(message, timestamp(entry)) : { model: [message] }),
|
|
351
|
+
data: JSON.parse(JSON.stringify({ displayId: entry.id, createdAt: timestamp(entry), ...(role === "custom" ? { message } : {}), ...(role === "toolResult" ? { diagnostics: [] } : {}) })),
|
|
223
352
|
});
|
|
224
353
|
} else if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
225
|
-
record = await tx.appendEntry(conversationId, {
|
|
354
|
+
record = anchor = await tx.appendEntry(conversationId, {
|
|
226
355
|
kind: "pi.compaction", model: [{ role: "user", content: `<summary>\n${entry.summary ?? ""}\n</summary>`, timestamp: timestamp(entry) }],
|
|
227
356
|
head: "self", data: { reason: "manual", displayId: entry.id, createdAt: timestamp(entry) },
|
|
228
357
|
});
|
|
@@ -241,12 +370,13 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
241
370
|
retainedTail = tail;
|
|
242
371
|
}
|
|
243
372
|
for (const retained of retainedTail ?? []) {
|
|
244
|
-
await tx.appendEntry(conversationId, { kind: "springbrand.retained-context", model: [retained] });
|
|
373
|
+
anchor = await tx.appendEntry(conversationId, { kind: "springbrand.retained-context", model: [retained] });
|
|
245
374
|
}
|
|
246
375
|
} else {
|
|
247
|
-
record = await tx.appendEntry(conversationId, { kind: "springbrand.history", data: { displayId: entry.id, createdAt: timestamp(entry), record: entry as unknown as Record<string, never> } });
|
|
376
|
+
record = anchor = await tx.appendEntry(conversationId, { kind: "springbrand.history", data: { displayId: entry.id, createdAt: timestamp(entry), record: entry as unknown as Record<string, never> } });
|
|
248
377
|
}
|
|
249
378
|
converted.set(entry.id, record);
|
|
379
|
+
anchors.set(entry.id, anchor);
|
|
250
380
|
conversationByEntry.set(entry.id, conversationId);
|
|
251
381
|
tips.set(conversationId, entry.id);
|
|
252
382
|
if (entry.id === oldTip) selected = conversationId;
|
|
@@ -257,7 +387,7 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
257
387
|
if (oldTip === null && entries.length) {
|
|
258
388
|
selected = (await session.commit((tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), BACKGROUND_CONTEXT)).id;
|
|
259
389
|
} else if (oldTip && tips.get(selected) !== oldTip) {
|
|
260
|
-
const tip =
|
|
390
|
+
const tip = anchors.get(oldTip);
|
|
261
391
|
if (!tip) throw new Error("Historical branch tip is missing");
|
|
262
392
|
selected = (await session.commit((tx) => tx.forkConversation(tip.conversationId, tip.id, { ownership: { kind: "ownerless" } }), BACKGROUND_CONTEXT)).id;
|
|
263
393
|
}
|
|
@@ -271,16 +401,29 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
271
401
|
if (typeof config.thinkingLevel === "string") agent.thinkingLevel = config.thinkingLevel as typeof agent.thinkingLevel;
|
|
272
402
|
if (Array.isArray(config.activeToolNames)) agent.tools = config.activeToolNames as string[];
|
|
273
403
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
404
|
+
const selectedBranch = branch(oldTip);
|
|
405
|
+
for (const { id, result } of finished) {
|
|
406
|
+
const ownedBySubmission = selectedBranch.filter((entry) => owners.get(entry.id) === id);
|
|
407
|
+
const input = ownedBySubmission.find((entry) => entry.message?.role === "user");
|
|
408
|
+
const reply = [...ownedBySubmission].reverse().find((entry) => entry.message?.role === "assistant");
|
|
409
|
+
const entry = input ? converted.get(input.id)!.id : undefined;
|
|
410
|
+
if ((result.status === "completed" || result.status === "declined") && entry !== undefined && reply) {
|
|
411
|
+
await tx.createSubmission({ conversationId: selected, requestId: id, type: "input", status: "done", entry, answer: converted.get(reply.id)!.id });
|
|
412
|
+
} else {
|
|
413
|
+
await tx.createSubmission({
|
|
414
|
+
conversationId: selected, requestId: id, type: "input", status: "unanswered",
|
|
415
|
+
reason: result.status === "aborted" ? "aborted" : result.status ?? "failed",
|
|
416
|
+
detail: result.error?.message ?? (result.status === "aborted" ? "Cancelled" : "The legacy request ended without an answer."),
|
|
417
|
+
...(entry !== undefined ? { entry } : {}),
|
|
418
|
+
});
|
|
280
419
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
420
|
+
}
|
|
421
|
+
if (active && interrupted) {
|
|
422
|
+
await tx.createSubmission({
|
|
423
|
+
conversationId: selected, requestId: active, type: "input", status: "unanswered", reason: "interrupted", detail: INTERRUPTED,
|
|
424
|
+
...(prompt ? { entry: converted.get(prompt.id)!.id } : {}),
|
|
425
|
+
});
|
|
426
|
+
} else if (active && prompt) {
|
|
284
427
|
const promptEntry = converted.get(prompt.id)!;
|
|
285
428
|
const submission = await tx.createSubmission({ conversationId: selected, requestId: active, type: "input", status: "placed", entry: promptEntry.id });
|
|
286
429
|
const assistant = [...owned].reverse().find((entry) => entry.message?.role === "assistant");
|
|
@@ -302,13 +445,46 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
|
|
|
302
445
|
sql.exec("CREATE TABLE IF NOT EXISTS pi_session_entry_submission (entry_id TEXT PRIMARY KEY, submission_id TEXT NOT NULL)");
|
|
303
446
|
for (const link of copiedLinks) sql.exec("INSERT OR IGNORE INTO pi_session_entry_submission VALUES (?,?)", link.id, link.submissionId);
|
|
304
447
|
for (const sidecar of copiedUi) sql.exec("INSERT OR IGNORE INTO pi_message_ui VALUES (?,?)", sidecar.id, sidecar.body);
|
|
448
|
+
if (hasTable(sql, "pi_pending_steers")) {
|
|
449
|
+
// Old Pi committed these Steers but the Host receipt survived; a native requeue would duplicate them.
|
|
450
|
+
const steers = sql.exec<{ message_id: string; harness_entry_id: string | null; ui_message_json: string | null }>("SELECT message_id,harness_entry_id,ui_message_json FROM pi_pending_steers").toArray();
|
|
451
|
+
for (const steer of steers) {
|
|
452
|
+
const record = converted.get(steer.harness_entry_id ?? "") ?? converted.get(steer.message_id);
|
|
453
|
+
if (!record) continue;
|
|
454
|
+
if (steer.ui_message_json) {
|
|
455
|
+
sql.exec("CREATE TABLE IF NOT EXISTS pi_message_ui (message_id TEXT PRIMARY KEY, body TEXT NOT NULL)");
|
|
456
|
+
sql.exec("INSERT OR IGNORE INTO pi_message_ui VALUES (?,?)", String(record.id), steer.ui_message_json);
|
|
457
|
+
}
|
|
458
|
+
sql.exec("DELETE FROM pi_pending_steers WHERE message_id=?", steer.message_id);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
305
461
|
for (const table of ["pi_approvals", "pi_tool_interactions"]) {
|
|
306
462
|
if (!hasTable(sql, table)) continue;
|
|
307
463
|
const rows = sql.exec<{ rowid: number; input_json: string }>(`SELECT rowid,input_json FROM ${table} WHERE tool_name='ask_user'`).toArray();
|
|
308
464
|
for (const row of rows) sql.exec(`UPDATE ${table} SET input_json=? WHERE rowid=?`, JSON.stringify(normalizeLegacyAskUserInput(JSON.parse(row.input_json))), row.rowid);
|
|
465
|
+
// An interrupted operation can no longer continue its parked call, so its decision must not park Host recovery.
|
|
466
|
+
if (active && interrupted && hasColumns(sql, table, ["submission_id", "status"])) {
|
|
467
|
+
sql.exec(`UPDATE ${table} SET status=? WHERE submission_id=? AND status='pending'`, table === "pi_approvals" ? "rejected" : "cancelled", active);
|
|
468
|
+
}
|
|
309
469
|
}
|
|
310
470
|
if (hasTable(sql, "cf_agents_schedules")) {
|
|
311
471
|
for (const callback of ["_cleanupStreamBuffers", "_piPlannedContinuation", "_piContinuationWatchdog", "_piApprovalContinuation", "_chatRecoveryRetry"]) sql.exec("DELETE FROM cf_agents_schedules WHERE callback=?", callback);
|
|
312
472
|
}
|
|
313
473
|
});
|
|
314
474
|
}
|
|
475
|
+
|
|
476
|
+
function hasColumns(sql: SqlStorage, table: string, names: string[]): boolean {
|
|
477
|
+
const columns = new Set(sql.exec<{ name: string }>(`PRAGMA table_info(${table})`).toArray().map((row) => row.name));
|
|
478
|
+
return names.every((name) => columns.has(name));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const LEGACY_TABLES = [
|
|
482
|
+
"pi_entries", "pi_values", "pi_lists", "pi_usage", "pi_storage_state", "pi_session_entries", "pi_session_metadata",
|
|
483
|
+
"pi_legacy_entries_backup", "pi_recovery_milestones", "pi_session_message_submission", "pi_tool_settlements",
|
|
484
|
+
];
|
|
485
|
+
|
|
486
|
+
/** Drops the inert pre-upgrade baseline once the migrated history it backs up is deleted. */
|
|
487
|
+
export async function dropLegacyPiTables(storage: DurableObjectStorage, session: Session): Promise<void> {
|
|
488
|
+
if (!(await session.snapshot(Migration, BACKGROUND_CONTEXT))?.completed) throw new Error("Legacy Pi tables are still the migration source");
|
|
489
|
+
for (const table of LEGACY_TABLES) storage.sql.exec(`DROP TABLE IF EXISTS ${table}`);
|
|
490
|
+
}
|