From 42a06f9477ce6b92f39d9467f64019f08767401e Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 4 Aug 2026 12:36:04 +0200 Subject: [PATCH 01/34] fix(coding-agent): test composite OAuth cancellation signal --- .../coding-agent/test/model-runtime-auth-options.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/test/model-runtime-auth-options.test.ts b/packages/coding-agent/test/model-runtime-auth-options.test.ts index 387e22cef2f..4baf1d6069d 100644 --- a/packages/coding-agent/test/model-runtime-auth-options.test.ts +++ b/packages/coding-agent/test/model-runtime-auth-options.test.ts @@ -259,7 +259,11 @@ describe("ModelRuntime auth options", () => { const controller = new AbortController(); await runtime.getAuth("extension-oauth", { signal: controller.signal }); - expect(refreshSignal).toBe(controller.signal); + expect(refreshSignal).toBeInstanceOf(AbortSignal); + const reason = new Error("cancelled"); + controller.abort(reason); + expect(refreshSignal?.aborted).toBe(true); + expect(refreshSignal?.reason).toBe(reason); }); it("does not fabricate an API key method for an extension OAuth-only provider", async () => { From e741cb05ca7c1c7bc5a9664c99697df32de9fac6 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 4 Aug 2026 12:37:02 +0200 Subject: [PATCH 02/34] fix(coding-agent): preserve extension auth endpoints closes #7579 --- packages/coding-agent/CHANGELOG.md | 1 + .../examples/extensions/custom-compaction.ts | 14 ----------- .../examples/extensions/handoff.ts | 8 ------ .../coding-agent/examples/extensions/qna.ts | 6 +---- .../examples/extensions/summarize.ts | 25 ++++++------------- .../coding-agent/src/core/model-registry.ts | 2 ++ .../compaction-extensions-example.test.ts | 8 ++++-- 7 files changed, 17 insertions(+), 47 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f23a77181db..b20d8ae213a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -109,6 +109,7 @@ - Fixed concurrent in-memory credential mutations losing unrelated provider updates by serializing their read-modify-write sections. - Updated `undici` to 8.9.0 and the packaged `brace-expansion` to 5.0.9 to address GHSA-8xcm-r25x-g524, GHSA-4cwx-7wf7-3272, GHSA-m8rv-5g2x-5cg5, GHSA-jr45-8vmc-qm54, GHSA-v3r7-h72x-cjcm, and GHSA-rgw5-rvv9-x895. - Fixed GitHub Copilot compaction and branch summaries using the Individual endpoint instead of the credential-resolved Business or Enterprise endpoint ([#6768](https://github.com/earendil-works/pi/issues/6768)). +- Fixed extension model calls dropping credential-resolved endpoints when forwarding request authentication, including custom compaction with GitHub Copilot Business and Enterprise accounts ([#7579](https://github.com/earendil-works/pi/issues/7579)). ## [0.83.0] - 2026-07-29 diff --git a/packages/coding-agent/examples/extensions/custom-compaction.ts b/packages/coding-agent/examples/extensions/custom-compaction.ts index 8b00346b12f..447b5e27f1e 100644 --- a/packages/coding-agent/examples/extensions/custom-compaction.ts +++ b/packages/coding-agent/examples/extensions/custom-compaction.ts @@ -31,17 +31,6 @@ export default function (pi: ExtensionAPI) { return; } - // Resolve request auth for the summarization model - const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); - if (!auth.ok) { - ctx.ui.notify(`Compaction auth failed: ${auth.error}`, "warning"); - return; - } - if (!auth.apiKey) { - ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning"); - return; - } - // Combine all messages for full summary const allMessages = [...messagesToSummarize, ...turnPrefixMessages]; @@ -91,9 +80,6 @@ ${conversationText} model, { messages: summaryMessages }, { - apiKey: auth.apiKey, - headers: auth.headers, - env: auth.env, maxTokens: 8192, signal, cacheRetention: "none", diff --git a/packages/coding-agent/examples/extensions/handoff.ts b/packages/coding-agent/examples/extensions/handoff.ts index 74457ed7e54..ed3416be8eb 100644 --- a/packages/coding-agent/examples/extensions/handoff.ts +++ b/packages/coding-agent/examples/extensions/handoff.ts @@ -117,11 +117,6 @@ export default function (pi: ExtensionAPI) { loader.onAbort = () => done(null); const doGenerate = async () => { - const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!); - if (!auth.ok || !auth.apiKey) { - throw new Error(auth.ok ? `No API key for ${ctx.model!.provider}` : auth.error); - } - const userMessage: Message = { role: "user", content: [ @@ -137,9 +132,6 @@ export default function (pi: ExtensionAPI) { ctx.model!, { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, { - apiKey: auth.apiKey, - headers: auth.headers, - env: auth.env, signal: loader.signal, cacheRetention: "none", sessionId: uuidv7(), diff --git a/packages/coding-agent/examples/extensions/qna.ts b/packages/coding-agent/examples/extensions/qna.ts index b3d725bf8b8..c848b3d0919 100644 --- a/packages/coding-agent/examples/extensions/qna.ts +++ b/packages/coding-agent/examples/extensions/qna.ts @@ -77,10 +77,6 @@ export default function (pi: ExtensionAPI) { // Do the work const doExtract = async () => { - const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!); - if (!auth.ok || !auth.apiKey) { - throw new Error(auth.ok ? `No API key for ${ctx.model!.provider}` : auth.error); - } const userMessage: UserMessage = { role: "user", content: [{ type: "text", text: lastAssistantText! }], @@ -90,7 +86,7 @@ export default function (pi: ExtensionAPI) { const response = await ctx.modelRegistry.complete( ctx.model!, { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, - { apiKey: auth.apiKey, headers: auth.headers, env: auth.env, signal: loader.signal }, + { signal: loader.signal }, ); if (response.stopReason === "aborted") { diff --git a/packages/coding-agent/examples/extensions/summarize.ts b/packages/coding-agent/examples/extensions/summarize.ts index 282ea0fb6a1..c86fa66cba6 100644 --- a/packages/coding-agent/examples/extensions/summarize.ts +++ b/packages/coding-agent/examples/extensions/summarize.ts @@ -1,5 +1,4 @@ import { uuidv7 } from "@earendil-works/pi-ai"; -import { complete, getModel } from "@earendil-works/pi-ai/compat"; import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { DynamicBorder, getMarkdownTheme } from "@earendil-works/pi-coding-agent"; import { Container, Markdown, matchesKey, Text } from "@earendil-works/pi-tui"; @@ -161,20 +160,13 @@ export default function (pi: ExtensionAPI) { ctx.ui.notify("Preparing summary...", "info"); } - const model = getModel("openai", "gpt-5.2"); - if (!model && ctx.hasUI) { - ctx.ui.notify("Model openai/gpt-5.2 not found", "warning"); - } - - const auth = model ? await ctx.modelRegistry.getApiKeyAndHeaders(model) : undefined; - if (auth && !auth.ok && ctx.hasUI) { - ctx.ui.notify(auth.error, "warning"); - } - if (auth?.ok && !auth.apiKey && ctx.hasUI) { - ctx.ui.notify("No API key for openai/gpt-5.2", "warning"); + const model = ctx.modelRegistry.find("openai", "gpt-5.2"); + if (!model) { + if (ctx.hasUI) ctx.ui.notify("Model openai/gpt-5.2 not found", "warning"); + return; } - - if (!model || !auth?.ok || !auth.apiKey) { + if (!ctx.modelRegistry.hasConfiguredAuth(model)) { + if (ctx.hasUI) ctx.ui.notify("No authentication configured for openai/gpt-5.2", "warning"); return; } @@ -186,13 +178,10 @@ export default function (pi: ExtensionAPI) { }, ]; - const response = await complete( + const response = await ctx.modelRegistry.complete( model, { messages: summaryMessages }, { - apiKey: auth.apiKey, - headers: auth.headers, - env: auth.env, reasoningEffort: "high", cacheRetention: "none", sessionId: uuidv7(), diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index d01ad3a03f1..cfb8dbf4e9c 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -19,6 +19,7 @@ export type ResolvedRequestAuth = ok: true; apiKey?: string; headers?: ProviderHeaders; + baseUrl?: string; env?: Record; } | { ok: false; error: string }; @@ -74,6 +75,7 @@ export class ModelRegistry { ok: true, apiKey: resolution.auth.apiKey, headers: resolution.auth.headers, + ...(resolution.auth.baseUrl ? { baseUrl: resolution.auth.baseUrl } : {}), env: resolution.env, }; } catch (error) { diff --git a/packages/coding-agent/test/compaction-extensions-example.test.ts b/packages/coding-agent/test/compaction-extensions-example.test.ts index cb3c9de0e66..9767e8a5cf5 100644 --- a/packages/coding-agent/test/compaction-extensions-example.test.ts +++ b/packages/coding-agent/test/compaction-extensions-example.test.ts @@ -109,7 +109,6 @@ describe("Documentation example", () => { ui: { notify: vi.fn() }, modelRegistry: { find: vi.fn(() => model), - getApiKeyAndHeaders: vi.fn(async () => ({ ok: true, apiKey: "fake-key" })), complete, }, }, @@ -118,7 +117,12 @@ describe("Documentation example", () => { expect(complete).toHaveBeenCalledWith( model, expect.objectContaining({ messages: expect.any(Array) }), - expect.objectContaining({ apiKey: "fake-key", maxTokens: 8192 }), + expect.objectContaining({ maxTokens: 8192 }), + ); + expect(complete).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ apiKey: expect.anything() }), ); expect(result).toMatchObject({ compaction: { From 720f0e8eeb1295a8030c50e165bc283f3d5fa6ff Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 4 Aug 2026 12:42:01 +0200 Subject: [PATCH 03/34] fix(ai): route Copilot Grok 4.5 through Responses, closes #7560 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 9 ++++++--- packages/ai/test/model-catalog-types.test.ts | 8 +++++++- packages/coding-agent/CHANGELOG.md | 1 + 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 09073672900..7053589674c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -75,6 +75,7 @@ ### Fixed +- Fixed GitHub Copilot Grok 4.5 requests to use the supported Responses API ([#7560](https://github.com/earendil-works/pi/issues/7560)). - Bounded OAuth token refreshes so stalled requests release the credential-store lock ([#7508](https://github.com/earendil-works/pi/issues/7508)). - Fixed tool argument validation to preserve values that already match an `anyOf`/`oneOf` union arm before attempting coercion, avoiding nullable unions converting `null` to another primitive value ([#7328](https://github.com/earendil-works/pi/issues/7328)). - Fixed cancellation of model catalog refreshes so callers stop waiting even when a custom provider ignores its abort signal ([#7027](https://github.com/earendil-works/pi/issues/7027)). diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 5f2ea81f7bd..b881c6c3ed0 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1862,10 +1862,13 @@ async function loadModelsDevData(): Promise[]> { // Claude 4.x and 5.x models route to Anthropic Messages API const isCopilotClaude = /^claude-(haiku|sonnet|opus)-[45]([.\-]|$)/.test(modelId); - // gpt-5, oswe, and MAI-Code models are only served through the - // Copilot /responses endpoint. + // Grok 4.5, gpt-5, oswe, and MAI-Code models are only served through + // the Copilot /responses endpoint. const needsResponsesApi = - modelId.startsWith("gpt-5") || modelId.startsWith("oswe") || modelId.startsWith("mai-"); + modelId === "grok-4.5" || + modelId.startsWith("gpt-5") || + modelId.startsWith("oswe") || + modelId.startsWith("mai-"); const api: Api = isCopilotClaude ? "anthropic-messages" diff --git a/packages/ai/test/model-catalog-types.test.ts b/packages/ai/test/model-catalog-types.test.ts index b3c47658e62..0facda3dbc8 100644 --- a/packages/ai/test/model-catalog-types.test.ts +++ b/packages/ai/test/model-catalog-types.test.ts @@ -1,4 +1,5 @@ -import { expectTypeOf, it } from "vitest"; +import { expect, expectTypeOf, it } from "vitest"; +import { GITHUB_COPILOT_MODELS } from "../src/providers/github-copilot.models.ts"; import { XAI_MODELS } from "../src/providers/xai.models.ts"; it("derives model API, ID, and provider literals from grouped model data", () => { @@ -7,3 +8,8 @@ it("derives model API, ID, and provider literals from grouped model data", () => expectTypeOf(XAI_MODELS["grok-4.5"].provider).toEqualTypeOf<"xai">(); expectTypeOf(XAI_MODELS["grok-4.3"].api).toEqualTypeOf<"openai-completions">(); }); + +it("routes GitHub Copilot Grok 4.5 through the Responses API", () => { + expectTypeOf(GITHUB_COPILOT_MODELS["grok-4.5"].api).toEqualTypeOf<"openai-responses">(); + expect(GITHUB_COPILOT_MODELS["grok-4.5"].api).toBe("openai-responses"); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b20d8ae213a..6382adf7bca 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -84,6 +84,7 @@ ### Fixed +- Fixed inherited GitHub Copilot Grok 4.5 requests to use the supported Responses API ([#7560](https://github.com/earendil-works/pi/issues/7560)). - Fixed fullscreen shutdown leaking terminal capability-query replies into the parent shell prompt. - Fixed bare exact `--model` IDs shared by multiple providers choosing the first catalog entry instead of the sole authenticated provider or a clear ambiguity error ([#7327](https://github.com/earendil-works/pi/issues/7327)). - Fixed standalone x64 binaries requiring Haswell-era AVX2/BMI2 instructions by compiling release executables against Bun's baseline runtime ([#7149](https://github.com/earendil-works/pi/issues/7149)). From f24ab6e14c0be7e623ec9beab0fc792cbb041cc0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 4 Aug 2026 12:41:01 +0200 Subject: [PATCH 04/34] fix(tui): honor nested stack minimum sizes --- packages/tui/src/components/v-stack.ts | 13 +++++++++-- packages/tui/test/layout.test.ts | 30 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/components/v-stack.ts b/packages/tui/src/components/v-stack.ts index 8e13b07c1e3..ce7b9292d6d 100644 --- a/packages/tui/src/components/v-stack.ts +++ b/packages/tui/src/components/v-stack.ts @@ -1,4 +1,4 @@ -import { Stack, type StackChild, type StackOptions, visibleStackEntries } from "./stack.ts"; +import { allocateStackSizes, Stack, type StackChild, type StackOptions, visibleStackEntries } from "./stack.ts"; export class VStack extends Stack { protected readonly layoutType = "vstack" as const; @@ -10,12 +10,21 @@ export class VStack extends Stack { override render(width: number): string[] { const viewport = { width: Math.max(1, width), height: Number.MAX_SAFE_INTEGER }; const entries = visibleStackEntries(this.entries, viewport); + const rendered = entries.map((entry) => entry.component.render(viewport.width)); + const sizes = allocateStackSizes( + entries, + rendered.map((lines) => lines.length), + undefined, + this.gap, + ); const lines: string[] = []; for (let index = 0; index < entries.length; index++) { if (index > 0) { for (let gap = 0; gap < this.gap; gap++) lines.push(""); } - lines.push(...entries[index]!.component.render(viewport.width)); + const childLines = rendered[index]!.slice(0, sizes[index]); + lines.push(...childLines); + for (let padding = childLines.length; padding < sizes[index]!; padding++) lines.push(""); } return lines; } diff --git a/packages/tui/test/layout.test.ts b/packages/tui/test/layout.test.ts index 147247338ff..8569542719f 100644 --- a/packages/tui/test/layout.test.ts +++ b/packages/tui/test/layout.test.ts @@ -86,6 +86,36 @@ describe("viewport layout", () => { assert.deepStrictEqual(visibleLines(frame.lines), ["a1", "b1", "b2", "b3"]); }); + it("includes nested minimum sizes in intrinsic stack measurement", () => { + const dock = new VStack([ + new Text("top1\ntop2\ntop3", 0, 0), + { component: new Text("selector", 0, 0), minSize: 3 }, + new Text("below", 0, 0), + { component: new Text("footer", 0, 0), minSize: 1 }, + ]); + const frame = renderLayoutFrame( + new VStack([ + { component: new Text("body", 0, 0), basis: 0, grow: 1, minSize: 1 }, + { component: dock, basis: "auto", minSize: 1 }, + ]), + 10, + 9, + () => {}, + ); + + assert.deepStrictEqual(visibleLines(frame.lines), [ + "body", + "top1", + "top2", + "top3", + "selector", + "", + "", + "below", + "footer", + ]); + }); + it("omits gaps around invisible entries", () => { const stack = new VStack( [new Text("one", 0, 0), { component: new Text("hidden", 0, 0), visible: () => false }, new Text("two", 0, 0)], From 686f193e51ccdc56fdf3366ce5d092530c1007ac Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 4 Aug 2026 12:44:55 +0200 Subject: [PATCH 05/34] fix(ai): separate deferred request options --- packages/agent/docs/harness-v2.md | 152 ++++++++++++------ packages/ai/CHANGELOG.md | 2 + packages/ai/src/models.ts | 40 +++-- packages/ai/src/providers/faux.ts | 10 +- packages/ai/src/types.ts | 144 +++++++---------- packages/ai/test/providers.test.ts | 87 +++++++++- .../coding-agent/src/core/model-runtime.ts | 34 ++-- ...model-runtime-modify-models-compat.test.ts | 59 +++++-- 8 files changed, 347 insertions(+), 181 deletions(-) diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index f798900ac89..703d7e0d954 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -178,7 +178,7 @@ Part II is backend-neutral. It defines the records a lane writes, when it writes > Before an effect: write an intent record that names what will happen and the ids it will produce. After the effect: append the result as an entry with exactly those ids. -There is no multi-record atomicity and none is needed. Each record and each entry is durable alone. A crash between intent and result leaves the intent unfulfilled; recovery decides per intent type: complete it, retry it, or close it with a synthetic result. An intent is fulfilled if and only if an entry with its provisioned id exists. The entry can itself name the next durable state: an assistant entry with `stopReason: "deferred"` fulfills its attempt's provisioned append but suspends the step rather than closing it. A provisioned id that exists with different content is corruption. +There is no multi-record atomicity and none is needed. Each record and each entry is durable alone. A crash between intent and result leaves the intent unfulfilled; recovery decides per intent type: complete it, retry it, or close it with a synthetic result. An intent is fulfilled if and only if an entry with its provisioned id exists. The entry can itself name the next durable state: an assistant entry with `stopReason: "deferred"` fulfills its attempt's provisioned append and closes the step; what stays outstanding is the operation — the persisted handle awaits redemption (section 6). A provisioned id that exists with different content is corruption. ### Provisioned ids @@ -194,7 +194,7 @@ type ProvisionedEntry = ### Record catalog -Every record belongs to one lane's operation log. Records that belong to an operation carry `runId`: the id of that operation's `operation_started` record. `queue_enqueued` for the next-run queue is the one record without `runId`; it is consumed by the lane's next run. +Every record belongs to one lane's operation log. Records that belong to an operation carry `runId`: the id of that operation's `operation_started` record. Next-run queue records (`queue_enqueued` and their `queue_cancelled`) and standalone `adjustment` usage records carry no `runId`. ```ts interface RecordBase { @@ -249,7 +249,6 @@ interface OperationStartedRecord extends RecordBase { interface AbortRequestedRecord extends RecordBase { type: "abort_requested"; runId: string; - reason: "user" | "shutdown"; } // Closes the operation. failed = orderly durable failure (for example, @@ -266,8 +265,8 @@ interface OperationFinishedRecord extends RecordBase { // do this, for the n-th time. Steps are logged only because they are // retryable: the durable count caps retries across restarts — a // crash-restart loop cannot reset it. One record per attempt; one attempt -// may make zero or several provider requests (hook-supplied summaries make -// none, split-turn compaction makes two). Deferred results need no extra +// may make zero or several provider requests (split-turn compaction +// makes two). Deferred results need no extra // record: the handle lives in the persisted assistant entry (section 1). interface StepAttemptRecord extends RecordBase { type: "step_attempt"; @@ -432,7 +431,7 @@ R usage E assistant message ``` -Every provider request settles with a `usage` record (section 5); the other traces omit them for brevity. +Every provider request settles with a `usage` record (section 5); the other traces omit them for brevity. Per-request hooks (`transform_context`, `before_request`, `after_response`) run inside every request and are omitted everywhere; Tier B records them (section 20). Crash during backoff: restore counts two attempts; resume starts attempt 3. The count never resets. Retryable errors below the cap are never appended as entries. Attempts exhausted — or a non-retryable terminal error — appends an assistant message with the error, then `operation_finished` failed: @@ -474,7 +473,7 @@ R step_attempt step assistant, attempt 1 — new step E assistant message ``` -**One recovery per conversational input.** An overflow compaction may start only when no overflow-reason compaction `step_attempt` is newer than this run's newest consumed conversational message (prompt, steering, or follow-up). A second recoverable response inside that window appends the give-up error entry and fails the run through the drain path — a `length` response never resets the guard; only consumed conversational input does. This bounds the compact-and-retry loop at one attempt per user action. A `before_compaction` decline or an empty compaction preparation for reason `overflow` is equally terminal: without compaction the request cannot fit. +**One recovery per conversational input.** An overflow compaction may start only when no overflow-reason compaction `step_attempt` is newer than this run's newest consumed conversational message (prompt, steering, or follow-up). A second recoverable response inside that window appends the give-up error entry and fails the run through the drain path — a `length` response never resets the guard; only consumed conversational input does. This bounds the compact-and-retry loop at one attempt per user action. A `before_compaction` decline or an empty compaction preparation for reason `overflow` is equally terminal: without compaction the request cannot fit. A hook-supplied overflow compaction writes its compaction `step_attempt` before the entry so the guard counts it — the one hook-supplied summary that writes an attempt record. Per crash site: @@ -623,7 +622,7 @@ E assistant message stop reason deferred, carries the handle ... hours pass, maybe a different process ... resume() newest entry on the lane's path is a deferred assistant message with no successor - → the attempt is outstanding, redeem it + → the handle is unredeemed, redeem it fetchDeferred(model, handle) model and handle from that entry E assistant message the real result run continues normally @@ -633,7 +632,7 @@ The suspended lane is indistinguishable from a crashed one in storage: an open o Each `resume()` performs one fetch. Three outcomes: -- **pending** — the provider returns stop reason `deferred` again. Nothing is written; the lane re-suspends. Poll cadence is application policy. +- **pending** — the provider returns stop reason `deferred` again. Nothing but a possible `usage` record is written (section 15); the lane re-suspends. Poll cadence is application policy. - **ready** — a normal assistant message. It is appended as the successor and the run continues. - **terminal** — the provider returns stop reason `error` (expired, unknown, consumed), or the fetch itself rejects; the harness converts a rejection to the same error-message form. The message is appended and the run finishes failed. Redemption failure never starts an automatic replacement request; steering or follow-up input already accepted for this run can still start a later turn. @@ -659,10 +658,11 @@ Both reads are bounded by the size of the open operation, not by the size of the From those two reads, the lane's state: - **aborting** — an `abort_requested` record exists. -- **attempts used** — `step_attempt` records whose `resultEntryId` has no entry. A step is closed exactly when its provisioned result exists — a point lookup, not adjacency inference; attempts whose result landed belong to finished work. +- **attempts used** — the newest `step_attempt` whose `resultEntryId` has no entry is the unfinished step; its `attempt` field is the durable count, its kind and `compactionReason` select the resume path. Closure is a point lookup, not adjacency inference: a step is closed exactly when the newest attempt's provisioned result exists. Earlier attempts' unfulfilled ids belong to finished work and need no inspection. - **overflow recovery used** — a compaction `step_attempt` with reason `overflow` is newer than the newest consumed conversational message of this run (section 6, overflow guard). - **tool batch** — the newest assistant entry with tool calls, each call matched against `tool_started` records and result entries (section 6, crash-site table). The assistant stop reason is retained: a `length` batch is truncated and never executes on recovery. Persisted `terminate` values on result entries decide whether the completed batch forces another turn. - **deferred handle** — the newest own entry is a deferred assistant message with no successor. +- **newest own entry** — the last entry of the second read; the pure predicates (`needsAssistant()`, terminal failure, abort closure) read it. - **pending queue items** — `queue_enqueued` records whose provisioned entry does not exist, excluding items retracted by `queue_cancelled` and steer/follow-up items killed by this run's `abort_requested`. - **pending writes** — `write_deferred` records whose provisioned entry does not exist. - **missing initial messages** — provisioned ids from the run intent without entries. @@ -834,6 +834,9 @@ interface AgentHarnessOptions { compaction?: CompactionSettings; steeringMode?: QueueMode; followUpMode?: QueueMode; + /** Batch default; a called tool declaring executionMode "sequential" + forces sequential regardless (section 14). */ + toolExecution?: "sequential" | "parallel"; // default parallel /** automatic: operation methods drive their procedures to completion. manual: the operation's effects park at the gate; peekAction() / executeAction() / runToCompletion() drive them. Deterministic tests @@ -1034,6 +1037,8 @@ Calls on a faulted harness reject with the same `HarnessFault` instance until th `finalMessage` is the run's newest entry that projects to an assistant message; `finalEntryId` is that entry's id. `leafId` is the lane's leaf when the operation finished — the race-free anchor for branch queries (`findEntriesOnBranch({ start: leafId })`). The two differ when a deferred write was applied after the final assistant message. Full transcripts are not duplicated into results; they are in the session and were delivered as events. +**Type provenance.** Types this document uses but does not redefine — `QueueMode`, `RetryPolicy`, `CompactionSettings`, `CompactionPreparation`, `NavigationPreparation`, `CompactResult`, `ToolResultPatch`, `SessionStats`, `SessionMetadata`, `NavigateOptions`, `EntryCursor`, `LogItem`, `StreamOptionsPatch` — keep their existing `harness/types.ts` shapes. Lowercase helpers in section 15 pseudocode without a definition (`preparation`, `runToolBatchForSingleCall`, request/option bags such as `AssistantRequest` and `FactWrite`) are constructive implementation detail, not contract. + ### Suspended operations ```ts @@ -1170,14 +1175,14 @@ Guarantees: - Events that report durable facts fire after the fact is committed; what an event announces is already queryable. - Events report final values, after hook transformation. - Payloads are JSON-serializable and secret-free; a server can proxy them verbatim. Live objects (models, tools) are referenced by name, never embedded. -- Lane-scoped events carry `lane: string` (omitted below); harness-global events such as `fault` omit it. Operation-scoped events carry `runId`; turn-scoped events carry `turnId`; recovered work carries `recovery: true`. +- Lane-scoped events carry `lane: string` (omitted below); harness-global events omit it — except `usage`, which is delivered harness-globally and carries the record's lane in its payload. Operation-scoped events carry `runId`; turn-scoped events carry `turnId`; recovered work carries `recovery: true`. ### Catalog ```ts // Run lifecycle { type: "run_start"; runId } -{ type: "run_resume"; runId } // resume() entered +{ type: "run_resume"; runId } // resume() entered (any operation kind) { type: "run_suspend"; runId; deferred: DeferredHandle } // lane parked { type: "run_abort"; runId; steer: AgentMessage[]; followUp: AgentMessage[] } // abort accepted; cleared payloads { type: "run_end"; runId; outcome: "completed" | "aborted" | "failed"; @@ -1377,12 +1382,7 @@ before_compaction: { before_navigation: { event: { targetId; preparation: NavigationPreparation }; - result: { - decline?: boolean; - summary?: { summary: string; details?; usage? }; - customInstructions?: string; - label?: string; - } | undefined; + result: { decline?: boolean; summary?: { summary: string; details?; usage? } } | undefined; } ``` @@ -1416,7 +1416,8 @@ interface EntryBase { timestamp: number; // Unix ms, storage-assigned } -interface MessageEntry extends EntryBase { type: "message"; message: AgentMessage } +interface MessageEntry extends EntryBase { type: "message"; message: AgentMessage; + terminate?: true } interface ModelChangeEntry extends EntryBase { type: "model_change"; provider: string; modelId: string } interface ThinkingLevelEntry extends EntryBase { type: "thinking_level_change"; thinkingLevel: string } interface ActiveToolsEntry extends EntryBase { type: "active_tools_change"; activeToolNames: string[] } @@ -1804,7 +1805,7 @@ export interface ToolCallbacks { args?: Record; block?: { reason: string }; } | undefined>; - afterToolCall?(call, result, isError, signal): Promise; + afterToolCall?(call, args, result, isError, signal): Promise; /** Between phases 1 and 2: the durability point. The harness writes its tool_started record here. Called in source order in both modes — preparation is always sequential. */ @@ -1906,7 +1907,7 @@ The jobs, by caller: - **Lane surface** (ungated, enqueue directly): - *Operation acceptance* — validate idle, capture the pending `nextRun` items into `initialMessages`, write `operation_started`, set `state.operation`. The second of two concurrent acceptances sees the first and rejects `busy` with no write. `before_run` ran before this job, outside the line, on the prompt only. - *Queue acceptance* (`steer`, `followUp`) — validate an active, non-aborting run; write `queue_enqueued`. `nextRun` validates nothing and always accepts. - - *Queue cancellation* (`cancelQueued`) — target entry exists: `already_consumed`; not pending (abort-drained or already cancelled): `already_cleared`; else write `queue_cancelled` and remove the item from its pending set. + - *Queue cancellation* (`cancelQueued`) — no `queue_enqueued` for the id: `Err(UnknownQueueItem)`; target entry exists: `already_consumed`; not pending (abort-drained or already cancelled): `already_cleared`; else write `queue_cancelled` and remove the item from its pending set. - *Deferred-write acceptance* (lane-view writes, config setters) — run open: write `write_deferred`; structural operation open: wait for it to end, then re-enter; idle: append the entry directly. - *Abort* — write `abort_requested`, set `aborting`, drain `pendingSteer`/`pendingFollowUp` (payloads return to the abort caller and in the `run_abort` event), signal the active effect's `AbortController`. - *Resume admission* — reserve the lane's single execution slot; no write. @@ -2013,6 +2014,7 @@ Semantics that make tests deterministic: records and own entries (section 7): live commits update it; restore recomputes it. */ interface LaneState { + lane: string; leafId: string | null; operation: null | { id: string; @@ -2076,14 +2078,17 @@ async function appendIfMissing(target: ProvisionedEntry): Promise { ```ts async function resume(): Promise { if (missing.tools.length || missing.models.length) { - return Result.err(new MissingIdentities({ lane: laneName(state), ...missing, + return Result.err(new MissingIdentities({ lane: state.lane, ...missing, message: "Missing tools or models" })); } + await fx.runHook("before_resume", beforeResumeEvent(state)); // per registration id (section 11) emit({ type: "run_resume", runId: op.id, recovery: true }); + // tagResume re-tags an operation Result as a ResumeResult: Ok gains + // { operation }, Err passes through unchanged. switch (op.kind) { - case "run": return { kind: "run", ...await runProcedure() }; - case "compaction": return { kind: "compaction", ...await compactionProcedure() }; - case "navigation": return { kind: "navigation", ...await navigationProcedure() }; + case "run": return tagResume("run", await runProcedure()); + case "compaction": return tagResume("compaction", await compactionProcedure()); + case "navigation": return tagResume("navigation", await navigationProcedure()); } } @@ -2126,6 +2131,9 @@ async function handleRunSignal(e: unknown): Promise { } ``` + +**Fixed-point self-check.** When `resume()` completes, parks, or closes its operation, the harness recomputes the section 7 reduction from storage and compares it to the live `LaneState`. A mismatch is corruption and faults the harness — writer/reducer drift is caught the moment it happens instead of one crash later. The check is cheap (the same two bounded reads restore performs) and runs in production, not only under test. + ### The loop ```ts @@ -2212,7 +2220,7 @@ async function handleRunFailed(error: OperationError): Promise { ### Steps -A failed attempt appends nothing; only a deferred handle, a terminal message, or the final give-up error enters the tree (section 6, retry trace). +A failed attempt appends nothing. Besides the successful response, only a deferred handle, a terminal message, or the final give-up error enters the tree (section 6, retry trace). ```ts async function assistantStep(): Promise { @@ -2220,9 +2228,10 @@ async function assistantStep(): Promise { if (op.aborting) throw new Aborted(); const attempt = (op.step?.kind === "assistant" ? op.step.attempts : 0) + 1; if (attempt > retry.maxAttempts) { + const error = retriesExhausted(); // The give-up entry fulfills the last attempt's provisioned id. - await fx.appendEntry(giveUpAssistantEntry(lastAttemptResultId(op), state)); - throw new RunFailed(retriesExhausted()); + await fx.appendEntry(giveUpAssistantEntry(lastAttemptResultId(op), state, error)); + throw new RunFailed(error); } const options = await fx.runHook("before_request", @@ -2255,7 +2264,7 @@ async function assistantStep(): Promise { `isRecoverableOverflow(final, state)` is `isContextOverflow(final)` — overflow-pattern errors and silent overflow — or `isRecoverableLength(final, desiredMaxOutput(state))` from section 6, where `desiredMaxOutput(state)` is the caller-supplied `maxTokens` when set, else the lane model's `maxTokens`. The check runs before the retryable-error branch: an overflow-form error compacts instead of retrying the same oversized request. -`summaryStep(step, reason, resultEntryId)` has the same shape: `step_attempt` before each attempt (`compactionReason` for compaction steps) carrying the step's single result id, `before_request`, one or two non-deferred requests — each followed by its `usage` record bound to that id — durable cap. It returns the summary value; the caller appends the result entry under that id. A hook-supplied summary makes no request and no request record; if it carries usage the hook measured itself, the appending procedure writes a `hook` usage record beside the entry. +`summaryStep(step, reason, resultEntryId)` has the same shape: `step_attempt` before each attempt (`compactionReason` for compaction steps) carrying the step's single result id, `before_request`, one or two non-deferred requests — each followed by its `usage` record bound to that id — durable cap. It returns the summary value; the caller appends the result entry under that id. A hook-supplied summary makes no request and no request record; if it carries usage the hook measured itself, the appending procedure writes a `hook` usage record beside the entry. For reason `overflow` the appending procedure also writes the compaction `step_attempt`, so the once-per-input guard counts the recovery (section 6). ### Deferred redemption @@ -2267,7 +2276,10 @@ async function redeemDeferred(): Promise { await fx.appendRecord(usageRecord("deferred_fetch", op.id, resultEntryId, 1, final)); } if (op.aborting) throw new Aborted(); - if (final.stopReason === "deferred") throw new Park(op.deferred!); // pending; no other write + if (final.stopReason === "deferred") { + requireSameHandle(final.deferred, op.deferred!); // mismatch is a defect (section 16) + throw new Park(op.deferred!); // pending; no other write + } if (final.stopReason === "aborted") throw new Aborted(); await fx.appendEntry(assistantEntry(resultEntryId, final)); // ready or terminal @@ -2278,8 +2290,6 @@ async function redeemDeferred(): Promise { One fetch per `resume()`. Pending re-parks without a write. A terminal answer — returned or converted from a rejected fetch — lands as the error entry and fails the run through the normal drain path, which still honors input accepted before the failure (section 6). -**Fixed-point self-check.** When `resume()` completes, parks, or closes its operation, the harness recomputes the section 7 reduction from storage and compares it to the live `LaneState`. A mismatch is corruption and faults the harness — writer/reducer drift is caught the moment it happens instead of one crash later. The check is cheap (the same two bounded reads restore performs) and runs in production, not only under test. - ### Tools The live path is section 14 `executeToolBatch`; the durability callbacks route through `fx`, so the gate and the traces see every write in order: @@ -2304,8 +2314,8 @@ async function runToolBatch(assistant: AssistantMessage): Promise { replay: declaredReplay(call), })); }, - afterToolCall: (call, result, isError) => - fx.runHook("after_tool", { toolCallId: call.id, toolName: call.name, ...result, isError }), + afterToolCall: (call, args, result, isError) => + fx.runHook("after_tool", { toolCallId: call.id, toolName: call.name, args, ...result, isError }), onToolResult: async (message, terminate) => { // Blocked/invalid calls have no tool_started and no provisioned id; // their error result entry gets a fresh id (section 5). @@ -2389,9 +2399,13 @@ async function compactionProcedure(): Promise { let result: CompactResult | undefined; if (!op.step) { // no attempt yet: the decision hook may still run const hook = await fx.runHook("before_compaction", - { reason: "manual", preparation, customInstructions: op.intent.customInstructions }); + { reason: "manual", preparation: preparation(state), + customInstructions: op.intent.customInstructions }); if (hook?.decline) return await finishStructural("declined"); result = hook?.compaction; + if (result?.usage) { + await fx.appendRecord(hookUsageRecord(op.id, op.intent.resultEntryId, result.usage)); + } } result ??= await summaryStep("compaction", "manual", op.intent.resultEntryId); await appendIfMissing(compactionEntry(op.intent.resultEntryId, result)); @@ -2408,14 +2422,25 @@ async function compactionProcedure(): Promise { RunFailed: without compaction the request cannot fit (section 6). */ async function autoCompact(reason: "threshold" | "overflow"): Promise { const resultEntryId = op.step?.kind === "compaction" ? op.step.resultEntryId : newId(); - if (!op.step) { - const hook = await fx.runHook("before_compaction", - { reason, preparation: preparation(state) }); + if (op.step?.kind !== "compaction") { // no durable compaction decision yet; on the overflow + // path op.step is the abandoned assistant step + const prep = preparation(state); + if (prep.nothingToCompact) { + if (reason === "overflow") throw new RunFailed(truncationError()); + return; + } + const hook = await fx.runHook("before_compaction", { reason, preparation: prep }); if (hook?.decline) { if (reason === "overflow") throw new RunFailed(truncationError()); return; } if (hook?.compaction) { + if (reason === "overflow") { // the once-per-input guard counts this attempt + await fx.appendRecord(stepAttempt(op.id, "compaction", 1, resultEntryId, reason)); + } + if (hook.compaction.usage) { + await fx.appendRecord(hookUsageRecord(op.id, resultEntryId, hook.compaction.usage)); + } await appendIfMissing(compactionEntry(resultEntryId, hook.compaction)); return; } @@ -2433,10 +2458,14 @@ async function navigationProcedure(): Promise { if (op.intent.summarize && !op.targets.summary) { if (!moved && !op.step) { // decision hook: once, pre-move const hook = await fx.runHook("before_navigation", - { targetId: op.intent.targetId, preparation }); // preparation derives from + { targetId: op.intent.targetId, + preparation: preparation(state) }); // preparation derives from // intent.sourceLeafId — valid pre- and post-move if (hook?.decline) return await finishStructural("declined"); summary = hook?.summary; + if (summary?.usage) { + await fx.appendRecord(hookUsageRecord(op.id, op.intent.summaryEntryId!, summary.usage)); + } } summary ??= await summaryStep("branch_summary", undefined, op.intent.summaryEntryId!); // regenerates after a post-move crash @@ -2487,6 +2516,7 @@ Hook-to-block wiring, in one table: | `before_tool` | `ToolCallbacks.beforeToolCall` (phase 1) | | `after_tool` | `ToolCallbacks.afterToolCall` (phase 3) | | `before_run_end` | `driverLoop` finish boundary; result committed via `fx.commitRunEndFollowUp` | +| `before_resume` | `resume()` dispatch, before any effect | | — (record/entry writes) | `ToolCallbacks.onToolStart` / `onToolResult` via `fx` | Notes: @@ -2531,6 +2561,30 @@ interface AssistantMessage { deferred?: DeferredHandle; // present iff stopReason === "deferred" } +// Authenticated HTTP request plumbing shared by stream, image, and deferred +// provider operations. Generation and streaming-transport controls are not +// part of this interface. +interface ProviderRequestOptions { + signal?: AbortSignal; + apiKey?: string; + fetch?: FetchFunction; + env?: ProviderEnv; + onPayload?: (payload: unknown, model: Model) => + unknown | undefined | Promise; + onResponse?: (response: ProviderResponse, model: Model) => void | Promise; + headers?: ProviderHeaders; + timeoutMs?: number; + maxRetries?: number; + maxRetryDelayMs?: number; +} + +interface DeferredFetchOptions extends ProviderRequestOptions { + /** Maximum provider long-poll duration. Omitted or zero checks once. */ + wait?: number; +} + +type DeferredCancelOptions = ProviderRequestOptions; + // Redemption lives on the provider. The two methods are optional: their // presence is the capability signal. A provider without them never returns // stopReason "deferred" and ignores the deferred request option. @@ -2546,28 +2600,30 @@ export interface ProviderStreams { `wait` expires; wait: 0 checks once) - terminal: stopReason "error" (expired, unknown, consumed) */ fetchDeferred?(model: Model, handle: DeferredHandle, - options?: { wait?: number; signal?: AbortSignal }): AssistantMessageEventStream; + options?: DeferredFetchOptions): AssistantMessageEventStream; /** Best effort; providers without cancellation omit it. */ - cancelDeferred?(model: Model, handle: DeferredHandle): Promise; + cancelDeferred?(model: Model, handle: DeferredHandle, + options?: DeferredCancelOptions): Promise; } ``` The harness never talks to a provider object directly; it uses the same authenticated dispatch surface as ordinary requests: ```ts -type ModelsDeferredOptions = StreamOptions & ModelsStreamTransforms; +type ModelsDeferredFetchOptions = DeferredFetchOptions & ModelsRequestTransforms; +type ModelsDeferredCancelOptions = DeferredCancelOptions & ModelsRequestTransforms; interface Models { // existing methods fetchDeferred(model: Model, handle: DeferredHandle, - options?: ModelsDeferredOptions): Promise; + options?: ModelsDeferredFetchOptions): Promise; cancelDeferred(model: Model, handle: DeferredHandle, - options?: ModelsDeferredOptions): Promise; + options?: ModelsDeferredCancelOptions): Promise; } ``` -`Models.fetchDeferred` and `Models.cancelDeferred` delegate to the provider methods with normal model resolution and authentication (credential store, expiring tokens, header merge); `ModelsDeferredOptions` carries the normal `AbortSignal`, transport, response callbacks, and model transforms. A provider that returns `stopReason: "deferred"` must implement fetch; cancellation is best effort. +`Models.fetchDeferred` and `Models.cancelDeferred` delegate to the provider methods with normal model resolution and authentication (credential store, expiring tokens, header merge). Their options carry the normal HTTP request settings, lifecycle callbacks, and model transforms; fetch options additionally carry the provider long-poll duration. A provider that returns `stopReason: "deferred"` must implement fetch; cancellation is best effort. A terminal fetch answer is final for the run: the harness appends the error message and fails the operation (section 6). It never starts an automatic replacement request. The executor converts a rejected fetch promise into the same `stopReason: "error"` message form, so expected provider and authentication failures stay in-band. On a returned pending message the harness requires the complete handle to equal the persisted handle: a provider cannot replace durable handle data without a write, so a mismatch is a defect. @@ -2686,7 +2742,6 @@ pi.harness.run runId, lane, recovery pi.harness.compaction manual operation pi.harness.navigation -pi.harness.resume ``` The harness owns the operation and turn spans. The `fx` implementation owns checkpoint, step, request, tool, hook, and append spans: `fx.streamAssistant` creates the retryable step span and its request children, and each tool step gets its own tool span. This split follows ownership of the corresponding work. @@ -2735,7 +2790,7 @@ The in-memory backend is the reference. The parity suite runs the same setups ag ### Tier B — writer conformance -Tier A assumes live execution writes the correct prefix; Tier B verifies it. Run the public harness against an instrumented `Session` recording every entry (`E`), record (`R`), fact (`G`), and hook (`H`). Assert exact order against the section 6 traces: one-tool run, retry, terminal failure, steering during a tool, finish-boundary orders, deferred write mid-turn, abort during a tool, auto-compaction, manual compaction, navigation (move-first), deferred suspension and every fetch outcome. This tier catches the critical regression class: an effect starting before its intent record. +Tier A assumes live execution writes the correct prefix; Tier B verifies it. Run the public harness against an instrumented `Session` recording every entry (`E`), record (`R`), lane move (`L`), fact (`G`), and hook (`H`). Assert exact order against the section 6 traces: one-tool run, retry, terminal failure, steering during a tool, queue cancellation, finish-boundary orders, deferred write mid-turn, abort during a tool, auto-compaction, context overflow (discard, guard, hook-supplied), manual compaction, navigation (move-first), deferred suspension and every fetch outcome. This tier catches the critical regression class: an effect starting before its intent record. Tier B also asserts the append-only-context invariant (section 4) executably: within a run, every faux-provider request's message list extends the previous request's as an exact prefix — except across a compaction entry, the one sanctioned invalidation. This turns the KV-cache discipline from prose into a failing test whenever a write path inserts before the tail. @@ -2762,8 +2817,7 @@ Crash simulation is `close()` at a chosen boundary, then reopening the same back Gate invariants, asserted across Tier C: -- after every `resume()` outcome, the recomputed reduction equals live `LaneState` (the section 15 fixed-point self-check fired and passed); - +- After every `resume()` outcome, the recomputed reduction equals live `LaneState` (the section 15 fixed-point self-check fired and passed). - `peekAction()` has no side effect and is stable until `executeAction()`. - `executeAction()` releases exactly the peeked action, never a later one. - Stopping before an action leaves exactly the preceding durable prefix. diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7053589674c..67bb733be22 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Breaking Changes +- Renamed the exported `ModelsStreamTransforms` interface to `ModelsRequestTransforms` because its header transformation now applies to all authenticated provider requests. - Required dynamic model providers to accept a concrete `RefreshModelsContext.signal`; `Models.refresh()` remains unbounded when callers omit its optional signal. - Required provider login, API-key check/resolution, and OAuth refresh implementations to accept a concrete abort signal; public auth and credential operations remain unbounded when callers omit their optional signal. - Replaced raw `RefreshModelsContext.store` access with the read-only `context.stored` snapshot and generation-checked `context.publish()` transaction. @@ -67,6 +68,7 @@ ### Added +- Added deferred provider request contracts, durable response handles, authenticated fetch/cancel dispatch, and faux-provider support for pending, ready, failed, and cancelled responses ([#7339](https://github.com/earendil-works/pi/pull/7339) by [@davidbrai](https://github.com/davidbrai)). - Added Baseten as a built-in OpenAI-compatible provider with models.dev catalog generation and native `chat_template_args` reasoning controls. ### Changed diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index b68c007ad8b..dfc5202c03e 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -20,15 +20,16 @@ import type { AssistantMessage, AssistantMessageEventStream, Context, + DeferredCancelOptions, DeferredFetchOptions, DeferredHandle, Model, ModelCostRates, ModelThinkingLevel, ProviderHeaders, + ProviderRequestOptions, ProviderStreams, SimpleStreamOptions, - StreamOptions, Usage, } from "./types.ts"; import { operationSignal, raceWithAbortSignal } from "./utils/abort.ts"; @@ -74,14 +75,15 @@ export interface ModelsRefreshResult { errors: ReadonlyMap; } -export interface ModelsStreamTransforms { +export interface ModelsRequestTransforms { /** Transform fully assembled model/auth/request headers before provider dispatch. */ transformHeaders?: (headers: ProviderHeaders) => ProviderHeaders | Promise; } -export type ModelsApiStreamOptions = ApiStreamOptions & ModelsStreamTransforms; -export type ModelsSimpleStreamOptions = SimpleStreamOptions & ModelsStreamTransforms; -export type ModelsDeferredOptions = DeferredFetchOptions & ModelsStreamTransforms; +export type ModelsApiStreamOptions = ApiStreamOptions & ModelsRequestTransforms; +export type ModelsSimpleStreamOptions = SimpleStreamOptions & ModelsRequestTransforms; +export type ModelsDeferredFetchOptions = DeferredFetchOptions & ModelsRequestTransforms; +export type ModelsDeferredCancelOptions = DeferredCancelOptions & ModelsRequestTransforms; /** * A provider is the concrete runtime unit. It owns id/name/base metadata, @@ -143,7 +145,7 @@ export interface Provider { handle: DeferredHandle, options?: DeferredFetchOptions, ): AssistantMessageEventStream; - cancelDeferred?(model: Model, handle: DeferredHandle, options?: StreamOptions): Promise; + cancelDeferred?(model: Model, handle: DeferredHandle, options?: DeferredCancelOptions): Promise; } /** @@ -212,8 +214,12 @@ export interface Models { streamSimple(model: Model, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream; completeSimple(model: Model, context: Context, options?: ModelsSimpleStreamOptions): Promise; - fetchDeferred(model: Model, handle: DeferredHandle, options?: ModelsDeferredOptions): Promise; - cancelDeferred(model: Model, handle: DeferredHandle, options?: ModelsDeferredOptions): Promise; + fetchDeferred( + model: Model, + handle: DeferredHandle, + options?: ModelsDeferredFetchOptions, + ): Promise; + cancelDeferred(model: Model, handle: DeferredHandle, options?: ModelsDeferredCancelOptions): Promise; } export interface MutableModels extends Models { @@ -627,10 +633,13 @@ class ModelsImpl implements MutableModels { return provider; } - private async applyAuth( + private async applyAuth( model: Model, options: TOptions | undefined, - ): Promise<{ requestModel: Model; requestOptions: StreamOptions | undefined }> { + ): Promise<{ + requestModel: Model; + requestOptions: Omit & ProviderRequestOptions; + }> { this.requireProvider(model); const resolution = await this.getAuth(model, { apiKey: options?.apiKey, @@ -649,7 +658,8 @@ class ModelsImpl implements MutableModels { const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined; const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model; const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {}; - const requestOptions = { ...providerOptions, apiKey, headers, env } as StreamOptions; + const requestOptions = { ...providerOptions, apiKey, headers, env } as Omit & + ProviderRequestOptions; return { requestModel, requestOptions }; } @@ -696,7 +706,7 @@ class ModelsImpl implements MutableModels { async fetchDeferred( model: Model, handle: DeferredHandle, - options?: ModelsDeferredOptions, + options?: ModelsDeferredFetchOptions, ): Promise { return lazyStream(model, async () => { const provider = this.requireProvider(model); @@ -708,7 +718,11 @@ class ModelsImpl implements MutableModels { }).result(); } - async cancelDeferred(model: Model, handle: DeferredHandle, options?: ModelsDeferredOptions): Promise { + async cancelDeferred( + model: Model, + handle: DeferredHandle, + options?: ModelsDeferredCancelOptions, + ): Promise { const provider = this.requireProvider(model); if (!provider.cancelDeferred) { throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`); diff --git a/packages/ai/src/providers/faux.ts b/packages/ai/src/providers/faux.ts index aac6567e594..284a099b314 100644 --- a/packages/ai/src/providers/faux.ts +++ b/packages/ai/src/providers/faux.ts @@ -3,6 +3,7 @@ import type { AssistantMessage, AssistantMessageEventStream, Context, + DeferredCancelOptions, DeferredFetchOptions, DeferredHandle, ImageContent, @@ -606,12 +607,7 @@ export function createFauxCore(options: RegisterFauxProviderOptions) { ...submissionOptions } = entry.options ?? {}; try { - entry.final = await resolveResponse( - entry.step, - entry.context, - { ...submissionOptions, ...fetchOptions }, - entry.model, - ); + entry.final = await resolveResponse(entry.step, entry.context, submissionOptions, entry.model); } catch (error) { entry.final = createErrorMessage(error, api, provider, entry.model.id); } @@ -637,7 +633,7 @@ export function createFauxCore(options: RegisterFauxProviderOptions) { const cancelDeferred = async ( requestModel: Model, handle: DeferredHandle, - cancelOptions?: StreamOptions, + cancelOptions?: DeferredCancelOptions, ): Promise => { state.cancelledDeferred.push(structuredClone(handle)); const entry = deferredResponses.get(handle.id); diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 7c55761c1f4..8e1ca5124bd 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -114,17 +114,8 @@ export interface ProviderResponse { headers: Record; } -export interface StreamOptions { - temperature?: number; - /** - * Arbitrary sampling parameters merged into the request body as-is, after the named request - * fields, so keys here override them. Lets custom OpenAI-compatible servers (llama.cpp, vLLM, - * SGLang, ...) receive parameters pi does not model, e.g. `top_p`, `top_k`, `min_p`, - * `repetition_penalty`. Merged over `Model.samplingParams` per key. Only applied by - * OpenAI-compatible adapters (completions, responses, Azure responses); other APIs ignore it. - */ - samplingParams?: Record; - maxTokens?: number; +/** Authentication, HTTP transport, and lifecycle callbacks shared by provider requests. */ +export interface ProviderRequestOptions> { signal?: AbortSignal; apiKey?: string; /** @@ -134,31 +125,20 @@ export interface StreamOptions { */ fetch?: FetchFunction; /** - * Preferred transport for providers that support multiple transports. - * Providers that do not support this option ignore it. - */ - transport?: Transport; - /** - * Prompt cache retention preference. Providers map this to their supported values. - * Default: "short". - */ - cacheRetention?: CacheRetention; - /** - * Optional session identifier for providers that support session-based caching. - * Providers can use this to enable prompt caching, request routing, or other - * session-aware features. Ignored by providers that don't support it. + * Provider-scoped environment values. These take precedence over process.env for + * provider configuration such as regional settings, endpoint placeholders, and + * proxy variables. */ - sessionId?: string; + env?: ProviderEnv; /** * Optional callback for inspecting or replacing provider payloads before sending. * Return undefined to keep the payload unchanged. */ - onPayload?: (payload: unknown, model: Model) => unknown | undefined | Promise; + onPayload?: (payload: unknown, model: TModel) => unknown | undefined | Promise; /** - * Optional callback invoked after an HTTP response is received and before - * its body stream is consumed. + * Optional callback invoked after an HTTP response is received. */ - onResponse?: (response: ProviderResponse, model: Model) => void | Promise; + onResponse?: (response: ProviderResponse, model: TModel) => void | Promise; /** * Optional custom HTTP headers to include in API requests. * Merged with provider defaults; caller values override default headers. @@ -173,12 +153,6 @@ export interface StreamOptions { * For example, OpenAI and Anthropic SDK clients default to 10 minutes. */ timeoutMs?: number; - /** - * WebSocket connect timeout in milliseconds for providers that support - * WebSocket transports. This covers the connection/open handshake only; - * stream idleness after connection uses timeoutMs. - */ - websocketConnectTimeoutMs?: number; /** * Maximum retry attempts for providers/SDKs that support client-side retries. * For example, OpenAI and Anthropic SDK clients default to 2. @@ -192,27 +166,67 @@ export interface StreamOptions { * Default: 60000 (60 seconds). Set to 0 to disable the cap. */ maxRetryDelayMs?: number; +} + +export interface StreamOptions extends ProviderRequestOptions> { + /** + * Optional callback invoked after an HTTP response is received and before + * its body stream is consumed. + */ + onResponse?: (response: ProviderResponse, model: Model) => void | Promise; + temperature?: number; + /** + * Arbitrary sampling parameters merged into the request body as-is, after the named request + * fields, so keys here override them. Lets custom OpenAI-compatible servers (llama.cpp, vLLM, + * SGLang, ...) receive parameters pi does not model, e.g. `top_p`, `top_k`, `min_p`, + * `repetition_penalty`. Merged over `Model.samplingParams` per key. Only applied by + * OpenAI-compatible adapters (completions, responses, Azure responses); other APIs ignore it. + */ + samplingParams?: Record; + maxTokens?: number; + /** + * Preferred transport for providers that support multiple transports. + * Providers that do not support this option ignore it. + */ + transport?: Transport; + /** + * Prompt cache retention preference. Providers map this to their supported values. + * Default: "short". + */ + cacheRetention?: CacheRetention; + /** + * Optional session identifier for providers that support session-based caching. + * Providers can use this to enable prompt caching, request routing, or other + * session-aware features. Ignored by providers that don't support it. + */ + sessionId?: string; + /** + * WebSocket connect timeout in milliseconds for providers that support + * WebSocket transports. This covers the connection/open handshake only; + * stream idleness after connection uses timeoutMs. + */ + websocketConnectTimeoutMs?: number; /** * Optional metadata to include in API requests. * Providers extract the fields they understand and ignore the rest. * For example, Anthropic uses `user_id` for abuse tracking and rate limiting. */ metadata?: Record; - /** - * Provider-scoped environment values. These take precedence over process.env for - * provider configuration such as regional settings, endpoint placeholders, and - * proxy variables. - */ - env?: ProviderEnv; } export type ProviderStreamOptions = StreamOptions & Record; -export interface DeferredFetchOptions extends StreamOptions { - /** Maximum time in milliseconds to wait for a terminal response. Zero checks once. */ +export interface DeferredFetchOptions extends ProviderRequestOptions> { + /** + * Maximum provider long-poll duration in milliseconds. + * Defaults to 0, which performs one status check. + */ wait?: number; } +/** Request options for best-effort deferred-response cancellation. */ +export type DeferredCancelOptions = ProviderRequestOptions>; + /** * Maps known APIs to their full provider-specific stream option types. * Type-only imports from API implementation modules are erased at emit, so @@ -255,7 +269,7 @@ export interface ProviderStreams { handle: DeferredHandle, options?: DeferredFetchOptions, ): AssistantMessageEventStream; - cancelDeferred?(model: Model, handle: DeferredHandle, options?: StreamOptions): Promise; + cancelDeferred?(model: Model, handle: DeferredHandle, options?: DeferredCancelOptions): Promise; } /** @@ -272,47 +286,7 @@ export interface ProviderImages { ): Promise; } -export interface ImagesOptions { - signal?: AbortSignal; - apiKey?: string; - /** Optional fetch implementation for provider HTTP requests. Defaults to `globalThis.fetch`. */ - fetch?: FetchFunction; - /** - * Provider-scoped environment values. These take precedence over process.env for - * provider configuration such as endpoint placeholders and proxy variables. - */ - env?: ProviderEnv; - /** - * Optional callback for inspecting or replacing provider payloads before sending. - * Return undefined to keep the payload unchanged. - */ - onPayload?: (payload: unknown, model: ImagesModel) => unknown | undefined | Promise; - /** - * Optional callback invoked after an HTTP response is received. - */ - onResponse?: (response: ProviderResponse, model: ImagesModel) => void | Promise; - /** - * Optional custom HTTP headers to include in API requests. - * Merged with provider defaults; can override default headers. - * A null value suppresses a provider/API default header with the same name. - */ - headers?: ProviderHeaders; - /** - * HTTP request timeout in milliseconds for providers/SDKs that support it. - */ - timeoutMs?: number; - /** - * Maximum retry attempts for providers/SDKs that support client-side retries. - */ - maxRetries?: number; - /** - * Maximum delay in milliseconds to wait for a retry when the server requests a long wait. - * If the server's requested delay exceeds this value, the request fails immediately - * with an error containing the requested delay, allowing higher-level retry logic - * to handle it with user visibility. - * Default: 60000 (60 seconds). Set to 0 to disable the cap. - */ - maxRetryDelayMs?: number; +export interface ImagesOptions extends ProviderRequestOptions> { /** * Optional metadata to include in API requests. * Providers extract the fields they understand and ignore the rest. diff --git a/packages/ai/test/providers.test.ts b/packages/ai/test/providers.test.ts index 7633cea2d5a..14f0b20a09c 100644 --- a/packages/ai/test/providers.test.ts +++ b/packages/ai/test/providers.test.ts @@ -11,7 +11,15 @@ import { cloudflareAIGatewayProvider } from "../src/providers/cloudflare-ai-gate import { cloudflareWorkersAIProvider } from "../src/providers/cloudflare-workers-ai.ts"; import { fauxAssistantMessage, fauxProvider } from "../src/providers/faux.ts"; import { googleVertexProvider } from "../src/providers/google-vertex.ts"; -import type { Api, Context, DeferredHandle, Model, ProviderStreams } from "../src/types.ts"; +import type { + Api, + Context, + DeferredCancelOptions, + DeferredFetchOptions, + DeferredHandle, + Model, + ProviderStreams, +} from "../src/types.ts"; import { AssistantMessageEventStream } from "../src/utils/event-stream.ts"; function fakeAuthContext(env: Record, files: string[] = []): AuthContext { @@ -415,6 +423,81 @@ describe("createProvider", () => { expect(capturedEnv).toEqual({ PROVIDER_ONLY: "provider", REQUEST_ONLY: "request", SHARED: "request" }); }); + it("applies resolved request options to deferred fetch and cancellation", async () => { + let fetchedModel: Model | undefined; + let fetchedOptions: DeferredFetchOptions | undefined; + let cancelledOptions: DeferredCancelOptions | undefined; + const deferredModel = { ...testModel("api-a", "model-a"), provider: "deferred-provider" }; + const streams = recordingStreams("deferred", []); + streams.fetchDeferred = (model, _handle, options) => { + fetchedModel = model; + fetchedOptions = options; + return streams.streamSimple(model, context); + }; + streams.cancelDeferred = async (_model, _handle, options) => { + cancelledOptions = options; + }; + const provider = createProvider({ + id: "deferred-provider", + auth: { + apiKey: { + name: "Test", + resolve: async () => ({ + auth: { + apiKey: "provider-key", + baseUrl: "https://resolved.test/v1", + headers: { Authorization: "Bearer provider", "X-Shared": "provider" }, + }, + env: { PROVIDER_ONLY: "provider", SHARED: "provider" }, + }), + }, + }, + models: [deferredModel], + api: streams, + }); + const models = createModels(); + models.setProvider(provider); + const handle: DeferredHandle = { + provider: deferredModel.provider, + modelId: deferredModel.id, + api: deferredModel.api, + id: "response-1", + }; + + await models.fetchDeferred(deferredModel, handle, { + wait: 50, + timeoutMs: 100, + apiKey: "request-key", + headers: { "X-Request": "request", "x-shared": "request" }, + env: { REQUEST_ONLY: "request", SHARED: "request" }, + transformHeaders: (headers) => ({ ...headers, "X-Transformed": "yes" }), + }); + await models.cancelDeferred(deferredModel, handle, { + timeoutMs: 200, + transformHeaders: (headers) => ({ ...headers, "X-Cancel": "yes" }), + }); + + expect(fetchedModel?.baseUrl).toBe("https://resolved.test/v1"); + expect(fetchedOptions).toMatchObject({ + wait: 50, + timeoutMs: 100, + apiKey: "request-key", + headers: { + Authorization: "Bearer provider", + "X-Request": "request", + "x-shared": "request", + "X-Transformed": "yes", + }, + env: { PROVIDER_ONLY: "provider", REQUEST_ONLY: "request", SHARED: "request" }, + }); + expect(cancelledOptions).toMatchObject({ + timeoutMs: 200, + apiKey: "provider-key", + headers: { Authorization: "Bearer provider", "X-Shared": "provider", "X-Cancel": "yes" }, + env: { PROVIDER_ONLY: "provider", SHARED: "provider" }, + }); + }); + it("produces a stream error for a model whose api has no implementation", async () => { const provider = createProvider({ id: "mixed", @@ -510,7 +593,7 @@ describe("fauxProvider", () => { }); if (!deferred.deferred) throw new Error("Faux response did not include a deferred handle"); - const pending = await models.fetchDeferred(model, deferred.deferred, { wait: 0 }); + const pending = await models.fetchDeferred(model, deferred.deferred); expect(pending.stopReason).toBe("deferred"); expect(pending.deferred).toEqual(deferred.deferred); diff --git a/packages/coding-agent/src/core/model-runtime.ts b/packages/coding-agent/src/core/model-runtime.ts index c3f8348c64b..64806ed9070 100644 --- a/packages/coding-agent/src/core/model-runtime.ts +++ b/packages/coding-agent/src/core/model-runtime.ts @@ -14,22 +14,25 @@ import { type CredentialInfo, type CredentialStore, createModels, + type DeferredCancelOptions, type DeferredFetchOptions, type DeferredHandle, lazyStream, type Model, type Models, type ModelsApiStreamOptions, - type ModelsDeferredOptions, + type ModelsDeferredCancelOptions, + type ModelsDeferredFetchOptions, ModelsError, type ModelsRefreshOptions, type ModelsRefreshResult, + type ModelsRequestTransforms, type ModelsSimpleStreamOptions, type ModelsStore, - type ModelsStreamTransforms, type MutableModels, type Provider, type ProviderHeaders, + type ProviderRequestOptions, type SimpleStreamOptions, type StreamOptions, } from "@earendil-works/pi-ai"; @@ -559,10 +562,14 @@ export class ModelRuntime implements Models { return check ? { configured: true, source: "environment", label: check.source } : { configured: false }; } - private async prepareRequest( + private async prepareRequest( model: Model, - options: (StreamOptions & ModelsStreamTransforms) | undefined, - ): Promise<{ provider: Provider; model: Model; options: StreamOptions }> { + options: TOptions | undefined, + ): Promise<{ + provider: Provider; + model: Model; + options: Omit & ProviderRequestOptions; + }> { const provider = this.models.getProvider(model.provider); if (!provider) throw new ModelsError("provider", `Unknown provider: ${model.provider}`); const resolution = await this.getAuth(model, { @@ -572,7 +579,8 @@ export class ModelRuntime implements Models { }); if (!resolution) throw new ModelsError("auth", `Provider is not configured: ${model.provider}`); - const { transformHeaders, ...providerOptions } = options ?? {}; + const { transformHeaders, ...rawProviderOptions } = options ?? {}; + const providerOptions = rawProviderOptions as Omit & ProviderRequestOptions; let headers = mergeHeaders(resolution.auth.headers, providerOptions.headers); if (transformHeaders) headers = await transformHeaders(headers ?? {}); const env = @@ -587,7 +595,7 @@ export class ModelRuntime implements Models { apiKey: providerOptions.apiKey ?? resolution.auth.apiKey, headers, env, - }, + } as Omit & ProviderRequestOptions, }; } @@ -599,7 +607,7 @@ export class ModelRuntime implements Models { return lazyStream(model, async () => { const prepared = await this.prepareRequest( model, - options as (StreamOptions & ModelsStreamTransforms) | undefined, + options as (StreamOptions & ModelsRequestTransforms) | undefined, ); return prepared.provider.stream( prepared.model as Model, @@ -631,7 +639,7 @@ export class ModelRuntime implements Models { async fetchDeferred( model: Model, handle: DeferredHandle, - options?: ModelsDeferredOptions, + options?: ModelsDeferredFetchOptions, ): Promise { return lazyStream(model, async () => { const prepared = await this.prepareRequest(model, options); @@ -642,12 +650,16 @@ export class ModelRuntime implements Models { }).result(); } - async cancelDeferred(model: Model, handle: DeferredHandle, options?: ModelsDeferredOptions): Promise { + async cancelDeferred( + model: Model, + handle: DeferredHandle, + options?: ModelsDeferredCancelOptions, + ): Promise { const prepared = await this.prepareRequest(model, options); if (!prepared.provider.cancelDeferred) { throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`); } - await prepared.provider.cancelDeferred(prepared.model, handle, prepared.options); + await prepared.provider.cancelDeferred(prepared.model, handle, prepared.options as DeferredCancelOptions); } login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise { diff --git a/packages/coding-agent/test/model-runtime-modify-models-compat.test.ts b/packages/coding-agent/test/model-runtime-modify-models-compat.test.ts index 942e1616aa4..f3000513184 100644 --- a/packages/coding-agent/test/model-runtime-modify-models-compat.test.ts +++ b/packages/coding-agent/test/model-runtime-modify-models-compat.test.ts @@ -3,6 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createAssistantMessageEventStream, + type DeferredCancelOptions, + type DeferredFetchOptions, InMemoryModelsStore, type Model, type Provider, @@ -113,7 +115,9 @@ describe("extension provider model lifecycle", () => { baseUrl: "https://native.test/v1", }; let fetchedBaseUrl: string | undefined; + let fetchedOptions: DeferredFetchOptions | undefined; let cancelledId: string | undefined; + let cancelledOptions: DeferredCancelOptions | undefined; const provider: Provider = { id: "extension-native-deferred", name: "Extension Native Deferred", @@ -130,8 +134,9 @@ describe("extension provider model lifecycle", () => { streamSimple: () => { throw new Error("unused"); }, - fetchDeferred: (requestModel) => { + fetchDeferred: (requestModel, _handle, options) => { fetchedBaseUrl = requestModel.baseUrl; + fetchedOptions = options; const message = { role: "assistant" as const, content: [], @@ -155,8 +160,9 @@ describe("extension provider model lifecycle", () => { stream.end(message); return stream; }, - cancelDeferred: async (_requestModel, handle) => { + cancelDeferred: async (_requestModel, handle, options) => { cancelledId = handle.id; + cancelledOptions = options; }, }; @@ -164,21 +170,46 @@ describe("extension provider model lifecycle", () => { const composedModel = runtime.getModel(provider.id, nativeModel.id); expect(composedModel).toBeDefined(); - await runtime.fetchDeferred(composedModel!, { - provider: provider.id, - modelId: nativeModel.id, - api: nativeModel.api, - id: "fetch-id", - }); - await runtime.cancelDeferred(composedModel!, { - provider: provider.id, - modelId: nativeModel.id, - api: nativeModel.api, - id: "cancel-id", - }); + await runtime.fetchDeferred( + composedModel!, + { + provider: provider.id, + modelId: nativeModel.id, + api: nativeModel.api, + id: "fetch-id", + }, + { + wait: 25, + headers: { "X-Fetch": "fetch" }, + transformHeaders: (headers) => ({ ...headers, "X-Transformed": "fetch" }), + }, + ); + await runtime.cancelDeferred( + composedModel!, + { + provider: provider.id, + modelId: nativeModel.id, + api: nativeModel.api, + id: "cancel-id", + }, + { + timeoutMs: 100, + transformHeaders: (headers) => ({ ...headers, "X-Transformed": "cancel" }), + }, + ); expect(fetchedBaseUrl).toBe("https://overlay.test/v1"); + expect(fetchedOptions).toMatchObject({ + apiKey: "key", + wait: 25, + headers: { "X-Fetch": "fetch", "X-Transformed": "fetch" }, + }); expect(cancelledId).toBe("cancel-id"); + expect(cancelledOptions).toMatchObject({ + apiKey: "key", + timeoutMs: 100, + headers: { "X-Transformed": "cancel" }, + }); } finally { rmSync(tempDir, { recursive: true, force: true }); } From d3da2e968a19fdd0b06e0bd1e532164b1baecbd1 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 4 Aug 2026 13:00:22 +0200 Subject: [PATCH 06/34] fix(ai): test composite OAuth refresh cancellation --- packages/ai/test/models-runtime.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ai/test/models-runtime.test.ts b/packages/ai/test/models-runtime.test.ts index 2e5626797d2..ffbcf6af3fa 100644 --- a/packages/ai/test/models-runtime.test.ts +++ b/packages/ai/test/models-runtime.test.ts @@ -766,7 +766,9 @@ describe("Models runtime", () => { controller.abort(); await expect(auth).rejects.toMatchObject({ name: "AbortError" }); - expect(receivedSignal).toBe(controller.signal); + expect(receivedSignal).toBeInstanceOf(AbortSignal); + expect(receivedSignal?.aborted).toBe(true); + expect(receivedSignal?.reason).toBe(controller.signal.reason); finishRefresh?.({ ...previous, access: "new", expires: Date.now() + 60_000 }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(await credentials.read("p1")).toEqual(previous); From 97f0ccdd96cc207b6ad3630c56eea4d32dbdcf53 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 4 Aug 2026 13:04:02 +0200 Subject: [PATCH 07/34] fix(coding-agent): recursively merge nested settings, closes #7572 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/settings-manager.ts | 37 +++++++++---------- ...7572-provider-retry-settings-merge.test.ts | 35 ++++++++++++++++++ 3 files changed, 53 insertions(+), 20 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/7572-provider-retry-settings-merge.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6382adf7bca..158a387a614 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -84,6 +84,7 @@ ### Fixed +- Fixed project-level nested provider retry settings replacing unmodified global provider retry settings ([#7572](https://github.com/earendil-works/pi/issues/7572)). - Fixed inherited GitHub Copilot Grok 4.5 requests to use the supported Responses API ([#7560](https://github.com/earendil-works/pi/issues/7560)). - Fixed fullscreen shutdown leaking terminal capability-query replies into the parent shell prompt. - Fixed bare exact `--model` IDs shared by multiple providers choosing the first catalog entry instead of the sole authenticated provider or a clear ambiguity error ([#7327](https://github.com/earendil-works/pi/issues/7327)). diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 9571cbd94b6..05872d7a674 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -133,37 +133,34 @@ export interface Settings { fullscreenScrollbar?: ScrollViewScrollbar; // default: "auto"; no effect in regular UI mode } -/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ -function deepMergeSettings(base: Settings, overrides: Settings): Settings { - const result: Settings = { ...base }; +function isMergeableObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} - for (const key of Object.keys(overrides) as (keyof Settings)[]) { - const overrideValue = overrides[key]; - const baseValue = base[key]; +function deepMergeObjects(base: Record, overrides: Record): Record { + const result = { ...base }; + for (const key of Object.keys(overrides)) { + const overrideValue = overrides[key]; if (overrideValue === undefined) { continue; } - // For nested objects, merge recursively - if ( - typeof overrideValue === "object" && - overrideValue !== null && - !Array.isArray(overrideValue) && - typeof baseValue === "object" && - baseValue !== null && - !Array.isArray(baseValue) - ) { - (result as Record)[key] = { ...baseValue, ...overrideValue }; - } else { - // For primitives and arrays, override value wins - (result as Record)[key] = overrideValue; - } + const baseValue = base[key]; + result[key] = + isMergeableObject(baseValue) && isMergeableObject(overrideValue) + ? deepMergeObjects(baseValue, overrideValue) + : overrideValue; } return result; } +/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ +function deepMergeSettings(base: Settings, overrides: Settings): Settings { + return deepMergeObjects(base as Record, overrides as Record) as Settings; +} + function parseTimeoutSetting(value: unknown, settingName: string): number | undefined { const timeoutMs = parseHttpIdleTimeoutMs(value); if (timeoutMs !== undefined) { diff --git a/packages/coding-agent/test/suite/regressions/7572-provider-retry-settings-merge.test.ts b/packages/coding-agent/test/suite/regressions/7572-provider-retry-settings-merge.test.ts new file mode 100644 index 00000000000..de6cd686a9b --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/7572-provider-retry-settings-merge.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { InMemorySettingsStorage, SettingsManager } from "../../../src/core/settings-manager.ts"; + +describe("regression #7572: nested provider retry settings merge", () => { + it("preserves global provider settings not overridden by the project", () => { + const storage = new InMemorySettingsStorage(); + storage.withLock("global", () => + JSON.stringify({ + retry: { + provider: { + timeoutMs: 30000, + maxRetryDelayMs: 45000, + }, + }, + }), + ); + storage.withLock("project", () => + JSON.stringify({ + retry: { + provider: { + maxRetries: 2, + }, + }, + }), + ); + + const settingsManager = SettingsManager.fromStorage(storage); + + expect(settingsManager.getProviderRetrySettings()).toEqual({ + timeoutMs: 30000, + maxRetries: 2, + maxRetryDelayMs: 45000, + }); + }); +}); From 2aa1f8422cd6c3ccfe070ad6d640c49250f60428 Mon Sep 17 00:00:00 2001 From: Leonhard Breuer Date: Tue, 4 Aug 2026 13:54:14 +0200 Subject: [PATCH 08/34] feat(coding-agent): add syntax guardrail to edit tool Add a lightweight, dependency-free post-edit syntax sanity check to the edit tool: JSON.parse for .json files, and a comment/string-aware brace/paren/bracket balance + unterminated-literal scan for common C-like/JS/TS languages. Advisory only - never blocks or rejects the edit. Surfaces as EditToolDetails.syntaxWarning, appended to the tool's text output, and shown in the TUI result render. --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/core/tools/edit.ts | 31 +- .../src/core/tools/syntax-check.ts | 298 ++++++++++++++++++ packages/coding-agent/test/tools.test.ts | 69 ++++ 4 files changed, 396 insertions(+), 6 deletions(-) create mode 100644 packages/coding-agent/src/core/tools/syntax-check.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7f03ce4decf..740f9bb5afa 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added an advisory post-edit syntax guardrail to the `edit` tool: after applying edits, the resulting file is checked with a lightweight, dependency-free scan (JSON parsing for `.json`; a comment/string-aware brace, paren, and bracket balance and unterminated-literal check for common C-like/JS/TS languages). A mismatch surfaces as a warning in the tool result and TUI display without failing or reverting the edit. + ## [0.83.0-cheetahbyte.3] - 2026-08-04 ### Breaking Changes diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index b9fc4c56221..742c430c765 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -22,6 +22,7 @@ import { import { withFileMutationQueue } from "./file-mutation-queue.ts"; import { resolveToCwd } from "./path-utils.ts"; import { renderToolPath, str } from "./render-utils.ts"; +import { checkSyntax } from "./syntax-check.ts"; import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; type EditPreview = EditDiffResult | EditDiffError; @@ -65,6 +66,8 @@ export interface EditToolDetails { patch: string; /** Line number of the first change in the new file (for editor navigation) */ firstChangedLine?: number; + /** Advisory warning if the resulting file may have a syntax issue; never blocks the edit */ + syntaxWarning?: string; } /** @@ -219,11 +222,18 @@ function formatEditResult( } const resultDiff = result.details?.diff; - if (resultDiff && resultDiff !== previewDiff) { - return renderDiff(resultDiff, { filePath: rawPath ?? undefined }); - } + const diffOutput = + resultDiff && resultDiff !== previewDiff ? renderDiff(resultDiff, { filePath: rawPath ?? undefined }) : undefined; + + const syntaxWarning = result.details?.syntaxWarning; + const warningOutput = syntaxWarning + ? theme.fg("warning", `[Warning: edited file may have a syntax issue — ${syntaxWarning}]`) + : undefined; - return undefined; + if (diffOutput && warningOutput) { + return `${diffOutput}\n${warningOutput}`; + } + return diffOutput ?? warningOutput; } function getEditHeaderBg( @@ -349,14 +359,23 @@ export function createEditToolDefinition( const diffResult = generateDiffString(baseContent, newContent); const patch = generateUnifiedPatch(path, baseContent, newContent); + const syntaxWarning = checkSyntax(path, newContent); + const text = syntaxWarning + ? `Successfully replaced ${edits.length} block(s) in ${path}.\n[Warning: edited file may have a syntax issue — ${syntaxWarning}]` + : `Successfully replaced ${edits.length} block(s) in ${path}.`; return { content: [ { type: "text", - text: `Successfully replaced ${edits.length} block(s) in ${path}.`, + text, }, ], - details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine }, + details: { + diff: diffResult.diff, + patch, + firstChangedLine: diffResult.firstChangedLine, + syntaxWarning, + }, }; }); }, diff --git a/packages/coding-agent/src/core/tools/syntax-check.ts b/packages/coding-agent/src/core/tools/syntax-check.ts new file mode 100644 index 00000000000..bd1f8bbac10 --- /dev/null +++ b/packages/coding-agent/src/core/tools/syntax-check.ts @@ -0,0 +1,298 @@ +/** + * Lightweight, dependency-free post-edit syntax sanity check. + * + * This is intentionally a heuristic, not a real parser: it exists to catch + * obviously broken edits (unbalanced brackets, an unterminated string left + * dangling at end of file, invalid JSON) and surface them as an advisory + * warning. It never blocks or fails an edit. Conservative by design: when a + * construct can't be classified with confidence (notably the JS/TS + * regex-literal-vs-division ambiguity), it prefers to stay silent rather than + * risk a false positive on well-formed code. + */ + +import { extname } from "path"; + +const JSON_EXTENSIONS = new Set([".json"]); + +// Extensions where `/` can start a regex literal and `${...}` template +// interpolation needs to be understood (otherwise a `}` closing an +// interpolation would look like a stray/unbalanced brace). +const JS_LIKE_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"]); + +// Other brace/paren/bracket-oriented languages that get the same balance and +// unterminated-literal scan, without the JS-specific regex/template handling. +const OTHER_BRACE_EXTENSIONS = new Set([ + ".go", + ".rs", + ".java", + ".c", + ".cpp", + ".cc", + ".h", + ".hpp", + ".hh", + ".cs", + ".swift", + ".kt", + ".kts", +]); + +const BRACE_EXTENSIONS = new Set([...JS_LIKE_EXTENSIONS, ...OTHER_BRACE_EXTENSIONS]); + +// Identifiers/keywords after which a following `/` is a value-producing +// position (division), not the start of a regex literal. +const VALUE_KEYWORDS = new Set(["this", "super", "true", "false", "null", "undefined"]); + +// Keywords after which a following `/` cannot be division, so it must be a +// regex literal (or, rarely, something else we don't need to model). +const REGEX_ALLOWED_KEYWORDS = new Set([ + "return", + "typeof", + "instanceof", + "in", + "of", + "new", + "delete", + "void", + "throw", + "yield", + "case", + "do", + "else", + "await", + "extends", + "default", +]); + +type OpenBracket = "(" | "[" | "{" | "$"; + +const CLOSE_TO_OPEN: Record = { ")": "(", "]": "[", "}": "{" }; + +/** + * Look ahead from a `/` that might start a regex literal. Regex literals in + * JS/TS cannot contain an unescaped newline, so if no plausible closing `/` + * is found before the next newline (or end of file), this returns null and + * the caller falls back to treating the `/` as a plain operator character. + */ +function tryScanRegexLiteral(content: string, start: number): number | null { + let i = start + 1; + let inCharClass = false; + while (i < content.length) { + const c = content[i]; + if (c === "\\") { + i += 2; + continue; + } + if (c === "\n") return null; + if (inCharClass) { + if (c === "]") inCharClass = false; + i++; + continue; + } + if (c === "[") { + inCharClass = true; + i++; + continue; + } + if (c === "/") { + i++; + while (i < content.length && /[a-zA-Z]/.test(content[i])) i++; + return i; + } + i++; + } + return null; +} + +/** + * Scan brace/paren/bracket-oriented source for (a) unbalanced `()[]{}` and + * (b) a string/char/template literal still open at end of file. + * + * Comment- and string-aware, single pass. Strings/char literals are allowed + * to span multiple lines without being flagged (many languages in scope + * permit this - e.g. Rust plain strings, C# verbatim strings): the only + * unterminated-literal signal we trust is "still inside a literal at EOF", + * per the guardrail's conservative brief. + */ +function checkBraceBalance(content: string, isJsLike: boolean): string | undefined { + const stack: OpenBracket[] = []; + type State = "code" | "line-comment" | "block-comment" | "single" | "double" | "template"; + let state: State = "code"; + let regexAllowed = true; + + for (let i = 0; i < content.length; i++) { + const c = content[i]; + + if (state === "line-comment") { + if (c === "\n") state = "code"; + continue; + } + + if (state === "block-comment") { + if (c === "*" && content[i + 1] === "/") { + i++; + state = "code"; + } + continue; + } + + if (state === "single" || state === "double") { + if (c === "\\") { + i++; + continue; + } + if ((state === "single" && c === "'") || (state === "double" && c === '"')) { + state = "code"; + regexAllowed = false; + } + continue; + } + + if (state === "template") { + if (c === "\\") { + i++; + continue; + } + if (c === "`") { + state = "code"; + regexAllowed = false; + continue; + } + if (isJsLike && c === "$" && content[i + 1] === "{") { + stack.push("$"); + i++; + state = "code"; + regexAllowed = true; + } + continue; + } + + // state === "code" + if (c === "/" && content[i + 1] === "/") { + state = "line-comment"; + i++; + continue; + } + if (c === "/" && content[i + 1] === "*") { + state = "block-comment"; + i++; + continue; + } + if (isJsLike && c === "/") { + if (regexAllowed) { + const end = tryScanRegexLiteral(content, i); + if (end !== null) { + i = end - 1; + regexAllowed = false; + continue; + } + } + regexAllowed = true; + continue; + } + + if (c === "'") { + state = "single"; + continue; + } + if (c === '"') { + state = "double"; + continue; + } + if (c === "`") { + state = "template"; + continue; + } + + if (c === "(" || c === "[" || c === "{") { + stack.push(c); + regexAllowed = true; + continue; + } + if (c === ")" || c === "]" || c === "}") { + const top = stack[stack.length - 1]; + if (c === "}" && top === "$") { + stack.pop(); + state = "template"; + continue; + } + const expectedOpen = CLOSE_TO_OPEN[c]; + if (top !== expectedOpen) { + return `unbalanced '${c}' with no matching '${expectedOpen}'`; + } + stack.pop(); + regexAllowed = false; + continue; + } + + if (/[a-zA-Z_$]/.test(c)) { + let j = i; + while (j < content.length && /[a-zA-Z0-9_$]/.test(content[j])) j++; + const word = content.slice(i, j); + i = j - 1; + if (VALUE_KEYWORDS.has(word)) { + regexAllowed = false; + } else if (REGEX_ALLOWED_KEYWORDS.has(word)) { + regexAllowed = true; + } else { + // A plain identifier is value-like (e.g. `foo / bar`). + regexAllowed = false; + } + continue; + } + if (/[0-9]/.test(c)) { + let j = i; + while (j < content.length && /[0-9a-fA-F.xXeEoObB_]/.test(content[j])) j++; + i = j - 1; + regexAllowed = false; + continue; + } + if (/\s/.test(c)) { + continue; + } + // Any other punctuation/operator character: a `/` after it is allowed + // to start a regex (covers `=`, `!`, `&`, `|`, `?`, `+`, `-`, `*`, `%`, + // `^`, `~`, `<`, `>`, `,`, `;`, `:`, and `}` which is ambiguous but + // biased toward "regex allowed" since an unmatched guess falls back + // safely to division via the same-line lookahead in tryScanRegexLiteral). + regexAllowed = true; + } + + if (state === "single") return "unterminated single-quoted string at end of file"; + if (state === "double") return "unterminated double-quoted string at end of file"; + if (state === "template") return "unterminated template literal at end of file"; + if (state === "block-comment") return "unterminated block comment at end of file"; + if (stack.length > 0) { + const unclosed = stack[stack.length - 1]; + if (unclosed === "$") return "unterminated '${' template interpolation at end of file"; + return `unbalanced '${unclosed}' with no matching close`; + } + return undefined; +} + +/** + * Run a lightweight syntax sanity check on the final content of an edited + * file, keyed off the file extension. Returns a short warning reason, or + * undefined if the file type isn't covered or nothing looked wrong. + * + * This is advisory only - callers must never fail or reject an edit based on + * this result. + */ +export function checkSyntax(path: string, content: string): string | undefined { + const ext = extname(path).toLowerCase(); + + if (JSON_EXTENSIONS.has(ext)) { + try { + JSON.parse(content); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + return undefined; + } + + if (BRACE_EXTENSIONS.has(ext)) { + return checkBraceBalance(content, JS_LIKE_EXTENSIONS.has(ext)); + } + + return undefined; +} diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 63b7f626064..0b4be7de552 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -471,6 +471,75 @@ describe("Coding Agent Tools", () => { }); }); + describe("edit tool syntax guardrail", () => { + it("should not produce a syntaxWarning for a normal, valid edit to a .ts file", async () => { + const testFile = join(testDir, "guardrail-valid.ts"); + writeFileSync(testFile, "function greet(name: string): string {\n\treturn 'hi ' + name;\n}\n"); + + const result = await editTool.execute("guardrail-1", { + path: testFile, + edits: [{ oldText: "hi", newText: "hello" }], + }); + + expect(result.details?.syntaxWarning).toBeUndefined(); + expect(getTextOutput(result)).not.toContain("Warning"); + }); + + it("should produce a syntaxWarning when an edit leaves unbalanced braces in a .ts file", async () => { + const testFile = join(testDir, "guardrail-unbalanced.ts"); + writeFileSync(testFile, "function greet() {\n\treturn 1;\n}\n"); + + const result = await editTool.execute("guardrail-2", { + path: testFile, + edits: [{ oldText: "function greet() {\n", newText: "function greet() {\n\tif (true) {\n" }], + }); + + expect(result.details?.syntaxWarning).toBeDefined(); + expect(getTextOutput(result)).toContain("[Warning: edited file may have a syntax issue"); + }); + + it("should produce a syntaxWarning with a parse-error message when an edit leaves invalid JSON", async () => { + const testFile = join(testDir, "guardrail.json"); + writeFileSync(testFile, '{\n\t"a": 1\n}\n'); + + const result = await editTool.execute("guardrail-3", { + path: testFile, + edits: [{ oldText: '"a": 1', newText: '"a": 1,' }], + }); + + expect(result.details?.syntaxWarning).toBeDefined(); + expect(result.details?.syntaxWarning?.length).toBeGreaterThan(0); + expect(getTextOutput(result)).toContain("[Warning: edited file may have a syntax issue"); + }); + + it("should never produce a syntaxWarning for file types with no checker (e.g. .md), even with mismatched braces", async () => { + const testFile = join(testDir, "guardrail.md"); + writeFileSync(testFile, "# Notes\n\nSome prose here.\n"); + + const result = await editTool.execute("guardrail-4", { + path: testFile, + edits: [{ oldText: "Some prose here.", newText: "Some prose with a { stray brace." }], + }); + + expect(result.details?.syntaxWarning).toBeUndefined(); + expect(getTextOutput(result)).not.toContain("Warning"); + }); + + it("should not false-positive on a well-formed string containing a brace character", async () => { + const testFile = join(testDir, "guardrail-brace-in-string.ts"); + writeFileSync(testFile, "const s = 'placeholder';\n"); + + const result = await editTool.execute("guardrail-5", { + path: testFile, + edits: [{ oldText: "'placeholder'", newText: '"{"' }], + }); + + expect(result.details?.syntaxWarning).toBeUndefined(); + expect(getTextOutput(result)).not.toContain("Warning"); + expect(readFileSync(testFile, "utf-8")).toBe('const s = "{";\n'); + }); + }); + describe("bash tool", () => { it("should execute simple commands", async () => { const result = await bashTool.execute("test-call-8", { command: "echo 'test output'" }); From 4df38ec44ae7c3ac8d0ffae187c14a12161f8287 Mon Sep 17 00:00:00 2001 From: Leonhard Breuer Date: Tue, 4 Aug 2026 13:57:44 +0200 Subject: [PATCH 09/34] feat(coding-agent): add session recall tool Adds a "recall" tool that searches the full session history (including entries evicted from the live context by compaction) for a case-insensitive substring match, so the model can recover exact error messages, code snippets, or earlier details that were summarized away. Registered as opt-in (like grep/find/ls), not part of default active tools. --- packages/coding-agent/src/core/sdk.ts | 2 + packages/coding-agent/src/core/tools/index.ts | 19 +- .../coding-agent/src/core/tools/recall.ts | 191 ++++++++++++++++++ packages/coding-agent/src/index.ts | 4 + .../coding-agent/test/recall-tool.test.ts | 123 +++++++++++ 5 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/src/core/tools/recall.ts create mode 100644 packages/coding-agent/test/recall-tool.test.ts diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 8dadf1ec4b2..eaf85113e6d 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -25,6 +25,7 @@ import { createLsTool, createReadOnlyTools, createReadTool, + createRecallTool, createWriteTool, type ToolName, withFileMutationQueue, @@ -123,6 +124,7 @@ export { createGrepTool, createFindTool, createLsTool, + createRecallTool, }; // Helper Functions diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 19d722cfef9..aaaefde15f0 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -50,6 +50,12 @@ export { type ReadToolInput, type ReadToolOptions, } from "./read.ts"; +export { + createRecallTool, + createRecallToolDefinition, + type RecallToolDetails, + type RecallToolInput, +} from "./recall.ts"; export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, @@ -76,12 +82,13 @@ import { createFindTool, createFindToolDefinition, type FindToolOptions } from " import { createGrepTool, createGrepToolDefinition, type GrepToolOptions } from "./grep.ts"; import { createLsTool, createLsToolDefinition, type LsToolOptions } from "./ls.ts"; import { createReadTool, createReadToolDefinition, type ReadToolOptions } from "./read.ts"; +import { createRecallTool, createRecallToolDefinition } from "./recall.ts"; import { createWriteTool, createWriteToolDefinition, type WriteToolOptions } from "./write.ts"; export type Tool = AgentTool; export type ToolDef = ToolDefinition; -export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls"; -export const allToolNames: Set = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]); +export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls" | "recall"; +export const allToolNames: Set = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "recall"]); export interface ToolsOptions { read?: ReadToolOptions; @@ -109,6 +116,8 @@ export function createToolDefinition(toolName: ToolName, cwd: string, options?: return createFindToolDefinition(cwd, options?.find); case "ls": return createLsToolDefinition(cwd, options?.ls); + case "recall": + return createRecallToolDefinition(cwd); default: throw new Error(`Unknown tool name: ${toolName}`); } @@ -130,6 +139,8 @@ export function createTool(toolName: ToolName, cwd: string, options?: ToolsOptio return createFindTool(cwd, options?.find); case "ls": return createLsTool(cwd, options?.ls); + case "recall": + return createRecallTool(cwd); default: throw new Error(`Unknown tool name: ${toolName}`); } @@ -150,6 +161,7 @@ export function createReadOnlyToolDefinitions(cwd: string, options?: ToolsOption createGrepToolDefinition(cwd, options?.grep), createFindToolDefinition(cwd, options?.find), createLsToolDefinition(cwd, options?.ls), + createRecallToolDefinition(cwd), ]; } @@ -162,6 +174,7 @@ export function createAllToolDefinitions(cwd: string, options?: ToolsOptions): R grep: createGrepToolDefinition(cwd, options?.grep), find: createFindToolDefinition(cwd, options?.find), ls: createLsToolDefinition(cwd, options?.ls), + recall: createRecallToolDefinition(cwd), }; } @@ -180,6 +193,7 @@ export function createReadOnlyTools(cwd: string, options?: ToolsOptions): Tool[] createGrepTool(cwd, options?.grep), createFindTool(cwd, options?.find), createLsTool(cwd, options?.ls), + createRecallTool(cwd), ]; } @@ -192,5 +206,6 @@ export function createAllTools(cwd: string, options?: ToolsOptions): Record; + +const DEFAULT_LIMIT = 20; +/** Characters of context shown on each side of a match within an entry's JSON. */ +const SNIPPET_CONTEXT_CHARS = 150; + +export interface RecallToolDetails { + truncation?: TruncationResult; + matchLimitReached?: number; +} + +function describeEntryType(entry: SessionEntry): string { + if (entry.type === "message") { + return `message (${entry.message.role})`; + } + return entry.type; +} + +/** + * Build a snippet of the entry's JSON around the first occurrence of `query` + * (case-insensitive). Falls back to the start of the JSON if, for some reason, + * the match cannot be located (should not happen since callers already found it). + */ +function buildSnippet(entryJson: string, query: string): string { + const matchIndex = entryJson.toLowerCase().indexOf(query.toLowerCase()); + if (matchIndex < 0) { + const head = entryJson.slice(0, SNIPPET_CONTEXT_CHARS * 2); + return entryJson.length > head.length ? `${head}...` : head; + } + const start = Math.max(0, matchIndex - SNIPPET_CONTEXT_CHARS); + const end = Math.min(entryJson.length, matchIndex + query.length + SNIPPET_CONTEXT_CHARS); + let snippet = entryJson.slice(start, end); + if (start > 0) snippet = `...${snippet}`; + if (end < entryJson.length) snippet = `${snippet}...`; + return snippet; +} + +function formatRecallCall(args: { query?: string; limit?: number } | undefined, theme: Theme): string { + const query = typeof args?.query === "string" ? args.query : ""; + let text = `${theme.fg("toolTitle", theme.bold("recall"))} ${theme.fg("accent", `"${query}"`)}`; + if (args?.limit !== undefined) { + text += theme.fg("toolOutput", ` (limit ${args.limit})`); + } + return text; +} + +function formatRecallResult( + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: RecallToolDetails; + }, + options: ToolRenderResultOptions, + theme: Theme, + showImages: boolean, +): string { + const output = getTextOutput(result, showImages).trim(); + let text = ""; + if (output) { + const lines = output.split("\n"); + const maxLines = options.expanded ? lines.length : 15; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; + if (remaining > 0) { + text += theme.fg("muted", `\n... (${remaining} more lines)`); + } + } + + const matchLimit = result.details?.matchLimitReached; + const truncation = result.details?.truncation; + if (matchLimit || truncation?.truncated) { + const warnings: string[] = []; + if (matchLimit) warnings.push(`${matchLimit} matches limit`); + if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); + text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; + } + return text; +} + +export function createRecallToolDefinition( + _cwd: string, +): ToolDefinition { + return { + name: "recall", + label: "recall", + description: + "Search the full session history (including entries removed from the live context by compaction) for a case-insensitive substring match. Use this to recover exact error messages, code snippets, or earlier details that were summarized away. " + + `Returns up to ${DEFAULT_LIMIT} matches with surrounding context, or ${DEFAULT_MAX_BYTES / 1024}KB, whichever is hit first.`, + promptSnippet: "Search full session history for details lost to compaction", + parameters: recallSchema, + async execute(_toolCallId, { query, limit }: RecallToolInput, signal?: AbortSignal, _onUpdate?, ctx?) { + if (signal?.aborted) { + throw new Error("Operation aborted"); + } + + if (!ctx || !ctx.sessionManager) { + return { + content: [ + { + type: "text", + text: "Recall is unavailable: no active session. This tool requires a running agent session with a session manager.", + }, + ], + details: undefined, + }; + } + + const entries = ctx.sessionManager.getEntries(); + const lowerQuery = query.toLowerCase(); + const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT); + + const resultLines: string[] = []; + let matchCount = 0; + let matchLimitReached = false; + + for (const entry of entries) { + const entryJson = JSON.stringify(entry); + if (!entryJson.toLowerCase().includes(lowerQuery)) continue; + + matchCount++; + if (matchCount > effectiveLimit) { + matchLimitReached = true; + break; + } + + const snippet = buildSnippet(entryJson, query); + resultLines.push(`[${entry.timestamp}] ${describeEntryType(entry)}: ${snippet}`); + } + + if (resultLines.length === 0) { + return { + content: [{ type: "text", text: `No matches found for "${query}" in session history.` }], + details: undefined, + }; + } + + const rawOutput = resultLines.join("\n\n"); + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + let output = truncation.content; + const details: RecallToolDetails = {}; + const notices: string[] = []; + if (matchLimitReached) { + notices.push( + `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or narrow your query`, + ); + details.matchLimitReached = effectiveLimit; + } + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + if (notices.length > 0) { + output += `\n\n[${notices.join(". ")}]`; + } + + return { + content: [{ type: "text", text: output }], + details: Object.keys(details).length > 0 ? details : undefined, + }; + }, + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatRecallCall(args, theme)); + return text; + }, + renderResult(result, options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatRecallResult(result as any, options, theme, context.showImages)); + return text; + }, + }; +} + +export function createRecallTool(cwd: string): AgentTool { + return wrapToolDefinition(createRecallToolDefinition(cwd)); +} diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 3d4ca9831f2..d74437fd48f 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -220,6 +220,7 @@ export { createLsTool, createReadOnlyTools, createReadTool, + createRecallTool, createWriteTool, type PromptTemplate, } from "./core/sdk.ts"; @@ -286,6 +287,7 @@ export { createLocalBashOperations, createLsToolDefinition, createReadToolDefinition, + createRecallToolDefinition, createWriteToolDefinition, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, @@ -310,6 +312,8 @@ export { type ReadToolDetails, type ReadToolInput, type ReadToolOptions, + type RecallToolDetails, + type RecallToolInput, type ToolsOptions, type TruncationOptions, type TruncationResult, diff --git a/packages/coding-agent/test/recall-tool.test.ts b/packages/coding-agent/test/recall-tool.test.ts new file mode 100644 index 00000000000..c14aab171fe --- /dev/null +++ b/packages/coding-agent/test/recall-tool.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import type { ExtensionContext } from "../src/core/extensions/index.ts"; +import type { SessionEntry } from "../src/core/session-manager.ts"; +import { createRecallToolDefinition } from "../src/core/tools/recall.ts"; + +function makeCtx(entries: SessionEntry[]): ExtensionContext { + return { + sessionManager: { + getEntries: () => entries, + }, + } as unknown as ExtensionContext; +} + +function messageEntry(id: string, role: "user" | "assistant", text: string, timestamp: string): SessionEntry { + return { + type: "message", + id, + parentId: null, + timestamp, + message: { + role, + content: [{ type: "text", text }], + }, + } as unknown as SessionEntry; +} + +describe("recall tool", () => { + it("finds a keyword present only in an old entry evicted from live context by compaction", async () => { + const entries: SessionEntry[] = [ + messageEntry("1", "user", "Please look into the ZORKMID-4471 stack trace", "2026-08-01T10:00:00.000Z"), + messageEntry( + "2", + "assistant", + "The error was: TypeError: Cannot read properties of undefined (reading 'foo') at ZORKMID-4471", + "2026-08-01T10:00:05.000Z", + ), + { + type: "compaction", + id: "3", + parentId: "2", + timestamp: "2026-08-01T10:05:00.000Z", + summary: "Investigated a stack trace and fixed the bug.", + firstKeptEntryId: "4", + tokensBefore: 5000, + } as unknown as SessionEntry, + messageEntry("4", "user", "Great, thanks for the fix!", "2026-08-01T10:05:10.000Z"), + messageEntry("5", "assistant", "You're welcome.", "2026-08-01T10:05:12.000Z"), + ]; + + const tool = createRecallToolDefinition("/tmp/project"); + const result = await tool.execute("call-1", { query: "ZORKMID-4471" }, undefined, undefined, makeCtx(entries)); + + const text = result.content.find((c) => c.type === "text")?.text ?? ""; + expect(text).toContain("ZORKMID-4471"); + // Both the original mention and the error message referencing it should be found. + expect((text.match(/ZORKMID-4471/g) ?? []).length).toBeGreaterThanOrEqual(2); + }); + + it("is case-insensitive", async () => { + const entries: SessionEntry[] = [ + messageEntry("1", "user", "The Widget Factory failed", "2026-08-01T10:00:00.000Z"), + ]; + const tool = createRecallToolDefinition("/tmp/project"); + + const result = await tool.execute("call-1", { query: "widget factory" }, undefined, undefined, makeCtx(entries)); + const text = result.content.find((c) => c.type === "text")?.text ?? ""; + expect(text).toContain("Widget Factory"); + }); + + it("returns a clear empty-result message when nothing matches", async () => { + const entries: SessionEntry[] = [messageEntry("1", "user", "hello there", "2026-08-01T10:00:00.000Z")]; + const tool = createRecallToolDefinition("/tmp/project"); + + const result = await tool.execute( + "call-1", + { query: "nonexistent-keyword-xyz" }, + undefined, + undefined, + makeCtx(entries), + ); + const text = result.content.find((c) => c.type === "text")?.text ?? ""; + expect(text.toLowerCase()).toContain("no matches"); + expect(result.details).toBeUndefined(); + }); + + it("respects limit and truncates with a notice when there are many matches", async () => { + const entries: SessionEntry[] = []; + for (let i = 0; i < 30; i++) { + entries.push(messageEntry(`id-${i}`, "user", `needle occurrence number ${i}`, "2026-08-01T10:00:00.000Z")); + } + const tool = createRecallToolDefinition("/tmp/project"); + + const result = await tool.execute( + "call-1", + { query: "needle", limit: 10 }, + undefined, + undefined, + makeCtx(entries), + ); + const text = result.content.find((c) => c.type === "text")?.text ?? ""; + const occurrences = (text.match(/needle occurrence/g) ?? []).length; + expect(occurrences).toBe(10); + expect(text).toContain("limit reached"); + expect(result.details).toBeDefined(); + }); + + it("returns the graceful 'no active session' message when ctx is undefined", async () => { + // The ToolDefinition interface types `ctx` as required, but at runtime (e.g. a + // plain AgentTool invoked outside a full session runtime, per + // tool-definition-wrapper.ts) it can be undefined. Cast to exercise that path. + const tool = createRecallToolDefinition("/tmp/project"); + const result = await tool.execute( + "call-1", + { query: "anything" }, + undefined, + undefined, + undefined as unknown as ExtensionContext, + ); + const text = result.content.find((c) => c.type === "text")?.text ?? ""; + expect(text.toLowerCase()).toContain("recall is unavailable"); + expect(result.details).toBeUndefined(); + }); +}); From 5a7bfb8778560082f75be732e6fb6d902c86e86c Mon Sep 17 00:00:00 2001 From: Leonhard Breuer Date: Tue, 4 Aug 2026 13:59:10 +0200 Subject: [PATCH 10/34] fix(coding-agent): resolve @cheetahbyte workspace packages to source in vitest config The @cheetahbyte/* workspace aliases were missing from vitest.config.ts (only the legacy @earendil-works/@mariozechner scopes were aliased), so coding-agent tests failed to resolve @cheetahbyte/pi-ai, pi-agent-core, pi-tui, pi-client, and pi-protocol in a worktree without a prior `npm run build`. Mirrors the existing alias pattern already used here and in packages/client and packages/server. --- packages/coding-agent/vitest.config.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/coding-agent/vitest.config.ts b/packages/coding-agent/vitest.config.ts index 3c49c145df8..00dc0dbffbf 100644 --- a/packages/coding-agent/vitest.config.ts +++ b/packages/coding-agent/vitest.config.ts @@ -34,6 +34,25 @@ export default mergeConfig( { find: /^@mariozechner\/pi-ai\/oauth$/, replacement: workspaceSourcePaths.aiOAuth }, { find: /^@mariozechner\/pi-agent-core$/, replacement: workspaceSourcePaths.agentIndex }, { find: /^@mariozechner\/pi-tui$/, replacement: workspaceSourcePaths.tuiIndex }, + // The @cheetahbyte/* packages ship built dist/ output; when running tests + // against a worktree that hasn't been built yet, resolve straight to source + // so `npm test` doesn't require a full `npm run build` first (mirrors the + // @earendil-works/@mariozechner aliases above). + { + find: /^@cheetahbyte\/pi-client$/, + replacement: fileURLToPath(new URL("../client/src/index.ts", import.meta.url)), + }, + { + find: /^@cheetahbyte\/pi-protocol$/, + replacement: fileURLToPath(new URL("../protocol/src/index.ts", import.meta.url)), + }, + { find: /^@cheetahbyte\/pi-ai$/, replacement: workspaceSourcePaths.aiIndex }, + { + find: /^@cheetahbyte\/pi-ai\/(.+)$/, + replacement: `${fileURLToPath(new URL("../ai/src/", import.meta.url))}$1.ts`, + }, + { find: /^@cheetahbyte\/pi-agent-core$/, replacement: workspaceSourcePaths.agentIndex }, + { find: /^@cheetahbyte\/pi-tui$/, replacement: workspaceSourcePaths.tuiIndex }, ], }, }), From 5fc158e607e074ef42e903e1c0bbf537ab0f979e Mon Sep 17 00:00:00 2001 From: Leonhard Breuer Date: Tue, 4 Aug 2026 13:59:27 +0200 Subject: [PATCH 11/34] feat(coding-agent): add symbol-aware search tool Adds a "symbol" tool that searches for exact-name declarations (function, class, method, type, struct, etc.) across source files and returns the actual declaration with its extracted body, instead of raw grep-style text matches. Covers JS/TS, Python, Go, Rust, Java, and C# via a per-extension regex ruleset, with string/comment-aware brace matching for body extraction and an indentation-based fallback for Python. Respects .gitignore. Registered as read-only and opt-in (not in defaultActiveToolNames), matching grep/find/ls precedent. --- packages/coding-agent/src/core/sdk.ts | 2 + packages/coding-agent/src/core/tools/index.ts | 21 +- .../coding-agent/src/core/tools/symbol.ts | 683 ++++++++++++++++++ packages/coding-agent/src/index.ts | 5 + .../coding-agent/test/symbol-tool.test.ts | 186 +++++ 5 files changed, 895 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/src/core/tools/symbol.ts create mode 100644 packages/coding-agent/test/symbol-tool.test.ts diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 8dadf1ec4b2..e621f6c2b14 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -25,6 +25,7 @@ import { createLsTool, createReadOnlyTools, createReadTool, + createSymbolTool, createWriteTool, type ToolName, withFileMutationQueue, @@ -123,6 +124,7 @@ export { createGrepTool, createFindTool, createLsTool, + createSymbolTool, }; // Helper Functions diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 19d722cfef9..346ee57dd6f 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -50,6 +50,13 @@ export { type ReadToolInput, type ReadToolOptions, } from "./read.ts"; +export { + createSymbolTool, + createSymbolToolDefinition, + type SymbolToolDetails, + type SymbolToolInput, + type SymbolToolOptions, +} from "./symbol.ts"; export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, @@ -76,12 +83,13 @@ import { createFindTool, createFindToolDefinition, type FindToolOptions } from " import { createGrepTool, createGrepToolDefinition, type GrepToolOptions } from "./grep.ts"; import { createLsTool, createLsToolDefinition, type LsToolOptions } from "./ls.ts"; import { createReadTool, createReadToolDefinition, type ReadToolOptions } from "./read.ts"; +import { createSymbolTool, createSymbolToolDefinition, type SymbolToolOptions } from "./symbol.ts"; import { createWriteTool, createWriteToolDefinition, type WriteToolOptions } from "./write.ts"; export type Tool = AgentTool; export type ToolDef = ToolDefinition; -export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls"; -export const allToolNames: Set = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]); +export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls" | "symbol"; +export const allToolNames: Set = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "symbol"]); export interface ToolsOptions { read?: ReadToolOptions; @@ -91,6 +99,7 @@ export interface ToolsOptions { grep?: GrepToolOptions; find?: FindToolOptions; ls?: LsToolOptions; + symbol?: SymbolToolOptions; } export function createToolDefinition(toolName: ToolName, cwd: string, options?: ToolsOptions): ToolDef { @@ -109,6 +118,8 @@ export function createToolDefinition(toolName: ToolName, cwd: string, options?: return createFindToolDefinition(cwd, options?.find); case "ls": return createLsToolDefinition(cwd, options?.ls); + case "symbol": + return createSymbolToolDefinition(cwd, options?.symbol); default: throw new Error(`Unknown tool name: ${toolName}`); } @@ -130,6 +141,8 @@ export function createTool(toolName: ToolName, cwd: string, options?: ToolsOptio return createFindTool(cwd, options?.find); case "ls": return createLsTool(cwd, options?.ls); + case "symbol": + return createSymbolTool(cwd, options?.symbol); default: throw new Error(`Unknown tool name: ${toolName}`); } @@ -150,6 +163,7 @@ export function createReadOnlyToolDefinitions(cwd: string, options?: ToolsOption createGrepToolDefinition(cwd, options?.grep), createFindToolDefinition(cwd, options?.find), createLsToolDefinition(cwd, options?.ls), + createSymbolToolDefinition(cwd, options?.symbol), ]; } @@ -162,6 +176,7 @@ export function createAllToolDefinitions(cwd: string, options?: ToolsOptions): R grep: createGrepToolDefinition(cwd, options?.grep), find: createFindToolDefinition(cwd, options?.find), ls: createLsToolDefinition(cwd, options?.ls), + symbol: createSymbolToolDefinition(cwd, options?.symbol), }; } @@ -180,6 +195,7 @@ export function createReadOnlyTools(cwd: string, options?: ToolsOptions): Tool[] createGrepTool(cwd, options?.grep), createFindTool(cwd, options?.find), createLsTool(cwd, options?.ls), + createSymbolTool(cwd, options?.symbol), ]; } @@ -192,5 +208,6 @@ export function createAllTools(cwd: string, options?: ToolsOptions): Record; + +const DEFAULT_LIMIT = 20; +/** Cap on extracted declaration bodies, in lines. */ +const MAX_BODY_LINES = 200; +/** Files larger than this are skipped without error (avoids minified/generated/binary files). */ +const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; +/** Safety valve on total files walked, so a huge unscoped search cannot run forever. */ +const MAX_FILES_SCANNED = 20000; +/** Safety valve on total matches collected before we stop scanning further files. */ +const HARD_MATCH_CAP = 500; +/** How many lines forward we look for an opening brace before giving up on brace-style extraction. */ +const OPEN_BRACE_SEARCH_WINDOW = 20; + +export interface SymbolToolDetails { + truncation?: TruncationResult; + matchLimitReached?: number; +} + +export interface SymbolToolOptions { + /** Override the max declaration body size (lines). Default: 200. */ + maxBodyLines?: number; +} + +// ============================================================================ +// Character-level scanning (string/comment aware brace + statement matching) +// ============================================================================ + +interface CharScanState { + inSingle: boolean; + inDouble: boolean; + inTemplate: boolean; + inBlockComment: boolean; +} + +function newCharScanState(): CharScanState { + return { inSingle: false, inDouble: false, inTemplate: false, inBlockComment: false }; +} + +/** + * Scan a single line char-by-char, invoking onChar for every character that is not + * inside a string literal or comment. State carries over across lines (for block + * comments/template literals spanning multiple lines). Not a full language parser - + * good enough to avoid counting braces inside strings/comments for common cases. + */ +function scanLine(line: string, state: CharScanState, onChar: (ch: string) => void): void { + let i = 0; + while (i < line.length) { + const ch = line[i]; + const next = line[i + 1]; + if (state.inBlockComment) { + if (ch === "*" && next === "/") { + state.inBlockComment = false; + i += 2; + continue; + } + i++; + continue; + } + if (state.inSingle) { + if (ch === "\\") { + i += 2; + continue; + } + if (ch === "'") state.inSingle = false; + i++; + continue; + } + if (state.inDouble) { + if (ch === "\\") { + i += 2; + continue; + } + if (ch === '"') state.inDouble = false; + i++; + continue; + } + if (state.inTemplate) { + if (ch === "\\") { + i += 2; + continue; + } + if (ch === "`") state.inTemplate = false; + i++; + continue; + } + if (ch === "/" && next === "/") return; + if (ch === "/" && next === "*") { + state.inBlockComment = true; + i += 2; + continue; + } + if (ch === "'") { + state.inSingle = true; + i++; + continue; + } + if (ch === '"') { + state.inDouble = true; + i++; + continue; + } + if (ch === "`") { + state.inTemplate = true; + i++; + continue; + } + onChar(ch); + i++; + } +} + +interface BraceBodyResult { + endLineIdx: number; + /** False when we stopped because we hit the body size cap, not because the brace actually closed. */ + closed: boolean; +} + +/** Find the end of a brace-delimited body starting at matchLineIdx. Returns null if no '{' is found nearby. */ +function findBraceBodyEnd(lines: string[], matchLineIdx: number, maxBodyLines: number): BraceBodyResult | null { + const state = newCharScanState(); + let depth = 0; + let foundOpen = false; + for (let li = matchLineIdx; li < lines.length; li++) { + let closedNow = false; + scanLine(lines[li], state, (ch) => { + if (ch === "{") { + depth++; + foundOpen = true; + } else if (ch === "}" && foundOpen) { + depth = Math.max(0, depth - 1); + if (depth === 0) closedNow = true; + } + }); + if (!foundOpen && li - matchLineIdx >= OPEN_BRACE_SEARCH_WINDOW) return null; + if (foundOpen && closedNow) return { endLineIdx: li, closed: true }; + if (li - matchLineIdx >= maxBodyLines - 1) return { endLineIdx: li, closed: false }; + } + return foundOpen ? { endLineIdx: lines.length - 1, closed: false } : null; +} + +/** Fallback for declarations without a brace body: extend to the end of the statement (';'), capped to a few lines. */ +function findStatementEnd(lines: string[], matchLineIdx: number): number { + const state = newCharScanState(); + const limit = Math.min(lines.length, matchLineIdx + 5); + for (let li = matchLineIdx; li < limit; li++) { + let hitSemicolon = false; + scanLine(lines[li], state, (ch) => { + if (ch === ";") hitSemicolon = true; + }); + if (hitSemicolon) return li; + } + return matchLineIdx; +} + +function leadingWhitespaceLength(line: string): number { + let n = 0; + while (n < line.length && (line[n] === " " || line[n] === "\t")) n++; + return n; +} + +/** Indentation-block extraction for languages without braces (Python). */ +function findIndentBodyEnd(lines: string[], matchLineIdx: number, maxBodyLines: number): BraceBodyResult { + const baseIndent = leadingWhitespaceLength(lines[matchLineIdx]); + let endLineIdx = matchLineIdx; + let cappedEarly = false; + for (let li = matchLineIdx + 1; li < lines.length; li++) { + if (li - matchLineIdx >= maxBodyLines - 1) { + cappedEarly = true; + break; + } + const line = lines[li]; + if (line.trim().length === 0) { + endLineIdx = li; + continue; + } + if (leadingWhitespaceLength(line) <= baseIndent) break; + endLineIdx = li; + } + while (endLineIdx > matchLineIdx && lines[endLineIdx].trim().length === 0) endLineIdx--; + return { endLineIdx, closed: !cappedEarly }; +} + +// ============================================================================ +// Per-language declaration rules +// ============================================================================ + +interface SymbolRule { + regex: RegExp; + kind: string | ((line: string) => string); + bodyStyle: "brace" | "indent"; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function jsTsRules(n: string): SymbolRule[] { + return [ + { + regex: new RegExp(`^\\s*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?function\\s*\\*?\\s*(${n})\\s*[(<]`), + kind: "function", + bodyStyle: "brace", + }, + { + regex: new RegExp(`^\\s*(?:export\\s+)?(?:default\\s+)?(?:abstract\\s+)?class\\s+(${n})\\b`), + kind: "class", + bodyStyle: "brace", + }, + { + regex: new RegExp(`^\\s*(?:export\\s+)?interface\\s+(${n})\\b`), + kind: "interface", + bodyStyle: "brace", + }, + { + regex: new RegExp(`^\\s*(?:export\\s+)?type\\s+(${n})\\b`), + kind: "type", + bodyStyle: "brace", + }, + { + regex: new RegExp( + `^\\s*(?:export\\s+)?(?:default\\s+)?const\\s+(${n})\\s*(?::[^=]+)?=\\s*(?:async\\s*)?[(\\w]`, + ), + kind: (line) => (/=>/.test(line) ? "function" : "const"), + bodyStyle: "brace", + }, + { + regex: new RegExp( + `^\\s*(?:public\\s+|private\\s+|protected\\s+|static\\s+|async\\s+|readonly\\s+|abstract\\s+|override\\s+|get\\s+|set\\s+|\\*\\s*)*(${n})\\s*\\(([^()]*)\\)\\s*(?::\\s*[^{;=]+)?\\s*\\{?\\s*$`, + ), + kind: "method", + bodyStyle: "brace", + }, + ]; +} + +function pythonRules(n: string): SymbolRule[] { + return [ + { + regex: new RegExp(`^\\s*(?:async\\s+)?def\\s+(${n})\\s*\\(`), + kind: (line) => (leadingWhitespaceLength(line) > 0 ? "method" : "function"), + bodyStyle: "indent", + }, + { + regex: new RegExp(`^\\s*class\\s+(${n})\\s*[:(]`), + kind: "class", + bodyStyle: "indent", + }, + ]; +} + +function goRules(n: string): SymbolRule[] { + return [ + { + regex: new RegExp(`^\\s*func\\s+(?:\\([^)]*\\)\\s*)?(${n})\\s*[(\\[]`), + kind: (line) => (/^\s*func\s*\(/.test(line) ? "method" : "function"), + bodyStyle: "brace", + }, + { + regex: new RegExp(`^\\s*type\\s+(${n})\\s+\\S`), + kind: "type", + bodyStyle: "brace", + }, + ]; +} + +function rustRules(n: string): SymbolRule[] { + const pub = `(?:pub(?:\\([^)]*\\))?\\s+)?`; + return [ + { + regex: new RegExp(`^\\s*${pub}(?:async\\s+)?(?:unsafe\\s+)?(?:extern\\s+"[^"]*"\\s+)?fn\\s+(${n})\\s*[<(]`), + kind: "fn", + bodyStyle: "brace", + }, + { + regex: new RegExp(`^\\s*${pub}struct\\s+(${n})\\b`), + kind: "struct", + bodyStyle: "brace", + }, + { + regex: new RegExp(`^\\s*${pub}enum\\s+(${n})\\b`), + kind: "enum", + bodyStyle: "brace", + }, + { + regex: new RegExp(`^\\s*${pub}trait\\s+(${n})\\b`), + kind: "trait", + bodyStyle: "brace", + }, + { + regex: new RegExp(`^\\s*impl(?:<[^>]*>)?\\s+(?:[\\w:<>,\\s]+\\s+for\\s+)?(${n})\\b`), + kind: "impl", + bodyStyle: "brace", + }, + ]; +} + +function javaCsRules(n: string): SymbolRule[] { + const modifiers = `(?:public\\s+|private\\s+|protected\\s+|internal\\s+|static\\s+|final\\s+|abstract\\s+|sealed\\s+|partial\\s+)*`; + return [ + { + regex: new RegExp(`^\\s*${modifiers}class\\s+(${n})\\b`), + kind: "class", + bodyStyle: "brace", + }, + { + regex: new RegExp(`^\\s*${modifiers}interface\\s+(${n})\\b`), + kind: "interface", + bodyStyle: "brace", + }, + { + regex: new RegExp( + `^\\s*(?:public\\s+|private\\s+|protected\\s+|internal\\s+|static\\s+|final\\s+|abstract\\s+|override\\s+|virtual\\s+|async\\s+|synchronized\\s+)*[\\w<>[\\],.\\s]+?\\s+(${n})\\s*\\(([^()]*)\\)\\s*(?:throws\\s+[\\w.,\\s]+)?\\s*\\{?\\s*$`, + ), + kind: "method", + bodyStyle: "brace", + }, + { + regex: new RegExp(`^\\s*(?:public\\s+|private\\s+|protected\\s+)?(${n})\\s*\\(([^()]*)\\)\\s*\\{?\\s*$`), + kind: "constructor", + bodyStyle: "brace", + }, + ]; +} + +const EXT_RULE_FACTORIES: Record SymbolRule[]> = { + ".js": jsTsRules, + ".jsx": jsTsRules, + ".ts": jsTsRules, + ".tsx": jsTsRules, + ".mjs": jsTsRules, + ".cjs": jsTsRules, + ".py": pythonRules, + ".go": goRules, + ".rs": rustRules, + ".java": javaCsRules, + ".cs": javaCsRules, +}; + +// ============================================================================ +// File walking (respects .gitignore, mirrors the approach used by skills.ts) +// ============================================================================ + +type IgnoreMatcher = ReturnType; + +function toPosixPath(p: string): string { + return p.split(path.sep).join("/"); +} + +function addGitignoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): void { + const relativeDir = path.relative(rootDir, dir); + const prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : ""; + const ignorePath = path.join(dir, ".gitignore"); + if (!existsSync(ignorePath)) return; + try { + const content = readFileSync(ignorePath, "utf-8"); + const patterns: string[] = []; + for (const rawLine of content.split(/\r?\n/)) { + const trimmed = rawLine.trim(); + if (!trimmed || (trimmed.startsWith("#") && !trimmed.startsWith("\\#"))) continue; + let pattern = rawLine; + let negated = false; + if (pattern.startsWith("!")) { + negated = true; + pattern = pattern.slice(1); + } else if (pattern.startsWith("\\!")) { + pattern = pattern.slice(1); + } + if (pattern.startsWith("/")) pattern = pattern.slice(1); + const prefixed = prefix ? `${prefix}${pattern}` : pattern; + patterns.push(negated ? `!${prefixed}` : prefixed); + } + if (patterns.length > 0) ig.add(patterns); + } catch { + // Ignore unreadable .gitignore files. + } +} + +function walkDirectory(dir: string, rootDir: string, ig: IgnoreMatcher, out: string[]): void { + if (out.length >= MAX_FILES_SCANNED) return; + addGitignoreRules(ig, dir, rootDir); + + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (out.length >= MAX_FILES_SCANNED) return; + if (entry.name === ".git" || entry.name === "node_modules") continue; + + const fullPath = path.join(dir, entry.name); + let isDirectory = entry.isDirectory(); + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + const st = statSync(fullPath); + isDirectory = st.isDirectory(); + isFile = st.isFile(); + } catch { + continue; + } + } + + const relPath = toPosixPath(path.relative(rootDir, fullPath)); + const ignoreCheckPath = isDirectory ? `${relPath}/` : relPath; + if (relPath && ig.ignores(ignoreCheckPath)) continue; + + if (isDirectory) { + walkDirectory(fullPath, rootDir, ig, out); + continue; + } + if (isFile) out.push(fullPath); + } +} + +// ============================================================================ +// Matching +// ============================================================================ + +interface SymbolMatch { + lineIdx: number; + kind: string; + bodyStyle: "brace" | "indent"; +} + +function findMatchesInFile(lines: string[], name: string, factory: (n: string) => SymbolRule[]): SymbolMatch[] { + const rules = factory(escapeRegExp(name)); + const matches: SymbolMatch[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + for (const rule of rules) { + const m = rule.regex.exec(line); + if (m?.[1] === name) { + const kind = typeof rule.kind === "function" ? rule.kind(line) : rule.kind; + matches.push({ lineIdx: i, kind, bodyStyle: rule.bodyStyle }); + break; + } + } + } + return matches; +} + +function buildSnippet(lines: string[], match: SymbolMatch, maxBodyLines: number): { text: string; truncated: boolean } { + if (match.bodyStyle === "indent") { + const { endLineIdx, closed } = findIndentBodyEnd(lines, match.lineIdx, maxBodyLines); + return { text: lines.slice(match.lineIdx, endLineIdx + 1).join("\n"), truncated: !closed }; + } + const brace = findBraceBodyEnd(lines, match.lineIdx, maxBodyLines); + if (!brace) { + const stmtEnd = findStatementEnd(lines, match.lineIdx); + return { text: lines.slice(match.lineIdx, stmtEnd + 1).join("\n"), truncated: false }; + } + return { text: lines.slice(match.lineIdx, brace.endLineIdx + 1).join("\n"), truncated: !brace.closed }; +} + +// ============================================================================ +// Rendering +// ============================================================================ + +function formatSymbolCall( + args: { name: string; path?: string; limit?: number } | undefined, + theme: Theme, + cwd: string, +): string { + const name = str(args?.name); + const rawPath = str(args?.path); + const pathDisplay = rawPath !== null ? shortenPath(rawPath || ".") : null; + const limit = args?.limit; + const invalidArg = invalidArgText(theme); + let text = + theme.fg("toolTitle", theme.bold("symbol")) + + " " + + (name === null ? invalidArg : theme.fg("accent", name || "")) + + theme.fg("toolOutput", ` in ${pathDisplay === null ? invalidArg : pathDisplay || shortenPath(cwd)}`); + if (limit !== undefined) text += theme.fg("toolOutput", ` limit ${limit}`); + return text; +} + +function formatSymbolResult( + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: SymbolToolDetails; + }, + options: ToolRenderResultOptions, + theme: Theme, + showImages: boolean, +): string { + const output = getTextOutput(result, showImages).trim(); + let text = ""; + if (output) { + const lines = output.split("\n"); + const maxLines = options.expanded ? lines.length : 20; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; + } + } + + const matchLimit = result.details?.matchLimitReached; + const truncation = result.details?.truncation; + if (matchLimit || truncation?.truncated) { + const warnings: string[] = []; + if (matchLimit) warnings.push(`${matchLimit} matches limit`); + if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); + text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; + } + return text; +} + +// ============================================================================ +// Tool definition +// ============================================================================ + +export function createSymbolToolDefinition( + cwd: string, + options?: SymbolToolOptions, +): ToolDefinition { + const maxBodyLines = options?.maxBodyLines ?? MAX_BODY_LINES; + return { + name: "symbol", + label: "symbol", + description: + "Search for a symbol's declaration by exact name (function, class, method, type, struct, etc.) across source files. " + + "Returns the actual declaration with its body (where cheaply extractable), not just matching text lines. " + + "Respects .gitignore. Supports JS/TS, Python, Go, Rust, Java, and C#. " + + `Output is capped to ${DEFAULT_LIMIT} matches, ${maxBodyLines} lines per body, or ${DEFAULT_MAX_BYTES / 1024}KB total (whichever is hit first).`, + promptSnippet: "Find a symbol's declaration by exact name, with its body", + parameters: symbolSchema, + async execute( + _toolCallId, + { name, path: searchPathArg, limit }: { name: string; path?: string; limit?: number }, + signal?: AbortSignal, + _onUpdate?, + _ctx?, + ) { + if (signal?.aborted) throw new Error("Operation aborted"); + if (!name || !name.trim()) throw new Error("name must not be empty"); + + const searchPath = resolveToCwd(searchPathArg || ".", cwd); + if (!existsSync(searchPath)) throw new Error(`Path not found: ${searchPath}`); + const rootStat = statSync(searchPath); + const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT); + + const files: string[] = []; + if (rootStat.isDirectory()) { + walkDirectory(searchPath, searchPath, ignore(), files); + } else if (rootStat.isFile()) { + files.push(searchPath); + } else { + throw new Error(`Not a file or directory: ${searchPath}`); + } + + interface FormattedMatch { + relPath: string; + line: number; + kind: string; + text: string; + truncated: boolean; + } + + const matches: FormattedMatch[] = []; + let totalMatchCount = 0; + + fileLoop: for (const filePath of files) { + if (signal?.aborted) throw new Error("Operation aborted"); + const ext = path.extname(filePath).toLowerCase(); + const factory = EXT_RULE_FACTORIES[ext]; + if (!factory) continue; + + let fileStat: ReturnType; + try { + fileStat = statSync(filePath); + } catch { + continue; + } + if (fileStat.size > MAX_FILE_SIZE_BYTES) continue; + + let content: string; + try { + content = readFileSync(filePath, "utf-8"); + } catch { + continue; + } + const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + + const fileMatches = findMatchesInFile(lines, name, factory); + for (const fm of fileMatches) { + totalMatchCount++; + if (matches.length < effectiveLimit) { + const relPath = rootStat.isDirectory() + ? toPosixPath(path.relative(searchPath, filePath)) + : path.basename(filePath); + const snippet = buildSnippet(lines, fm, maxBodyLines); + matches.push({ + relPath, + line: fm.lineIdx + 1, + kind: fm.kind, + text: snippet.text, + truncated: snippet.truncated, + }); + } + if (totalMatchCount >= HARD_MATCH_CAP) break fileLoop; + } + } + + if (matches.length === 0) { + return { content: [{ type: "text", text: `No symbol named '${name}' found` }], details: undefined }; + } + + const blocks = matches.map((m) => { + let block = `${m.relPath}:${m.line}: [${m.kind}]\n${m.text}`; + if (m.truncated) block += "\n... [body truncated]"; + return block; + }); + const rawOutput = blocks.join("\n\n"); + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + let output = truncation.content; + const details: SymbolToolDetails = {}; + const notices: string[] = []; + const matchLimitReached = totalMatchCount > effectiveLimit; + if (matchLimitReached) { + notices.push( + `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or narrow path`, + ); + details.matchLimitReached = effectiveLimit; + } + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`; + + return { + content: [{ type: "text", text: output }], + details: Object.keys(details).length > 0 ? details : undefined, + }; + }, + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatSymbolCall(args, theme, context.cwd)); + return text; + }, + renderResult(result, options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatSymbolResult(result as any, options, theme, context.showImages)); + return text; + }, + }; +} + +export function createSymbolTool(cwd: string, options?: SymbolToolOptions): AgentTool { + return wrapToolDefinition(createSymbolToolDefinition(cwd, options)); +} diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 3d4ca9831f2..27b6e13d721 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -220,6 +220,7 @@ export { createLsTool, createReadOnlyTools, createReadTool, + createSymbolTool, createWriteTool, type PromptTemplate, } from "./core/sdk.ts"; @@ -286,6 +287,7 @@ export { createLocalBashOperations, createLsToolDefinition, createReadToolDefinition, + createSymbolToolDefinition, createWriteToolDefinition, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, @@ -310,6 +312,9 @@ export { type ReadToolDetails, type ReadToolInput, type ReadToolOptions, + type SymbolToolDetails, + type SymbolToolInput, + type SymbolToolOptions, type ToolsOptions, type TruncationOptions, type TruncationResult, diff --git a/packages/coding-agent/test/symbol-tool.test.ts b/packages/coding-agent/test/symbol-tool.test.ts new file mode 100644 index 00000000000..6537c226041 --- /dev/null +++ b/packages/coding-agent/test/symbol-tool.test.ts @@ -0,0 +1,186 @@ +import { mkdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createSymbolTool } from "../src/index.ts"; + +// Helper to extract text from content blocks +function getTextOutput(result: any): string { + return ( + result.content + ?.filter((c: any) => c.type === "text") + .map((c: any) => c.text) + .join("\n") || "" + ); +} + +describe("symbol tool", () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `coding-agent-symbol-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it("finds a TS function declaration with its body", async () => { + const testFile = join(testDir, "example.ts"); + writeFileSync( + testFile, + [ + "export function greet(name: string): string {", + " const message = 'Hello, ' + name + '!';", + " return message;", + "}", + "", + ].join("\n"), + ); + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-1", { name: "greet" }); + const output = getTextOutput(result); + + expect(output).toContain("example.ts:1: [function]"); + expect(output).toContain("export function greet(name: string): string {"); + expect(output).toContain("return message;"); + expect(output).toContain("}"); + }); + + it("finds a TS class declaration and a class method with correct bodies", async () => { + const testFile = join(testDir, "widget.ts"); + writeFileSync( + testFile, + [ + "export class Widget {", + " private count = 0;", + "", + " increment() {", + " this.count += 1;", + " return this.count;", + " }", + "}", + "", + ].join("\n"), + ); + + const tool = createSymbolTool(testDir); + + const classResult = await tool.execute("call-2", { name: "Widget" }); + const classOutput = getTextOutput(classResult); + expect(classOutput).toContain("widget.ts:1: [class]"); + expect(classOutput).toContain("export class Widget {"); + expect(classOutput).toContain("increment()"); + + const methodResult = await tool.execute("call-3", { name: "increment" }); + const methodOutput = getTextOutput(methodResult); + expect(methodOutput).toContain("widget.ts:4: [method]"); + expect(methodOutput).toContain("increment() {"); + expect(methodOutput).toContain("this.count += 1;"); + expect(methodOutput).not.toContain("private count"); + }); + + it("finds a Python def", async () => { + const testFile = join(testDir, "util.py"); + writeFileSync( + testFile, + ["def add(a, b):", " total = a + b", " return total", "", "", "def unrelated():", " pass", ""].join( + "\n", + ), + ); + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-4", { name: "add" }); + const output = getTextOutput(result); + + expect(output).toContain("util.py:1: [function]"); + expect(output).toContain("def add(a, b):"); + expect(output).toContain("total = a + b"); + expect(output).toContain("return total"); + expect(output).not.toContain("unrelated"); + }); + + it("labels an indented Python def as a method", async () => { + const testFile = join(testDir, "cls.py"); + writeFileSync(testFile, ["class Foo:", " def bar(self):", " return 1", ""].join("\n")); + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-5", { name: "bar" }); + const output = getTextOutput(result); + + expect(output).toContain("cls.py:2: [method]"); + expect(output).toContain("return 1"); + }); + + it("returns a clear empty result (not an error) for a nonexistent symbol", async () => { + const testFile = join(testDir, "empty.ts"); + writeFileSync(testFile, "export function realFunction() {}\n"); + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-6", { name: "doesNotExist" }); + const output = getTextOutput(result); + + expect(output).toBe("No symbol named 'doesNotExist' found"); + expect(result.details).toBeUndefined(); + }); + + it("respects path scoping to a subdirectory", async () => { + const includedDir = join(testDir, "included"); + const excludedDir = join(testDir, "excluded"); + mkdirSync(includedDir, { recursive: true }); + mkdirSync(excludedDir, { recursive: true }); + writeFileSync(join(includedDir, "a.ts"), "export function scoped() {\n return 1;\n}\n"); + writeFileSync(join(excludedDir, "b.ts"), "export function scoped() {\n return 2;\n}\n"); + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-7", { name: "scoped", path: "included" }); + const output = getTextOutput(result); + + expect(output).toContain("a.ts:1: [function]"); + expect(output).not.toContain("b.ts"); + expect(output).not.toContain("return 2;"); + }); + + it("truncates and shows a notice when results exceed the cap", async () => { + const dir = join(testDir, "many"); + mkdirSync(dir, { recursive: true }); + for (let i = 0; i < 5; i++) { + writeFileSync(join(dir, `file${i}.ts`), "export function dup() {\n return 1;\n}\n"); + } + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-8", { name: "dup", path: "many", limit: 2 }); + const output = getTextOutput(result); + const occurrences = output.match(/\[function\]/g) ?? []; + + expect(occurrences.length).toBe(2); + expect(output).toContain("[2 matches limit reached. Use limit=4 for more, or narrow path]"); + expect(result.details?.matchLimitReached).toBe(2); + }); + + it("silently skips files with unrecognized extensions", async () => { + writeFileSync(join(testDir, "notes.txt"), "function weirdMatch() {}\n"); + writeFileSync(join(testDir, "data.json"), '{"weirdMatch": "function weirdMatch() {}"}\n'); + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-9", { name: "weirdMatch" }); + const output = getTextOutput(result); + + expect(output).toBe("No symbol named 'weirdMatch' found"); + }); + + it("respects .gitignore when walking a directory", async () => { + writeFileSync(join(testDir, ".gitignore"), "ignored.ts\n"); + writeFileSync(join(testDir, "ignored.ts"), "export function fromIgnored() {\n return 1;\n}\n"); + writeFileSync(join(testDir, "kept.ts"), "export function fromKept() {\n return 1;\n}\n"); + + const tool = createSymbolTool(testDir); + const ignoredResult = await tool.execute("call-10", { name: "fromIgnored" }); + expect(getTextOutput(ignoredResult)).toBe("No symbol named 'fromIgnored' found"); + + const keptResult = await tool.execute("call-11", { name: "fromKept" }); + expect(getTextOutput(keptResult)).toContain("kept.ts:1: [function]"); + }); +}); From 83c72e3947e3247f8e0619ef68ff446f7a2d4e1a Mon Sep 17 00:00:00 2001 From: Leonhard Breuer Date: Tue, 4 Aug 2026 13:59:51 +0200 Subject: [PATCH 12/34] feat(coding-agent): re-surface constraints near end of context after compaction --- packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/core/agent-session.ts | 23 +++ .../src/core/compaction/compaction.ts | 7 +- .../core/compaction/constraints-reminder.ts | 132 +++++++++++++++ .../coding-agent/src/core/compaction/index.ts | 1 + ...agent-session-constraints-reminder.test.ts | 153 ++++++++++++++++++ 6 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/src/core/compaction/constraints-reminder.ts create mode 100644 packages/coding-agent/test/suite/agent-session-constraints-reminder.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7f03ce4decf..05deaaf2e20 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Re-surface the latest compaction summary's "Constraints & Preferences" as a short reminder near the end of context every 15 turns after compaction, so hard constraints stay reliably visible instead of drifting into the middle of long sessions. + ## [0.83.0-cheetahbyte.3] - 2026-08-04 ### Breaking Changes diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 253b2406dfc..dcc514f9b78 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -54,6 +54,7 @@ import { normalizeToolResultImages } from "../utils/tool-result-images.ts"; import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts"; import { type BashResult, executeBashWithOperations } from "./bash-executor.ts"; import { + buildConstraintsReminderMessage, type CompactionResult, calculateContextTokens, collectEntriesForBranchSummary, @@ -395,6 +396,7 @@ export class AgentSession { this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); this._installAgentToolHooks(); this._installAgentNextTurnRefresh(); + this._installConstraintsReminderTransform(); this._buildRuntime({ activeToolNames: this._initialActiveToolNames, @@ -555,6 +557,27 @@ export class AgentSession { }; } + /** + * Wrap `agent.transformContext` so a short reminder of the latest compaction + * summary's "Constraints & Preferences" is appended near the end of the + * outgoing message list every `CONSTRAINTS_REMINDER_INTERVAL_TURNS` turns. + * + * `transformContext` runs once per LLM call, on a local copy of the context + * that is never written back to `agent.state.messages` and never persisted + * via the session manager, so the reminder is rebuilt fresh each time and + * cannot itself accumulate as clutter. Chained after any previously + * installed `transformContext` (e.g. the extension "context" hook wired up + * in sdk.ts/harness.ts) so both still run. + */ + private _installConstraintsReminderTransform(): void { + const previousTransformContext = this.agent.transformContext; + this.agent.transformContext = async (messages, signal) => { + const transformed = previousTransformContext ? await previousTransformContext(messages, signal) : messages; + const reminder = buildConstraintsReminderMessage(this.sessionManager.getBranch()); + return reminder ? [...transformed, reminder] : transformed; + }; + } + // ========================================================================= // Event Subscription // ========================================================================= diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index ef8ac699a34..1c4e8b458bc 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -335,7 +335,12 @@ function isTurnStartMessage(message: AgentMessage): boolean { return false; } -function isTurnStartEntry(entry: SessionEntry): boolean { +/** + * Whether an entry starts a new turn (a user-initiated message boundary). + * Exported for reuse by the constraints-reminder module, which needs to count + * turns elapsed since the last compaction on the current branch. + */ +export function isTurnStartEntry(entry: SessionEntry): boolean { if (entry.type === "compaction") { return false; } diff --git a/packages/coding-agent/src/core/compaction/constraints-reminder.ts b/packages/coding-agent/src/core/compaction/constraints-reminder.ts new file mode 100644 index 00000000000..37ebe9d9865 --- /dev/null +++ b/packages/coding-agent/src/core/compaction/constraints-reminder.ts @@ -0,0 +1,132 @@ +/** + * Periodic re-surfacing of the most recent compaction summary's + * "Constraints & Preferences" section near the end of context. + * + * Once compaction runs, its structured summary sits near the front of what + * remains of the context. As the session continues, new messages pile up + * after it and the summary drifts toward the middle of the context window — + * the "lost in the middle" zone where models retrieve information least + * reliably. This module builds a short, ephemeral reminder message that is + * appended near the end of the outgoing message list every few turns, so + * hard constraints stay reliably visible without re-summarizing or bloating + * the persisted session. + * + * The reminder is constructed fresh at context-assembly time only (see + * `AgentSession._installConstraintsReminderTransform`) and is never persisted + * as a session entry, so it cannot itself become stale or get duplicated on + * future rebuilds. + */ + +import { type CustomMessage, createCustomMessage } from "../messages.ts"; +import type { CompactionEntry, SessionEntry } from "../session-manager.ts"; +import { isTurnStartEntry } from "./compaction.ts"; + +/** + * How many turns must elapse since the last compaction before the + * constraints reminder is re-surfaced, and the recurring period after that + * (turn 15, 30, 45, ...). Picked from the middle of the 15-20 turn range + * suggested for periodic reinforcement: frequent enough that a long session + * doesn't drift far past the "lost in the middle" zone before being + * refreshed, infrequent enough to avoid adding noise on every turn. + */ +export const CONSTRAINTS_REMINDER_INTERVAL_TURNS = 15; + +/** `customType` used for the ephemeral reminder message. Not persisted anywhere. */ +export const CONSTRAINTS_REMINDER_CUSTOM_TYPE = "constraints-reminder"; + +const CONSTRAINTS_HEADING_RE = /^##+\s*Constraints\s*&\s*Preferences\s*$/im; +const NEXT_HEADING_RE = /^##+\s/m; + +/** + * Extract the bullet lines under "## Constraints & Preferences" from a + * compaction summary string. Returns undefined if the section is missing, + * has no bullets, or contains only a "(none)" placeholder. + */ +export function extractConstraintsBullets(summary: string): string[] | undefined { + const headingMatch = CONSTRAINTS_HEADING_RE.exec(summary); + if (!headingMatch) return undefined; + + const sectionStart = headingMatch.index + headingMatch[0].length; + const rest = summary.slice(sectionStart); + NEXT_HEADING_RE.lastIndex = 0; + const nextHeadingMatch = NEXT_HEADING_RE.exec(rest); + const sectionText = nextHeadingMatch ? rest.slice(0, nextHeadingMatch.index) : rest; + + const bullets = sectionText + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("-")) + .map((line) => line.replace(/^-+\s*/, "").trim()) + .filter((line) => line.length > 0 && line.toLowerCase() !== "(none)"); + + return bullets.length > 0 ? bullets : undefined; +} + +/** Result of scanning a branch for turns elapsed since its most recent compaction. */ +export interface TurnsSinceCompaction { + /** The most recent compaction entry on the branch, or null if there is none. */ + compaction: CompactionEntry | null; + /** Number of turn-start entries strictly after the compaction entry. */ + turnsSinceCompaction: number; +} + +/** + * Scan a branch (root-to-leaf ordered entries, e.g. from `SessionManager.getBranch()`) + * for the most recent compaction entry and count turns that started after it. + */ +export function countTurnsSinceCompaction(branchEntries: SessionEntry[]): TurnsSinceCompaction { + let compactionIndex = -1; + for (let i = branchEntries.length - 1; i >= 0; i--) { + if (branchEntries[i].type === "compaction") { + compactionIndex = i; + break; + } + } + if (compactionIndex < 0) { + return { compaction: null, turnsSinceCompaction: 0 }; + } + + let turnsSinceCompaction = 0; + for (let i = compactionIndex + 1; i < branchEntries.length; i++) { + if (isTurnStartEntry(branchEntries[i])) turnsSinceCompaction++; + } + + return { compaction: branchEntries[compactionIndex] as CompactionEntry, turnsSinceCompaction }; +} + +function formatReminderText(bullets: string[]): string { + return [ + "Reminder — constraints and preferences from earlier in this session:", + ...bullets.map((bullet) => `- ${bullet}`), + ].join("\n"); +} + +/** + * Build the ephemeral constraints reminder message for the current turn, if one + * should be surfaced. Returns undefined when: + * - there is no compaction entry on the branch, + * - its summary has no non-empty "Constraints & Preferences" section, or + * - fewer than `CONSTRAINTS_REMINDER_INTERVAL_TURNS` turns (or a non-multiple + * of it) have elapsed since the compaction. + * + * `display: false` keeps this out of TUI rendering (it's a synthetic nudge, + * not something the user or assistant said); `convertToLlm` still turns it + * into a plain user-role message for the model. + */ +export function buildConstraintsReminderMessage(branchEntries: SessionEntry[]): CustomMessage | undefined { + const { compaction, turnsSinceCompaction } = countTurnsSinceCompaction(branchEntries); + if (!compaction) return undefined; + if (turnsSinceCompaction < CONSTRAINTS_REMINDER_INTERVAL_TURNS) return undefined; + if (turnsSinceCompaction % CONSTRAINTS_REMINDER_INTERVAL_TURNS !== 0) return undefined; + + const bullets = extractConstraintsBullets(compaction.summary); + if (!bullets) return undefined; + + return createCustomMessage( + CONSTRAINTS_REMINDER_CUSTOM_TYPE, + formatReminderText(bullets), + false, + undefined, + new Date().toISOString(), + ); +} diff --git a/packages/coding-agent/src/core/compaction/index.ts b/packages/coding-agent/src/core/compaction/index.ts index 7fae5f2c3f9..0ea1fcb73fe 100644 --- a/packages/coding-agent/src/core/compaction/index.ts +++ b/packages/coding-agent/src/core/compaction/index.ts @@ -4,4 +4,5 @@ export * from "./branch-summarization.ts"; export * from "./compaction.ts"; +export * from "./constraints-reminder.ts"; export * from "./utils.ts"; diff --git a/packages/coding-agent/test/suite/agent-session-constraints-reminder.test.ts b/packages/coding-agent/test/suite/agent-session-constraints-reminder.test.ts new file mode 100644 index 00000000000..838b9dfc1c6 --- /dev/null +++ b/packages/coding-agent/test/suite/agent-session-constraints-reminder.test.ts @@ -0,0 +1,153 @@ +import { fauxAssistantMessage } from "@cheetahbyte/pi-ai"; +import type { Context, FauxResponseFactory } from "@cheetahbyte/pi-ai/compat"; +import { afterEach, describe, expect, it } from "vitest"; +import { CONSTRAINTS_REMINDER_INTERVAL_TURNS } from "../../src/core/compaction/index.ts"; +import { createHarness, type Harness } from "./harness.ts"; + +const REMINDER_TEXT = "Reminder — constraints and preferences from earlier in this session:"; +const CONSTRAINT_BULLET = "Never touch the production database directly"; + +const SUMMARY_WITH_CONSTRAINTS = `## Goal +Ship the export feature. + +## Constraints & Preferences +- ${CONSTRAINT_BULLET} +- Always run \`npm run check\` before finishing + +## Progress +### Done +- [x] Set up scaffolding + +### In Progress +- [ ] Wire up export button + +### Blocked +- (none) + +## Key Decisions +- **Use CSV**: simplest format for the user's needs + +## Next Steps +1. Finish the export button + +## Critical Context +- (none)`; + +const SUMMARY_WITHOUT_CONSTRAINTS = `## Goal +Ship the export feature. + +## Constraints & Preferences +- (none) + +## Progress +### Done +- [x] Set up scaffolding + +## Next Steps +1. Finish the export button`; + +function messageText(content: string | Array<{ type: string; text?: string }>): string { + if (typeof content === "string") return content; + return content + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map((block) => block.text) + .join("\n"); +} + +function findReminderMessage(context: Context) { + return context.messages.find( + (message) => message.role === "user" && messageText(message.content).includes(REMINDER_TEXT), + ); +} + +function contextHasReminder(context: Context): boolean { + return findReminderMessage(context) !== undefined; +} + +/** Seed a compaction entry directly on the session, bypassing real summarization. */ +function seedCompaction(harness: Harness, summary: string): void { + const keptId = harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "kept context before compaction" }], + timestamp: Date.now(), + }); + harness.sessionManager.appendCompaction(summary, keptId, 1000); + harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages; +} + +/** Drive `turnCount` sequential prompts, capturing the outgoing LLM context for each. */ +async function driveTurns(harness: Harness, turnCount: number): Promise { + const capturedContexts: Context[] = []; + const responses: FauxResponseFactory[] = []; + for (let i = 0; i < turnCount; i++) { + responses.push((context) => { + capturedContexts.push(context); + return fauxAssistantMessage(`assistant reply ${i}`); + }); + } + harness.setResponses(responses); + + for (let i = 0; i < turnCount; i++) { + await harness.session.prompt(`user turn ${i + 1}`); + } + + return capturedContexts; +} + +describe("constraints reminder re-surfacing", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + it("stays silent before the interval and appears at the threshold turn, then periodically", async () => { + const harness = await createHarness(); + harnesses.push(harness); + seedCompaction(harness, SUMMARY_WITH_CONSTRAINTS); + + const turnsToRun = CONSTRAINTS_REMINDER_INTERVAL_TURNS * 2 + 1; + const contexts = await driveTurns(harness, turnsToRun); + + expect(contexts).toHaveLength(turnsToRun); + + for (let turn = 1; turn < CONSTRAINTS_REMINDER_INTERVAL_TURNS; turn++) { + expect(contextHasReminder(contexts[turn - 1]!), `turn ${turn} should not carry the reminder`).toBe(false); + } + + const reminderMessage = findReminderMessage(contexts[CONSTRAINTS_REMINDER_INTERVAL_TURNS - 1]!); + expect(reminderMessage).toBeDefined(); + expect(messageText(reminderMessage!.content)).toContain(CONSTRAINT_BULLET); + + for (let turn = CONSTRAINTS_REMINDER_INTERVAL_TURNS + 1; turn < CONSTRAINTS_REMINDER_INTERVAL_TURNS * 2; turn++) { + expect(contextHasReminder(contexts[turn - 1]!), `turn ${turn} should not carry the reminder`).toBe(false); + } + + expect(contextHasReminder(contexts[CONSTRAINTS_REMINDER_INTERVAL_TURNS * 2 - 1]!)).toBe(true); + }); + + it("never surfaces a reminder without a prior compaction", async () => { + const harness = await createHarness(); + harnesses.push(harness); + + const contexts = await driveTurns(harness, CONSTRAINTS_REMINDER_INTERVAL_TURNS * 2); + + for (const context of contexts) { + expect(contextHasReminder(context)).toBe(false); + } + }); + + it("stays silent when the compaction summary has no constraints", async () => { + const harness = await createHarness(); + harnesses.push(harness); + seedCompaction(harness, SUMMARY_WITHOUT_CONSTRAINTS); + + const contexts = await driveTurns(harness, CONSTRAINTS_REMINDER_INTERVAL_TURNS * 2); + + for (const context of contexts) { + expect(contextHasReminder(context)).toBe(false); + } + }); +}); From 1633821da6936f6a540f55fa1f6d74ada5a279eb Mon Sep 17 00:00:00 2001 From: Leonhard Breuer Date: Tue, 4 Aug 2026 14:04:36 +0200 Subject: [PATCH 13/34] feat(coding-agent): add file outline tool Adds a new `outline` built-in tool that returns a compact, signature-only preview of a single file's top-level declarations (classes, functions, interfaces, types, structs, etc.), with methods nested one level under their class/impl/trait, using per-extension regex heuristics. Covers JS/TS, Python, Go, Rust, and Java/C#. Unsupported extensions get a plain "use read instead" message rather than an error. Output is capped at 500 declarations / 50KB with an actionable truncation notice, reusing the existing truncate.ts conventions. Registered like grep/find/ls: opt-in via --tools, included in createReadOnlyTool(Definitions)/createAllTool(Definitions), and exported from the package's public API. Not added to defaultActiveToolNames. --- packages/coding-agent/src/core/sdk.ts | 2 + packages/coding-agent/src/core/tools/index.ts | 22 +- .../coding-agent/src/core/tools/outline.ts | 527 ++++++++++++++++++ packages/coding-agent/src/index.ts | 6 + .../coding-agent/test/outline-tool.test.ts | 185 ++++++ 5 files changed, 740 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/src/core/tools/outline.ts create mode 100644 packages/coding-agent/test/outline-tool.test.ts diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 8dadf1ec4b2..5a1e92d2014 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -23,6 +23,7 @@ import { createFindTool, createGrepTool, createLsTool, + createOutlineTool, createReadOnlyTools, createReadTool, createWriteTool, @@ -123,6 +124,7 @@ export { createGrepTool, createFindTool, createLsTool, + createOutlineTool, }; // Helper Functions diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 19d722cfef9..5c6fc3e78e5 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -42,6 +42,14 @@ export { type LsToolInput, type LsToolOptions, } from "./ls.ts"; +export { + createOutlineTool, + createOutlineToolDefinition, + type OutlineOperations, + type OutlineToolDetails, + type OutlineToolInput, + type OutlineToolOptions, +} from "./outline.ts"; export { createReadTool, createReadToolDefinition, @@ -75,13 +83,14 @@ import { createEditTool, createEditToolDefinition, type EditToolOptions } from " import { createFindTool, createFindToolDefinition, type FindToolOptions } from "./find.ts"; import { createGrepTool, createGrepToolDefinition, type GrepToolOptions } from "./grep.ts"; import { createLsTool, createLsToolDefinition, type LsToolOptions } from "./ls.ts"; +import { createOutlineTool, createOutlineToolDefinition, type OutlineToolOptions } from "./outline.ts"; import { createReadTool, createReadToolDefinition, type ReadToolOptions } from "./read.ts"; import { createWriteTool, createWriteToolDefinition, type WriteToolOptions } from "./write.ts"; export type Tool = AgentTool; export type ToolDef = ToolDefinition; -export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls"; -export const allToolNames: Set = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]); +export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls" | "outline"; +export const allToolNames: Set = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "outline"]); export interface ToolsOptions { read?: ReadToolOptions; @@ -91,6 +100,7 @@ export interface ToolsOptions { grep?: GrepToolOptions; find?: FindToolOptions; ls?: LsToolOptions; + outline?: OutlineToolOptions; } export function createToolDefinition(toolName: ToolName, cwd: string, options?: ToolsOptions): ToolDef { @@ -109,6 +119,8 @@ export function createToolDefinition(toolName: ToolName, cwd: string, options?: return createFindToolDefinition(cwd, options?.find); case "ls": return createLsToolDefinition(cwd, options?.ls); + case "outline": + return createOutlineToolDefinition(cwd, options?.outline); default: throw new Error(`Unknown tool name: ${toolName}`); } @@ -130,6 +142,8 @@ export function createTool(toolName: ToolName, cwd: string, options?: ToolsOptio return createFindTool(cwd, options?.find); case "ls": return createLsTool(cwd, options?.ls); + case "outline": + return createOutlineTool(cwd, options?.outline); default: throw new Error(`Unknown tool name: ${toolName}`); } @@ -150,6 +164,7 @@ export function createReadOnlyToolDefinitions(cwd: string, options?: ToolsOption createGrepToolDefinition(cwd, options?.grep), createFindToolDefinition(cwd, options?.find), createLsToolDefinition(cwd, options?.ls), + createOutlineToolDefinition(cwd, options?.outline), ]; } @@ -162,6 +177,7 @@ export function createAllToolDefinitions(cwd: string, options?: ToolsOptions): R grep: createGrepToolDefinition(cwd, options?.grep), find: createFindToolDefinition(cwd, options?.find), ls: createLsToolDefinition(cwd, options?.ls), + outline: createOutlineToolDefinition(cwd, options?.outline), }; } @@ -180,6 +196,7 @@ export function createReadOnlyTools(cwd: string, options?: ToolsOptions): Tool[] createGrepTool(cwd, options?.grep), createFindTool(cwd, options?.find), createLsTool(cwd, options?.ls), + createOutlineTool(cwd, options?.outline), ]; } @@ -192,5 +209,6 @@ export function createAllTools(cwd: string, options?: ToolsOptions): Record; + +const DEFAULT_ENTRY_LIMIT = 500; + +export interface OutlineToolDetails { + truncation?: TruncationResult; + entryLimitReached?: number; +} + +/** + * Pluggable operations for the outline tool. + * Override these to delegate file access to remote systems (for example SSH). + */ +export interface OutlineOperations { + /** Check if file is readable (throw if not) */ + access: (absolutePath: string) => Promise; + /** Get file or directory stats. Throws if not found. */ + stat: (absolutePath: string) => Promise<{ isDirectory: () => boolean }> | { isDirectory: () => boolean }; + /** Read file contents as a Buffer */ + readFile: (absolutePath: string) => Promise; +} + +const defaultOutlineOperations: OutlineOperations = { + access: (path) => fsAccess(path, constants.R_OK), + stat: (path) => fsStat(path), + readFile: (path) => fsReadFile(path), +}; + +export interface OutlineToolOptions { + /** Custom operations for file access. Default: local filesystem */ + operations?: OutlineOperations; +} + +// ============================================================================ +// Declaration extraction +// ============================================================================ + +interface OutlineEntry { + line: number; + /** Nesting depth: 0 for top-level, 1 for members nested one level under their parent. */ + indent: 0 | 1; + text: string; +} + +interface TopLevelRule { + regex: RegExp; + /** Whether a match opens a "classy" container (class/interface/struct/impl/trait) whose direct members should be extracted. */ + classy?: boolean; +} + +interface MemberRule { + regex: RegExp; + /** Index of the capture group holding the declared name, used for exclusion checks. */ + nameGroup: number; + excludeNames?: Set; +} + +interface BraceLanguageConfig { + commentStyle: "//"; + topLevelRules: TopLevelRule[]; + memberRules: MemberRule[]; + /** + * When true, top-level declarations are still recognized even when nested inside a + * non-classy wrapper block (e.g. a C#/Java `namespace`/package block). Members are then + * matched against the innermost classy ancestor regardless of absolute brace depth. + */ + allowNamespaceWrapping?: boolean; +} + +const CONTROL_KEYWORDS = new Set([ + "if", + "for", + "while", + "switch", + "catch", + "return", + "new", + "super", + "typeof", + "else", + "do", + "yield", + "await", + "delete", + "in", + "of", + "instanceof", + "void", + "throw", + "try", + "finally", +]); + +/** + * Blank out string/char literal contents and truncate trailing line comments, preserving + * character offsets so brace/signature indices computed against the cleaned line still line + * up with the original raw line. This is a heuristic, not a real tokenizer: multi-line block + * comments and multi-line template literals are not tracked. + */ +function stripStringsAndLineComments(line: string, commentStyle: "//" | "#"): string { + let result = ""; + let inString: string | null = null; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inString) { + if (ch === "\\") { + result += " "; + i++; + continue; + } + if (ch === inString) { + inString = null; + result += " "; + continue; + } + result += " "; + continue; + } + if (ch === '"' || ch === "'" || ch === "`") { + inString = ch; + result += " "; + continue; + } + if (commentStyle === "//" && ch === "/" && line[i + 1] === "/") break; + if (commentStyle === "#" && ch === "#") break; + result += ch; + } + return result; +} + +/** + * Derive a compact signature from a declaration line by cutting off the body. + * For brace languages, cuts at the last top-level '{'; for line-based languages + * (no brace on the line, e.g. Python), strips a trailing block-opening ':'. + */ +function toSignature(raw: string, cleaned: string): string { + const braceIdx = cleaned.lastIndexOf("{"); + const cut = braceIdx > 0 ? raw.slice(0, braceIdx) : raw; + return cut.trim().replace(/;\s*$/, ""); +} + +function toPythonSignature(raw: string): string { + return raw.trim().replace(/:\s*$/, ""); +} + +/** + * Generic extractor for brace-delimited languages (JS/TS, Go, Rust, Java/C#). + * Tracks brace depth with a lightweight per-line heuristic (no real parser) to + * decide which lines are candidate top-level declarations vs. members nested + * one level under a class/struct/impl/trait-like container. + */ +function extractBraceLanguage(content: string, config: BraceLanguageConfig): OutlineEntry[] { + const lines = content.split("\n"); + const entries: OutlineEntry[] = []; + // Each entry marks whether the corresponding open brace was opened by a "classy" declaration. + const classStack: boolean[] = []; + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + const cleaned = stripStringsAndLineComments(raw, config.commentStyle); + const trimmed = cleaned.trim(); + let matchedClassy = false; + + if (trimmed.length > 0) { + const depth = classStack.length; + const topIsClassy = depth > 0 && classStack[depth - 1] === true; + let canTryTopLevel: boolean; + let canTryMember: boolean; + if (config.allowNamespaceWrapping) { + const hasClassyAncestor = classStack.includes(true); + canTryTopLevel = !hasClassyAncestor; + canTryMember = topIsClassy; + } else { + canTryTopLevel = depth === 0; + canTryMember = depth === 1 && topIsClassy; + } + + if (canTryTopLevel) { + for (const rule of config.topLevelRules) { + if (rule.regex.test(trimmed)) { + entries.push({ line: i + 1, indent: 0, text: toSignature(raw, cleaned) }); + matchedClassy = !!rule.classy; + break; + } + } + } else if (canTryMember) { + for (const rule of config.memberRules) { + const m = trimmed.match(rule.regex); + if (!m) continue; + const name = (m[rule.nameGroup] ?? "").replace(/^[*#\s]+/, ""); + if (rule.excludeNames?.has(name)) continue; + entries.push({ line: i + 1, indent: 1, text: toSignature(raw, cleaned) }); + break; + } + } + } + + // Update brace depth. The classy flag only attaches to the first brace opened on this line. + let opensOnLine = 0; + for (const ch of cleaned) { + if (ch === "{") { + classStack.push(opensOnLine === 0 ? matchedClassy : false); + opensOnLine++; + } else if (ch === "}") { + classStack.pop(); + } + } + } + + return entries; +} + +/** + * Python is indentation-based rather than brace-based: top-level `def`/`class` sit at column 0, + * and methods are indented under their class. Only one level of nesting is tracked. + */ +function extractPython(content: string): OutlineEntry[] { + const lines = content.split("\n"); + const entries: OutlineEntry[] = []; + const defRegex = /^(?:async\s+)?def\s+[A-Za-z_]\w*\s*\(/; + const classRegex = /^class\s+[A-Za-z_]\w*/; + let inClass = false; + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + const cleaned = stripStringsAndLineComments(raw, "#"); + const trimmed = cleaned.trim(); + if (trimmed.length === 0) continue; + + const indentLen = raw.length - raw.trimStart().length; + if (indentLen === 0) { + if (classRegex.test(trimmed)) { + entries.push({ line: i + 1, indent: 0, text: toPythonSignature(raw) }); + inClass = true; + } else if (defRegex.test(trimmed)) { + entries.push({ line: i + 1, indent: 0, text: toPythonSignature(raw) }); + inClass = false; + } else { + // Any other unindented, non-blank line ends the current class body. + inClass = false; + } + continue; + } + + if (inClass && defRegex.test(trimmed)) { + entries.push({ line: i + 1, indent: 1, text: toPythonSignature(raw) }); + } + } + + return entries; +} + +const JS_TS_CONFIG: BraceLanguageConfig = { + commentStyle: "//", + topLevelRules: [ + { regex: /^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+[A-Za-z_$]/, classy: true }, + { regex: /^(?:export\s+)?interface\s+[A-Za-z_$]/, classy: true }, + { regex: /^(?:export\s+)?type\s+[A-Za-z_$][\w$]*\s*(?:<[^=]*>)?\s*=/, classy: false }, + { regex: /^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s+[A-Za-z_$]/, classy: false }, + { + regex: /^export\s+(?:default\s+)?const\s+[A-Za-z_$][\w$]*\s*(?::.*)?=\s*(?:async\s*)?\(.*\)\s*(?::.*)?=>/, + classy: false, + }, + ], + memberRules: [ + { + regex: /^(?:(?:public|private|protected|static|readonly|abstract|override|async)\s+)*(?:get\s+|set\s+)?(\*?\s*#?[A-Za-z_$][\w$]*)\s*\(/, + nameGroup: 1, + excludeNames: CONTROL_KEYWORDS, + }, + ], +}; + +const GO_CONFIG: BraceLanguageConfig = { + commentStyle: "//", + topLevelRules: [ + { regex: /^func\s+(?:\([^)]*\)\s*)?[A-Za-z_]\w*\s*\(/, classy: false }, + { regex: /^type\s+[A-Za-z_]\w*\b/, classy: false }, + ], + memberRules: [], +}; + +const RUST_MEMBER_FN_REGEX = /^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+([A-Za-z_]\w*)/; + +const RUST_CONFIG: BraceLanguageConfig = { + commentStyle: "//", + topLevelRules: [ + { regex: /^(?:pub(?:\([^)]*\))?\s+)?struct\s+[A-Za-z_]/, classy: false }, + { regex: /^(?:pub(?:\([^)]*\))?\s+)?enum\s+[A-Za-z_]/, classy: false }, + { regex: /^(?:pub(?:\([^)]*\))?\s+)?trait\s+[A-Za-z_]/, classy: true }, + { regex: /^(?:unsafe\s+)?impl(?:<[^>]*>)?\s+/, classy: true }, + { regex: RUST_MEMBER_FN_REGEX, classy: false }, + ], + memberRules: [{ regex: RUST_MEMBER_FN_REGEX, nameGroup: 1 }], +}; + +const JAVA_CS_CONFIG: BraceLanguageConfig = { + commentStyle: "//", + topLevelRules: [ + { + regex: /^(?:(?:public|private|protected|internal|static|abstract|sealed|final|partial)\s+)*(?:class|interface|enum|record|struct)\s+[A-Za-z_]/, + classy: true, + }, + ], + memberRules: [ + { + regex: /^(?:(?:public|private|protected|internal|static|final|abstract|override|virtual|async)\s+)*(?:[\w<>[\],.?\s]+\s+)?([A-Za-z_]\w*)\s*\(/, + nameGroup: 1, + excludeNames: CONTROL_KEYWORDS, + }, + ], + allowNamespaceWrapping: true, +}; + +type SupportedLanguage = "js" | "python" | "go" | "rust" | "javaCs"; + +const EXTENSION_LANGUAGE: Record = { + ".js": "js", + ".jsx": "js", + ".ts": "js", + ".tsx": "js", + ".mjs": "js", + ".cjs": "js", + ".py": "python", + ".go": "go", + ".rs": "rust", + ".java": "javaCs", + ".cs": "javaCs", +}; + +/** Extract declaration entries for a file, or undefined if the extension has no outline support. */ +function extractOutline(content: string, ext: string): OutlineEntry[] | undefined { + const language = EXTENSION_LANGUAGE[ext.toLowerCase()]; + switch (language) { + case "js": + return extractBraceLanguage(content, JS_TS_CONFIG); + case "python": + return extractPython(content); + case "go": + return extractBraceLanguage(content, GO_CONFIG); + case "rust": + return extractBraceLanguage(content, RUST_CONFIG); + case "javaCs": + return extractBraceLanguage(content, JAVA_CS_CONFIG); + default: + return undefined; + } +} + +// ============================================================================ +// Rendering +// ============================================================================ + +function formatOutlineCall(args: { path?: string } | undefined, theme: Theme, cwd: string): string { + const pathDisplay = renderToolPath(str(args?.path), theme, cwd); + return `${theme.fg("toolTitle", theme.bold("outline"))} ${pathDisplay}`; +} + +function formatOutlineResult( + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: OutlineToolDetails; + }, + options: ToolRenderResultOptions, + theme: Theme, + showImages: boolean, +): string { + const output = getTextOutput(result, showImages).trim(); + let text = ""; + if (output) { + const lines = output.split("\n"); + const maxLines = options.expanded ? lines.length : 20; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; + } + } + + const entryLimit = result.details?.entryLimitReached; + const truncation = result.details?.truncation; + if (entryLimit || truncation?.truncated) { + const warnings: string[] = []; + if (entryLimit) warnings.push(`${entryLimit} declarations limit`); + if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); + text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; + } + return text; +} + +export function createOutlineToolDefinition( + cwd: string, + options?: OutlineToolOptions, +): ToolDefinition { + const ops = options?.operations ?? defaultOutlineOperations; + return { + name: "outline", + label: "outline", + description: + "Show a compact outline of a single file's top-level declarations (classes, functions, interfaces, types, structs, methods, etc.) as signatures only, without bodies. " + + "Cheaper than reading a whole file to learn its shape; use it before read/grep to narrow in on the relevant part of a large or unfamiliar file. " + + `Supports JS/TS, Python, Go, Rust, and Java/C#. Output is truncated to ${DEFAULT_ENTRY_LIMIT} declarations or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`, + promptSnippet: "Show a file's top-level declarations as signatures only", + promptGuidelines: ["Use outline to preview a large or unfamiliar file's structure before reading it in full."], + parameters: outlineSchema, + async execute(_toolCallId, { path }: { path: string }, signal?: AbortSignal, _onUpdate?, _ctx?) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Operation aborted")); + return; + } + const onAbort = () => reject(new Error("Operation aborted")); + signal?.addEventListener("abort", onAbort, { once: true }); + + (async () => { + try { + const absolutePath = resolveToCwd(path, cwd); + + try { + await ops.access(absolutePath); + } catch { + reject(new Error(`Path not found: ${absolutePath}`)); + return; + } + + const stat = await ops.stat(absolutePath); + if (stat.isDirectory()) { + reject(new Error(`Not a file: ${absolutePath}. outline only supports a single file`)); + return; + } + + const buffer = await ops.readFile(absolutePath); + const content = buffer.toString("utf-8"); + const ext = extname(absolutePath); + const entries = extractOutline(content, ext); + + signal?.removeEventListener("abort", onAbort); + + if (entries === undefined) { + resolve({ + content: [{ type: "text", text: "No outline support for this file type; use read instead." }], + details: undefined, + }); + return; + } + + if (entries.length === 0) { + resolve({ + content: [{ type: "text", text: "(no top-level declarations found)" }], + details: undefined, + }); + return; + } + + let entryLimitReached = false; + let limitedEntries = entries; + if (entries.length > DEFAULT_ENTRY_LIMIT) { + entryLimitReached = true; + limitedEntries = entries.slice(0, DEFAULT_ENTRY_LIMIT); + } + + const rawOutput = limitedEntries + .map((entry) => `${entry.indent === 1 ? " " : ""}${entry.line}: ${entry.text}`) + .join("\n"); + // Apply byte truncation. There is no separate line limit because entry count is already capped. + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + let output = truncation.content; + const details: OutlineToolDetails = {}; + const notices: string[] = []; + if (entryLimitReached) { + notices.push(`${DEFAULT_ENTRY_LIMIT} declarations limit reached. Use grep to search for more`); + details.entryLimitReached = DEFAULT_ENTRY_LIMIT; + } + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + if (notices.length > 0) { + output += `\n\n[${notices.join(". ")}]`; + } + + resolve({ + content: [{ type: "text", text: output }], + details: Object.keys(details).length > 0 ? details : undefined, + }); + } catch (e: any) { + signal?.removeEventListener("abort", onAbort); + reject(e); + } + })(); + }); + }, + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatOutlineCall(args, theme, context.cwd)); + return text; + }, + renderResult(result, options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatOutlineResult(result as any, options, theme, context.showImages)); + return text; + }, + }; +} + +export function createOutlineTool(cwd: string, options?: OutlineToolOptions): AgentTool { + return wrapToolDefinition(createOutlineToolDefinition(cwd, options)); +} diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 3d4ca9831f2..9f1a53beb00 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -218,6 +218,7 @@ export { createFindTool, createGrepTool, createLsTool, + createOutlineTool, createReadOnlyTools, createReadTool, createWriteTool, @@ -285,6 +286,7 @@ export { createGrepToolDefinition, createLocalBashOperations, createLsToolDefinition, + createOutlineToolDefinition, createReadToolDefinition, createWriteToolDefinition, DEFAULT_MAX_BYTES, @@ -306,6 +308,10 @@ export { type LsToolDetails, type LsToolInput, type LsToolOptions, + type OutlineOperations, + type OutlineToolDetails, + type OutlineToolInput, + type OutlineToolOptions, type ReadOperations, type ReadToolDetails, type ReadToolInput, diff --git a/packages/coding-agent/test/outline-tool.test.ts b/packages/coding-agent/test/outline-tool.test.ts new file mode 100644 index 00000000000..ecf3d2f715e --- /dev/null +++ b/packages/coding-agent/test/outline-tool.test.ts @@ -0,0 +1,185 @@ +import { mkdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createOutlineTool } from "../src/index.ts"; + +function getTextOutput(result: any): string { + return ( + result.content + ?.filter((c: any) => c.type === "text") + .map((c: any) => c.text) + .join("\n") || "" + ); +} + +describe("outline tool", () => { + let testDir: string; + const outlineTool = createOutlineTool(process.cwd()); + + beforeEach(() => { + testDir = join(tmpdir(), `coding-agent-outline-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it("extracts top-level functions/classes and nested methods from a JS/TS fixture", async () => { + const testFile = join(testDir, "example.ts"); + const content = [ + /* 1 */ "import { foo } from './foo';", + /* 2 */ "", + /* 3 */ "export interface Point {", + /* 4 */ " x: number;", + /* 5 */ "}", + /* 6 */ "", + /* 7 */ "export type Vector = Point[];", + /* 8 */ "", + /* 9 */ "export class Shape {", + /*10 */ " private name: string;", + /*11 */ "", + /*12 */ " constructor(name: string) {", + /*13 */ " this.name = name;", + /*14 */ " }", + /*15 */ "", + /*16 */ " area(): number {", + /*17 */ " return 0;", + /*18 */ " }", + /*19 */ "}", + /*20 */ "", + /*21 */ "export function makeShape(name: string): Shape {", + /*22 */ " return new Shape(name);", + /*23 */ "}", + /*24 */ "", + /*25 */ "export const scale = (v: Vector, factor: number): Vector => {", + /*26 */ " return v;", + /*27 */ "};", + ].join("\n"); + writeFileSync(testFile, content); + + const result = await outlineTool.execute("test-call-1", { path: testFile }); + const output = getTextOutput(result); + + expect(output).toContain("3: export interface Point"); + expect(output).toContain("7: export type Vector = Point[]"); + expect(output).toContain("9: export class Shape"); + expect(output).toContain(" 12: constructor(name: string)"); + expect(output).toContain(" 16: area(): number"); + expect(output).toContain("21: export function makeShape(name: string): Shape"); + expect(output).toContain("25: export const scale = (v: Vector, factor: number): Vector =>"); + // Method bodies should not leak in as separate declarations. + expect(output).not.toContain("return 0"); + expect(result.details).toBeUndefined(); + }); + + it("extracts def/class from a Python fixture with nested methods", async () => { + const testFile = join(testDir, "example.py"); + const content = [ + /*1*/ "import os", + /*2*/ "", + /*3*/ "def top_level(x):", + /*4*/ " return x", + /*5*/ "", + /*6*/ "class Animal:", + /*7*/ " def __init__(self, name):", + /*8*/ " self.name = name", + /*9*/ "", + /*10*/ " def speak(self):", + /*11*/ " return 'noise'", + /*12*/ "", + /*13*/ "def another_top_level():", + /*14*/ " pass", + ].join("\n"); + writeFileSync(testFile, content); + + const result = await outlineTool.execute("test-call-2", { path: testFile }); + const output = getTextOutput(result); + + expect(output).toContain("3: def top_level(x)"); + expect(output).toContain("6: class Animal"); + expect(output).toContain(" 7: def __init__(self, name)"); + expect(output).toContain(" 10: def speak(self)"); + expect(output).toContain("13: def another_top_level()"); + expect(output).not.toContain("return x"); + }); + + it("returns a not-supported message (not an error) for an unrecognized extension", async () => { + const testFile = join(testDir, "notes.md"); + writeFileSync(testFile, "# Title\n\nSome text.\n"); + + const result = await outlineTool.execute("test-call-3", { path: testFile }); + const output = getTextOutput(result); + + expect(output).toBe("No outline support for this file type; use read instead."); + expect(result.details).toBeUndefined(); + }); + + it("handles an empty file without crashing", async () => { + const testFile = join(testDir, "empty.ts"); + writeFileSync(testFile, ""); + + const result = await outlineTool.execute("test-call-4", { path: testFile }); + const output = getTextOutput(result); + + expect(output).toBe("(no top-level declarations found)"); + }); + + it("truncates with a notice when a file has an unusually large number of declarations", async () => { + const testFile = join(testDir, "many.go"); + const lines: string[] = []; + for (let i = 0; i < 600; i++) { + lines.push(`func Fn${i}() {`); + lines.push(`\treturn`); + lines.push(`}`); + } + writeFileSync(testFile, lines.join("\n")); + + const result = await outlineTool.execute("test-call-5", { path: testFile }); + const output = getTextOutput(result); + + expect(output).toContain("func Fn0()"); + expect(output).toContain("declarations limit reached"); + expect(result.details).toBeDefined(); + expect(result.details?.entryLimitReached).toBe(500); + }); + + it("extracts Go func/type declarations without picking up locals inside function bodies", async () => { + const testFile = join(testDir, "example.go"); + const content = [ + /*1*/ "package main", + /*2*/ "", + /*3*/ "type Point struct {", + /*4*/ "\tX int", + /*5*/ "}", + /*6*/ "", + /*7*/ "func (p *Point) Move(dx int) {", + /*8*/ "\ttype local struct{}", + /*9*/ "\tp.X += dx", + /*10*/ "}", + /*11*/ "", + /*12*/ "func NewPoint() *Point {", + /*13*/ "\treturn &Point{}", + /*14*/ "}", + ].join("\n"); + writeFileSync(testFile, content); + + const result = await outlineTool.execute("test-call-6", { path: testFile }); + const output = getTextOutput(result); + + expect(output).toContain("3: type Point struct {".replace(" {", "")); + expect(output).toContain("7: func (p *Point) Move(dx int)"); + expect(output).toContain("12: func NewPoint() *Point"); + expect(output).not.toContain("local struct"); + }); + + it("rejects directories", async () => { + await expect(outlineTool.execute("test-call-7", { path: testDir })).rejects.toThrow(/Not a file/); + }); + + it("rejects missing files", async () => { + const missing = join(testDir, "missing.ts"); + await expect(outlineTool.execute("test-call-8", { path: missing })).rejects.toThrow(/Path not found/); + }); +}); From b68decede07c06a8a3905ecb8c10b6e966877082 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 4 Aug 2026 15:07:26 +0200 Subject: [PATCH 14/34] docs(agent): fix coherence defects found by independent review Full-document review by a fresh-context subagent found 2 blockers, 15 minors, and 12 nits; a second subagent verified every fix. All applied: - autoCompact ran before_compaction only when no step was unfinished, making the hook unreachable on the live overflow path where the abandoned assistant step still occupies LaneState; now keyed on "not resuming a compaction step". - Hook-supplied overflow compactions wrote no step_attempt, so the once-per-input guard never counted them and hook-driven compact-and- retry was unbounded; for reason overflow the hook path now writes the compaction step_attempt first. - Empty compaction preparation is a real code branch, terminal for overflow. before_resume is invoked in the resume() dispatch. Pending deferred re-park verifies handle equality. resume() re-tags operation results as ResumeResult. MessageEntry declares terminate; options gain toolExecution; before_navigation loses its orphaned result fields; AbortRequestedRecord loses its unreachable reason field. - Reduction bullets restated (newest-attempt closure, newest-own entry); Tier B records lane moves and covers the overflow and cancellation traces; hook-measured usage is written as hook ledger records at all three append sites; assorted stale wording from before the deferred- closure, ledger, and B2 changes brought current. --- packages/agent/docs/harness-v2.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index 703d7e0d954..d9aaf1c82f5 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -114,7 +114,7 @@ An operation is accepted before it executes. Acceptance is durable: after a cras A run is a sequence of turns. A turn is one assistant step plus the complete tool batch requested by that assistant message. -A step is a retryable unit of work inside an operation: produce an assistant message, a compaction summary, or a branch summary. A step may make zero, one, or several provider requests. A failed attempt retries the same step; the attempt count is durable and survives restarts. A deferred provider request suspends an assistant step: the handle arrives inside a persisted assistant message, the lane suspends, and redemption later appends the real result (section 1). +A step is a retryable unit of work inside an operation: produce an assistant message, a compaction summary, or a branch summary. A step may make zero, one, or several provider requests. A failed attempt retries the same step; the attempt count is durable and survives restarts. A deferred provider request ends an assistant step: the handle arrives inside a persisted assistant message that closes the step, the operation suspends, and redemption later appends the real result (section 1). Each tool call that starts an effect is also a step. `tool_started` opens it; its tool-result entry closes it. A parallel batch holds several open tool steps at once; their effects run concurrently and finalize in source order (section 14). @@ -262,7 +262,7 @@ interface OperationFinishedRecord extends RecordBase { } // Written before each attempt at a retryable step. Marks: we are about to -// do this, for the n-th time. Steps are logged only because they are +// do this, for the n-th time. Steps are logged because they are // retryable: the durable count caps retries across restarts — a // crash-restart loop cannot reset it. One record per attempt; one attempt // may make zero or several provider requests (split-turn compaction @@ -658,7 +658,7 @@ Both reads are bounded by the size of the open operation, not by the size of the From those two reads, the lane's state: - **aborting** — an `abort_requested` record exists. -- **attempts used** — the newest `step_attempt` whose `resultEntryId` has no entry is the unfinished step; its `attempt` field is the durable count, its kind and `compactionReason` select the resume path. Closure is a point lookup, not adjacency inference: a step is closed exactly when the newest attempt's provisioned result exists. Earlier attempts' unfulfilled ids belong to finished work and need no inspection. +- **attempts used** — the newest `step_attempt`, when its `resultEntryId` has no entry, is the unfinished step; its `attempt` field is the durable count, its kind and `compactionReason` select the resume path. Closure is a point lookup, not adjacency inference: a step is closed exactly when the newest attempt's provisioned result exists. Earlier attempts' unfulfilled ids belong to finished work and need no inspection. - **overflow recovery used** — a compaction `step_attempt` with reason `overflow` is newer than the newest consumed conversational message of this run (section 6, overflow guard). - **tool batch** — the newest assistant entry with tool calls, each call matched against `tool_started` records and result entries (section 6, crash-site table). The assistant stop reason is retained: a `length` batch is truncated and never executes on recovery. Persisted `terminate` values on result entries decide whether the completed batch forces another turn. - **deferred handle** — the newest own entry is a deferred assistant message with no successor. @@ -1037,7 +1037,7 @@ Calls on a faulted harness reject with the same `HarnessFault` instance until th `finalMessage` is the run's newest entry that projects to an assistant message; `finalEntryId` is that entry's id. `leafId` is the lane's leaf when the operation finished — the race-free anchor for branch queries (`findEntriesOnBranch({ start: leafId })`). The two differ when a deferred write was applied after the final assistant message. Full transcripts are not duplicated into results; they are in the session and were delivered as events. -**Type provenance.** Types this document uses but does not redefine — `QueueMode`, `RetryPolicy`, `CompactionSettings`, `CompactionPreparation`, `NavigationPreparation`, `CompactResult`, `ToolResultPatch`, `SessionStats`, `SessionMetadata`, `NavigateOptions`, `EntryCursor`, `LogItem`, `StreamOptionsPatch` — keep their existing `harness/types.ts` shapes. Lowercase helpers in section 15 pseudocode without a definition (`preparation`, `runToolBatchForSingleCall`, request/option bags such as `AssistantRequest` and `FactWrite`) are constructive implementation detail, not contract. +**Type provenance.** Types this document uses but does not redefine — `QueueMode`, `RetryPolicy`, `CompactionSettings`, `CompactionPreparation`, `NavigationPreparation` (today's `TreePreparation`, renamed), `CompactResult`, `ToolResultPatch`, `SessionStats`, `SessionMetadata`, `NavigateOptions`, `EntryCursor`, `LogItem`, `StreamOptionsPatch` — keep their existing `harness/types.ts` shapes. Lowercase helpers in section 15 pseudocode without a definition (`preparation`, `runToolBatchForSingleCall`, request/option bags such as `AssistantRequest` and `FactWrite`) are constructive implementation detail, not contract. ### Suspended operations @@ -1373,8 +1373,9 @@ after_tool: { // Structural operations ------------------------------------------------ // Decline, adjust, or supply the summary. Runs after operation_started, -// live and on resume alike. Not re-run when the result entry exists or a -// step_attempt already durably selected generated-summary work. +// live and on resume alike. Not re-run when the result entry exists or +// any step_attempt for this work already exists (hook-written or generated +// — records cannot distinguish them, and neither needs the hook again). before_compaction: { event: { reason: "manual" | "threshold" | "overflow"; preparation: CompactionPreparation; customInstructions? }; result: { decline?: boolean; compaction?: CompactResult } | undefined; @@ -1398,7 +1399,7 @@ Hooks re-run only where the work itself re-runs. Persisted outputs are never rec | `after_response` | per response | per response | per response | | `before_tool` | per call | — | not when `tool_started` exists | | `after_tool` | per executed result | — | on safe replay only | -| `before_compaction`, `before_navigation` | per operation | no | not when a result entry or a generated-summary `step_attempt` exists | +| `before_compaction`, `before_navigation` | per operation | no | not when a result entry or any `step_attempt` for this work exists | | `before_run_end` | per normal finish boundary | — | at the boundary resume reaches (may repeat); never for abort, terminal failure, or exhausted auto-compaction | ## 12. Session and SessionTree @@ -2349,7 +2350,8 @@ async function reconcileToolBatch(batch: ToolBatchState): Promise { tool: toolByName(call.started.toolName), args: call.started.effectiveArgs }; // persisted, not re-derived const executed = await fx.executeTool(prepared); - const finalized = await finalizeToolCall(prepared, executed, { afterToolCall }, abortSignal); + const finalized = await finalizeToolCall(prepared, executed, + { afterToolCall }, toolContext, abortSignal); // the fx-wired hook callback (runToolBatch) if (finalized.result.usage) { await fx.appendRecord(toolUsageRecord(op.id, call.started.resultEntryId, call.toolCall.id, finalized.result.usage)); // the replay's own record @@ -2425,7 +2427,7 @@ async function autoCompact(reason: "threshold" | "overflow"): Promise { if (op.step?.kind !== "compaction") { // no durable compaction decision yet; on the overflow // path op.step is the abandoned assistant step const prep = preparation(state); - if (prep.nothingToCompact) { + if (nothingToCompact(prep)) { if (reason === "overflow") throw new RunFailed(truncationError()); return; } @@ -2830,7 +2832,7 @@ Gate invariants, asserted across Tier C: - The existing `agent-loop` and `agent` suites pass unchanged — the section 14 compatibility criterion. - Event ordering per section 10, including `message_end` after commit. - Hooks: registration-id `resumeData` round trips, duplicate-id rejection, aggregation order, fail-closed `before_tool`. -- Ledger completeness and the match invariant: every provider request leaves exactly one `usage` record per physical request (split-turn: two per attempt); failed compaction series and discarded overflow responses lose no recorded cost; each usage-bearing entry's snapshot equals the newest non-adjustment record(s) bound to its id; a replayed tool records both executions; adjustments never alter entries and sum into read-time effective cost; `getStats()` equals the ledger sum and the `usage` event's totals after every commit; forks start at zero; v3 conversion preserves totals through the aggregate import adjustment. +- Ledger completeness and the match invariant: every provider request leaves exactly one `usage` record per physical request (split-turn: two per attempt; a pending deferred fetch that reports no usage writes none); failed compaction series and discarded overflow responses lose no recorded cost; each usage-bearing entry's snapshot equals the newest non-adjustment record(s) bound to its id; a replayed tool records both executions; adjustments never alter entries and sum into read-time effective cost; `getStats()` equals the ledger sum and the `usage` event's totals after every commit; forks start at zero; v3 conversion preserves totals through the aggregate import adjustment. - Overflow classification against the reported provider shapes: prompt 268,009 of a 272,000 window and 81,217 of 84,500 (recoverable), non-zero reasoning-only output, cache-write-heavy usage, a Codex-style provider that rejects `max_output_tokens`, a genuine 1,024-token cap fully used (not recoverable), and `length → length` stopping after exactly one recovery per conversational input. - v3 fixtures: labels, session info, and `leaf` entries mid-chain and at end of file, old `firstKeptEntryId` compactions — all open as one normalized idle `main` lane. From 4756e7d1220cd5bb4e88cd28690a8597fa50d89c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 4 Aug 2026 15:14:30 +0200 Subject: [PATCH 15/34] docs(agent): make harness v2 telemetry self-contained Section 18 no longer references observability.md, which described the superseded ALS/global-context mechanism and is being removed. Absorbed the two load-bearing points: the multi-runtime rationale for rejecting ambient context (explicit arguments are the only portable abstraction), and the adapter framing - the application supplies ExecutionContext to bridge spans into OTel/Sentry/logs/metrics, pi ships no exporter and no vendor dependency, adapters allocate their own span/trace ids, and may use AsyncLocalStorage internally. Dropped the reading-list entry. --- packages/agent/docs/harness-v2.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index d9aaf1c82f5..cafa8cf4c27 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -2657,7 +2657,9 @@ repo.create({ id?, parentSessionId? }): Promise; ## 18. Telemetry -Telemetry uses explicit context propagation. Core code does not use `AsyncLocalStorage`, global current-span state, or runtime-specific context APIs. This section defines the harness telemetry mechanism; `packages/agent/docs/observability.md` provides background only. +Telemetry uses explicit context propagation. Core code does not use `AsyncLocalStorage`, global current-span state, or runtime-specific context APIs: pi runs in Node, Bun, browsers, and workers, so no runtime's ambient-context mechanism can be the core abstraction, and explicit arguments are the only portable one. This section is the complete telemetry design; no other document defines any of it. + +Pi ships no exporter and depends on no telemetry vendor. The application supplies the `ExecutionContext` below; an **adapter** is such an implementation that bridges spans into OTel, Sentry, logs, or metrics. The contract passes live span objects, not span/trace ids — an adapter that needs ids (every OTel-shaped backend does) allocates and correlates them internally, so core never carries id plumbing. An adapter may use `AsyncLocalStorage` inside its own implementation on runtimes that have it; core will never require it. ### Context contract @@ -2876,4 +2878,3 @@ For a fresh implementation session, in this order. This document wins over anyth 15. `packages/storage/sqlite-node/src/sqlite/storage/branch-entries.ts` — the branch cache being generalized. 16. `packages/storage/sqlite-node/src/sqlite/repo.ts` — create/open/fork. 17. `packages/coding-agent/docs/session-format.md` — v3 JSONL, the compatibility target. -18. `packages/agent/docs/observability.md` — telemetry background; section 18 defines context propagation. From 04133eb01b082248f7d667c1214e09477a1c3db1 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 4 Aug 2026 15:19:23 +0200 Subject: [PATCH 16/34] docs(agent): remove superseded design docs --- packages/agent/docs/agent-harness.md | 506 ------------- packages/agent/docs/durable-harness.md | 212 ------ packages/agent/docs/hooks.md | 445 ------------ packages/agent/docs/models.md | 964 ------------------------- packages/agent/docs/observability.md | 376 ---------- 5 files changed, 2503 deletions(-) delete mode 100644 packages/agent/docs/agent-harness.md delete mode 100644 packages/agent/docs/durable-harness.md delete mode 100644 packages/agent/docs/hooks.md delete mode 100644 packages/agent/docs/models.md delete mode 100644 packages/agent/docs/observability.md diff --git a/packages/agent/docs/agent-harness.md b/packages/agent/docs/agent-harness.md deleted file mode 100644 index fdcffe24e41..00000000000 --- a/packages/agent/docs/agent-harness.md +++ /dev/null @@ -1,506 +0,0 @@ -# AgentHarness lifecycle - -`AgentHarness` is the orchestration layer above the low-level agent loop. It owns session persistence, runtime configuration, resource resolution, operation locking, and extension-facing mutation semantics. - -This document describes the current direction and implemented behavior. Some extension/session-facade details are planned and called out explicitly. - -## Ultimate lifecycle goal - -Harness listeners and hooks should be able to close over the `AgentHarness` instance and call public harness APIs from any event where those APIs are documented as allowed. Those calls must not corrupt in-flight turn snapshots, reorder persisted transcript entries, lose pending writes, deadlock settlement, or leave the harness in the wrong phase. - -The intended rule is: - -- structural operations remain rejected while busy -- queue operations are accepted at documented turn-safe points -- runtime config setters update future snapshots without mutating the current provider request -- session writes made while busy are durably queued and flushed in deterministic order -- getters return latest harness config, not in-flight snapshots -- listeners/hooks currently receive no facade; if they close over the raw harness and call settlement APIs such as `waitForIdle()` during the active run, they can deadlock. A future facade should expose `runWhenIdle()` instead. - -`AssistantMessageStream` already decouples provider transport streaming, such as SSE or websocket reads, from downstream event consumption. The harness can therefore await listeners, extension hooks, persistence, and save-point work without blocking the provider transport reader or reintroducing ad hoc event queues. Lifecycle code should prefer explicit awaited sequencing at harness boundaries over fire-and-forget hook/event settlement. - -A final lifecycle hardening pass should prove these guarantees with a broad listener/hook reentrancy test suite. - -## Error handling - -The current split is: - -- low-level capabilities and helpers use `Result` where expected failures are contained and must not throw, such as `ExecutionEnv`, filesystem/shell operations, shell-output capture, resource loading, and compaction helpers -- high-level mutation/orchestration APIs such as `Session` and `AgentHarness` reject/throw instead of returning bare results that can be ignored -- public `AgentHarness` failures are normalized to `AgentHarnessError` where practical; subsystem errors are preserved as `cause` - -Harness events observe committed state. Public mutators validate required input and persistence before committing when practical, then await notifications. If a hook or subscriber fails after commit, the state change is not rolled back and the public method rejects with `AgentHarnessError` code `"hook"`. - -## State model - -The harness separates state into four categories. - -### Harness config - -Harness config is the latest runtime configuration set by the application or extensions: - -- model -- thinking level -- tools -- active tool names -- tool context source -- resources -- stream options -- system prompt or system prompt provider - -Getters return harness config. They do not return the snapshot used by an in-flight provider request. - -Setters update harness config immediately, including while a turn is in flight. Changes affect the next turn snapshot, not the currently running provider request. - -`setResources()` accepts concrete resources and emits `resources_update` on every call with shallow-copied current and previous resources. Applications own loading/reloading resources from disk or other sources and should call `setResources()` with new values. - -`getResources()` returns shallow-copied current resources. It is a live config read, not the last turn snapshot. - -### Turn snapshot - -A turn snapshot is the concrete state used for one LLM turn. It is created by `createTurnState()` and contains: - -- persisted session messages -- resolved resources -- resolved system prompt -- model -- thinking level -- all tools -- active tools -- resolved tool context -- stream options -- derived session id - -Static option values are used directly. System-prompt provider callbacks are invoked once per `createTurnState()` call. All logic for that turn uses the same snapshot. - -Resource arrays are shallow-copied when a snapshot is created. Individual skill and prompt-template objects are not deep-copied. - -`toolContext` is application-defined and required when the configured tools require a non-`undefined` context. A static value is reused, while a zero-argument sync or async provider is resolved once for each turn snapshot. Harness tools receive that resolved value when they execute. Individual tools can structurally require only the context fields they use. - -Stream options are shallow-copied when a snapshot is created. `headers` and `metadata` maps are shallow-copied; their values are not deep-copied. Credentials from `getApiKeyAndHeaders()` are resolved per provider request so expiring tokens can refresh, but the configured stream options and derived session id come from the current turn snapshot. - -### Built-in tools - -The package exports `createReadTool()`, `createWriteTool()`, `createEditTool()`, and `createBashTool()`. They perform filesystem and shell operations exclusively through the `ExecutionEnv` supplied in their tool context. Each tool structurally requires the shared `ExecutionToolContext`, containing `env: ExecutionEnv`; applications may provide additional fields. `createReadTool()` accepts an optional image processor for host-provided conversion and resizing without imposing an image-processing dependency on the agent package. `createBashTool()` accepts an async `prepare` hook that can mutate the command, working directory, environment, and environment-inheritance policy using the current tool context. - -### Session - -The session contains persisted entries only. Session reads return persisted state and do not include queued writes. - -`Session.buildContextEntries()` returns the compaction-aware entry sequence used for model context construction. `Session.buildContext()` derives runtime state from the full active branch, then projects those context entries to `AgentMessage[]`. Custom entries are omitted from model context by default; applications can pass `entryProjectors` to the `Session` constructor or `buildContext()` to project selected custom entries into messages. Applications can also pass stacked `entryTransforms`, which run after the default compaction transform, to filter or reorder context entries before projection. - -Session storage implementations must persist leaf changes as `leaf` entries. `setLeafId()` is not an in-memory-only cursor update; it appends a durable entry whose `targetId` is the active tree leaf or `null` for root. Reopening storage must reconstruct the current leaf from the latest persisted leaf-affecting entry. - -### Pending session writes - -Session writes requested while an operation is active are queued as pending session writes. Pending writes are based on session-entry shapes without generated fields (`id`, `parentId`, `timestamp`). - -Pending session writes are always persisted. They are flushed at save points, at operation settlement, and in failure cleanup. - -A public pending-writes/session-facade API is planned but not implemented yet. - -## Operation phases - -The harness has an explicit phase: - -```ts -type AgentHarnessPhase = "idle" | "turn" | "compaction" | "branch_summary" | "retry"; -``` - -Structural operations require `phase === "idle"` and synchronously set the phase before the first `await`: - -- `prompt` -- `skill` -- `promptFromTemplate` -- `compact` -- `navigateTree` - -Starting another structural operation while the harness is not idle rejects with `AgentHarnessError` code `"busy"`. - -The following operations are allowed during a turn where appropriate: - -- `steer` -- `followUp` -- `nextTurn` -- `abort` -- runtime config setters - -Phase/settlement semantics are still provisional and need a full lifecycle pass. - -## Turn execution - -`prompt`, `skill`, and `promptFromTemplate` follow the same flow: - -1. Assert idle and set phase to `"turn"`. -2. Create a turn snapshot with `createTurnState()`. -3. Derive invocation text from that snapshot. -4. Execute the turn with `executeTurn()`. - -`skill` and `promptFromTemplate` resolve their resource from the same snapshot that is passed to the turn. They do not resolve resources separately. - -`steer`, `followUp`, and `nextTurn` accept text plus optional images and create user messages internally. `nextTurn` messages are inserted before the new user message on the next user-initiated turn. - -Queue modes are live, not turn-snapshotted: - -- `getSteeringMode()` / `setSteeringMode()` -- `getFollowUpMode()` / `setFollowUpMode()` - -Changing a queue mode during a run affects the next queue drain. Queue drains happen at safe points. - -## Save points - -A save point occurs after an assistant turn and its tool-result messages have completed. - -At a save point the harness: - -1. flushes pending session writes after the agent-emitted messages for that turn -2. creates a fresh turn snapshot if the low-level loop may continue -3. applies the fresh context/model/thinking-level/stream-options/session-id state before the next provider request - -This lets model, thinking level, tool, resource, stream option, and system prompt changes made during a turn affect the next turn in the same run, while never mutating an in-flight provider request. Because provider transport reading is already decoupled by `AssistantMessageStream`, save-point work and hook settlement can be awaited directly to keep transcript/session ordering deterministic. The loop callbacks are not recreated at save points. - -The low-level loop converts harness `ThinkingLevel` to provider `reasoning` at the provider boundary: - -- `"off"` -> `undefined` -- all other thinking levels pass through - -No state refresh is needed on `agent_end` except flushing leftover pending session writes and clearing the operation phase. The exact `settled` event timing is still under review. - -If the system-prompt callback throws while starting `prompt`, `skill`, or `promptFromTemplate`, the operation rejects with `AgentHarnessError` and the harness returns to idle. If it throws from the save-point snapshot created by `prepareNextTurn`, the low-level agent run records an assistant error message. - -## Hooks and events - -The target hook system is described in [hooks.md](./hooks.md). - -Summary: - -- `AgentHarness` emits typed hook events and consumes typed results. -- A single hooks implementation owns registration, cleanup, provenance, and result reducers. -- Observational and mutation hooks use one event-specific `on()` API; the event result type determines whether a handler may return a result. -- Result-producing events are reduced by typed reducer tables; app-specific hooks add reducers only for app-specific result-producing events. -- Hook registration provenance is sidecar metadata on the registration. Resource and tool provenance belongs on app-specific concrete value types. -- Hook context should be a plain object of facades, not raw internals or late-bound getter mazes. - -Event payloads describe what is happening. Harness getters describe latest config for future snapshots. Hook and listener settlement should be awaited in lifecycle order where possible; transport backpressure is handled below the harness by `AssistantMessageStream`, so the harness does not need a separate async event queue merely to keep SSE or websocket reads flowing. - -### Summarization retry events - -When the harness is configured with a retry policy, generated compaction and branch-summary requests emit retry lifecycle events for transient provider errors: - -- `retry_scheduled`: a retry was scheduled. Includes `operation: "compaction" | "branch_summary"`, `attempt`, `maxAttempts`, `delayMs`, and `errorMessage`. -- `retry_attempt_start`: the backoff delay completed and the retried summarization request is starting. Includes `operation`. -- `retry_finished`: the retry loop finished after success, exhaustion, or abort. Includes `operation`. - -These events are observational and do not accept hook results. - -## Planned session facade - -Extensions should eventually interact with a harness-scoped `HarnessSession` facade rather than the raw session. The facade should wrap the internal session and enforce harness pending-write ordering semantics. Once this exists, hooks and event listeners can receive a context that exposes the full `AgentHarness` plus the session facade without giving direct access to unordered raw session writes. - -Planned read semantics: - -- reads delegate to persisted session state -- reads do not include queued pending writes - -Planned write semantics: - -- idle: persist immediately -- busy: enqueue as pending session writes - -A planned diagnostics API may expose pending writes explicitly: - -```ts -getPendingWrites(): readonly PendingSessionWrite[] -``` - -Agent-emitted messages are persisted on `message_end` to preserve transcript ordering. Pending extension/session writes flush after those messages at save points. - -## Abort - -Abort is allowed during a turn. It aborts the low-level run and clears steering/follow-up queues. - -Abort does not clear `nextTurn` messages. Messages queued with `nextTurn()` survive abort and are inserted before the user message on the next user-initiated turn. - -Abort does not discard pending session writes. Pending writes flush at the next save point if reached, at `agent_end`, or in operation failure cleanup. - -Abort barrier semantics still need an audit. - -## Compaction and tree navigation - -Compaction and tree navigation are structural session mutations. - -They are allowed only while idle and are not queued. They operate on persisted session state. The next prompt creates a fresh turn snapshot. - -Branch summary generation is part of the tree navigation operation. - -Auto-compaction and retry decision points are not implemented in `AgentHarness` yet. - -## Test organization - -Harness tests should stay focused by area instead of growing one large catch-all file. - -Current structure: - -- `packages/agent/test/harness/agent-harness.test.ts`: core lifecycle and public API behavior. -- `packages/agent/test/harness/agent-harness-stream.test.ts`: stream options and provider hook semantics. - -Preferred future structure: - -- `agent-harness-resources.test.ts`: resource snapshot/loading semantics. -- `agent-harness-tools.test.ts`: tool registry getters, active-tool semantics, and update events. -- `agent-harness-lifecycle.test.ts`: phase/save-point/settled/reentrancy behavior. - -Use the `pi-ai` faux provider (`registerFauxProvider`, `fauxAssistantMessage`) for deterministic harness/provider tests. Faux response factories can inspect `StreamOptions`, invoke `options.onPayload`, and return scripted assistant messages without real provider APIs or network access. - -Harness coverage is configured separately from the default package test run: - -```bash -npm run test:harness -npm run coverage:harness -``` - -`coverage:harness` runs `test/harness/**/*.test.ts` and reports coverage for `src/harness/**/*.ts` plus the non-harness runtime files it directly exercises (`src/agent.ts` and `src/agent-loop.ts`) into `coverage/harness`. Type-only dependencies such as `src/types.ts` are not included because they have no meaningful runtime coverage. - -## Implementation todo - -This list tracks the remaining work before treating `AgentHarness` as migration-ready. Active/planned items are ordered from easiest to hardest. Completed items are archived at the bottom. - -### 1. Add explicit tool registry read/update semantics - -Status: In progress - -Done: - -- Added `setTools(tools, activeToolNames?)`. -- Added `setActiveTools(toolNames)`. -- Invalid active tool names reject with `AgentHarnessError`. -- Added generic app tool and context shapes via `AgentHarness`. -- Exported `QueueMode` from core types. -- Added `AgentHarnessOptions.steeringMode` and `followUpMode`. -- Added live `getSteeringMode()` / `setSteeringMode()` and `getFollowUpMode()` / `setFollowUpMode()`. -- Added `getTools()` and `getActiveTools()`. -- Added `tools_update` observability events, including active-tool-only updates. -- Active tool changes are persisted as branch-scoped `active_tools_change` entries. -- Duplicate tool names and duplicate active tool names reject. - -Remaining: - -- None. - -Notes: - -- Observability design: [observability.md](./observability.md) - -### 2. Design per-`AgentHarness` model registry - -Status: Planned - -Done: - -- Current `setModel()` behavior is preserved. - -Remaining: - -- Decide how applications supply the model registry. -- Decide whether the harness stores concrete `Model` objects, model references, or both. -- Validate model selection against the registry. -- Define model change semantics during active turns and save points. - -### 3. Full `AgentHarness` lifecycle/state pass - -Status: In progress - -Done: - -- Removed constructor `void syncFromTree()`, `syncFromTree()`, `liveOperationId`, and `shell()`. -- Added `createTurnState()`, `applyTurnState()`, and `executeTurn()`. -- Added explicit `phase` in place of boolean idle state. -- Save points refresh context, model, thinking level, stream options, and session snapshot state. -- Pending session writes use session-entry shapes without generated fields. -- Pending session writes flush at save points, settlement, and failure cleanup. -- `steer`, `followUp`, and `nextTurn` create user messages from text plus optional images. -- `nextTurn` messages are inserted before the new user prompt. -- Structural compaction/tree operations restore phase with `finally`. -- Public harness failures normalize subsystem causes to `AgentHarnessError`. -- Pending session writes flush one-by-one and are not dropped on failure. -- Queue drains roll back if queue-update notification fails. -- `message_end` persistence happens before subscriber notification. -- `abort()` signals cancellation before notifications and still waits for idle through notification errors. -- Idle model/thinking/tool updates validate and persist before committing in-memory state. -- `setLeafId()` persists durable `leaf` entries so tree navigation survives storage reopen. - -Remaining: - -- Finalize phase/idle semantics. -- Audit whether `settled` can fire too early. -- Make session writes inside `settled` callbacks deterministic. -- Audit follow-up behavior around `agent_end`. -- Implement auto-compaction decision point. -- Implement retry handling. -- Verify `before_agent_start` hook semantics against coding-agent. -- Decide whether `before_agent_start` needs more turn info such as tools/tool snippets. -- Document or change runtime config event timing while busy. -- Audit `abort()` barrier semantics. - -### 4. Implement generic hook/event extension mechanism - -Status: Designed in [hooks.md](./hooks.md), not implemented - -Done: - -- Removed `AgentHarnessContext`. -- Hooks receive only event payloads. -- `emitHook(event)` derives the hook type from `event.type`. -- Provider request/payload hooks have ordered transform semantics. - -Remaining: - -- Add `HookEvent`, `ResultOf`, registration options with generic source metadata, and the single `AgentHarnessHooks` implementation. -- Move result chaining out of `AgentHarness` into reducer functions. -- Type-check base harness reducers so every result-producing `AgentHarnessEvent` has reducer semantics. -- Make `AgentHarness` accept and expose the concrete hooks instance with constructor inference for app-specific hooks. -- Define the initial harness/context facades exposed through hook context. -- Preserve current provider hook behavior, including stream option patch deletion semantics. -- Add parity tests for reducer semantics: transform chaining, patch chaining, early block/cancel, cleanup, source metadata, and typed app-specific reducer coverage. - -Notes: - -- Hook design: [hooks.md](./hooks.md) - -### 5. Spike semi-durable harness/session recovery - -Status: Planned - -Done: - -- Wrote durability design: [durable-harness.md](./durable-harness.md) - -Remaining: - -- Decide whether session owns all durable harness state or whether any sidecars are needed for large blobs. -- Define durable entries for queues, pending writes, operations, turns, provider requests, and tool calls. -- Define resume requirements for app-provided tools, models, extensions, resources, hooks, and auth providers. -- Define conservative recovery policy for unfinished agent turns, provider requests, tool calls, compaction, and tree navigation. -- Prototype reducer-based recovery from session entries. -- Decide whether interrupted operations append user-visible messages or only internal operation entries. - -Notes: - -- Provider streams are not resumable; recovery should restart from durable boundaries or mark operations interrupted. -- Unfinished tool calls are unsafe to retry unless tools declare idempotent/retry-safe behavior. - -### 6. Final lifecycle hardening suite - -Status: Planned - -Done: - -- None. - -Remaining: - -- Add broad listener/hook reentrancy tests across relevant events. -- Test runtime config setters from low-level lifecycle events and harness events. -- Test runtime config observability for model, thinking, resources, tools, active tools, and stream options. -- Test resource/tool/model/thinking/stream-option updates during active turns and save points. -- Test session writes from listeners and hooks, including `settled` writes. -- Test queue operations from turn events, tool events, and provider hooks. -- Test rejected structural operations while busy. -- Test abort from listeners/hooks. -- Test getter behavior during active operations. -- Test deterministic ordering of agent-emitted messages and pending listener writes. -- Test no deadlocks when async listeners call harness APIs and await them. -- Test phase cleanup through success, provider error, hook error, abort, compaction, and tree navigation. - -### 7. Later coding-agent migration plan - -Status: Planned - -Done: - -- None. - -Remaining: - -- Map coding-agent resources to sourced loaders. -- Keep app-level resource dedupe/provenance outside the harness. -- Adapt extension loading to the future hook/session facade. -- Preserve UI/session behavior outside core. -- Move coding-agent stream/auth/retry/header behavior onto harness stream configuration and provider hooks. - ---- - -## Completed implementation todo - -### 8. Remove `Agent` dependency from `AgentHarness` - -Status: Done - -Done: - -- `AgentHarness` calls `runAgentLoop()` directly. -- Harness owns run lifecycle, abort controller, queue draining, provider stream config, event reduction, session persistence, pending write flushing, and save-point snapshots. -- Harness tests cover prompt construction, queue draining, abort behavior, save-point refresh, pending write ordering, awaited listener settlement, tool hooks, and provider stream wrapping. - -Remaining: - -- None. - -Notes: - -- Broader listener/hook reentrancy coverage is tracked in item 6. - -### 9. Finish curated provider/stream configuration - -Status: Done - -Done: - -- Added curated `AgentHarnessOptions.streamOptions`, `getStreamOptions()`, and `setStreamOptions()`. -- Stream options, headers, metadata, and derived session id are snapshotted per turn. -- Harness-owned stream wrapper calls `streamSimple()` and keeps lifecycle-owned `signal` and `reasoning` from the low-level loop. -- `getApiKeyAndHeaders()` resolves credentials per provider request. -- `before_provider_request`, `before_provider_payload`, and `after_provider_response` hooks are implemented. -- Stream option patching supports explicit field deletion and ordered hook chaining. -- `agent-harness-stream.test.ts` covers forwarding, auth merge, hook patching/deletion/chaining, payload hooks, and busy/save-point snapshot behavior. - -Remaining: - -- None. - -### 10. Complete low-level `Result` cleanup - -Status: Done - -Done: - -- Added generic `Result` plus helpers. -- Updated `ExecutionEnv` and `NodeExecutionEnv` to return typed results for filesystem/process operations. -- Split filesystem and shell capabilities. -- Moved JSONL session storage/repo onto filesystem picks instead of direct Node imports. -- Added `ExecutionEnv.appendFile()` for streaming append use cases. -- Updated skill and prompt-template loaders to consume `ExecutionEnv` results. -- Updated shell output capture to return a result and use `ExecutionEnv`, including full-output spill via `appendFile()`. -- Removed `NodeExecutionEnv` from browser-safe root exports. -- Replaced `Buffer` usage in generic truncation utilities with runtime-neutral UTF-8 handling. -- Converted compaction and branch-summary helpers to typed result returns. -- Added `readTextLines()` so JSONL metadata loading reads only the header line. -- Removed no-op abort handling from Node filesystem methods where cancellation is not meaningful. -- Mapped filesystem errors crossing the session boundary to typed `SessionError`. -- Added typed branch-summary errors and cause-aware public harness error normalization. -- Resource loaders report structured diagnostics for non-`not_found` filesystem failures. -- Expanded `NodeExecutionEnv` tests for file operations, exec errors, aborts, callbacks, timeouts, and shell-output spill. - -Remaining: - -- None. - -Notes: - -- Keep low-level capability/helper APIs non-throwing where they return `Result`. -- Keep session storage/repo/session APIs throwing typed `SessionError`. -- Keep public structural harness failures normalized to `AgentHarnessError`. -- Keep Node-specific APIs isolated under `src/harness/env/nodejs.ts`, Node-backed storage/session implementations, or explicit Node-only entry points. -- Audit generic harness utilities for Node globals as APIs are added. -- Audit package exports so browser/generic imports do not pull Node-only modules. -- Keep expanding `ExecutionEnv` and shell-output contract tests as APIs evolve. diff --git a/packages/agent/docs/durable-harness.md b/packages/agent/docs/durable-harness.md deleted file mode 100644 index ce5aa46d40e..00000000000 --- a/packages/agent/docs/durable-harness.md +++ /dev/null @@ -1,212 +0,0 @@ -# Durable AgentHarness and session design - - - -Durable AgentHarness / session design notes. - -## Framing - -A fully durable `AgentHarness` is not realistic by itself because important dependencies are runtime JS supplied by the host app: - -- tool implementations -- model/auth providers -- extensions and hook handlers -- resource loaders -- system-prompt callbacks/modifiers - -Tool registries are runtime dependencies. The harness should persist serializable tool configuration, such as active tool names, but not concrete tool implementations. - -The practical target is a semi-durable harness: - -- session is the durable append-only state tree -- harness persists the state it owns into session entries -- the host app is responsible for recreating compatible non-persistable dependencies on resume -- recovery restarts from durable boundaries, not from an in-flight provider stream - -## Session owns durable state - -Treat session as all durable agent state, not just transcript history. - -Existing session state already includes harness state: - -- model changes -- thinking-level changes -- active-tool changes -- leaf entries -- labels -- compactions and branch summaries -- custom messages and custom entries - -That suggests continuing with one durable session log rather than adding harness sidecars. Sidecars may still be useful for large blobs, but the session entry should remain the source-of-truth reference. - -## What the app must provide on resume - -The app must recreate compatible runtime dependencies: - -- model registry / model objects -- tool registry -- extension set, versions, and ordering -- resource loaders -- system prompt providers/hooks -- auth providers -- app-specific hooks - -Harness can validate stable IDs/versions/hashes when available, but it cannot serialize these dependencies itself. - -## Runtime configuration and restore - -Constructor options remain explicit runtime configuration and do not read session state. Hidden async restore in a constructor would make failure handling ambiguous. - -A future async builder/factory should own durable restore: - -```ts -const harness = await AgentHarness.builder() - .env(env) - .session(session) - .model(defaultModel) - .tools(runtimeTools) - .defaultActiveTools(["read", "edit"]) - .restore({ missingActiveTools: "fail" }); -``` - -`restore()` should read the active branch, reduce durable harness configuration, apply defaults for missing entries, validate against app-supplied runtime dependencies, construct the harness, and optionally emit `source: "restore"` update events after construction. - -For active tools: - -- `active_tools_change` entries are branch-scoped durable config. -- If no `active_tools_change` exists on the branch, restore uses builder defaults, or all registered tools if no default active names were supplied. -- Active tool names must be unique. -- Tool registry names must be unique. -- Missing restored active tool names should fail restore by default; permissive drop/disable policies can be added explicitly later. -- Concrete tools are never restored from session; the host app must provide compatible tools. - -## What harness should persist - -Minimum useful durability entries: - -- branch-scoped active tool names -- queued steer/followUp/nextTurn messages -- queue consumption tied to a turn -- pending session writes accepted during active operations -- pending write application status -- operation start/finish/interruption -- turn start/finish -- provider request start/finish, if needed for recovery diagnostics -- tool call start/finish, if we want safe tool recovery - -Potential entries: - -```ts -type DurableHarnessEntry = - | QueueEnqueuedEntry - | QueueConsumedEntry - | PendingWriteEnqueuedEntry - | PendingWriteAppliedEntry - | OperationStartedEntry - | OperationFinishedEntry - | OperationInterruptedEntry - | TurnStartedEntry - | TurnFinishedEntry - | ProviderRequestStartedEntry - | ProviderRequestFinishedEntry - | ToolCallStartedEntry - | ToolCallFinishedEntry; -``` - -Every accepted mutation must be durable before the public API resolves. - -## Recovery model - -On startup: - -1. Host app registers tools/models/extensions/resources/auth/hooks. -2. Harness opens session. -3. Harness reduces session entries into: - - current leaf - - conversation branch - - harness config, including active tool names - - queues - - pending writes - - active operation/turn/tool state -4. Harness validates required runtime dependencies, including restored active tool names against the app-provided tool registry. -5. Harness reconciles unfinished operation state. - -Provider streams are not resumable. Recovery can only retry from a durable boundary or mark the operation interrupted. - -## Recovery policies - -Default conservative policy: - -- unfinished agent turn: mark interrupted, preserve durable queues/pending writes, return idle -- unfinished provider request: mark interrupted; do not retry automatically -- unfinished tool call: append interrupted/error tool result; retry only if the tool declares retry-safe/idempotent -- unfinished compaction: rerun if no compaction entry exists -- unfinished branch summary/tree navigation: rerun/apply missing summary or leaf entries if safe - -Optional policy: - -```ts -recovery: "mark_interrupted" | "retry_unfinished" -``` - -`retry_unfinished` must be guarded around non-idempotent tool calls. - -## Critical scenarios - -### Queues - -- Crash before `queue_enqueued`: message was not accepted. -- Crash after `queue_enqueued`: message is restored. -- Crash after queue drain but before durable turn record: risk of loss/duplication. -- Required invariant: consumed queue IDs must be recorded in `turn_started` or equivalent before they are considered consumed. - -### Pending writes - -- Crash before `pending_write_enqueued`: write was not accepted. -- Crash after enqueue before apply: recovery applies it. -- Crash after apply before applied marker: deterministic target entry IDs let recovery detect the entry already exists and mark it applied. - -### Agent loop turn - -- Crash before provider request: retry or mark interrupted. -- Crash during provider request: mark interrupted by default. -- Crash after provider response before assistant message persisted: response is lost unless provider result was journaled. -- Crash after assistant message persisted: recover from durable message. - -### Tool calls - -- Crash after tool call starts but before result: external side effects may already have happened. -- Default recovery should not rerun non-idempotent tools. -- Tool calls need stable IDs and retry-safety metadata for automatic recovery. - -### Compaction - -- Crash before summary generation: rerun preparation/summary. -- Crash after generated summary but before compaction entry: rerun unless summary was journaled. -- Crash after compaction entry: operation is complete; append finish marker if missing. - -### Branch summary / tree navigation - -- Crash before summary: rerun or mark interrupted. -- Crash after summary entry before leaf entry: append missing leaf entry. -- Crash after leaf entry: operation is complete; append finish marker if missing. - -## Minimum viable spike - -1. Add durable queue entries. -2. Add durable pending write entries with deterministic target IDs. -3. Add operation start/finish/interrupted entries. -4. Add turn start with consumed queue IDs. -5. Recover by reducing the session log. -6. Mark unfinished agent turns interrupted by default. -7. Rerun unfinished compaction/tree operations only when no final entry exists. -8. Do not retry unfinished tool calls unless tool metadata says retry-safe. - -## Open questions - -- Which remaining harness config entries should move into session first: resources, stream options, system prompt refs? -- Should resolved system prompt text be snapshotted per turn for audit/debug? -- Do we require strict dependency ID/version matching on resume? -- How much provider request data should be journaled? -- Should recovery append user-visible assistant interruption messages or only internal operation entries? -- Should storage support truncating a final partial JSONL line during recovery? diff --git a/packages/agent/docs/hooks.md b/packages/agent/docs/hooks.md deleted file mode 100644 index de7230e3d88..00000000000 --- a/packages/agent/docs/hooks.md +++ /dev/null @@ -1,445 +0,0 @@ -# AgentHarness hooks design - - - -Final design. - -## Core model - -Events carry their result type as a type-only phantom: - -```ts -declare const HookResult: unique symbol; - -interface HookEvent { - type: TType; - readonly [HookResult]?: TResult; -} - -type ResultOf = E extends { readonly [HookResult]?: infer R } ? R : void; - -type HookHandler = ( - event: E, - ctx: Ctx, - signal?: AbortSignal, -) => ResultOf | void | Promise | void>; - -type HookObserver = ( - event: E, - ctx: Ctx, - signal?: AbortSignal, -) => void | Promise; -``` - -Example: - -```ts -interface ContextEvent extends HookEvent<"context", { messages?: AgentMessage[] }> { - type: "context"; - messages: AgentMessage[]; -} - -interface ToolCallEvent extends HookEvent<"tool_call", { block?: boolean; reason?: string }> { - type: "tool_call"; - toolName: string; - input: Record; -} - -interface MessageEndEvent extends HookEvent<"message_end"> { - type: "message_end"; - message: AgentMessage; -} -``` - -No result map. No spec table. The event type defines its own result. - -## Hooks interface - -```ts -interface AgentHarnessHooks, Ctx> { - context: Ctx; - - setContext(ctx: Ctx): void; - - observe(handler: HookObserver): () => void; - - on( - type: TType, - handler: HookHandler, Ctx>, - ): () => void; - - emit( - event: TEvent, - signal?: AbortSignal, - ): Promise | undefined>; - - addCleanup(cleanup: () => void | Promise): () => void; - - clear(): Promise; - dispose(): Promise; -} -``` - -Important split: - -- `observe()` sees all events, read-only, return ignored. -- `on(type, handler)` participates in that event’s semantics. -- `emit(event)` is the only thing `AgentHarness` calls. -- `clear()` removes observers/handlers and runs cleanups. - -## Default implementation internals - -```ts -class DefaultAgentHarnessHooks, Ctx> - implements AgentHarnessHooks { - context: Ctx; - - private observers = new Set>(); - private handlers = new Map>>(); - private cleanups = new Set<() => void | Promise>(); - - constructor(ctx: Ctx) { - this.context = ctx; - } - - setContext(ctx: Ctx): void { - this.context = ctx; - } - - observe(handler: HookObserver): () => void { - this.observers.add(handler); - return () => this.observers.delete(handler); - } - - on(type, handler): () => void { - let handlers = this.handlers.get(type); - if (!handlers) { - handlers = new Set(); - this.handlers.set(type, handlers); - } - handlers.add(handler); - return () => handlers.delete(handler); - } - - async emit(event, signal?) { - for (const observer of this.observers) { - await observer(event, this.context, signal); - } - - switch (event.type) { - case "context": - return this.emitContext(event, signal); - case "before_provider_request": - return this.emitBeforeProviderRequest(event, signal); - case "before_provider_payload": - return this.emitBeforeProviderPayload(event, signal); - case "before_agent_start": - return this.emitBeforeAgentStart(event, signal); - case "tool_call": - return this.emitToolCall(event, signal); - case "tool_result": - return this.emitToolResult(event, signal); - case "session_before_compact": - case "session_before_tree": - return this.emitFirstCancelOrLast(event, signal); - default: - await this.emitObservationHandlers(event, signal); - return undefined; - } - } -} -``` - -Internal casts are acceptable inside the implementation because `Map` loses specificity. Public API remains typed. - -## Mutation semantics - -### Observation - -```ts -await hooks.emit({ type: "message_end", message }, signal); -``` - -Observers run. `message_end` handlers run. Return ignored unless that event later gets a result type. - -### Context transform - -Handlers run in order. Each sees current messages. - -```ts -let current = event; - -for (const handler of handlers("context")) { - const result = await handler(current, ctx, signal); - if (result?.messages) { - current = { ...current, messages: result.messages }; - } -} - -return current.messages === event.messages ? undefined : { messages: current.messages }; -``` - -### Provider request / payload - -Sequential transform. Each handler sees previous output. - -```ts -let current = event; - -for (const handler of handlers("before_provider_payload")) { - const result = await handler(current, ctx, signal); - if (result !== undefined) { - current = { ...current, payload: result.payload }; - } -} - -return changed ? { payload: current.payload } : undefined; -``` - -### Before agent start - -Collect injected messages, chain system prompt. - -```ts -let systemPrompt = event.systemPrompt; -const messages = []; - -for (const handler of handlers("before_agent_start")) { - const result = await handler({ ...event, systemPrompt }, ctx, signal); - if (result?.messages) messages.push(...result.messages); - if (result?.systemPrompt !== undefined) systemPrompt = result.systemPrompt; -} - -return messages.length || systemPrompt !== event.systemPrompt - ? { messages, systemPrompt } - : undefined; -``` - -### Tool call - -Sequential, early exit on block. - -```ts -for (const handler of handlers("tool_call")) { - const result = await handler(event, ctx, signal); - if (result?.block) return result; -} -``` - -### Tool result - -Sequential patch accumulation. Each handler sees current patched result. - -```ts -let current = event; -let modified = false; - -for (const handler of handlers("tool_result")) { - const result = await handler(current, ctx, signal); - if (!result) continue; - - current = { - ...current, - content: result.content ?? current.content, - details: result.details ?? current.details, - isError: result.isError ?? current.isError, - }; - - modified = true; -} - -return modified - ? { content: current.content, details: current.details, isError: current.isError } - : undefined; -``` - -### Session-before events - -Sequential, early exit on cancel. - -```ts -let last; - -for (const handler of handlers(event.type)) { - const result = await handler(event, ctx, signal); - if (!result) continue; - last = result; - if (result.cancel) return result; -} - -return last; -``` - -## Harness usage - -Harness only does this: - -```ts -await this.hooks.emit(event, signal); -``` - -or: - -```ts -const result = await this.hooks.emit({ type: "context", messages }, signal); -return result?.messages ?? messages; -``` - -Harness does not store handlers, chain listeners, or know extension policy. - -## Context - -Context is a normal object, not rebuilt per emit. - -```ts -const hooks = new CodingAgentHooks({ - harness: harnessFacade, - session: sessionFacade, - ui: noUiFacade, -}); -``` - -Later: - -```ts -hooks.setContext({ - ...hooks.context, - ui: tuiFacade, -}); -``` - -For dynamic state, prefer stable facades/methods over getter maze: - -```ts -interface CodingAgentHookContext { - harness: HarnessFacade; - session: SessionFacade; - ui: UiFacade; - models: ModelFacade; -} -``` - -Per-run `signal` is passed as the third handler arg. - -## Extension loading later - -Extension loading can live next to harness and construct hooks: - -```ts -const hooks = await loadExtensions({ - paths, - context, - hooks: new CodingAgentHooks(context), -}); -const harness = new AgentHarness({ ..., hooks }); -``` - -The loader registers into hooks: - -```ts -hooks.on("context", handler); -hooks.on("tool_call", handler); -hooks.addCleanup(cleanup); -``` - -For reload: - -```ts -await hooks.clear(); -const nextHooks = await loadExtensions(...); -harness.setHooks(nextHooks); // idle-only if supported -``` - -## Poking holes - -### 1. Error policy must be explicit - -Existing coding-agent catches extension errors, reports them, and continues. New hooks need the same policy, likely: - -```ts -errorMode: "continue" | "throw" -onError(error) -``` - -For coding-agent, default should be `"continue"`. - -### 2. Source metadata matters - -Existing runner knows which extension produced an error/resource/tool. Plain `on()` loses that unless we add registration metadata or scopes. - -Probably needed: - -```ts -const scope = hooks.createScope({ sourceInfo }); -scope.on("context", handler); -scope.addCleanup(...); -``` - -Or `on(type, handler, { sourceInfo })`. - -### 3. Some extension capabilities are registries, not hooks - -These are not covered by `emit()` and should stay as registries on `CodingAgentHooks` or an extension host: - -- tools -- commands -- shortcuts -- flags -- message renderers -- provider registrations -- OAuth providers -- custom model providers - -That is fine. They do not belong in `AgentHarness`. - -### 4. Existing coding-agent events can be represented - -No blocker for: - -- `context` -- `before_provider_request` -- `after_provider_response` -- `before_agent_start` -- `message_end` -- `tool_call` -- `tool_result` -- `input` -- `user_bash` -- `resources_discover` -- `session_before_*` -- `session_*` -- model/thinking selection events -- agent/turn/message/tool lifecycle events - -They become additional event types handled by `CodingAgentHooks`. - -### 5. Need to preserve exact old semantics - -When porting coding-agent, special cases must be copied: - -- `input`: transform chain, `handled` short-circuits. -- `user_bash`: first meaningful result wins. -- `message_end`: replacement must keep same role. -- `before_agent_start`: `ctx.getSystemPrompt()` must reflect current chained prompt. -- `resources_discover`: aggregate paths and keep extension source. -- `tool_call`: argument mutation remains visible to later handlers. -- `tool_result`: later handlers see prior patches. - -The design allows all of that, but the default/coding hooks implementation must encode it. - -### 6. `emit()` switch can miss custom mutation events - -If a subclass adds a result-producing event but forgets to override `emit()`, it will behave observationally. Tests should catch this. Could add a protected strategy registry later if this becomes error-prone, but not initially. - -### 7. Observer semantics are intentionally limited - -Observers see the original emitted event once. They do not see every intermediate mutation. If something needs final transformed state, emit a separate final event or use an event-specific handler. - -## Verdict - -This design can implement a new coding-agent. It is simpler than the current runner, keeps harness clean, and preserves the important extension capabilities as long as `CodingAgentHooks` adds source-aware scopes, registries, cleanup, and the exact old event semantics. - ---- Comments --- - -Thread hn2xk0tzhj on "addCleanup(cleanup" - [tmluyaub9v] Owner (2026-05-14T12:55:45.500Z): cleanup should be passed along optionally to on/observe diff --git a/packages/agent/docs/models.md b/packages/agent/docs/models.md deleted file mode 100644 index 64a623c1c28..00000000000 --- a/packages/agent/docs/models.md +++ /dev/null @@ -1,964 +0,0 @@ -# Models architecture - -This document describes the target design for the next `pi-ai` model/provider refactor. It describes the desired shape, not the current implementation. It is intended to be complete enough to start implementing from a fresh session. - -Goals: - -- `Models` is a dumb runtime collection of providers. -- Concrete providers own metadata, auth, model listing, and stream behavior. -- API implementations live under `src/api/` and are reusable/lazy. -- Concrete provider factories live under `src/providers/`. -- Users can import only the providers they need. -- Importing a provider must not eagerly import heavy SDKs. -- Dynamic model lists are first-class: reads are sync (last-known list), fetching happens in an explicit async `refresh`. -- `models.json` and extensions layer by wrapping providers, not by mutating provider internals ad hoc. -- Old global APIs survive only in an explicit, temporary `/compat` entrypoint. - -Non-goals for the immediate `pi-ai` pass: - -- Do not migrate coding-agent `ModelRegistry` yet. -- Do not keep the stream/API registry inside `Models`. -- Do not implement web OAuth flows yet. -- Image generation mirrors the chat-side design (`ImagesModels`/`ImagesProvider` in `images-models.ts`); the old global image API (`images.ts`, `images-api-registry.ts`) lives on compat. - -## Package layout - -Target source layout: - -```txt -packages/ai/src/ - index.ts # core exports only; no built-in provider imports - models.ts # Models runtime, Provider - images-models.ts # ImagesModels runtime, ImagesProvider (mirrors models.ts) - compat.ts # temporary old-API compatibility entrypoint - auth/ # auth method types, helpers, shared resolveProviderAuth(), login callbacks - api/ # API implementations and lazy wrappers - openai-completions.ts # real implementation, imports SDKs, exports stream/streamSimple - openai-completions.lazy.ts - openai-responses.ts - openai-responses.lazy.ts - openai-codex-responses.ts - openai-codex-responses.lazy.ts - azure-openai-responses.ts - azure-openai-responses.lazy.ts - anthropic-messages.ts - anthropic-messages.lazy.ts - google-generative-ai.ts - google-generative-ai.lazy.ts - google-vertex.ts - google-vertex.lazy.ts - mistral-conversations.ts - mistral-conversations.lazy.ts - bedrock-converse-stream.ts - bedrock-converse-stream.lazy.ts - openrouter-images.ts # image-generation API implementation - openrouter-images.lazy.ts - lazy.ts # lazyStream()/lazyApi() helpers - (shared helpers: openai-responses-shared, google-shared, transform-messages, ...) - providers/ # concrete provider factories and per-provider catalogs - openai.ts - openai.models.ts # generated OpenAI catalog - openai-codex.ts - openai-codex.models.ts - anthropic.ts - anthropic.models.ts - google.ts - google.models.ts - ...one pair per built-in provider... - openrouter-images.ts # image-generation provider factory - faux.ts # test provider factory - all.ts # explicit aggregate: builtinModels(), builtinImagesModels(), getBuiltin*() - auth/oauth/ # Canonical OAuth implementations (node), lazy-loaded -``` - -`src/index.ts` must stay core-only. It must not import: - -- generated model catalogs -- built-in provider factories -- provider SDK implementations -- Node-only OAuth modules -- `providers/all` -- `compat` - -Provider, API, and compat entrypoints are explicit subpath exports. - -## Public usage - -Minimal provider usage: - -```ts -import { createModels } from "@earendil-works/pi-ai"; -import { openaiProvider } from "@earendil-works/pi-ai/providers/openai"; - -const models = createModels(); -models.setProvider(openaiProvider()); - -const model = models.getModel("openai", "gpt-4o-mini"); -if (!model) throw new Error("model not found"); - -const response = await models.complete(model, context); -``` - -Multiple providers: - -```ts -const models = createModels(); -models.setProvider(openaiProvider()); -models.setProvider(openrouterProvider()); -``` - -All built-ins, explicitly heavy metadata entrypoint: - -```ts -import { builtinModels } from "@earendil-works/pi-ai/providers/all"; - -const models = builtinModels(); -``` - -`providers/all` may import all provider metadata/catalogs. It still must not eagerly import SDK implementations; provider streams use lazy wrappers. - -## Core runtime: Models - -`Models` is a provider collection plus auth application and stream convenience. No stream registry, no auth resolver strategy object. - -```ts -export function createModels(options?: { - /** App-owned credential storage. Default: in-memory store. */ - credentials?: CredentialStore; - /** Environment access for auth resolution (env vars, file existence). Default: process.env/node:fs backed; injectable for tests and non-Node hosts. */ - authContext?: AuthContext; -}): MutableModels; - -export interface Models { - getProviders(): readonly Provider[]; - getProvider(id: string): Provider | undefined; - - /** Sync read of last-known models. Best-effort: a provider whose getModels() throws yields no models. */ - getModels(provider?: string): readonly Model[]; - /** Dynamic lists are honestly Model; narrow with the hasApi() guard. */ - getModel(provider: string, id: string): Model | undefined; - - /** - * Ask dynamic providers to re-fetch their model lists. With a provider id, - * rejects on that provider's failure; without, refreshes all concurrently - * best-effort. Static providers are no-ops. - */ - refresh(provider?: string): Promise; - - /** - * Resolve request auth for a model. Includes source label for status UI. - * Resolves undefined when the provider is unknown or unconfigured. Rejects - * with ModelsError ("oauth" on refresh failure, "auth" on api-key/store - * failure); status/availability UIs catch rejections and render - * "needs re-login" instead of treating them as unconfigured. - */ - getAuth(model: Model): Promise; - - stream( - model: Model, - context: Context, - options?: ApiStreamOptions, - ): AssistantMessageEventStream; - - complete( - model: Model, - context: Context, - options?: ApiStreamOptions, - ): Promise; - - streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; - completeSimple(model: Model, context: Context, options?: SimpleStreamOptions): Promise; -} - -export interface MutableModels extends Models { - /** Upsert/replace by provider.id. Provider ids are unique. */ - setProvider(provider: Provider): void; - deleteProvider(id: string): void; - clearProviders(): void; -} -``` - -Removed concepts: - -```txt -no Models.setStreamFunctions() / getStreamFunctions() -no api-registry as a real dispatch mechanism -no Models.provider(id) builder, no setModel/upsertModel/patchModel lifecycle -no ModelAuthResolver / setAuthResolver — resolution policy is fixed, store is injected -``` - -If an app needs different auth policy, it wraps providers (wrap auth methods or `getModels`) or passes explicit request auth in stream options. - -## Provider - -A provider is the concrete runtime unit. It owns id/name/base metadata, auth methods, model listing, and stream behavior. - -`Provider` is generic over the APIs its models use. Concrete factories declare what they emit (`openaiProvider(): Provider<"openai-responses" | "openai-completions">`), giving typed model lists to direct factory users. A `Models` collection holds providers as `Provider`. - -```ts -export interface Provider { - readonly id: string; - readonly name: string; - - readonly baseUrl?: string; - readonly headers?: Record; - - /** - * Required: at least one of apiKey/oauth. Even ambient-credential providers - * (env vars, AWS profiles, ADC) and keyless local servers provide apiKey - * auth whose resolve() reports whether the provider is configured. - * getAuth() returning undefined = not configured. - */ - readonly auth: ProviderAuth; - - /** Current known models, sync. Static providers: the catalog. Dynamic providers: as of the last refresh (empty before the first). */ - getModels(): readonly Model[]; - - /** Dynamic providers only: fetch and update the model list. Concurrent calls share one in-flight fetch. */ - refreshModels?(): Promise; - - stream(model: Model, context: Context, options?: ApiStreamOptions): AssistantMessageEventStream; - - streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; -} -``` - -There is no `Provider.api` field. `model.api` carries API identity; the provider dispatches internally (see `createProvider()`). - -`Model.api` remains: existing metadata and tests use it, it is useful for diagnostics, and provider construction uses it for API implementation selection. But `Models` never dispatches on it; the provider does. - -### Typed stream options - -Full stream options are API-specific. `Model` pays off by deriving the option type from the API: - -```ts -// types.ts — type-only imports from API impl modules are erased, so this is tree-shake safe -export interface ApiOptionsMap { - "anthropic-messages": AnthropicOptions; - "openai-completions": OpenAICompletionsOptions; - "openai-responses": OpenAIResponsesOptions; - "openai-codex-responses": OpenAICodexResponsesOptions; - "azure-openai-responses": AzureOpenAIResponsesOptions; - "google-generative-ai": GoogleOptions; - "google-vertex": GoogleVertexOptions; - "mistral-conversations": MistralOptions; - "bedrock-converse-stream": BedrockOptions; -} - -export type ApiStreamOptions = TApi extends keyof ApiOptionsMap - ? ApiOptionsMap[TApi] - : StreamOptions & Record; -``` - -Custom api strings fall back to the generic shape. - -### Typed model narrowing - -Runtime model lists are dynamic, so `models.getModel()`/`getModels()` honestly return `Model`. Typing improves at three points: - -1. **`hasApi()` type guard** — runtime-checked narrowing for dynamic lookups (no blind casts): - - ```ts - export function hasApi(model: Model, api: TApi): model is Model; - - const model = models.getModel("anthropic", "claude-opus-4-7"); - if (model && hasApi(model, "anthropic-messages")) { - // model: Model<"anthropic-messages">, stream options fully typed - } - ``` - -2. **`getBuiltinModel()`** — sync, generated-catalog lookup with typed overloads: `(provider, id) -> Model`. The path for hardcoded known models. - -3. **`Provider` factories** — typed model lists when using a provider directly, without a `Models` collection. - -Deliberately not done: tying `models.getModel(provider, ...)` to typed provider/model ids would require statically knowing which providers are installed in a mutable runtime collection. The harness path (`streamSimple` + `SimpleStreamOptions`) is API-agnostic and unaffected. - -For comparison: Vercel AI SDK attaches the implementation to the model object, which dissolves dispatch typing but makes models non-serializable (no sessions/RPC/catalogs as plain data), and its `providerOptions` bag is `Record` checked only by `satisfies` convention. Plain-data models + provider-owned behavior keeps stronger typing where it matters. - -### Name collision - -`types.ts` currently exports `type Provider = KnownProvider | string` (a provider id). Rename that alias to `ProviderId` and fix call sites. The `Provider` interface above takes the name. - -## Provider model listing - -Reads are sync; fetching is an explicit async verb. `Provider.getModels()` returns the current known list — the full catalog for static providers, the last-refreshed list for dynamic ones (llama.cpp, OpenRouter live listing). `refreshModels()` is where dynamic providers fetch. - -This split exists because a sync-or-async union (`Promise | T`) invites latent sync assumptions that detonate on the first async provider, while async-only reads force every consumer (UI lists, extension `find`/`getAll` surfaces) through Promises for data that is almost always static. Sync reads + explicit refresh keeps the staleness visible and the contract single: `getModels()` = last known, `refresh()` = make it current. A fetched list is stale the moment it returns anyway; naming the refresh point is honest about it. - -Apps own the refresh lifecycle: startup, registry reload, opening a model selector. Freshness-critical lookups are two-step: `await models.refresh("llamacpp"); models.getModel("llamacpp", id)`. - -Dynamic refresh must be side-effect-free discovery: - -```txt -OK: fetch /v1/models, enumerate local catalog, refresh cached remote model list -Not OK: load model, download model, mutate server state, run request probe -``` - -Provider-specific model lifecycle (load/unload) belongs in app/provider-management commands, not in `refreshModels()`. - -## Streaming path - -`Models.stream()` finds the provider by `model.provider`, resolves auth, merges it into request options, and delegates: - -```ts -function stream(model, context, options) { - const provider = this.getProvider(model.provider); - if (!provider) { - // produce an error stream, not a throw — see Error behavior - } - - // async setup happens inside the returned stream (lazyStream pattern) - const resolution = await this.getAuth(model); - const requestModel = resolution?.auth.baseUrl ? { ...model, baseUrl: resolution.auth.baseUrl } : model; - const requestOptions = mergeAuth(options, resolution?.auth); // explicit options win per-field - - return provider.stream(requestModel, context, requestOptions); -} -``` - -`stream()` returns `AssistantMessageEventStream` synchronously; async setup (auth resolution, lazy module load) happens inside the returned stream. The forwarding pattern already exists in today's `register-builtins.ts` (`createLazyStream`); extract it as `lazyStream()` in `src/api/lazy.ts`. - -No request hot-path model canonicalization: `stream()` uses the supplied model object as-is. If an app wants fresh model metadata, it refreshes the provider and re-reads (`await models.refresh(p); models.getModel(p, id)`) before starting the turn. - -## API implementations under `src/api` - -An API implementation is reusable stream behavior. It is not a provider. - -Uniform export contract — every real implementation module exports exactly: - -```ts -// src/api/anthropic-messages.ts — imports SDKs -export function stream(model, context, options) { ... } -export function streamSimple(model, context, options) { ... } -``` - -This makes the module itself satisfy `ProviderStreams`, so the lazy wrapper is one generic helper instead of bespoke per-API plumbing. `ProviderStreams` is the untyped dispatch shape (implementation modules export concretely typed functions, which would not be assignable to a generic method); per-API option typing lives on the modules themselves and on `Provider.stream()` via `ApiStreamOptions`: - -```ts -export interface ProviderStreams { - stream(model: Model, context: Context, options?: StreamOptions): AssistantMessageEventStream; - streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; -} - -// src/api/lazy.ts -export function lazyApi(load: () => Promise): ProviderStreams; - -// src/api/anthropic-messages.lazy.ts -export const anthropicMessagesApi = (): ProviderStreams => lazyApi(() => import("./anthropic-messages.ts")); -``` - -Import chain: - -```txt -provider module -> lazy API wrapper -> dynamic import(real API impl) -> SDK deps -``` - -Notes: - -- Bedrock keeps the node-only dynamic import trick (`importNodeOnlyProvider`, `.ts`/`.js` specifier rewrite) inside its lazy wrapper. `setBedrockProviderModule()` (used by the Bun build) moves into the bedrock lazy wrapper module. -- Shared helper modules (`openai-responses-shared.ts`, `google-shared.ts`, `transform-messages.ts`, prompt-cache, copilot headers) move to `src/api/` alongside the implementations. - -## Shared API implementations across concrete providers - -Many concrete providers share an API implementation (OpenAI-completions: OpenRouter, Groq, Cerebras, xAI, ZAI, ...). They share lazy API objects by reference: - -```ts -import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; - -export function openrouterProvider(): Provider { - return createProvider({ - id: "openrouter", - name: "OpenRouter", - baseUrl: "https://openrouter.ai/api/v1", - auth: { apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]) }, - models: OPENROUTER_MODELS, - api: openAICompletionsApi(), - }); -} -``` - -This copies Vercel AI SDK's useful property: users import concrete providers; shared protocol implementation is internal. - -## Auth - -Request auth output stays small: - -```ts -export interface ModelAuth { - apiKey?: string; - headers?: Record; - baseUrl?: string; -} -``` - -If a value cannot be expressed as `apiKey`, `headers`, or `baseUrl`, it is provider config, not auth (Vertex project/location, Bedrock region/profile, Azure apiVersion are provider factory options). - -### Provider auth - -`Provider.auth` has exactly two slots; real providers have at most one api-key path and at most one OAuth path, and the slot names carry the UI's oauth-vs-api-key split without a `kind` discriminant or method ids: - -```ts -export interface ProviderAuth { - apiKey?: ApiKeyAuth; // stored key/provider env + ambient env/files/ADC/IAM - oauth?: OAuthAuth; // login flow + refresh -} - -export interface ApiKeyAuth { - name: string; // "Anthropic API key" - - /** Interactive setup (prompt for key/provider env). Absent = ambient-only (env, ADC, IAM). */ - login?(interaction: AuthInteraction): Promise; - - /** - * Resolve auth from the stored credential and/or ambient sources, merging - * per field (credential.key ?? env("..."), credential.env?.NAME ?? env("...")). - * undefined = not configured. - */ - resolve(input: { - model: Model; - ctx: AuthContext; - credential?: ApiKeyCredential; - }): Promise; -} - -export interface OAuthAuth { - name: string; // "Anthropic (Claude Pro/Max)" - - login(interaction: AuthInteraction): Promise; - - /** Exchange the refresh token. Network call; throws on failure (invalid_grant etc.). Runs under the store lock. */ - refresh(credential: OAuthCredential): Promise; - - /** Side-effect-free derivation of request auth from a valid credential. Covers Copilot-style per-credential baseUrl. Async so lazy wrappers can load the implementation. */ - toAuth(credential: OAuthCredential): Promise; -} - -export interface AuthResult { - auth: ModelAuth; - /** Human-readable label for status UI: "ANTHROPIC_API_KEY", "OAuth", "~/.aws/credentials". */ - source?: string; -} - -export interface AuthContext { - env(name: string): Promise; - fileExists(path: string): Promise; // supports leading ~ -} -``` - -The `refresh`/`toAuth` split lets `Models` own the locked refresh pattern without closure gymnastics: refresh produces a credential, while `toAuth` derives request auth from whatever credential ends up stored. - -OAuth implementations use the provider-neutral `AuthInteraction` protocol directly. A callback-server flow issues a `manual_code` prompt racing the server and aborts the prompt when the callback wins, so the UI needs no provider-specific callback or static callback-server flag. - -### Credentials - -One credential per provider, type-tagged — exactly the shape of today's auth.json (`type: "api_key" | "oauth"` per provider id): - -```ts -export interface ApiKeyCredential { - type: "api_key"; - key?: string; - env?: ProviderEnv; // e.g. Cloudflare account/gateway ids, Azure/Vertex/Bedrock scoped config -} - -export interface OAuthCredential extends OAuthCredentials { - type: "oauth"; // access, refresh, expires from OAuthCredentials -} - -export type Credential = ApiKeyCredential | OAuthCredential; -``` - -`ApiKeyCredential.env` stores provider-scoped environment/config values alongside or instead of a key. `ApiKeyAuth.resolve()` merges per field: `credential.key ?? env("CLOUDFLARE_API_KEY")`, `credential.env?.CLOUDFLARE_ACCOUNT_ID ?? env("CLOUDFLARE_ACCOUNT_ID")`, etc. The credential discriminator intentionally matches today's `auth.json` (`api_key`) so the file-backed store does not need lossy type translation. - -### Credential store - -The app injects storage; `pi-ai` ships an in-memory default. Keyed by provider id, one credential per provider: - -```ts -export interface CredentialStore { - /** Read the stored credential, possibly expired. Display/status use; request auth comes from Models.getAuth(). */ - read(providerId: string): Promise; - - /** - * Serialized write — the only write path. fn sees the current credential - * because correct writes (refresh, login-during-refresh) depend on it; - * return the new credential, or undefined to leave the entry unchanged. - * Mutual exclusion per provider id, cross-process too where the backing - * store supports it (file lock). Resolves with the post-write credential. - */ - modify( - providerId: string, - fn: (current: Credential | undefined) => Promise, - ): Promise; - - /** Remove (logout). Serialized against modify. */ - delete(providerId: string): Promise; -} -``` - -There is deliberately no `set`: an unserialized write path invites read-modify-write races (login-during-refresh clobbering a fresh credential, double token refresh). Call sites: - -```ts -await store.modify(pid, async () => credential); // login: store this -await store.read(pid); // status UI ("logged in via OAuth") -await store.delete(pid); // logout -// refresh RMW happens inside Models.getAuth -``` - -Error semantics: `read` resolves `undefined` for missing entries; methods reject only on storage failure, and `Models` wraps such rejections in `ModelsError` code `"auth"`. Best-effort stores that serve an in-memory view and record persistence errors internally (today's AuthStorage behavior) are valid implementations. - -### Resolution policy (fixed) - -`Models.getAuth(model)` is a decision tree, not a loop. A stored credential owns the provider — ambient/env is consulted only when nothing is stored (AuthStorage parity: no silent env fallback after a failed refresh or for an unmatched credential type): - -```ts -const stored = await store.read(provider.id); -if (stored) { - if (stored.type === "oauth" && provider.auth.oauth) { - const oauth = provider.auth.oauth; - let credential = stored; - if (Date.now() >= credential.expires) { // optimistic check, lock-free - const post = await store.modify(provider.id, async (current) => { - if (current?.type !== "oauth") return undefined; // logged out meanwhile - return Date.now() >= current.expires // authoritative check, under lock - ? oauth.refresh(current) // throws -> ModelsError("oauth") - : undefined; // another process/request refreshed - }); - if (post?.type !== "oauth") return undefined; - credential = post; - } - return { auth: await oauth.toAuth(credential), source: "OAuth" }; - } - if (stored.type === "api_key" && provider.auth.apiKey) { - return provider.auth.apiKey.resolve({ model, ctx, credential: stored }); - } - return undefined; // stored credential without matching handler blocks ambient -} -return provider.auth.apiKey?.resolve({ model, ctx, credential: undefined }); // ambient -``` - -Properties: - -- Double-checked locking, same as today's `refreshOAuthTokenWithLock`: valid tokens cost one `read` and zero locks; expired tokens lock, re-check under the lock, refresh once globally, persist before release. -- Explicit request auth (stream options `apiKey`/`headers`) is merged per-field on top in `stream()`, winning over everything. -- Refresh failure rejects with `ModelsError("oauth")`; the stored credential is untouched (preserved for retry). Request paths surface this as a stream error with the real cause ("run /login"); status/availability UIs catch the rejection and render "needs re-login" — documented contract on `getAuth`. - -### Replacing AuthStorage - -The end state for coding-agent: AuthStorage is deleted; its capabilities map onto a `CredentialStore` implementation plus composition. - -Today's `getApiKey` priority and its new home: - -| AuthStorage today | New design | -|---|---| -| runtime override (CLI `--api-key`) | `withRuntimeOverrides(store, overrides)` decorator: `read` returns the override as an `ApiKeyCredential`; never persisted | -| stored `api_key` (with `$ENV`/`!command` via `resolveConfigValue`) | stored `ApiKeyCredential`; config-value resolution happens at `read` in coding-agent's adapter/decorator (command execution stays app policy) | -| stored `oauth` + locked refresh, undefined on failure | `getAuth` decision tree above; failure rejects with cause instead of silently unconfiguring | -| env var (only when nothing stored) | ambient branch of `apiKey.resolve` | -| `fallbackResolver` (models.json custom providers) | gone — custom providers carry their own `auth.apiKey` | - -```txt -FileCredentialStore ports AuthStorage's lock backend: read = memory snapshot, - modify = withLockAsync(re-read, fn, merge-write), delete, - internal error recording (drainErrors equivalent) -└─ withConfigValues $ENV / !command at read - └─ withRuntimeOverrides --api-key - └─ createModels({ credentials: store }) - -login/logout UI provider.auth.{oauth,apiKey}.login(interaction) + store.modify/delete -status UI store.read(pid) + getAuth try/catch ("needs /login" on rejection) -getOAuthProviders presence of provider.auth.oauth across registered providers -``` - -### Login callbacks - -One interface serves api-key and OAuth login: - -```ts -export interface AuthInteraction { - /** Aborts the whole login flow. Per-prompt cancellation uses AuthPrompt.signal. */ - signal?: AbortSignal; - - prompt(prompt: AuthPrompt): Promise; - notify(event: AuthEvent): void; -} - -/** `signal` lets the flow cancel a pending prompt when an out-of-band event resolves the step. */ -export type AuthPrompt = { signal?: AbortSignal } & ( - | { type: "text"; message: string; placeholder?: string } - | { type: "secret"; message: string; placeholder?: string } - | { type: "select"; message: string; options: readonly { id: string; label: string; description?: string }[] } - | { type: "manual_code"; message: string; placeholder?: string } -); - -export type AuthEvent = - | { type: "auth_url"; url: string; instructions?: string } - | { type: "device_code"; userCode: string; verificationUri: string; intervalSeconds?: number; expiresInSeconds?: number } - | { type: "progress"; message: string }; -``` - -`prompt()` returns the entered/selected string (`select` returns the option id). Flows race a `manual_code` prompt against a callback server by setting `AuthPrompt.signal` and aborting the prompt when the callback wins. - -### OAuth attachment - -Providers that support OAuth always attach it. There is no factory toggle: the flow is lazy-loaded, so advertising OAuth costs nothing until `login()`/`refresh()` actually runs, and a host that never logs in never loads it. - -```ts -export function anthropicProvider(): Provider { - return createProvider({ - id: "anthropic", - name: "Anthropic", - baseUrl: "https://api.anthropic.com/v1", - auth: { - apiKey: envApiKeyAuth("Anthropic API key", ["ANTHROPIC_API_KEY"]), - oauth: lazyOAuth({ - name: "Anthropic (Claude Pro/Max)", - load: () => import("../auth/oauth/anthropic.ts").then((m) => m.anthropicOAuth), - }), - }, - models: ANTHROPIC_MODELS, - api: anthropicMessagesApi(), - }); -} -``` - -`lazyOAuth()` wraps a dynamically imported `OAuthAuth` so provider definitions can advertise OAuth without importing the implementation (`toAuth` is async for exactly this reason): - -```ts -export function lazyOAuth(input: { - name: string; - load: () => Promise; -}): OAuthAuth; -``` - -OAuth must not force Node-only code (`node:http`, `node:crypto`) into browser bundles: the dynamic import inside `lazyOAuth()` uses the same bundler-opaque variable-specifier trick as the bedrock lazy wrapper. Browser hosts never trigger the load (no stored node OAuth credentials, no login flow). If web OAuth lands later (sitegeist proved feasibility: Web Crypto PKCE, auth tab, fetch token exchange, device-code polling), it is just a different `OAuthAuth` implementation — no reserved option values. - -The built-in flows in `src/auth/oauth/` implement `OAuthAuth` and `AuthInteraction` directly while remaining Node-targeted and lazy-loaded. Copilot derives its credential-specific request endpoint through `toAuth().baseUrl`. - -## Provider wrappers and models.json - -`models.json` is a provider wrapper layer. It does not mutate providers in place: - -```ts -function withProviderOverrides(base: Provider, overrides: ProviderOverrides): Provider { - return { - ...base, - name: overrides.name ?? base.name, - baseUrl: overrides.baseUrl ?? base.baseUrl, - headers: mergeHeaders(base.headers, overrides.headers), - - getModels: () => applyModelOverrides(base.getModels(), overrides.models), - refreshModels: base.refreshModels?.bind(base), - - stream: base.stream, - streamSimple: base.streamSimple, - }; -} -``` - -This composes with dynamic providers because `getModels()` delegates to the base source and `refreshModels()` passes through. - -Request-auth config from models.json (`$ENV`, `!command`, inline keys) remains app-owned sidecar state, surfaced either as explicit request auth or as a custom `ApiKeyAuth` the app sets on the wrapped provider's `auth.apiKey`. - -## Custom providers: createProvider() - -One helper builds providers from parts; it handles both single-API and mixed-API providers: - -```ts -export function createProvider(input: { - id: string; - name?: string; // default: id - baseUrl?: string; - headers?: Record; - auth: ProviderAuth; // required, at least one of apiKey/oauth (no "no-auth" providers) - /** Initial model list (empty for purely dynamic providers). */ - models: readonly Model[]; - /** Dynamic providers: fetch the current list; createProvider stores it and dedupes in-flight calls. */ - refreshModels?: () => Promise[]>; - /** Single implementation, or map keyed by model.api for mixed-API providers. */ - api: ProviderStreams | Record; -}): Provider; -``` - -- Single `api`: all models stream through it. -- Map `api`: `stream()`/`streamSimple()` dispatch on `model.api`; unknown api produces a stream error. - -Mixed-API custom providers must be supported (opencode Go/Zen-style providers expose models backed by different APIs under one provider id). - -Built-in provider factories use `createProvider()` internally. models.json custom providers map onto it directly: - -```json -{ - "providers": { - "my-openai-proxy": { - "api": "openai-completions", - "baseUrl": "https://proxy.example/v1", - "models": [ ... ] - } - } -} -``` - -## Compat entrypoint - -`@earendil-works/pi-ai/compat` preserves the old global API surface until the coding-agent migration deletes it. New code never imports it. - -Old semantics being preserved: global `stream()` can still dispatch by `model.api` through the legacy api-registry for custom providers, mutated models, and tests/extensions that override a built-in API implementation. - -- `stream/complete/streamSimple/completeSimple(model, ctx, opts)`: real built-in provider/model/api matches route through a singleton `builtinModels()` collection, so provider auth/env/baseUrl behavior is shared with the new runtime. Unknown providers, mutated models, or overridden API registrations fall back to api-registry dispatch plus `getEnvApiKey` injection. -- The builtin api registration side effect moves from the root barrel into compat. It skips api ids that already have a registration, since compat may load after a test or extension has already registered an override. `registerApiProvider()/unregisterApiProviders()` keep feeding the compat-local registry; `resetApiProviders()` clears and re-registers builtins. -- Sync `getModel/getModels/getProviders` are deprecated aliases of `getBuiltinModel/getBuiltinModels/getBuiltinProviders` from `providers/all` (they were always pure generated-catalog reads — verified: nothing ever mutated the old `modelRegistry`). -- Re-exports the per-API lazy stream wrappers (incl. `setBedrockProviderModule`), `env-api-keys.ts`, and the image-generation registry/catalogs; none of these stay on the root barrel. -- `export * from "./index.ts"`: compat is a strict superset of the core entrypoint, so consumers switch a file's import path wholesale without symbol surgery. - -coding-agent (and the interim agent package) switch imports of these symbols from `@earendil-works/pi-ai` to `@earendil-works/pi-ai/compat` (import-path-only change) and are otherwise untouched until the ModelManager migration. - -Extension grace period: the coding-agent extension loader (jiti aliases + Bun `virtualModules`) resolves the `@earendil-works/pi-ai` ROOT specifier to the compat entrypoint. Existing user extensions using the old global API (`complete`, `getModel`, `registerApiProvider`, ...) keep working at runtime without changes; they break only when compat is removed at the ModelManager migration, with a migration guide in the changelog. Typechecking is the nudge: editors resolve the root to the slim core types, so extension sources that typecheck must import old globals from `/compat` — which is what the repo example extensions demonstrate. - -## Builtin static helpers - -Typed, sync, generated-catalog-only helpers live with the catalogs (exported from `providers/all`): - -```ts -getBuiltinModel(provider, id) // sync, typed overloads from generated catalog -getBuiltinModels(provider) // sync -getBuiltinProviders() // sync -``` - -Runtime lookup through a `Models` instance is sync over the last-known provider lists: `models.getModel(...)`. Freshness-critical callers run `await models.refresh(provider)` first. - -Generated catalogs are split per provider (`providers/.models.ts`) by updating `packages/ai/scripts/generate-models.ts`. If the generator change turns out too large for this pass, splitting may be deferred; `providers/all` and provider factories may temporarily import the monolithic `models.generated.ts`, relying on `sideEffects: false` for pruning. - -## Tree-shaking and lazy imports - -Rules: - -1. Main `@earendil-works/pi-ai` import is core-only. -2. Provider modules import their catalog, auth helpers, and lazy API wrappers only. -3. Lazy API wrappers dynamically import real API implementations. -4. Real API implementations import SDK dependencies. -5. OAuth implementations are always attached via `lazyOAuth()` and lazy-loaded behind a bundler-opaque dynamic import; provider metadata never eagerly imports Node-only OAuth code. -6. `providers/all` imports every built-in provider factory and all catalogs. It is the explicit heavy entrypoint. -7. Provider modules are side-effect-free; importing a provider does not register anything globally. -8. `package.json` lists only effectful compat/image registration files in `sideEffects`; root and provider modules stay tree-shakeable. -9. With code splitting, provider SDKs stay in lazy chunks. Without code splitting, bundlers fold statically reachable lazy API implementations into the single bundle; `providers/all` then pulls all statically visible SDKs. Bedrock is the exception because its AWS SDK implementation is behind a bundler-opaque Node-only import and needs `setBedrockProviderModule()` for standalone single-file bundles. - -Exports map sketch: - -```json -{ - "exports": { - ".": "./dist/index.js", - "./compat": "./dist/compat.js", - "./providers/all": "./dist/providers/all.js", - "./providers/openai": "./dist/providers/openai.js", - "./providers/anthropic": "./dist/providers/anthropic.js", - "./providers/*": "./dist/providers/*.js", - "./api/*": "./dist/api/*.js" - } -} -``` - -Browser smoke check (`scripts/check-browser-smoke.mjs`) must keep passing: bundling the core entrypoint (and any non-node provider entrypoint) must not pull `node:http`/`node:crypto`. - -## AgentHarness integration - -`AgentHarness` receives a `Models` instance. - -- `AgentHarnessOptions.models` is required. -- The harness does not snapshot `Models` into turn state. -- Request path calls `this.models.streamSimple(model, context, options)`; same for compaction/branch-summarization paths. -- Request path never calls async `models.getModel()` to canonicalize; if model metadata needs refresh, the app updates the selected model before starting a turn. -- Harness tests build `createModels()` and install the faux provider (`fauxProvider()` factory from `providers/faux`). - -## coding-agent next phase (not this pass) - -coding-agent builds providers in layers and binds them per session: - -```txt -built-in providers (builtinModels) --> models.json provider wrappers / custom providers (createProvider) --> extension provider wrappers/additions -``` - -```ts -sessionModels.clearProviders(); -for (const provider of layeredProviders) sessionModels.setProvider(provider); -``` - -coding-agent owns: `FileCredentialStore` + decorators replacing AuthStorage (see "Replacing AuthStorage"), models.json auth sidecar (`$ENV`, `!command`), command execution policy, provider status labels (from `AuthResult.source`), login/logout UI (driving `auth.{apiKey,oauth}.login()` with `prompt()/notify()`), extension lifecycle, provider-management slash commands. - -Current interim state: - -- `AgentHarness` already accepts a `Models` instance and uses it for turn streaming, compaction, and branch summaries. -- coding-agent does not use `AgentHarness` yet; `AgentSession` still drives the low-level `Agent` with a `streamFn`. -- coding-agent still uses legacy `AuthStorage` + `ModelRegistry` and imports old global pi-ai APIs through `@earendil-works/pi-ai/compat`. -- The extension loader still aliases the pi-ai root to `/compat` as the runtime grace period for old extensions. - -## Implementation TODOs - -Check items off as they land. Keep this list current; it is the working state for resumed sessions. - -### Phase 1 — core types/runtime - -- [x] Rename `types.ts` `Provider` alias to `ProviderId`; fix call sites. -- [x] Add `ApiOptionsMap` and `ApiStreamOptions` to `types.ts` (type-only imports). -- [x] New `models.ts`: `Provider` interface, `hasApi()` guard, `ModelsError` + codes. Auth types live in `src/auth/types.ts` (`ProviderAuth` = `{ apiKey?, oauth? }`, credentials, `CredentialStore` (`read`/`modify`/`delete`, one credential per provider), `AuthResult`, `AuthContext`, `ModelAuth`, login callbacks), in-memory store in `src/auth/credential-store.ts`, default context in `src/auth/context.ts` (browser-safe node:fs trick), `lazyStream()` in `src/api/lazy.ts`. -- [x] `Models`/`MutableModels`/`createModels({ credentials?, authContext? })` with provider map, sync `getModel(s)` (per-provider failure isolation), explicit async `refresh(provider?)`, `getAuth` (decision tree, double-checked locked refresh), `stream/complete/streamSimple/completeSimple` with per-field auth merge. Tests: `packages/ai/test/models-runtime.test.ts`. -- [x] Keep metadata helpers: `calculateCost`, `getSupportedThinkingLevels`, `clampThinkingLevel`, `modelsAreEqual`. - -### Phase 2 — `src/api/` - -- [x] Move stream implementations from `src/providers/` to `src/api/`, renamed by API id (`anthropic.ts` -> `api/anthropic-messages.ts`, etc.). -- [x] Normalize each implementation module to export exactly `stream` and `streamSimple`. -- [x] Move shared helpers (`openai-responses-shared`, `google-shared`, `transform-messages`, `openai-prompt-cache`, `github-copilot-headers`, `cloudflare`, `simple-options`) to `src/api/`. -- [x] Extract `lazyStream()`/`lazyApi()` into `src/api/lazy.ts`. -- [x] Add `*.lazy.ts` wrappers per API; bedrock keeps node-only import trick and `setBedrockProviderModule()`. -- [x] Delete `providers/register-builtins.ts`. Interim until Phase 5 compat: builtin api-registry registration lives in `stream.ts`; lazy API wrappers are exported from the root barrel. - -### Phase 3 — provider factories + catalogs - -- [x] Auth helpers in `src/auth/helpers.ts`: `envApiKeyAuth()` (with secret-prompt `login`), `lazyOAuth()`. OAuth flow loads go through `auth/oauth/load.ts` (bundler-opaque dynamic import); the `OAuthAuth` exports it references land in Phase 4. -- [x] `createProvider()` in `models.ts` (single + mixed `api` map, dispatch on `model.api`, unknown api -> stream error). -- [x] Per-provider factories under `src/providers/` for all built-in catalog providers; OAuth attached via `lazyOAuth()` (anthropic, openai-codex, github-copilot); ambient `ApiKeyAuth` for amazon-bedrock (AWS env/profile) and google-vertex (key or ADC+project+location). -- [x] `providers/all.ts`: `builtinProviders()`, `builtinModels()`, `getBuiltinModel/getBuiltinModels/getBuiltinProviders` re-exports. -- [x] Faux provider factory (`fauxProvider()` in `providers/faux.ts`) for tests; legacy `registerFauxProvider()` kept until compat dies. -- [x] Split generated catalogs per provider via `scripts/generate-models.ts` (`providers/.models.ts`); `models.generated.ts` becomes a generated aggregator. - -### Phase 4 — OAuth adaptation - -- [x] Built-in implementations live under `auth/oauth/` and implement `OAuthAuth` directly through `AuthInteraction.prompt()`/`notify()`. They are private provider implementations loaded lazily by provider factories. -- [x] Callback-server flows race a `manual_code` prompt, aborted through `AuthPrompt.signal` once the flow settles. The public `oauth` subpath retains only coding-agent extension compatibility types. - -### Phase 5 — packaging - -- [x] `index.ts` core-only and side-effect free (no catalogs, no provider factories, no api-registry, no env-api-keys, no images, no OAuth, no compat). Typed catalog reads (`getBuiltin*`) implemented in `providers/all.ts`; `models.ts` no longer imports `models.generated.ts`. -- [x] `compat.ts`: superset of index + old api-dispatch globals, deprecated `getModel/getModels/getProviders` aliases, lazy api wrappers + `setBedrockProviderModule`, `getEnvApiKey`, images. Registration side effect lives here (skip-if-present). -- [x] Subpath exports map (`./compat`, `./providers/*`, `./api/*`); `sideEffects` array listing the effectful modules (`compat`, images registration) instead of `false`. -- [x] Browser smoke (entry now imports old globals from `/compat`) + shrinkwrap checks green. Internal old-global imports switched to `/compat` already (42 files in agent/coding-agent/examples; vitest configs alias `/compat` to src; spawn-CLI tests resolve workspace dist, so `packages/ai` + `packages/agent` dists were rebuilt). - -### Phase 6 — AgentHarness - -- [x] `AgentHarnessOptions.models` required (`readonly models` on the harness); the harness stream path uses `models.streamSimple()`. `StreamFn` redefined structurally (no compat type dependency); `Models.streamSimple` satisfies it. -- [x] Compaction/branch-summarization take the harness `Models` instance. `getApiKeyAndHeaders` is removed entirely — `Models` is the only auth path; per-request key resolution becomes provider auth on the collection. `compact()`/`generateSummary()`/`generateBranchSummary()` lose their explicit `apiKey`/`headers` parameters. -- [x] Harness tests use `createModels()` + `fauxProvider()` with unique per-fake provider ids; no global api-registry state, no unregister bookkeeping. - -### Phase 7 — coding-agent bridge (minimal) - -- [x] Switch old-global imports to `@earendil-works/pi-ai/compat` (landed with Phase 5; compat is a superset so the switch was path-only). Extension loader resolves the pi-ai root to compat as the runtime grace period. -- [x] Everything else originally sketched here is gated on coding-agent actually streaming through a `Models` instance — coding-agent's `AgentSession` drives the low-level `Agent` via `streamFn`, not the harness — and moved to Phase 9. - -### Phase 8 — wrap-up - -- [x] Update/add tests; run affected suites (tests landed with each phase; `./test.sh` green throughout). -- [x] `packages/ai/CHANGELOG.md`: `### Breaking Changes` with migration guide (compat entrypoint, `Provider` -> `ProviderId`, api module moves) + `### Added` for the new Models/provider/auth API. -- [x] `packages/coding-agent/CHANGELOG.md`: `### Changed` entry for extension authors — runtime unaffected (loader resolves the pi-ai root to compat), typecheck nudges to `/compat` or the new API; removal happens later with a migration guide. -- [x] `packages/agent/CHANGELOG.md`: `### Breaking Changes` for required `AgentHarnessOptions.models`, compaction signature changes, structural `StreamFn`. -- [x] `npm run check` clean. - -### Phase 9 — coding-agent on Models + CredentialStore (in scope) - -coding-agent replaces AuthStorage and ModelRegistry's internals with `FileCredentialStore` + a `MutableModels` collection. AgentSession itself stays (AgentHarness adoption is pi 2.0); only its model/auth substrate swaps. Layering is strictly one-directional: - -```txt -FileCredentialStore (auth.json, locked, $ENV/!command resolution) + explicit --api-key overlay - ↑ -MutableModels: builtin factories (wrapped per models.json config) + custom providers (models.json ∪ extensions) - ↑ -ModelRegistry: compatibility facade — sync last-known reads delegate to the collection; registerProvider/login/logout/status for extensions + UI - ↑ -AgentSession / sdk / interactive-mode (stream via models; await only auth/refresh paths) -``` - -Decisions: - -- `AuthStorage` is deleted as a type — it would otherwise depend on provider auth while provider auth depends on its store (circular). Its surface splits: `get`/`set`/`remove` -> `CredentialStore`; `getApiKey` -> `Models.getAuth`; `login`/`logout`/`getAuthStatus` -> ModelRegistry facade methods over `provider.auth.oauth` + the store. -- `FileCredentialStore` is self-contained (path, locking, parse/write, chmod, error buffering) and owns `auth.json` semantics, including `$ENV`/`!command` resolution for stored API-key credentials. Persisted values stay raw; resolution returns copies for auth use. -- Runtime `--api-key` overrides are an explicit store overlay (an override reads as an ephemeral stored api-key credential, masking stored OAuth — matches today's priority). Every registered provider is guaranteed an `apiKey` auth slot so overrides apply to OAuth-only providers too. -- `ModelRegistry.getAll`/`find`/`getAvailable` stay sync for SDK and extension compatibility, delegating to the collection's last-known sync model lists and fast configured-looking status checks. Dynamic providers update through explicit async `refresh()`, and request auth remains async through `getApiKeyAndHeaders()`/`Models.getAuth()`. Extensions also get the collection itself as the forward API. -- models.json keeps FULL feature parity, implemented as provider decoration: builtin factories wrapped so `getModels()` applies provider `baseUrl`/`compat` overlays, `modelOverrides`, and custom-model merges (async-safe); provider `apiKey`/`headers`/`authHeader` configs become that provider's `ApiKeyAuth` (config first, factory auth fallback); parse errors keep `getError()` semantics. -- Extension `ProviderConfig` parity: provider-keyed `streamSimple`, legacy extension OAuth callbacks adapted to `OAuthAuth`, and full model replacement per provider. Legacy `registerApiProvider` writes stay compat-local for consumers that call global `complete()`; they die with compat. -- Copilot: stored-credential baseUrl applied in the wrapped `getModels()` (extension-visible models stay correct) plus per-request `toAuth().baseUrl`. -- Cloudflare: provider-auth substitution (key + `CLOUDFLARE_ACCOUNT_ID`/`CLOUDFLARE_GATEWAY_ID` from credential `env` or ambient `AuthContext.env()` -> `ModelAuth.baseUrl`). Built-in compat calls route through `Models`, so they use the same provider auth path. - -Ordering for new sessions: - -1. [x] pi-ai rework first: `Provider.getModels()` sync + optional `refreshModels()`; `Models.getModels`/`getModel` sync, `Models.refresh(provider?)` async; `createProvider` takes `models` array + optional `refreshModels` fetcher (in-flight dedupe). Reverses Phase 1's async-listing decision — see "Provider model listing" for rationale (sync-or-async unions breed latent sync assumptions; async-only breaks sync consumer surfaces like extension `find`/`getAll`). -2. [x] Cloudflare provider auth in pi-ai factories: Workers AI and AI Gateway validate their required account/gateway env/config and return resolved `baseUrl`, provider-scoped env, and header suppression/override metadata from provider auth. -3. [ ] Add `FileCredentialStore` in coding-agent. - - Implement the pi-ai `CredentialStore` interface as a self-contained `auth.json` store; do not depend on the old `AuthStorageBackend` abstraction, though its lock/retry semantics may be ported. - - Preserve the existing file format. `ApiKeyCredential` uses `{ type: "api_key", key?, env? }`, matching today's `auth.json`; do not translate `env` into metadata or rewrite discriminators. - - Resolve `$ENV`/`!command` in stored API-key `key` and `env` values out of the box using an injected execution/config environment. `$ENV` lookup should come from that environment, and `!command` should run through the shared shell execution path rather than direct `execSync`. - - Persist raw config values; resolved credentials returned for auth use must be copies and must not rewrite `$ENV`/`!command` strings unless a caller explicitly stores new values. - - `read(provider)` returns the current credential snapshot and records parse/storage errors for status UI parity. - - `modify(provider, fn)` must lock, re-read, run `fn`, merge-write the provider entry, chmod `0600`, and return the post-write credential. - - `delete(provider)` must lock and remove only that provider's entry. - - Add file-backed and in-memory tests covering lock/RMW behavior, `api_key` reads with config-value resolution, OAuth reads, provider `env` preservation, delete, parse errors, and concurrent refresh-style modifications. -4. [ ] Add runtime override overlay for coding-agent policy. - - `withRuntimeOverrides(store, overrides)` implements CLI `--api-key`: read returns an ephemeral `{ type: "api_key", key }` for each overridden provider, masking stored OAuth/API credentials without persisting. - - Runtime overrides must apply even to OAuth-capable providers; every provider registered in coding-agent must retain or gain an `apiKey` auth slot so the overlay is meaningful. - - Tests cover precedence: runtime override > stored credential > models.json config auth > ambient provider env, with stored credential blocking ambient fallback. -5. [ ] Build provider decoration helpers for `models.json`. - - Start from built-in provider factories, not generated model arrays. - - Wrap provider `getModels()` so provider-level `baseUrl`/`headers`/`compat`, per-model `modelOverrides`, and custom model merges apply on every sync read. - - Preserve `refreshModels()` passthrough so dynamic providers compose with decorations. - - Convert provider `apiKey`/`headers`/`authHeader` models.json config into a wrapped `ApiKeyAuth` that resolves config values first and falls back to the base provider auth. - - Custom providers with `models` use `createProvider()` with the appropriate lazy API wrapper or extension-provided stream implementation. - - Parse errors must keep current `ModelRegistry.getError()` behavior: built-ins remain available, and the error is visible. -6. [ ] Copilot `getModels()` baseUrl wrap. - - GitHub Copilot OAuth `toAuth()` already returns per-credential request `baseUrl` for streaming. - - Wrap Copilot's provider `getModels()` when an OAuth credential is present so extension/UI-visible model metadata also carries the authenticated account base URL. - - Keep API-key/env-token Copilot behavior unchanged. - - Add tests for model metadata before login, after OAuth credential, after refresh/baseUrl change, and logout. -7. [x] Extension OAuth adapter. - - Keep only the legacy callback/credential declarations required by coding-agent `ProviderConfig.oauth`. - - `login` maps legacy callbacks/events to `AuthInteraction.prompt()`/`notify()`. - - `refreshToken` maps to `refresh`; `getApiKey` maps to `toAuth`. - - Preserve the type-only pi-ai `oauth` barrel and extension-loader aliases. -8. [ ] Rebuild coding-agent `ModelRegistry` over `MutableModels`. - - It owns a `MutableModels` instance built from decorated built-ins + models.json custom providers + extension providers. - - `getAll()`, `find()`, and `getAvailable()` remain sync compatibility methods over last-known model lists and fast configured-looking auth status. Do not break the extension-facing `modelRegistry` surface for these reads. - - `refresh()` is the explicit async freshness boundary: rebuild provider layers and call `models.refresh()` where needed; no global api-registry reset should be part of the new path except compat-only grace behavior. - - `registerProvider()`/`unregisterProvider()` mutate provider layers and rebuild the collection. - - Facade auth ops (`login`, `logout`, provider status, available OAuth providers) drive `provider.auth.{apiKey,oauth}` and the `CredentialStore`; no `AuthStorage` type remains. - - Legacy `registerApiProvider` writes stay only for `/compat` callers and are removed in Phase 10. -9. [ ] Rewire consumers. - - `AgentSession` stream function resolves through `ModelRegistry`/`Models`, not `getApiKeyAndHeaders()` + compat globals. - - SDK options replace `authStorage` with `credentials?: CredentialStore` or an agent-dir-backed default; update `sdk.md` and examples. - - `model-resolver`, `--list-models`, model selector, login/logout/status UI, and provider attribution use sync last-known model reads and await only explicit refresh/auth operations. - - CLI `--api-key` populates the runtime override decorator instead of mutating `AuthStorage`. - - Keep extension loader root-to-compat alias until Phase 10, but expose the new collection/facade as the forward API. -10. [ ] Test migration and real-provider validation. - - Unit tests for `FileCredentialStore`, runtime override overlay, provider decoration, extension OAuth adapter, Models-backed ModelRegistry facade, and consumer rewiring. - - Regression tests for Cloudflare account/gateway env, Copilot OAuth baseUrl wrapping, runtime `--api-key` precedence, `$ENV`/`!command` resolution, and stored credential blocking ambient fallback. - - Update existing tests for sync last-known `ModelRegistry.getAll/find/getAvailable` plus explicit async refresh behavior. - - Run targeted non-e2e suites plus tmux validation of login flows against real providers (Anthropic OAuth/API key, OpenAI Codex OAuth, GitHub Copilot OAuth, Cloudflare AI Gateway, Bedrock if credentials are available). - -### Phase 10 — compat deletion (pi 2.0 era, separate) - -- [ ] AgentSession -> AgentHarness; the registry facade dies in favor of harness `Models`. -- [ ] Move ALL internal `/compat` imports to the new API: every package's src, all tests, and the example extensions (examples then demonstrate the new API). Nothing inside the repo may import `/compat` at that point. -- [ ] Delete `/compat`, `env-api-keys.ts`, the extension-loader root-to-compat alias, and the compat-local legacy API registry. The old OAuth registry/provider interface is already gone; the type-only `oauth` barrel remains for extension compatibility. - -### Deferred / follow-ups - -- [ ] Web OAuth implementations (sitegeist-style) as an alternative `OAuthAuth`. -- [x] Images API redesign: `ImagesModels`/`ImagesProvider`/`createImagesProvider` mirror the chat-side design (sync reads, explicit refresh, never-reject generation); auth resolution shared with the chat side via the free-standing `resolveProviderAuth()` in `auth/resolve.ts` (which also owns `ModelsError`; both collections pass their store/context as arguments — no resolver object). `openrouterImagesProvider()` factory + `builtinImagesProviders()`/`builtinImagesModels()` in `providers/all`; impl moved to `api/openrouter-images.ts` with a lazy wrapper. The old global image API (registry + `getImageModel*` + `generateImages`) stays on compat; `ImagesProvider` id alias in types.ts renamed to `ImagesProviderId` (mirror of `Provider` -> `ProviderId`). - -## Error behavior - -`undefined` means not found or not configured. Real failures reject or become stream errors. - -```ts -export type ModelsErrorCode = - | "model_source" // provider model refresh failed - | "model_validation" // model object invalid - | "provider" // unknown provider, dispatch failure - | "stream" // stream setup failure - | "auth" // auth resolution failure - | "oauth"; // oauth login/refresh failure -``` - -- `Models.stream()` produces stream errors (error event + error result) for async setup failures; it does not throw after returning the stream. -- `Models.getModels()` is a sync best-effort read: a provider whose `getModels()` throws yields no models. `Models.refresh(provider)` rejects on that provider's fetch failure; `Models.refresh()` (all providers) is concurrent best-effort. Apps that need a concrete listing failure refresh the single provider. -- Auth resolution and credential store failures reject loudly (`ModelsError` codes `auth`/`oauth`); silent fallback to a different auth path after a failure risks billing surprises. A stored credential always blocks ambient/env fallback, including after a failed refresh. -- Status/availability UIs catch `getAuth` rejections and render "needs re-login"; they do not treat rejection as "unconfigured". diff --git a/packages/agent/docs/observability.md b/packages/agent/docs/observability.md deleted file mode 100644 index 2f77b3fca93..00000000000 --- a/packages/agent/docs/observability.md +++ /dev/null @@ -1,376 +0,0 @@ - - -# Pi Observability Design Notes - -## Goal - -Make `packages/ai` and `packages/agent`/harness observable without depending on OpenTelemetry, Sentry, or any APM vendor. - -Pi should emit stable, structured lifecycle events. External listeners can convert those events into OTel spans, Sentry spans, logs, metrics, or custom telemetry. - -## Mental model - -A trace is one causal tree of work, e.g. one user turn. - -A span is one timed operation in that tree. It is normally represented by IDs, not object pointers: - -```ts -interface SpanRecord { - traceId: string; - spanId: string; - parentSpanId?: string; - name: string; - startTime: number; - endTime?: number; - attributes: Record; - status: "ok" | "error"; -} -``` - -Example tree: - -```text -traceId=t1 spanId=s1 parent=- name=pi.agent.prompt -traceId=t1 spanId=s2 parent=s1 name=pi.agent.turn -traceId=t1 spanId=s3 parent=s2 name=pi.ai.provider.request -traceId=t1 spanId=s4 parent=s2 name=pi.agent.tool_call -traceId=t1 spanId=s5 parent=s4 name=pi.session.append_entry -``` - -## Async context - -JavaScript has one event loop but multiple async chains can interleave. A single global `currentContext` breaks under concurrency. - -`AsyncLocalStorage` is the Node equivalent of `ThreadLocal` for async continuations. It lets concurrent operations keep distinct current contexts: - -```ts -await Promise.all([ - runWithPiContext({ userId: "alice" }, () => harness.prompt("A")), - runWithPiContext({ userId: "bob" }, () => harness.prompt("B")), -]); -``` - -Deep code can then read the correct current context for the active async chain. - -Pi must run in Node, Bun, browser, workers, and other JS runtimes, so ALS cannot be the core abstraction. It should be a runtime adapter. - -## Core design - -Pi owns a small runtime-agnostic observability abstraction: - -```ts -export interface PiObservabilityContext { - traceId?: string; - currentSpanId?: string; - userContext?: Record; -} - -export interface PiObservabilityEvent { - type: "start" | "end" | "error" | "event"; - name: string; - traceId: string; - spanId?: string; - parentSpanId?: string; - timestamp: number; - durationMs?: number; - context?: Record; - payload?: Record; - error?: { name: string; message: string }; -} - -export interface PiObservability { - getContext(): PiObservabilityContext | undefined; - runWithContext(context: PiObservabilityContext, fn: () => T): T; - emit(event: PiObservabilityEvent): void; - hasSubscribers(): boolean; -} -``` - -Public API: - -```ts -export function configurePiObservability(observability: PiObservability): void; -export function subscribePiObservability(listener: (event: PiObservabilityEvent) => void): () => void; -export function runWithPiContext(userContext: Record, fn: () => T): T; -export function traceOperation(name: string, payload: Record, fn: () => T): T; -``` - -`traceOperation()`: - -1. reads the current context -2. creates `traceId` if missing -3. creates a new `spanId` -4. uses current span as `parentSpanId` -5. emits `start` -6. runs callback under child context -7. emits `end` or `error` -8. rethrows on error - -Pseudo-code: - -```ts -function traceOperation(name: string, payload: Record, fn: () => T): T { - const parent = getContext(); - const traceId = parent?.traceId ?? createId(); - const spanId = createId(); - const parentSpanId = parent?.currentSpanId; - - const child = { ...parent, traceId, currentSpanId: spanId }; - - emit({ type: "start", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: parent?.userContext, payload }); - - return runWithContext(child, () => { - try { - const result = fn(); - // Promise-aware implementation emits end/error after settlement. - emit({ type: "end", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: child.userContext, payload }); - return result; - } catch (error) { - emit({ type: "error", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: child.userContext, payload, error: serializeError(error) }); - throw error; - } - }); -} -``` - -## Runtime adapters - -Core packages should not import Node-only APIs. - -Possible implementations: - -- Node adapter: `AsyncLocalStorage` for context, optional `diagnostics_channel` publishing. -- Browser/workers fallback: local subscriber set and limited/manual context propagation. -- Bun/Deno adapters: use runtime-specific async context if available. - -For Node, diagnostics channels can be used as a passive event bus: - -```ts -import { channel } from "diagnostics_channel"; -channel("pi.observability").publish(event); -``` - -Subscribers can create OTel/Sentry spans without monkey-patching pi. - -## What pi emits - -Pi emits what happened. It does not create OTel/Sentry spans directly. - -Initial minimal event names: - -```text -pi.agent.prompt -pi.agent.skill -pi.agent.prompt_template -pi.agent.compaction -pi.agent.branch_navigation -pi.agent.session.append_entry -pi.ai.provider.request -``` - -Each operation emits: - -```text -start -end -error -``` - -Later additions: - -```text -pi.agent.turn -pi.agent.tool_call -pi.agent.queue_update -pi.ai.provider.retry -pi.ai.provider.first_token -pi.ai.provider.usage -pi.session.read -pi.session.write -``` - -## Minimal instrumentation points - -### packages/agent - -Wrap: - -- `AgentHarness.prompt()` -- `AgentHarness.skill()` -- `AgentHarness.promptFromTemplate()` -- `AgentHarness.compact()` -- `AgentHarness.navigateTree()` -- `Session.appendTypedEntry()` or storage append facade - -Example: - -```ts -return traceOperation( - "pi.agent.prompt", - { - sessionId: turnState.sessionId, - provider: turnState.model.provider, - model: turnState.model.id, - promptLength: text.length, - imageCount: options?.images?.length ?? 0, - }, - () => this.executeTurn(turnState, text, options), -); -``` - -Session write: - -```ts -return traceOperation( - "pi.agent.session.append_entry", - { entryType: entry.type }, - async () => { - await this.unwrap(this.storage.appendEntry(entry)); - return entry.id; - }, -); -``` - -### packages/ai - -Wrap common provider boundaries: - -- `streamSimple()` -- `completeSimple()` - -Example: - -```ts -return traceOperation( - "pi.ai.provider.request", - { - api: model.api, - provider: model.provider, - model: model.id, - sessionId: options.sessionId, - reasoning: options.reasoning, - }, - () => actualStreamSimple(model, context, options), -); -``` - -End/error payloads can include safe metadata: - -- stop reason -- status code -- retry count -- input/output/total tokens -- cost total -- aborted/timeout flag - -## Safety and redaction - -Default payloads must be safe. - -Safe by default: - -- provider -- model -- API identifier -- session id -- entry type -- tool name -- status code -- stop reason -- token counts -- costs -- durations - -Unsafe by default: - -- prompts -- completions -- tool args -- tool results -- shell output -- file contents -- provider request payloads -- provider response bodies -- API keys -- headers - -Content capture can be opt-in later with explicit redaction hooks. - -## Listener behavior - -Observability must never affect pi execution. - -Subscriber errors should be swallowed or isolated. Harness hooks are control-plane and may affect execution; observability subscribers are passive and must not. - -## User context - -Users can associate arbitrary context with a turn: - -```ts -await runWithPiContext( - { - userId: "u123", - orgId: "acme", - region: "eu", - }, - () => harness.prompt("fix this"), -); -``` - -Every emitted event inside that async chain includes the context: - -```ts -{ - type: "start", - name: "pi.ai.provider.request", - traceId: "t1", - spanId: "s3", - parentSpanId: "s1", - context: { - userId: "u123", - orgId: "acme", - region: "eu", - }, - payload: { - provider: "anthropic", - model: "claude-sonnet-4", - }, -} -``` - -An OTel adapter can map this to span attributes. A Sentry adapter can map it to Sentry context/spans. A custom user can log JSON. - -## Package story - -Minimal initial package: - -```text -packages/observability - runtime-agnostic context + traceOperation + subscribe -``` - -Then: - -```text -packages/ai - emits pi.ai.* events - -packages/agent - emits pi.agent.* / pi.session.* events -``` - -Optional later: - -```text -packages/observability-node - AsyncLocalStorage + diagnostics_channel bridge - -packages/otel - subscribes to pi events and creates OpenTelemetry spans -``` - -## Thesis - -Pi defines a stable, safe event contract. Adapters define where events go. - -This makes ai/harness observable without binding core packages to OTel, Sentry, Node-only APIs, or monkey-patching. From f119b01cb122ea55e17905caff62d4523f6cce1d Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:56:56 +0200 Subject: [PATCH 17/34] refactor: update sqlite for lanes (#7591) * feat: align sqlite storage with session lanes * fix: delete lane from entry, action * fix: errors and types * fix: tests * fix: usage records instead of session materialized * chore: 1 mig * fix * refactor(agent): move harness experimental changes to split branch * refactor and cleanup * fix: use assertJsonSerializable for session metadata * fix(sqlite): reject corrupt lane leafs on open * fix: rebuild cache * refactor: branch cache * feat: session stats * fix: enforce leases * feat(agent): enforce fenced SQLite writer leases * fix(agent): keep SQLite transactions synchronous * fix(agent): align SQLite storage with conformance suite --------- Co-authored-by: Christian Klotz --- packages/agent/docs/harness-v2.md | 4 +- .../harness/experimental/session/session.ts | 2 +- .../agent/test/harness/branch-query.test.ts | 51 +- .../experimental/session/sqlite.test.ts | 58 + .../agent/test/harness/session-test-utils.ts | 128 ++ .../agent/test/harness/sqlite-adapter.test.ts | 36 + .../test/harness/sqlite-branch-cache.test.ts | 139 +-- .../agent/test/harness/sqlite-leases.test.ts | 136 +++ .../test/harness/sqlite-migrations.test.ts | 387 +++--- .../agent/test/harness/sqlite-node.test.ts | 50 +- packages/agent/vitest.config.ts | 2 + packages/agent/vitest.harness.config.ts | 2 + packages/storage/sqlite-node/src/index.ts | 23 +- .../sqlite-node/src/sqlite/branch-cache.ts | 105 ++ .../storage/sqlite-node/src/sqlite/index.ts | 11 +- .../sqlite-node/src/sqlite/migrations.ts | 24 +- .../src/sqlite/migrations/001_initial.sql | 97 +- .../src/sqlite/migrations/002_branch_tips.sql | 12 - .../storage/sqlite-node/src/sqlite/repo.ts | 1054 +++++++++++++---- .../sqlite-node/src/sqlite/search-backend.ts | 42 +- .../src/sqlite/storage/branch-cache.ts | 326 ----- .../src/sqlite/storage/branch-entries.ts | 146 +++ .../src/sqlite/storage/branch-tips.ts | 40 + .../sqlite-node/src/sqlite/storage/entries.ts | 75 ++ .../sqlite-node/src/sqlite/storage/facts.ts | 73 ++ .../sqlite-node/src/sqlite/storage/index.ts | 459 ------- .../sqlite-node/src/sqlite/storage/lanes.ts | 116 ++ .../sqlite-node/src/sqlite/storage/leases.ts | 65 + .../sqlite-node/src/sqlite/storage/records.ts | 95 ++ .../src/sqlite/storage/session-entries.ts | 217 ---- .../sqlite/storage/session-materialized.ts | 355 ------ .../src/sqlite/storage/session-sequences.ts | 24 +- .../src/sqlite/storage/session-stats.ts | 63 + .../src/sqlite/storage/sessions.ts | 62 +- .../sqlite-node/src/sqlite/storage/shared.ts | 17 - .../storage/sqlite-node/src/sqlite/types.ts | 13 +- tsconfig.json | 1 + 37 files changed, 2501 insertions(+), 2009 deletions(-) create mode 100644 packages/agent/test/harness/experimental/session/sqlite.test.ts create mode 100644 packages/agent/test/harness/sqlite-adapter.test.ts create mode 100644 packages/agent/test/harness/sqlite-leases.test.ts create mode 100644 packages/storage/sqlite-node/src/sqlite/branch-cache.ts delete mode 100644 packages/storage/sqlite-node/src/sqlite/migrations/002_branch_tips.sql delete mode 100644 packages/storage/sqlite-node/src/sqlite/storage/branch-cache.ts create mode 100644 packages/storage/sqlite-node/src/sqlite/storage/branch-entries.ts create mode 100644 packages/storage/sqlite-node/src/sqlite/storage/branch-tips.ts create mode 100644 packages/storage/sqlite-node/src/sqlite/storage/entries.ts create mode 100644 packages/storage/sqlite-node/src/sqlite/storage/facts.ts delete mode 100644 packages/storage/sqlite-node/src/sqlite/storage/index.ts create mode 100644 packages/storage/sqlite-node/src/sqlite/storage/lanes.ts create mode 100644 packages/storage/sqlite-node/src/sqlite/storage/leases.ts create mode 100644 packages/storage/sqlite-node/src/sqlite/storage/records.ts delete mode 100644 packages/storage/sqlite-node/src/sqlite/storage/session-entries.ts delete mode 100644 packages/storage/sqlite-node/src/sqlite/storage/session-materialized.ts create mode 100644 packages/storage/sqlite-node/src/sqlite/storage/session-stats.ts delete mode 100644 packages/storage/sqlite-node/src/sqlite/storage/shared.ts diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index cafa8cf4c27..350602bdedd 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -1649,7 +1649,7 @@ lane_moves (session_id, seq, lane, leaf_id) -- history; getLog parity facts (session_id, seq, kind, key, value) -- name, labels; latest by seq branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) branch_tips (session_id, branch_id, tip_id) -- PRIMARY KEY (session_id, tip_id) -leases (session_id, owner, heartbeat) -- writer claim +leases (session_id, owner_id, fence, expires_at_ms) -- writer claim -- indexes records: (session_id, lane, type, seq), (session_id, lane, type, op_kind, seq) @@ -1657,6 +1657,8 @@ branch_entries: (session_id, branch_id, entry_type, entry_seq) (session_id, entry_id) -- reverse lookup: entry → branches ``` +`leases` enforces one writer per session with expiring, fenced claims. Storage renews the claim inside every write transaction and while idle. Repository-owned cleanup releases only its matching owner and fence. + `branch_entries` and `branch_tips` are a private read cache. No interface exposes them; no other backend has them; rebuilding them from parent pointers is an explicit repair operation, never a runtime fallback. Two invariants carry the whole design: diff --git a/packages/agent/src/harness/experimental/session/session.ts b/packages/agent/src/harness/experimental/session/session.ts index 9d8269feba0..aa19cd7ae02 100644 --- a/packages/agent/src/harness/experimental/session/session.ts +++ b/packages/agent/src/harness/experimental/session/session.ts @@ -37,7 +37,7 @@ function assertValidCursor(afterSeq: number | undefined): void { } } -function assertJsonSerializable(value: unknown): void { +export function assertJsonSerializable(value: unknown): void { const active = new WeakSet(); const stack: JsonValidationFrame[] = [{ value }]; while (stack.length > 0) { diff --git a/packages/agent/test/harness/branch-query.test.ts b/packages/agent/test/harness/branch-query.test.ts index fb2f4b062ae..d67161c63e4 100644 --- a/packages/agent/test/harness/branch-query.test.ts +++ b/packages/agent/test/harness/branch-query.test.ts @@ -171,7 +171,7 @@ describe("bounded session branch queries", () => { const db = await sqlite.open(databasePath); try { await db - .prepare("UPDATE session_entries SET payload = ? WHERE session_id = ? AND id = ?") + .prepare("UPDATE entries SET payload = ? WHERE session_id = ? AND id = ?") .run("not json", "bounded-sqlite", middleId); const branch = await db .prepare("SELECT branch_id FROM branch_entries WHERE session_id = ? AND entry_id = ?") @@ -187,21 +187,6 @@ describe("bounded session branch queries", () => { expect((await session.findEntriesOnBranch({ start: tailId, stopAtId: tailId })).map((entry) => entry.id)).toEqual( [tailId], ); - const inspection = await sqlite.open(databasePath); - try { - const repaired = await inspection - .prepare( - `SELECT entry_id FROM branch_entries - WHERE session_id = ? AND branch_id = ( - SELECT branch_id FROM branch_entries WHERE session_id = ? AND entry_id = ? LIMIT 1 - ) - ORDER BY entry_seq`, - ) - .all<{ entry_id: string }>("bounded-sqlite", "bounded-sqlite", tailId); - expect(repaired.map((row) => row.entry_id)).toEqual([rootId, tailId]); - } finally { - await inspection.close(); - } expect( ( await session.findEntriesOnBranch({ @@ -214,7 +199,7 @@ describe("bounded session branch queries", () => { ).toEqual([rootId]); await expect(session.findEntriesOnBranch({ start: tailId, limit: 2 })).rejects.toMatchObject({ code: "invalid_entry", - message: expect.stringContaining(`failed to decode entry ${middleId}`), + message: expect.stringContaining(`Entry ${middleId} not found`), }); }); @@ -236,7 +221,7 @@ describe("bounded session branch queries", () => { const db = await sqlite.open(databasePath); try { await db - .prepare("UPDATE session_entries SET payload = ? WHERE session_id = ? AND id = ?") + .prepare("UPDATE entries SET payload = ? WHERE session_id = ? AND id = ?") .run("{}", "invalid-filtered-sqlite", customId); } finally { await db.close(); @@ -249,7 +234,7 @@ describe("bounded session branch queries", () => { const invalidJsonDb = await sqlite.open(databasePath); try { await invalidJsonDb - .prepare("UPDATE session_entries SET payload = ? WHERE session_id = ? AND id = ?") + .prepare("UPDATE entries SET payload = ? WHERE session_id = ? AND id = ?") .run("not json", "invalid-filtered-sqlite", customId); } finally { await invalidJsonDb.close(); @@ -277,7 +262,7 @@ describe("bounded session branch queries", () => { const db = await sqlite.open(databasePath); try { await db - .prepare("UPDATE session_entries SET parent_id = ? WHERE session_id = ? AND id = ?") + .prepare("UPDATE entries SET parent_id = ? WHERE session_id = ? AND id = ?") .run("missing-parent", "bounded-corrupt-sqlite", childId); } finally { await db.close(); @@ -289,17 +274,17 @@ describe("bounded session branch queries", () => { (await session.findEntriesOnBranch({ start: childId, stopAtType: "message" })).map((entry) => entry.id), ).toEqual([childId]); await expect(session.findEntriesOnBranch({ start: childId })).rejects.toMatchObject({ - code: "invalid_session", + code: "invalid_entry", message: expect.stringContaining("Entry missing-parent not found"), }); const cycleDb = await sqlite.open(databasePath); try { await cycleDb - .prepare("UPDATE session_entries SET parent_id = ? WHERE session_id = ? AND id = ?") + .prepare("UPDATE entries SET parent_id = ? WHERE session_id = ? AND id = ?") .run(rootId, "bounded-corrupt-sqlite", childId); await cycleDb - .prepare("UPDATE session_entries SET parent_id = ? WHERE session_id = ? AND id = ?") + .prepare("UPDATE entries SET parent_id = ? WHERE session_id = ? AND id = ?") .run(childId, "bounded-corrupt-sqlite", rootId); } finally { await cycleDb.close(); @@ -311,24 +296,8 @@ describe("bounded session branch queries", () => { (await session.findEntriesOnBranch({ start: childId, stopAtType: "message" })).map((entry) => entry.id), ).toEqual([childId]); await expect(session.findEntriesOnBranch({ start: childId })).rejects.toMatchObject({ - code: "invalid_session", - message: expect.stringContaining(`cycle in parent chain at entry ${childId}`), - }); - }); - - it("provides identical SQLite query semantics", async () => { - const root = createTempDir(); - const repo = new SqliteSessionRepository({ - env: new NodeExecutionEnv({ cwd: root }), - sqlite: createNodeSqliteFactory(), - databasePath: join(root, "sessions.sqlite"), + code: "invalid_entry", + message: expect.stringContaining(`Entry ${childId} not found`), }); - ownedRepositories.push(repo); - const session = await repo.create({ id: "sqlite", cwd: root }); - const expected = await verifyBranchQueries(session); - const reopened = await repo.open(await session.getMetadata()); - expect( - (await reopened.findEntriesOnBranch({ start: expected.tail, order: "oldestFirst" })).map((entry) => entry.id), - ).toEqual(expected.fullPath); }); }); diff --git a/packages/agent/test/harness/experimental/session/sqlite.test.ts b/packages/agent/test/harness/experimental/session/sqlite.test.ts new file mode 100644 index 00000000000..b5ce7292201 --- /dev/null +++ b/packages/agent/test/harness/experimental/session/sqlite.test.ts @@ -0,0 +1,58 @@ +import { join } from "node:path"; +import type { SessionMetadata, SessionRepo } from "@earendil-works/pi-agent-core/experimental"; +import { describe, it } from "vitest"; +import { + createNodeSqliteFactory, + type SqliteSessionMetadata, + SqliteSessionRepository, +} from "../../../../../storage/sqlite-node/src/index.ts"; +import { NodeExecutionEnv } from "../../../../src/harness/env/nodejs.ts"; +import { + createSessionBackendConformance, + type SessionBackendFixture, +} from "../../../../src/harness/experimental/session/testing/index.ts"; +import { createTempDir } from "../../session-test-utils.ts"; + +function requireSqliteMetadata(metadata: SessionMetadata): SqliteSessionMetadata { + const cwd = "cwd" in metadata ? metadata.cwd : undefined; + if (typeof cwd !== "string") { + throw new Error(`Expected SQLite metadata for session ${metadata.id}`); + } + const path = "path" in metadata ? metadata.path : undefined; + if (typeof path !== "string") { + throw new Error(`Expected SQLite metadata for session ${metadata.id}`); + } + return { ...metadata, cwd, path }; +} + +const conformance = createSessionBackendConformance(async () => { + const root = createTempDir(); + const sqliteRepository = new SqliteSessionRepository({ + env: new NodeExecutionEnv({ cwd: root }), + sqlite: createNodeSqliteFactory(), + databasePath: join(root, "sessions.sqlite"), + }); + const repository: SessionRepo = { + create: (options = {}) => sqliteRepository.create({ ...options, cwd: root }), + open: (metadata) => sqliteRepository.open(requireSqliteMetadata(metadata)), + list: () => sqliteRepository.list(), + delete: (metadata) => sqliteRepository.delete(requireSqliteMetadata(metadata)), + fork: (source, options = {}) => sqliteRepository.fork(requireSqliteMetadata(source), { ...options, cwd: root }), + }; + return { + repository, + async [Symbol.asyncDispose]() { + await sqliteRepository.close(); + }, + } satisfies SessionBackendFixture; +}); + +describe("SqliteSessionRepository conformance", () => { + for (const group of new Set(conformance.map((testCase) => testCase.group))) { + describe(group, () => { + for (const testCase of conformance.filter((candidate) => candidate.group === group)) { + it(testCase.name, () => testCase.run()); + } + }); + } +}); diff --git a/packages/agent/test/harness/session-test-utils.ts b/packages/agent/test/harness/session-test-utils.ts index 621ce84c51f..81f98491d6c 100644 --- a/packages/agent/test/harness/session-test-utils.ts +++ b/packages/agent/test/harness/session-test-utils.ts @@ -2,7 +2,18 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { + BranchSummaryEntry, + CompactionEntry, + Session as CoreSession, + Entry, + MessageEntry, + ModelChangeEntry, + ThinkingLevelChangeEntry, +} from "@earendil-works/pi-agent-core/experimental"; +import type { Usage } from "@earendil-works/pi-ai"; import { afterEach } from "vitest"; +import type { SqliteSessionMetadata } from "../../../storage/sqlite-node/src/index.ts"; import { InMemorySessionRepository } from "../../src/harness/session/memory-repo.ts"; import type { Session } from "../../src/harness/session/session.ts"; @@ -38,6 +49,123 @@ export function createAssistantMessage(text: string): AgentMessage { }; } +export type SqliteTestSession = CoreSession; +export type SqliteTestMessage = MessageEntry["message"]; + +export async function appendSqliteCompaction( + session: SqliteTestSession, + summary: string, + _firstKeptEntryId: string | undefined, + tokensBefore: number, + details?: unknown, + _fromHook?: boolean, + usage?: Usage, + retainedTail: SqliteTestMessage[] = [], +): Promise { + const provisioned = { + type: "compaction", + id: session.idGenerator.next(), + summary, + retainedTail, + tokensBefore, + ...(details === undefined ? {} : { details }), + ...(usage === undefined ? {} : { usage }), + } satisfies Omit; + const entry = await session.appendEntry(provisioned, "main"); + return entry.id; +} + +export async function moveSqliteMainLane( + session: SqliteTestSession, + entryId: string | null, + summary?: { summary: string; details?: unknown; usage?: Usage; fromHook?: boolean }, +): Promise { + await session.moveLane("main", entryId); + if (!summary) return undefined; + const provisioned = { + type: "branch_summary", + id: session.idGenerator.next(), + fromId: entryId ?? "root", + summary: summary.summary, + ...(summary.details === undefined ? {} : { details: summary.details }), + ...(summary.usage === undefined ? {} : { usage: summary.usage }), + } satisfies Omit; + const entry = await session.appendEntry(provisioned, "main"); + return entry.id; +} + +export async function getSqliteBranch(session: SqliteTestSession, fromId?: string | null): Promise { + const start = fromId === undefined ? await session.getLeafId() : fromId; + if (start === null) return []; + const newestWindow = await session.findEntriesOnBranch({ start, stopAtType: "compaction" }); + return newestWindow.reverse(); +} + +export async function getSqliteEntries( + session: SqliteTestSession, + options?: { afterEntrySeq?: number; limit?: number }, +): Promise { + return session.findEntries({ + order: "oldestFirst", + limit: options?.limit, + cursor: options?.afterEntrySeq === undefined ? undefined : { afterSeq: options.afterEntrySeq }, + }); +} + +export async function appendSqliteSessionName(session: SqliteTestSession, name: string): Promise { + await session.setName(name.replace(/[\r\n]+/g, " ").trim()); +} + +export async function appendSqliteLabel( + session: SqliteTestSession, + targetId: string, + label: string | undefined, +): Promise { + await session.setLabel(targetId, label); +} + +export async function buildSqliteContext(session: SqliteTestSession): Promise<{ messages: SqliteTestMessage[] }> { + const entries = await getSqliteBranch(session); + const messages = entries.flatMap((entry): SqliteTestMessage[] => { + if (entry.type === "message") return [entry.message]; + if (entry.type === "compaction") return entry.retainedTail; + return []; + }); + return { messages }; +} + +export async function appendSqliteThinkingLevelChange( + session: SqliteTestSession, + thinkingLevel: string, +): Promise { + const entry = await session.appendEntry( + { + type: "thinking_level_change", + id: session.idGenerator.next(), + thinkingLevel, + } satisfies Omit, + "main", + ); + return entry.id; +} + +export async function appendSqliteModelChange( + session: SqliteTestSession, + provider: string, + modelId: string, +): Promise { + const entry = await session.appendEntry( + { + type: "model_change", + id: session.idGenerator.next(), + provider, + modelId, + } satisfies Omit, + "main", + ); + return entry.id; +} + const tempDirs: string[] = []; export function createTempDir(): string { diff --git a/packages/agent/test/harness/sqlite-adapter.test.ts b/packages/agent/test/harness/sqlite-adapter.test.ts new file mode 100644 index 00000000000..34daaf28677 --- /dev/null +++ b/packages/agent/test/harness/sqlite-adapter.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { createNodeSqliteFactory } from "../../../storage/sqlite-node/src/index.ts"; + +describe("node:sqlite adapter", () => { + it("runs transaction callbacks synchronously", async () => { + const db = await createNodeSqliteFactory().open(":memory:"); + try { + db.exec("CREATE TABLE values_table (value INTEGER NOT NULL)"); + const result = db.transaction(() => { + db.prepare("INSERT INTO values_table (value) VALUES (?)").run(42); + return "committed"; + }); + + expect(result).toBe("committed"); + expect(db.prepare("SELECT value FROM values_table").get()).toEqual({ value: 42 }); + } finally { + db.close(); + } + }); + + it("rejects asynchronous transaction callbacks", async () => { + const db = await createNodeSqliteFactory().open(":memory:"); + try { + db.exec("CREATE TABLE values_table (value INTEGER NOT NULL)"); + const asynchronous = async () => { + db.prepare("INSERT INTO values_table (value) VALUES (?)").run(42); + await Promise.resolve(); + }; + expect(() => db.transaction(asynchronous)).toThrow("SQLite transaction callbacks must be synchronous"); + await Promise.resolve(); + expect(db.prepare("SELECT value FROM values_table").all()).toEqual([]); + } finally { + db.close(); + } + }); +}); diff --git a/packages/agent/test/harness/sqlite-branch-cache.test.ts b/packages/agent/test/harness/sqlite-branch-cache.test.ts index 28bdfaa8ca6..30f3dbb7a84 100644 --- a/packages/agent/test/harness/sqlite-branch-cache.test.ts +++ b/packages/agent/test/harness/sqlite-branch-cache.test.ts @@ -4,7 +4,13 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createNodeSqliteFactory, SqliteSessionRepository } from "../../../storage/sqlite-node/src/index.ts"; import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { createAssistantMessage, createUserMessage } from "./session-test-utils.ts"; +import { + appendSqliteCompaction, + createAssistantMessage, + createUserMessage, + getSqliteBranch, + moveSqliteMainLane, +} from "./session-test-utils.ts"; function createTempDir(): string { return mkdtempSync(join(tmpdir(), "pi-agent-sqlite-branch-cache-")); @@ -20,9 +26,9 @@ describe("SQLite branch cache", () => { const session = await repo.create({ cwd: root, id: "session-1" }); const rootId = await session.appendMessage(createUserMessage("root")); const keptId = await session.appendMessage(createUserMessage("kept")); - const compactionId = await session.appendCompaction("summary", keptId, 100); + const compactionId = await appendSqliteCompaction(session, "summary", keptId, 100); await session.appendMessage(createAssistantMessage("first child")); - await session.moveTo(compactionId); + await moveSqliteMainLane(session, compactionId); const branchedId = await session.appendMessage(createAssistantMessage("branched child")); const db = await sqlite.open(databasePath); @@ -49,19 +55,19 @@ describe("SQLite branch cache", () => { const session = await repo.create({ cwd: root, id: "session-1" }); const oldId = await session.appendMessage(createUserMessage("old")); const keptId = await session.appendMessage(createUserMessage("kept")); - const compactionId = await session.appendCompaction("summary", keptId, 100); + const compactionId = await appendSqliteCompaction(session, "summary", keptId, 100); const leafId = await session.appendMessage(createAssistantMessage("new")); const db = await sqlite.open(databasePath); try { await db - .prepare("UPDATE session_entries SET payload = ? WHERE session_id = ? AND id = ?") + .prepare("UPDATE entries SET payload = ? WHERE session_id = ? AND id = ?") .run("not json", "session-1", oldId); } finally { await db.close(); } - expect((await session.getBranch()).map((entry) => entry.id)).toEqual([keptId, compactionId, leafId]); + expect((await getSqliteBranch(session)).map((entry) => entry.id)).toEqual([compactionId, leafId]); }); it("preserves nested compaction boundaries when reading the cache", async () => { @@ -71,7 +77,8 @@ describe("SQLite branch cache", () => { const repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath }); const session = await repo.create({ cwd: root, id: "session-1" }); const rootId = await session.appendMessage(createUserMessage("root")); - const firstCompactionId = await session.appendCompaction( + const firstCompactionId = await appendSqliteCompaction( + session, "first summary", rootId, 100, @@ -81,18 +88,15 @@ describe("SQLite branch cache", () => { [], ); const middleId = await session.appendMessage(createUserMessage("middle")); - const secondCompactionId = await session.appendCompaction("second summary", rootId, 200); + const secondCompactionId = await appendSqliteCompaction(session, "second summary", rootId, 200); const leafId = await session.appendMessage(createAssistantMessage("new")); - expect((await session.getBranch()).map((entry) => entry.id)).toEqual([ - firstCompactionId, - middleId, - secondCompactionId, - leafId, - ]); + expect(firstCompactionId).not.toBe(secondCompactionId); + expect(middleId).not.toBe(leafId); + expect((await getSqliteBranch(session)).map((entry) => entry.id)).toEqual([secondCompactionId, leafId]); }); - it("repairs a missing branch cache from canonical parent links", async () => { + it("fails loudly when the private branch cache is missing", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); @@ -110,25 +114,11 @@ describe("SQLite branch cache", () => { await db.close(); } - expect((await session.getBranch()).map((entry) => entry.id)).toEqual([rootId, childId]); - - const inspection = await sqlite.open(databasePath); - try { - const rows = await inspection - .prepare("SELECT branch_id, entry_id FROM branch_entries WHERE session_id = ? ORDER BY entry_seq") - .all<{ branch_id: string; entry_id: string }>("session-1"); - expect(rows.map((row) => row.entry_id)).toEqual([rootId, childId]); - const tip = await inspection - .prepare("SELECT branch_id, tip_id FROM branch_tips WHERE session_id = ?") - .get<{ branch_id: string; tip_id: string }>("session-1"); - expect(rows).toHaveLength(2); - expect(tip).toEqual({ branch_id: rows[0]?.branch_id, tip_id: childId }); - } finally { - await inspection.close(); - } + expect(rootId).not.toBe(childId); + await expect(getSqliteBranch(session)).rejects.toMatchObject({ code: "invalid_entry" }); }); - it("rolls back an interrupted branch cache repair", async () => { + it("does not repair the private branch cache during normal reads", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); @@ -141,35 +131,49 @@ describe("SQLite branch cache", () => { try { await db.prepare("DELETE FROM branch_tips WHERE session_id = ?").run("session-1"); await db.prepare("DELETE FROM branch_entries WHERE session_id = ?").run("session-1"); - await db.exec(` - CREATE TRIGGER fail_branch_cache_repair - BEFORE INSERT ON branch_tips - BEGIN - SELECT RAISE(ABORT, 'repair failed'); - END; - `); } finally { await db.close(); } - await expect(session.getBranch()).rejects.toMatchObject({ - code: "storage", - message: expect.stringContaining("Failed to rebuild SQLite branch cache"), - }); + await expect(getSqliteBranch(session)).rejects.toMatchObject({ code: "invalid_entry" }); const inspection = await sqlite.open(databasePath); try { expect( await inspection.prepare("SELECT entry_id FROM branch_entries WHERE session_id = ?").all("session-1"), ).toEqual([]); - await inspection.exec("DROP TRIGGER fail_branch_cache_repair"); } finally { await inspection.close(); } - expect(await session.getBranch()).toHaveLength(1); }); - it("repairs a missing source branch cache while forking transactionally", async () => { + it("repairs the private branch cache explicitly", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const env = new NodeExecutionEnv({ cwd: root }); + const sqlite = createNodeSqliteFactory(); + const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + const session = await repo.create({ cwd: root, id: "session-1" }); + const rootId = await session.appendMessage(createUserMessage("root")); + const childId = await session.appendMessage(createAssistantMessage("child")); + const metadata = await session.getMetadata(); + + const db = await sqlite.open(databasePath); + try { + await db.prepare("DELETE FROM branch_tips WHERE session_id = ?").run("session-1"); + await db.prepare("DELETE FROM branch_entries WHERE session_id = ?").run("session-1"); + } finally { + await db.close(); + } + + await expect(getSqliteBranch(session)).rejects.toMatchObject({ code: "invalid_entry" }); + + await repo.repairBranchCache(metadata); + + expect((await getSqliteBranch(session)).map((entry) => entry.id)).toEqual([rootId, childId]); + }); + + it("fails when forking from a source with a missing branch cache", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); @@ -187,16 +191,18 @@ describe("SQLite branch cache", () => { await db.close(); } - const fork = await repo.fork(await source.getMetadata(), { - cwd: root, - id: "fork", - entryId: childId, - position: "at", - }); - expect((await fork.getEntries()).map((entry) => entry.id)).toEqual([rootId, childId]); + expect(rootId).not.toBe(childId); + await expect( + repo.fork(await source.getMetadata(), { + cwd: root, + id: "fork", + entryId: childId, + position: "at", + }), + ).rejects.toMatchObject({ code: "invalid_fork_target" }); }); - it("repairs a stale branch cache from canonical parent links", async () => { + it("fails when the private branch cache is stale", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); @@ -210,31 +216,16 @@ describe("SQLite branch cache", () => { const db = await sqlite.open(databasePath); try { await db - .prepare("UPDATE session_entries SET parent_id = ? WHERE session_id = ? AND id = ?") + .prepare("UPDATE entries SET parent_id = ? WHERE session_id = ? AND id = ?") .run(rootId, "session-1", leafId); } finally { await db.close(); } - expect( - (await session.findEntriesOnBranch({ start: leafId, order: "oldestFirst" })).map((entry) => entry.id), - ).toEqual([rootId, leafId]); - const inspection = await sqlite.open(databasePath); - try { - const rows = await inspection - .prepare( - `SELECT entry_id FROM branch_entries - WHERE session_id = ? AND branch_id = ( - SELECT branch_id FROM branch_entries WHERE session_id = ? AND entry_id = ? LIMIT 1 - ) - ORDER BY entry_seq`, - ) - .all<{ entry_id: string }>("session-1", "session-1", leafId); - expect(rows.map((row) => row.entry_id)).toEqual([rootId, leafId]); - expect(rows.map((row) => row.entry_id)).not.toContain(staleId); - } finally { - await inspection.close(); - } + expect(staleId).not.toBe(leafId); + await expect(session.findEntriesOnBranch({ start: leafId, order: "oldestFirst" })).rejects.toMatchObject({ + code: "invalid_entry", + }); }); it("deletes branch entries and tips with the session", async () => { diff --git a/packages/agent/test/harness/sqlite-leases.test.ts b/packages/agent/test/harness/sqlite-leases.test.ts new file mode 100644 index 00000000000..9ea4f9c6ab6 --- /dev/null +++ b/packages/agent/test/harness/sqlite-leases.test.ts @@ -0,0 +1,136 @@ +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createNodeSqliteFactory, + type SqliteSessionMetadata, + SqliteSessionRepository, +} from "../../../storage/sqlite-node/src/index.ts"; +import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; +import { createTempDir, createUserMessage } from "./session-test-utils.ts"; + +const repositories: SqliteSessionRepository[] = []; + +function createRepository(root: string, databasePath: string, lease?: { ttlMs: number; heartbeatIntervalMs: number }) { + const repository = new SqliteSessionRepository({ + env: new NodeExecutionEnv({ cwd: root }), + sqlite: createNodeSqliteFactory(), + databasePath, + writerLease: lease, + }); + repositories.push(repository); + return repository; +} + +afterEach(async () => { + vi.useRealTimers(); + for (const repository of repositories.splice(0)) await repository.close(); +}); + +describe("SQLite session writer leases", () => { + it("rejects a second writer until the first session releases its claim", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const firstRepository = createRepository(root, databasePath); + const secondRepository = createRepository(root, databasePath); + const first = await firstRepository.create({ cwd: root, id: "session-1" }); + const metadata = await first.getMetadata(); + + await expect(secondRepository.open(metadata)).rejects.toMatchObject({ + code: "storage", + message: expect.stringContaining("already has an active writer"), + }); + + await firstRepository.close(); + const second = await secondRepository.open(metadata); + await expect(second.appendMessage(createUserMessage("new owner"))).resolves.toBeTypeOf("string"); + }); + + it("fences a stale owner after an expired lease is acquired by another writer", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const lease = { ttlMs: 120_000, heartbeatIntervalMs: 60_000 }; + const firstRepository = createRepository(root, databasePath, lease); + const secondRepository = createRepository(root, databasePath, lease); + const first = await firstRepository.create({ cwd: root, id: "session-1" }); + const metadata = await first.getMetadata(); + const sqlite = createNodeSqliteFactory(); + const db = await sqlite.open(databasePath); + try { + await db.prepare("UPDATE leases SET expires_at_ms = 0 WHERE session_id = ?").run(metadata.id); + } finally { + await db.close(); + } + + const second = await secondRepository.open(metadata); + await expect(first.appendMessage(createUserMessage("stale owner"))).rejects.toMatchObject({ + code: "storage", + message: expect.stringContaining("writer lease was lost"), + }); + expect(await second.findEntries()).toEqual([]); + + const inspection = await sqlite.open(databasePath); + let currentLease: { owner_id: string; fence: number } | undefined; + try { + currentLease = await inspection + .prepare("SELECT owner_id, fence FROM leases WHERE session_id = ?") + .get<{ owner_id: string; fence: number }>(metadata.id); + expect(currentLease?.fence).toBe(2); + } finally { + await inspection.close(); + } + + await firstRepository.close(); + const afterStaleClose = await sqlite.open(databasePath); + try { + expect( + await afterStaleClose.prepare("SELECT owner_id, fence FROM leases WHERE session_id = ?").get(metadata.id), + ).toEqual(currentLease); + } finally { + await afterStaleClose.close(); + } + await expect(second.appendMessage(createUserMessage("current owner"))).resolves.toBeTypeOf("string"); + }); + + it("serializes lease-checked writes for sessions sharing one database connection", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const repository = createRepository(root, databasePath); + const first = await repository.create({ cwd: root, id: "session-1" }); + const second = await repository.create({ cwd: root, id: "session-2" }); + + await expect( + Promise.all([ + first.appendMessage(createUserMessage("first")), + second.appendMessage(createUserMessage("second")), + ]), + ).resolves.toHaveLength(2); + }); + + it("renews an idle writer lease with a heartbeat", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const repository = createRepository(root, databasePath, { ttlMs: 30_000, heartbeatIntervalMs: 10_000 }); + const session = await repository.create({ cwd: root, id: "session-1" }); + const metadata = (await session.getMetadata()) as SqliteSessionMetadata; + const sqlite = createNodeSqliteFactory(); + + const readExpiry = async (): Promise => { + const db = await sqlite.open(databasePath); + try { + return ( + await db + .prepare("SELECT expires_at_ms FROM leases WHERE session_id = ?") + .get<{ expires_at_ms: number }>(metadata.id) + )?.expires_at_ms; + } finally { + await db.close(); + } + }; + + const initialExpiry = await readExpiry(); + await vi.advanceTimersByTimeAsync(10_000); + expect(await readExpiry()).toBe((initialExpiry ?? 0) + 10_000); + }); +}); diff --git a/packages/agent/test/harness/sqlite-migrations.test.ts b/packages/agent/test/harness/sqlite-migrations.test.ts index 9056754bd79..397febe1ca1 100644 --- a/packages/agent/test/harness/sqlite-migrations.test.ts +++ b/packages/agent/test/harness/sqlite-migrations.test.ts @@ -3,9 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { - applyMigrations, createNodeSqliteFactory, - loadMigrations, type SqliteDatabase, type SqliteDatabaseFactory, type SqliteRunResult, @@ -13,30 +11,38 @@ import { SqliteSessionRepository, type SqliteStatement, } from "../../../storage/sqlite-node/src/index.ts"; -import { SqliteSessionConnection } from "../../../storage/sqlite-node/src/sqlite/storage/index.ts"; import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { createAssistantMessage, createUserMessage } from "./session-test-utils.ts"; +import { + appendSqliteCompaction, + appendSqliteLabel, + appendSqliteSessionName, + buildSqliteContext, + createAssistantMessage, + createUserMessage, + getSqliteEntries, + moveSqliteMainLane, +} from "./session-test-utils.ts"; function createTempDir(): string { return mkdtempSync(join(tmpdir(), "pi-agent-sqlite-")); } class ThrowingStatement implements SqliteStatement { - private readonly onRun: () => Promise; + private readonly onRun: () => SqliteRunResult; - constructor(onRun: () => Promise) { + constructor(onRun: () => SqliteRunResult) { this.onRun = onRun; } - async run(..._params: unknown[]): Promise { + run(..._params: unknown[]): SqliteRunResult { return this.onRun(); } - async get(..._params: unknown[]): Promise { + get(..._params: unknown[]): TRow | undefined { return undefined; } - async all(..._params: unknown[]): Promise { + all(..._params: unknown[]): TRow[] { return []; } } @@ -49,17 +55,17 @@ class CountingDatabase implements SqliteDatabase { this.statementFactory = statementFactory; } - async exec(_sql: string): Promise {} + exec(_sql: string): void {} prepare(sql: string): SqliteStatement { return this.statementFactory(sql); } - async transaction(fn: () => Promise): Promise { + transaction(fn: () => T): T { return fn(); } - async close(): Promise { + close(): void { this.closeCount += 1; } } @@ -80,9 +86,9 @@ function createCloseCountingSqliteFactory(): { exec: (sql) => db.exec(sql), prepare: (sql) => db.prepare(sql), transaction: (fn) => db.transaction(fn), - async close() { + close() { counts.closes += 1; - await db.close(); + db.close(); }, }; }, @@ -102,7 +108,7 @@ describe("SQLite migrations", () => { const db = await sqlite.open(databasePath); try { const rows = await db.prepare("SELECT id FROM migrations ORDER BY id").all<{ id: string }>(); - expect(rows.map((row) => row.id)).toEqual(["001_initial.sql", "002_branch_tips.sql"]); + expect(rows.map((row) => row.id)).toEqual(["001_initial.sql"]); const tables = await db .prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' ORDER BY name") .all<{ name: string; sql: string | null }>(); @@ -110,16 +116,17 @@ describe("SQLite migrations", () => { expect.arrayContaining([ "migrations", "sessions", - "session_entries", + "entries", "session_sequences", "branch_entries", "branch_tips", - "session_materialized", - "entry_materialized", ]), ); const sessionColumns = await db.prepare("PRAGMA table_info(sessions)").all<{ name: string }>(); - expect(sessionColumns.map((column) => column.name)).toContain("active_leaf_id"); + expect(sessionColumns.map((column) => column.name)).not.toContain("leaf_id"); + expect(tables.map((row) => row.name)).toEqual( + expect.arrayContaining(["lanes", "records", "lane_moves", "facts", "leases", "session_stats"]), + ); const branchIndexes = await db .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'branch_entries'") .all<{ name: string }>(); @@ -128,10 +135,13 @@ describe("SQLite migrations", () => { for (const tableName of [ "sessions", "session_sequences", + "session_stats", "branch_entries", "branch_tips", - "session_materialized", - "entry_materialized", + "lanes", + "records", + "lane_moves", + "facts", ]) { const table = tables.find((row) => row.name === tableName); expect(table?.sql).toContain("WITHOUT ROWID"); @@ -141,37 +151,6 @@ describe("SQLite migrations", () => { } }); - it("clears legacy branch projections when adding explicit tips", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const sqlite = createNodeSqliteFactory(); - const db = await sqlite.open(databasePath); - try { - const initial = (await loadMigrations()).find((migration) => migration.id === "001_initial.sql"); - if (!initial) throw new Error("Missing initial SQLite migration"); - await db.exec(initial.sql); - await db.exec("CREATE TABLE migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"); - await db - .prepare("INSERT INTO migrations (id, applied_at) VALUES (?, ?)") - .run(initial.id, "2026-01-01T00:00:00.000Z"); - await db - .prepare("INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq) VALUES (?, ?, ?, ?)") - .run("session-1", "legacy-branch", "entry-1", 1); - - await applyMigrations(db); - - expect(await db.prepare("SELECT entry_id FROM branch_entries").all<{ entry_id: string }>()).toEqual([]); - expect( - await db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'branch_tips'").get(), - ).toBeDefined(); - expect( - (await db.prepare("SELECT id FROM migrations ORDER BY id").all<{ id: string }>()).map((row) => row.id), - ).toEqual(["001_initial.sql", "002_branch_tips.sql"]); - } finally { - await db.close(); - } - }); - it("persists session metadata through create, list, open, and fork", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); @@ -185,7 +164,8 @@ describe("SQLite migrations", () => { const sourceMetadata = await source.getMetadata(); expect(sourceMetadata.metadata).toEqual({ profile: "reviewer" }); expect((await repo.list({ cwd: root })).map((listed) => listed.metadata)).toEqual([{ profile: "reviewer" }]); - expect((await (await repo.open(sourceMetadata)).getMetadata()).metadata).toEqual({ profile: "reviewer" }); + const reopened = await repo.open(sourceMetadata); + expect((await reopened.getMetadata()).metadata).toEqual({ profile: "reviewer" }); const fork = await repo.fork(sourceMetadata, { cwd: root, id: "session-2" }); expect((await fork.getMetadata()).metadata).toEqual({ profile: "reviewer" }); const overridden = await repo.fork(sourceMetadata, { @@ -209,8 +189,8 @@ describe("SQLite migrations", () => { const db = await sqlite.open(databasePath); try { await db.exec(` -CREATE TRIGGER fail_fork_entry BEFORE INSERT ON session_entries -WHEN new.session_id = 'fork' AND new.entry_seq = 2 +CREATE TRIGGER fail_fork_entry BEFORE INSERT ON entries +WHEN new.session_id = 'fork' AND new.seq = 2 BEGIN SELECT RAISE(ABORT, 'fail fork'); END; @@ -228,14 +208,14 @@ END; await inspection.prepare("SELECT id FROM sessions WHERE id = ?").get<{ id: string }>("fork"), ).toBeUndefined(); expect( - await inspection.prepare("SELECT id FROM session_entries WHERE session_id = ?").all<{ id: string }>("fork"), + await inspection.prepare("SELECT id FROM entries WHERE session_id = ?").all<{ id: string }>("fork"), ).toEqual([]); } finally { await inspection.close(); } }); - it("materializes active leaf id in sessions transactionally", async () => { + it("materializes the main lane leaf transactionally", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); @@ -244,29 +224,18 @@ END; const session = await repo.create({ cwd: root, id: "session-1" }); const rootId = await session.appendMessage(createUserMessage("root")); const childId = await session.appendMessage(createAssistantMessage("child")); - await session.moveTo(rootId); + await moveSqliteMainLane(session, rootId); const db = await sqlite.open(databasePath); try { const row = await db - .prepare("SELECT active_leaf_id FROM sessions WHERE id = ?") - .get<{ active_leaf_id: string | null }>("session-1"); - expect(row?.active_leaf_id).toBe(rootId); - const latestBranchRow = await db - .prepare( - "SELECT branch_id, entry_id, entry_seq FROM branch_entries WHERE session_id = ? ORDER BY entry_seq DESC LIMIT 1", - ) - .get<{ branch_id: string; entry_id: string; entry_seq: number }>("session-1"); - const latestSessionEntry = await db - .prepare("SELECT id, type FROM session_entries WHERE session_id = ? ORDER BY entry_seq DESC LIMIT 1") - .get<{ id: string; type: string }>("session-1"); - expect(latestSessionEntry?.type).toBe("leaf"); - expect(latestBranchRow?.entry_id).toBe(latestSessionEntry?.id); - if (!latestBranchRow) throw new Error("Missing latest branch row"); - const branchTip = await db - .prepare("SELECT branch_id, tip_id FROM branch_tips WHERE session_id = ? AND branch_id = ?") - .get<{ branch_id: string; tip_id: string }>("session-1", latestBranchRow.branch_id); - expect(branchTip?.tip_id).toBe(latestSessionEntry?.id); + .prepare("SELECT leaf_id FROM lanes WHERE session_id = ? AND lane = ?") + .get<{ leaf_id: string | null }>("session-1", "main"); + expect(row?.leaf_id).toBe(rootId); + const latestLaneMove = await db + .prepare("SELECT lane, leaf_id FROM lane_moves WHERE session_id = ? ORDER BY seq DESC LIMIT 1") + .get<{ lane: string; leaf_id: string | null }>("session-1"); + expect(latestLaneMove).toEqual({ lane: "main", leaf_id: rootId }); } finally { await db.close(); } @@ -285,7 +254,7 @@ END; const session = await repo.create({ cwd: root, id: "session-1" }); const rootId = await session.appendMessage(createUserMessage("root")); const firstChildId = await session.appendMessage(createAssistantMessage("first child")); - await session.moveTo(rootId); + await moveSqliteMainLane(session, rootId); const secondChildId = await session.appendMessage(createAssistantMessage("second child")); const db = await sqlite.open(databasePath); @@ -318,43 +287,30 @@ END; const session = await repo.create({ cwd: root, id: "session-1" }); const rootId = await session.appendMessage(createUserMessage("root")); await session.appendMessage(createAssistantMessage("first child")); - await session.appendSessionName(" Reopened Session "); - await session.moveTo(rootId); + await appendSqliteSessionName(session, " Reopened Session "); + await moveSqliteMainLane(session, rootId); await session.appendMessage(createAssistantMessage("branched child")); const reopened = await repo.open(await session.getMetadata()); - expect(await reopened.getSessionName()).toBe("Reopened Session"); - expect((await reopened.buildContext()).messages.map((message) => message.role)).toEqual(["user", "assistant"]); - expect((await reopened.buildContext()).messages.at(-1)).toMatchObject({ + expect(await reopened.getName()).toBe("Reopened Session"); + expect((await buildSqliteContext(reopened)).messages.map((message) => message.role)).toEqual([ + "user", + "assistant", + ]); + expect((await buildSqliteContext(reopened)).messages.at(-1)).toMatchObject({ content: [{ type: "text", text: "branched child" }], }); }); - it("pages entries by entry_seq cursor", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const ids = [ - await session.appendMessage(createUserMessage("one")), - await session.appendMessage(createAssistantMessage("two")), - await session.appendMessage(createUserMessage("three")), - ]; - - expect((await session.getEntries({ limit: 2 })).map((entry) => entry.id)).toEqual(ids.slice(0, 2)); - expect((await session.getEntries({ afterEntrySeq: 1, limit: 2 })).map((entry) => entry.id)).toEqual(ids.slice(1)); - }); - it("closes the database when create fails after openDatabase succeeds", async () => { const root = createTempDir(); const db = new CountingDatabase((sql) => { if (sql.startsWith("INSERT INTO sessions")) { - return new ThrowingStatement(async () => { + return new ThrowingStatement(() => { throw new Error("insert failed"); }); } - return new ThrowingStatement(async () => ({ changes: 1 })); + return new ThrowingStatement(() => ({ changes: 1 })); }); const sqlite: SqliteDatabaseFactory = { open: async () => db, @@ -372,9 +328,9 @@ END; const root = createTempDir(); const db = new CountingDatabase((sql) => { if (sql.includes("FROM sessions WHERE id = ?")) { - return new ThrowingStatement(async () => ({ changes: 0 })); + return new ThrowingStatement(() => ({ changes: 0 })); } - return new ThrowingStatement(async () => ({ changes: 1 })); + return new ThrowingStatement(() => ({ changes: 1 })); }); const sqlite: SqliteDatabaseFactory = { open: async () => db, @@ -383,7 +339,7 @@ END; const repo = new SqliteSessionRepository({ env, sqlite, databasePath: join(root, "sessions.sqlite") }); const metadata: SqliteSessionMetadata = { id: "missing", - createdAt: new Date().toISOString(), + createdAt: Date.now(), cwd: root, path: join(root, "sessions.sqlite"), }; @@ -404,7 +360,7 @@ END; const session = await repo.create({ cwd: root, id: "session-1" }); for (let i = 0; i < 10; i++) await session.appendMessage(createUserMessage(`message ${i}`)); - await session.getEntries(); + await getSqliteEntries(session); expect(counts).toEqual({ opens: 1, closes: 0 }); await repo[Symbol.asyncDispose](); expect(counts).toEqual({ opens: 1, closes: 1 }); @@ -427,7 +383,7 @@ END; expect(counts).toEqual({ opens: 1, closes: 1 }); }); - it("rejects a missing active leaf when opened", async () => { + it("rejects a missing lane leaf when listing lanes and opening", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); @@ -438,14 +394,20 @@ END; const db = await sqlite.open(databasePath); try { - await db.prepare("UPDATE sessions SET active_leaf_id = ? WHERE id = ?").run("missing", metadata.id); + await db + .prepare("UPDATE lanes SET leaf_id = ? WHERE session_id = ? AND lane = ?") + .run("missing", metadata.id, "main"); } finally { await db.close(); } + await expect(session.getLanes()).rejects.toMatchObject({ + code: "storage", + message: expect.stringContaining("Lane main points at missing entry missing"), + }); await expect(repo.open(metadata)).rejects.toMatchObject({ - code: "invalid_session", - message: "Entry missing not found", + code: "storage", + message: expect.stringContaining("Lane main points at missing entry missing"), }); }); @@ -462,64 +424,46 @@ END; const db = await sqlite.open(databasePath); try { await db - .prepare("UPDATE session_entries SET payload = ? WHERE session_id = ? AND id = ?") + .prepare("UPDATE entries SET payload = ? WHERE session_id = ? AND id = ?") .run("not json", metadata.id, entryId); } finally { await db.close(); } const reopened = await repo.open(metadata); - await expect(reopened.getEntries()).rejects.toMatchObject({ code: "invalid_entry" }); + await expect(getSqliteEntries(reopened)).rejects.toMatchObject({ code: "invalid_entry" }); }); it("does not publish connection state when an append transaction fails", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); + const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); + const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + const session = await repo.create({ cwd: root, id: "session-1" }); const db = await sqlite.open(databasePath); - await applyMigrations(db); - const storage = await SqliteSessionConnection.create(db, databasePath, { - cwd: root, - sessionId: "session-1", - }); - await db.exec(` - CREATE TEMP TRIGGER fail_branch_tip_insert - BEFORE INSERT ON branch_tips - BEGIN - SELECT RAISE(ABORT, 'branch insert failed'); - END; - `); - - const rootEntry = { - type: "message" as const, - id: "root", - parentId: null, - timestamp: new Date().toISOString(), - message: createUserMessage("root"), - }; try { - await expect(storage.appendEntry(rootEntry)).rejects.toMatchObject({ code: "storage" }); - } finally { + await db.exec(` + CREATE TRIGGER fail_branch_tip_insert + BEFORE INSERT ON branch_tips + BEGIN + SELECT RAISE(ABORT, 'branch insert failed'); + END; + `); + await expect(session.appendMessage(createUserMessage("root"))).rejects.toThrow("branch insert failed"); + const lane = await db + .prepare("SELECT leaf_id FROM lanes WHERE session_id = ? AND lane = ?") + .get<{ leaf_id: string | null }>("session-1", "main"); + expect(lane?.leaf_id).toBeNull(); + expect(await db.prepare("SELECT id FROM entries WHERE session_id = ?").all("session-1")).toEqual([]); + expect(await session.getStats()).toMatchObject({ messageCount: 0 }); await db.exec("DROP TRIGGER fail_branch_tip_insert"); + } finally { + await db.close(); } - const sessionRow = await db - .prepare("SELECT active_leaf_id FROM sessions WHERE id = ?") - .get<{ active_leaf_id: string | null }>("session-1"); - expect(sessionRow?.active_leaf_id).toBeNull(); - expect(await storage.readEntries()).toEqual([]); - await expect( - storage.appendEntry({ - type: "leaf", - id: "leaf", - parentId: null, - timestamp: new Date().toISOString(), - targetId: rootEntry.id, - }), - ).rejects.toMatchObject({ code: "not_found" }); - expect(await storage.readEntries()).toEqual([]); - await storage.appendEntry(rootEntry); - expect(await storage.readEntries()).toEqual([rootEntry]); - await db.close(); + const entryId = await session.appendMessage(createUserMessage("root")); + expect((await getSqliteEntries(session)).map((entry) => entry.id)).toEqual([entryId]); + expect(await session.getStats()).toMatchObject({ messageCount: 1 }); }); it("materializes session summary fields transactionally", async () => { @@ -530,8 +474,6 @@ END; const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); const session = await repo.create({ cwd: root, id: "session-1" }); const userId = await session.appendMessage(createUserMessage("one")); - await session.appendThinkingLevelChange("high"); - await session.appendModelChange("anthropic", "claude-sonnet-4-5"); const assistant = { ...createAssistantMessage("two"), provider: "anthropic", @@ -545,62 +487,117 @@ END; cost: { input: 0.1, output: 0.2, cacheRead: 0.03, cacheWrite: 0.04, total: 0.37 }, }, }; - await session.appendMessage(assistant); - await session.appendCompaction("summary", userId, 200, undefined, false, { + const assistantId = await session.appendMessage(assistant); + await session.appendRecord({ + type: "usage", + id: "assistant-usage", + lane: "main", + cause: "assistant", + runId: "run", + entryId: assistantId, + attempt: 1, + stopReason: "stop", + usage: assistant.usage, + }); + const compactionUsage = { input: 1, output: 2, cacheRead: 3, cacheWrite: 4, totalTokens: 10, cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 }, + }; + const compactionId = await appendSqliteCompaction( + session, + "summary", + userId, + 200, + undefined, + false, + compactionUsage, + ); + await session.appendRecord({ + type: "usage", + id: "compaction-usage", + lane: "main", + cause: "compaction", + runId: "run", + entryId: compactionId, + attempt: 1, + stopReason: "stop", + usage: compactionUsage, }); - await session.moveTo(userId, { + const branchUsage = { + input: 5, + output: 6, + cacheRead: 7, + cacheWrite: 8, + totalTokens: 26, + cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 }, + }; + const branchSummaryId = await moveSqliteMainLane(session, userId, { summary: "branch summary", - usage: { - input: 5, - output: 6, - cacheRead: 7, - cacheWrite: 8, - totalTokens: 26, - cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 }, - }, + usage: branchUsage, + }); + if (!branchSummaryId) throw new Error("Expected branch summary"); + await session.appendRecord({ + type: "usage", + id: "branch-summary-usage", + lane: "main", + cause: "branch_summary", + runId: "run", + entryId: branchSummaryId, + attempt: 1, + stopReason: "stop", + usage: branchUsage, + }); + await appendSqliteSessionName(session, " My Session "); + await appendSqliteLabel(session, userId, "checkpoint"); + + expect(await session.getStats()).toMatchObject({ + messageCount: 2, + cachedTokens: 50, + uncachedTokens: 128, + totalTokens: 211, + costTotal: 0.73, }); - await session.appendSessionName(" My Session "); - await session.appendLabel(userId, "checkpoint"); const db = await sqlite.open(databasePath); try { - const row = await db.prepare("SELECT session_id, payload FROM session_materialized WHERE session_id = ?").get<{ - session_id: string; - payload: string; - }>("session-1"); - expect(row).toBeDefined(); - expect(row?.session_id).toBe("session-1"); - expect(JSON.parse(row?.payload ?? "null")).toMatchObject({ - name: "My Session", - messageCount: 2, - cachedTokens: 50, - uncachedTokens: 128, - totalTokens: 211, - costTotal: 0.73, - currentModel: { provider: "anthropic", modelId: "claude-sonnet-4-5" }, - currentThinkingLevel: "high", - }); - const entryRows = await db - .prepare( - "SELECT session_id, entry_seq, type, payload FROM entry_materialized WHERE session_id = ? ORDER BY entry_seq, type", - ) - .all<{ - session_id: string; - entry_seq: number; - type: string; - payload: string; - }>("session-1"); expect( - entryRows.some((entryRow) => entryRow.type === "label" && JSON.parse(entryRow.payload).targetId === userId), - ).toBe(true); - expect(entryRows.some((entryRow) => entryRow.type === "thinking")).toBe(false); - expect(entryRows.some((entryRow) => entryRow.type === "model")).toBe(false); + await db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_materialized'") + .get(), + ).toBeUndefined(); + expect( + await db + .prepare("SELECT type, COUNT(*) AS count FROM records WHERE session_id = ? AND type = ? GROUP BY type") + .get<{ type: string; count: number }>("session-1", "usage"), + ).toEqual({ type: "usage", count: 3 }); + expect( + await db + .prepare( + `SELECT message_count, cached_tokens, uncached_tokens, total_tokens, cost_total + FROM session_stats + WHERE session_id = ?`, + ) + .get("session-1"), + ).toEqual({ + message_count: 2, + cached_tokens: 50, + uncached_tokens: 128, + total_tokens: 211, + cost_total: 0.73, + }); + const nameFact = await db + .prepare("SELECT value FROM facts WHERE session_id = ? AND kind = 'name' ORDER BY seq DESC LIMIT 1") + .get<{ value: string }>("session-1"); + expect(JSON.parse(nameFact?.value ?? "null")).toBe("My Session"); + const labelFact = await db + .prepare("SELECT key, value FROM facts WHERE session_id = ? AND kind = 'label' ORDER BY seq DESC LIMIT 1") + .get<{ key: string; value: string }>("session-1"); + expect(labelFact?.key).toBe(userId); + expect(JSON.parse(labelFact?.value ?? "null")).toBe("checkpoint"); } finally { await db.close(); } diff --git a/packages/agent/test/harness/sqlite-node.test.ts b/packages/agent/test/harness/sqlite-node.test.ts index 2fda0053954..d7cc7c9e723 100644 --- a/packages/agent/test/harness/sqlite-node.test.ts +++ b/packages/agent/test/harness/sqlite-node.test.ts @@ -9,8 +9,8 @@ import { import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; import { JsonlSessionRepository } from "../../src/harness/session/jsonl-repo.ts"; import { createScanningSessionSearch } from "../../src/harness/session/search.ts"; -import type { SessionSearch, SessionSearchHit, SessionSearchOptions } from "../../src/harness/types.ts"; -import { createTempDir, createUserMessage } from "./session-test-utils.ts"; +import type { SessionSearchOptions } from "../../src/harness/types.ts"; +import { createTempDir, createUserMessage, getSqliteEntries } from "./session-test-utils.ts"; const ownedRepositories: AsyncDisposable[] = []; @@ -46,6 +46,42 @@ describe("JsonlSessionBackend with scanning search", () => { }); }); +describe("SqliteSessionRepository writer leases", () => { + it("shares one storage queue for repeated opens in the same repository", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const sqlite = createNodeSqliteFactory(); + const databasePath = join(root, "sessions.sqlite"); + const { repository: repo } = createSqliteFixture({ env, sqlite, databasePath }); + const session = await repo.create({ cwd: root, id: "session" }); + const reopened = await repo.open(await session.getMetadata()); + + const [first, second] = await Promise.all([ + session.appendMessage(createUserMessage("first")), + reopened.appendMessage(createUserMessage("second")), + ]); + + expect((await getSqliteEntries(session)).map((entry) => entry.id)).toEqual([first, second]); + }); + + it("rejects a second repository while a session lease is active", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const sqlite = createNodeSqliteFactory(); + const databasePath = join(root, "sessions.sqlite"); + const { repository: firstRepo } = createSqliteFixture({ env, sqlite, databasePath }); + const session = await firstRepo.create({ cwd: root, id: "session" }); + const metadata = await session.getMetadata(); + const secondRepo = new SqliteSessionRepository({ env, sqlite, databasePath }); + ownedRepositories.push(secondRepo); + + await expect(secondRepo.open(metadata)).rejects.toMatchObject({ code: "storage" }); + + await firstRepo.close(); + await expect(secondRepo.open(metadata)).resolves.toBeDefined(); + }); +}); + describe("SqliteSessionBackend with explicit SQLite FTS5 search", () => { it("uses SQLite FTS5 when composed with its search implementation", async () => { const root = createTempDir(); @@ -118,7 +154,7 @@ describe("SqliteSessionBackend with explicit SQLite FTS5 search", () => { } await expect(session.appendMessage(createUserMessage("must roll back"))).rejects.toThrow(); - await expect(session.getEntries()).resolves.toEqual([]); + await expect(getSqliteEntries(session)).resolves.toEqual([]); }); it("rolls back canonical deletion when co-located FTS cleanup fails", async () => { @@ -141,7 +177,7 @@ describe("SqliteSessionBackend with explicit SQLite FTS5 search", () => { await expect(repo.delete(metadata)).rejects.toThrow(); const reopened = await repo.open(metadata); - await expect(reopened.getEntries()).resolves.toHaveLength(1); + await expect(getSqliteEntries(reopened)).resolves.toHaveLength(1); }); it("initializes canonical storage when searched before the first session is created", async () => { @@ -168,8 +204,10 @@ describe("SqliteSessionRepository with custom search", () => { it("uses an independently supplied search implementation", async () => { const root = createTempDir(); const searches: SessionSearchOptions[] = []; - const search: SessionSearch = { - async search(options): Promise[]> { + const search: { + search(options: SessionSearchOptions): Promise<{ metadata: SqliteSessionMetadata; entryId: string }[]>; + } = { + async search(options) { searches.push(options); return []; }, diff --git a/packages/agent/vitest.config.ts b/packages/agent/vitest.config.ts index ed0ab064ab1..5ca2a36facc 100644 --- a/packages/agent/vitest.config.ts +++ b/packages/agent/vitest.config.ts @@ -4,6 +4,7 @@ import { defineConfig } from "vitest/config"; const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url)); const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url)); const agentSrcIndex = fileURLToPath(new URL("./src/index.ts", import.meta.url)); +const agentSrcExperimental = fileURLToPath(new URL("./src/experimental.ts", import.meta.url)); export default defineConfig({ test: { @@ -16,6 +17,7 @@ export default defineConfig({ resolve: { alias: [ { find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex }, + { find: /^@earendil-works\/pi-agent-core\/experimental$/, replacement: agentSrcExperimental }, { find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex }, { find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat }, ], diff --git a/packages/agent/vitest.harness.config.ts b/packages/agent/vitest.harness.config.ts index 8045bb1a628..be1e8f800f7 100644 --- a/packages/agent/vitest.harness.config.ts +++ b/packages/agent/vitest.harness.config.ts @@ -4,6 +4,7 @@ import { defineConfig } from "vitest/config"; const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url)); const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url)); const agentSrcIndex = fileURLToPath(new URL("../agent/src/index.ts", import.meta.url)); +const agentSrcExperimental = fileURLToPath(new URL("../agent/src/experimental.ts", import.meta.url)); export default defineConfig({ test: { @@ -23,6 +24,7 @@ export default defineConfig({ resolve: { alias: [ { find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex }, + { find: /^@earendil-works\/pi-agent-core\/experimental$/, replacement: agentSrcExperimental }, { find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex }, { find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat }, ], diff --git a/packages/storage/sqlite-node/src/index.ts b/packages/storage/sqlite-node/src/index.ts index 05f822e3bbf..98c7fdfa47e 100644 --- a/packages/storage/sqlite-node/src/index.ts +++ b/packages/storage/sqlite-node/src/index.ts @@ -8,6 +8,10 @@ function isNamedParameters(value: unknown): value is Record; @@ -15,7 +19,7 @@ class NodeSqliteStatement implements SqliteStatement { this.statement = statement; } - async run(...params: unknown[]): Promise { + run(...params: unknown[]): SqliteRunResult { const [first, ...rest] = params; const result = isNamedParameters(first) ? this.statement.run(first, ...(rest as SQLInputValue[])) @@ -26,7 +30,7 @@ class NodeSqliteStatement implements SqliteStatement { }; } - async get(...params: unknown[]): Promise { + get(...params: unknown[]): TRow | undefined { const [first, ...rest] = params; return ( isNamedParameters(first) @@ -35,7 +39,7 @@ class NodeSqliteStatement implements SqliteStatement { ) as TRow | undefined; } - async all(...params: unknown[]): Promise { + all(...params: unknown[]): TRow[] { const [first, ...rest] = params; return ( isNamedParameters(first) @@ -52,7 +56,7 @@ class NodeSqliteDatabase implements SqliteDatabase { this.db = db; } - async exec(sql: string): Promise { + exec(sql: string): void { this.db.exec(sql); } @@ -60,10 +64,13 @@ class NodeSqliteDatabase implements SqliteDatabase { return new NodeSqliteStatement(this.db.prepare(sql)); } - async transaction(fn: () => Promise): Promise { - this.db.exec("BEGIN"); + transaction(fn: () => T): T { + this.db.exec("BEGIN IMMEDIATE"); try { - const result = await fn(); + const result = fn(); + if (isAsyncResult(result)) { + throw new TypeError("SQLite transaction callbacks must be synchronous"); + } this.db.exec("COMMIT"); return result; } catch (error) { @@ -76,7 +83,7 @@ class NodeSqliteDatabase implements SqliteDatabase { } } - async close(): Promise { + close(): void { this.db.close(); } } diff --git a/packages/storage/sqlite-node/src/sqlite/branch-cache.ts b/packages/storage/sqlite-node/src/sqlite/branch-cache.ts new file mode 100644 index 00000000000..babb2c1a38d --- /dev/null +++ b/packages/storage/sqlite-node/src/sqlite/branch-cache.ts @@ -0,0 +1,105 @@ +import { SessionError } from "@earendil-works/pi-agent-core/experimental"; +import { uuidv7 } from "@earendil-works/pi-ai"; +import { + copyBranchEntriesThroughSeq, + deleteBranchEntries, + insertBranchEntriesForPath, + insertBranchEntry, + readBranchContainingEntry, +} from "./storage/branch-entries.ts"; + +import { deleteBranchTips, insertBranchTip, readBranchTipBranchId, updateBranchTip } from "./storage/branch-tips.ts"; +import type { SqliteDatabase } from "./types.ts"; + +export function deleteBranchCache(db: SqliteDatabase, sessionId: string) { + deleteBranchTips(db, sessionId); + deleteBranchEntries(db, sessionId); +} + +export function rebuildBranchCache(db: SqliteDatabase, sessionId: string) { + const tips = db + .prepare( + `SELECT leaf.id + FROM entries AS leaf + WHERE leaf.session_id = ? + AND NOT EXISTS ( + SELECT 1 FROM entries AS child WHERE child.session_id = leaf.session_id AND child.parent_id = leaf.id + ) + ORDER BY leaf.seq`, + ) + .all<{ id: string }>(sessionId); + deleteBranchCache(db, sessionId); + for (const tip of tips) buildCachedBranch(db, sessionId, tip.id); +} + +export function buildCachedBranch(db: SqliteDatabase, sessionId: string, leafId: string) { + db.exec("SAVEPOINT build_branch_cache"); + try { + const branchId = uuidv7(); + insertBranchEntriesForPath(db, sessionId, branchId, leafId); + insertBranchTip(db, sessionId, leafId, branchId); + db.exec("RELEASE SAVEPOINT build_branch_cache"); + } catch (error) { + try { + db.exec("ROLLBACK TO SAVEPOINT build_branch_cache"); + db.exec("RELEASE SAVEPOINT build_branch_cache"); + } catch { + // Preserve the original build failure. + } + if (error instanceof SessionError) throw error; + throw new SessionError( + "storage", + `Failed to build SQLite branch cache at entry ${leafId}`, + error instanceof Error ? error : undefined, + ); + } +} + +function extendBranch( + db: SqliteDatabase, + sessionId: string, + branchId: string, + parentId: string, + entryId: string, + entrySeq: number, + entryType: string, + customType: string | null, +) { + insertBranchEntry(db, sessionId, branchId, entryId, entrySeq, entryType, customType); + if (!updateBranchTip(db, sessionId, branchId, parentId, entryId)) { + throw new SessionError("invalid_entry", `Branch tip ${parentId} changed during append`); + } +} + +export function appendEntryToBranchCache( + db: SqliteDatabase, + sessionId: string, + entryId: string, + entrySeq: number, + entryType: string, + customType: string | null, + parentId: string | null, +) { + if (parentId === null) { + const branchId = uuidv7(); + insertBranchEntry(db, sessionId, branchId, entryId, entrySeq, entryType, customType); + insertBranchTip(db, sessionId, entryId, branchId); + return; + } + + const tipBranchId = readBranchTipBranchId(db, sessionId, parentId); + if (tipBranchId !== undefined) { + extendBranch(db, sessionId, tipBranchId, parentId, entryId, entrySeq, entryType, customType); + return; + } + + const source = readBranchContainingEntry(db, sessionId, parentId); + if (!source) { + throw new SessionError("invalid_entry", `Branch cache has no branch containing parent entry ${parentId}`); + } + + const branchId = uuidv7(); + copyBranchEntriesThroughSeq(db, sessionId, branchId, source.branchId, source.entrySeq); + insertBranchEntry(db, sessionId, branchId, entryId, entrySeq, entryType, customType); + insertBranchTip(db, sessionId, entryId, branchId); +} diff --git a/packages/storage/sqlite-node/src/sqlite/index.ts b/packages/storage/sqlite-node/src/sqlite/index.ts index 1c2b8519916..8b5fbbaa074 100644 --- a/packages/storage/sqlite-node/src/sqlite/index.ts +++ b/packages/storage/sqlite-node/src/sqlite/index.ts @@ -1,7 +1,16 @@ export * from "./migrations.ts"; export { + type SqliteSessionCreateOptions, + type SqliteSessionListOptions, + type SqliteSessionMetadata, SqliteSessionRepository, type SqliteSessionRepositoryOptions, + type SqliteWriterLeaseOptions, } from "./repo.ts"; export * from "./search-backend.ts"; -export * from "./types.ts"; +export type { + SqliteDatabase, + SqliteDatabaseFactory, + SqliteRunResult, + SqliteStatement, +} from "./types.ts"; diff --git a/packages/storage/sqlite-node/src/sqlite/migrations.ts b/packages/storage/sqlite-node/src/sqlite/migrations.ts index 08414c0d0c1..a0debe3f061 100644 --- a/packages/storage/sqlite-node/src/sqlite/migrations.ts +++ b/packages/storage/sqlite-node/src/sqlite/migrations.ts @@ -19,16 +19,11 @@ export async function loadMigrations(): Promise { order: 1, sql: await loadMigrationSql("./migrations/001_initial.sql"), }, - { - id: "002_branch_tips.sql", - order: 2, - sql: await loadMigrationSql("./migrations/002_branch_tips.sql"), - }, ]; } -async function ensureMigrationsTable(db: SqliteDatabase): Promise { - await db.exec(` +function ensureMigrationsTable(db: SqliteDatabase): void { + db.exec(` CREATE TABLE IF NOT EXISTS migrations ( id TEXT PRIMARY KEY, applied_at TEXT NOT NULL @@ -37,18 +32,19 @@ CREATE TABLE IF NOT EXISTS migrations ( } export async function applyMigrations(db: SqliteDatabase): Promise { - await ensureMigrationsTable(db); + ensureMigrationsTable(db); const migrations = await loadMigrations(); - const appliedRows = await db.prepare("SELECT id FROM migrations ORDER BY applied_at, id").all<{ id: string }>(); + const appliedRows = db.prepare("SELECT id FROM migrations ORDER BY applied_at, id").all<{ id: string }>(); const applied = new Set(appliedRows.map((row) => row.id)); for (const migration of migrations) { if (applied.has(migration.id)) continue; - await db.transaction(async () => { - await db.exec(migration.sql); - await db - .prepare("INSERT INTO migrations (id, applied_at) VALUES (?, ?)") - .run(migration.id, new Date().toISOString()); + db.transaction(() => { + db.exec(migration.sql); + db.prepare("INSERT INTO migrations (id, applied_at) VALUES (?, ?)").run( + migration.id, + new Date().toISOString(), + ); }); applied.add(migration.id); } diff --git a/packages/storage/sqlite-node/src/sqlite/migrations/001_initial.sql b/packages/storage/sqlite-node/src/sqlite/migrations/001_initial.sql index 6e6f397d92d..3937389a481 100644 --- a/packages/storage/sqlite-node/src/sqlite/migrations/001_initial.sql +++ b/packages/storage/sqlite-node/src/sqlite/migrations/001_initial.sql @@ -3,57 +3,122 @@ CREATE TABLE IF NOT EXISTS sessions ( created_at TEXT NOT NULL, cwd TEXT NOT NULL, parent_session_id TEXT NULL, - metadata TEXT NULL, - active_leaf_id TEXT NULL + metadata TEXT NULL ) WITHOUT ROWID; CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd); CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id); -CREATE TABLE IF NOT EXISTS session_entries ( +CREATE TABLE IF NOT EXISTS entries ( session_id TEXT NOT NULL, + seq INTEGER NOT NULL, id TEXT NOT NULL, - entry_seq INTEGER NOT NULL, parent_id TEXT NULL, type TEXT NOT NULL, timestamp TEXT NOT NULL, payload TEXT NOT NULL, - PRIMARY KEY (session_id, id) + PRIMARY KEY (session_id, id), + UNIQUE (session_id, seq) ); -CREATE UNIQUE INDEX IF NOT EXISTS idx_session_entries_session_seq ON session_entries(session_id, entry_seq); -CREATE INDEX IF NOT EXISTS idx_session_entries_session_parent ON session_entries(session_id, parent_id); -CREATE INDEX IF NOT EXISTS idx_session_entries_session_type ON session_entries(session_id, type); +CREATE INDEX IF NOT EXISTS idx_entries_session_seq ON entries(session_id, seq); +CREATE INDEX IF NOT EXISTS idx_entries_session_parent ON entries(session_id, parent_id); +CREATE INDEX IF NOT EXISTS idx_entries_session_type_seq ON entries(session_id, type, seq); CREATE TABLE IF NOT EXISTS session_sequences ( session_id TEXT PRIMARY KEY, next_seq INTEGER NOT NULL ) WITHOUT ROWID; +CREATE TABLE IF NOT EXISTS session_stats ( + session_id TEXT PRIMARY KEY, + message_count INTEGER NOT NULL, + cached_tokens REAL NOT NULL, + uncached_tokens REAL NOT NULL, + total_tokens REAL NOT NULL, + cost_total REAL NOT NULL +) WITHOUT ROWID; + +-- Derived branch cache. Parent links in entries remain canonical; this cache +-- exists only to make branch scans cheap. CREATE TABLE IF NOT EXISTS branch_entries ( session_id TEXT NOT NULL, branch_id TEXT NOT NULL, entry_id TEXT NOT NULL, entry_seq INTEGER NOT NULL, + entry_type TEXT NULL, + custom_type TEXT NULL, PRIMARY KEY (session_id, branch_id, entry_id) ) WITHOUT ROWID; -CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch ON branch_entries(session_id, branch_id); CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch_seq ON branch_entries(session_id, branch_id, entry_seq); CREATE INDEX IF NOT EXISTS idx_branch_entries_session_entry ON branch_entries(session_id, entry_id); +CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch_type_seq ON branch_entries(session_id, branch_id, entry_type, entry_seq); +CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch_custom_seq ON branch_entries(session_id, branch_id, custom_type, entry_seq); -CREATE TABLE IF NOT EXISTS session_materialized ( - session_id TEXT PRIMARY KEY, - payload TEXT NOT NULL +CREATE TABLE IF NOT EXISTS lanes ( + session_id TEXT NOT NULL, + lane TEXT NOT NULL, + leaf_id TEXT NULL, + PRIMARY KEY (session_id, lane) ) WITHOUT ROWID; -CREATE TABLE IF NOT EXISTS entry_materialized ( +CREATE INDEX IF NOT EXISTS idx_lanes_session_leaf ON lanes(session_id, leaf_id); + +CREATE TABLE IF NOT EXISTS records ( session_id TEXT NOT NULL, - entry_seq INTEGER NOT NULL, + seq INTEGER NOT NULL, + id TEXT NOT NULL, + lane TEXT NOT NULL, + run_id TEXT NULL, type TEXT NOT NULL, + op_kind TEXT NULL, + timestamp TEXT NOT NULL, payload TEXT NOT NULL, - PRIMARY KEY (session_id, entry_seq, type) + PRIMARY KEY (session_id, id), + UNIQUE (session_id, seq) ) WITHOUT ROWID; -CREATE INDEX IF NOT EXISTS idx_entry_materialized_session_type_seq ON entry_materialized(session_id, type, entry_seq); +CREATE INDEX IF NOT EXISTS idx_records_session_seq ON records(session_id, seq); +CREATE INDEX IF NOT EXISTS idx_records_session_lane_type_seq ON records(session_id, lane, type, seq); +CREATE INDEX IF NOT EXISTS idx_records_session_lane_type_op_kind_seq ON records(session_id, lane, type, op_kind, seq); +CREATE INDEX IF NOT EXISTS idx_records_session_run_id_seq ON records(session_id, run_id, seq); + +CREATE TABLE IF NOT EXISTS lane_moves ( + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + lane TEXT NOT NULL, + leaf_id TEXT NULL, + PRIMARY KEY (session_id, seq) +) WITHOUT ROWID; + +CREATE INDEX IF NOT EXISTS idx_lane_moves_session_lane_seq ON lane_moves(session_id, lane, seq); + +CREATE TABLE IF NOT EXISTS facts ( + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + kind TEXT NOT NULL, + key TEXT NULL, + value TEXT NULL, + PRIMARY KEY (session_id, seq) +) WITHOUT ROWID; + +CREATE INDEX IF NOT EXISTS idx_facts_session_kind_key_seq ON facts(session_id, kind, key, seq); + +CREATE TABLE IF NOT EXISTS branch_tips ( + session_id TEXT NOT NULL, + tip_id TEXT NOT NULL, + branch_id TEXT NOT NULL, + PRIMARY KEY (session_id, tip_id), + UNIQUE (session_id, branch_id) +) WITHOUT ROWID; + +-- Per-session writer claim. The fence prevents an expired owner from writing +-- after a new owner takes over the session. +CREATE TABLE IF NOT EXISTS leases ( + session_id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + fence INTEGER NOT NULL, + expires_at_ms INTEGER NOT NULL +) WITHOUT ROWID; diff --git a/packages/storage/sqlite-node/src/sqlite/migrations/002_branch_tips.sql b/packages/storage/sqlite-node/src/sqlite/migrations/002_branch_tips.sql deleted file mode 100644 index aba7c939762..00000000000 --- a/packages/storage/sqlite-node/src/sqlite/migrations/002_branch_tips.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE IF NOT EXISTS branch_tips ( - session_id TEXT NOT NULL, - tip_id TEXT NOT NULL, - branch_id TEXT NOT NULL, - PRIMARY KEY (session_id, tip_id), - UNIQUE (session_id, branch_id) -) WITHOUT ROWID; - -DELETE FROM branch_tips; -DELETE FROM branch_entries; - -DROP INDEX IF EXISTS idx_branch_entries_session_branch; diff --git a/packages/storage/sqlite-node/src/sqlite/repo.ts b/packages/storage/sqlite-node/src/sqlite/repo.ts index f38e48dc597..abf78d2e244 100644 --- a/packages/storage/sqlite-node/src/sqlite/repo.ts +++ b/packages/storage/sqlite-node/src/sqlite/repo.ts @@ -1,51 +1,142 @@ -import type { - SessionForkOptions, - SessionForkSelection, - SessionRepository, - SessionStorage, - SessionTreeEntry, -} from "@earendil-works/pi-agent-core"; +import type { FileError, FileSystem, Result } from "@earendil-works/pi-agent-core"; import { - createSession, - createSessionForkSelection, - createSessionId, - getFileSystemResultOrThrow, - readSessionEntriesForFork, - type Session, - type SessionContextBuildOptions, + type BranchBounds, + type Entry, + type EntryQuery, + type ForkOptions, + type LaneRecord, + type LogItem, + type LogOptions, + type NewRecord, + type ProvisionedEntry, + type RecordQuery, + Session, + type SessionCreateOptions, SessionError, -} from "@earendil-works/pi-agent-core"; + type SessionMetadata, + type SessionRepo as SessionRepository, + type SessionStats, + type SessionStorage, +} from "@earendil-works/pi-agent-core/experimental"; +import { uuidv7 } from "@earendil-works/pi-ai"; +import { appendEntryToBranchCache, buildCachedBranch, deleteBranchCache, rebuildBranchCache } from "./branch-cache.ts"; import { applyMigrations } from "./migrations.ts"; -import { SqliteSessionConnection } from "./storage/index.ts"; -import { rowToMetadata, type SessionRow } from "./storage/sessions.ts"; -import type { - SqliteDatabase, - SqliteDatabaseFactory, - SqliteSessionCreateOptions, - SqliteSessionListOptions, - SqliteSessionMetadata, - SqliteSessionRepositoryEnv, -} from "./types.ts"; +import { type CachedBranchEntryRow, queryCachedBranchRows, readCachedBranch } from "./storage/branch-entries.ts"; +import { readBranchTipIds } from "./storage/branch-tips.ts"; +import { + deleteEntryRows, + type EntryRow, + entryPayload, + idExistsInEntries, + insertEntryRow, + readEntryRow, + readEntryRows, +} from "./storage/entries.ts"; +import { appendFact, deleteFactRows, readFactRows, readLatestFact, readLatestLabelFacts } from "./storage/facts.ts"; +import { + createInitialLane, + deleteLaneRows, + createLane as insertLane, + readLane, + readLaneHead, + readLaneMoveRows, + readLanes, + setLaneLeaf, + moveLane as updateLane, +} from "./storage/lanes.ts"; +import { + acquireSessionLease, + deleteSessionLease, + releaseSessionLease, + renewSessionLease, + type SessionLease, +} from "./storage/leases.ts"; +import { appendRecordRow, deleteRecordRows, idExistsInRecords, readRecordRows } from "./storage/records.ts"; +import { + advanceSequence, + createSequence, + deleteSequence, + getNextSequence, + setNextSequence, +} from "./storage/session-sequences.ts"; +import { + addUsageToStats, + createStats, + deleteStats, + incrementMessageCount, + readStats, +} from "./storage/session-stats.ts"; +import { + deleteSessionRow, + insertSessionRow, + readSessionRow, + readSessionRows, + rowToMetadata, + type SessionRow, + sessionExists, +} from "./storage/sessions.ts"; +import type { SqliteDatabase, SqliteDatabaseFactory } from "./types.ts"; + +export interface SqliteSessionMetadata extends SessionMetadata { + cwd: string; + path: string; + metadata?: Record; +} -function getParentPath(path: string): string { - const normalized = path.replace(/[\\/]+$/, ""); - const lastSlash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); - if (lastSlash < 0) return "."; - if (lastSlash === 0) return normalized.slice(0, 1); - return normalized.slice(0, lastSlash); +export interface SqliteSessionCreateOptions extends SessionCreateOptions { + cwd: string; + metadata?: Record; } -async function configureSqliteDatabase(db: SqliteDatabase): Promise { - await db.exec("PRAGMA journal_mode=WAL"); - await db.exec("PRAGMA synchronous=FULL"); - await db.exec("PRAGMA busy_timeout=5000"); +export interface SqliteSessionListOptions { + cwd?: string; } -export type SqliteSessionBackendOptions = { +export interface SqliteWriterLeaseOptions { + /** Time without a successful heartbeat before another writer may take over. Default: 30 seconds. */ + ttlMs?: number; + /** Idle heartbeat cadence. Default: 10 seconds. Must be less than ttlMs. */ + heartbeatIntervalMs?: number; +} + +export type SqliteSessionRepositoryEnv = Pick; + +export interface SqliteSessionRepositoryOptions { env: SqliteSessionRepositoryEnv; sqlite: SqliteDatabaseFactory; databasePath: string; -}; + writerLease?: SqliteWriterLeaseOptions; +} + +interface ResolvedWriterLeaseOptions { + ttlMs: number; + heartbeatIntervalMs: number; +} + +function resolveWriterLeaseOptions(options: SqliteWriterLeaseOptions | undefined): ResolvedWriterLeaseOptions { + const ttlMs = options?.ttlMs ?? 30_000; + const heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 10_000; + if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) throw new RangeError("writerLease.ttlMs must be positive"); + if (!Number.isSafeInteger(heartbeatIntervalMs) || heartbeatIntervalMs <= 0 || heartbeatIntervalMs >= ttlMs) { + throw new RangeError("writerLease.heartbeatIntervalMs must be positive and less than ttlMs"); + } + return { ttlMs, heartbeatIntervalMs }; +} + +function activeWriterError(sessionId: string): SessionError { + return new SessionError("storage", `SQLite session ${sessionId} already has an active writer`); +} + +function lostWriterError(sessionId: string): SessionError { + return new SessionError("storage", `SQLite session ${sessionId} writer lease was lost`); +} + +function acquireWriterLease(db: SqliteDatabase, sessionId: string, options: ResolvedWriterLeaseOptions): SessionLease { + const now = Date.now(); + const lease = acquireSessionLease(db, sessionId, uuidv7(), now, now + options.ttlMs); + if (!lease) throw activeWriterError(sessionId); + return lease; +} class SerialOperationQueue { private tail: Promise = Promise.resolve(); @@ -64,264 +155,771 @@ class SerialOperationQueue { } } -class SqliteSessionBackend { - private readonly env: SqliteSessionRepositoryEnv; - private readonly sqlite: SqliteDatabaseFactory; - private readonly databasePathInput: string; - private databasePath: string | undefined; - private databasePromise: Promise | undefined; - private database: SqliteDatabase | undefined; - private disposed = false; - private disposePromise: Promise | undefined; +function resultOrThrow(result: Result, message: string): T { + if (!result.ok) { + const code = result.error.code === "not_found" ? "not_found" : "storage"; + throw new SessionError(code, `${message}: ${result.error.message}`, result.error); + } + return result.value; +} + +function getParentPath(path: string): string { + const normalized = path.replace(/[\\/]+$/, ""); + const lastSlash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); + if (lastSlash < 0) return "."; + if (lastSlash === 0) return normalized.slice(0, 1); + return normalized.slice(0, lastSlash); +} + +function configureSqliteDatabase(db: SqliteDatabase): void { + db.exec("PRAGMA journal_mode=WAL"); + db.exec("PRAGMA synchronous=FULL"); + db.exec("PRAGMA busy_timeout=5000"); +} + +function timestampToText(timestamp: number): string { + return new Date(timestamp).toISOString(); +} + +function timestampFromText(timestamp: string): number { + return Date.parse(timestamp); +} + +function entryRowFromCached(row: CachedBranchEntryRow): EntryRow { + return { ...row, seq: row.entry_seq, type: row.type as Entry["type"] }; +} + +function readObjectPayload(row: EntryRow): Record { + const payload = JSON.parse(row.payload) as unknown; + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + throw new Error("Payload is not an object"); + } + return payload as Record; +} + +function decodeEntry(row: EntryRow): Entry { + try { + const payload = readObjectPayload(row); + const timestamp = timestampFromText(row.timestamp); + if (!Number.isFinite(timestamp)) throw new Error(`Invalid timestamp ${row.timestamp}`); + const base = { id: row.id, seq: row.seq, parentId: row.parent_id, timestamp }; + switch (row.type) { + case "message": + if (typeof payload.message !== "object" || payload.message === null) throw new Error("Missing message"); + return { + ...base, + type: "message", + message: payload.message as Extract["message"], + ...(payload.terminate === true ? { terminate: true as const } : {}), + }; + case "model_change": + if (typeof payload.provider !== "string" || typeof payload.modelId !== "string") { + throw new Error("Invalid model_change payload"); + } + return { ...base, type: "model_change", provider: payload.provider, modelId: payload.modelId }; + case "thinking_level_change": + if (typeof payload.thinkingLevel !== "string") throw new Error("Invalid thinking_level_change payload"); + return { ...base, type: "thinking_level_change", thinkingLevel: payload.thinkingLevel }; + case "active_tools_change": + if (!Array.isArray(payload.activeToolNames)) throw new Error("Invalid active_tools_change payload"); + if (payload.activeToolNames.some((value) => typeof value !== "string")) { + throw new Error("Invalid active_tools_change payload"); + } + return { ...base, type: "active_tools_change", activeToolNames: payload.activeToolNames }; + case "compaction": + if ( + typeof payload.summary !== "string" || + !Array.isArray(payload.retainedTail) || + typeof payload.tokensBefore !== "number" + ) { + throw new Error("Invalid compaction payload"); + } + return { + ...base, + type: "compaction", + summary: payload.summary, + retainedTail: payload.retainedTail as Extract["retainedTail"], + tokensBefore: payload.tokensBefore, + ...(Object.hasOwn(payload, "details") ? { details: payload.details } : {}), + ...(Object.hasOwn(payload, "usage") + ? { usage: payload.usage as Extract["usage"] } + : {}), + }; + case "branch_summary": + if (typeof payload.fromId !== "string" || typeof payload.summary !== "string") { + throw new Error("Invalid branch_summary payload"); + } + return { + ...base, + type: "branch_summary", + fromId: payload.fromId, + summary: payload.summary, + ...(Object.hasOwn(payload, "details") ? { details: payload.details } : {}), + ...(Object.hasOwn(payload, "usage") + ? { usage: payload.usage as Extract["usage"] } + : {}), + }; + case "custom": + if (typeof payload.customType !== "string") throw new Error("Invalid custom payload"); + return { + ...base, + type: "custom", + customType: payload.customType, + ...(Object.hasOwn(payload, "data") ? { data: payload.data } : {}), + }; + } + } catch (error) { + throw new SessionError( + "invalid_entry", + `Invalid SQLite session entry ${row.id}: failed to decode entry ${row.id}`, + error instanceof Error ? error : undefined, + ); + } +} + +function recordRunId(record: NewRecord): string | undefined { + return record.type === "operation_started" ? record.id : "runId" in record ? record.runId : undefined; +} + +function recordOpKind(record: NewRecord): string | undefined { + return record.type === "operation_started" ? record.intent.kind : undefined; +} + +function decodeRecord(row: { seq: number; timestamp: string; payload: string }): LaneRecord { + try { + const timestamp = timestampFromText(row.timestamp); + if (!Number.isFinite(timestamp)) throw new Error(`Invalid timestamp ${row.timestamp}`); + return { + ...(JSON.parse(row.payload) as object), + seq: row.seq, + timestamp, + } as LaneRecord; + } catch (error) { + throw new SessionError( + "storage", + `Invalid SQLite session record at sequence ${row.seq}: failed to decode payload`, + error instanceof Error ? error : undefined, + ); + } +} + +function validateCachedBranchRows(rows: readonly CachedBranchEntryRow[], query: BranchBounds): void { + if (rows.length === 0) return; + const path = [...rows].sort((left, right) => left.entry_seq - right.entry_seq); + if (query.stopAtId === undefined && query.stopAtType === undefined && path[0]?.parent_id !== null) { + throw new SessionError("invalid_entry", `Entry ${path[0]?.parent_id} not found`); + } + for (let index = 1; index < path.length; index++) { + const previous = path[index - 1]!; + const current = path[index]!; + if (current.parent_id !== previous.id) { + throw new SessionError("invalid_entry", `Entry ${current.parent_id} not found`); + } + } +} + +function matchesEntryQuery(entry: Entry, query: EntryQuery): boolean { + return ( + (query.type === undefined || entry.type === query.type) && + (query.customType === undefined || (entry.type === "custom" && entry.customType === query.customType)) && + (query.cursor === undefined || + (query.order === "oldestFirst" ? entry.seq > query.cursor.afterSeq : entry.seq < query.cursor.afterSeq)) + ); +} + +function assertUnusedId(db: SqliteDatabase, sessionId: string, id: string): void { + if (idExistsInEntries(db, sessionId, id) || idExistsInRecords(db, sessionId, id)) { + throw new SessionError("already_exists", `ID already exists: ${id}`); + } +} + +function requireSessionRow(db: SqliteDatabase, sessionId: string): SessionRow { + const row = readSessionRow(db, sessionId); + if (!row) throw new SessionError("not_found", `Session not found: ${sessionId}`); + return row; +} + +class SqliteSessionStorage implements SessionStorage { + private readonly db: SqliteDatabase; + private readonly metadata: SqliteSessionMetadata; + private readonly lease: SessionLease; + private readonly leaseOptions: ResolvedWriterLeaseOptions; + private readonly onRelease: () => void; private readonly operations = new SerialOperationQueue(); - private readonly writers = new Map(); + private heartbeatTimer: ReturnType | undefined; + private leaseError: SessionError | undefined; + private closing = false; + private releasePromise: Promise | undefined; - constructor(options: SqliteSessionBackendOptions) { - this.env = options.env; - this.sqlite = options.sqlite; - this.databasePathInput = options.databasePath; + constructor( + db: SqliteDatabase, + metadata: SqliteSessionMetadata, + lease: SessionLease, + leaseOptions: ResolvedWriterLeaseOptions, + onRelease: () => void, + ) { + this.db = db; + this.metadata = metadata; + this.lease = lease; + this.leaseOptions = leaseOptions; + this.onRelease = onRelease; + this.scheduleHeartbeat(); } - create(options: SqliteSessionCreateOptions): Promise> { - this.assertOpen(); - return this.operations.enqueue(async () => { - const db = await this.getDatabase(); - const path = await this.getDatabasePath(); - const connection = await db.transaction(() => - SqliteSessionConnection.create(db, path, { - cwd: options.cwd, - sessionId: options.id ?? createSessionId(), - parentSessionId: options.parentSessionId, - metadata: options.metadata, - }), + async release(): Promise { + this.releasePromise ??= this.finishRelease(); + await this.releasePromise; + } + + private async finishRelease(): Promise { + this.closing = true; + if (this.heartbeatTimer !== undefined) clearTimeout(this.heartbeatTimer); + try { + await this.operations.enqueue(() => + this.db.transaction(() => releaseSessionLease(this.db, this.metadata.id, this.lease)), ); - this.writers.set(connection.metadata.id, connection); - return this.storage(connection); + } finally { + this.onRelease(); + } + } + + private enqueueWrite(operation: () => T): Promise { + if (this.closing) + return Promise.reject(new SessionError("storage", `SQLite session ${this.metadata.id} is closed`)); + return this.operations.enqueue(() => { + if (this.leaseError) throw this.leaseError; + return this.db.transaction(() => { + const now = Date.now(); + if (!renewSessionLease(this.db, this.metadata.id, this.lease, now, now + this.leaseOptions.ttlMs)) { + this.leaseError = lostWriterError(this.metadata.id); + if (this.heartbeatTimer !== undefined) clearTimeout(this.heartbeatTimer); + throw this.leaseError; + } + return operation(); + }); }); } - open(metadata: SqliteSessionMetadata): Promise> { - this.assertOpen(); - return this.operations.enqueue(() => this.loadSession(metadata)); + private scheduleHeartbeat(): void { + if (this.closing || this.leaseError) return; + this.heartbeatTimer = setTimeout(async () => { + this.heartbeatTimer = undefined; + try { + await this.operations.enqueue(() => { + if (this.closing || this.leaseError) return; + this.db.transaction(() => { + const now = Date.now(); + if (!renewSessionLease(this.db, this.metadata.id, this.lease, now, now + this.leaseOptions.ttlMs)) { + this.leaseError = lostWriterError(this.metadata.id); + } + }); + }); + } catch { + // A transient heartbeat failure is retried. Every write still verifies ownership transactionally. + } finally { + this.scheduleHeartbeat(); + } + }, this.leaseOptions.heartbeatIntervalMs); + this.heartbeatTimer.unref(); + } + + async getMetadata(): Promise { + return structuredClone(this.metadata); } - private async loadSession(metadata: SqliteSessionMetadata): Promise> { - if ( - !getFileSystemResultOrThrow(await this.env.exists(metadata.path), `Failed to check database ${metadata.path}`) - ) { - throw new SessionError("not_found", `Session not found: ${metadata.id}`); - } - const connection = - this.writers.get(metadata.id) ?? (await SqliteSessionConnection.open(await this.getDatabase(), metadata)); - this.writers.set(metadata.id, connection); - return this.storage(connection); + isForSession(sessionId: string): boolean { + return this.metadata.id === sessionId; } - list(options: SqliteSessionListOptions = {}): Promise { - this.assertOpen(); - return this.operations.enqueue(() => this.listSessions(options)); + async getLanes(): Promise<{ lane: string; leafId: string | null }[]> { + return readLanes(this.db, this.metadata.id).map((row) => ({ lane: row.lane, leafId: row.leaf_id })); } - private async listSessions(options: SqliteSessionListOptions): Promise { - const path = await this.getDatabasePath(); - if (!getFileSystemResultOrThrow(await this.env.exists(path), `Failed to check database ${path}`)) return []; - const db = await this.getDatabase(); - const rows = options.cwd - ? await db - .prepare( - "SELECT id, created_at, metadata, cwd, parent_session_id, active_leaf_id FROM sessions WHERE cwd = ? ORDER BY created_at DESC", - ) - .all(options.cwd) - : await db - .prepare( - "SELECT id, created_at, metadata, cwd, parent_session_id, active_leaf_id FROM sessions ORDER BY created_at DESC", - ) - .all(); - return rows.map((row) => rowToMetadata(row, path)); - } - - private appendEntry(metadata: SqliteSessionMetadata, entry: SessionTreeEntry): Promise { - this.assertOpen(); - return this.operations.enqueue(async () => { - const connection = - this.writers.get(metadata.id) ?? (await SqliteSessionConnection.open(await this.getDatabase(), metadata)); - this.writers.set(metadata.id, connection); - await connection.appendEntry(entry); + async createLane(lane: string, at: string | null): Promise { + return this.enqueueWrite(() => { + if (readLane(this.db, this.metadata.id, lane)) { + throw new SessionError("already_exists", `Lane already exists: ${lane}`); + } + if (at !== null && !readEntryRow(this.db, this.metadata.id, at)) { + throw new SessionError("not_found", `Entry not found: ${at}`); + } + const seq = getNextSequence(this.db, this.metadata.id); + insertLane(this.db, this.metadata.id, seq, lane, at); + advanceSequence(this.db, this.metadata.id, seq); }); } - delete(metadata: SqliteSessionMetadata): Promise { - this.assertOpen(); - return this.operations.enqueue(async () => { - const db = await this.getDatabase(); - await db.transaction(async () => { - await db.prepare("DELETE FROM branch_tips WHERE session_id = ?").run(metadata.id); - await db.prepare("DELETE FROM branch_entries WHERE session_id = ?").run(metadata.id); - await db.prepare("DELETE FROM session_entries WHERE session_id = ?").run(metadata.id); - await db.prepare("DELETE FROM entry_materialized WHERE session_id = ?").run(metadata.id); - await db.prepare("DELETE FROM session_materialized WHERE session_id = ?").run(metadata.id); - await db.prepare("DELETE FROM session_sequences WHERE session_id = ?").run(metadata.id); - const result = await db.prepare("DELETE FROM sessions WHERE id = ?").run(metadata.id); - if (result.changes === 0) throw new SessionError("not_found", `Session not found: ${metadata.id}`); + async moveLane(lane: string, to: string | null): Promise { + return this.enqueueWrite(() => { + if (!readLane(this.db, this.metadata.id, lane)) + throw new SessionError("invalid_lane", `Lane not found: ${lane}`); + if (to !== null && !readEntryRow(this.db, this.metadata.id, to)) { + throw new SessionError("not_found", `Entry not found: ${to}`); + } + const seq = getNextSequence(this.db, this.metadata.id); + updateLane(this.db, this.metadata.id, seq, lane, to); + advanceSequence(this.db, this.metadata.id, seq); + }); + } + + async appendEntry(entry: ProvisionedEntry, lane: string): Promise { + return this.enqueueWrite(() => { + const parentId = readLaneHead(this.db, this.metadata.id, lane).leafId; + assertUnusedId(this.db, this.metadata.id, entry.id); + const seq = getNextSequence(this.db, this.metadata.id); + const committed = { ...entry, parentId, seq, timestamp: Date.now() } as Entry; + insertEntryRow(this.db, this.metadata.id, { + seq, + id: committed.id, + parentId: committed.parentId, + type: committed.type, + timestamp: timestampToText(committed.timestamp), + payload: JSON.stringify(entryPayload(committed)), }); - this.writers.delete(metadata.id); + setLaneLeaf(this.db, this.metadata.id, lane, committed.id); + appendEntryToBranchCache( + this.db, + this.metadata.id, + committed.id, + seq, + committed.type, + committed.type === "custom" ? committed.customType : null, + committed.parentId, + ); + if (committed.type === "message") incrementMessageCount(this.db, this.metadata.id); + advanceSequence(this.db, this.metadata.id, seq); + return structuredClone(committed as TEntry); }); } - fork( - source: SqliteSessionMetadata, - options: SqliteSessionCreateOptions, - selection: SessionForkSelection, - ): Promise> { - this.assertOpen(); - return this.operations.enqueue(async () => { - const db = await this.getDatabase(); - const connection = await db.transaction(async () => { - const sourceConnection = this.writers.get(source.id) ?? (await SqliteSessionConnection.open(db, source)); - this.writers.set(source.id, sourceConnection); - const entries = await readSessionEntriesForFork(sourceConnection, selection); - const connection = await SqliteSessionConnection.create(db, await this.getDatabasePath(), { - cwd: options.cwd, - sessionId: options.id ?? createSessionId(), - parentSessionId: options.parentSessionId ?? source.id, - metadata: options.metadata ?? source.metadata, - }); - for (const entry of entries) await connection.appendEntry(entry, { transaction: false }); - return connection; + async appendRecord(record: NewRecord): Promise; + async appendRecord(record: NewRecord): Promise { + return this.enqueueWrite(() => { + if (!readLane(this.db, this.metadata.id, record.lane)) { + throw new SessionError("invalid_lane", `Lane not found: ${record.lane}`); + } + assertUnusedId(this.db, this.metadata.id, record.id); + const seq = getNextSequence(this.db, this.metadata.id); + const committed: LaneRecord = { ...record, seq, timestamp: Date.now() }; + appendRecordRow(this.db, this.metadata.id, { + seq, + id: record.id, + lane: record.lane, + runId: recordRunId(record), + type: record.type, + opKind: recordOpKind(record), + timestamp: timestampToText(committed.timestamp), + payload: JSON.stringify(record), }); - this.writers.set(connection.metadata.id, connection); - return this.storage(connection); + if (record.type === "usage") addUsageToStats(this.db, this.metadata.id, record.usage); + advanceSequence(this.db, this.metadata.id, seq); + return structuredClone(committed); }); } - async [Symbol.asyncDispose](): Promise { - if (!this.disposePromise) { - this.disposed = true; - this.disposePromise = this.finishDisposal(); + async getEntry(id: string): Promise { + const row = readEntryRow(this.db, this.metadata.id, id); + return row ? decodeEntry(row) : undefined; + } + + async findEntries(query: EntryQuery = {}): Promise { + const rows = readEntryRows(this.db, this.metadata.id, { order: query.order }); + const entries = rows.map(decodeEntry).filter((entry) => matchesEntryQuery(entry, query)); + return structuredClone(query.limit === undefined ? entries : entries.slice(0, query.limit)); + } + + async findEntriesOnBranch(query: EntryQuery & BranchBounds & { start: string }): Promise { + const cached = readCachedBranch(this.db, this.metadata.id, query.start); + if (!cached) { + if (!readEntryRow(this.db, this.metadata.id, query.start)) + throw new SessionError("not_found", `Entry not found: ${query.start}`); + throw new SessionError("invalid_entry", `Branch cache missing entry ${query.start}`); } - await this.disposePromise; + const rows = queryCachedBranchRows(this.db, this.metadata.id, cached, query); + validateCachedBranchRows(rows, query); + const entries = rows + .map(entryRowFromCached) + .map(decodeEntry) + .filter((entry) => matchesEntryQuery(entry, query)); + return structuredClone(query.limit === undefined ? entries : entries.slice(0, query.limit)); } - private async finishDisposal(): Promise { - await this.operations.drain(); - const db = this.database ?? (this.databasePromise ? await this.databasePromise : undefined); - this.database = undefined; - this.databasePromise = undefined; - this.writers.clear(); - if (db) await db.close(); + async findRecords(query: RecordQuery = {}): Promise { + const rows = readRecordRows(this.db, this.metadata.id, query); + return structuredClone(rows.map(decodeRecord)); } - private assertOpen(): void { - if (this.disposed) throw new SessionError("storage", "SQLite session repository is disposed"); + async getLog(options: LogOptions = {}): Promise { + const afterSeq = options.afterSeq ?? 0; + const entryRows = readEntryRows(this.db, this.metadata.id, { afterSeq, order: "oldestFirst" }); + const recordRows = readRecordRows(this.db, this.metadata.id, { afterSeq }); + const laneRows = readLaneMoveRows(this.db, this.metadata.id, { afterSeq }); + const factRows = readFactRows(this.db, this.metadata.id, { afterSeq }); + + const log: LogItem[] = [ + ...entryRows.map((row) => ({ kind: "entry" as const, seq: row.seq, entry: decodeEntry(row) })), + ...recordRows.map((row) => ({ kind: "record" as const, seq: row.seq, record: decodeRecord(row) })), + ...laneRows.map((row) => ({ kind: "lane" as const, seq: row.seq, lane: row.lane, leafId: row.leaf_id })), + ...factRows.map((row) => { + if (row.kind === "name") + return { + kind: "fact" as const, + seq: row.seq, + fact: "name" as const, + name: JSON.parse(row.value ?? "null") as string, + }; + return { + kind: "fact" as const, + seq: row.seq, + fact: "label" as const, + targetId: row.key ?? "", + label: row.value === null ? undefined : (JSON.parse(row.value) as string), + }; + }), + ].sort((left, right) => left.seq - right.seq); + return structuredClone(options.limit === undefined ? log : log.slice(0, options.limit)); } - private storage(connection: SqliteSessionConnection): SessionStorage { - const metadata = connection.metadata; - return { - metadata, - readHead: () => this.read(metadata, (current) => current.readHead()), - readEntry: (id) => this.read(metadata, (current) => current.readEntry(id)), - readEntries: (options) => this.read(metadata, (current) => current.readEntries(options)), - appendEntry: (entry) => this.appendEntry(metadata, entry), - findEntriesOnBranch: (query) => this.read(metadata, (current) => current.findEntriesOnBranch(query)), - readPathToRootOrCompaction: (leafId) => - this.read(metadata, (current) => current.readPathToRootOrCompaction(leafId)), - getLabel: (id) => this.read(metadata, (current) => current.getLabel(id)), - getName: () => this.read(metadata, (current) => current.getName()), - getStats: () => this.read(metadata, (current) => current.getStats()), - }; - } - - private read( - metadata: SqliteSessionMetadata, - read: (connection: SqliteSessionConnection) => Promise, - ): Promise { - this.assertOpen(); - return this.operations.enqueue(async () => { - const connection = - this.writers.get(metadata.id) ?? (await SqliteSessionConnection.open(await this.getDatabase(), metadata)); - this.writers.set(metadata.id, connection); - return read(connection); + async getName(): Promise { + const row = readLatestFact(this.db, this.metadata.id, "name", null); + return row?.value === undefined || row.value === null ? undefined : (JSON.parse(row.value) as string); + } + + async setName(name: string): Promise { + return this.enqueueWrite(() => { + const seq = getNextSequence(this.db, this.metadata.id); + appendFact(this.db, this.metadata.id, seq, "name", null, JSON.stringify(name)); + advanceSequence(this.db, this.metadata.id, seq); }); } - private async getDatabasePath(): Promise { - this.databasePath ??= getFileSystemResultOrThrow( - await this.env.absolutePath(this.databasePathInput), - `Failed to resolve SQLite sessions database ${this.databasePathInput}`, - ); - return this.databasePath; + async getLabel(id: string): Promise { + const row = readLatestFact(this.db, this.metadata.id, "label", id); + return row?.value === undefined || row.value === null ? undefined : (JSON.parse(row.value) as string); } - private async getDatabase(): Promise { - if (!this.databasePromise) this.databasePromise = this.openDatabase(); - this.database = await this.databasePromise; - return this.database; + async setLabel(id: string, label: string | undefined): Promise { + return this.enqueueWrite(() => { + if (!readEntryRow(this.db, this.metadata.id, id)) { + throw new SessionError("not_found", `Entry not found: ${id}`); + } + const seq = getNextSequence(this.db, this.metadata.id); + appendFact(this.db, this.metadata.id, seq, "label", id, label === undefined ? null : JSON.stringify(label)); + advanceSequence(this.db, this.metadata.id, seq); + }); } - private async openDatabase(): Promise { - const path = await this.getDatabasePath(); - const directory = getParentPath(path); - getFileSystemResultOrThrow( - await this.env.createDir(directory, { recursive: true }), - `Failed to create SQLite sessions directory ${directory}`, - ); - const db = await this.sqlite.open(path); - try { - await configureSqliteDatabase(db); - await applyMigrations(db); - return db; - } catch (error) { - await db.close(); - throw error; - } + async getStats(): Promise { + return readStats(this.db, this.metadata.id); } } -export interface SqliteSessionRepositoryOptions extends SqliteSessionBackendOptions { - contextBuildOptions?: SessionContextBuildOptions; +function claimStorage( + db: SqliteDatabase, + metadata: SqliteSessionMetadata, + leaseOptions: ResolvedWriterLeaseOptions, + onRelease: () => void, +): SqliteSessionStorage { + requireSessionRow(db, metadata.id); + const claimed = db.transaction(() => { + const lease = acquireWriterLease(db, metadata.id, leaseOptions); + const row = requireSessionRow(db, metadata.id); + readLanes(db, metadata.id); + return { lease, row }; + }); + return new SqliteSessionStorage( + db, + metadataFromRow(claimed.row, metadata.path), + claimed.lease, + leaseOptions, + onRelease, + ); +} + +function metadataFromRow(row: SessionRow, path: string): SqliteSessionMetadata { + const base = rowToMetadata(row, path); + return { ...base, createdAt: Date.parse(base.createdAt) }; } export class SqliteSessionRepository implements SessionRepository { - private readonly backend: SqliteSessionBackend; - private readonly contextBuildOptions: SessionContextBuildOptions; + private databasePath: string | undefined; + private database: SqliteDatabase | undefined; + private databasePromise: Promise | undefined; + private readonly operations = new SerialOperationQueue(); + private readonly activeStorages = new Set(); + private readonly options: SqliteSessionRepositoryOptions; + private readonly leaseOptions: ResolvedWriterLeaseOptions; constructor(options: SqliteSessionRepositoryOptions) { - const { contextBuildOptions, ...backendOptions } = options; - this.backend = new SqliteSessionBackend(backendOptions); - this.contextBuildOptions = contextBuildOptions ?? {}; + this.options = options; + this.leaseOptions = resolveWriterLeaseOptions(options.writerLease); + } + + private async releaseStoragesForSession(sessionId: string): Promise { + for (const storage of [...this.activeStorages]) { + if (storage.isForSession(sessionId)) await storage.release(); + } + } + + private sessionFromLease( + db: SqliteDatabase, + metadata: SqliteSessionMetadata, + lease: SessionLease, + ): Session { + let storage: SqliteSessionStorage; + storage = new SqliteSessionStorage(db, metadata, lease, this.leaseOptions, () => { + this.activeStorages.delete(storage); + }); + this.activeStorages.add(storage); + return new Session(storage); + } + + private claimSession(db: SqliteDatabase, metadata: SqliteSessionMetadata): Session { + const active = [...this.activeStorages].find((storage) => storage.isForSession(metadata.id)); + if (active) { + readLanes(db, metadata.id); + return new Session(active); + } + let storage: SqliteSessionStorage; + storage = claimStorage(db, metadata, this.leaseOptions, () => { + this.activeStorages.delete(storage); + }); + this.activeStorages.add(storage); + return new Session(storage); } async create(options: SqliteSessionCreateOptions): Promise> { - return createSession(await this.backend.create(options), this.contextBuildOptions); + return this.operations.enqueue(async () => { + const db = await this.getDatabase(); + const path = await this.getDatabasePath(); + const id = options.id ?? uuidv7(); + if (sessionExists(db, id)) throw new SessionError("already_exists", `Session already exists: ${id}`); + const createdAt = Date.now(); + const lease = db.transaction(() => { + insertSessionRow(db, { + id, + createdAt: timestampToText(createdAt), + cwd: options.cwd, + parentSessionId: options.parentSessionId, + metadata: options.metadata, + }); + createSequence(db, id); + createStats(db, id); + createInitialLane(db, id); + return acquireWriterLease(db, id, this.leaseOptions); + }); + const row = requireSessionRow(db, id); + return this.sessionFromLease(db, metadataFromRow(row, path), lease); + }); } async open(metadata: SqliteSessionMetadata): Promise> { - return createSession(await this.backend.open(metadata), this.contextBuildOptions); + return this.operations.enqueue(async () => this.claimSession(await this.getDatabase(), metadata)); } - async list(options?: SqliteSessionListOptions): Promise { - return await this.backend.list(options); + /** Rebuilds this session's private branch-read cache from canonical entry parent links. */ + async repairBranchCache(metadata: SqliteSessionMetadata): Promise { + return this.operations.enqueue(async () => { + await this.releaseStoragesForSession(metadata.id); + const db = await this.getDatabase(); + db.transaction(() => { + const lease = acquireWriterLease(db, metadata.id, this.leaseOptions); + requireSessionRow(db, metadata.id); + rebuildBranchCache(db, metadata.id); + releaseSessionLease(db, metadata.id, lease); + }); + }); + } + + async list(options: SqliteSessionListOptions = {}): Promise { + return this.operations.enqueue(async () => { + const path = await this.getDatabasePath(); + if (!resultOrThrow(await this.options.env.exists(path), `Failed to check database ${path}`)) return []; + const db = await this.getDatabase(); + const rows = readSessionRows(db, options); + return rows.map((row) => metadataFromRow(row, path)); + }); } async delete(metadata: SqliteSessionMetadata): Promise { - await this.backend.delete(metadata); + return this.operations.enqueue(async () => { + await this.releaseStoragesForSession(metadata.id); + const db = await this.getDatabase(); + db.transaction(() => { + if (!sessionExists(db, metadata.id)) { + deleteSessionLease(db, metadata.id); + return; + } + acquireWriterLease(db, metadata.id, this.leaseOptions); + deleteBranchCache(db, metadata.id); + deleteFactRows(db, metadata.id); + deleteLaneRows(db, metadata.id); + deleteRecordRows(db, metadata.id); + deleteEntryRows(db, metadata.id); + deleteSessionLease(db, metadata.id); + deleteStats(db, metadata.id); + deleteSequence(db, metadata.id); + deleteSessionRow(db, metadata.id); + }); + }); } async fork( source: SqliteSessionMetadata, - options: SessionForkOptions & SqliteSessionCreateOptions, + options: ForkOptions & SqliteSessionCreateOptions, ): Promise> { - const { entryId: _entryId, position: _position, ...createOptions } = options; - return createSession( - await this.backend.fork(source, createOptions, createSessionForkSelection(options)), - this.contextBuildOptions, - ); + return this.operations.enqueue(async () => { + const db = await this.getDatabase(); + const path = await this.getDatabasePath(); + const sourceMetadata = metadataFromRow(requireSessionRow(db, source.id), path); + const id = options.id ?? uuidv7(); + if (sessionExists(db, id)) throw new SessionError("already_exists", `Session already exists: ${id}`); + + const entries: EntryRow[] = []; + const lanes: { lane: string; leafId: string | null }[] = []; + const branchTips: string[] = []; + let branchForkTargetId: string | null = null; + + if (options.scope === "tree") { + entries.push(...readEntryRows(db, source.id, { order: "oldestFirst" })); + lanes.push(...readLanes(db, source.id).map((row) => ({ lane: row.lane, leafId: row.leaf_id }))); + branchTips.push(...readBranchTipIds(db, source.id)); + } else { + const main = readLane(db, source.id, "main"); + if (!main) throw new SessionError("invalid_lane", "Lane not found: main"); + const selectedEntryId = options.entryId ?? main.leaf_id; + if (selectedEntryId !== null) { + const target = readEntryRow(db, source.id, selectedEntryId); + if (!target || target.type !== "message") { + throw new SessionError( + "invalid_fork_target", + `Fork target is not a message entry: ${selectedEntryId}`, + ); + } + const position = options.position ?? (options.entryId === undefined ? "at" : "before"); + branchForkTargetId = position === "at" ? target.id : target.parent_id; + } + lanes.push({ lane: "main", leafId: branchForkTargetId }); + if (branchForkTargetId !== null) { + const cached = readCachedBranch(db, source.id, branchForkTargetId); + if (!cached) { + throw new SessionError( + "invalid_fork_target", + `Fork target is not on a cached branch: ${branchForkTargetId}`, + ); + } + const rows = queryCachedBranchRows(db, source.id, cached, { order: "oldestFirst" }); + entries.push(...rows.map(entryRowFromCached)); + branchTips.push(branchForkTargetId); + } + } + + const copiedIds = new Set(entries.map((entry) => entry.id)); + const latestName = readLatestFact(db, source.id, "name", null); + const latestLabels = readLatestLabelFacts(db, source.id); + const labelsToCopy = latestLabels.filter( + (row) => options.scope === "tree" || (row.key !== null && copiedIds.has(row.key)), + ); + const createdAt = Date.now(); + const metadata = options.metadata ?? sourceMetadata.metadata; + let lease: SessionLease; + + try { + lease = db.transaction(() => { + insertSessionRow(db, { + id, + createdAt: timestampToText(createdAt), + cwd: options.cwd, + parentSessionId: options.parentSessionId ?? source.id, + metadata, + }); + createSequence(db, id); + createStats(db, id); + + let nextSeq = 1; + const allocateSeq = () => nextSeq++; + for (const entry of entries) { + insertEntryRow(db, id, { + seq: allocateSeq(), + id: entry.id, + parentId: entry.parent_id, + type: entry.type, + timestamp: entry.timestamp, + payload: entry.payload, + }); + } + + if (options.scope === "tree") { + for (const lane of lanes) insertLane(db, id, allocateSeq(), lane.lane, lane.leafId); + } else { + createInitialLane(db, id, "main", branchForkTargetId); + } + + if (latestName?.value !== undefined && latestName.value !== null) { + appendFact(db, id, allocateSeq(), "name", null, latestName.value); + } + for (const label of labelsToCopy) appendFact(db, id, allocateSeq(), "label", label.key, label.value); + + setNextSequence(db, id, nextSeq); + for (const tip of branchTips) buildCachedBranch(db, id, tip); + return acquireWriterLease(db, id, this.leaseOptions); + }); + } catch (error) { + if (error instanceof SessionError) throw error; + throw new SessionError( + "storage", + `Failed to fork SQLite session ${id}`, + error instanceof Error ? error : undefined, + ); + } + + const row = requireSessionRow(db, id); + return this.sessionFromLease(db, metadataFromRow(row, path), lease); + }); + } + + async close(): Promise { + await this.operations.drain(); + for (const storage of [...this.activeStorages]) await storage.release(); + if (this.database) this.database.close(); + this.database = undefined; + this.databasePromise = undefined; } async [Symbol.asyncDispose](): Promise { - await this.backend[Symbol.asyncDispose](); + await this.close(); + } + + private async getDatabasePath(): Promise { + this.databasePath ??= resultOrThrow( + await this.options.env.absolutePath(this.options.databasePath), + `Failed to resolve SQLite sessions database ${this.options.databasePath}`, + ); + return this.databasePath; + } + + private async getDatabase(): Promise { + if (!this.databasePromise) this.databasePromise = this.openDatabase(); + this.database = await this.databasePromise; + return this.database; + } + + private async openDatabase(): Promise { + const path = await this.getDatabasePath(); + resultOrThrow( + await this.options.env.createDir(getParentPath(path), { recursive: true }), + `Failed to create SQLite sessions directory ${path}`, + ); + const db = await this.options.sqlite.open(path); + try { + configureSqliteDatabase(db); + await applyMigrations(db); + return db; + } catch (error) { + db.close(); + throw error; + } } } diff --git a/packages/storage/sqlite-node/src/sqlite/search-backend.ts b/packages/storage/sqlite-node/src/sqlite/search-backend.ts index 6ba2e865fa3..73f2cd9852a 100644 --- a/packages/storage/sqlite-node/src/sqlite/search-backend.ts +++ b/packages/storage/sqlite-node/src/sqlite/search-backend.ts @@ -17,10 +17,10 @@ function getParentPath(path: string): string { return normalized.slice(0, lastSlash); } -async function configureSqliteDatabase(db: SqliteDatabase): Promise { - await db.exec("PRAGMA journal_mode=WAL"); - await db.exec("PRAGMA synchronous=FULL"); - await db.exec("PRAGMA busy_timeout=5000"); +function configureSqliteDatabase(db: SqliteDatabase): void { + db.exec("PRAGMA journal_mode=WAL"); + db.exec("PRAGMA synchronous=FULL"); + db.exec("PRAGMA busy_timeout=5000"); } export interface SqliteSessionSearchOptions { @@ -29,33 +29,33 @@ export interface SqliteSessionSearchOptions { databasePath: string; } -async function tableExists(db: SqliteDatabase, name: string): Promise { - return !!(await db +function tableExists(db: SqliteDatabase, name: string): boolean { + return !!db .prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1") - .get<{ found: number }>(name)); + .get<{ found: number }>(name); } -async function ensureSearchSchema(db: SqliteDatabase): Promise { - const ftsExists = await tableExists(db, "session_search_fts"); - await db.exec(` +function ensureSearchSchema(db: SqliteDatabase): void { + const ftsExists = tableExists(db, "session_search_fts"); + db.exec(` CREATE VIRTUAL TABLE IF NOT EXISTS session_search_fts USING fts5( payload, - content = 'session_entries', + content = 'entries', content_rowid = 'rowid', tokenize = 'trigram remove_diacritics 1' ); -CREATE TRIGGER IF NOT EXISTS session_search_fts_ai AFTER INSERT ON session_entries BEGIN +CREATE TRIGGER IF NOT EXISTS session_search_fts_ai AFTER INSERT ON entries BEGIN INSERT INTO session_search_fts(rowid, payload) VALUES (new.rowid, new.payload); END; -CREATE TRIGGER IF NOT EXISTS session_search_fts_ad AFTER DELETE ON session_entries BEGIN +CREATE TRIGGER IF NOT EXISTS session_search_fts_ad AFTER DELETE ON entries BEGIN INSERT INTO session_search_fts(session_search_fts, rowid, payload) VALUES('delete', old.rowid, old.payload); END; -CREATE TRIGGER IF NOT EXISTS session_search_fts_au AFTER UPDATE OF payload ON session_entries BEGIN +CREATE TRIGGER IF NOT EXISTS session_search_fts_au AFTER UPDATE OF payload ON entries BEGIN INSERT INTO session_search_fts(session_search_fts, rowid, payload) VALUES('delete', old.rowid, old.payload); INSERT INTO session_search_fts(rowid, payload) VALUES (new.rowid, new.payload); END; `); - if (!ftsExists) await db.exec("INSERT INTO session_search_fts(session_search_fts) VALUES('rebuild')"); + if (!ftsExists) db.exec("INSERT INTO session_search_fts(session_search_fts) VALUES('rebuild')"); } /** SQLite FTS search over a co-located canonical session database. */ @@ -86,12 +86,12 @@ class SqliteSessionSearch implements SessionSearch { ); const db = await this.options.sqlite.open(path); try { - await configureSqliteDatabase(db); + configureSqliteDatabase(db); await applyMigrations(db); - await ensureSearchSchema(db); + ensureSearchSchema(db); return db; } catch (error) { - await db.close(); + db.close(); throw error; } } @@ -102,9 +102,9 @@ class SqliteSessionSearch implements SessionSearch { const db = await this.openDatabase(); try { const query = `"${text.replaceAll('"', '""')}"`; - const rows = await db + const rows = db .prepare( - "SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, s.active_leaf_id, se.id AS entry_id, se.timestamp, bm25(session_search_fts) AS score FROM session_search_fts JOIN session_entries se ON se.rowid = session_search_fts.rowid JOIN sessions s ON s.id = se.session_id WHERE session_search_fts MATCH ? AND (? IS NULL OR s.cwd = ?) ORDER BY score", + "SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, se.id AS entry_id, se.timestamp, bm25(session_search_fts) AS score FROM session_search_fts JOIN entries se ON se.rowid = session_search_fts.rowid JOIN sessions s ON s.id = se.session_id WHERE session_search_fts MATCH ? AND (? IS NULL OR s.cwd = ?) ORDER BY score", ) .all( query, @@ -119,7 +119,7 @@ class SqliteSessionSearch implements SessionSearch { score: row.score, })); } finally { - await db.close(); + db.close(); } } } diff --git a/packages/storage/sqlite-node/src/sqlite/storage/branch-cache.ts b/packages/storage/sqlite-node/src/sqlite/storage/branch-cache.ts deleted file mode 100644 index 9f448927b03..00000000000 --- a/packages/storage/sqlite-node/src/sqlite/storage/branch-cache.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { uuidv7 } from "@earendil-works/pi-ai"; -import type { SqliteDatabase } from "../types.ts"; -import type { SessionEntryRow } from "./session-entries.ts"; -import { invalidSession } from "./shared.ts"; - -/** Derived root-to-tip paths. Canonical parent links remain authoritative. */ -export interface CachedBranch { - branchId: string; - leafSeq: number; -} - -export interface CachedBranchQuery { - stopAtType?: SessionEntryRow["type"]; - stopAtId?: string; - order?: "newestFirst" | "oldestFirst"; -} - -export async function readCachedBranch( - db: SqliteDatabase, - sessionId: string, - leafId: string, -): Promise { - const membership = await db - .prepare( - "SELECT branch_id, entry_seq FROM branch_entries WHERE session_id = ? AND entry_id = ? ORDER BY branch_id LIMIT 1", - ) - .get<{ branch_id: string; entry_seq: number }>(sessionId, leafId); - if (!membership) return undefined; - return { branchId: membership.branch_id, leafSeq: membership.entry_seq }; -} - -export async function isCachedBranchValid( - db: SqliteDatabase, - sessionId: string, - branch: CachedBranch, - leafId: string, - startSeq = 0, -): Promise { - const result = await db - .prepare( - `WITH path AS ( - SELECT - b.entry_id, - b.entry_seq, - e.id AS stored_entry_id, - e.parent_id, - LAG(b.entry_id) OVER (ORDER BY b.entry_seq) AS previous_entry_id - FROM branch_entries AS b - LEFT JOIN session_entries AS e ON e.session_id = b.session_id AND e.id = b.entry_id - WHERE b.session_id = ? AND b.branch_id = ? AND b.entry_seq BETWEEN ? AND ? - ) - SELECT - COUNT(*) AS row_count, - COALESCE(SUM( - stored_entry_id IS NULL OR - (? = 0 AND previous_entry_id IS NULL AND parent_id IS NOT NULL) OR - (previous_entry_id IS NOT NULL AND parent_id IS NOT previous_entry_id) - ), 0) AS invalid_count, - COALESCE(MAX(entry_seq = ? AND entry_id = ?), 0) AS contains_leaf - FROM path`, - ) - .get<{ row_count: number; invalid_count: number; contains_leaf: number }>( - sessionId, - branch.branchId, - startSeq, - branch.leafSeq, - startSeq, - branch.leafSeq, - leafId, - ); - return result?.row_count !== 0 && result?.invalid_count === 0 && result.contains_leaf === 1; -} - -export async function readNewestCachedStopSeq( - db: SqliteDatabase, - sessionId: string, - branch: CachedBranch, - stopAtType: SessionEntryRow["type"] | undefined, - stopAtId: string | undefined, -): Promise { - const predicates: string[] = []; - const params: unknown[] = [sessionId, branch.branchId, branch.leafSeq]; - if (stopAtType !== undefined) { - predicates.push("e.type = ?"); - params.push(stopAtType); - } - if (stopAtId !== undefined) { - predicates.push("b.entry_id = ?"); - params.push(stopAtId); - } - if (predicates.length === 0) return undefined; - const row = await db - .prepare( - `SELECT MAX(b.entry_seq) AS entry_seq - FROM branch_entries AS b - JOIN session_entries AS e ON e.session_id = b.session_id AND e.id = b.entry_id - WHERE b.session_id = ? AND b.branch_id = ? AND b.entry_seq <= ? - AND (${predicates.join(" OR ")})`, - ) - .get<{ entry_seq: number | null }>(...params); - return row?.entry_seq ?? undefined; -} - -export async function readCachedBranchRows( - db: SqliteDatabase, - sessionId: string, - branch: CachedBranch, - startSeq: number, -): Promise { - return db - .prepare( - `SELECT e.session_id, e.id, e.entry_seq, e.parent_id, e.type, e.timestamp, e.payload - FROM branch_entries AS b - JOIN session_entries AS e ON e.session_id = b.session_id AND e.id = b.entry_id - WHERE b.session_id = ? AND b.branch_id = ? AND b.entry_seq BETWEEN ? AND ? - ORDER BY b.entry_seq`, - ) - .all(sessionId, branch.branchId, startSeq, branch.leafSeq); -} - -export async function queryCachedBranchRows( - db: SqliteDatabase, - sessionId: string, - branch: CachedBranch, - query: CachedBranchQuery, -): Promise { - const oldestFirst = query.order === "oldestFirst"; - const boundaryParams: unknown[] = [sessionId, branch.branchId, branch.leafSeq]; - const stopPredicates: string[] = []; - if (query.stopAtType !== undefined) { - stopPredicates.push("stop_entry.type = ?"); - boundaryParams.push(query.stopAtType); - } - if (query.stopAtId !== undefined) { - stopPredicates.push("stop.entry_id = ?"); - boundaryParams.push(query.stopAtId); - } - - const boundary = stopPredicates.length - ? `WITH boundary AS ( - SELECT ${oldestFirst ? "MIN" : "MAX"}(stop.entry_seq) AS entry_seq - FROM branch_entries AS stop - JOIN session_entries AS stop_entry - ON stop_entry.session_id = stop.session_id AND stop_entry.id = stop.entry_id - WHERE stop.session_id = ? AND stop.branch_id = ? AND stop.entry_seq <= ? - AND (${stopPredicates.join(" OR ")}) - )` - : ""; - const range = stopPredicates.length - ? `AND b.entry_seq ${oldestFirst ? "<=" : ">="} COALESCE( - (SELECT entry_seq FROM boundary), ${oldestFirst ? branch.leafSeq : 0} - )` - : ""; - const sql = `${boundary} - SELECT e.session_id, e.id, e.entry_seq, e.parent_id, e.type, e.timestamp, e.payload - FROM branch_entries AS b - JOIN session_entries AS e ON e.session_id = b.session_id AND e.id = b.entry_id - WHERE b.session_id = ? AND b.branch_id = ? AND b.entry_seq <= ? - ${range} - ORDER BY b.entry_seq ${oldestFirst ? "ASC" : "DESC"}`; - - const params = [...(stopPredicates.length === 0 ? [] : boundaryParams), sessionId, branch.branchId, branch.leafSeq]; - return db.prepare(sql).all(...params); -} - -export async function readCachedEntryRowsByType( - db: SqliteDatabase, - sessionId: string, - branch: CachedBranch, - type: SessionEntryRow["type"], -): Promise { - // Drive the join from the usually sparse entry type. Ordering from branch_entries - // makes SQLite scan the complete cached path before filtering by type. - return db - .prepare( - `SELECT e.session_id, e.id, e.entry_seq, e.parent_id, e.type, e.timestamp, e.payload - FROM session_entries AS e INDEXED BY idx_session_entries_session_type - CROSS JOIN branch_entries AS b - WHERE e.session_id = ? AND e.type = ? - AND b.session_id = e.session_id AND b.entry_id = e.id - AND b.branch_id = ? AND b.entry_seq <= ? - ORDER BY e.entry_seq DESC`, - ) - .all(sessionId, type, branch.branchId, branch.leafSeq); -} - -export async function readCachedEntrySeq( - db: SqliteDatabase, - sessionId: string, - branchId: string, - entryId: string, -): Promise { - const row = await db - .prepare("SELECT entry_seq FROM branch_entries WHERE session_id = ? AND branch_id = ? AND entry_id = ?") - .get<{ entry_seq: number }>(sessionId, branchId, entryId); - return row?.entry_seq; -} - -export async function rebuildCachedBranch( - db: SqliteDatabase, - sessionId: string, - leafId: string, - branchIdToReplace?: string, -): Promise { - await db.exec("SAVEPOINT rebuild_branch_cache"); - try { - const tip = await db - .prepare("SELECT branch_id FROM branch_tips WHERE session_id = ? AND tip_id = ?") - .get<{ branch_id: string }>(sessionId, leafId); - const branchIds = new Set([branchIdToReplace, tip?.branch_id].filter((id): id is string => id !== undefined)); - for (const branchId of branchIds) { - await db.prepare("DELETE FROM branch_tips WHERE session_id = ? AND branch_id = ?").run(sessionId, branchId); - await db.prepare("DELETE FROM branch_entries WHERE session_id = ? AND branch_id = ?").run(sessionId, branchId); - } - - const branchId = uuidv7(); - await db - .prepare( - `WITH RECURSIVE path(id, entry_seq, parent_id) AS ( - SELECT id, entry_seq, parent_id - FROM session_entries - WHERE session_id = ? AND id = ? - UNION ALL - SELECT parent.id, parent.entry_seq, parent.parent_id - FROM session_entries AS parent - JOIN path AS child ON child.parent_id = parent.id - WHERE parent.session_id = ? - ) - INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq) - SELECT ?, ?, id, entry_seq FROM path`, - ) - .run(sessionId, leafId, sessionId, sessionId, branchId); - await db - .prepare("INSERT INTO branch_tips (session_id, tip_id, branch_id) VALUES (?, ?, ?)") - .run(sessionId, leafId, branchId); - await db.exec("RELEASE SAVEPOINT rebuild_branch_cache"); - } catch (error) { - try { - await db.exec("ROLLBACK TO SAVEPOINT rebuild_branch_cache"); - await db.exec("RELEASE SAVEPOINT rebuild_branch_cache"); - } catch { - // Preserve the original repair failure. - } - throw error; - } -} - -async function extendBranch( - db: SqliteDatabase, - sessionId: string, - branchId: string, - parentId: string, - entryId: string, - entrySeq: number, -): Promise { - await db - .prepare("INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq) VALUES (?, ?, ?, ?)") - .run(sessionId, branchId, entryId, entrySeq); - const result = await db - .prepare("UPDATE branch_tips SET tip_id = ? WHERE session_id = ? AND branch_id = ? AND tip_id = ?") - .run(entryId, sessionId, branchId, parentId); - if (result.changes !== 1) throw invalidSession(`branch tip ${parentId} changed during append`); -} - -export async function appendEntryToBranchCache( - db: SqliteDatabase, - sessionId: string, - entryId: string, - entrySeq: number, - parentId: string | null, - repairParent: (parentId: string) => Promise, -): Promise { - if (parentId === null) { - const branchId = uuidv7(); - await db - .prepare("INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq) VALUES (?, ?, ?, ?)") - .run(sessionId, branchId, entryId, entrySeq); - await db - .prepare("INSERT INTO branch_tips (session_id, tip_id, branch_id) VALUES (?, ?, ?)") - .run(sessionId, entryId, branchId); - return; - } - - let tip = await db - .prepare("SELECT branch_id FROM branch_tips WHERE session_id = ? AND tip_id = ?") - .get<{ branch_id: string }>(sessionId, parentId); - if (tip) { - await extendBranch(db, sessionId, tip.branch_id, parentId, entryId, entrySeq); - return; - } - - const source = await db - .prepare( - `SELECT b.branch_id, b.entry_seq - FROM branch_entries AS b - WHERE b.session_id = ? AND b.entry_id = ? - ORDER BY b.branch_id - LIMIT 1`, - ) - .get<{ branch_id: string; entry_seq: number }>(sessionId, parentId); - if (!source) { - await repairParent(parentId); - tip = await db - .prepare("SELECT branch_id FROM branch_tips WHERE session_id = ? AND tip_id = ?") - .get<{ branch_id: string }>(sessionId, parentId); - if (!tip) throw invalidSession(`branch cache repair did not create tip ${parentId}`); - await extendBranch(db, sessionId, tip.branch_id, parentId, entryId, entrySeq); - return; - } - - const branchId = uuidv7(); - await db - .prepare( - `INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq) - SELECT session_id, ?, entry_id, entry_seq - FROM branch_entries - WHERE session_id = ? AND branch_id = ? AND entry_seq <= ?`, - ) - .run(branchId, sessionId, source.branch_id, source.entry_seq); - await db - .prepare("INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq) VALUES (?, ?, ?, ?)") - .run(sessionId, branchId, entryId, entrySeq); - await db - .prepare("INSERT INTO branch_tips (session_id, tip_id, branch_id) VALUES (?, ?, ?)") - .run(sessionId, entryId, branchId); -} diff --git a/packages/storage/sqlite-node/src/sqlite/storage/branch-entries.ts b/packages/storage/sqlite-node/src/sqlite/storage/branch-entries.ts new file mode 100644 index 00000000000..dfdadf64c77 --- /dev/null +++ b/packages/storage/sqlite-node/src/sqlite/storage/branch-entries.ts @@ -0,0 +1,146 @@ +import type { Entry } from "@earendil-works/pi-agent-core/experimental"; +import type { SqliteDatabase } from "../types.ts"; + +/** Derived root-to-tip branch cache membership. Canonical parent links remain in entries. */ +export interface CachedBranch { + branchId: string; + leafSeq: number; +} + +export interface CachedBranchEntryRow { + session_id: string; + id: string; + entry_seq: number; + parent_id: string | null; + type: Entry["type"]; + timestamp: string; + payload: string; +} + +export interface CachedBranchQuery { + stopAtType?: Entry["type"]; + stopAtId?: string; + order?: "newestFirst" | "oldestFirst"; +} + +export function readCachedBranch(db: SqliteDatabase, sessionId: string, leafId: string) { + const membership = db + .prepare( + "SELECT branch_id, entry_seq FROM branch_entries WHERE session_id = ? AND entry_id = ? ORDER BY branch_id LIMIT 1", + ) + .get<{ branch_id: string; entry_seq: number }>(sessionId, leafId); + if (!membership) return undefined; + return { branchId: membership.branch_id, leafSeq: membership.entry_seq }; +} + +export function queryCachedBranchRows( + db: SqliteDatabase, + sessionId: string, + branch: CachedBranch, + query: CachedBranchQuery, +) { + const oldestFirst = query.order === "oldestFirst"; + const boundaryParams: unknown[] = [sessionId, branch.branchId, branch.leafSeq]; + const stopPredicates: string[] = []; + if (query.stopAtType !== undefined) { + stopPredicates.push("stop_entry.type = ?"); + boundaryParams.push(query.stopAtType); + } + if (query.stopAtId !== undefined) { + stopPredicates.push("stop.entry_id = ?"); + boundaryParams.push(query.stopAtId); + } + + const boundary = stopPredicates.length + ? `WITH boundary AS ( + SELECT ${oldestFirst ? "MIN" : "MAX"}(stop.entry_seq) AS entry_seq + FROM branch_entries AS stop + JOIN entries AS stop_entry + ON stop_entry.session_id = stop.session_id AND stop_entry.id = stop.entry_id + WHERE stop.session_id = ? AND stop.branch_id = ? AND stop.entry_seq <= ? + AND (${stopPredicates.join(" OR ")}) + )` + : ""; + const range = stopPredicates.length + ? `AND b.entry_seq ${oldestFirst ? "<=" : ">="} COALESCE( + (SELECT entry_seq FROM boundary), ${oldestFirst ? branch.leafSeq : 0} + )` + : ""; + const sql = `${boundary} + SELECT e.session_id, e.id, e.seq AS entry_seq, e.parent_id, e.type, e.timestamp, e.payload + FROM branch_entries AS b + JOIN entries AS e ON e.session_id = b.session_id AND e.id = b.entry_id + WHERE b.session_id = ? AND b.branch_id = ? AND b.entry_seq <= ? + ${range} + ORDER BY b.entry_seq ${oldestFirst ? "ASC" : "DESC"}`; + + const params = [...(stopPredicates.length === 0 ? [] : boundaryParams), sessionId, branch.branchId, branch.leafSeq]; + return db.prepare(sql).all(...params); +} + +export function deleteBranchEntries(db: SqliteDatabase, sessionId: string) { + db.prepare("DELETE FROM branch_entries WHERE session_id = ?").run(sessionId); +} + +export function insertBranchEntry( + db: SqliteDatabase, + sessionId: string, + branchId: string, + entryId: string, + entrySeq: number, + entryType: string, + customType: string | null, +) { + db.prepare( + `INSERT INTO branch_entries + (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run(sessionId, branchId, entryId, entrySeq, entryType, customType); +} + +export function insertBranchEntriesForPath(db: SqliteDatabase, sessionId: string, branchId: string, leafId: string) { + db.prepare( + `WITH RECURSIVE path(id, entry_seq, parent_id, type, custom_type) AS ( + SELECT id, seq, parent_id, type, + CASE WHEN type = 'custom' THEN json_extract(payload, '$.customType') ELSE NULL END + FROM entries + WHERE session_id = ? AND id = ? + UNION ALL + SELECT parent.id, parent.seq, parent.parent_id, parent.type, + CASE WHEN parent.type = 'custom' THEN json_extract(parent.payload, '$.customType') ELSE NULL END + FROM entries AS parent + JOIN path AS child ON child.parent_id = parent.id + WHERE parent.session_id = ? + ) + INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) + SELECT ?, ?, id, entry_seq, type, custom_type FROM path`, + ).run(sessionId, leafId, sessionId, sessionId, branchId); +} + +export function readBranchContainingEntry(db: SqliteDatabase, sessionId: string, entryId: string) { + const row = db + .prepare( + `SELECT b.branch_id, b.entry_seq + FROM branch_entries AS b + WHERE b.session_id = ? AND b.entry_id = ? + ORDER BY b.branch_id + LIMIT 1`, + ) + .get<{ branch_id: string; entry_seq: number }>(sessionId, entryId); + return row === undefined ? undefined : { branchId: row.branch_id, entrySeq: row.entry_seq }; +} + +export function copyBranchEntriesThroughSeq( + db: SqliteDatabase, + sessionId: string, + targetBranchId: string, + sourceBranchId: string, + throughSeq: number, +) { + db.prepare( + `INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) + SELECT session_id, ?, entry_id, entry_seq, entry_type, custom_type + FROM branch_entries + WHERE session_id = ? AND branch_id = ? AND entry_seq <= ?`, + ).run(targetBranchId, sessionId, sourceBranchId, throughSeq); +} diff --git a/packages/storage/sqlite-node/src/sqlite/storage/branch-tips.ts b/packages/storage/sqlite-node/src/sqlite/storage/branch-tips.ts new file mode 100644 index 00000000000..e4dffc48855 --- /dev/null +++ b/packages/storage/sqlite-node/src/sqlite/storage/branch-tips.ts @@ -0,0 +1,40 @@ +import type { SqliteDatabase } from "../types.ts"; + +export function readBranchTipIds(db: SqliteDatabase, sessionId: string) { + return db + .prepare("SELECT tip_id FROM branch_tips WHERE session_id = ? ORDER BY tip_id") + .all<{ tip_id: string }>(sessionId) + .map((row) => row.tip_id); +} + +export function readBranchTipBranchId(db: SqliteDatabase, sessionId: string, tipId: string) { + const tip = db + .prepare("SELECT branch_id FROM branch_tips WHERE session_id = ? AND tip_id = ?") + .get<{ branch_id: string }>(sessionId, tipId); + return tip?.branch_id; +} + +export function insertBranchTip(db: SqliteDatabase, sessionId: string, tipId: string, branchId: string) { + db.prepare("INSERT INTO branch_tips (session_id, tip_id, branch_id) VALUES (?, ?, ?)").run( + sessionId, + tipId, + branchId, + ); +} + +export function updateBranchTip( + db: SqliteDatabase, + sessionId: string, + branchId: string, + oldTipId: string, + newTipId: string, +) { + const result = db + .prepare("UPDATE branch_tips SET tip_id = ? WHERE session_id = ? AND branch_id = ? AND tip_id = ?") + .run(newTipId, sessionId, branchId, oldTipId); + return result.changes === 1; +} + +export function deleteBranchTips(db: SqliteDatabase, sessionId: string) { + db.prepare("DELETE FROM branch_tips WHERE session_id = ?").run(sessionId); +} diff --git a/packages/storage/sqlite-node/src/sqlite/storage/entries.ts b/packages/storage/sqlite-node/src/sqlite/storage/entries.ts new file mode 100644 index 00000000000..fc227d45de4 --- /dev/null +++ b/packages/storage/sqlite-node/src/sqlite/storage/entries.ts @@ -0,0 +1,75 @@ +import type { Entry, EntryOrder } from "@earendil-works/pi-agent-core/experimental"; +import type { SqliteDatabase } from "../types.ts"; + +export interface EntryRow { + session_id: string; + seq: number; + id: string; + parent_id: string | null; + type: Entry["type"]; + timestamp: string; + payload: string; +} + +export interface NewEntryRow { + seq: number; + id: string; + parentId: string | null; + type: Entry["type"]; + timestamp: string; + payload: string; +} + +export function entryPayload(entry: Entry): Record { + const { type: _type, id: _id, seq: _seq, parentId: _parentId, timestamp: _timestamp, ...payload } = entry; + return payload; +} + +function orderedSql(order: EntryOrder | undefined): string { + return order === "oldestFirst" ? "ASC" : "DESC"; +} + +export function insertEntryRow(db: SqliteDatabase, sessionId: string, entry: NewEntryRow) { + db.prepare( + "INSERT INTO entries (session_id, id, seq, parent_id, type, timestamp, payload) VALUES (?, ?, ?, ?, ?, ?, ?)", + ).run(sessionId, entry.id, entry.seq, entry.parentId, entry.type, entry.timestamp, entry.payload); +} + +export function readEntryRow(db: SqliteDatabase, sessionId: string, entryId: string) { + return db + .prepare( + "SELECT session_id, seq, id, parent_id, type, timestamp, payload FROM entries WHERE session_id = ? AND id = ?", + ) + .get(sessionId, entryId); +} + +export function readEntryRows( + db: SqliteDatabase, + sessionId: string, + options: { afterSeq?: number; order?: EntryOrder } = {}, +) { + const predicates = ["session_id = ?"]; + const params: unknown[] = [sessionId]; + if (options.afterSeq !== undefined) { + predicates.push("seq > ?"); + params.push(options.afterSeq); + } + return db + .prepare( + `SELECT session_id, seq, id, parent_id, type, timestamp, payload + FROM entries + WHERE ${predicates.join(" AND ")} + ORDER BY seq ${orderedSql(options.order)}`, + ) + .all(...params); +} + +export function idExistsInEntries(db: SqliteDatabase, sessionId: string, id: string) { + return !!db + .prepare("SELECT 1 AS found FROM entries WHERE session_id = ? AND id = ? LIMIT 1") + .get<{ found: number }>(sessionId, id); +} + +export function deleteEntryRows(db: SqliteDatabase, sessionId: string) { + db.prepare("DELETE FROM entries WHERE session_id = ?").run(sessionId); +} diff --git a/packages/storage/sqlite-node/src/sqlite/storage/facts.ts b/packages/storage/sqlite-node/src/sqlite/storage/facts.ts new file mode 100644 index 00000000000..50d056f4741 --- /dev/null +++ b/packages/storage/sqlite-node/src/sqlite/storage/facts.ts @@ -0,0 +1,73 @@ +import type { SqliteDatabase } from "../types.ts"; + +export interface FactRow { + session_id: string; + seq: number; + kind: string; + key: string | null; + value: string | null; +} + +export function appendFact( + db: SqliteDatabase, + sessionId: string, + seq: number, + kind: string, + key: string | null, + value: string | null, +) { + db.prepare("INSERT INTO facts (session_id, seq, kind, key, value) VALUES (?, ?, ?, ?, ?)").run( + sessionId, + seq, + kind, + key, + value, + ); +} + +export function readLatestFact(db: SqliteDatabase, sessionId: string, kind: string, key: string | null) { + return db + .prepare( + `SELECT session_id, seq, kind, key, value + FROM facts + WHERE session_id = ? AND kind = ? AND key IS ? + ORDER BY seq DESC + LIMIT 1`, + ) + .get(sessionId, kind, key); +} + +export function readLatestLabelFacts(db: SqliteDatabase, sessionId: string) { + return db + .prepare( + `SELECT key, value FROM ( + SELECT key, value, ROW_NUMBER() OVER (PARTITION BY key ORDER BY seq DESC) AS rank + FROM facts + WHERE session_id = ? AND kind = 'label' + ) + WHERE rank = 1 AND value IS NOT NULL + ORDER BY key`, + ) + .all<{ key: string; value: string }>(sessionId); +} + +export function readFactRows(db: SqliteDatabase, sessionId: string, options: { afterSeq?: number } = {}) { + const predicates = ["session_id = ?"]; + const params: unknown[] = [sessionId]; + if (options.afterSeq !== undefined) { + predicates.push("seq > ?"); + params.push(options.afterSeq); + } + return db + .prepare( + `SELECT session_id, seq, kind, key, value + FROM facts + WHERE ${predicates.join(" AND ")} + ORDER BY seq`, + ) + .all(...params); +} + +export function deleteFactRows(db: SqliteDatabase, sessionId: string) { + db.prepare("DELETE FROM facts WHERE session_id = ?").run(sessionId); +} diff --git a/packages/storage/sqlite-node/src/sqlite/storage/index.ts b/packages/storage/sqlite-node/src/sqlite/storage/index.ts deleted file mode 100644 index c60a73d785c..00000000000 --- a/packages/storage/sqlite-node/src/sqlite/storage/index.ts +++ /dev/null @@ -1,459 +0,0 @@ -import type { - SessionBranchQuery, - SessionEntryCursorOptions, - SessionStats, - SessionTreeEntry, -} from "@earendil-works/pi-agent-core"; -import { SessionError, toError } from "@earendil-works/pi-agent-core"; -import type { SqliteDatabase, SqliteSessionMetadata } from "../types.ts"; -import { - appendEntryToBranchCache, - type CachedBranch, - isCachedBranchValid, - queryCachedBranchRows, - readCachedBranch, - readCachedBranchRows, - readCachedEntryRowsByType, - readCachedEntrySeq, - readNewestCachedStopSeq, - rebuildCachedBranch, -} from "./branch-cache.ts"; -import { decodeEntry, encodeEntry, type SessionEntryRow } from "./session-entries.ts"; -import { - applyEntryToMaterializedState, - createEmptyMaterializedState, - type EntryMaterializedRow, - entryMaterializedValues, - materializedStateFromRows, - materializedStateValues, - type SessionMaterializedRow, - type SessionMaterializedState, - serializeSummary, -} from "./session-materialized.ts"; -import { advanceSequence, getNextSequence } from "./session-sequences.ts"; -import { rowToMetadata, type SessionRow } from "./sessions.ts"; -import { invalidEntry, invalidSession, leafIdAfterEntry } from "./shared.ts"; - -function decodeEntryRows(entryRows: SessionEntryRow[]): SessionTreeEntry[] { - const entries: SessionTreeEntry[] = []; - for (const entryRow of entryRows) { - try { - const entry = decodeEntry(entryRow); - entries.push(entry); - } catch (error) { - throw invalidEntry(`failed to decode entry ${entryRow.id}`, toError(error)); - } - } - return entries; -} - -async function loadSqliteSession( - db: SqliteDatabase, - sessionId: string, -): Promise<{ - row: SessionRow; - materializedState: SessionMaterializedState; -}> { - const row = await db - .prepare("SELECT id, created_at, metadata, cwd, parent_session_id, active_leaf_id FROM sessions WHERE id = ?") - .get(sessionId); - if (!row) throw new SessionError("not_found", `Session not found: ${sessionId}`); - - const materializedRow = await db - .prepare("SELECT session_id, payload FROM session_materialized WHERE session_id = ?") - .get(sessionId); - if (!materializedRow) throw invalidSession(`missing materialized row for session ${sessionId}`); - const entryMaterializedRows = await db - .prepare( - "SELECT session_id, entry_seq, type, payload FROM entry_materialized WHERE session_id = ? ORDER BY entry_seq, type", - ) - .all(sessionId); - return { - row, - materializedState: materializedStateFromRows(materializedRow, entryMaterializedRows), - }; -} - -export class SqliteSessionConnection { - private readonly db: SqliteDatabase; - readonly metadata: SqliteSessionMetadata; - private byId: Map; - private materializedState: SessionMaterializedState; - - async findEntriesOnBranch(query: SessionBranchQuery & { start: string | null }): Promise { - if (query.limit !== undefined && (!Number.isInteger(query.limit) || query.limit <= 0)) { - throw new RangeError("Session branch query limit must be a positive integer"); - } - if (query.start === null) return []; - const startId = query.start; - let cached = await readCachedBranch(this.db, this.metadata.id, startId); - const validationStartSeq = - cached && query.order !== "oldestFirst" - ? await readNewestCachedStopSeq(this.db, this.metadata.id, cached, query.stopAtType, query.stopAtId) - : undefined; - if (!cached || !(await isCachedBranchValid(this.db, this.metadata.id, cached, startId, validationStartSeq))) { - if (query.order !== "oldestFirst" && (query.stopAtId !== undefined || query.stopAtType !== undefined)) { - return this.findEntriesOnCanonicalBranch({ ...query, start: startId }); - } - cached = await this.repairBranchCacheForQuery(startId, cached?.branchId); - } - const decoded = decodeEntryRows(await queryCachedBranchRows(this.db, this.metadata.id, cached, query)); - const filtered = decoded.filter( - (entry) => - (query.type === undefined || entry.type === query.type) && - (query.customType === undefined || (entry.type === "custom" && entry.customType === query.customType)), - ); - const entries = query.limit === undefined ? filtered : filtered.slice(0, query.limit); - for (const entry of entries) this.byId.set(entry.id, entry); - return entries; - } - - private async findEntriesOnCanonicalBranch( - query: SessionBranchQuery & { start: string }, - ): Promise { - const rows: SessionEntryRow[] = []; - const visited = new Set(); - let currentId: string | null = query.start; - while (currentId !== null) { - if (visited.has(currentId)) throw invalidSession(`cycle in parent chain at entry ${currentId}`); - visited.add(currentId); - const row: SessionEntryRow | undefined = await this.db - .prepare( - "SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND id = ?", - ) - .get(this.metadata.id, currentId); - if (!row) { - if (currentId === query.start) throw new SessionError("not_found", `Entry ${query.start} not found`); - throw invalidSession(`Entry ${currentId} not found`); - } - rows.push(row); - if (row.id === query.stopAtId || row.type === query.stopAtType) break; - currentId = row.parent_id; - } - const entries = decodeEntryRows(rows).filter( - (entry) => - (query.type === undefined || entry.type === query.type) && - (query.customType === undefined || (entry.type === "custom" && entry.customType === query.customType)), - ); - const limited = query.limit === undefined ? entries : entries.slice(0, query.limit); - for (const entry of limited) this.byId.set(entry.id, entry); - return limited; - } - - private async repairBranchCacheForQuery(leafId: string, branchIdToReplace?: string): Promise { - const visited = new Set(); - let currentId: string | null = leafId; - while (currentId !== null) { - if (visited.has(currentId)) throw invalidSession(`cycle in parent chain at entry ${currentId}`); - visited.add(currentId); - const row: { parent_id: string | null } | undefined = await this.db - .prepare("SELECT parent_id FROM session_entries WHERE session_id = ? AND id = ?") - .get<{ parent_id: string | null }>(this.metadata.id, currentId); - if (!row) { - if (currentId === leafId) throw new SessionError("not_found", `Entry ${leafId} not found`); - throw invalidSession(`Entry ${currentId} not found`); - } - currentId = row.parent_id; - } - try { - await rebuildCachedBranch(this.db, this.metadata.id, leafId, branchIdToReplace); - } catch (error) { - if (error instanceof SessionError) throw error; - throw new SessionError("storage", `Failed to rebuild SQLite branch cache at entry ${leafId}`, toError(error)); - } - const cached = await readCachedBranch(this.db, this.metadata.id, leafId); - if (!cached || !(await isCachedBranchValid(this.db, this.metadata.id, cached, leafId))) { - throw invalidSession(`branch cache repair did not produce a valid path to entry ${leafId}`); - } - return cached; - } - - async readPathToRootOrCompaction(leafId: string | null): Promise { - if (leafId === null) return []; - const cached = await readCachedBranch(this.db, this.metadata.id, leafId); - if (cached) { - const compactionRows = await readCachedEntryRowsByType(this.db, this.metadata.id, cached, "compaction"); - let startSeq = 0; - let expectedStartId: string | null = null; - let pendingStop: { id: string; seq: number } | undefined; - for (const compactionRow of compactionRows) { - if (pendingStop && pendingStop.seq >= compactionRow.entry_seq) { - startSeq = pendingStop.seq; - expectedStartId = pendingStop.id; - break; - } - const compaction = decodeEntryRows([compactionRow])[0]!; - if (compaction.type !== "compaction") throw invalidSession(`entry ${compaction.id} is not a compaction`); - if (compaction.retainedTail) { - startSeq = compactionRow.entry_seq; - expectedStartId = compaction.id; - pendingStop = undefined; - break; - } else if (compaction.firstKeptEntryId !== undefined) { - const firstKeptSeq = await readCachedEntrySeq( - this.db, - this.metadata.id, - cached.branchId, - compaction.firstKeptEntryId, - ); - pendingStop = - firstKeptSeq !== undefined && firstKeptSeq < compactionRow.entry_seq - ? { id: compaction.firstKeptEntryId, seq: firstKeptSeq } - : undefined; - } else { - pendingStop = undefined; - } - } - if (startSeq === 0 && pendingStop) { - startSeq = pendingStop.seq; - expectedStartId = pendingStop.id; - } - const entries = decodeEntryRows(await readCachedBranchRows(this.db, this.metadata.id, cached, startSeq)); - if (this.isValidCachedPath(entries, leafId, expectedStartId)) { - for (const entry of entries) this.byId.set(entry.id, entry); - return entries; - } - } - - const entries = await this.repairBranchCache(leafId, cached?.branchId); - return this.trimPathToRootOrCompaction(entries); - } - - private async repairBranchCache(leafId: string, branchIdToReplace?: string): Promise { - const entries = await this.readCanonicalPathToRoot(leafId); - try { - await rebuildCachedBranch(this.db, this.metadata.id, leafId, branchIdToReplace); - } catch (error) { - if (error instanceof SessionError) throw error; - throw new SessionError("storage", `Failed to rebuild SQLite branch cache at entry ${leafId}`, toError(error)); - } - return entries; - } - - private async readCanonicalPathToRoot(leafId: string): Promise { - const path: SessionTreeEntry[] = []; - let current = await this.readEntry(leafId); - if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`); - const visited = new Set(); - while (current) { - if (visited.has(current.id)) throw invalidSession(`cycle in parent chain at entry ${current.id}`); - visited.add(current.id); - path.push(current); - if (!current.parentId) break; - const parent = await this.readEntry(current.parentId); - if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); - current = parent; - } - return path.reverse(); - } - - private isValidCachedPath( - entries: readonly SessionTreeEntry[], - leafId: string, - expectedStartId: string | null, - ): boolean { - if (entries.length === 0 || entries.at(-1)!.id !== leafId) return false; - if (expectedStartId === null ? entries[0]!.parentId !== null : entries[0]!.id !== expectedStartId) return false; - for (let index = 1; index < entries.length; index++) { - if (entries[index]!.parentId !== entries[index - 1]!.id) return false; - } - return true; - } - - private trimPathToRootOrCompaction(entries: readonly SessionTreeEntry[]): SessionTreeEntry[] { - const path: SessionTreeEntry[] = []; - let stopAtEntryId: string | null = null; - for (let index = entries.length - 1; index >= 0; index--) { - const entry = entries[index]!; - path.push(entry); - if (stopAtEntryId !== null && entry.id === stopAtEntryId) break; - if (entry.type === "compaction") { - if (entry.retainedTail) break; - stopAtEntryId = entry.firstKeptEntryId ?? null; - } - } - return path.reverse(); - } - - private constructor( - db: SqliteDatabase, - metadata: SqliteSessionMetadata, - materializedState: SessionMaterializedState, - ) { - this.db = db; - this.metadata = metadata; - this.byId = new Map(); - this.materializedState = materializedState; - } - - static async open(db: SqliteDatabase, metadata: SqliteSessionMetadata): Promise { - const loaded = await loadSqliteSession(db, metadata.id); - return new SqliteSessionConnection(db, rowToMetadata(loaded.row, metadata.path), loaded.materializedState); - } - - static async create( - db: SqliteDatabase, - path: string, - options: { - cwd: string; - sessionId: string; - parentSessionId?: string; - metadata?: Record; - }, - ): Promise { - const createdAt = new Date().toISOString(); - await db - .prepare( - "INSERT INTO sessions (id, created_at, metadata, cwd, parent_session_id, active_leaf_id) VALUES (?, ?, ?, ?, ?, ?)", - ) - .run( - options.sessionId, - createdAt, - options.metadata === undefined ? null : JSON.stringify(options.metadata), - options.cwd, - options.parentSessionId ?? null, - null, - ); - await db.prepare("INSERT INTO session_sequences (session_id, next_seq) VALUES (?, ?)").run(options.sessionId, 1); - await db - .prepare("INSERT INTO session_materialized (session_id, payload) VALUES (?, ?)") - .run(...materializedStateValues(options.sessionId, createEmptyMaterializedState())); - return new SqliteSessionConnection( - db, - { - id: options.sessionId, - createdAt, - cwd: options.cwd, - path, - parentSessionId: options.parentSessionId, - metadata: options.metadata, - }, - createEmptyMaterializedState(), - ); - } - - async getLabel(id: string): Promise { - return this.materializedState.labelsById.get(id); - } - - async getName(): Promise { - return this.materializedState.name; - } - - async getStats(): Promise { - const { messageCount, cachedTokens, uncachedTokens, totalTokens, costTotal } = this.materializedState; - return { messageCount, cachedTokens, uncachedTokens, totalTokens, costTotal }; - } - - async readHead(): Promise<{ leafId: string | null }> { - const row = await this.db - .prepare( - `SELECT - s.active_leaf_id, - (s.active_leaf_id IS NULL OR EXISTS ( - SELECT 1 FROM session_entries AS e WHERE e.session_id = s.id AND e.id = s.active_leaf_id - )) AS active_leaf_exists - FROM sessions AS s - WHERE s.id = ?`, - ) - .get<{ active_leaf_id: string | null; active_leaf_exists: number }>(this.metadata.id); - if (!row) throw new SessionError("not_found", `Session not found: ${this.metadata.id}`); - if (row.active_leaf_exists === 0) { - throw new SessionError("invalid_session", `Entry ${row.active_leaf_id} not found`); - } - return { leafId: row.active_leaf_id }; - } - - async appendEntry(entry: SessionTreeEntry, options: { transaction?: boolean } = {}): Promise { - if (entry.type === "leaf" && entry.targetId !== null && !(await this.readEntry(entry.targetId))) { - throw new SessionError("not_found", `Entry ${entry.targetId} not found`); - } - const encoded = encodeEntry(entry); - const nextMaterializedState: SessionMaterializedState = { - ...this.materializedState, - labelsById: new Map(this.materializedState.labelsById), - modelThinkingConfigs: [...this.materializedState.modelThinkingConfigs], - currentModel: this.materializedState.currentModel ? { ...this.materializedState.currentModel } : null, - }; - const nextLeafId = leafIdAfterEntry(entry); - try { - applyEntryToMaterializedState(nextMaterializedState, entry); - const write = async () => { - const nextSeq = await getNextSequence(this.db, this.metadata.id); - await this.db - .prepare( - "INSERT INTO session_entries (session_id, id, entry_seq, parent_id, type, timestamp, payload) VALUES (?, ?, ?, ?, ?, ?, ?)", - ) - .run(this.metadata.id, entry.id, nextSeq, entry.parentId, entry.type, entry.timestamp, encoded.payload); - await advanceSequence(this.db, this.metadata.id, nextSeq); - await this.db - .prepare("UPDATE session_materialized SET payload = ? WHERE session_id = ?") - .run(serializeSummary(nextMaterializedState), this.metadata.id); - for (const materializedEntry of entryMaterializedValues(entry)) { - await this.db - .prepare("INSERT INTO entry_materialized (session_id, entry_seq, type, payload) VALUES (?, ?, ?, ?)") - .run(this.metadata.id, nextSeq, materializedEntry.type, materializedEntry.payload); - } - await this.db - .prepare("UPDATE sessions SET active_leaf_id = ? WHERE id = ?") - .run(nextLeafId, this.metadata.id); - await appendEntryToBranchCache( - this.db, - this.metadata.id, - entry.id, - nextSeq, - entry.parentId, - async (parentId) => { - await this.repairBranchCache(parentId); - }, - ); - }; - if (options.transaction === false) await write(); - else await this.db.transaction(write); - this.materializedState = nextMaterializedState; - this.byId.set(entry.id, entry); - } catch (error) { - if (error instanceof SessionError) throw error; - throw new SessionError("storage", `Failed to append SQLite session entry ${entry.id}`, toError(error)); - } - } - - async readEntry(id: string): Promise { - const cached = this.byId.get(id); - if (cached) return cached; - const row = await this.db - .prepare( - "SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND id = ?", - ) - .get(this.metadata.id, id); - if (!row) return undefined; - try { - const entry = decodeEntry(row); - this.byId.set(entry.id, entry); - return entry; - } catch (error) { - throw invalidEntry(`failed to decode entry ${row.id}`, toError(error)); - } - } - - async readEntries(options?: SessionEntryCursorOptions): Promise { - const afterEntrySeq = options?.afterEntrySeq ?? 0; - const rows = - options?.limit === undefined - ? await this.db - .prepare( - "SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND entry_seq > ? ORDER BY entry_seq", - ) - .all(this.metadata.id, afterEntrySeq) - : await this.db - .prepare( - "SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND entry_seq > ? ORDER BY entry_seq LIMIT ?", - ) - .all(this.metadata.id, afterEntrySeq, options.limit); - const entries = decodeEntryRows(rows); - for (const entry of entries) { - this.byId.set(entry.id, entry); - } - return entries; - } -} diff --git a/packages/storage/sqlite-node/src/sqlite/storage/lanes.ts b/packages/storage/sqlite-node/src/sqlite/storage/lanes.ts new file mode 100644 index 00000000000..0b8ca7916a1 --- /dev/null +++ b/packages/storage/sqlite-node/src/sqlite/storage/lanes.ts @@ -0,0 +1,116 @@ +import { SessionError } from "@earendil-works/pi-agent-core/experimental"; +import type { SqliteDatabase } from "../types.ts"; + +export interface LaneRow { + session_id: string; + lane: string; + leaf_id: string | null; +} + +export interface LaneMoveRow { + session_id: string; + seq: number; + lane: string; + leaf_id: string | null; +} + +export function createInitialLane(db: SqliteDatabase, sessionId: string, lane = "main", leafId: string | null = null) { + db.prepare("INSERT INTO lanes (session_id, lane, leaf_id) VALUES (?, ?, ?)").run(sessionId, lane, leafId); +} + +export function readLanes(db: SqliteDatabase, sessionId: string) { + const rows = db + .prepare( + `SELECT + l.session_id, + l.lane, + l.leaf_id, + (l.leaf_id IS NULL OR EXISTS ( + SELECT 1 FROM entries AS e WHERE e.session_id = l.session_id AND e.id = l.leaf_id + )) AS leaf_exists + FROM lanes AS l + WHERE l.session_id = ? + ORDER BY l.lane`, + ) + .all(sessionId); + for (const row of rows) { + if (row.leaf_exists === 0) { + throw new SessionError("storage", `Lane ${row.lane} points at missing entry ${row.leaf_id}`); + } + } + return rows.map(({ session_id, lane, leaf_id }) => ({ session_id, lane, leaf_id })); +} + +export function readLane(db: SqliteDatabase, sessionId: string, lane: string) { + return db + .prepare("SELECT session_id, lane, leaf_id FROM lanes WHERE session_id = ? AND lane = ?") + .get(sessionId, lane); +} + +export function readLaneHead(db: SqliteDatabase, sessionId: string, lane: string) { + const row = db + .prepare( + `SELECT + l.leaf_id, + (l.leaf_id IS NULL OR EXISTS ( + SELECT 1 FROM entries AS e WHERE e.session_id = l.session_id AND e.id = l.leaf_id + )) AS leaf_exists + FROM lanes AS l + WHERE l.session_id = ? AND l.lane = ?`, + ) + .get<{ leaf_id: string | null; leaf_exists: number }>(sessionId, lane); + if (!row) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); + if (row.leaf_exists === 0) throw new SessionError("storage", `Entry ${row.leaf_id} not found`); + return { leafId: row.leaf_id }; +} + +export function createLane(db: SqliteDatabase, sessionId: string, seq: number, lane: string, leafId: string | null) { + db.prepare("INSERT INTO lanes (session_id, lane, leaf_id) VALUES (?, ?, ?)").run(sessionId, lane, leafId); + appendLaneMove(db, sessionId, seq, lane, leafId); +} + +export function moveLane(db: SqliteDatabase, sessionId: string, seq: number, lane: string, leafId: string | null) { + const result = db + .prepare("UPDATE lanes SET leaf_id = ? WHERE session_id = ? AND lane = ?") + .run(leafId, sessionId, lane); + if (result.changes !== 1) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); + appendLaneMove(db, sessionId, seq, lane, leafId); +} + +export function setLaneLeaf(db: SqliteDatabase, sessionId: string, lane: string, leafId: string | null) { + const result = db + .prepare("UPDATE lanes SET leaf_id = ? WHERE session_id = ? AND lane = ?") + .run(leafId, sessionId, lane); + if (result.changes !== 1) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); +} + +export function readLaneMoveRows(db: SqliteDatabase, sessionId: string, options: { afterSeq?: number } = {}) { + const predicates = ["session_id = ?"]; + const params: unknown[] = [sessionId]; + if (options.afterSeq !== undefined) { + predicates.push("seq > ?"); + params.push(options.afterSeq); + } + return db + .prepare( + `SELECT session_id, seq, lane, leaf_id + FROM lane_moves + WHERE ${predicates.join(" AND ")} + ORDER BY seq`, + ) + .all(...params); +} + +export function deleteLaneRows(db: SqliteDatabase, sessionId: string) { + db.prepare("DELETE FROM lane_moves WHERE session_id = ?").run(sessionId); + db.prepare("DELETE FROM lanes WHERE session_id = ?").run(sessionId); +} + +function appendLaneMove(db: SqliteDatabase, sessionId: string, seq: number, lane: string, leafId: string | null) { + db.prepare("INSERT INTO lane_moves (session_id, seq, lane, leaf_id) VALUES (?, ?, ?, ?)").run( + sessionId, + seq, + lane, + leafId, + ); +} diff --git a/packages/storage/sqlite-node/src/sqlite/storage/leases.ts b/packages/storage/sqlite-node/src/sqlite/storage/leases.ts new file mode 100644 index 00000000000..c00d6c82df2 --- /dev/null +++ b/packages/storage/sqlite-node/src/sqlite/storage/leases.ts @@ -0,0 +1,65 @@ +import type { SqliteDatabase } from "../types.ts"; + +export interface SessionLease { + ownerId: string; + fence: number; + expiresAtMs: number; +} + +interface SessionLeaseRow { + owner_id: string; + fence: number; + expires_at_ms: number; +} + +export function acquireSessionLease( + db: SqliteDatabase, + sessionId: string, + ownerId: string, + now: number, + expiresAtMs: number, +) { + const row = db + .prepare( + `INSERT INTO leases (session_id, owner_id, fence, expires_at_ms) + VALUES (?, ?, 1, ?) + ON CONFLICT(session_id) DO UPDATE SET + owner_id = excluded.owner_id, + fence = leases.fence + 1, + expires_at_ms = excluded.expires_at_ms + WHERE leases.expires_at_ms <= ? + RETURNING owner_id, fence, expires_at_ms`, + ) + .get(sessionId, ownerId, expiresAtMs, now); + return row === undefined ? undefined : { ownerId: row.owner_id, fence: row.fence, expiresAtMs: row.expires_at_ms }; +} + +export function renewSessionLease( + db: SqliteDatabase, + sessionId: string, + lease: SessionLease, + now: number, + expiresAtMs: number, +) { + const result = db + .prepare( + `UPDATE leases + SET expires_at_ms = ? + WHERE session_id = ? AND owner_id = ? AND fence = ? AND expires_at_ms > ?`, + ) + .run(expiresAtMs, sessionId, lease.ownerId, lease.fence, now); + if (result.changes === 1) lease.expiresAtMs = expiresAtMs; + return result.changes === 1; +} + +export function releaseSessionLease(db: SqliteDatabase, sessionId: string, lease: SessionLease) { + db.prepare("DELETE FROM leases WHERE session_id = ? AND owner_id = ? AND fence = ?").run( + sessionId, + lease.ownerId, + lease.fence, + ); +} + +export function deleteSessionLease(db: SqliteDatabase, sessionId: string) { + db.prepare("DELETE FROM leases WHERE session_id = ?").run(sessionId); +} diff --git a/packages/storage/sqlite-node/src/sqlite/storage/records.ts b/packages/storage/sqlite-node/src/sqlite/storage/records.ts new file mode 100644 index 00000000000..c615a5096a4 --- /dev/null +++ b/packages/storage/sqlite-node/src/sqlite/storage/records.ts @@ -0,0 +1,95 @@ +import type { SqliteDatabase } from "../types.ts"; + +export interface RecordRow { + session_id: string; + seq: number; + id: string; + lane: string; + run_id: string | null; + type: string; + op_kind: string | null; + timestamp: string; + payload: string; +} + +export interface NewRecordRow { + seq: number; + id: string; + lane: string; + runId?: string; + type: string; + opKind?: string; + timestamp: string; + payload: string; +} + +export function appendRecordRow(db: SqliteDatabase, sessionId: string, record: NewRecordRow) { + db.prepare( + `INSERT INTO records + (session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + sessionId, + record.seq, + record.id, + record.lane, + record.runId ?? null, + record.type, + record.opKind ?? null, + record.timestamp, + record.payload, + ); +} + +export function idExistsInRecords(db: SqliteDatabase, sessionId: string, id: string) { + return !!db + .prepare("SELECT 1 AS found FROM records WHERE session_id = ? AND id = ? LIMIT 1") + .get<{ found: number }>(sessionId, id); +} + +export function deleteRecordRows(db: SqliteDatabase, sessionId: string) { + db.prepare("DELETE FROM records WHERE session_id = ?").run(sessionId); +} + +export function readRecordRows( + db: SqliteDatabase, + sessionId: string, + query: { + lane?: string; + type?: string; + runId?: string; + afterSeq?: number; + order?: "newestFirst" | "oldestFirst"; + limit?: number; + } = {}, +) { + const predicates = ["session_id = ?"]; + const params: unknown[] = [sessionId]; + if (query.lane !== undefined) { + predicates.push("lane = ?"); + params.push(query.lane); + } + if (query.type !== undefined) { + predicates.push("type = ?"); + params.push(query.type); + } + if (query.runId !== undefined) { + predicates.push("run_id = ?"); + params.push(query.runId); + } + if (query.afterSeq !== undefined) { + predicates.push("seq > ?"); + params.push(query.afterSeq); + } + const limit = query.limit === undefined ? "" : " LIMIT ?"; + if (query.limit !== undefined) params.push(query.limit); + const direction = query.order === "oldestFirst" ? "ASC" : "DESC"; + return db + .prepare( + `SELECT session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload + FROM records + WHERE ${predicates.join(" AND ")} + ORDER BY seq ${direction}${limit}`, + ) + .all(...params); +} diff --git a/packages/storage/sqlite-node/src/sqlite/storage/session-entries.ts b/packages/storage/sqlite-node/src/sqlite/storage/session-entries.ts deleted file mode 100644 index 1b1cd75a8d9..00000000000 --- a/packages/storage/sqlite-node/src/sqlite/storage/session-entries.ts +++ /dev/null @@ -1,217 +0,0 @@ -import type { SessionTreeEntry, SessionTreeEntryBase } from "@earendil-works/pi-agent-core"; -import { invalidEntry, isRecord } from "./shared.ts"; - -export interface SessionEntryRow { - session_id: string; - id: string; - entry_seq: number; - parent_id: string | null; - type: SessionTreeEntry["type"]; - timestamp: string; - payload: string; -} - -export type EncodedEntry = { - payload: string; -}; - -type EntryPayload = Omit; - -type MessagePayload = EntryPayload>; -type ThinkingLevelChangePayload = EntryPayload>; -type ModelChangePayload = EntryPayload>; -type ActiveToolsChangePayload = EntryPayload>; -type CompactionPayload = EntryPayload>; -type BranchSummaryPayload = EntryPayload>; -type CustomPayload = EntryPayload>; -type CustomMessagePayload = EntryPayload>; -type LabelPayload = EntryPayload>; -type SessionInfoPayload = EntryPayload>; -type LeafPayload = EntryPayload>; - -function parsePayload(row: SessionEntryRow): unknown { - try { - return JSON.parse(row.payload); - } catch (error) { - throw invalidEntry(`entry ${row.id} payload is not valid JSON`, error instanceof Error ? error : undefined); - } -} - -function isTextImageContentArray(value: unknown): boolean { - return ( - Array.isArray(value) && - value.every( - (item) => - isRecord(item) && typeof item.type === "string" && (item.type !== "text" || typeof item.text === "string"), - ) - ); -} - -export function validateSessionTreeEntry(entry: SessionTreeEntry): void { - if (typeof entry.id !== "string" || !entry.id) throw invalidEntry("entry is missing id"); - if (entry.parentId !== null && typeof entry.parentId !== "string") { - throw invalidEntry(`entry ${entry.id} has invalid parentId`); - } - if (typeof entry.timestamp !== "string" || !entry.timestamp) { - throw invalidEntry(`entry ${entry.id} is missing timestamp`); - } - - switch (entry.type) { - case "message": - if (!isRecord(entry.message) || typeof entry.message.role !== "string") { - throw invalidEntry(`entry ${entry.id} is missing message payload`); - } - break; - case "thinking_level_change": - if (typeof entry.thinkingLevel !== "string") throw invalidEntry(`entry ${entry.id} is missing thinkingLevel`); - break; - case "model_change": - if (typeof entry.provider !== "string" || typeof entry.modelId !== "string") { - throw invalidEntry(`entry ${entry.id} has invalid model_change payload`); - } - break; - case "active_tools_change": - if ( - !Array.isArray(entry.activeToolNames) || - entry.activeToolNames.some((value) => typeof value !== "string") - ) { - throw invalidEntry(`entry ${entry.id} has invalid active_tools_change payload`); - } - break; - case "compaction": - if ( - typeof entry.summary !== "string" || - typeof entry.firstKeptEntryId !== "string" || - typeof entry.tokensBefore !== "number" || - (entry.retainedTail !== undefined && !Array.isArray(entry.retainedTail)) - ) { - throw invalidEntry(`entry ${entry.id} has invalid compaction payload`); - } - break; - case "branch_summary": - if (typeof entry.fromId !== "string" || typeof entry.summary !== "string") { - throw invalidEntry(`entry ${entry.id} has invalid branch_summary payload`); - } - break; - case "custom": - if (typeof entry.customType !== "string") throw invalidEntry(`entry ${entry.id} has invalid custom payload`); - break; - case "custom_message": - if ( - typeof entry.customType !== "string" || - typeof entry.display !== "boolean" || - !(typeof entry.content === "string" || isTextImageContentArray(entry.content)) - ) { - throw invalidEntry(`entry ${entry.id} has invalid custom_message payload`); - } - break; - case "label": - if (typeof entry.targetId !== "string" || (entry.label !== undefined && typeof entry.label !== "string")) { - throw invalidEntry(`entry ${entry.id} has invalid label payload`); - } - break; - case "session_info": - if (entry.name !== undefined && typeof entry.name !== "string") { - throw invalidEntry(`entry ${entry.id} has invalid session_info payload`); - } - break; - case "leaf": - if (entry.targetId !== null && typeof entry.targetId !== "string") { - throw invalidEntry(`entry ${entry.id} has invalid leaf payload`); - } - break; - default: { - const exhaustive: never = entry; - throw invalidEntry(`unknown entry type ${(exhaustive as { type?: string }).type ?? "unknown"}`); - } - } -} - -function entryToPayload(entry: TEntry): EntryPayload { - const { type: _type, id: _id, parentId: _parentId, timestamp: _timestamp, ...payload } = entry; - return payload as EntryPayload; -} - -export function encodeEntry(entry: SessionTreeEntry): EncodedEntry { - validateSessionTreeEntry(entry); - return { payload: JSON.stringify(entryToPayload(entry)) }; -} - -export function decodeEntry(row: SessionEntryRow): SessionTreeEntry { - const payload = parsePayload(row); - if (!isRecord(payload)) throw invalidEntry(`entry ${row.id} payload is not an object`); - const base = { - id: row.id, - parentId: row.parent_id, - timestamp: row.timestamp, - }; - - switch (row.type) { - case "message": { - if (!("message" in payload)) throw invalidEntry(`entry ${row.id} is missing message payload`); - const messagePayload = payload as MessagePayload; - return { ...base, type: "message", ...messagePayload }; - } - case "thinking_level_change": - if (typeof payload.thinkingLevel !== "string") throw invalidEntry(`entry ${row.id} is missing thinkingLevel`); - return { ...base, type: "thinking_level_change", ...(payload as ThinkingLevelChangePayload) }; - case "model_change": - if (typeof payload.provider !== "string" || typeof payload.modelId !== "string") { - throw invalidEntry(`entry ${row.id} has invalid model_change payload`); - } - return { ...base, type: "model_change", ...(payload as ModelChangePayload) }; - case "active_tools_change": - if ( - !Array.isArray(payload.activeToolNames) || - payload.activeToolNames.some((value) => typeof value !== "string") - ) { - throw invalidEntry(`entry ${row.id} has invalid active_tools_change payload`); - } - return { ...base, type: "active_tools_change", ...(payload as ActiveToolsChangePayload) }; - case "compaction": - if ( - typeof payload.summary !== "string" || - typeof payload.firstKeptEntryId !== "string" || - typeof payload.tokensBefore !== "number" || - (payload.retainedTail !== undefined && !Array.isArray(payload.retainedTail)) - ) { - throw invalidEntry(`entry ${row.id} has invalid compaction payload`); - } - return { ...base, type: "compaction", ...(payload as CompactionPayload) }; - case "branch_summary": - if (typeof payload.fromId !== "string" || typeof payload.summary !== "string") { - throw invalidEntry(`entry ${row.id} has invalid branch_summary payload`); - } - return { ...base, type: "branch_summary", ...(payload as BranchSummaryPayload) }; - case "custom": - if (typeof payload.customType !== "string") throw invalidEntry(`entry ${row.id} has invalid custom payload`); - return { ...base, type: "custom", ...(payload as CustomPayload) }; - case "custom_message": - if ( - typeof payload.customType !== "string" || - typeof payload.display !== "boolean" || - !("content" in payload) - ) { - throw invalidEntry(`entry ${row.id} has invalid custom_message payload`); - } - return { ...base, type: "custom_message", ...(payload as CustomMessagePayload) }; - case "label": - if (typeof payload.targetId !== "string") throw invalidEntry(`entry ${row.id} has invalid label payload`); - if (payload.label !== undefined && typeof payload.label !== "string") { - throw invalidEntry(`entry ${row.id} has invalid label payload`); - } - return { ...base, type: "label", ...(payload as LabelPayload) }; - case "session_info": - if (payload.name !== undefined && typeof payload.name !== "string") { - throw invalidEntry(`entry ${row.id} has invalid session_info payload`); - } - return { ...base, type: "session_info", ...(payload as SessionInfoPayload) }; - case "leaf": - if (payload.targetId !== null && typeof payload.targetId !== "string") { - throw invalidEntry(`entry ${row.id} has invalid leaf payload`); - } - return { ...base, type: "leaf", ...(payload as LeafPayload) }; - default: - throw invalidEntry(`unknown entry type ${row.type}`); - } -} diff --git a/packages/storage/sqlite-node/src/sqlite/storage/session-materialized.ts b/packages/storage/sqlite-node/src/sqlite/storage/session-materialized.ts deleted file mode 100644 index e46b543d307..00000000000 --- a/packages/storage/sqlite-node/src/sqlite/storage/session-materialized.ts +++ /dev/null @@ -1,355 +0,0 @@ -import type { SessionTreeEntry, ThinkingLevel } from "@earendil-works/pi-agent-core"; -import { invalidSession, isRecord } from "./shared.ts"; - -export interface SessionMaterializedRow { - session_id: string; - payload: string; -} - -export interface EntryMaterializedRow { - session_id: string; - entry_seq: number; - type: string; - payload: string; -} - -export interface ModelThinkingConfig { - provider: string; - modelId: string; - thinkingLevel: ThinkingLevel; -} - -export interface SessionMaterializedState { - name: string | undefined; - messageCount: number; - cachedTokens: number; - uncachedTokens: number; - totalTokens: number; - costTotal: number; - labelsById: Map; - modelThinkingConfigs: ModelThinkingConfig[]; - currentModel: { provider: string; modelId: string } | null; - currentThinkingLevel: ThinkingLevel | null; -} - -interface SessionMaterializedSummary { - name?: string; - messageCount: number; - cachedTokens: number; - uncachedTokens: number; - totalTokens: number; - costTotal: number; - currentModel?: { provider: string; modelId: string } | null; - currentThinkingLevel?: ThinkingLevel | null; -} - -function compareModelThinkingConfig(left: ModelThinkingConfig, right: ModelThinkingConfig): number { - return ( - left.provider.localeCompare(right.provider) || - left.modelId.localeCompare(right.modelId) || - left.thinkingLevel.localeCompare(right.thinkingLevel) - ); -} - -function normalizeModelThinkingConfigs(configs: readonly ModelThinkingConfig[]): ModelThinkingConfig[] { - const unique = new Map(); - for (const config of configs) { - unique.set(`${config.provider}\u0000${config.modelId}\u0000${config.thinkingLevel}`, config); - } - return [...unique.values()].sort(compareModelThinkingConfig); -} - -function addModelThinkingConfig( - state: SessionMaterializedState, - provider: string, - modelId: string, - thinkingLevel: ThinkingLevel, -): void { - state.modelThinkingConfigs = normalizeModelThinkingConfigs([ - ...state.modelThinkingConfigs, - { provider, modelId, thinkingLevel }, - ]); -} - -export function isThinkingLevel(value: unknown): value is ThinkingLevel { - return ( - value === "off" || - value === "minimal" || - value === "low" || - value === "medium" || - value === "high" || - value === "xhigh" - ); -} - -function getAssistantUsage(message: unknown): - | { - provider: string; - modelId: string; - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - costTotal: number; - } - | undefined { - if (!isRecord(message) || message.role !== "assistant") return undefined; - if (typeof message.provider !== "string" || typeof message.model !== "string") return undefined; - if (!isRecord(message.usage) || !isRecord(message.usage.cost)) return undefined; - const { input, output, cacheRead, cacheWrite } = message.usage; - const costTotal = message.usage.cost.total; - if ( - typeof input !== "number" || - typeof output !== "number" || - typeof cacheRead !== "number" || - typeof cacheWrite !== "number" || - typeof costTotal !== "number" - ) { - return undefined; - } - return { - provider: message.provider, - modelId: message.model, - input, - output, - cacheRead, - cacheWrite, - costTotal, - }; -} - -export function createEmptyMaterializedState(): SessionMaterializedState { - return { - name: undefined, - messageCount: 0, - cachedTokens: 0, - uncachedTokens: 0, - totalTokens: 0, - costTotal: 0, - labelsById: new Map(), - modelThinkingConfigs: [], - currentModel: null, - currentThinkingLevel: null, - }; -} - -export function applyEntryToMaterializedState(state: SessionMaterializedState, entry: SessionTreeEntry): void { - switch (entry.type) { - case "session_info": - state.name = entry.name?.trim() || undefined; - break; - case "label": { - const label = entry.label?.trim(); - if (label) { - state.labelsById.set(entry.targetId, label); - } else { - state.labelsById.delete(entry.targetId); - } - break; - } - case "model_change": - state.currentModel = { provider: entry.provider, modelId: entry.modelId }; - if (state.currentThinkingLevel) { - addModelThinkingConfig(state, entry.provider, entry.modelId, state.currentThinkingLevel); - } - break; - case "thinking_level_change": - if (!isThinkingLevel(entry.thinkingLevel)) break; - state.currentThinkingLevel = entry.thinkingLevel; - if (state.currentModel) { - addModelThinkingConfig(state, state.currentModel.provider, state.currentModel.modelId, entry.thinkingLevel); - } - break; - case "message": { - state.messageCount += 1; - const usage = getAssistantUsage(entry.message); - if (!usage) break; - state.cachedTokens += usage.cacheRead; - state.uncachedTokens += usage.input + usage.cacheWrite; - state.totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite; - state.costTotal += usage.costTotal; - state.currentModel = { provider: usage.provider, modelId: usage.modelId }; - if (state.currentThinkingLevel) { - addModelThinkingConfig(state, usage.provider, usage.modelId, state.currentThinkingLevel); - } - break; - } - case "compaction": - case "branch_summary": { - const usage = entry.usage; - if ( - !isRecord(usage) || - !isRecord(usage.cost) || - typeof usage.input !== "number" || - typeof usage.output !== "number" || - typeof usage.cacheRead !== "number" || - typeof usage.cacheWrite !== "number" || - typeof usage.cost.total !== "number" - ) { - break; - } - state.cachedTokens += usage.cacheRead; - state.uncachedTokens += usage.input + usage.cacheWrite; - state.totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite; - state.costTotal += usage.cost.total; - break; - } - case "active_tools_change": - case "custom": - case "custom_message": - case "leaf": - break; - default: { - const exhaustive: never = entry; - void exhaustive; - break; - } - } -} - -export function serializeSummary(state: SessionMaterializedState): string { - const summary: SessionMaterializedSummary = { - name: state.name, - messageCount: state.messageCount, - cachedTokens: state.cachedTokens, - uncachedTokens: state.uncachedTokens, - totalTokens: state.totalTokens, - costTotal: state.costTotal, - currentModel: state.currentModel, - currentThinkingLevel: state.currentThinkingLevel, - }; - return JSON.stringify(summary); -} - -function parseSummary(json: string): SessionMaterializedSummary { - let parsed: unknown; - try { - parsed = JSON.parse(json); - } catch (error) { - throw invalidSession( - `materialized session summary is not valid JSON`, - error instanceof Error ? error : undefined, - ); - } - if (!isRecord(parsed) || Array.isArray(parsed)) { - throw invalidSession("materialized session summary is not an object"); - } - const currentModel = parsed.currentModel; - const currentThinkingLevel = parsed.currentThinkingLevel; - if ( - (parsed.name !== undefined && typeof parsed.name !== "string") || - typeof parsed.messageCount !== "number" || - typeof parsed.cachedTokens !== "number" || - typeof parsed.uncachedTokens !== "number" || - typeof parsed.totalTokens !== "number" || - typeof parsed.costTotal !== "number" || - (currentModel !== undefined && - currentModel !== null && - (!isRecord(currentModel) || - typeof currentModel.provider !== "string" || - typeof currentModel.modelId !== "string")) || - (currentThinkingLevel !== undefined && currentThinkingLevel !== null && !isThinkingLevel(currentThinkingLevel)) - ) { - throw invalidSession("materialized session summary has invalid fields"); - } - return { - name: parsed.name?.trim() || undefined, - messageCount: parsed.messageCount, - cachedTokens: parsed.cachedTokens, - uncachedTokens: parsed.uncachedTokens, - totalTokens: parsed.totalTokens, - costTotal: parsed.costTotal, - currentModel: - currentModel && isRecord(currentModel) - ? { provider: currentModel.provider as string, modelId: currentModel.modelId as string } - : (currentModel ?? undefined), - currentThinkingLevel: (currentThinkingLevel as ThinkingLevel | null | undefined) ?? undefined, - }; -} - -function parseEntryMaterializedPayload(row: EntryMaterializedRow): unknown { - try { - return JSON.parse(row.payload); - } catch (error) { - throw invalidSession( - `materialized entry row ${row.entry_seq} is not valid JSON`, - error instanceof Error ? error : undefined, - ); - } -} - -export function materializedStateFromRows( - summaryRow: SessionMaterializedRow, - entryRows: EntryMaterializedRow[], -): SessionMaterializedState { - const summary = parseSummary(summaryRow.payload); - const state: SessionMaterializedState = { - name: summary.name, - messageCount: summary.messageCount, - cachedTokens: summary.cachedTokens, - uncachedTokens: summary.uncachedTokens, - totalTokens: summary.totalTokens, - costTotal: summary.costTotal, - labelsById: new Map(), - modelThinkingConfigs: [], - currentModel: summary.currentModel ?? null, - currentThinkingLevel: summary.currentThinkingLevel ?? null, - }; - for (const row of entryRows) { - const payload = parseEntryMaterializedPayload(row); - if (!isRecord(payload)) throw invalidSession(`materialized entry row ${row.entry_seq} is not an object`); - if (row.type === "label") { - if (typeof payload.targetId !== "string") { - throw invalidSession(`materialized label row ${row.entry_seq} is missing targetId`); - } - if (payload.label !== null && payload.label !== undefined && typeof payload.label !== "string") { - throw invalidSession(`materialized label row ${row.entry_seq} has invalid label`); - } - const label = typeof payload.label === "string" ? payload.label.trim() : ""; - if (label) { - state.labelsById.set(payload.targetId, label); - } else { - state.labelsById.delete(payload.targetId); - } - } - } - return state; -} - -export function materializedStateValues( - sessionId: string, - state: SessionMaterializedState, -): [sessionId: string, payload: string] { - return [sessionId, serializeSummary(state)]; -} - -export function entryMaterializedValues( - entry: SessionTreeEntry, -): Array<{ type: EntryMaterializedRow["type"]; payload: string }> { - switch (entry.type) { - case "label": - return [ - { - type: "label", - payload: JSON.stringify({ targetId: entry.targetId, label: entry.label ?? null }), - }, - ]; - case "model_change": - case "thinking_level_change": - case "message": - return []; - case "active_tools_change": - case "branch_summary": - case "compaction": - case "custom": - case "custom_message": - case "leaf": - case "session_info": - return []; - default: { - const exhaustive: never = entry; - void exhaustive; - return []; - } - } -} diff --git a/packages/storage/sqlite-node/src/sqlite/storage/session-sequences.ts b/packages/storage/sqlite-node/src/sqlite/storage/session-sequences.ts index a38bb668f51..04798f5312c 100644 --- a/packages/storage/sqlite-node/src/sqlite/storage/session-sequences.ts +++ b/packages/storage/sqlite-node/src/sqlite/storage/session-sequences.ts @@ -1,16 +1,28 @@ +import { SessionError } from "@earendil-works/pi-agent-core/experimental"; import type { SqliteDatabase } from "../types.ts"; -import { invalidSession } from "./shared.ts"; -export async function getNextSequence(db: SqliteDatabase, sessionId: string): Promise { - const sequenceRow = await db +export function createSequence(db: SqliteDatabase, sessionId: string, nextSeq = 1) { + db.prepare("INSERT INTO session_sequences (session_id, next_seq) VALUES (?, ?)").run(sessionId, nextSeq); +} + +export function getNextSequence(db: SqliteDatabase, sessionId: string) { + const sequenceRow = db .prepare("SELECT next_seq FROM session_sequences WHERE session_id = ?") .get<{ next_seq: number }>(sessionId); if (!sequenceRow) { - throw invalidSession(`missing sequence row for session ${sessionId}`); + throw new SessionError("storage", `Missing sequence row for session ${sessionId}`); } return sequenceRow.next_seq; } -export async function advanceSequence(db: SqliteDatabase, sessionId: string, nextSeq: number): Promise { - await db.prepare("UPDATE session_sequences SET next_seq = ? WHERE session_id = ?").run(nextSeq + 1, sessionId); +export function setNextSequence(db: SqliteDatabase, sessionId: string, nextSeq: number) { + db.prepare("UPDATE session_sequences SET next_seq = ? WHERE session_id = ?").run(nextSeq, sessionId); +} + +export function advanceSequence(db: SqliteDatabase, sessionId: string, seq: number) { + setNextSequence(db, sessionId, seq + 1); +} + +export function deleteSequence(db: SqliteDatabase, sessionId: string) { + db.prepare("DELETE FROM session_sequences WHERE session_id = ?").run(sessionId); } diff --git a/packages/storage/sqlite-node/src/sqlite/storage/session-stats.ts b/packages/storage/sqlite-node/src/sqlite/storage/session-stats.ts new file mode 100644 index 00000000000..ce180a0fcd6 --- /dev/null +++ b/packages/storage/sqlite-node/src/sqlite/storage/session-stats.ts @@ -0,0 +1,63 @@ +import { SessionError, type SessionStats } from "@earendil-works/pi-agent-core/experimental"; +import type { Usage } from "@earendil-works/pi-ai"; +import type { SqliteDatabase } from "../types.ts"; + +export interface SessionStatsRow { + session_id: string; + message_count: number; + cached_tokens: number; + uncached_tokens: number; + total_tokens: number; + cost_total: number; +} + +export function createStats(db: SqliteDatabase, sessionId: string): void { + db.prepare( + `INSERT INTO session_stats + (session_id, message_count, cached_tokens, uncached_tokens, total_tokens, cost_total) + VALUES (?, 0, 0, 0, 0, 0)`, + ).run(sessionId); +} + +export function readStats(db: SqliteDatabase, sessionId: string): SessionStats { + const row = db + .prepare( + `SELECT session_id, message_count, cached_tokens, uncached_tokens, total_tokens, cost_total + FROM session_stats + WHERE session_id = ?`, + ) + .get(sessionId); + if (!row) throw new SessionError("storage", `Missing stats row for session ${sessionId}`); + return { + messageCount: row.message_count, + cachedTokens: row.cached_tokens, + uncachedTokens: row.uncached_tokens, + totalTokens: row.total_tokens, + costTotal: row.cost_total, + }; +} + +export function incrementMessageCount(db: SqliteDatabase, sessionId: string): void { + const result = db + .prepare("UPDATE session_stats SET message_count = message_count + 1 WHERE session_id = ?") + .run(sessionId); + if (result.changes !== 1) throw new SessionError("storage", `Missing stats row for session ${sessionId}`); +} + +export function addUsageToStats(db: SqliteDatabase, sessionId: string, usage: Usage): void { + const result = db + .prepare( + `UPDATE session_stats + SET cached_tokens = cached_tokens + ?, + uncached_tokens = uncached_tokens + ?, + total_tokens = total_tokens + ?, + cost_total = cost_total + ? + WHERE session_id = ?`, + ) + .run(usage.cacheRead, usage.input + usage.cacheWrite, usage.totalTokens, usage.cost.total, sessionId); + if (result.changes !== 1) throw new SessionError("storage", `Missing stats row for session ${sessionId}`); +} + +export function deleteStats(db: SqliteDatabase, sessionId: string): void { + db.prepare("DELETE FROM session_stats WHERE session_id = ?").run(sessionId); +} diff --git a/packages/storage/sqlite-node/src/sqlite/storage/sessions.ts b/packages/storage/sqlite-node/src/sqlite/storage/sessions.ts index 2cb4d495985..3ea1db3c475 100644 --- a/packages/storage/sqlite-node/src/sqlite/storage/sessions.ts +++ b/packages/storage/sqlite-node/src/sqlite/storage/sessions.ts @@ -1,5 +1,5 @@ -import { SessionError } from "@earendil-works/pi-agent-core"; -import type { SqliteSessionMetadata } from "../types.ts"; +import { assertJsonSerializable, SessionError } from "@earendil-works/pi-agent-core/experimental"; +import type { SqliteDatabase, SqliteSessionMetadata } from "../types.ts"; export interface SessionRow { id: string; @@ -7,7 +7,14 @@ export interface SessionRow { metadata: string | null; cwd: string; parent_session_id: string | null; - active_leaf_id: string | null; +} + +export interface NewSessionRow { + id: string; + createdAt: string; + cwd: string; + parentSessionId?: string; + metadata?: Record; } function parseMetadata(metadata: string | null, sessionId: string): Record | undefined { @@ -17,17 +24,62 @@ function parseMetadata(metadata: string | null, sessionId: string): Record; } +export function sessionExists(db: SqliteDatabase, sessionId: string) { + return !!db.prepare("SELECT 1 AS found FROM sessions WHERE id = ?").get<{ found: number }>(sessionId); +} + +function serializeMetadata(metadata: Record | undefined): string | null { + if (metadata === undefined) return null; + if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) { + throw new SessionError("invalid_payload", "SQLite session metadata must be an object"); + } + assertJsonSerializable(metadata); + return JSON.stringify(metadata); +} + +export function insertSessionRow(db: SqliteDatabase, session: NewSessionRow) { + db.prepare("INSERT INTO sessions (id, created_at, metadata, cwd, parent_session_id) VALUES (?, ?, ?, ?, ?)").run( + session.id, + session.createdAt, + serializeMetadata(session.metadata), + session.cwd, + session.parentSessionId ?? null, + ); +} + +export function readSessionRow(db: SqliteDatabase, sessionId: string) { + return db + .prepare("SELECT id, created_at, metadata, cwd, parent_session_id FROM sessions WHERE id = ?") + .get(sessionId); +} + +export function readSessionRows(db: SqliteDatabase, options: { cwd?: string } = {}) { + return options.cwd + ? db + .prepare( + "SELECT id, created_at, metadata, cwd, parent_session_id FROM sessions WHERE cwd = ? ORDER BY created_at DESC", + ) + .all(options.cwd) + : db + .prepare("SELECT id, created_at, metadata, cwd, parent_session_id FROM sessions ORDER BY created_at DESC") + .all(); +} + +export function deleteSessionRow(db: SqliteDatabase, sessionId: string) { + db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId); +} + export function rowToMetadata(row: SessionRow, path: string): SqliteSessionMetadata { return { id: row.id, diff --git a/packages/storage/sqlite-node/src/sqlite/storage/shared.ts b/packages/storage/sqlite-node/src/sqlite/storage/shared.ts deleted file mode 100644 index 704b065076d..00000000000 --- a/packages/storage/sqlite-node/src/sqlite/storage/shared.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { SessionTreeEntry } from "@earendil-works/pi-agent-core"; -import { SessionError } from "@earendil-works/pi-agent-core"; -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -export function invalidSession(message: string, cause?: Error): SessionError { - return new SessionError("invalid_session", `Invalid SQLite session: ${message}`, cause); -} - -export function invalidEntry(message: string, cause?: Error): SessionError { - return new SessionError("invalid_entry", `Invalid SQLite session entry: ${message}`, cause); -} - -export function leafIdAfterEntry(entry: SessionTreeEntry): string | null { - return entry.type === "leaf" ? entry.targetId : entry.id; -} diff --git a/packages/storage/sqlite-node/src/sqlite/types.ts b/packages/storage/sqlite-node/src/sqlite/types.ts index 5624ccc43da..80044378489 100644 --- a/packages/storage/sqlite-node/src/sqlite/types.ts +++ b/packages/storage/sqlite-node/src/sqlite/types.ts @@ -10,17 +10,18 @@ export interface SqliteRunResult { /** Prepared SQLite statement capability used by the SQLite session backend. */ export interface SqliteStatement { - run(...params: unknown[]): Promise; - get(...params: unknown[]): Promise; - all(...params: unknown[]): Promise; + run(...params: unknown[]): SqliteRunResult; + get(...params: unknown[]): TRow | undefined; + all(...params: unknown[]): TRow[]; } /** SQLite database capability used by the SQLite session backend. */ export interface SqliteDatabase { - exec(sql: string): Promise; + exec(sql: string): void; prepare(sql: string): SqliteStatement; - transaction(fn: () => Promise): Promise; - close(): Promise; + /** Runs a synchronous write transaction. The callback must not return a promise. */ + transaction(fn: () => T): T; + close(): void; } export interface SqliteDatabaseFactory { diff --git a/tsconfig.json b/tsconfig.json index a409cdf1107..c139f41c876 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "@earendil-works/pi-ai/*": ["./packages/ai/src/*.ts", "./packages/ai/src/providers/*.ts"], "@earendil-works/pi-ai/dist/*": ["./packages/ai/src/*"], "@earendil-works/pi-agent-core": ["./packages/agent/src/index.ts"], + "@earendil-works/pi-agent-core/experimental": ["./packages/agent/src/experimental.ts"], "@earendil-works/pi-agent-core/*": ["./packages/agent/src/*"], "@earendil-works/pi-agent-sqlite-node": ["./packages/storage/sqlite-node/src/index.ts"], "@earendil-works/pi-coding-agent": ["./packages/coding-agent/src/index.ts"], From 469035841298a5569204894bc49714159acb31e9 Mon Sep 17 00:00:00 2001 From: Leonhard Breuer Date: Tue, 4 Aug 2026 16:27:08 +0200 Subject: [PATCH 18/34] Ignore docs-references directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0e241a9dc3e..e1c07f00edc 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ plans/ .pi/hf-sessions/ .pi/hf-sessions-backup/ collect.sh +docs-references From 44289550aa06750542c0ace8ab4bac0c7e68ce54 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 4 Aug 2026 17:01:43 +0200 Subject: [PATCH 19/34] feat(agent): promote durable harness API --- packages/agent/CHANGELOG.md | 6 +- packages/agent/docs/harness-v2.md | 76 +- packages/agent/docs/harness.md | 2320 ----------------- packages/agent/package.json | 10 +- packages/agent/src/experimental.ts | 1 - packages/agent/src/harness/agent-harness.ts | 1541 ++++------- .../compaction/branch-summarization.ts | 63 +- .../src/harness/compaction/compaction.ts | 116 +- .../harness/experimental/session/session.ts | 281 -- packages/agent/src/harness/messages.ts | 16 +- packages/agent/src/harness/result.ts | 63 + .../harness/session/array-session-index.ts | 187 -- packages/agent/src/harness/session/context.ts | 100 + .../{experimental => }/session/index.ts | 1 + .../agent/src/harness/session/jsonl-repo.ts | 487 ---- .../harness/session/keyed-operation-queue.ts | 69 - .../agent/src/harness/session/memory-repo.ts | 172 -- .../{experimental => }/session/memory.ts | 0 .../agent/src/harness/session/repository.ts | 71 - packages/agent/src/harness/session/search.ts | 47 +- packages/agent/src/harness/session/session.ts | 569 ++-- .../session/testing/conformance.ts | 2 +- .../session/testing/index.ts | 0 .../session/testing/types.ts | 0 .../{experimental => }/session/types.ts | 13 +- packages/agent/src/harness/types.ts | 683 +---- packages/agent/src/index.ts | 61 +- .../harness/agent-harness-scaffold.test.ts | 34 + .../test/harness/agent-harness-stream.test.ts | 208 -- .../agent/test/harness/agent-harness.test.ts | 1280 --------- .../agent/test/harness/branch-query.test.ts | 303 --- .../test/harness/branch-summarization.test.ts | 39 + .../agent/test/harness/compaction.test.ts | 147 +- packages/agent/test/harness/repo.test.ts | 645 ----- .../test/harness/session-backends.test.ts | 199 -- .../agent/test/harness/session-test-utils.ts | 49 +- packages/agent/test/harness/session.test.ts | 291 --- .../test/harness/session/context.test.ts | 124 + .../{experimental => }/session/memory.test.ts | 4 +- .../{experimental => }/session/sqlite.test.ts | 10 +- .../test/harness/sqlite-branch-cache.test.ts | 21 +- .../test/harness/sqlite-migrations.test.ts | 10 +- .../agent/test/harness/sqlite-node.test.ts | 26 +- .../agent/test/harness/tool-context.types.ts | 17 - packages/agent/test/scratch/simple.ts | 79 - packages/agent/vitest.config.ts | 2 - packages/agent/vitest.harness.config.ts | 2 - packages/storage/sqlite-node/CHANGELOG.md | 6 +- .../sqlite-node/src/sqlite/branch-cache.ts | 2 +- .../storage/sqlite-node/src/sqlite/index.ts | 7 +- .../storage/sqlite-node/src/sqlite/repo.ts | 35 +- .../src/sqlite/storage/branch-entries.ts | 2 +- .../sqlite-node/src/sqlite/storage/entries.ts | 2 +- .../sqlite-node/src/sqlite/storage/lanes.ts | 2 +- .../src/sqlite/storage/session-sequences.ts | 2 +- .../src/sqlite/storage/session-stats.ts | 2 +- .../src/sqlite/storage/sessions.ts | 4 +- scripts/browser-smoke-entry.ts | 4 +- tsconfig.json | 1 - 59 files changed, 1333 insertions(+), 9181 deletions(-) delete mode 100644 packages/agent/docs/harness.md delete mode 100644 packages/agent/src/experimental.ts delete mode 100644 packages/agent/src/harness/experimental/session/session.ts create mode 100644 packages/agent/src/harness/result.ts delete mode 100644 packages/agent/src/harness/session/array-session-index.ts create mode 100644 packages/agent/src/harness/session/context.ts rename packages/agent/src/harness/{experimental => }/session/index.ts (74%) delete mode 100644 packages/agent/src/harness/session/jsonl-repo.ts delete mode 100644 packages/agent/src/harness/session/keyed-operation-queue.ts delete mode 100644 packages/agent/src/harness/session/memory-repo.ts rename packages/agent/src/harness/{experimental => }/session/memory.ts (100%) delete mode 100644 packages/agent/src/harness/session/repository.ts rename packages/agent/src/harness/{experimental => }/session/testing/conformance.ts (99%) rename packages/agent/src/harness/{experimental => }/session/testing/index.ts (100%) rename packages/agent/src/harness/{experimental => }/session/testing/types.ts (100%) rename packages/agent/src/harness/{experimental => }/session/types.ts (97%) create mode 100644 packages/agent/test/harness/agent-harness-scaffold.test.ts delete mode 100644 packages/agent/test/harness/agent-harness-stream.test.ts delete mode 100644 packages/agent/test/harness/agent-harness.test.ts delete mode 100644 packages/agent/test/harness/branch-query.test.ts create mode 100644 packages/agent/test/harness/branch-summarization.test.ts delete mode 100644 packages/agent/test/harness/repo.test.ts delete mode 100644 packages/agent/test/harness/session-backends.test.ts delete mode 100644 packages/agent/test/harness/session.test.ts create mode 100644 packages/agent/test/harness/session/context.test.ts rename packages/agent/test/harness/{experimental => }/session/memory.test.ts (91%) rename packages/agent/test/harness/{experimental => }/session/sqlite.test.ts (86%) delete mode 100644 packages/agent/test/harness/tool-context.types.ts delete mode 100644 packages/agent/test/scratch/simple.ts diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index e3ce3dde227..94384b4fb58 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -4,12 +4,14 @@ ### Breaking Changes -- Changed `Session` into the sole opened-session aggregate and replaced `SessionStorage`, `SessionRepo`, and concrete per-session persistence classes with a non-owning `SessionRepository` and caller-owned, async-disposable `SessionStore` instances. Create stores with `createInMemorySessionStore()` or `createJsonlSessionStore()`, compose them with `createSessionRepository({ store, search: createScanningSessionSearch(store) })`, and dispose the store after draining harness and session work. -- `Session` instances are now created by `SessionRepository`; direct construction from an independently supplied store and snapshot was removed. +- Replaced the legacy harness session model with the v4 lane-based `Session`, `SessionStorage`, and `SessionRepo` APIs, including durable operation records, global facts, shared sequence numbers, and tree-scoped lane views. +- Promoted the v2 session and `AgentHarness` API from the experimental entrypoint to the default package export and removed the experimental subpaths. +- Removed the legacy JSONL and in-memory repository APIs. `InMemorySessionRepo` is the reference v4 repository; JSONL v4 support will use the new `SessionRepo` contract. ### Added - Added bounded `Session.findEntriesOnBranch()` and `findEntryOnBranch()` queries with explicit traversal, filtering, ordering, and limit options. +- Added a compile-complete `AgentHarness` v2 scaffold; unfinished operation paths reject with `HarnessNotImplemented` while durable execution is implemented. ## [0.83.0] - 2026-07-29 diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index 350602bdedd..fbe596c448c 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -1037,7 +1037,7 @@ Calls on a faulted harness reject with the same `HarnessFault` instance until th `finalMessage` is the run's newest entry that projects to an assistant message; `finalEntryId` is that entry's id. `leafId` is the lane's leaf when the operation finished — the race-free anchor for branch queries (`findEntriesOnBranch({ start: leafId })`). The two differ when a deferred write was applied after the final assistant message. Full transcripts are not duplicated into results; they are in the session and were delivered as events. -**Type provenance.** Types this document uses but does not redefine — `QueueMode`, `RetryPolicy`, `CompactionSettings`, `CompactionPreparation`, `NavigationPreparation` (today's `TreePreparation`, renamed), `CompactResult`, `ToolResultPatch`, `SessionStats`, `SessionMetadata`, `NavigateOptions`, `EntryCursor`, `LogItem`, `StreamOptionsPatch` — keep their existing `harness/types.ts` shapes. Lowercase helpers in section 15 pseudocode without a definition (`preparation`, `runToolBatchForSingleCall`, request/option bags such as `AssistantRequest` and `FactWrite`) are constructive implementation detail, not contract. +**Type provenance.** Core conversation and tool types (`AgentMessage`, `AgentTool`, `AgentToolResult`, `QueueMode`, `ThinkingLevel`) come from `packages/agent/src/types.ts`. Provider types (`Model`, `Models`, `Usage`, `RetryPolicy`, stream options, deferred handles) come from `packages/ai`. Session, harness, hook, event, result, snapshot, navigation, and durable-record types are defined by the v2 implementation under `packages/agent/src/harness/`. Lowercase helpers in section 15 pseudocode without a definition (`preparation`, `runToolBatchForSingleCall`, request/option bags such as `AssistantRequest` and `FactWrite`) are constructive implementation detail, not contract. ### Suspended operations @@ -1611,7 +1611,7 @@ Contract rules, all backends: - One writer per session, enforced by the serving layer; SQLite additionally rejects a second writer itself. Per session, not per backend: one SQLite database hosts many sessions, each with its own single writer. - Any write failure faults the harness (section 4). The store is left a valid prefix. - Global-fact and lane-move history is kept, never rewritten: latest by `seq` wins. History is the cheaper implementation (insert, never update), and lane-move history is a reflog if anyone ever wants one. -- `getStats()` for format-4 sessions is the sum of `usage` records across all lanes — one rule, nothing entry-derived, no double counting by construction. Backends maintain it as a running projection updated per record commit, so reads and the `usage` event's totals are O(1). Format-3 sessions have no records; their stats stay entry-derived. The one-time v4 conversion writes one aggregate `adjustment` record (`details: { source: "v3-import" }`) summing the v3 entries' usage, so totals survive conversion. Outside the ledger's claim: the settle-to-write crash window, unreported mid-stream billing, tools that die without reporting, and extension-private LLM calls (section 1 non-goal) — though `adjustment` records let an application close even those after the fact. +- For format-4 sessions, the token and cost fields returned by `getStats()` are the sum of `usage` records across all lanes — one rule, no entry-derived billing, and no double counting by construction. `messageCount` counts message entries appended by this session; entries copied into a fork do not increment it. Backends maintain both as running projections, so reads and the `usage` event's totals are O(1). Format-3 sessions have no records; their usage stats stay entry-derived. The one-time v4 conversion writes one aggregate `adjustment` record (`details: { source: "v3-import" }`) summing the v3 entries' usage, so totals survive conversion. Outside the ledger's claim: the settle-to-write crash window, unreported mid-stream billing, tools that die without reporting, and extension-private LLM calls (section 1 non-goal) — though `adjustment` records let an application close even those after the fact. ### Memory @@ -2840,43 +2840,51 @@ Gate invariants, asserted across Tier C: - Overflow classification against the reported provider shapes: prompt 268,009 of a 272,000 window and 81,217 of 84,500 (recoverable), non-zero reasoning-only output, cache-write-heavy usage, a Codex-style provider that rejects `max_output_tokens`, a genuine 1,024-token cap fully used (not recoverable), and `length → length` stopping after exactly one recovery per conversational input. - v3 fixtures: labels, session info, and `leaf` entries mid-chain and at end of file, old `firstKeptEntryId` compactions — all open as one normalized idle `main` lane. -## 21. Implementation sequence +## 21. Implementation status and remaining sequence -Implementation lives in `packages/agent/src/harness/experimental/`, tests in `packages/agent/test/harness/experimental/`. Nothing outside `experimental/` is modified in place; when the experimental implementation is complete and green, **everything currently under `src/harness/` outside `experimental/` is removed** and the experimental code replaces it wholesale. +Implementation lives directly in `packages/agent/src/harness/`, with v4 session tests in `packages/agent/test/harness/session/`. The v4 session and SQLite backend are the default package interfaces; there is no experimental package surface. Retained compaction, message projection, resource, tool, environment, and utility modules stay under `src/harness/` and are adapted in place. -Keep each stage passing before starting the next. +### Landed foundation -1. v4 `Session` and in-memory storage: entries, records, lanes, facts, shared `seq`. Backend-neutral parity suite. -2. JSONL v4 with the v3 read path; greenfield SQLite with the branch cache. Parity suite against all three. -3. Section 7 reduction and validity checks. Tier A before any live procedure exists. -4. Split `agent-loop.ts` into the section 14 blocks; existing `agent-loop`/`agent` tests pass unchanged. -5. `Effects`, the lane mutation line, the conditional commits, and the gate. Automatic/manual equivalence for a no-tool run. -6. The run procedure: acceptance with capture, checkpoints, steps and retries, overflow recovery, queues, deferred writes, terminal failure, conditional finish, abort. Tier B traces as they land. -7. Tool batches through the section 14 callbacks, `terminate` persistence, replay and reconciliation. The full tool crash matrix in Tier A and C. -8. Deferred provider requests through `Models`; faux-provider support for pending, ready, terminal, and cancellation outcomes. -9. Manual and auto compaction; navigation with the move-first commit. -10. Events, hooks, snapshots, telemetry context. Tier C race-catalog completion. -11. Adapt coding-agent to the new result and session APIs; run its non-e2e suites on the faux provider. -12. `npm run check` clean. +- The v4 `Session`, in-memory storage, backend-neutral conformance suite, and test helpers are the default agent package API. +- The v4 SQLite repository, branch cache, leases, forks, and conformance coverage are the default SQLite package API. +- The old harness/session runtime and experimental export surface have been removed. `AgentHarness` now exposes a compile-complete scaffold; unfinished operations fail explicitly with `HarnessNotImplemented`. +- Compaction preparation, context projection, and branch summarization use v4 `Entry` queries and no longer depend on the legacy `SessionTreeEntry` model. Reusable compaction tests and dedicated context tests cover this interim behavior. +- `Session.getBranch()` is not part of v4 and must not be reintroduced. All branch callers use `findEntriesOnBranch()` with explicit bounds and order. + +### Remaining work + +Keep every stage passing before starting the next. Replace scaffold failures only when the corresponding procedure and tests land. + +1. **Finish session-level test reconstruction.** Audit the removed legacy session and branch-query suites case by case. Keep semantics already covered by backend conformance in one place; port uncovered v4 behavior, corruption, bounded-query, fork, validation, context, and configuration-state cases to dedicated tests. Do not restore tests of deleted APIs. +2. **Implement JSONL v4 and v3 loading.** Add the v4 backend, torn-tail handling, normalization of supported coding-agent v3 files, and first-write conversion. Run the same storage conformance suite against memory, JSONL, and SQLite, plus v3 fixtures and format-specific corruption tests. +3. **Implement the section 7 reducer and validity checks.** Reconstruct idle/suspended lane state from bounded record and branch queries. Add Tier A recovery tests before adding live execution, including invalid logs and idempotent half-completed recovery. +4. **Split `agent-loop.ts` into the section 14 blocks.** Preserve the existing public wrappers and behavior; keep the existing `agent-loop` and `agent` suites unchanged and passing. +5. **Implement `Effects`, lane mutation lines, conditional commits, and manual gating.** Establish automatic/manual equivalence for a no-tool run and verify that parked procedures perform no effects. +6. **Implement the run procedure.** Replace scaffold paths for acceptance, checkpoints, durable attempts and retries, overflow recovery, queues, cancellation, deferred writes, terminal failure, conditional finish, and abort. Add Tier B traces as each path lands. +7. **Implement durable tool batches and recovery.** Wire section 14 callbacks, persist `terminate`, enforce replay policy, and complete the X1–X5 crash matrix in Tier A and Tier C. +8. **Implement deferred provider requests.** Add `Models` dispatch and faux-provider support for pending, ready, terminal, rejected-fetch, mismatched-handle, and cancellation outcomes. +9. **Integrate compaction and navigation into operations.** Reuse the v4 compaction/context helpers, persist complete `retainedTail`, implement manual and automatic compaction, and implement move-first navigation. Add operation, crash, hook, and usage-ledger tests; remove any remaining legacy declarations once no caller needs them. +10. **Implement events, hooks, snapshots, and telemetry.** Complete Tier C for every race-catalog row and test event ordering, hook isolation, snapshot subscription gaps, and fixed-point reducer checks. +11. **Adapt coding-agent to the new results and session APIs.** Restore its session behavior on the v4 harness, retain v3 session loading, and run its non-e2e suites with the faux provider. +12. **Complete the cutover audit.** Remove dead legacy declarations and compatibility comments, verify public exports and declarations, run all non-e2e tests, then run `npm run check` clean. ## 22. Required reading -For a fresh implementation session, in this order. This document wins over anything older; `harness.md` (v1 of this design) is superseded and must not be followed where they disagree. +For a fresh implementation session, in this order. This document wins over older harness designs. 1. `packages/agent/docs/harness-v2.md` — this document. -2. `packages/agent/src/agent-loop.ts` — the loop to split into the section 14 building blocks. -3. `packages/agent/src/agent.ts` — queues, continuation, abort, settlement to preserve in spirit. -4. `packages/agent/src/harness/agent-harness.ts` — the harness being replaced. -5. `packages/agent/src/harness/types.ts` — current entry union and storage contract. -6. `packages/agent/src/harness/session/session.ts` — context build, projectors, entry creation. -7. `packages/agent/src/harness/session/jsonl-repo.ts` — v3 format and reload. -8. `packages/agent/src/harness/session/memory-repo.ts` — in-memory parity. -9. `packages/agent/src/harness/messages.ts` — message conversion (toProviderMessages default). -10. `packages/agent/src/harness/compaction/compaction.ts` — preparation, split-turn summaries. -11. `packages/ai/src/utils/transform-messages.ts` — orphaned-tool-call healing. -12. `packages/coding-agent/src/core/agent-session.ts` — old behavior to preserve in spirit. -13. `packages/coding-agent/src/core/extensions/runner.ts` — old extension error isolation. -14. `packages/storage/sqlite-node/src/sqlite/storage/index.ts` — current engine: transactions, sequences, branch materialization. -15. `packages/storage/sqlite-node/src/sqlite/storage/branch-entries.ts` — the branch cache being generalized. -16. `packages/storage/sqlite-node/src/sqlite/repo.ts` — create/open/fork. -17. `packages/coding-agent/docs/session-format.md` — v3 JSONL, the compatibility target. +2. `packages/agent/src/harness/session/types.ts` — v4 entries, records, storage, and repository contracts. +3. `packages/agent/src/harness/session/session.ts` — session validation and lane-bound views. +4. `packages/agent/src/harness/session/memory.ts` — reference backend. +5. `packages/storage/sqlite-node/src/sqlite/repo.ts` — v4 SQLite repository, leases, and forks. +6. `packages/storage/sqlite-node/src/sqlite/storage/branch-entries.ts` — branch cache queries. +7. `packages/agent/src/harness/agent-harness.ts` — v2 public API scaffold. +8. `packages/agent/src/agent-loop.ts` — the loop to split into the section 14 building blocks. +9. `packages/agent/src/agent.ts` — queues, continuation, abort, settlement to preserve in spirit. +10. `packages/agent/src/harness/messages.ts` — message conversion (`toProviderMessages` default). +11. `packages/agent/src/harness/compaction/compaction.ts` — preparation and split-turn summaries. +12. `packages/ai/src/utils/transform-messages.ts` — orphaned-tool-call healing. +13. `packages/coding-agent/src/core/agent-session.ts` — old behavior to preserve in spirit. +14. `packages/coding-agent/src/core/extensions/runner.ts` — old extension error isolation. +15. `packages/coding-agent/docs/session-format.md` — v3 JSONL, the compatibility target. diff --git a/packages/agent/docs/harness.md b/packages/agent/docs/harness.md deleted file mode 100644 index b1e5d63a4ed..00000000000 --- a/packages/agent/docs/harness.md +++ /dev/null @@ -1,2320 +0,0 @@ -# Durable AgentHarness plan - -## 1. Goals - -- **Durable runs.** An accepted prompt is a durable operation. After a process crash, a new process restores the session and resumes the run from the last safe boundary. Every valid session prefix is a recoverable state. -- **Correct branch semantics.** Runs and queued messages are anchored to the branch they were accepted on. Navigation and compaction write multiple records; a crash between records must leave either a valid pre-operation state or one that recovery completes — never a half-moved cursor or a summary on the wrong branch. -- **Harness API.** Passive events to observe execution; awaited hooks to transform harness behavior (context, requests, tools, run boundaries). Extensions build on these. -- **Observability.** Everything is instrumentable — down to provider request/response internals — for logging and tracing (e.g. OTel), without going through the hook system. -- **UI model.** Atomic snapshot plus live event stream. No event replay; reconnect means new snapshot. -- **Single writer, parallel refs.** Exactly one harness writes a session at a time, enforced by the serving layer; restore treats impossible states from interleaved writers as corruption. Within that one writer, a session hosts one or more **refs** — named movable leaf pointers, each running at most one operation at a time, in parallel with its siblings (section 6). Interactive use never sees more than the default ref. -- **Old sessions load.** Existing session files open unchanged and restore as idle. Session entry types and tree semantics are unchanged. - -## Non-goals - -- **Exactly-once hook side effects.** What a hook hands to the harness (queue a message, append an entry) is durable once the call resolves and survives crashes. What a hook does on its own (HTTP calls, file writes) is invisible to the harness: after a crash it cannot know how far an interrupted handler got, so handlers are never re-run on resume. Hooks needing crash-safe external effects must be idempotent themselves, e.g. keyed by operation ID. -- **Provider stream resumption.** An interrupted provider request is retried or abandoned; partial streams are never persisted. - -## 2. Terminology - -The onion, outside in: - -- **Harness** — executes runs against one session: drives provider requests and tools, manages queues, emits events, applies hooks. Exactly one harness writes a session at a time. -- **Session** — the durable state: an ordered, append-only log of entries. Two views over the same log: the tree (session entries, conversational state) and orchestration history (harness entries). -- **Session entry** — an entry in the session tree (`message`, `compaction`, `leaf`, ...). Defines conversational ancestry via `parentId`; visible in transcripts and model context. -- **Harness entry** — a private orchestration fact (operation started, tool started, ...) used to resume a run after abnormal termination. Lives in the same log, never in the tree: no parent, never the leaf, never in model context, never emitted publicly. -- **Ref** — a named, movable pointer to a leaf of the tree plus the work serialized on it: one active operation, its own queues, its persisted config derived from the path behind its leaf. Every session has the default ref `main`; embedders may create more (section 6). - -Execution: - -- **Operation** — a run, a manual compaction, or a tree navigation, executed on one ref. At most one operation is active per ref at any time; restore treats a log with two unmatched operation starts on the same ref as corruption. -- **Run** — one accepted prompt through all automatic continuations (tool calls, steering, follow-ups, auto-compaction) until the harness is idle again. Durable; may span process restarts. -- **Step** — one generation (plus retries), the resulting assistant response, and the complete tool batch it requested. A run is a sequence of steps. -- **Generation** — one billable cycle of producing a result (assistant response, compaction or branch summary); may span several physical provider requests. A step contains one or more generations (retries). -- **Checkpoint** — the safe point between steps where queued messages are consumed, deferred writes flush, and compaction is considered. -- **Deferred write** — a session write requested while a ref has a step in flight: hooks or the application appending a custom message, or changing model/thinking level/active tools. Applying it immediately would insert content before the in-flight request's tail — splitting an assistant tool-call message from its tool results, and violating the append-only context invariant (section 5) that keeps provider KV caches valid. So it is accepted durably on request and applied at the next checkpoint of that ref. While the ref is idle, the same writes apply immediately. -- **Resume** — continuing an unfinished run in a new process, possibly mid-step. - -API: - -- **Event** — a passive public observation. Cannot alter execution; not persisted or replayed. -- **Hook** — an awaited public interception point. Can transform or block execution. -- **Snapshot** — an atomic capture of current harness state, delivered race-free with every event after it (section 8). -- **Config** — model, thinking level, system prompt, active tools, resources. Getters return the latest accepted value. Setters while a step is in flight become deferred writes; the in-flight request and tool batch are not affected. - -Central invariant: - -> Session entries define what the conversation is. Harness entries define what the harness did, in what order. Log order determines orchestration history; `parentId` and per-ref leaf pointers determine branches; harness entries never alter tree topology. - -## 3. Architecture - -```mermaid -flowchart TD - App[Application / UI] -->|prompt, steer, abort, config| Harness - Harness -->|snapshot + events| App - Harness -->|hooks + events| Ext[Extensions] - Harness --> Loop[Step primitives
request / tools] - Loop --> Provider[LLM provider] - Loop --> Tools[Tools] - Harness --> Session - Session --> Storage[(JSONL / memory / SQLite)] - Harness -.->|telemetry| Otel[Observability] -``` - -- **Step primitives** — the low-level building blocks split out of today's monolithic loop: one provider request, one tool batch, one step. Own no durable state; `runAgentLoop()` remains as a compatibility wrapper over them. -- **Harness** — the driver: accepts operations, sequences steps and checkpoints, owns queues, retry, compaction, navigation, cancellation, recovery. The only writer of harness entries. -- **Session** — owns the log and the tree view; validates and appends entries, answers branch/context queries. -- **Storage** — append + read for one session. No orchestration knowledge. - -The public surface is harness methods, events, hooks, snapshots, config, session tree queries/writes, and the telemetry stream. Harness entries, their schemas, and recovery logic are private: nothing outside harness and storage reads or depends on them. - -## 4. Run and step lifecycle - -### Run states - -States are per ref: each ref is independently Idle, Running, Cancelling, or Suspended. Faulted is the exception — an append failure faults the whole harness, every ref included, because none of them can record what it does. - -```mermaid -stateDiagram-v2 - [*] --> Idle - Idle --> Running: prompt accepted - Running --> Idle: finished - Running --> Cancelling: abort - Cancelling --> Idle: reconciled - Running --> Faulted: append failure - Suspended --> Running: resume - Suspended --> Cancelling: abort -``` - -- **prompt accepted** — the operation start is durable. Before that, a crash means the prompt never happened. -- **finished** — outcome `completed` or `failed`; the finish is durable, then the harness is idle. -- **abort** — cancellation is recorded durably, active effects are signalled, `abort()` returns. Reconciliation (tool results for unresolved calls, closing aborted assistant message) runs to completion in the background. -- **Suspended** — restore found an unfinished run. Nothing executes until `resume()`; `abort()` cancels without resuming execution. -- **Faulted** — a session append failed (disk full, I/O error). The harness stops all effects and rejects everything: it can no longer record what it does. The log is not corrupted, just a valid prefix, as after a crash. Fix the cause, reopen, restore: the run shows as Suspended. Log corruption is different: restore rejects it, no automatic path forward. - -### Steps and checkpoints - -A running operation alternates between steps and checkpoints: - -```mermaid -flowchart TD - CP[Checkpoint] --> DW[Flush deferred writes] - DW --> Q[Consume queued messages] - Q --> AC{Context too big?} - AC -->|yes| C[Auto-compact] --> S - AC -->|no| S[Step] - S --> More{Tool calls or
queued messages?} - More -->|yes| CP - More -->|no| End[before_run_end hook] - End -->|returned follow-up| CP - End -->|nothing| F[Finish run] -``` - -A step: - -```mermaid -flowchart LR - R[Request] -->|retry| R - R --> A[Assistant response persisted] - A --> T[Tool batch] --> E[Step end] -``` - -- A step with tool calls always forces another checkpoint + step; the model must see its tool results answered. -- `steer()` and `followUp()` enqueue durably at any time during a run — acceptance appends a harness entry (tree-neutral, so mid-step is fine); the message itself becomes a session entry at its consumption point. Steering is injected at the next checkpoint, before the next request. Follow-ups are consumed only when tool continuation and steering are exhausted — when the model would otherwise stop. -- Auto-compaction is evaluated against the prospective context for the next request, after queued messages and deferred writes are applied. -- `before_run_end` runs when nothing is pending: no tool continuation, no queued messages. It may return or enqueue follow-ups, each durable on acceptance. The run finishes only when nothing is pending afterwards. - -### Resume - -Resume continues the existing run; it never starts a new one: - -- Entry point is wherever the log ends: mid-step (retry the request, or reconcile an unfinished tool batch) or at a checkpoint. -- `before_run` ran when the prompt was accepted and is not called again; `before_resume` is. -- Pending queued messages and deferred writes accepted before the crash are still pending and apply normally. -- Per-request and per-tool hooks run for work actually performed after resume. - -## 5. Session and the log - -### One log, two views - -A session is an append-only log. JSONL implements this literally (one JSON object per line); other backends may use tables and indices as long as ordering and semantics match. A record is either a **session entry** (tree) or a **harness entry** (orchestration). Log order is total; tree structure comes only from `parentId` on session entries. - -Harness entries exist to resume a run after abnormal termination. They record accepted operations, issued provider requests, started tools, queued messages, and deferred writes, so a new process can tell how far execution got and continue without repeating effects. Nothing reads them during normal operation. - -```mermaid -flowchart TB - subgraph LOG ["log (append order, top to bottom)"] - direction TB - h1(["op_started op-1"]) - u1["message user U1"] - h2(["generation_started"]) - a1["message assistant A1"] - h3(["tool_started call-1"]) - h4(["queue_enqueued steer S1"]) - t1["message toolResult T1"] - s1["message user S1"] - a2["message assistant A2"] - h5(["op_finished op-1"]) - h1 ~~~ u1 ~~~ h2 ~~~ a1 ~~~ h3 ~~~ h4 ~~~ t1 ~~~ s1 ~~~ a2 ~~~ h5 - end - u1 -->|parent| a1 -->|parent| t1 -->|parent| s1 -->|parent| a2 -``` - -Rounded records are harness entries: no `parentId`, never the leaf, never in model context or transcripts, invisible through `SessionTree`. Rectangular records connected by `parent` arrows are session entries; the tree is that chain. - -Consequences: - -- Harness entries can be appended mid-step (steering, deferred-write acceptance) without touching tree ordering. -- Navigation, compaction, and forks (section 13) operate on the tree; orchestration facts are never hidden by a compaction barrier or copied into a fork. -- Old files contain no harness entries and restore idle. - -### Durability rule - -> Before an effect: append an intent entry naming what will happen and the ids it will produce. After the effect: append the result as a session entry with those ids. - -No multi-record atomicity. Any log prefix is a valid state: an intent without its result means in flight or interrupted; recovery (section 12) decides completion per intent type. - -### Append-only context - -> Across the requests of a branch, provider context only ever grows at the tail. Inserting content before the previous request's tail invalidates the provider's KV cache from the insertion point onward — silently multiplying token cost. - -This invariant, not just tool-call adjacency, is why mid-step writes defer to checkpoints: checkpoint application and queue consumption append at the tail, so the cached prefix survives every request. Compaction is the one deliberate exception — it trades a full cache invalidation for a smaller context, knowingly. - -Two mechanisms carry mid-run content, with deliberately different contracts: - -- **Queues** carry conversational intent: steer/followUp die on abort (payloads returned so a client can requeue), nextRun survives. The session entry lands at the consumption point — the position the model actually first saw it. -- **Deferred writes** carry facts: they survive abort and are applied even during cancellation reconciliation. Both are durable at acceptance. - -Custom entries enter provider context only through registered projectors (`entryProjectors`: custom entry → context messages, evaluated at context build); without one they project to nothing and cannot affect the cache. Corollaries: projector output must be stable across context builds for entries already in context, and registering a projector later re-animates existing entries at their historical positions — a one-time cache break, the application's responsibility. - -### Provisioned ids - -Intent entries carry ids of session entries that do not exist yet: `tool_started.resultEntryId`, `queue_enqueued.target.id`, the operation start's initial message ids. The later session entry uses exactly that id. An intent is fulfilled iff an entry with its provisioned id exists; an id collision with different content is corruption. - -```ts -/** A session entry payload with its id pre-allocated. parentId and timestamp - are assigned when the entry is actually appended: it becomes a child of the - then-current leaf, exactly like a normal append. */ -type ProvisionedEntry = Omit; -type ProvisionedMessage = ProvisionedEntry; -``` - -### Harness entry schemas - -```ts -interface HarnessEntryBase { - id: string; - seq: number; // position in the chronological log - ref: string; // the ref this record belongs to ("main" in a single-ref session) - timestamp: string; -} -// Session entries carry no ref — the tree is shared between refs (common -// prefixes), so a ref field would fake ownership that does not exist. Which -// ref appended a session entry is derivable: a ref's operation appends a -// chain from its anchor, so membership is parentId linkage into that chain — -// one pass over the bounded tail, in seq order. Leaf records are the -// exception: they carry ref explicitly, they ARE the per-ref pointer. -// Old files: everything reads as "main". -// Entries that belong to an operation carry runId: the id of that operation's -// operation_started entry. Not on the base: queue_enqueued(nextRun) belongs to -// no operation — it targets the next run, and can be accepted while idle. - -// The durable acceptance boundary for an operation. Everything decided -// before acceptance is persisted here: before_run output, queued next-run -// consumption, provisioned ids for structural results. -interface OperationStartedEntry extends HarnessEntryBase { - type: "operation_started"; - sourceLeafId: string | null; // the ref's leaf at acceptance - intent: - | { - kind: "run"; - /** Prompt + before_run injections, full payloads with provisioned ids. */ - initialMessages: ProvisionedMessage[]; - /** Set iff before_run overrode the system prompt; fixed for the run. - Absent: the systemPrompt config callback is evaluated per request - (sees current active tools — mid-run tool changes rebuild the prompt). */ - systemPromptOverride?: string; - resumeData?: Record; // per extension id - } - | { - kind: "compaction"; - customInstructions?: string; - resultEntryId: string; - } - | { - kind: "navigation"; - targetId: string; - destinationLeafId: string | null; - summarize: boolean; - customInstructions?: string; - label?: string; - summaryEntryId?: string; - labelEntryId?: string; - leafEntryId: string; - }; -} - -// Cancellation is durable the moment abort() resolves. Clears this -// operation's steer/follow-up queue items; next-run items survive. -interface OperationCancelledEntry extends HarnessEntryBase { - type: "operation_cancelled"; - runId: string; - reason: "user" | "shutdown"; -} - -// Closes the operation. Cursor-neutral: cannot undo a navigation's leaf move. -// outcome "failed" is a durable, orderly failure: retry attempts exhausted, -// compaction/summary generation permanently failing, required model missing. -// outcome "cancelled" is a structural operation declined by its hook -// (before_compaction/before_navigation cancel). Either way the operation is -// closed; nothing resumes. Distinct from Faulted (section 4): a fault means -// appends fail, so no finish entry can be written and the operation restores -// as suspended instead. -interface OperationFinishedEntry extends HarnessEntryBase { - type: "operation_finished"; - runId: string; - outcome: "completed" | "aborted" | "failed" | "cancelled"; - error?: { code: string; message: string }; // safe fields only, no payloads -} - -// Appended before each generation cycle the harness bills to a provider — an -// uncertainty marker ("a request may have gone out and been billed") and the -// durable crash-loop bound: attempt counts survive restarts. One entry per -// cycle, even when a cycle makes several physical requests (split-turn -// compaction runs two); recovery granularity is the result entry, so finer -// accounting buys nothing. Position and completion are positional: attempts -// after the newest session entry belong to the current request cycle, and a -// session entry committing closes the cycle. Validation: attempt numbers are -// consecutive within each gap between session entries (a gap is always -// single-purpose — compaction either commits its entry, closing the gap, -// or fails the run). All positional rules are per ref: "newest session -// entry" means the newest session entry chained by this ref's operation -// (parentId membership, see above) — the per-ref partition is what makes -// positional reduction safe under interleaved refs. -interface GenerationStartedEntry extends HarnessEntryBase { - type: "generation_started"; - runId: string; - purpose: "step" | "compaction" | "branch_summary"; - attempt: number; // 1-based within the current cycle - model: { provider: string; modelId: string }; -} - -// Appended after before_tool and validation, before the effect starts. -// assistantEntryId + toolIndex is the durable invocation identity. -interface ToolStartedEntry extends HarnessEntryBase { - type: "tool_started"; - runId: string; - assistantEntryId: string; - toolIndex: number; - toolCallId: string; - toolName: string; - effectiveArgs: Record; // post-before_tool - resultEntryId: string; // provisioned - replay: "never" | "safe"; -} - -// steer()/followUp()/nextRun() acceptance. The message payload (any -// AgentMessage that converts to a user LLM message) travels here; the session -// entry appears at the consumption point. Steer/follow-up items resolve within -// their run. Next-run items are consumed by the next run-kind operation on the -// same ref, embedded in its operation_started initialMessages; compaction and -// navigation pass through without consuming. Pending items therefore always -// sit after the ref's last run-kind operation_started — recovery never scans -// further back. -interface QueueEnqueuedEntry extends HarnessEntryBase { - type: "queue_enqueued"; - queue: "steer" | "followUp" | "nextRun"; - /** steer/followUp: their active run. Absent for nextRun — the item belongs - to no operation until the next run consumes it. */ - runId?: string; - target: ProvisionedMessage; -} - -// SessionTree write or config setter accepted mid-step: message, custom entry, -// label, session name, or config change (model_change, ...). Applied in -// acceptance order at the next checkpoint — live or during recovery, each -// target is appended as a child of the then-current leaf; that is why -// ProvisionedEntry omits parentId. -interface WriteDeferredEntry extends HarnessEntryBase { - type: "write_deferred"; - runId: string; - target: ProvisionedEntry; // full payload, provisioned id -} -``` - -Blocked, invalid, or truncation-failed tool calls append no `tool_started` — no external effect begins; they go straight to a synthetic error result. - -### Log invariants - -Restore rejects a log violating any of these as corrupt: - -- at most one unmatched `operation_started` per ref -- operation events reference an existing operation of the same ref; finish/cancel never precede start -- attempt numbers are consecutive from 1 within each gap between session entries of the same ref -- tool invocation identities are unique; their assistant entries exist -- provisioned ids never collide with differing content -- a ref's active run cursor moves only through that run's appends - -### SessionTree - -`SessionTree` is new: the tree-facing contract over the log. `Session` implements it plus the log side below. Each ref exposes its own harness-owned `SessionTree` view (`ref.session`; `harness.session` is main's): branch-scoped reads default to that ref's leaf, appends chain to it, and writes defer while that ref has a step in flight — an immediate append would land before the in-flight request's tail, breaking tool-call adjacency and the append-only context invariant. Writes through one ref's view never defer because another ref is busy. Standalone `Session` writes apply immediately. - -```ts -/** Filters and paging. Omit type to match every entry. */ -interface EntryQuery { - type?: SessionTreeEntry["type"]; - customType?: string; // for type: "custom" - order?: "newestFirst" | "oldestFirst"; // default newestFirst - limit?: number; - cursor?: EntryCursor; // continue a previous page -} - -/** Where a branch scan starts and stops. Defaults: the whole path, leaf to root. */ -interface BranchBounds { - /** Leaf end of the path. Default: the view's ref leaf. Needed to query another branch or an old compaction tail. */ - start?: string; - /** Scan ends after the first matching entry, inclusive. */ - stopAtType?: SessionTreeEntry["type"]; - stopAtId?: string; -} - -// Generic over metadata; finders return Extract. -interface SessionTree { - // Reads — always allowed - getMetadata(): Promise; - getEntry(id: string): Promise; - getLeafId(): Promise; - getStats(): Promise; - - // Global facts — latest-wins records outside the tree, not branch-scoped. - // Setter naming is deliberate: "append" is reserved for tree writes. - getName(): Promise; - setName(name: string): Promise; - getLabel(id: string): Promise; - setLabel(targetId: string, label: string | undefined): Promise; - - /** Session-wide: all branches, log order. */ - findEntries(query?: EntryQuery): Promise; - - /** Sugar: findEntries with limit 1. */ - findEntry(query?: Omit): Promise; - - /** Branch-scoped: the path start (default leaf) → root. */ - findEntriesOnBranch(query?: EntryQuery & BranchBounds): Promise; - - /** Sugar: findEntriesOnBranch with limit 1. */ - findEntryOnBranch(query?: Omit & BranchBounds): Promise; - - // Writes — immediate on standalone Session, deferred while a harness runs. - // Resolve on durable acceptance; the returned string is the provisioned entry - // id the session entry will carry when applied. Safe to call from hook and - // event handlers at any point. - appendMessage(message: AgentMessage): Promise; // includes custom app message types - appendCustomEntry(customType: string, data?: unknown): Promise; - - /** Writes accepted but not yet applied, in acceptance order. Empty on standalone Session. */ - getPendingWrites(): { id: string; entry: SessionTreeEntry }[]; -} -``` - -Query semantics — a branch scan is: take the path from `start` (default: active leaf) to root, walk it in `order` direction, stop after a `stopAt` match (inclusive), filter, apply `limit`/`cursor`: - -- `newestFirst` walks leaf→root: `stopAtType: "compaction"` ends at the **newest** compaction — the context window. -- `oldestFirst` walks root→leaf: the same query ends at the **oldest** compaction. The barrier is found in walk direction. For the newest-compaction segment in chronological order, fetch `newestFirst` and reverse; it is one context window, not a big list. -- `type`/`customType` filter results; a `stopAt` entry is returned only if it passes the filter. -- These subsume `getBranch()` and `getPathToRootOrCompaction()`: context build is `findEntriesOnBranch({ stopAtType: "compaction" })`; old-style compaction tails are a second call with `start: compaction.parentId, stopAtId: firstKeptEntryId`. -- Extension patterns: effective state = `findEntryOnBranch({ type: "custom", customType })`; branch collections = `findEntriesOnBranch({ type: "custom", customType })`; global inventory = `findEntries({ type: "custom", customType })`. -- `SessionTree` has no cursor mutation; tree navigation is `navigateTree()` on the harness. - -> **Read consistency:** finders and `getEntry()` return committed entries only. A deferred write is not in the tree until applied; a handler that appends and immediately queries will not see its own write. A pending entry has no `parentId` yet, and any overlaid position would be a guess that later entries invalidate. Pending writes are visible via `getPendingWrites()` and the snapshot, correlated by provisioned id. - -### Session API changes - -`Session` implements `SessionTree` and gains the log side, used only by the harness and recovery: - -```ts -class Session implements SessionTree { - appendHarnessEntry(entry: HarnessEntryInput): Promise; - /** Full chronological log — session and harness entries interleaved. */ - getLog(options?: { afterSeq?: number; limit?: number }): Promise; - - /** Typed harness-entry queries, same shape as the tree finders. SQLite - serves them from an indexed harness-entry table. */ - findHarnessEntries(query?: { type?: HarnessEntry["type"]; ref?: string; runId?: string; afterSeq?: number; order?: "newestFirst" | "oldestFirst"; limit?: number }): Promise; - findHarnessEntry(query?): Promise; // limit 1 -} -``` - -Restore reads are bounded regardless of session length, per ref: the ref's latest `operation_started`/`operation_finished` (index seek) locates its active operation, and everything else recovery needs — attempt counts, tool starts, pending queue items, deferred writes — lives at `seq` greater than that operation's start, filtered by ref (range scan). Pending next-run items cannot sit further back than the ref's last run-kind `operation_started` because run acceptance consumes them (see `QueueEnqueuedEntry`); SQLite stores ref and operation kind as columns, so locating them is an index seek. - -Changes to the existing contract: - -- `getPathToRootOrCompaction()` and `getBranch()` are removed — subsumed by `findEntriesOnBranch`. The duplicated walk logic in the JSONL and SQLite backends is deleted. -- `buildContext()` is reimplemented on the finders: one branch scan with `stopAtType: "compaction"`, plus the old-style tail scan (`start: compaction.parentId, stopAtId: firstKeptEntryId`) when the compaction entry predates embedded tails. -- Config derivation leaves `buildContext()`: model/thinking/active tools are point queries (`findEntryOnBranch`), correct across compaction barriers — and per ref, since each ref queries from its own leaf. -- Labels and session name are **de-treed**: `LabelEntry` and `SessionInfoEntry` records lose `parentId` and live outside the tree as global latest-wins facts (single writer makes log order a valid last-writer-wins order). They no longer appear in branch queries; old tree-entry forms convert on read. Fork handling: section 13. -- Leaf records gain `ref`; a session keeps one leaf pointer per ref (absent = `main`, which is how old files read). -- `custom_message` entries convert to custom agent messages on read; the entry type is retired from the write path. -- `getStorage()` is gone: raw storage is unreachable, all writes flow through `Session`, and `Session` is the single writer the log format assumes. -- JSONL becomes format v4: same file, same one-JSON-object-per-line, harness entries interleaved. v3 files load unchanged (zero harness entries, restore idle). - -Storage backends implement append + read + the finder queries; they know nothing about operations, queues, or recovery (section 14). - -## 6. Refs - -A **ref** is a named, movable pointer to a leaf of the tree, plus the work serialized on it. It is what a git branch is — a name attached to a position, advanced by new work, movable to any point without rewriting history — fused with its worktree: at most one operation runs on a ref at a time, exactly as git refuses to check the same branch out into two worktrees. One intuition git users must extend: navigation can move a ref to *any* entry (like `git reset`), not only forward. - -```text -tree (shared, append-only) refs -a ── b ── c ── d main → d - └── e ── f slack:1719432.0021 → f -``` - -- Every session has the default ref **`main`**. `AgentHarness` implements the ref surface directly (section 7): `harness.prompt(...)` *is* main's prompt. Interactive pi never creates a second ref — one active branch, resume-where-you-left-off, `/tree` — nothing about that model changes. -- Embedders create refs keyed by external identities: Slack channel = session + `main`, each thread = a ref anchored at the pinged entry; each email thread = a ref. The platform's UI is the ref picker; no client browses refs abstractly, and end users never see the word. -- Each ref owns: its leaf; one active operation; its steer/followUp/nextRun queues; its pending deferred writes; and its persisted config view — model, thinking level, and active tools are point queries on the path behind its leaf, so two refs run different models without knowing of each other. Harness-global stay: tool implementations, resources, stream options, retry policy — registries and runtime capabilities are shared, activation is per-ref and branch-anchored. -- Refs are pointers, not containers. Deleting one removes the name, never entries. Two refs at the same entry diverge on their next append. `navigateTree` moves one ref. -- Refs run in parallel under one harness: one writer, one log, interleaved records partitioned by `ref`. Cross-process concurrency stays out of scope — all traffic for a session routes to the process holding its harness. -- After a crash, every ref with an unfinished operation restores suspended, independently; `create()` returns them all (section 7). - -**Why refs are not trees.** Harness entries carry `ref` but no parent pointers, deliberately. Within one ref, operations are serialized and there is one writer — so for records filtered by ref, log order *is* causal order. A parent pointer would repeat what `ref` + append order already say, and add validation surface (parent exists, chain does not fork, chain belongs to the operation) with no consumer. Parenting earns its keep only when order stops being reliable: concurrent writers to the *same* ref (excluded by design) or replication without a total order (section 17). - -## 7. Public API - -```ts -const { harness, suspended } = await AgentHarness.create({ session, models, model, ... }); - -for (const s of suspended) { - await harness.ref(s.ref)!.resume(); // or .abort(); interactive pi: 0 or 1, always "main" -} -await harness.prompt("..."); -``` - -The existing surface stays. New: `create()` replaces the constructor, `resume()` continues a suspended operation, `watch()` provides snapshots (section 8), `hooks`/`events` replace `on()`/`subscribe()` (sections 9, 10) — and the operation surface is factored into `AgentRef`, which `AgentHarness` implements for `main`. There is one operation surface, defined once. - -```ts -interface AgentHarnessOptions { - session: Session; - /** Provider collection for all requests: steps, compaction, branch summaries. */ - models: Models; - /** model, thinkingLevel, activeToolNames: initial values only. If a ref's - branch has persisted config entries, the session wins; these apply to fresh - sessions and refs without config history. An unresolvable persisted model - surfaces in suspended[].missing / falls back with a warning, mirroring the - old coding agent. */ - model: Model; - thinkingLevel?: ThinkingLevel; - tools?: TTool[]; - activeToolNames?: string[]; - toolContext?: TContext | (() => TContext | Promise); - systemPrompt?: AgentHarnessSystemPrompt; - resources?: AgentHarnessResources; - /** Curated provider request options (transport, headers, timeouts, provider-internal retries). Snapshotted at request start. */ - streamOptions?: AgentHarnessStreamOptions; - /** Harness-level retry for failed requests (steps, compaction, branch summaries). Attempt counts are durable; a restart never resets them. */ - retry?: RetryPolicy; - compaction?: CompactionSettings; // enabled, reserveTokens, keepRecentTokens - steeringMode?: QueueMode; - followUpMode?: QueueMode; - /** Converts AgentMessages (including app custom types via CustomAgentMessages) - to provider messages before each request. Default: exported - defaultConvertToLlm, handling bashExecution, custom, branchSummary, - compactionSummary; standard messages pass through. Also used at - prompt/queue acceptance to validate that a submitted AgentMessage - converts to a user message. Reordering/pruning is not its job; that is - transform_context. */ - convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; -} - -/** The operation surface of one ref. AgentHarness implements it for main; - harness.ref(name) returns the same surface for any other ref. */ -interface AgentRef { - readonly name: string; // "main" on the harness itself - getLeafId(): Promise; - - // Operations. Never throw — they resolve with a result value in every case - // (see "Results, not exceptions" below). Message forms mirror Agent: any - // AgentMessage accepted here (and by the queues) must convert to a user LLM - // message via convertToLlm; validated at acceptance. At most one operation - // is active per ref; operations on different refs run concurrently. - prompt(text: string, images?: ImageContent[]): Promise; - prompt(message: AgentMessage | AgentMessage[]): Promise; // e.g. sendMessage triggerTurn - skill(name: string, additionalInstructions?: string): Promise; - promptFromTemplate(name: string, args?: string[]): Promise; - compact(options?: { customInstructions?: string; settings?: Partial }): Promise; - navigateTree(targetId: string, options?: NavigateTreeOptions): Promise; - - /** New. Continue this ref's suspended operation to its durable end. */ - resume(): Promise; - - /** Existing. Now durable: cancellation is recorded before effects are signalled; returns without waiting for reconciliation. No-op while this ref is idle. */ - abort(): Promise; - - // Queues (existing) — now durable on resolve, and per ref (nextRun included: - // consumed by this ref's next run). Payloads are AgentMessage: user messages - // and custom extension messages (sendMessage deliverAs). As with prompt(), - // every AgentMessage must convert to a user LLM message via convertToLlm; - // validated at acceptance. - steer(text: string, images?: ImageContent[]): Promise; // requires active run - steer(message: AgentMessage): Promise; - followUp(text: string, images?: ImageContent[]): Promise; // requires active run - followUp(message: AgentMessage): Promise; - nextRun(text: string, images?: ImageContent[]): Promise; // any time; was nextTurn() - nextRun(message: AgentMessage): Promise; - - // Idle coordination — this ref's idleness. - waitForIdle(): Promise; // existing - runWhenIdle(callback): Promise; // new; runtime-only, not durable - - // Persisted, branch-anchored config — per ref by construction: session - // entries on the path behind this ref's leaf, restored by point queries. - // Setters mid-step become deferred writes on this ref. - getModel() / setModel(model) - getThinkingLevel() / setThinkingLevel(level) - getActiveTools() / setActiveTools(toolNames) // unknown names reject - - /** This ref's SessionTree view (section 5): branch reads default to this - ref's leaf, appends chain to it, writes defer while this ref runs. - Replaces appendMessage(). */ - session: SessionTree; - - /** Scoped: this ref's transcript, run state, queues, and events (section 8). */ - watch(): Promise<{ snapshot: RefSnapshot, start, unsubscribe }>; -} - -class AgentHarness implements AgentRef { - /** Opens the session log, restores state, starts no effects. Replaces the - constructor. One suspended entry per ref with an unfinished operation. */ - static create(options: AgentHarnessOptions): Promise<{ - harness: AgentHarness; - suspended: SuspendedOperation[]; - }>; - - // Ref management. Names are app-chosen keys ("slack:1719432.0021"). - ref(name: string): AgentRef | undefined; // lookup, never creates - createRef(name: string, at: string | null): Promise; // anchor at an entry or root - deleteRef(name: string): Promise; // pointer only; rejected while its - // operation is active or suspended; - // "main" cannot be deleted - refs(): RefInfo[]; - - // Harness-global config — registries and runtime capabilities, shared by - // all refs. Tool implementations are code and cannot persist; the active - // set (names) is what persists, per ref. - getTools() / setTools(tools, activeToolNames?) // registry; active set applies to main - getResources() / setResources(resources) - getStreamOptions() / setStreamOptions(streamOptions) - getRetryPolicy() / setRetryPolicy(policy) // new - getCompactionSettings() / setCompactionSettings(s) // new - getSteeringMode() / setSteeringMode(mode) - getFollowUpMode() / setFollowUpMode(mode) - - /** Session-wide observer: refs inventory snapshot plus the unfiltered event - stream (section 9). No transcripts — compose with ref.watch() per ref. */ - watchSession(): Promise<{ snapshot: SessionSnapshot, start, unsubscribe }>; - - // Harness-global; every hook/event payload carries ref (sections 9, 10). - hooks: ...; - events: ...; - - /** New. Detach cleanly — see semantics below. Does not abort operations; they stay resumable. */ - close(): Promise; -} - -interface RefInfo { - name: string; - leafId: string | null; - run: null | { id: string; kind: "run" | "compaction" | "navigation"; - status: "running" | "suspended" | "cancelling" }; -} - -type RefResult = { ok: true; ref: AgentRef } | { ok: false; outcome: "rejected"; error: ErrorInfo }; - -interface SuspendedOperation { - ref: string; - kind: "run" | "compaction" | "navigation"; - id: string; - startedAt: string; - /** For runs: original prompt content (text and images), for display. */ - prompt?: (TextContent | ImageContent)[]; - /** The operation was cancelled pre-crash; resume() completes the abort. Undelivered - steer/follow-up payloads are returned here — the crash-path equivalent of - AbortResult — so a client can offer to requeue them. */ - cancelled?: { clearedSteer: AgentMessage[]; clearedFollowUp: AgentMessage[] }; - /** Identities the log references that current config cannot resolve. Non-empty: resume() resolves rejected. */ - missing: { tools: string[]; models: string[] }; -} -``` - -### Results, not exceptions - -Operation and queue methods never throw. Every call resolves with a result; a promise rejection is a bug, not an outcome. The invariant: a **durable outcome** corresponds exactly to an `operation_started`…`operation_finished` pair in the log (with matching events); `rejected` corresponds to a log that was not written; `faulted` to a log that can no longer be written. - -Result shape: `ok: true` carries the goods and nothing else; `ok: false` is a discriminated union of everything that went differently. Typical caller code is one check; code that cares switches on `outcome`. - -```ts -interface ErrorInfo { code: string; message: string } - -/** Failures shared by all methods. */ -type Failure = - | { ok: false; outcome: "rejected"; error: ErrorInfo } - // the call never became an operation — no runId exists anywhere, the log is untouched - | { ok: false; outcome: "faulted"; runId?: string; error: ErrorInfo }; - // appends stopped working. runId present: the operation started and restores as - // suspended after reopening. absent: the fault hit the acceptance append itself. - -// finalMessage is defined via the entry→message projection: the run's newest -// message entry that projects to an AssistantMessage. Custom entries project to -// nothing and custom messages project to user messages, so neither can be -// finalMessage by construction. Full transcript content is not duplicated in -// results — it is in the session (branch query scoped to the run) and was -// delivered via events. -type RunResult = - | { ok: true; runId: string; finalMessage: AssistantMessage } - | { ok: false; outcome: "aborted"; runId: string; finalMessage: AssistantMessage } // the aborted closure - | { ok: false; outcome: "failed"; runId: string; error: ErrorInfo; finalMessage?: AssistantMessage } - // finalMessage absent when the run failed before any assistant response (e.g. auto-compaction) - | Failure; - -type CompactionRunResult = - | { ok: true; runId: string; entry: CompactionEntry } - | { ok: false; outcome: "cancelled" | "aborted"; runId: string } // hook declined / user abort - | { ok: false; outcome: "failed"; runId: string; error: ErrorInfo } - | Failure; - -type NavigationRunResult = - | { ok: true; runId: string; newLeafId: string | null; summaryEntry?: BranchSummaryEntry } - | { ok: false; outcome: "cancelled" | "aborted"; runId: string } - | { ok: false; outcome: "failed"; runId: string; error: ErrorInfo } - | Failure; - -type QueueResult = - | { ok: true } // durably accepted in the log - | Failure; - -// Runs can never be "cancelled" (no hook vetoes an accepted run); -// kind discriminates which result shape applies. -type ResumeResult = - | ({ kind: "run" } & RunResult) - | ({ kind: "compaction" } & CompactionRunResult) - | ({ kind: "navigation" } & NavigationRunResult); -``` - -```ts -const result = await harness.prompt("..."); -if (!result.ok) { - showError(result); // switch on result.outcome for finer handling - return; -} -render(result.finalMessage); -``` - -Rejection reasons (`error.code`): `busy` (this ref), `suspended_pending`, `no_active_run` (steer/follow-up while the ref is idle), `nothing_to_resume`, `missing_identities` (resume with `missing`), `invalid_message` (does not convert to a user message), `unknown_skill`, `unknown_template`, `unknown_target`, `unknown_ref`, `ref_exists`, `invalid_ref` (createRef with unknown anchor or reserved name), `nothing_to_compact`, `closed`, `faulted` (harness already faulted when called). - -Why rejections are not `outcome: "failed"`: `failed` is a durable fact — the run happened, may have cost money, and its end is recorded. A rejection is the absence of any fact: no runId that appears anywhere, no events, nothing to resume. Callers also handle them differently — rejected means fix the call or wait; failed means the run is over, show the error. Faults are neither: the operation may still be resumable after the underlying cause is fixed, so claiming a terminal outcome would lie about the log. - -Semantics not visible in the signatures: - -- `prompt()`/`skill()`/`promptFromTemplate()` resolve when the run reaches its durable end; `finalMessage` carries the answer when one exists. A failed auto-compaction or exhausted retries resolve `outcome: "failed"` — no assistant message is fabricated for the return value (the transcript still gets an error assistant message where one naturally belongs, i.e. failed provider steps and aborts). Operations resolve `rejected` while the same ref has an operation active or suspended — a suspended operation must be resumed or aborted first, explicitly. Other refs are unaffected. -- `steer()`/`followUp()`/`nextRun()` resolve when the message is durably accepted, not when consumed. Consumption rules live with `QueueEnqueuedEntry` (section 5): steer/follow-up resolve within their run; next-run items are consumed by the same ref's next run, prepended to its initial messages. Once a run start exists, its initial messages are guaranteed to be appended — by recovery if necessary, even if the run is then cancelled — so accepted content is never silently dropped. A client that prefers to hold material itself can compose `prompt(messages[])` instead. -- Bash executions are not queue items: `!cmd` and `!!cmd` results are transcript appends via `session.appendMessage()` — immediate while idle, deferred writes mid-run. Both are persisted and displayed; `!!` sets `excludeFromContext`, and the default `convertToLlm` drops it from provider context. -- Persisted config (model, thinking level, active tools) is restored via branch point queries — `findEntryOnBranch("model_change")` etc. Compaction truncates message context, not config history: config entries behind a compaction barrier still count. (Today's `buildContext` loses them on reload; the old coding-agent got this right via full-path replay.) -- `abort()` on a suspended operation records the cancellation and reconciles without executing further provider or tool work. -- Retry lives in two layers: `streamOptions.maxRetries` covers provider-internal transport retries inside one request; `retry: RetryPolicy` is the harness policy across failed requests, with durable attempt counts. -- Deferred writes through `harness.session` apply in acceptance order at the next checkpoint. Both moments are observable and correlated by the provisioned entry id: acceptance fires a pending-write event (config getters also update immediately) and pending writes appear in the snapshot; application fires the normal entry events at the checkpoint with the same id (section 9). The raw storage is not reachable from the harness; writing to it directly while a harness is live is a contract violation. -- All calls on an already-faulted harness resolve `{ ok: false, outcome: "rejected", error: { code: "faulted" } }` — rejected, not faulted: the call itself never started anything. -- `close()`: rejects all further calls, signals in-flight provider/tool effects (no durable cancellation is recorded), waits for the append in progress to settle, discards late effect results, and releases the writer claim. An active run restores as suspended, same as after a crash; the log needs no shutdown record. -- One live `AgentHarness` per session; `create()` on a session with a live harness is a serving-layer error (SQLite rejects it, JSONL cannot detect it). - -## 8. Snapshots and subscription - -A UI needs current state plus every change after it, gap-free. That includes the transport gap: a server proxying a harness must get the snapshot to its client before any event reaches the wire. `watch()` buffers until the consumer arms delivery: - -```ts -const { snapshot, start, unsubscribe } = await ref.watch(); // harness.watch() = main's - -await send(client, { kind: "snapshot", snapshot }); // snapshot is on the wire -start((event) => send(client, event)); // flush buffer in order, then go live -``` - -`watch()` captures the snapshot and starts buffering atomically. `start(listener)` flushes the buffered events in order and switches to live delivery. Each event is delivered exactly once, in order — no sequence numbers, no registration race. `unsubscribe()` (before or after `start()`) drops the subscription and any buffer. - -`watch()` is **ref-scoped**: this ref's transcript, run state, queues, pending writes, and only this ref's events. A Slack-thread renderer sees its thread and nothing else; sibling refs are invisible (no refs inventory in a `RefSnapshot`). The session-wide observer is `harness.watchSession()`: its snapshot is the refs inventory — `RefInfo` per ref plus suspended details — with no transcripts, and its stream is the unfiltered firehose. A dashboard composes: `watchSession()` for the overview, `ref.watch()` per opened thread. - -```ts -interface RefSnapshot { - ref: string; - // Transcript: this ref's branch, oldest first (the context window plus - // its compaction entry; UIs page further history via session queries) - transcript: SessionTreeEntry[]; - leafId: string | null; - - run: null | { - id: string; - kind: "run" | "compaction" | "navigation"; - status: "running" | "suspended" | "cancelling"; - startedAt: string; - /** status "suspended" only: what a client needs to offer resume/abort. - Same data create() returned — duplicated here because a remote UI - only ever sees the snapshot, not create()'s return value. */ - suspended?: { - prompt?: (TextContent | ImageContent)[]; - missing: { tools: string[]; models: string[] }; - cancelled?: { clearedSteer: AgentMessage[]; clearedFollowUp: AgentMessage[] }; - }; - /** Live progress, when mid-step. */ - streamingMessage?: AssistantMessage; - runningTools: { - /** Unique within the current tool batch; correlates with the tool-call block in the newest assistant message. */ - toolCallId: string; - toolName: string; - args: unknown; - /** Latest streamed partial result, when the tool reports updates. */ - partialResult?: AgentToolResult; - }[]; - retry?: { attempt: number; maxAttempts: number; nextAttemptAt: string }; - }; - - queues: { steer: AgentMessage[]; followUp: AgentMessage[]; nextRun: AgentMessage[] }; - pendingWrites: { id: string; entry: SessionTreeEntry }[]; - - faulted: boolean; // harness-wide; mirrored into every ref snapshot -} - -interface SessionSnapshot { - refs: (RefInfo & { - suspended?: SuspendedOperation; // resume/abort offer data, per ref - })[]; - faulted: boolean; -} -``` - -- Config (model, thinking level, active tools, resources, stream options) is not in the snapshot — getters are always current, and config events (section 9) tell the UI when to re-read. One source of truth. -- `streamingMessage` and `runningTools` let a UI attaching mid-step render immediately: the partial assistant message and each running tool's latest partial result, the state it would have accumulated from `message_update`/`tool_update` events. -- A `suspended` run in the snapshot is the UI's cue to offer resume/abort. -- Reconnect means calling `watch()` again. Against a living harness the new snapshot includes current live progress. Only process death loses it: a restored harness has no partial streams or running tools to report, and the snapshot shows the suspended run instead; the durable transcript is complete regardless. Surviving transport drops is the serving layer's job. -- A ref watcher receives the full event vocabulary of section 9, filtered to its ref; `watchSession()` and `harness.events.on(type, ...)` receive everything, unfiltered. `events.on` is live-only: no snapshot, no buffering. -- Watchers are independent, each with its own buffer and `start()` gate. -- A watcher that never calls `start()` buffers unboundedly; call `unsubscribe()` when abandoning one. - -## 9. Events - -One flat stream, shared by `harness.events.on(type, listener)` and `watch()`. - -Guarantees: - -- Passive: events cannot alter execution. A thrown listener exception (`watch()` or `events.on`) is caught and reported as a `handler_error` event plus telemetry — same channel as hook handler errors (section 10), never stdio. A listener that throws while handling a `handler_error` event is reported to telemetry only; the event is not re-emitted. -- Ordered: delivery follows process order, identically for streams and push listeners. -- Not persisted, not replayed; reconnect means a new `watch()`. -- Events reporting durable facts (`message_end`, `entry_added`, `run_end`, ...) fire only after the fact is committed; what an event announces is already queryable. -- Events report final effective values, after hook transformation. -- Event payloads are JSON-serializable and secret-free, so a server can proxy them to clients verbatim. Live objects (models, tools, resources, stream options) are referenced by name/id, never embedded. -- Every event carries `ref: string` — bluntly, all of them; omitted from the catalog listings for brevity. Every operational event additionally carries `runId`; step-scoped events carry `stepId`; recovered work carries `recovery: true`. - -### Catalog - -Derived from the existing `AgentEvent`, `AgentHarnessOwnEvent`, and coding-agent `AgentSessionEvent` vocabularies. Mutation hooks (`before_agent_start`, `context`, `tool_call`, ...) are not events anymore — they move to section 10. Fields shown without comment keep their existing meaning. - -```ts -// Run lifecycle ----------------------------------------------------------- - -interface RunStartEvent { - type: "run_start"; // was: agent_start - runId: string; -} - -interface RunResumeEvent { - type: "run_resume"; // new - runId: string; -} - -interface RunCancelEvent { - type: "run_cancel"; // was: abort - runId: string; - clearedSteer: AgentMessage[]; - clearedFollowUp: AgentMessage[]; -} - -interface RunEndEvent { - type: "run_end"; // was: agent_end + settled - runId: string; - outcome: "completed" | "aborted" | "failed"; - /** Same definition as RunResult.finalMessage. Transcript deltas were - already delivered via message/entry events; agent_end.messages is gone. */ - finalMessage?: AssistantMessage; - error?: ErrorInfo; -} - -interface FaultEvent { - type: "fault"; // new - code: string; - message: string; -} - -// was: coding-agent ExtensionError via onError. One channel for all -// extension-code failures — hook handlers and event listeners (section 10). -type HandlerErrorEvent = { - type: "handler_error"; - runId?: string; - error: string; - stack?: string; -} & ( - | { kind: "hook"; hook: string } // hook type - | { kind: "event"; event: string } // event type being delivered -); - -// Step and retry ---------------------------------------------------------- - -interface StepStartEvent { - type: "step_start"; // was: turn_start - runId: string; - stepId: string; -} - -interface StepEndEvent { - type: "step_end"; // was: turn_end (also the save-point moment) - runId: string; - stepId: string; - message: AssistantMessage; - toolResults: ToolResultMessage[]; -} - -// Retry — unifies harness retry_scheduled/retry_attempt_start/retry_finished -// and coding-agent auto_retry_* / summarization_retry_*. Normal requests emit -// nothing: the UI's busy state spans run_start..run_end (or the -// compaction/navigation brackets). Retry events only surface the exception. - -interface RetryScheduledEvent { - type: "retry_scheduled"; // a request failed, next attempt is pending - runId: string; - purpose: "step" | "compaction" | "branch_summary"; - attempt: number; - maxAttempts: number; - delayMs: number; - errorMessage: string; -} - -interface RetryStartEvent { - type: "retry_start"; // the scheduled attempt begins - runId: string; - purpose: "step" | "compaction" | "branch_summary"; - attempt: number; -} - -interface RetryEndEvent { - type: "retry_end"; // retrying resolved: success, or final failure - runId: string; - purpose: "step" | "compaction" | "branch_summary"; - attempt: number; - success: boolean; - finalError?: string; -} - -// Messages and tools ------------------------------------------------------ - -interface MessageStartEvent { - type: "message_start"; - runId?: string; // absent for idle SessionTree writes - message: AgentMessage; -} - -interface MessageUpdateEvent { - type: "message_update"; // only emitted for assistant messages during streaming, as today. - runId: string; // AssistantMessageEvent already carries the partial message and - message: AgentMessage; // per-block deltas; we add nothing on top. - assistantMessageEvent: AssistantMessageEvent; -} - -interface MessageEndEvent { - type: "message_end"; - runId?: string; - message: AgentMessage; - entryId: string; // new: the committed session entry -} - -interface ToolStartEvent { - type: "tool_start"; // was: tool_execution_start - runId: string; - stepId: string; - toolCallId: string; - toolName: string; - args: unknown; // effective args, after before_tool -} - -interface ToolUpdateEvent { - type: "tool_update"; // was: tool_execution_update - runId: string; - stepId: string; - toolCallId: string; - toolName: string; - partialResult: AgentToolResult; -} - -interface ToolEndEvent { - type: "tool_end"; // was: tool_execution_end - runId: string; - stepId: string; - toolCallId: string; - toolName: string; - result: AgentToolResult; - isError: boolean; -} - -// Session and config ------------------------------------------------------ - -interface EntryAddedEvent { - type: "entry_added"; // generalizes coding-agent entry_appended - entry: SessionTreeEntry; // non-message entries: custom, label, name, config, compaction, summary -} - -interface WritePendingEvent { - type: "write_pending"; // new — deferred write durably accepted - runId: string; - entryId: string; // provisioned id; entry_added/message_end follows with the same id - entry: SessionTreeEntry; -} - -interface QueueUpdateEvent { - type: "queue_update"; // per ref — like every event it carries ref; - steer: AgentMessage[]; // these are that ref's queues - followUp: AgentMessage[]; - nextRun: AgentMessage[]; -} - -// Payloads identify the change compactly; clients needing full objects use the -// getters (locally or via their server). streamOptions carries no value: headers -// may hold secrets, transport may be a function. -type ConfigUpdateEvent = { - type: "config_update"; // was: model_update, thinking_level_update, tools_update, resources_update -} & ( - | { property: "model"; value: { provider: string; modelId: string }; previous: { provider: string; modelId: string } | null } - | { property: "thinkingLevel"; value: ThinkingLevel; previous: ThinkingLevel } - | { property: "activeTools"; value: string[]; previous: string[] } - | { property: "tools"; value: string[]; previous: string[] } // names - | { property: "resources"; value: { skills: string[]; promptTemplates: string[] } } // names - | { property: "streamOptions" } - | { property: "retryPolicy"; value: RetryPolicy } - | { property: "compactionSettings"; value: CompactionSettings } - | { property: "steeringMode"; value: QueueMode } - | { property: "followUpMode"; value: QueueMode } -); - -// Compaction and navigation ---------------------------------------------- - -interface CompactionStartEvent { - type: "compaction_start"; // from coding-agent compaction_start - runId: string; // the run for auto, the operation for manual - reason: "manual" | "threshold" | "overflow"; -} - -// End events mirror operation_finished.outcome and the result types — one -// vocabulary across log, results, and events. -interface CompactionEndEvent { - type: "compaction_end"; // was: session_compact + coding-agent compaction_end - runId: string; - reason: "manual" | "threshold" | "overflow"; - outcome: "completed" | "cancelled" | "aborted" | "failed"; - entry?: CompactionEntry; // outcome "completed" - fromHook: boolean; - error?: ErrorInfo; // outcome "failed" -} - -interface NavigationStartEvent { - type: "navigation_start"; // new — operation accepted, summary may generate - runId: string; // the navigation operation - targetId: string; -} - -interface NavigationEndEvent { - type: "navigation_end"; // was: session_tree — the leaf moves atomically here; - runId: string; // navigateTree() is the only cursor mutation - outcome: "completed" | "cancelled" | "aborted" | "failed"; - oldLeafId: string | null; - newLeafId: string | null; - summaryEntry?: BranchSummaryEntry; // outcome "completed", when summarize - error?: ErrorInfo; // outcome "failed" -} -``` - -### Nesting - -Start/end pairs bracket their operation; request, message, and tool events happen between them. What a consumer sees: - -```text -run_start - step_start - message_start - message_update* - message_end assistant committed - tool_start / tool_update* / tool_end per tool call - message_end toolResult committed, source order - step_end - compaction_start auto-compaction at a checkpoint, when needed - entry_added compaction entry committed - compaction_end - step_start ... step_end until no continuation -run_end -``` - -A UI's busy indicator spans the brackets: `run_start`..`run_end`, and for standalone operations `compaction_start`..`compaction_end` / `navigation_start`..`navigation_end`: - -```text -compaction_start reason: manual navigation_start - entry_added compaction entry entry_added summary entry -compaction_end navigation_end leaf moves here -``` - -A failed request inside any bracket emits `retry_scheduled`, then `retry_start` for the next attempt, then `retry_end` when retrying resolves — success or final failure. Requests that succeed first try emit no request-level events at all. - -All events additionally carry `recovery?: true` when emitted for recovered work. - -### Notes - -- Every message entering the session fires message events, regardless of source: agent loop, queues, `SessionTree` writes. `message_end` means committed, final content, usage known; UIs drop their streaming buffer on it. ToolResult messages get `message_start`/`message_end` in source order after `tool_end`. -- Messages get message events; every other entry gets `entry_added`. Both fire only after durable persistence. No entry commits without exactly one of the two firing. -- `runId`/`stepId` on message/tool events exist for correlation (telemetry spans, server-side log processing). A single-harness UI can ignore them; there is only one active run. -- `run_cancel` fires when cancellation is accepted; `run_end` (outcome `aborted`) fires after reconciliation completes. Between them the snapshot shows `status: "cancelling"`. -- Provider request internals (status, headers, payloads, timings) are not events; they belong to the observability channel and the `after_response` hook. - -### Old vs. new - -| old (loop / harness / coding-agent) | new | -|---|---| -| `agent_start` | `run_start` | -| `agent_end` | `run_end` (`outcome`, `error`, `finalMessage`; `agent_end.messages` dropped — deltas via message/entry events) | -| `settled` | `run_end` (settlement is part of finishing) | -| `abort` | `run_cancel` | -| `turn_start` / `turn_end` | `step_start` / `step_end` | -| `save_point` | dropped — internal; deferred-write application is visible via `entry_added`/`message_end` | -| `message_start` / `message_update` / `message_end` | same names and payloads; `message_end` gains `entryId` | -| `tool_execution_start` / `_update` / `_end` | `tool_start` / `tool_update` / `tool_end` | -| `after_provider_response` | dropped as event — `after_response` hook (section 10) and observability | -| `retry_scheduled`, `retry_attempt_start`, `retry_finished` (+ coding-agent `auto_retry_*`, `summarization_retry_*`) | `retry_scheduled` / `retry_start` / `retry_end`, unified via `purpose` | -| `queue_update` | `queue_update` (`nextTurn` field renamed `nextRun`) | -| `model_update`, `thinking_level_update`, `tools_update`, `resources_update` | `config_update` (discriminated union, all config properties) | -| coding-agent `entry_appended` | `entry_added` | -| — | `write_pending` (new: deferred write accepted) | -| `session_compact` (+ coding-agent `compaction_start`/`_end`) | `compaction_start` / `compaction_end` | -| `session_tree` | `navigation_start` / `navigation_end` | -| coding-agent `session_info_changed`, `thinking_level_changed` | `entry_added` / `config_update` | -| coding-agent `ExtensionError` via `onError` | `handler_error` | -| — | `run_resume`, `fault` (new) | -| `before_agent_start`, `context`, `before_provider_request`, `before_provider_payload`, `tool_call`, `tool_result`, `session_before_compact`, `session_before_tree` | not events — hooks (section 10) | - -## 10. Hooks - -Hooks are awaited control points: they can transform or block what the harness does next. Registration mirrors events: - -```ts -const off = harness.hooks.on("before_tool", async (event) => { - if (event.toolName === "bash") return { block: { reason: "not allowed" } }; -}); -``` - -Semantics, uniform across all hooks: - -- Registration is harness-global; every hook event carries `ref` (omitted from the shapes below), so a handler can scope itself. Whether per-ref registration is also wanted is an open question (section 17). -- Handlers run sequentially in registration order; each transformation handler sees the output of the previous one (same reduction rules as today's `emitHook` pipelines). -- A thrown hook handler exception does not fail the run. Following the old coding-agent's extension runner: the exception is caught per handler, reported, and the handler is treated as having returned nothing — remaining handlers still run. The exception: `before_tool` fails closed (see below). Already-committed mutations are never rolled back. -- Hook results that feed durable state are persisted before execution proceeds: `before_run` output lands in the operation-start harness entry, `before_tool` effective arguments in the `tool_started` harness entry. -- Events report post-hook effective values; observers never see pre-hook state. - -### Catalog - -```ts -// Run boundaries ---------------------------------------------------------- - -// was: before_agent_start. Once per run, before durable acceptance. -// Not re-run on retry or resume; its effective output is persisted. -// -// Durable run setup, unlike transform_context: returned messages become -// session entries after the prompt (skill preambles, injected context files); -// the effective system prompt is stored in the operation-start harness entry -// and used for the whole run, including resume. transform_context is -// per-request and ephemeral: it shapes what the provider sees, never what -// the session contains. -interface BeforeRunHook { - event: { - prompt: (TextContent | ImageContent)[]; - systemPrompt: string; - resources: AgentHarnessResources; - }; - result: { - /** Persisted as session entries after the prompt. */ - messages?: AgentMessage[]; - /** Persisted as systemPromptOverride in the operation-start harness entry; - fixed for the whole run. Without an override, the systemPrompt config - callback is evaluated per request instead. */ - systemPrompt?: string; - /** Opaque JSON, keyed by extension id, persisted in the operation-start - harness entry, handed back to before_resume (possibly on another - machine). For per-run state that would otherwise live in a closure: - external job ids, idempotency keys, mode flags. Keep it small. */ - resumeData?: JsonValue; - } | undefined; -} - -// New. On resume(), before any effect. Rebuilds process-local extension -// state; must be idempotent (a crash can rerun it). Cannot rewrite the -// accepted prompt or system prompt. -interface BeforeResumeHook { - event: { - runId: string; - kind: "run" | "compaction" | "navigation"; - /** Persisted effective before_run output. */ - prepared: { prompt: (TextContent | ImageContent)[]; systemPromptOverride?: string }; - resumeData?: JsonValue; - }; - result: void; -} - -// was: the actionable part of agent_end/settled. Runs when nothing is -// pending: no tool continuation, no queued messages. Work enqueued here -// (returned or via followUp()) continues the same run: no new -// run_start/run_end pair, same runId, more steps. run_end fires once, -// when this boundary passes with nothing pending. -interface BeforeRunEndHook { - event: { runId: string; messages: AgentMessage[] }; - result: { followUp?: string } | undefined; // or call steer()/followUp() directly -} - -// Request pipeline --------------------------------------------------------- - -// was: context. AgentMessage level, before convertToLlm. -// Pruning, injection, custom-message handling. -interface TransformContextHook { - event: { messages: AgentMessage[] }; - result: { messages: AgentMessage[] } | undefined; -} - -// was: before_provider_request. Provider-neutral request, after conversion. -interface BeforeRequestHook { - event: { - model: Model; - purpose: "step" | "compaction" | "branch_summary"; - attempt: number; - streamOptions: AgentHarnessStreamOptions; - }; - result: { streamOptions?: AgentHarnessStreamOptionsPatch } | undefined; -} - -// was: before_provider_payload. Provider-specific wire payload. Last stop. -interface BeforePayloadHook { - event: { model: Model; payload: unknown }; - result: { payload: unknown } | undefined; -} - -// was: after_provider_response (observation) + message_end replacement -// (mutation). Runs after the stream finishes, before the assistant message -// is committed. The committed message is what events and the session see. -interface AfterResponseHook { - event: { - status: number; - headers: Record; - message: AssistantMessage; - }; - result: { message?: AssistantMessage } | undefined; // must keep role -} - -// Tools -------------------------------------------------------------------- - -// was: tool_call + loop beforeToolCall. After validation, before execution. -// Effective args are persisted in the tool_started harness entry. -interface BeforeToolHook { - event: { - toolCallId: string; - toolName: string; - args: Record; - }; - result: { - args?: Record; - block?: { reason: string }; - } | undefined; -} - -// was: tool_result + loop afterToolCall. Patch semantics field-by-field, -// no deep merge, as today. -interface AfterToolHook { - event: { - toolCallId: string; - toolName: string; - args: Record; - content: (TextContent | ImageContent)[]; - details: unknown; - isError: boolean; - usage?: Usage; - }; - result: { - content?: (TextContent | ImageContent)[]; - details?: unknown; - isError?: boolean; - usage?: Usage; - terminate?: boolean; - } | undefined; -} - -// Structural operations ---------------------------------------------------- - -// was: session_before_compact. Cancel, adjust, or supply the summary. -interface BeforeCompactionHook { - event: { - reason: "manual" | "threshold" | "overflow"; - preparation: CompactionPreparation; - customInstructions?: string; - }; - result: { cancel?: boolean; compaction?: CompactResult } | undefined; -} - -// was: session_before_tree. Cancel, adjust, or supply the branch summary. -interface BeforeNavigationHook { - event: { targetId: string; preparation: TreePreparation }; - result: { - cancel?: boolean; - summary?: { summary: string; details?: unknown; usage?: Usage }; - customInstructions?: string; - replaceInstructions?: boolean; - label?: string; - } | undefined; -} -``` - -### Failure semantics - -The old coding-agent catches every extension handler error, wraps it as `ExtensionError`, emits it to `onError` listeners, skips that handler's contribution, and continues; an extension bug never kills a run. (Only the old pi-agent harness `emitHook` rethrew into the run; that behavior is dropped.) - -The new harness keeps that model: - -- Default, all hooks: the throwing handler is skipped, a `handler_error` event is emitted, execution continues with the remaining handlers and the last effective value. -- `before_tool` fails closed: a throwing handler blocks the tool; an error tool result is committed and the run continues. Skipping a broken policy handler must not allow a tool it might have blocked. - -Reporting: the `handler_error` event (section 9) plus telemetry, one channel for hook handlers and event listeners alike. Recursion guard: a listener throwing while handling `handler_error` goes to telemetry only. - -### Replay across retry and resume - -| hook | fresh run | request retry | resume | output persisted | -|---|---|---|---|---| -| `before_run` | once | no | no | yes (operation start) | -| `before_resume` | no | no | yes, idempotent | no | -| `transform_context` | per request | yes | yes | no | -| `before_request` | per request | yes | yes | no | -| `before_payload` | per request | yes | yes | no | -| `after_response` | per response | per response | per response | via committed message | -| `before_tool` | per invocation | n/a | not for uncertain unsafe tools | yes (tool start) | -| `after_tool` | per executed result | n/a | on safe replay | via committed result | -| `before_compaction` | per compaction | no | not if result committed | via committed entry | -| `before_navigation` | per navigation | no | not if result committed | via committed entries | -| `before_run_end` | at every finish boundary | n/a | at the boundary resume reaches (may repeat across a crash) | via durable follow-ups | - -Hooks re-run only where the work itself re-runs; persisted effective outputs are never recomputed. - -### Old vs. new - -| old (harness / loop / coding-agent) | new | -|---|---| -| `before_agent_start` | `before_run` | -| `context` / loop `transformContext` | `transform_context` | -| `before_provider_request` | `before_request` | -| `before_provider_payload` | `before_payload` | -| `after_provider_response` + coding-agent `message_end` replacement | `after_response` | -| `tool_call` / loop `beforeToolCall` | `before_tool` | -| `tool_result` / loop `afterToolCall` | `after_tool` | -| `session_before_compact` | `before_compaction` | -| `session_before_tree` | `before_navigation` | -| `agent_end` / `settled` handlers that queue work | `before_run_end` | -| — | `before_resume` (new) | - -## 11. Traces - -How hooks, events, and durable appends interleave. Legend: - -```text -H hook (awaited) -E event (passive) -CS durable append: session entry (tree) -CH durable append: harness entry (orchestration, schemas: section 5) -X process dies -``` - -All traces except the last show a single-ref session; `ref: "main"` is omitted from records and events. - -### Simple run, one tool call - -```text - prompt("fix the bug") -H before_run may inject messages, transform system prompt -CH operation_started → E run_start op-1, prepared output persisted -CS message user → E message_start, message_end -E step_start step-1 -H transform_context -H before_request -H before_payload -CH generation_started before the billable request -E message_start assistant streaming begins -E message_update* -H after_response may replace the assistant message -CS message assistant [tool call] → E message_end -H before_tool may mutate args or block -CH tool_started effective args persisted -E tool_start -E tool_update* -H after_tool may patch the result -E tool_end -CS message toolResult → E message_start, message_end -E step_end - checkpoint: deferred writes, queues, compaction — nothing pending -E step_start step-2 -H transform_context / before_request / before_payload -CH generation_started -E message_start, message_update* -H after_response -CS message assistant "done" → E message_end -E step_end -H before_run_end returns nothing -CH operation_finished → E run_end outcome: completed -``` - -### Steering while a tool runs - -```text -E tool_start tool executing - steer("focus on tests") caller resolves at CH -CH queue_enqueued → E queue_update tree-neutral, mid-step is fine -E tool_end -CS message toolResult → E message_start, message_end -E step_end - checkpoint consumes steering -CS message user "focus on tests" → E message_start, message_end, queue_update -E step_start next request sees the steering message -``` - -Crash before `queue_enqueued`: steering was never accepted; the caller's promise never resolved. Crash after: recovery finds the queued item without its target message and delivers it. - -### Follow-up from before_run_end - -```text -E step_end -H before_run_end handler calls followUp("now write tests") -CH queue_enqueued → E queue_update durable before the hook returns - run continues — same runId, no new run_start -CS message user "now write tests" → E message_start, message_end -E step_start ... -H before_run_end runs again after those steps; returns nothing -CH operation_finished → E run_end exactly one, outcome: completed -``` - -### Request failure and retry - -```text -CH generation_started attempt 1 - provider fails (overloaded) -E retry_scheduled attempt 1, delayMs -E retry_start attempt 2 -H transform_context / before_request / before_payload re-run per attempt -CH generation_started attempt 2 — durable count survives restarts -E message_start, message_update* -E retry_end success: true -CS message assistant → E message_end -``` - -A crash during backoff: restore reads two attempt entries; resume continues with attempt 3. The count never resets. - -### Abort during a tool - -```text -E tool_start tool executing - abort() caller resolves after CH + signal -CH operation_cancelled → E run_cancel cleared steer/follow-up items returned - tool signalled; reconciliation in background -CS message toolResult → E message_start, message_end synthetic "interrupted", or real if it finished -CS message assistant → E message_start, message_end stopReason: aborted — provider-valid closure -CH operation_finished → E run_end outcome: aborted -``` - -### Auto-compaction between steps - -```text -E step_end - checkpoint: prospective context too big -E compaction_start reason: threshold -H before_compaction may cancel or supply the summary -CH generation_started purpose compaction — skipped if hook supplied -CS compaction entry → E entry_added -E compaction_end -E step_start next request uses compacted context -``` - -### Crash mid-tool, resume on another machine - -```text -CS message assistant [tool call] → E message_end -CH tool_started replay: never -X machine dies mid-execution - - — new machine — - AgentHarness.create(...) suspended: { kind: "run", ... } -H before_resume receives persisted resumeData; idempotent - resume() -E run_resume -CS message toolResult → E message_start, message_end synthetic "interrupted", not re-run; recovery: true -E step_end recovery: true -E step_start run continues normally from here -H transform_context / before_request / before_payload -CH generation_started -... -``` - -Not re-run: `before_run` (persisted), `before_tool` for the interrupted call (its decision is already durable in `tool_started`). A `replay: safe` tool would re-execute with the persisted effective args instead of getting a synthetic result. - -### Crash mid-request - -```text -CH generation_started attempt 1 -X dies mid-stream — partial tokens lost, never persisted - - — restore + resume — -E run_resume -H transform_context / before_request / before_payload -CH generation_started attempt 2: same step, durable attempt count -E message_start ... -``` - -### Navigation with summary, crash windows - -```text - navigateTree(target, { summarize: true }) -CH operation_started → E navigation_start destination + provisioned summary/leaf ids -H before_navigation may cancel (→ finished cancelled) or supply the summary -CH generation_started purpose branch_summary — skipped if hook supplied -CS leaf entry → E entry_added cursor moves — the atomic boundary -CS branch_summary entry → E entry_added -CH operation_finished → E navigation_end old/new leaf, summary -``` - -Crash before the leaf entry: old branch still active; resume completes the navigation. Crash after: destination active; resume appends only what is missing (summary, finish). Every prefix is a valid tree. - -### Deferred projecting write mid-request (append-only context) - -```text -CH generation_started request in flight; context tip is user U1 - handler calls session.appendMessage(M) a custom message that projects into context -CH write_deferred → E write_pending durable acceptance; M is not in the tree yet -CS message assistant A1 → E message_end provider cached prefix [.., U1, A1] -E step_end - checkpoint applies deferred writes -CS message M → E message_start, message_end tail append, after A1 -E step_start next context [.., U1, A1, M, ...] — prefix intact -``` - -Appending M immediately would have produced [.., U1, M, A1] — a provider-valid sequence that silently invalidates the KV cache from M onward, and a transcript claiming A1 saw M when it did not. The checkpoint prevents both (append-only context, section 5). A custom *entry* without a projector needs none of this — it projects to nothing and could not affect the provider view either way; it still defers, uniformly. - -### Two refs, interleaved log - -```text - main: prompt("fix the bug") slack:t1: prompt("summarize this thread") -CH operation_started ref=main -CH operation_started ref=slack:t1 -CH generation_started ref=main attempt 1 for main's cycle -CH generation_started ref=slack:t1 attempt 1 for t1's cycle — no interference -CS message assistant chained on main's branch (no ref field — -CS message assistant chained on t1's branch membership by parentId) -CH operation_finished ref=slack:t1 → E run_end (ref slack:t1) -CH operation_finished ref=main → E run_end (ref main) -``` - -Records interleave freely in the log; every reduction filters by ref first, and within one ref the single-writer positional rules of section 5 hold unchanged. The two runs share nothing but the writer and the tree prefix behind their anchors. - -## 12. Recovery - -### Restore - -`AgentHarness.create()` reduces the log to harness state. It performs no provider or tool effects and appends nothing. The reduction runs once per ref — refs restore independently; the flow below is per ref, with all reads filtered by ref: - -```mermaid -flowchart TD - O[open session, enumerate refs] --> A{ref has unmatched
operation_started?} - A -->|no| I[ref Idle] - I --> NR[collect ref's pending next-run items] - A -->|yes| T[read ref's tail: seq > opStart] - T --> V{tail valid?} - V -->|no| X[corruption error] - V -->|yes| M[resolve model/tool identities] - M --> S[ref Suspended] -``` - -Restore never reads the full log. The invariants of section 5 are enforced in two places: at append time by storage constraints (id uniqueness, seq monotonicity — violations cannot enter the log), and at restore time only over what restore reads. JSONL reads the whole file at open anyway and validates everything as a side effect; SQLite does not have to. - -Precisely: - -1. **Single-operation check.** Per ref: `count(operation_started) − count(operation_finished) ≤ 1` — indexed aggregates grouped by ref. More than one unmatched operation on one ref: corruption error, no automatic repair. -2. **Locate the active operation.** The ref's latest `operation_started` without matching `operation_finished` (index seek). -3. **Idle path.** No active operation: the ref is idle. Pending next-run items = the ref's `queue_enqueued(nextRun)` entries after its last run-kind `operation_started` whose target id has no entry (point lookups). Done. -4. **Suspended path.** Read the ref's tail (`seq > opStart.seq`, filtered by ref, range scan) and reduce: - - cancellation: is `operation_cancelled` present - - attempts: count of tail attempts after the newest session entry of this ref's chain (parentId membership; the current request cycle) - - tools: `tool_started` entries and, per entry, whether `resultEntryId` exists - - queues: `queue_enqueued` items whose target id has no entry; steer/follow-up dead if cancelled - - deferred writes: `write_deferred` entries whose target id has no entry, in acceptance order - - initial messages: which provisioned ids from the operation start exist - - Tail-scoped validation happens here: attempt numbers consecutive, tool identities unique, referenced assistant entries present, provisioned targets consistent. A violation is a corruption error. -5. **Resolve identities.** Persisted model references and tool names from the operation start and tail against current config → that entry's `missing`. -6. Return `{ harness, suspended }` — one `SuspendedOperation` per ref that has one. Nothing has executed. - -Old sessions have no harness entries and restore idle, even when the transcript ends in a state that looks continuable. - -### Harness state - -One in-memory record is the harness's working state, live and restored alike. The invariant: **`state` always equals the reduction of the log.** During normal execution the harness never queries the log — every accepted append updates `state` in the same serialized section that performed the append. Restore recomputes the identical record from the log; that is all restore is. - -Update rules per append, applied to the appending ref (known directly while live; recovered via chain membership during restore): `generation_started` → increment `requestAttempts`; any session entry → reset that ref's `requestAttempts` to 0; assistant message → set `toolBatch` (or clear it when call-free); tool result → mark its call resolved; `queue_enqueued` → push to the matching pending list; a queue target or deferred-write target landing → remove the pending item; `write_deferred` → push to `pendingWrites`; `operation_cancelled` → set `cancelled`; `operation_started`/`operation_finished` → set/clear the ref's `operation`. - -```ts -interface HarnessState { - /** One slot per ref; the structure below is per ref. */ - refs: Map; -} - -interface RefState { - leafId: string | null; - - operation: null | { - id: string; - kind: "run" | "compaction" | "navigation"; - sourceLeafId: string | null; - intent: OperationStartedEntry["intent"]; - cancelled: boolean; // operation_cancelled present - - /** Provisioned initial messages whose ids have no entry, in order. Runs only. */ - missingInitialMessages: ProvisionedMessage[]; - - /** Attempts already made for the current request cycle — there is never - more than one live. Incremented on generation_started; reset to 0 - whenever a session entry commits (the transcript advanced, the next - request asks a different question). Restore seeds it during the tail - reduction: same two rules applied left to right. */ - requestAttempts: number; - - /** This run's newest assistant message with tool calls, if any, with per-call state. */ - toolBatch: null | { - assistantEntryId: string; - calls: { - toolIndex: number; - toolCallId: string; - started: ToolStartedEntry | null; - resultExists: boolean; - }[]; - }; - - /** Accepted, unconsumed, in acceptance order. Dead (returned via suspended.cancelled) if cancelled. */ - pendingSteer: ProvisionedMessage[]; - pendingFollowUp: ProvisionedMessage[]; - /** Accepted, unapplied, in acceptance order. Survive cancellation. */ - pendingWrites: ProvisionedEntry[]; - - /** Structural targets: does an entry with the provisioned id exist? */ - targets: { result?: boolean; leaf?: boolean; summary?: boolean; label?: boolean }; - }; - - /** This ref's queue_enqueued(nextRun) after its last run-kind operation start, targets absent. */ - pendingNextRun: ProvisionedMessage[]; -} -``` - -### Resume: dispatch - -The code below is the specification. It runs in the context of one ref: `resume()` is an `AgentRef` method, `state` is that ref's `RefState`, `op` its operation; different refs' procedures run concurrently, serialized only through the log append path. Two error classes carry control flow: `RunFailed` (orderly durable failure — converted to `operation_finished(failed)`) and `AppendFailed` (storage broke — converted to the faulted state; no finish entry is possible). Neither escapes to the API caller. - -```ts -async function resume(): Promise { // per ref - if (suspended.missing.tools.length || suspended.missing.models.length) { - return { ok: false, outcome: "rejected", error: { code: "missing_identities", message: ... } }; - } - events.emit({ type: "run_resume", runId: op.id, recovery: true }); - switch (op.kind) { - case "run": return { kind: "run", ...await runProcedure() }; - case "compaction": return { kind: "compaction", ...await compactionProcedure() }; - case "navigation": return { kind: "navigation", ...await navigationProcedure() }; - } -} -``` - -Live and resume paths run the *same* procedures — `prompt()` calls `runProcedure()` after appending `operation_started`, `resume()` calls it with the operation already in the log. That includes `abort()`: a resuming operation is just a running operation, so abort applies normally (cancellation appended, effects signalled, `cancellationPath()` reconciles) and `resume()` resolves `outcome: "aborted"`. The helper that makes re-entry safe everywhere: - -```ts -/** Append a provisioned session entry unless an entry with its id already exists. */ -async function appendIfMissing(target: ProvisionedEntry): Promise { - if (!(await session.getEntry(target.id))) { - await appendSessionEntry(target); // → message/entry events, recovery-flagged during resume - } -} -``` - -Watchers across resume — snapshot on attach, then events, all with `recovery: true`: - -| case | snapshot shows | events during resume() | -|---|---|---| -| run, mid-step | `run.status: "suspended"` | `run_resume` → message events for reconciliation appends (initial messages, synthetic results) → normal step/message/tool events → `run_end(outcome)` | -| run, cancelled pre-crash | `"suspended"`; payloads in `suspended.cancelled` | `run_resume` → message events for synthetics and aborted closure → `run_end(aborted)`. No second `run_cancel` — acceptance was announced pre-crash | -| compaction | `run.kind: "compaction"`, `"suspended"` | `run_resume` → `compaction_start` re-emitted so brackets balance → `entry_added` → `compaction_end` | -| navigation | same pattern | `run_resume` → `navigation_start` re-emitted → `entry_added` per leaf/summary/label → `navigation_end` | - -Resumed structural operations re-emit their start event (`recovery: true`) so a UI attaching mid-resume always sees balanced start/end pairs. - -Every append recovery makes is an ordinary append: it emits the ordinary events (section 9 rules — messages get message events, other entries get `entry_added`, finishes get `run_end`/`compaction_end`/`navigation_end`), each with `recovery: true`. - -### Run procedure - -```ts -async function runProcedure(): Promise { - try { - // Initial messages — unconditional, even when cancelled below: - // accepted content is never dropped. - for (const msg of op.intent.initialMessages) await appendIfMissing(msg); - - if (state.cancelled) return await cancellationPath(); - - if (state.toolBatch?.calls.some((c) => !c.resultExists)) { - await reconcileToolBatch(state.toolBatch); - } - - return await driverLoop(); - } catch (err) { - return await handleRunError(err); - } -} - -async function handleRunError(err: unknown): Promise { - if (err instanceof RunFailed) { - await appendHarnessEntry({ type: "operation_finished", outcome: "failed", error: err.info }); - // newestAssistantProjection() is run-scoped: newest message entry appended - // after operation_started that projects to an AssistantMessage. May be undefined. - const finalMessage = newestAssistantProjection(); - events.emit({ type: "run_end", runId: op.id, outcome: "failed", error: err.info, finalMessage }); - return { ok: false, outcome: "failed", runId: op.id, error: err.info, finalMessage }; - } - enterFaultedState(err); // AppendFailed, or a bug — either way we cannot safely continue - events.emit({ type: "fault", code: ..., message: ... }); - return { ok: false, outcome: "faulted", runId: op.id, error: errorInfo(err) }; -} -``` - -There is no separate "crashed mid-generation" case: an interrupted generation means its result entry does not exist, and `driverLoop()` starts the next generation with the durable attempt count deciding retry versus `RunFailed`. Same for a crash mid-auto-compaction: the loop re-evaluates the checkpoint. - -### The driver loop - -The same loop drives fresh and resumed runs. `appendIfMissing` everywhere is what makes re-entry after a mid-loop crash safe: - -```ts -async function driverLoop(): Promise { - while (true) { - // ── checkpoint ───────────────────────────────────────────── - for (const write of state.pendingWrites) await appendIfMissing(write.target); - for (const msg of takeQueued(state.pendingSteer, config.steeringMode)) { - await appendIfMissing(msg); - } - if (await contextOverLimit()) await autoCompact(); // may throw RunFailed - - // ── step, while the model owes a response ───────────────────────── - if (needsAssistantResponse()) { // newest run message is user/steering/toolResult - const assistant = await requestAssistant(); // may throw RunFailed - if (hasToolCalls(assistant)) await executeToolBatch(assistant); - continue; // every step ends in a fresh checkpoint - } - - // ── follow-ups ────────────────────────────────────────────── - const followUps = takeQueued(state.pendingFollowUp, config.followUpMode); - if (followUps.length > 0) { - for (const msg of followUps) await appendIfMissing(msg); - continue; - } - - // ── finish boundary ──────────────────────────────────────── - const result = await hooks.run("before_run_end", { runId: op.id, messages: runMessages() }); // this run's messages - if (result?.followUp) await harness.followUp(result.followUp); - if (hasPendingWork()) continue; // hook enqueued something → keep going - - await appendHarnessEntry({ type: "operation_finished", outcome: "completed" }); - const finalMessage = newestAssistantProjection(); - events.emit({ type: "run_end", runId: op.id, outcome: "completed", finalMessage }); - return { ok: true, runId: op.id, finalMessage }; - } -} - -async function requestAssistant(): Promise { - while (true) { - // Retrying the same position grows the count; any committed session entry - // resets it. Seeded from the log at restore, so the bound survives - // restarts (see HarnessState.requestAttempts). - const attempt = state.requestAttempts + 1; - if (attempt > config.retry.maxAttempts) { - await appendSessionEntry(errorAssistantMessage()); // transcript records the give-up - throw new RunFailed({ code: "retries_exhausted", message: ... }); - } - - // Effective system prompt, per request: the persisted before_run override - // if set, else the systemPrompt config callback evaluated fresh — it sees - // current active tools, preserving the old mid-run rebuild behavior. - const systemPrompt = op.intent.systemPromptOverride ?? await evalSystemPromptConfig(); - const context = await hooks.run("transform_context", { messages: await contextMessages() }); - const options = await hooks.run("before_request", { model, purpose: "step", attempt, streamOptions }); - // before_payload runs inside the provider call, on the wire payload - - await appendHarnessEntry({ type: "generation_started", purpose: "step", attempt, ... }); - try { - const response = await streamRequest(context, options); // → message_start/update events - const final = (await hooks.run("after_response", response))?.message ?? response.message; - await appendSessionEntry(assistantEntry(final)); // → message_end - return final; - } catch (err) { - if (!isRetryable(err)) { - await appendSessionEntry(errorAssistantMessage(err)); - throw new RunFailed(errorInfo(err)); - } - events.emit({ type: "retry_scheduled", attempt, delayMs: backoff(attempt), ... }); - await sleep(backoff(attempt)); - events.emit({ type: "retry_start", attempt: attempt + 1, ... }); - } - } -} - -async function autoCompact(): Promise { - events.emit({ type: "compaction_start", runId: op.id, reason: "threshold" }); - const prep = prepareCompaction(await contextEntries()); - const hook = await hooks.run("before_compaction", { reason: "threshold", preparation: prep }); - if (hook?.cancel) { - events.emit({ type: "compaction_end", runId: op.id, outcome: "cancelled", ... }); - return; // run continues; overflow, if it comes, fails the step - } - const result = hook?.compaction ?? await generateBounded("compaction", prep); // may throw RunFailed - await appendSessionEntry(compactionEntry(result)); // → entry_added - events.emit({ type: "compaction_end", runId: op.id, outcome: "completed", entry, fromHook: !!hook?.compaction, ... }); -} -``` - -One undecidable case, decided by policy: the log ends at "final assistant committed, nothing pending". Resume enters `driverLoop()` and reaches the finish boundary, so `before_run_end` runs — whether it already ran before the crash cannot be known. Policy: the hook fires at every finish boundary actually reached, including this one; a handler may see the same boundary twice across a crash. Handlers that must not double-fire keep their own durable marker (resumeData, custom entries). This is boundary re-evaluation, not replay of interrupted handler code — the non-goal in section 1 stands. - -### Tool batch reconciliation - -```ts -async function reconcileToolBatch(batch: ToolBatch): Promise { - for (const call of batch.calls) { // assistant source order - if (call.resultExists) continue; // committed, incl. hook-patched content - - if (call.started) { - // tool_started exists ⇒ before_tool and validation already ran and - // cleared this invocation; effective args and the not-blocked decision - // are the durable outcome. - if (call.started.replay === "safe") { - const result = await executeTool(call.started.toolName, call.started.effectiveArgs); - const patched = await hooks.run("after_tool", { ...call, ...result }); - await appendIfMissing(toolResultEntry(call.started.resultEntryId, patched ?? result)); - } else { - // replay "never": the effect may or may not have happened; running it - // again is worse than admitting that. No hooks run. - await appendIfMissing(syntheticToolResult(call.started.resultEntryId, "interrupted")); - } - } else { - // never began: full normal path - await executeToolCallNormally(call); // validate → before_tool → block? error result - // : tool_started → execute → after_tool → result - } - } - // The step then ends normally; the model sees every call answered. -} -``` - -Synthetic "aborted" results exist only in `cancellationPath()`, where pending calls are not executed because the run is ending. - -### Cancellation path - -Reached live after `abort()`, or on resume when `operation_cancelled` exists without `operation_finished` (the process died between `abort()` and the end of reconciliation): - -```ts -async function cancellationPath(): Promise { - // initial messages were already appended by runProcedure() - - for (const call of state.toolBatch?.calls ?? []) { // source order - if (call.resultExists) continue; - await appendIfMissing(syntheticToolResult( - call.started ? call.started.resultEntryId : provisionResultId(call), - call.started ? "interrupted" : "aborted", // post-crash there is nothing to salvage - )); - } - - for (const write of state.pendingWrites) await appendIfMissing(write.target); // facts survive abort - - // The transcript always records how the run ended — including the - // no-assistant-yet case (cancelled during the first request). - if (!newestRunAssistantIsAborted()) { - await appendSessionEntry(abortedClosureMessage()); - } - - await appendHarnessEntry({ type: "operation_finished", outcome: "aborted" }); - const finalMessage = newestAssistantProjection(); // the aborted closure - events.emit({ type: "run_end", runId: op.id, outcome: "aborted", finalMessage }); - return { ok: false, outcome: "aborted", runId: op.id, finalMessage }; -} -``` - -Steer/follow-up items die undelivered; their payloads were surfaced via `AbortResult` (live) or `suspended.cancelled` (restore) so a client can requeue them. Next-run items survive. - -### Compaction procedure - -One procedure for live `compact()` and resume: live appends `operation_started` first; resume enters with it already in the log and skips whatever the targets say is done. `before_compaction` runs after `operation_started`, always — same contract on both paths, and a persisted operation always reaches a durable end: - -```ts -async function compactionProcedure(): Promise { - try { - // op.persisted is runtime-only (not in HarnessState): false when the live - // call enters before its operation_started entry exists, true on resume. - if (!op.persisted) await appendOperationStarted(); // live entry point - events.emit({ type: "compaction_start", runId: op.id, reason }); // re-emitted on resume - - if (!state.targets.result) { - const hook = await hooks.run("before_compaction", { reason, preparation, customInstructions }); - if (hook?.cancel) { - await appendHarnessEntry({ type: "operation_finished", outcome: "cancelled" }); - events.emit({ type: "compaction_end", runId: op.id, outcome: "cancelled", ... }); - return { ok: false, outcome: "cancelled", runId: op.id }; - } - const result = hook?.compaction - ?? await generateBounded("compaction", preparation); // may throw RunFailed - await appendSessionEntry(compactionEntry(op.intent.resultEntryId, result)); - } - - await appendHarnessEntry({ type: "operation_finished", outcome: "completed" }); - events.emit({ type: "compaction_end", runId: op.id, outcome: "completed", entry, ... }); - return { ok: true, runId: op.id, entry }; - } catch (err) { - return await handleStructuralError(err); - } -} - -// Structural twin of handleRunError. The failed path still emits the end -// event so the start/end bracket balances for every outcome. -async function handleStructuralError(err: unknown) { - const endEvent = op.kind === "compaction" ? "compaction_end" : "navigation_end"; - if (err instanceof RunFailed) { - await appendHarnessEntry({ type: "operation_finished", outcome: "failed", error: err.info }); - events.emit({ type: endEvent, runId: op.id, outcome: "failed", error: err.info, ... }); - return { ok: false, outcome: "failed", runId: op.id, error: err.info }; - } - enterFaultedState(err); // AppendFailed, or a bug - events.emit({ type: "fault", code: ..., message: ... }); - return { ok: false, outcome: "faulted", runId: op.id, error: errorInfo(err) }; -} -``` - -### Navigation procedure - -Same shape; append order is attempt → leaf → summary → label → finish. `before_navigation` runs after `operation_started`, always: - -```ts -async function navigationProcedure(): Promise { - try { - if (!op.persisted) await appendOperationStarted(); - events.emit({ type: "navigation_start", runId: op.id, targetId }); // re-emitted on resume - const { intent } = op; - let summary: SummaryContent | undefined; - - if (!state.targets.leaf) { - // the navigation has not happened yet - const hook = await hooks.run("before_navigation", { targetId: intent.targetId, preparation }); - if (hook?.cancel) { - await appendHarnessEntry({ type: "operation_finished", outcome: "cancelled" }); - events.emit({ type: "navigation_end", runId: op.id, outcome: "cancelled", ... }); - return { ok: false, outcome: "cancelled", runId: op.id }; - } - summary = hook?.summary - ?? (intent.summarize ? await generateBounded("branch_summary", ...) : undefined); - await appendSessionEntry(leafEntry(intent.leafEntryId, intent.destinationLeafId)); - // ↑ the cursor moves here, atomically - } - - if (intent.summarize && !state.targets.summary) { - // leaf moved pre-crash without a summary → regenerate (attempt-bounded) - summary ??= await generateBounded("branch_summary", ...); - await appendIfMissing(summaryEntry(intent.summaryEntryId, summary)); - } - if (intent.label && !state.targets.label) { - await appendIfMissing(labelEntry(intent.labelEntryId, intent.label)); - } - - await appendHarnessEntry({ type: "operation_finished", outcome: "completed" }); - events.emit({ type: "navigation_end", runId: op.id, outcome: "completed", oldLeafId, newLeafId, summaryEntry, ... }); - return { ok: true, runId: op.id, newLeafId, summaryEntry }; - } catch (err) { - return await handleStructuralError(err); - } -} -``` - -The transient state — destination active without its summary (crash between the leaf and summary appends) — is a valid tree; recovery closes the gap. - -### Guarantees - -- **Idempotent.** A crash during recovery leaves a longer valid prefix; the next restore continues from it. Every recovery append is an entry normal execution would have written — there is no recovery-only entry type — and every append skips targets that already exist. -- **Effect-safe.** Recovery never repeats an effect whose outcome is unknown: unsafe tools get synthetic results, lost provider responses get new attempts under the durable count (possibly double-billed — the count still bounds spend). -- **Hook-honest.** Hooks re-run only where the work itself re-runs (section 10 replay table). Interrupted handlers are not replayed; their accepted durable outputs are already in the log. -- **Observable.** Ordinary events with `recovery: true`; `run_resume` fires once per `resume()`; the first snapshot after restore shows the suspended operation. - -## 13. Forks - -One copy primitive; the scope option decides how much comes along: - -```ts -interface SessionCreateOptions { - id?: string; - /** New. Link a fresh session to a parent, e.g. a subagent's session to the - session whose tool call spawned it. Same linkage fork sets automatically. */ - parentSessionId?: string; -} - -type SessionForkOptions = - /** Existing behavior: the selected branch only — root to the fork point. - Sibling branches are not copied. Default: the source main's branch. */ - | { scope?: "branch"; entryId?: string; position?: "before" | "at" } - /** New: the entire tree — all session entries, every branch, leaf preserved. */ - | { scope: "tree" }; - -interface SessionRepo { - ... - create(options: TCreateOptions): Promise; - fork(source, options: SessionForkOptions & TCreateOptions): Promise; -} -``` - -Rules, both scopes: - -- **Session entries only, zero harness entries.** Orchestration history describes the source's execution. A fork starts idle: no operation, no queues, no pending writes, `suspended: []`. -- **Refs:** `scope: "branch"` → the new session has only `main`, at the fork point. `scope: "tree"` → **TBD**: current proposal copies all refs as-is; alternatives (only `main`, positioned at the source's `main`) unresolved. Labels and the session name (global records, section 5) copy with `scope: "tree"`; with `scope: "branch"`, labels copy iff their target entry was copied, the name always. -- **The source is untouched.** Copying while the source has an active run reads the committed prefix; the run stays active in the source and is never inherited. Forking a running session is safe in both scopes: a fork point may be **any message entry** (relaxed from the old user-message-only validation — platform threads root at arbitrary messages), and a copy whose tip sits mid-tool-batch is still promptable — pi-ai's `transformMessages` inserts synthetic empty results for orphaned tool calls at request build time, so no acceptance check or history rewriting is needed. -- **Linkage** via metadata (`parentSessionPath` in JSONL, the equivalent in SQLite), set automatically by `fork()` and explicitly by `create({ parentSessionId })` — the basis for session-group operations like export bundles and subagent parent/child tracking. A subagent tool creates its child session this way and returns the child's id in its tool result. Durability needs no schema support: the tool can derive the child id deterministically from its invocation (`execute(toolCallId, ...)` — e.g. `f(parentSessionId, toolCallId)`), so a `replay: "safe"` re-execution derives the same id and reattaches instead of spawning a twin; and because the child records `parentSessionId` at creation, children remain discoverable from the parent even when a crash swallowed the tool result. -- Persisted config derives from the copied tree via the usual branch point queries; `main` sits at the fork point (`scope: "branch"`) or the source's `main` leaf (`scope: "tree"`). -- **Threads are refs first.** A platform thread sharing one source of truth with its channel is a ref in the same session (section 6), not a fork. Fork when a *separate* session is wanted: subagents, exports, clones. Whether a thread becomes a ref, a fork, or a fresh session with platform backlog as prompt-time context is application policy; all three are supported. - -## 14. Storage backends - -Backends implement append + read + the finder queries for one session. They know nothing about operations, queues, or recovery — the harness entry payloads are opaque to them apart from the columns they index. - -Contract, all backends: - -- One total append order (`seq`) across session and harness entries. Harness entries and leaf records carry `ref`; session entries do not (membership derives from parent linkage). -- An append is durable when its promise resolves; events fire after. -- Entry ids are unique per session, enforced at append. -- Reads return immutable snapshots; callers cannot mutate stored state. -- One writer per *session*, enforced by the serving layer; SQLite additionally rejects concurrent writers itself. This is per session, not per backend: one SQLite database is a repo hosting many sessions, all writable concurrently — each through its own single live harness. Same for a directory of JSONL files. - -### JSONL - -One file: metadata header line, then one JSON object per line in append order. Format v4 adds harness entries, interleaved exactly as appended. - -- Open reads the whole file into memory; all queries (finders, chain walks, log reads, restore validation) run against that in-memory state. Appends serialize through the instance and write one line each. v4: harness entries and leaf records carry `ref`; label/name records are global (no `parentId`); absent fields in v3 files read as `main`. -- **Torn tail:** a malformed *final* line is a crash artifact — the append that died mid-write. Open truncates it and continues; the entry it would have contained was never acknowledged, so nothing is lost. A malformed line anywhere else is corruption: open rejects. -- **Uncertain acknowledgement** (process died between write and ack): the caller that died never observed the resolve. On reopen the line either parses (committed) or is the torn tail (not). No ambiguity survives. -- **v3 files** load unchanged: zero harness entries, restore idle. `custom_message` entries convert to custom messages on read. The format version lives in the header line, so a v3 file cannot simply receive v4 appends: before the first append, the file is rewritten once with a v4 header (write temp, rename — atomic on the same filesystem), entries byte-identical. Read-only opens never rewrite. -- Durability is process-crash level: a resolved `appendFile` call. No fsync/power-loss promise; if that is ever required it becomes an explicit capability, not an implied one. - -### In-memory - -Chronological record list plus an id index. Append validates, clones, then commits to state; reads clone out. Reference implementation for the contract — the parity test suite runs against it first. - -### SQLite - -Session entries stay in `session_entries`. Harness entries get their own table — they never participate in branch materialization, stats, or context, so mixing them into `session_entries` would force every existing query to exclude them: - -```sql -CREATE TABLE harness_entries ( - session_id TEXT NOT NULL, - seq INTEGER NOT NULL, -- shared sequence with session_entries - id TEXT NOT NULL, - type TEXT NOT NULL, -- operation_started, generation_started, ... - ref TEXT NOT NULL, -- partition key for per-ref reduction - run_id TEXT NOT NULL, - op_kind TEXT, -- operation_started only: run | compaction | navigation - timestamp TEXT NOT NULL, - payload TEXT NOT NULL, - PRIMARY KEY (session_id, seq) -); -CREATE UNIQUE INDEX idx_harness_session_id ON harness_entries(session_id, id); -CREATE INDEX idx_harness_ref_type_seq ON harness_entries(session_id, ref, type, seq); -CREATE INDEX idx_harness_ref_kind_seq ON harness_entries(session_id, ref, type, op_kind, seq); -``` - -- `seq` comes from the existing per-session sequence, allocated across both tables, so `getLog()` is a merge of two range scans by `seq`. `session_entries` is unchanged — no ref column; the tail reduction resolves chain membership in memory. -- Restore's queries are all index seeks + bounded scans, per ref: the ref's latest `operation_started`/`operation_finished` via `idx_harness_ref_type_seq`, its last run-kind start via `idx_harness_ref_kind_seq`, the tail via the primary key filtered by ref. -- **Per-ref leaf state:** the single active-leaf projection becomes a refs table — `(session_id, ref, leaf_id)` — updated by leaf records. Old databases migrate to a single `main` row. -- **`branch_entries`** is materialized to root — no longer truncated at the newest compaction — keyed **per ref** (each ref's queries walk its own path: extended incrementally by that ref's appends, rebuilt only when that ref navigates), and gains denormalized `entry_type` / `custom_type` columns with an index on `(session_id, ref, entry_type, entry_seq)`. Every branch finder is an index seek plus a range scan in either direction; compaction is a query-time `stopAtType`, not a materialization boundary. Refs whose paths share a prefix duplicate those rows — bounded cache cost, not log cost. Branch-switch rebuild stays the rare expensive case, optimizable later by diffing against the previous path. -- **Each append is one transaction:** allocate seq → insert → update projections (the ref's leaf, its `branch_entries`, materialized stats/labels for session entries; nothing for harness entries) → commit → then events. In-memory caches roll back with the transaction. -- Labels and session name live in their existing projection tables; their records are no longer tree entries (section 5), which changes nothing about how they are stored, only that they never enter `branch_entries`. -- **Writer claim:** a lease row per session (owner id + heartbeat). `create()` on a session with a live claim fails; a stale claim (crashed owner) is taken over. This is the "SQLite rejects concurrent harnesses" enforcement from section 1. -- **Fork** copies session entries only — the selected branch (`scope: "branch"`) or all (`scope: "tree"`) — and never touches `harness_entries`. -- Malformed rows are never silently skipped: a row that fails decoding in any durable read path is a corruption error. (The current implementation drops such rows in `findEntries`; that behavior is a bug under this design.) -- `PRAGMA journal_mode=WAL`, `synchronous=FULL` stays the durability policy. - -### Append failure - -Any backend append failure faults the harness (section 4): the instance stops, in-flight calls resolve `faulted`, and the log remains a valid prefix. For SQLite, a failed transaction rolls back cleanly; for JSONL, a partial line becomes the torn tail the next open repairs. - -## 15. Telemetry - -The third channel, next to events (observe from outside) and hooks (control): in-process diagnostics for logging and tracing. Vendor-neutral — pi emits stable, structured span events; external subscribers convert them to OTel spans, Sentry spans, logs, or metrics. Core packages never import OTel, Sentry, or Node-only APIs. (Origin: `packages/agent/docs/observability.md`; this section supersedes its event names with the new vocabulary.) - -### Mechanism - -A trace is one causal tree of work (one run). A span is one timed operation in it, represented by ids: - -```ts -interface PiObservabilityEvent { - type: "start" | "end" | "error" | "event"; - name: string; // e.g. "pi.harness.generation" - traceId: string; - spanId?: string; - parentSpanId?: string; - timestamp: number; - durationMs?: number; - context?: Record; // user context, see below - payload?: Record; // safe attributes, see redaction - error?: { name: string; message: string }; -} - -// Runtime-agnostic core; adapters supply context propagation. -interface PiObservability { - getContext(): PiObservabilityContext | undefined; - runWithContext(context: PiObservabilityContext, fn: () => T): T; - emit(event: PiObservabilityEvent): void; - hasSubscribers(): boolean; // skip payload assembly when nobody listens -} - -function configurePiObservability(o: PiObservability): void; -function subscribePiObservability(listener: (e: PiObservabilityEvent) => void): () => void; -function runWithPiContext(userContext: Record, fn: () => T): T; -function traceOperation(name: string, payload: Record, fn: () => T): T; -``` - -`traceOperation()` reads the current context, mints `traceId` (if absent) and a fresh `spanId`, parents to the current span, emits `start`, runs the callback under the child context, then emits `end` or `error` (rethrowing). Promise-aware: `end` fires after settlement. - -Context propagation is a runtime adapter, not a core dependency: Node uses `AsyncLocalStorage` (plus optional `diagnostics_channel` publishing); browser/workers fall back to a local subscriber set with manual propagation. Concurrent runs therefore keep distinct contexts: - -```ts -await Promise.all([ - runWithPiContext({ userId: "alice" }, () => harnessA.prompt("A")), - runWithPiContext({ userId: "bob" }, () => harnessB.prompt("B")), -]); -``` - -Every span emitted inside a chain carries that chain's `context` — an OTel adapter maps it to span attributes, a log adapter prints JSON. - -### Span tree - -Aligned to the execution model; each span emits `start` + `end`/`error`: - -```text -pi.harness.run runId, sessionId, recovery -├─ pi.harness.step stepId -│ ├─ pi.harness.generation purpose, attempt, provider, model -│ │ └─ pi.ai.provider.request physical request — emitted by packages/ai -│ │ (several per generation for split-turn compaction) -│ └─ pi.harness.tool toolName, toolCallId, replay -├─ pi.harness.checkpoint deferred writes / queue consumption / compaction decision -└─ pi.harness.hook hook type — the awaited control points - -pi.harness.compaction manual operation (auto-compaction nests under its run) -pi.harness.navigation -pi.harness.resume wraps recovery work; child spans as above -pi.session.append entry type, seq — storage-level timing -``` - -Instrumentation points: the operation methods (`prompt`/`skill`/`promptFromTemplate`/`compact`/`navigateTree`/`resume`), the driver loop's step/generation/tool boundaries, hook dispatch, `Session` appends, and `streamSimple()`/`completeSimple()` in `packages/ai`. End payloads for provider requests carry safe metadata: stop reason, status code, retry count, token counts, cost, aborted/timeout flags. - -Correlation attributes are the same ids the public events carry (`ref`, `runId`, `stepId`, `toolCallId`), so a trace, the event stream, and the log line up without translation. Concurrent refs produce concurrent `pi.harness.run` traces, distinguished by `ref`. `handler_error` and `fault` are mirrored here, as specced in sections 9/10. - -### Safety and redaction - -Default payloads must be safe: - -| safe by default | unsafe — never emitted by default | -|---|---| -| provider, model, API id | prompts, completions | -| session id, entry type, tool name | tool args, tool results | -| status code, stop reason | shell output, file contents | -| token counts, costs, durations | provider request payloads, response bodies | -| retry counts, aborted/timeout flags | API keys, headers | - -Content capture (the "down to provider internals" of section 1) is opt-in via explicit redaction hooks at subscriber configuration — never ambient. - -### Subscriber contract - -- Passive, always: subscriber errors are swallowed/isolated and can never affect execution — unlike hooks, which are control-plane by design. -- Exporting, sampling, and scrubbing are the subscriber's job. Pi emits facts; it does not talk to APM vendors. -- Package layout: a minimal runtime-agnostic `packages/observability` (context + traceOperation + subscribe); `packages/ai` and `packages/agent` emit; optional adapters (`observability-node` with ALS/diagnostics_channel, an OTel bridge) live outside core. - -### Integration examples - -Harness — spans wrap the section 12 procedures; ALS context propagation makes the nesting automatic (no ids passed by hand): - -```ts -// prompt() and resume() both land here -async function executeRun(op: RunOperation): Promise { - return traceOperation("pi.harness.run", - { runId: op.id, sessionId, recovery: op.persisted }, - () => runProcedure()); -} - -// driverLoop(): one span per step, per generation, per tool -await traceOperation("pi.harness.step", { runId: op.id, stepId }, async () => { - const assistant = await traceOperation("pi.harness.generation", - { purpose: "step", attempt, provider: model.provider, model: model.id }, - () => requestAssistant()); // pi.ai.provider.request nests underneath - if (hasToolCalls(assistant)) await executeToolBatch(assistant); -}); - -// executeToolBatch(), per call -await traceOperation("pi.harness.tool", - { runId: op.id, toolName: call.name, toolCallId: call.id, replay }, - () => executeTool(call)); - -// hook dispatch and Session appends: same one-line wrapping -``` - -pi-ai — `streamSimple()` returns its stream synchronously, so the span cannot wrap the return value; it ends when the stream settles. That is the correct span boundary for a streaming API, not a workaround — the caller gets the identical stream, unchanged: - -```ts -// packages/ai/src/models.ts -streamSimple(model, context, options): AssistantMessageEventStream { - const stream = this.doStreamSimple(model, context, options); // existing body - if (!hasSubscribers()) return stream; // zero cost when idle - - // stream.result() is the existing final-message promise; the span becomes - // one more awaiter. Provider errors are in-band in pi-ai (stopReason - // "error"/"aborted" messages, not rejections), so span status derives from - // the final message; nothing propagates to the caller. - void traceOperation("pi.ai.provider.request", { - api: model.api, provider: model.provider, model: model.id, - sessionId: options?.sessionId, reasoning: options?.reasoning, - }, async () => { - const message = await stream.result(); - if (message.stopReason === "error" || message.stopReason === "aborted") { - throw new ProviderSpanError(message); // → "error" span event - } - return { stopReason: message.stopReason, ...safeUsage(message.usage) }; - }).catch(() => {}); // span error recorded; never rethrown - - return stream; -} -``` - -Application — per-request context and subscribers: - -```ts -// every span in this run carries the user context -await runWithPiContext({ userId, orgId, sessionId }, () => harness.prompt(text)); - -// console JSON sink -subscribePiObservability((e) => log.write(JSON.stringify(e))); - -// OTel bridge, in its entirety -subscribePiObservability((e) => { - if (e.type === "start") { - spans.set(e.spanId!, tracer.startSpan(e.name, { attributes: flatten(e.payload) })); - } else if (e.type === "end" || e.type === "error") { - const span = spans.get(e.spanId!); - if (!span) return; - if (e.type === "error") span.setStatus({ code: SpanStatusCode.ERROR, message: e.error?.message }); - span.end(); - spans.delete(e.spanId!); - } -}); -``` - -## 16. API examples - -```ts -// Interactive pi: single ref, nothing changes. AgentHarness implements -// AgentRef for main; suspended has 0 or 1 entries, always "main". -const { harness, suspended } = await AgentHarness.create({ session, models, model }); -for (const s of suspended) await harness.ref(s.ref)!.resume(); // or offer resume/abort in UI -await harness.prompt("fix the bug"); -await harness.steer("focus on the tests"); -harness.setModel(opus); // main's branch-anchored config - -// Slack bot: channel = session + main, each thread = a ref keyed by thread_ts. -const key = `slack:${threadTs}`; -const t = harness.ref(key) ?? (await harness.createRef(key, pingedEntryId)).ref; -await t.prompt("summarize this thread"); // parallel with main and other threads -await t.steer("shorter"); -t.setModel(haiku); // this thread only -await t.navigateTree(earlierId); // moves this thread's leaf only -await t.session.appendMessage(msg); // appends to this thread's branch -await t.nextRun("also check the links"); // consumed by this thread's next run - -// Thread renderer: scoped snapshot + only this thread's events. -const { snapshot, start, unsubscribe } = await t.watch(); -render(snapshot.transcript); -start((event) => update(event)); - -// Dashboard / server: inventory + firehose, no transcripts. -const sess = await harness.watchSession(); -for (const r of sess.snapshot.refs) { - if (r.run?.status === "suspended") await harness.ref(r.name)!.resume(); -} -``` - -## 17. Open questions - -For review (Armin): - -1. **Hook/event scoping.** Registration is harness-global; every payload carries `ref`, so handlers can scope themselves. Is that enough for API users, or do we want per-ref registration (`ref.hooks.on(...)`, `ref.events.on(...)`) with scoped delivery — e.g. a `before_tool` policy that applies to one Slack thread only? Global-with-ref is strictly more general but pushes filtering boilerplate onto every scoped consumer. -2. **Refs and replication.** Refs are stored as flat per-ref sequences without parenting, because single-writer serialization makes log order causal order within a ref (section 6). Replication/split-brain reconciliation would need explicit causality — parent pointers or equivalent — to merge two divergent copies of the same ref. Is that the substance of the harness-entries-as-trees proposal, and do we accept designing it out for now? Everything else in that proposal (partition-safe reduction, one-table storage) is covered by the `ref` field. - -## 18. Testing strategy - -TODO — after the document has been reviewed end to end. - -## 19. Implementation sequence - -TODO — after the document has been reviewed end to end. - -## 20. Required reading - -For a fresh implementation session. Read in full, in this order. This document is the authoritative design; where older docs conflict, this one wins. - -Design and contracts: - -1. `packages/agent/docs/harness.md` — this document. -2. `packages/agent/docs/agent-harness.md` — current harness contract and implementation status. -3. `packages/agent/docs/hooks.md` — prior hook/event reduction design. -4. `packages/agent/docs/observability.md` — passive tracing requirements. - -Current implementation (what is being replaced or wrapped): - -5. `packages/agent/src/agent-loop.ts` — monolithic loop to split into step primitives. -6. `packages/agent/src/agent.ts` — stateful wrapper: queues, continuation, abort, settlement. -7. `packages/agent/src/harness/agent-harness.ts` — the harness this design replaces. -8. `packages/agent/src/harness/types.ts` — entry union, storage contract, event/hook types. -9. `packages/agent/src/harness/session/session.ts` — Session, context build, entry creation. -10. `packages/agent/src/harness/session/jsonl-storage.ts` — JSONL v3 format and reload. -11. `packages/agent/src/harness/session/memory-storage.ts` — in-memory parity. -12. `packages/agent/src/harness/messages.ts` — defaultConvertToLlm and message helpers. -12a. `packages/ai/src/utils/transform-messages.ts` — orphaned-tool-call healing; the adjacency backstop referenced in sections 5 and 13. -13. `packages/agent/src/harness/compaction/compaction.ts` — preparation, split-turn generation, retry. -14. `packages/coding-agent/src/core/agent-session.ts` — old behavior to preserve in spirit: queues, bash, extensions, retry, compaction flows. -15. `packages/coding-agent/src/core/extensions/runner.ts` — old extension semantics (error isolation, before_agent_start reduction). - -SQLite backend: - -16. `packages/storage/sqlite-node/src/sqlite/storage/index.ts` — transactions, sequences, leaf state, branch materialization. -17. `packages/storage/sqlite-node/src/sqlite/storage/session-entries.ts` — encoding and validation. -18. `packages/storage/sqlite-node/src/sqlite/storage/branch-entries.ts` — active-branch materialization. -19. `packages/storage/sqlite-node/src/sqlite/storage/session-materialized.ts` — stats/labels/config projections. -20. `packages/storage/sqlite-node/src/sqlite/migrations/001_initial.sql` and `migrations.ts` — schema and migration mechanism. -21. `packages/storage/sqlite-node/src/sqlite/repo.ts` — create/open/fork. - -Behavioral tests (compatibility requirements): - -22. `packages/agent/test/agent-loop.test.ts` -23. `packages/agent/test/agent.test.ts` -24. `packages/agent/test/harness/agent-harness.test.ts` -25. `packages/agent/test/harness/session.test.ts` -26. `packages/agent/test/harness/storage.test.ts` -27. `packages/agent/test/harness/sqlite-migrations.test.ts` diff --git a/packages/agent/package.json b/packages/agent/package.json index 47f81afb723..d588fab12e5 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -14,13 +14,9 @@ "types": "./dist/node.d.ts", "import": "./dist/node.js" }, - "./experimental": { - "types": "./dist/experimental.d.ts", - "import": "./dist/experimental.js" - }, - "./experimental/session/testing": { - "types": "./dist/harness/experimental/session/testing/index.d.ts", - "import": "./dist/harness/experimental/session/testing/index.js" + "./session/testing": { + "types": "./dist/harness/session/testing/index.d.ts", + "import": "./dist/harness/session/testing/index.js" }, "./package.json": "./package.json" }, diff --git a/packages/agent/src/experimental.ts b/packages/agent/src/experimental.ts deleted file mode 100644 index 8639f4073af..00000000000 --- a/packages/agent/src/experimental.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./harness/experimental/session/index.ts"; diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index 53b1f334706..c77e602c89f 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -1,1185 +1,532 @@ -import { - type AssistantMessage, - contentText, - type ImageContent, - type Model, - type Models, - type RetryCallbacks, - type RetryPolicy, - type UserMessage, -} from "@earendil-works/pi-ai"; -import { runAgentLoop } from "../agent-loop.ts"; import type { - AgentContext, - AgentEvent, - AgentLoopConfig, - AgentMessage, - AgentTool, - QueueMode, - StreamFn, - ThinkingLevel, -} from "../types.ts"; -import { collectEntriesForBranchSummary, generateBranchSummary } from "./compaction/branch-summarization.ts"; -import { compact, DEFAULT_COMPACTION_SETTINGS, prepareCompaction } from "./compaction/compaction.ts"; -import { convertToLlm } from "./messages.ts"; -import { formatPromptTemplateInvocation } from "./prompt-templates.ts"; -import { formatSkillInvocation } from "./skills.ts"; + Api, + AssistantMessage, + DeferredHandle, + ImageContent, + Message, + Model, + Models, + RetryPolicy, + SimpleStreamOptions, + Usage, +} from "@earendil-works/pi-ai"; +import type { AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../types.ts"; +import type { CompactionSettings } from "./compaction/compaction.ts"; +import { type Result as ResultValue, TaggedError } from "./result.ts"; import type { - AbortResult, - AgentHarnessEvent, - AgentHarnessEventResultMap, - AgentHarnessOptions, - AgentHarnessOwnEvent, - AgentHarnessPhase, - AgentHarnessResources, - AgentHarnessStreamOptions, - AgentHarnessStreamOptionsPatch, - AgentHarnessSystemPrompt, - AgentHarnessTool, - AgentHarnessToolContextSource, - CompactResult, - NavigateTreeResult, - PendingSessionWrite, - PromptTemplate, + BranchSummaryEntry, + CompactionEntry, + Entry, + JsonValue, + ProvisionedEntry, Session, - Skill, -} from "./types.ts"; -import { AgentHarnessError, BranchSummaryError, CompactionError, SessionError, toError } from "./types.ts"; + SessionTree, +} from "./session/index.ts"; +import type { AgentHarnessResources, PromptTemplate, Skill } from "./types.ts"; + +export class LaneBusy extends TaggedError("LaneBusy")<{ + lane: string; + operationId: string; + operationKind: "run" | "compaction" | "navigation"; + message: string; +}> {} +export class MissingIdentities extends TaggedError("MissingIdentities")<{ + lane: string; + tools: string[]; + models: string[]; + message: string; +}> {} +export class NoActiveRun extends TaggedError("NoActiveRun")<{ lane: string; message: string }> {} +export class NoActiveOperation extends TaggedError("NoActiveOperation")<{ lane: string; message: string }> {} +export class NothingToResume extends TaggedError("NothingToResume")<{ lane: string; message: string }> {} +export class InvalidMessage extends TaggedError("InvalidMessage")<{ lane: string; reason: string; message: string }> {} +export class UnknownSkill extends TaggedError("UnknownSkill")<{ name: string; message: string }> {} +export class UnknownTemplate extends TaggedError("UnknownTemplate")<{ name: string; message: string }> {} +export class UnknownTarget extends TaggedError("UnknownTarget")<{ targetId: string; message: string }> {} +export class UnknownQueueItem extends TaggedError("UnknownQueueItem")<{ + lane: string; + entryId: string; + message: string; +}> {} +export class LaneExists extends TaggedError("LaneExists")<{ lane: string; message: string }> {} +export class InvalidLane extends TaggedError("InvalidLane")<{ lane: string; reason: string; message: string }> {} +export class NothingToCompact extends TaggedError("NothingToCompact")<{ lane: string; message: string }> {} +export class Closed extends TaggedError("Closed")<{ message: string }> {} + +export class HarnessFault extends Error { + readonly cause: unknown; + + constructor(message: string, cause: unknown) { + super(message); + this.name = "HarnessFault"; + this.cause = cause; + } +} -function createUserMessage(text: string, images?: ImageContent[]): UserMessage { - const content: Array<{ type: "text"; text: string } | ImageContent> = [{ type: "text", text }]; - if (images) content.push(...images); - return { role: "user", content, timestamp: Date.now() }; +export class HarnessClosed extends Error { + constructor() { + super("AgentHarness was closed while the operation was active"); + this.name = "HarnessClosed"; + } } -function createFailureMessage(model: Model, error: unknown, aborted: boolean): AssistantMessage { - return { - role: "assistant", - content: [{ type: "text", text: "" }], - api: model.api, - provider: model.provider, - model: model.id, - stopReason: aborted ? "aborted" : "error", - errorMessage: error instanceof Error ? error.message : String(error), - timestamp: Date.now(), - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - }; +export class HarnessNotImplemented extends Error { + readonly operation: string; + + constructor(operation: string) { + super(`AgentHarness.${operation} is not implemented yet`); + this.name = "HarnessNotImplemented"; + this.operation = operation; + } } -function cloneStreamOptions(streamOptions?: AgentHarnessStreamOptions): AgentHarnessStreamOptions { - return { - ...streamOptions, - headers: streamOptions?.headers ? { ...streamOptions.headers } : undefined, - metadata: streamOptions?.metadata ? { ...streamOptions.metadata } : undefined, - }; +export interface OperationError { + code: string; + message: string; } -function findDuplicateNames(names: string[]): string[] { - const seen = new Set(); - const duplicates = new Set(); - for (const name of names) { - if (seen.has(name)) duplicates.add(name); - seen.add(name); - } - return [...duplicates]; +export type RunOutcome = + | { kind: "completed"; leafId: string; finalEntryId: string; finalMessage: AssistantMessage } + | { kind: "aborted"; leafId: string; finalEntryId: string; finalMessage: AssistantMessage } + | { kind: "failed"; leafId: string; error: OperationError; finalEntryId?: string; finalMessage?: AssistantMessage } + | { kind: "suspended"; leafId: string; finalEntryId: string; deferred: DeferredHandle }; + +export type CompactionOutcome = + | { kind: "completed"; leafId: string; entry: CompactionEntry } + | { kind: "declined" | "aborted"; leafId: string } + | { kind: "failed"; leafId: string; error: OperationError }; + +export type NavigationOutcome = + | { kind: "completed"; newLeafId: string | null; summaryEntry?: BranchSummaryEntry } + | { kind: "declined" | "aborted"; leafId: string | null } + | { kind: "failed"; leafId: string | null; error: OperationError }; + +export type RunRejected = LaneBusy | InvalidMessage | UnknownSkill | UnknownTemplate | Closed; +export type CompactionRejected = LaneBusy | NothingToCompact | Closed; +export type NavigationRejected = LaneBusy | UnknownTarget | Closed; +export type ResumeRejected = LaneBusy | NothingToResume | MissingIdentities | Closed; +export type QueueRejected = NoActiveRun | InvalidMessage | Closed; +export type CancelQueuedRejected = UnknownQueueItem | Closed; +export type AbortRejected = NoActiveOperation | Closed; + +export type RunResult = ResultValue<{ runId: string } & RunOutcome, RunRejected>; +export type CompactionResult = ResultValue<{ runId: string } & CompactionOutcome, CompactionRejected>; +export type NavigationResult = ResultValue<{ runId: string } & NavigationOutcome, NavigationRejected>; +export type QueueResult = ResultValue<{ entryId: string }, QueueRejected>; +export type CancelQueuedResult = ResultValue< + { outcome: "cancelled" | "already_consumed" | "already_cleared" }, + CancelQueuedRejected +>; +export type RecordUsageResult = ResultValue; +export type AbortResult = ResultValue< + { runId: string; steer: AgentMessage[]; followUp: AgentMessage[] }, + AbortRejected +>; + +export type ResumeOutcome = + | ({ operation: "run"; runId: string } & RunOutcome) + | ({ operation: "compaction"; runId: string } & CompactionOutcome) + | ({ operation: "navigation"; runId: string } & NavigationOutcome); +export type ResumeResult = ResultValue; +export type CreateLaneResult = ResultValue; + +export interface NavigateOptions { + summarize?: boolean; + customInstructions?: string; + label?: string; } -function applyStreamOptionsPatch( - base: AgentHarnessStreamOptions, - patch?: AgentHarnessStreamOptionsPatch, -): AgentHarnessStreamOptions { - const result = cloneStreamOptions(base); - if (!patch) return result; +export interface SuspendedOperation { + lane: string; + kind: "run" | "compaction" | "navigation"; + id: string; + startedAt: number; + reason: "crash" | "deferred"; + prompt?: AgentMessage[]; + deferred?: DeferredHandle; + aborting?: { steer: AgentMessage[]; followUp: AgentMessage[] }; + missing: { tools: string[]; models: string[] }; +} - if (Object.hasOwn(patch, "transport")) result.transport = patch.transport; - if (Object.hasOwn(patch, "timeoutMs")) result.timeoutMs = patch.timeoutMs; - if (Object.hasOwn(patch, "maxRetries")) result.maxRetries = patch.maxRetries; - if (Object.hasOwn(patch, "maxRetryDelayMs")) result.maxRetryDelayMs = patch.maxRetryDelayMs; - if (Object.hasOwn(patch, "cacheRetention")) result.cacheRetention = patch.cacheRetention; +export interface LaneInfo { + name: string; + leafId: string | null; + operation: null | { + id: string; + kind: "run" | "compaction" | "navigation"; + status: "running" | "suspended" | "aborting"; + }; +} - if (Object.hasOwn(patch, "headers")) { - if (patch.headers === undefined) { - result.headers = undefined; - } else { - const headers = { ...(result.headers ?? {}) }; - for (const [key, value] of Object.entries(patch.headers)) { - if (value === undefined) delete headers[key]; - else headers[key] = value; - } - result.headers = Object.keys(headers).length > 0 ? headers : undefined; - } - } +export interface QueuedItem { + entryId: string; + message: AgentMessage; +} - if (Object.hasOwn(patch, "metadata")) { - if (patch.metadata === undefined) { - result.metadata = undefined; - } else { - const metadata = { ...(result.metadata ?? {}) }; - for (const [key, value] of Object.entries(patch.metadata)) { - if (value === undefined) delete metadata[key]; - else metadata[key] = value; - } - result.metadata = Object.keys(metadata).length > 0 ? metadata : undefined; - } - } +export interface LaneSnapshot { + lane: string; + transcript: Entry[]; + leafId: string | null; + operation: LaneInfo["operation"]; + queues: { steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] }; + pendingWrites: { id: string; entry: ProvisionedEntry }[]; + faulted: boolean; +} - return result; +export interface SessionSnapshot { + lanes: (LaneInfo & { suspended?: SuspendedOperation })[]; + faulted: boolean; } -const SUBSCRIBER_EVENT_TYPE = "*"; +export type ActionInfo = + | { kind: "append_entry"; entryType: Entry["type"]; entryId: string } + | { kind: "append_record"; recordType: string } + | { kind: "move_lane"; to: string | null } + | { kind: "set_fact"; fact: "name" | "label" } + | { kind: "try_finish_run"; outcome: "completed" | "failed" } + | { kind: "finish_operation"; outcome: "completed" | "declined" | "failed" | "aborted" } + | { kind: "commit_follow_up" } + | { kind: "consume_queue_item"; queue: "steer" | "followUp"; entryId: string } + | { kind: "apply_pending_write"; entryId: string } + | { kind: "stream_assistant"; step: "assistant" | "compaction" | "branch_summary"; attempt: number } + | { kind: "execute_tool"; toolCallId: string; toolName: string } + | { kind: "fetch_deferred" | "cancel_deferred"; provider: string; id: string } + | { kind: "hook"; name: HookName } + | { kind: "sleep"; delayMs: number }; + +export type HookName = + | "before_run" + | "before_resume" + | "before_run_end" + | "transform_context" + | "before_request" + | "before_payload" + | "after_response" + | "before_tool" + | "after_tool" + | "before_compaction" + | "before_navigation"; + +export interface Hooks { + on(name: HookName, handler: (event: unknown) => unknown | Promise, options?: { id?: string }): () => void; +} -type AgentHarnessHandler = (event: any, signal?: AbortSignal) => Promise | any; +export interface Events { + on(type: string, listener: (event: unknown) => void | Promise): () => void; +} -type TrackedTaskKind = "operation" | "mutation"; +class PassiveRegistry implements Hooks, Events { + on( + _name: HookName | string, + _handler: (event: unknown) => unknown | Promise, + _options?: { id?: string }, + ): () => void { + return () => {}; + } +} -function normalizeHarnessError(error: unknown, fallbackCode: AgentHarnessError["code"]): AgentHarnessError { - if (error instanceof AgentHarnessError) return error; - const cause = toError(error); - if (cause instanceof SessionError) return new AgentHarnessError("session", cause.message, cause); - if (cause instanceof CompactionError) return new AgentHarnessError("compaction", cause.message, cause); - if (cause instanceof BranchSummaryError) return new AgentHarnessError("branch_summary", cause.message, cause); - return new AgentHarnessError(fallbackCode, cause.message, cause); +export interface ExecutionSpan extends ExecutionContext { + addEvent(name: string, attributes?: SpanAttributes): void; + setAttributes(attributes: SpanAttributes): void; + end(result: SpanEnd): void; +} + +export interface ExecutionContext { + startSpan(name: string, attributes?: SpanAttributes): ExecutionSpan; +} +export interface SpanAttributes { + [name: string]: string | number | boolean | undefined; +} +export interface SpanEnd { + status: "ok" | "error"; + error?: { name: string; message: string }; + attributes?: SpanAttributes; } -function normalizeHookError(error: unknown): AgentHarnessError { - return normalizeHarnessError(error, "hook"); +export type HarnessTool = AgentTool & { replay?: "never" | "safe" }; +export type Resources = AgentHarnessResources; +export type StreamOptions = SimpleStreamOptions; +export type StreamOptionsPatch = Partial; +export type EntryProjector = (entry: Entry) => AgentMessage[] | Promise; + +export interface AgentHarnessOptions { + session: Session; + models: Models; + model: Model; + thinkingLevel?: ThinkingLevel; + activeToolNames?: string[]; + tools?: HarnessTool[]; + toolContext?: object | (() => object | Promise); + systemPrompt?: string | (() => string | Promise); + resources?: Resources; + streamOptions?: StreamOptions; + retry?: RetryPolicy; + compaction?: CompactionSettings; + steeringMode?: QueueMode; + followUpMode?: QueueMode; + toolExecution?: "sequential" | "parallel"; + drive?: "automatic" | "manual"; + toProviderMessages?: (messages: AgentMessage[]) => Message[] | Promise; + entryProjectors?: Record; + context?: ExecutionContext; } -interface AgentHarnessTurnState< - TContext extends object | undefined, - TSkill extends Skill = Skill, - TPromptTemplate extends PromptTemplate = PromptTemplate, - TTool extends AgentHarnessTool = AgentHarnessTool, -> { - messages: AgentMessage[]; - resources: AgentHarnessResources; - toolContext: TContext; - streamOptions: AgentHarnessStreamOptions; - sessionId: string; - systemPrompt: string; - model: Model; - thinkingLevel: ThinkingLevel; - tools: TTool[]; - activeTools: TTool[]; +export interface WatchHandle { + snapshot: TSnapshot; + start(listener: (event: unknown) => void): void; + unsubscribe(): void; } -export class AgentHarness< - TContext extends object | undefined = undefined, - TSkill extends Skill = Skill, - TPromptTemplate extends PromptTemplate = PromptTemplate, - TTool extends AgentHarnessTool = AgentHarnessTool, -> { - private session: Session; - readonly models: Models; - private phase: AgentHarnessPhase = "idle"; - private activeAbortController?: AbortController; - private readonly activeTasks = new Map, TrackedTaskKind>(); - private shutdownPromise?: Promise; - private isShutdown = false; - private pendingSessionWrites: PendingSessionWrite[] = []; - private model: Model; +export interface AgentLane { + readonly name: string; + getLeafId(): Promise; + prompt(text: string, images?: ImageContent[]): Promise; + prompt(message: AgentMessage | AgentMessage[]): Promise; + skill(name: string, additionalInstructions?: string): Promise; + promptFromTemplate(name: string, args?: string[]): Promise; + compact(options?: { customInstructions?: string }): Promise; + navigateTree(targetId: string | null, options?: NavigateOptions): Promise; + resume(): Promise; + abort(): Promise; + steer(text: string, images?: ImageContent[]): Promise; + steer(message: AgentMessage): Promise; + followUp(text: string, images?: ImageContent[]): Promise; + followUp(message: AgentMessage): Promise; + nextRun(text: string, images?: ImageContent[]): Promise; + nextRun(message: AgentMessage): Promise; + cancelQueued(entryId: string): Promise; + recordUsage(usage: Usage, options?: { entryId?: string; details?: JsonValue }): Promise; + waitForIdle(): Promise; + runWhenIdle(callback: () => void | Promise): Promise; + peekAction(): Promise; + executeAction(): Promise; + runToCompletion(): Promise; + getModel(): Promise>; + setModel(model: Model): Promise; + getThinkingLevel(): Promise; + setThinkingLevel(level: ThinkingLevel): Promise; + getActiveTools(): Promise; + setActiveTools(names: string[]): Promise; + readonly session: SessionTree; + watch(): Promise>; +} + +export class AgentHarness implements AgentLane { + readonly name = "main"; + readonly session: SessionTree; + readonly hooks: Hooks = new PassiveRegistry(); + readonly events: Events = new PassiveRegistry(); + private readonly durableSession: Session; + private model: Model; private thinkingLevel: ThinkingLevel; - private systemPrompt: AgentHarnessSystemPrompt | undefined; - private toolContext: AgentHarnessToolContextSource | undefined; - private streamOptions: AgentHarnessStreamOptions; - private retry: RetryPolicy | undefined; - private resources: AgentHarnessResources; - private tools = new Map(); private activeToolNames: string[]; - private steerQueue: UserMessage[] = []; - private steeringQueueMode: QueueMode; - private followUpQueue: UserMessage[] = []; - private followUpQueueMode: QueueMode; - private nextTurnQueue: AgentMessage[] = []; - private handlers = new Map>(); - - constructor(options: AgentHarnessOptions) { + private tools: HarnessTool[]; + private resources: Resources; + private streamOptions: StreamOptions; + private retryPolicy: RetryPolicy; + private compactionSettings: CompactionSettings; + private steeringMode: QueueMode; + private followUpMode: QueueMode; + private closed = false; + + private constructor(options: AgentHarnessOptions) { + this.durableSession = options.session; this.session = options.session; - this.models = options.models; - this.resources = options.resources ?? {}; - this.streamOptions = cloneStreamOptions(options.streamOptions); - this.retry = options.retry; - this.systemPrompt = options.systemPrompt; - this.toolContext = options.toolContext; - this.validateUniqueNames( - (options.tools ?? []).map((tool) => tool.name), - "Duplicate tool name(s)", - ); - for (const tool of options.tools ?? []) { - this.tools.set(tool.name, tool); - } this.model = options.model; this.thinkingLevel = options.thinkingLevel ?? "off"; - this.activeToolNames = options.activeToolNames - ? [...options.activeToolNames] - : (options.tools ?? []).map((tool) => tool.name); - this.validateUniqueNames(this.activeToolNames, "Duplicate active tool name(s)"); - this.validateToolNames(this.activeToolNames); - this.steeringQueueMode = options.steeringMode ?? "one-at-a-time"; - this.followUpQueueMode = options.followUpMode ?? "one-at-a-time"; - } - - private assertNotShutDown(): void { - if (this.isShutdown) throw new AgentHarnessError("invalid_state", "AgentHarness has been shut down"); - } - - private getHandlers(type: string): Set | undefined { - return this.handlers.get(type); - } - - private async emitOwn(event: AgentHarnessOwnEvent, signal?: AbortSignal): Promise { - for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) { - try { - await listener(event, signal); - } catch (error) { - throw normalizeHookError(error); - } - } - } - - private async emitAny(event: AgentHarnessEvent, signal?: AbortSignal): Promise { - for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) { - try { - await listener(event, signal); - } catch (error) { - throw normalizeHookError(error); - } - } - } - - private async emitHook( - event: Extract, - ): Promise { - const handlers = this.getHandlers(event.type as TType); - if (!handlers || handlers.size === 0) return undefined; - let lastResult: AgentHarnessEventResultMap[TType] | undefined; - for (const handler of handlers) { - try { - const result = await handler(event); - if (result !== undefined) { - lastResult = result; - } - } catch (error) { - throw normalizeHookError(error); - } - } - return lastResult; - } - - private retryCallbacks(operation: "compaction" | "branch_summary"): RetryCallbacks { - return { - onRetryScheduled: (attempt, maxAttempts, delayMs, errorMessage) => - this.emitOwn({ type: "retry_scheduled", operation, attempt, maxAttempts, delayMs, errorMessage }), - onRetryAttemptStart: () => this.emitOwn({ type: "retry_attempt_start", operation }), - onRetryFinished: () => this.emitOwn({ type: "retry_finished", operation }), - }; - } - - private async emitBeforeProviderRequest( - model: Model, - sessionId: string, - streamOptions: AgentHarnessStreamOptions, - ): Promise { - const handlers = this.getHandlers("before_provider_request"); - let current = cloneStreamOptions(streamOptions); - if (!handlers || handlers.size === 0) return current; - for (const handler of handlers) { - try { - const result = await handler({ - type: "before_provider_request", - model, - sessionId, - streamOptions: cloneStreamOptions(current), - }); - if (result?.streamOptions) { - current = applyStreamOptionsPatch(current, result.streamOptions); - } - } catch (error) { - throw normalizeHookError(error); - } - } - return current; - } - - private async emitBeforeProviderPayload(model: Model, payload: unknown): Promise { - const handlers = this.getHandlers("before_provider_payload"); - let current = payload; - if (!handlers || handlers.size === 0) return current; - for (const handler of handlers) { - try { - const result = await handler({ type: "before_provider_payload", model, payload: current }); - if (result !== undefined) { - current = result.payload; - } - } catch (error) { - throw normalizeHookError(error); - } - } - return current; - } - - private async emitQueueUpdate(): Promise { - await this.emitOwn({ - type: "queue_update", - steer: [...this.steerQueue], - followUp: [...this.followUpQueue], - nextTurn: [...this.nextTurnQueue], - }); - } - - private startOperation(): { signal: AbortSignal; finish: () => void } { - const abortController = new AbortController(); - let finish = () => {}; - this.activeAbortController = abortController; - void this.track( - "operation", - () => - new Promise((resolve) => { - finish = resolve; - }), - ); - return { - signal: abortController.signal, - finish: () => { - this.activeAbortController = undefined; - finish(); - }, - }; - } - - private async track(kind: TrackedTaskKind, operation: () => Promise): Promise { - let settle = () => {}; - const settled = new Promise((resolve) => { - settle = resolve; - }); - this.activeTasks.set(settled, kind); - try { - return await operation(); - } finally { - this.activeTasks.delete(settled); - settle(); - } - } - - private async waitForTasks(kind?: TrackedTaskKind): Promise { - while (true) { - const tasks = [...this.activeTasks].flatMap(([task, taskKind]) => - kind === undefined || kind === taskKind ? [task] : [], - ); - if (tasks.length === 0) return; - await Promise.all(tasks); - } - } - - private async resolveToolContext(): Promise { - if (typeof this.toolContext === "function") { - return await (this.toolContext as () => TContext | Promise)(); - } - return this.toolContext as TContext; - } - - private bindToolContext(tool: TTool, context: TContext): AgentTool { - return { - ...tool, - execute: (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate, context), - }; - } - - private async createTurnState(): Promise> { - this.assertNotShutDown(); - const context = await this.session.buildContext(); - const resources = this.getResources(); - const sessionMetadata = await this.session.getMetadata(); - const toolContext = await this.resolveToolContext(); - const tools = [...this.tools.values()]; - const activeTools = this.activeToolNames - .map((name) => this.tools.get(name)) - .filter((tool): tool is TTool => tool !== undefined); - let systemPrompt = "You are a helpful assistant."; - if (typeof this.systemPrompt === "string") { - systemPrompt = this.systemPrompt; - } else if (this.systemPrompt) { - systemPrompt = await this.systemPrompt({ - session: this.session, - model: this.model, - thinkingLevel: this.thinkingLevel, - activeTools, - resources, - }); - } - return { - messages: context.messages, - resources, - toolContext, - streamOptions: cloneStreamOptions(this.streamOptions), - sessionId: sessionMetadata.id, - systemPrompt, - model: this.model, - thinkingLevel: this.thinkingLevel, - tools, - activeTools, + this.activeToolNames = [...(options.activeToolNames ?? options.tools?.map((tool) => tool.name) ?? [])]; + this.tools = [...(options.tools ?? [])]; + this.resources = { + skills: options.resources?.skills ? [...options.resources.skills] : undefined, + promptTemplates: options.resources?.promptTemplates ? [...options.resources.promptTemplates] : undefined, }; - } - - private createContext( - turnState: AgentHarnessTurnState, - systemPrompt?: string, - ): AgentContext { - return { - systemPrompt: systemPrompt ?? turnState.systemPrompt, - messages: turnState.messages.slice(), - tools: turnState.activeTools.map((tool) => this.bindToolContext(tool, turnState.toolContext)), + this.streamOptions = { ...(options.streamOptions ?? {}) }; + this.retryPolicy = options.retry ?? { enabled: false, maxRetries: 0, baseDelayMs: 1000 }; + this.compactionSettings = options.compaction ?? { + enabled: true, + reserveTokens: 16384, + keepRecentTokens: 20000, }; + this.steeringMode = options.steeringMode ?? "one-at-a-time"; + this.followUpMode = options.followUpMode ?? "one-at-a-time"; } - private createStreamFn( - getTurnState: () => AgentHarnessTurnState, - ): StreamFn { - return async (model, context, streamOptions) => { - const turnState = getTurnState(); - const snapshotOptions: AgentHarnessStreamOptions = { ...turnState.streamOptions }; - const requestOptions = await this.emitBeforeProviderRequest(model, turnState.sessionId, snapshotOptions); - return this.models.streamSimple(model, context, { - cacheRetention: requestOptions.cacheRetention, - headers: requestOptions.headers, - maxRetries: requestOptions.maxRetries, - maxRetryDelayMs: requestOptions.maxRetryDelayMs, - metadata: requestOptions.metadata, - onPayload: async (payload) => await this.emitBeforeProviderPayload(model, payload), - onResponse: async (response) => { - const headers = { ...(response.headers as Record) }; - await this.emitOwn( - { type: "after_provider_response", status: response.status, headers }, - streamOptions?.signal, - ); - }, - reasoning: streamOptions?.reasoning, - signal: streamOptions?.signal, - sessionId: turnState.sessionId, - timeoutMs: requestOptions.timeoutMs, - transport: requestOptions.transport, - }); - }; + static async create( + options: AgentHarnessOptions, + ): Promise<{ harness: AgentHarness; suspended: SuspendedOperation[] }> { + return { harness: new AgentHarness(options), suspended: [] }; } - private async drainQueuedMessages(queue: AgentMessage[], mode: QueueMode): Promise { - const messages = mode === "all" ? queue.splice(0) : queue.splice(0, 1); - if (messages.length === 0) return messages; - try { - await this.emitQueueUpdate(); - return messages; - } catch (error) { - queue.unshift(...messages); - throw normalizeHookError(error); - } + private unavailable(operation: string): Promise { + return Promise.reject(this.closed ? new HarnessClosed() : new HarnessNotImplemented(operation)); } - private createLoopConfig( - getTurnState: () => AgentHarnessTurnState, - setTurnState: (turnState: AgentHarnessTurnState) => void, - ): AgentLoopConfig { - const turnState = getTurnState(); - return { - model: turnState.model, - reasoning: turnState.thinkingLevel === "off" ? undefined : turnState.thinkingLevel, - convertToLlm, - transformContext: async (messages) => { - const result = await this.emitHook({ type: "context", messages: [...messages] }); - return result?.messages ?? messages; - }, - beforeToolCall: async ({ toolCall, args }) => { - const result = await this.emitHook({ - type: "tool_call", - toolCallId: toolCall.id, - toolName: toolCall.name, - input: args as Record, - }); - return result ? { block: result.block, reason: result.reason } : undefined; - }, - afterToolCall: async ({ toolCall, args, result, isError }) => { - const patch = await this.emitHook({ - type: "tool_result", - toolCallId: toolCall.id, - toolName: toolCall.name, - input: args as Record, - content: result.content, - details: result.details, - isError, - usage: result.usage, - }); - return patch - ? { - content: patch.content, - details: patch.details, - isError: patch.isError, - usage: patch.usage, - terminate: patch.terminate, - } - : undefined; - }, - prepareNextTurn: async () => { - await this.flushPendingSessionWrites(); - const nextTurnState = await this.createTurnState(); - setTurnState(nextTurnState); - return { - context: this.createContext(nextTurnState), - model: nextTurnState.model, - thinkingLevel: nextTurnState.thinkingLevel, - }; - }, - getSteeringMessages: async () => this.drainQueuedMessages(this.steerQueue, this.steeringQueueMode), - getFollowUpMessages: async () => this.drainQueuedMessages(this.followUpQueue, this.followUpQueueMode), - }; + async getLeafId(): Promise { + return this.durableSession.getLeafId(); } - private validateUniqueNames(names: string[], message: string): void { - const duplicates = findDuplicateNames(names); - if (duplicates.length > 0) - throw new AgentHarnessError("invalid_argument", `${message}: ${duplicates.join(", ")}`); + async prompt(_text: string, _images?: ImageContent[]): Promise; + async prompt(_message: AgentMessage | AgentMessage[]): Promise; + async prompt(_input: string | AgentMessage | AgentMessage[], _images?: ImageContent[]): Promise { + return this.unavailable("prompt"); } - - private validateToolNames(toolNames: string[], tools: Map = this.tools): void { - this.validateUniqueNames(toolNames, "Duplicate active tool name(s)"); - const missing = toolNames.filter((name) => !tools.has(name)); - if (missing.length > 0) throw new AgentHarnessError("invalid_argument", `Unknown tool(s): ${missing.join(", ")}`); + async skill(_name: string, _additionalInstructions?: string): Promise { + return this.unavailable("skill"); } - - private async flushPendingSessionWrites(): Promise { - while (this.pendingSessionWrites.length > 0) { - const write = this.pendingSessionWrites[0]!; - if (write.type === "message") { - await this.session.appendMessage(write.message); - } else if (write.type === "model_change") { - await this.session.appendModelChange(write.provider, write.modelId); - } else if (write.type === "thinking_level_change") { - await this.session.appendThinkingLevelChange(write.thinkingLevel); - } else if (write.type === "active_tools_change") { - await this.session.appendActiveToolsChange(write.activeToolNames); - } else if (write.type === "custom") { - await this.session.appendCustomEntry(write.customType, write.data); - } else if (write.type === "custom_message") { - await this.session.appendCustomMessageEntry(write.customType, write.content, write.display, write.details); - } else if (write.type === "label") { - await this.session.appendLabel(write.targetId, write.label); - } else if (write.type === "session_info") { - await this.session.appendSessionName(write.name ?? ""); - } else if (write.type === "leaf") { - await this.session.moveTo(write.targetId); - } - this.pendingSessionWrites.shift(); - } + async promptFromTemplate(_name: string, _args?: string[]): Promise { + return this.unavailable("promptFromTemplate"); } - - private async handleAgentEvent(event: AgentEvent, signal?: AbortSignal): Promise { - if (event.type === "message_end") { - await this.session.appendMessage(event.message); - await this.emitAny(event, signal); - return; - } - if (event.type === "turn_end") { - let eventError: unknown; - try { - await this.emitAny(event, signal); - } catch (error) { - eventError = error; - } - const hadPendingMutations = this.pendingSessionWrites.length > 0; - await this.flushPendingSessionWrites(); - if (eventError) throw eventError; - await this.emitOwn({ type: "save_point", hadPendingMutations }); - return; - } - if (event.type === "agent_end") { - await this.flushPendingSessionWrites(); - this.phase = "idle"; - await this.emitAny(event, signal); - await this.emitOwn({ type: "settled", nextTurnCount: this.nextTurnQueue.length }, signal); - return; - } - await this.emitAny(event, signal); + async compact(_options?: { customInstructions?: string }): Promise { + return this.unavailable("compact"); } - - private async emitRunFailure( - model: Model, - error: unknown, - aborted: boolean, - signal: AbortSignal, - ): Promise { - const failureMessage = createFailureMessage(model, error, aborted); - await this.handleAgentEvent({ type: "message_start", message: failureMessage }, signal); - await this.handleAgentEvent({ type: "message_end", message: failureMessage }, signal); - await this.handleAgentEvent({ type: "turn_end", message: failureMessage, toolResults: [] }, signal); - await this.handleAgentEvent({ type: "agent_end", messages: [failureMessage] }, signal); - return [failureMessage]; + async navigateTree(_targetId: string | null, _options?: NavigateOptions): Promise { + return this.unavailable("navigateTree"); } - - private async executeTurn( - turnState: AgentHarnessTurnState, - text: string, - signal: AbortSignal, - options?: { images?: ImageContent[] }, - ): Promise { - this.assertNotShutDown(); - let activeTurnState = turnState; - let messages: AgentMessage[] = [createUserMessage(text, options?.images)]; - if (this.nextTurnQueue.length > 0) { - const queuedMessages = this.nextTurnQueue.splice(0); - try { - await this.emitQueueUpdate(); - } catch (error) { - this.nextTurnQueue.unshift(...queuedMessages); - throw normalizeHookError(error); - } - messages = [...queuedMessages, messages[0]!]; - } - const beforeResult = await this.emitHook({ - type: "before_agent_start", - prompt: text, - images: options?.images, - systemPrompt: turnState.systemPrompt, - resources: turnState.resources, - }); - this.assertNotShutDown(); - if (beforeResult?.messages) messages = [...messages, ...beforeResult.messages]; - - const getTurnState = () => activeTurnState; - const setTurnState = (nextTurnState: AgentHarnessTurnState) => { - activeTurnState = nextTurnState; - }; - const runResultPromise = (async () => { - try { - return await runAgentLoop( - messages, - this.createContext(turnState, beforeResult?.systemPrompt), - this.createLoopConfig(getTurnState, setTurnState), - (event) => this.handleAgentEvent(event, signal), - signal, - this.createStreamFn(getTurnState), - ); - } catch (error) { - try { - return await this.emitRunFailure(activeTurnState.model, error, signal.aborted, signal); - } catch (failureError) { - const cause = new AggregateError( - [toError(error), toError(failureError)], - "Agent run failed and failure reporting failed", - ); - throw new AgentHarnessError("unknown", cause.message, cause); - } - } - })(); - try { - const newMessages = await runResultPromise; - for (let i = newMessages.length - 1; i >= 0; i--) { - const message = newMessages[i]!; - if (message.role === "assistant") { - return message; - } - } - throw new AgentHarnessError("invalid_state", "AgentHarness prompt completed without an assistant message"); - } finally { - await this.flushPendingSessionWrites(); - } + async resume(): Promise { + return this.unavailable("resume"); } - - async prompt(text: string, options?: { images?: ImageContent[] }): Promise { - this.assertNotShutDown(); - if (this.phase !== "idle") throw new AgentHarnessError("busy", "AgentHarness is busy"); - this.phase = "turn"; - const operation = this.startOperation(); - try { - const turnState = await this.createTurnState(); - return await this.executeTurn(turnState, text, operation.signal, options); - } catch (error) { - this.phase = "idle"; - throw normalizeHarnessError(error, "unknown"); - } finally { - operation.finish(); - } + async abort(): Promise { + return this.unavailable("abort"); } - - async skill(name: string, additionalInstructions?: string): Promise { - this.assertNotShutDown(); - if (this.phase !== "idle") throw new AgentHarnessError("busy", "AgentHarness is busy"); - this.phase = "turn"; - const operation = this.startOperation(); - try { - const turnState = await this.createTurnState(); - const skill = (turnState.resources.skills ?? []).find((candidate) => candidate.name === name); - if (!skill) throw new AgentHarnessError("invalid_argument", `Unknown skill: ${name}`); - return await this.executeTurn( - turnState, - formatSkillInvocation(skill, additionalInstructions), - operation.signal, - ); - } catch (error) { - this.phase = "idle"; - throw normalizeHarnessError(error, "unknown"); - } finally { - operation.finish(); - } + async steer(_text: string, _images?: ImageContent[]): Promise; + async steer(_message: AgentMessage): Promise; + async steer(_input: string | AgentMessage, _images?: ImageContent[]): Promise { + return this.unavailable("steer"); } - - async promptFromTemplate(name: string, args: string[] = []): Promise { - this.assertNotShutDown(); - if (this.phase !== "idle") throw new AgentHarnessError("busy", "AgentHarness is busy"); - this.phase = "turn"; - const operation = this.startOperation(); - try { - const turnState = await this.createTurnState(); - const template = (turnState.resources.promptTemplates ?? []).find((candidate) => candidate.name === name); - if (!template) throw new AgentHarnessError("invalid_argument", `Unknown prompt template: ${name}`); - return await this.executeTurn(turnState, formatPromptTemplateInvocation(template, args), operation.signal); - } catch (error) { - this.phase = "idle"; - throw normalizeHarnessError(error, "unknown"); - } finally { - operation.finish(); - } + async followUp(_text: string, _images?: ImageContent[]): Promise; + async followUp(_message: AgentMessage): Promise; + async followUp(_input: string | AgentMessage, _images?: ImageContent[]): Promise { + return this.unavailable("followUp"); } - - async steer(text: string, options?: { images?: ImageContent[] }): Promise { - this.assertNotShutDown(); - if (this.phase === "idle") throw new AgentHarnessError("invalid_state", "Cannot steer while idle"); - this.steerQueue.push(createUserMessage(text, options?.images)); - await this.emitQueueUpdate(); + async nextRun(_text: string, _images?: ImageContent[]): Promise; + async nextRun(_message: AgentMessage): Promise; + async nextRun(_input: string | AgentMessage, _images?: ImageContent[]): Promise { + return this.unavailable("nextRun"); } - - async followUp(text: string, options?: { images?: ImageContent[] }): Promise { - this.assertNotShutDown(); - if (this.phase === "idle") throw new AgentHarnessError("invalid_state", "Cannot follow up while idle"); - this.followUpQueue.push(createUserMessage(text, options?.images)); - await this.emitQueueUpdate(); + async cancelQueued(_entryId: string): Promise { + return this.unavailable("cancelQueued"); } - - async nextTurn(text: string, options?: { images?: ImageContent[] }): Promise { - this.assertNotShutDown(); - this.nextTurnQueue.push(createUserMessage(text, options?.images)); - await this.emitQueueUpdate(); + async recordUsage(_usage: Usage, _options?: { entryId?: string; details?: JsonValue }): Promise { + return this.unavailable("recordUsage"); } - - async appendMessage(message: AgentMessage): Promise { - this.assertNotShutDown(); - return this.track("mutation", async () => { - try { - if (this.phase === "idle") { - await this.session.appendMessage(message); - } else { - this.pendingSessionWrites.push({ type: "message", message }); - } - } catch (error) { - throw normalizeHarnessError(error, "session"); - } - }); + async waitForIdle(): Promise {} + async runWhenIdle(callback: () => void | Promise): Promise { + await callback(); } - - async compact(customInstructions?: string): Promise { - this.assertNotShutDown(); - if (this.phase !== "idle") throw new AgentHarnessError("busy", "compact() requires idle harness"); - this.phase = "compaction"; - const operation = this.startOperation(); - try { - const model = this.model; - if (!model) throw new AgentHarnessError("invalid_state", "No model set for compaction"); - const branchEntries = await this.session.getBranch(); - const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS); - if (!preparationResult.ok) throw preparationResult.error; - const preparation = preparationResult.value; - if (!preparation) throw new AgentHarnessError("compaction", "Nothing to compact"); - const hookResult = await this.emitHook({ - type: "session_before_compact", - preparation, - branchEntries, - customInstructions, - signal: operation.signal, - }); - if (hookResult?.cancel) throw new AgentHarnessError("compaction", "Compaction cancelled"); - const provided = hookResult?.compaction; - const compactResult = provided - ? { ok: true as const, value: provided } - : await compact( - preparation, - this.models, - model, - customInstructions, - operation.signal, - this.thinkingLevel, - this.retry, - this.retryCallbacks("compaction"), - ); - if (!compactResult.ok) throw compactResult.error; - const result = compactResult.value; - this.assertNotShutDown(); - const entryId = await this.session.appendCompaction( - result.summary, - result.firstKeptEntryId, - result.tokensBefore, - result.details, - provided !== undefined, - result.usage, - result.retainedTail, - ); - const entry = await this.session.getEntry(entryId); - if (entry?.type === "compaction") { - await this.emitOwn({ type: "session_compact", compactionEntry: entry, fromHook: provided !== undefined }); - } - return result; - } catch (error) { - throw normalizeHarnessError(error, "compaction"); - } finally { - this.phase = "idle"; - operation.finish(); - } + async peekAction(): Promise { + return undefined; } - - async navigateTree( - targetId: string, - options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }, - ): Promise { - this.assertNotShutDown(); - if (this.phase !== "idle") throw new AgentHarnessError("busy", "navigateTree() requires idle harness"); - this.phase = "branch_summary"; - const operation = this.startOperation(); - try { - const oldLeafId = await this.session.getLeafId(); - if (oldLeafId === targetId) return { cancelled: false }; - const targetEntry = await this.session.getEntry(targetId); - if (!targetEntry) throw new AgentHarnessError("invalid_argument", `Entry ${targetId} not found`); - const { entries, commonAncestorId } = await collectEntriesForBranchSummary(this.session, oldLeafId, targetId); - const preparation = { - targetId, - oldLeafId, - commonAncestorId, - entriesToSummarize: entries, - userWantsSummary: options?.summarize ?? false, - customInstructions: options?.customInstructions, - replaceInstructions: options?.replaceInstructions, - label: options?.label, - }; - const hookResult = await this.emitHook({ - type: "session_before_tree", - preparation, - signal: operation.signal, - }); - if (hookResult?.cancel) return { cancelled: true }; - let summaryEntry: NavigateTreeResult["summaryEntry"]; - let summaryText: string | undefined = hookResult?.summary?.summary; - let summaryDetails: unknown = hookResult?.summary?.details; - let summaryUsage = hookResult?.summary?.usage; - if (!summaryText && options?.summarize && entries.length > 0) { - const model = this.model; - if (!model) throw new AgentHarnessError("invalid_state", "No model set for branch summary"); - const branchSummary = await generateBranchSummary(entries, { - models: this.models, - model, - signal: operation.signal, - customInstructions: hookResult?.customInstructions ?? options?.customInstructions, - replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions, - retry: this.retry, - callbacks: this.retryCallbacks("branch_summary"), - }); - if (!branchSummary.ok) { - if (branchSummary.error.code === "aborted") return { cancelled: true }; - throw new AgentHarnessError("branch_summary", branchSummary.error.message, branchSummary.error); - } - summaryText = branchSummary.value.summary; - summaryUsage = branchSummary.value.usage; - summaryDetails = { - readFiles: branchSummary.value.readFiles, - modifiedFiles: branchSummary.value.modifiedFiles, - }; - } - let editorText: string | undefined; - let newLeafId: string | null; - if (targetEntry.type === "message" && targetEntry.message.role === "user") { - newLeafId = targetEntry.parentId; - editorText = contentText(targetEntry.message.content, ""); - } else if (targetEntry.type === "custom_message") { - newLeafId = targetEntry.parentId; - editorText = contentText(targetEntry.content, ""); - } else { - newLeafId = targetId; - } - this.assertNotShutDown(); - const summaryId = await this.session.moveTo( - newLeafId, - summaryText - ? { - summary: summaryText, - details: summaryDetails, - usage: summaryUsage, - fromHook: hookResult?.summary !== undefined, - } - : undefined, - ); - if (summaryId) { - const entry = await this.session.getEntry(summaryId); - if (entry?.type === "branch_summary") summaryEntry = entry; - } - await this.emitOwn({ - type: "session_tree", - newLeafId: await this.session.getLeafId(), - oldLeafId, - summaryEntry, - fromHook: hookResult?.summary !== undefined, - }); - return { cancelled: false, editorText, summaryEntry }; - } catch (error) { - throw normalizeHarnessError(error, "branch_summary"); - } finally { - this.phase = "idle"; - operation.finish(); - } + async executeAction(): Promise { + return undefined; } - - getModel(): Model { + async runToCompletion(): Promise {} + async getModel(): Promise> { return this.model; } - - async setModel(model: Model): Promise { - this.assertNotShutDown(); - return this.track("mutation", async () => { - try { - const previousModel = this.model; - if (this.phase === "idle") { - await this.session.appendModelChange(model.provider, model.id); - } else { - this.pendingSessionWrites.push({ type: "model_change", provider: model.provider, modelId: model.id }); - } - this.model = model; - await this.emitOwn({ type: "model_update", model, previousModel, source: "set" }); - } catch (error) { - throw normalizeHarnessError(error, "session"); - } - }); + async setModel(model: Model): Promise { + this.model = model; } - - getThinkingLevel(): ThinkingLevel { + async getThinkingLevel(): Promise { return this.thinkingLevel; } - async setThinkingLevel(level: ThinkingLevel): Promise { - this.assertNotShutDown(); - return this.track("mutation", async () => { - try { - const previousLevel = this.thinkingLevel; - if (this.phase === "idle") { - await this.session.appendThinkingLevelChange(level); - } else { - this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level }); - } - this.thinkingLevel = level; - await this.emitOwn({ type: "thinking_level_update", level, previousLevel }); - } catch (error) { - throw normalizeHarnessError(error, "session"); - } - }); - } - - getTools(): TTool[] { - return [...this.tools.values()]; - } - - async setTools(tools: TTool[], activeToolNames?: string[]): Promise { - this.assertNotShutDown(); - return this.track("mutation", () => this.applyTools(tools, activeToolNames)); + this.thinkingLevel = level; } - - private async applyTools(tools: TTool[], activeToolNames?: string[]): Promise { - try { - this.validateUniqueNames( - tools.map((tool) => tool.name), - "Duplicate tool name(s)", - ); - const nextTools = new Map(tools.map((tool) => [tool.name, tool])); - const nextActiveToolNames = activeToolNames ? [...activeToolNames] : this.activeToolNames; - this.validateToolNames(nextActiveToolNames, nextTools); - const previousToolNames = [...this.tools.keys()]; - const previousActiveToolNames = [...this.activeToolNames]; - if (this.phase === "idle") { - await this.session.appendActiveToolsChange(nextActiveToolNames); - } else { - this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...nextActiveToolNames] }); - } - this.tools = nextTools; - this.activeToolNames = [...nextActiveToolNames]; - await this.emitOwn({ - type: "tools_update", - toolNames: [...this.tools.keys()], - previousToolNames, - activeToolNames: [...this.activeToolNames], - previousActiveToolNames, - source: "set", - }); - } catch (error) { - throw normalizeHarnessError(error, "invalid_argument"); - } + async getActiveTools(): Promise { + return [...this.activeToolNames]; } - - getActiveTools(): TTool[] { - return this.activeToolNames.map((name) => this.tools.get(name)!); + async setActiveTools(names: string[]): Promise { + this.activeToolNames = [...names]; } - - async setActiveTools(toolNames: string[]): Promise { - this.assertNotShutDown(); - return this.track("mutation", () => this.applyActiveTools(toolNames)); + async watch(): Promise> { + const leafId = await this.getLeafId(); + const transcript = + leafId === null ? [] : await this.session.findEntriesOnBranch({ start: leafId, order: "oldestFirst" }); + return { + snapshot: { + lane: this.name, + transcript, + leafId, + operation: null, + queues: { steer: [], followUp: [], nextRun: [] }, + pendingWrites: [], + faulted: false, + }, + start() {}, + unsubscribe() {}, + }; } - private async applyActiveTools(toolNames: string[]): Promise { - try { - this.validateToolNames(toolNames); - const previousToolNames = [...this.tools.keys()]; - const previousActiveToolNames = [...this.activeToolNames]; - if (this.phase === "idle") { - await this.session.appendActiveToolsChange(toolNames); - } else { - this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...toolNames] }); - } - this.activeToolNames = [...toolNames]; - await this.emitOwn({ - type: "tools_update", - toolNames: [...this.tools.keys()], - previousToolNames, - activeToolNames: [...this.activeToolNames], - previousActiveToolNames, - source: "set", - }); - } catch (error) { - throw normalizeHarnessError(error, "invalid_argument"); - } + async lane(name: string): Promise { + return name === "main" ? this : undefined; } - - getSteeringMode(): QueueMode { - return this.steeringQueueMode; + async createLane(_name: string, _at: string | null): Promise { + return this.unavailable("createLane"); } - - async setSteeringMode(mode: QueueMode): Promise { - this.assertNotShutDown(); - this.steeringQueueMode = mode; + async lanes(): Promise { + return (await this.durableSession.getLanes()).map(({ lane, leafId }) => ({ + name: lane, + leafId, + operation: null, + })); } - - getFollowUpMode(): QueueMode { - return this.followUpQueueMode; + async getTools(): Promise { + return [...this.tools]; } - - async setFollowUpMode(mode: QueueMode): Promise { - this.assertNotShutDown(); - this.followUpQueueMode = mode; + async setTools(tools: HarnessTool[], activeNames?: string[]): Promise { + this.tools = [...tools]; + this.activeToolNames = [...(activeNames ?? tools.map((tool) => tool.name))]; } - - getResources(): AgentHarnessResources { + async getResources(): Promise { return { - skills: this.resources.skills?.slice(), - promptTemplates: this.resources.promptTemplates?.slice(), + skills: this.resources.skills ? [...this.resources.skills] : undefined, + promptTemplates: this.resources.promptTemplates ? [...this.resources.promptTemplates] : undefined, }; } - - async setResources(resources: AgentHarnessResources): Promise { - this.assertNotShutDown(); - const previousResources = this.getResources(); + async setResources(resources: Resources): Promise { this.resources = { - skills: resources.skills?.slice(), - promptTemplates: resources.promptTemplates?.slice(), + skills: resources.skills ? [...resources.skills] : undefined, + promptTemplates: resources.promptTemplates ? [...resources.promptTemplates] : undefined, }; - await this.emitOwn({ type: "resources_update", resources: this.getResources(), previousResources }); } - - getStreamOptions(): AgentHarnessStreamOptions { - return cloneStreamOptions(this.streamOptions); + async getStreamOptions(): Promise { + return { ...this.streamOptions }; } - - async setStreamOptions(streamOptions: AgentHarnessStreamOptions): Promise { - this.assertNotShutDown(); - this.streamOptions = cloneStreamOptions(streamOptions); + async setStreamOptions(options: StreamOptions): Promise { + this.streamOptions = { ...options }; } - - /** Permanently stop this harness instance without deleting its durable session. */ - requestShutdown(): void { - if (this.isShutdown) return; - this.isShutdown = true; - this.pendingSessionWrites = []; - this.steerQueue = []; - this.followUpQueue = []; - this.nextTurnQueue = []; - this.activeAbortController?.abort(); - this.shutdownPromise = this.waitForTasks(); + async getRetryPolicy(): Promise { + return { ...this.retryPolicy }; } - - /** Waits for work active when shutdown was requested to settle. */ - waitForShutdown(): Promise { - if (!this.shutdownPromise) { - return Promise.reject(new AgentHarnessError("invalid_state", "Shutdown has not been requested")); - } - return this.shutdownPromise; + async setRetryPolicy(policy: RetryPolicy): Promise { + this.retryPolicy = { ...policy }; } - - async abort(): Promise { - this.assertNotShutDown(); - const clearedSteer = [...this.steerQueue]; - const clearedFollowUp = [...this.followUpQueue]; - this.steerQueue = []; - this.followUpQueue = []; - this.activeAbortController?.abort(); - const errors: Error[] = []; - try { - await this.emitQueueUpdate(); - } catch (error) { - errors.push(toError(error)); - } - try { - await this.waitForIdle(); - } catch (error) { - errors.push(toError(error)); - } - try { - await this.emitOwn({ type: "abort", clearedSteer, clearedFollowUp }); - } catch (error) { - errors.push(toError(error)); - } - if (errors.length > 0) { - const cause = errors.length === 1 ? errors[0]! : new AggregateError(errors, "Abort completed with errors"); - throw normalizeHarnessError(cause, "hook"); - } - return { clearedSteer, clearedFollowUp }; + async getCompactionSettings(): Promise { + return { ...this.compactionSettings }; } - - async waitForIdle(): Promise { - await this.waitForTasks("operation"); + async setCompactionSettings(settings: CompactionSettings): Promise { + this.compactionSettings = { ...settings }; } - - subscribe( - listener: (event: AgentHarnessEvent, signal?: AbortSignal) => Promise | void, - ): () => void { - this.assertNotShutDown(); - let handlers = this.handlers.get(SUBSCRIBER_EVENT_TYPE); - if (!handlers) { - handlers = new Set(); - this.handlers.set(SUBSCRIBER_EVENT_TYPE, handlers); - } - handlers.add(listener as AgentHarnessHandler); - return () => handlers!.delete(listener as AgentHarnessHandler); + async getSteeringMode(): Promise { + return this.steeringMode; } - - on( - type: TType, - handler: ( - event: Extract, - ) => Promise | AgentHarnessEventResultMap[TType], - ): () => void { - this.assertNotShutDown(); - let handlers = this.handlers.get(type); - if (!handlers) { - handlers = new Set(); - this.handlers.set(type, handlers); - } - handlers.add(handler as AgentHarnessHandler); - return () => handlers!.delete(handler as AgentHarnessHandler); + async setSteeringMode(mode: QueueMode): Promise { + this.steeringMode = mode; + } + async getFollowUpMode(): Promise { + return this.followUpMode; + } + async setFollowUpMode(mode: QueueMode): Promise { + this.followUpMode = mode; + } + async watchSession(): Promise> { + return { + snapshot: { lanes: await this.lanes(), faulted: false }, + start() {}, + unsubscribe() {}, + }; + } + async close(): Promise { + this.closed = true; } } diff --git a/packages/agent/src/harness/compaction/branch-summarization.ts b/packages/agent/src/harness/compaction/branch-summarization.ts index 51683e09732..64c088f70d3 100644 --- a/packages/agent/src/harness/compaction/branch-summarization.ts +++ b/packages/agent/src/harness/compaction/branch-summarization.ts @@ -1,14 +1,17 @@ -import { contentText, type Model, type Models, type RetryCallbacks, type RetryPolicy } from "@earendil-works/pi-ai"; +import { + type Api, + contentText, + type Model, + type Models, + type RetryCallbacks, + type RetryPolicy, + type Usage, +} from "@earendil-works/pi-ai"; import type { AgentMessage } from "../../types.ts"; -import { - convertToLlm, - createBranchSummaryMessage, - createCompactionSummaryMessage, - createCustomMessage, -} from "../messages.ts"; -import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.ts"; -import { BranchSummaryError, err, ok, type Result, SessionError } from "../types.ts"; +import { convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage } from "../messages.ts"; +import { type Entry, type Session, SessionError } from "../session/index.ts"; +import { BranchSummaryError, err, ok, type Result } from "../types.ts"; import { completeSimpleWithRetries, estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.ts"; import { computeFileLists, @@ -19,6 +22,14 @@ import { serializeConversation, } from "./utils.ts"; +/** Generated branch summary data ready to be persisted as a branch-summary entry. */ +export interface BranchSummaryResult { + summary: string; + usage?: Usage; + readFiles: string[]; + modifiedFiles: string[]; +} + /** File-operation details stored on generated branch summary entries. */ export interface BranchSummaryDetails { /** Files read while exploring the summarized branch. */ @@ -42,7 +53,7 @@ export interface BranchPreparation { /** Entries selected for branch summarization. */ export interface CollectEntriesResult { /** Entries to summarize in chronological order. */ - entries: SessionTreeEntry[]; + entries: Entry[]; /** Deepest common ancestor between the previous leaf and target entry. */ commonAncestorId: string | null; } @@ -52,7 +63,7 @@ export interface GenerateBranchSummaryOptions { /** Provider collection the summarization request goes through; owns auth resolution. */ models: Models; /** Model used for summarization. */ - model: Model; + model: Model; /** Abort signal for the summarization request. */ signal: AbortSignal; /** Optional instructions appended to or replacing the default prompt. */ @@ -76,37 +87,34 @@ export async function collectEntriesForBranchSummary( if (!oldLeafId) { return { entries: [], commonAncestorId: null }; } - const oldPath = new Set((await session.getBranch(oldLeafId)).map((e) => e.id)); - const targetPath = await session.getBranch(targetId); + const oldPath = new Set((await session.findEntriesOnBranch({ start: oldLeafId })).map((entry) => entry.id)); + const targetPath = await session.findEntriesOnBranch({ start: targetId }); let commonAncestorId: string | null = null; - for (let i = targetPath.length - 1; i >= 0; i--) { - if (oldPath.has(targetPath[i].id)) { - commonAncestorId = targetPath[i].id; + for (const entry of targetPath) { + if (oldPath.has(entry.id)) { + commonAncestorId = entry.id; break; } } - const entries: SessionTreeEntry[] = []; + const entries: Entry[] = []; let current: string | null = oldLeafId; while (current && current !== commonAncestorId) { const entry = await session.getEntry(current); - if (!entry) throw new SessionError("invalid_session", `Entry ${current} not found`); - entries.push(entry as SessionTreeEntry); + if (!entry) throw new SessionError("invalid_entry", `Entry ${current} not found`); + entries.push(entry); current = entry.parentId; } entries.reverse(); return { entries, commonAncestorId }; } -function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined { +function getMessageFromEntry(entry: Entry): AgentMessage | undefined { switch (entry.type) { case "message": if (entry.message.role === "toolResult") return undefined; return entry.message; - case "custom_message": - return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); - case "branch_summary": return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); @@ -116,20 +124,17 @@ function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined case "model_change": case "active_tools_change": case "custom": - case "label": - case "session_info": - case "leaf": return undefined; } } /** Prepare branch entries for summarization within an optional token budget. */ -export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: number = 0): BranchPreparation { +export function prepareBranchEntries(entries: Entry[], tokenBudget: number = 0): BranchPreparation { const messages: AgentMessage[] = []; const fileOps = createFileOps(); let totalTokens = 0; for (const entry of entries) { - if (entry.type === "branch_summary" && !entry.fromHook && entry.details) { + if (entry.type === "branch_summary" && entry.details) { const details = entry.details as BranchSummaryDetails; if (Array.isArray(details.readFiles)) { for (const f of details.readFiles) fileOps.read.add(f); @@ -201,7 +206,7 @@ Keep each section concise. Preserve exact file paths, function names, and error /** Generate a summary for abandoned branch entries. */ export async function generateBranchSummary( - entries: SessionTreeEntry[], + entries: Entry[], options: GenerateBranchSummaryOptions, ): Promise> { const { diff --git a/packages/agent/src/harness/compaction/compaction.ts b/packages/agent/src/harness/compaction/compaction.ts index 6b4aebc43ae..06ae8afb1dd 100644 --- a/packages/agent/src/harness/compaction/compaction.ts +++ b/packages/agent/src/harness/compaction/compaction.ts @@ -1,27 +1,22 @@ import { + type Api, type AssistantMessage, type Context, contentText, - type ImageContent, type Model, type Models, type RetryCallbacks, type RetryPolicy, retryAssistantCall, type SimpleStreamOptions, - type TextContent, type Usage, uuidv7, } from "@earendil-works/pi-ai"; import type { AgentMessage, ThinkingLevel } from "../../types.ts"; -import { - convertToLlm, - createBranchSummaryMessage, - createCompactionSummaryMessage, - createCustomMessage, -} from "../messages.ts"; -import { buildSessionContext } from "../session/session.ts"; -import { type CompactionEntry, CompactionError, err, ok, type Result, type SessionTreeEntry } from "../types.ts"; +import { convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage } from "../messages.ts"; +import { buildSessionContext } from "../session/context.ts"; +import type { CompactionEntry, Entry } from "../session/types.ts"; +import { CompactionError, err, ok, type Result } from "../types.ts"; import { computeFileLists, createFileOps, @@ -48,13 +43,13 @@ function safeJsonStringify(value: unknown): string { function extractFileOperations( messages: AgentMessage[], - entries: SessionTreeEntry[], + entries: Entry[], prevCompactionIndex: number, ): FileOperations { const fileOps = createFileOps(); if (prevCompactionIndex >= 0) { const prevCompaction = entries[prevCompactionIndex] as CompactionEntry; - if (!prevCompaction.fromHook && prevCompaction.details) { + if (prevCompaction.details) { const details = prevCompaction.details as CompactionDetails; if (Array.isArray(details.readFiles)) { for (const f of details.readFiles) fileOps.read.add(f); @@ -70,19 +65,10 @@ function extractFileOperations( return fileOps; } -function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined { +function getMessageFromEntry(entry: Entry): AgentMessage | undefined { if (entry.type === "message") { return entry.message as AgentMessage; } - if (entry.type === "custom_message") { - return createCustomMessage( - entry.customType, - entry.content as string | (TextContent | ImageContent)[], - entry.display, - entry.details, - entry.timestamp, - ); - } if (entry.type === "branch_summary") { return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); } @@ -92,7 +78,7 @@ function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined return undefined; } -function getMessageFromEntryForCompaction(entry: SessionTreeEntry): AgentMessage | undefined { +function getMessageFromEntryForCompaction(entry: Entry): AgentMessage | undefined { if (entry.type === "compaction") { return undefined; } @@ -100,24 +86,22 @@ function getMessageFromEntryForCompaction(entry: SessionTreeEntry): AgentMessage } /** Generated compaction data ready to be persisted as a compaction entry. */ -export interface CompactionResult { +export interface CompactResult { /** Summary text that replaces compacted history in future context. */ summary: string; - /** Entry id where retained history starts. Optional during Pi 2.0 transition. */ - firstKeptEntryId?: string; /** Estimated context tokens before compaction. */ tokensBefore: number; /** Usage from the LLM call(s) that generated this summary, if available. */ usage?: Usage; - /** Retained recent messages stored directly on the compaction entry. Optional during Pi 2.0 transition. */ - retainedTail?: AgentMessage[]; + /** Retained recent messages stored directly on the compaction entry. */ + retainedTail: AgentMessage[]; /** Optional implementation-specific details stored with the compaction entry. */ details?: T; } export async function completeSimpleWithRetries( models: Models, - model: Model, + model: Model, context: Context, options: SimpleStreamOptions, retry?: RetryPolicy, @@ -197,7 +181,7 @@ function getAssistantUsage(msg: AgentMessage): Usage | undefined { } /** Return usage from the last valid assistant message in session entries. */ -export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | undefined { +export function getLastAssistantUsage(entries: Entry[]): Usage | undefined { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry.type === "message") { @@ -325,7 +309,7 @@ export function estimateTokens(message: AgentMessage): number { return 0; } -function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, endIndex: number): number[] { +function findValidCutPoints(entries: Entry[], startIndex: number, endIndex: number): number[] { const cutPoints: number[] = []; for (let i = startIndex; i < endIndex; i++) { const entry = entries[i]; @@ -352,24 +336,18 @@ function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, end case "compaction": case "branch_summary": case "custom": - case "custom_message": - case "label": - case "session_info": - case "leaf": break; } - if (entry.type === "branch_summary" || entry.type === "custom_message") { - cutPoints.push(i); - } + if (entry.type === "branch_summary") cutPoints.push(i); } return cutPoints; } /** Find the user-visible message that starts the turn containing an entry. */ -export function findTurnStartIndex(entries: SessionTreeEntry[], entryIndex: number, startIndex: number): number { +export function findTurnStartIndex(entries: Entry[], entryIndex: number, startIndex: number): number { for (let i = entryIndex; i >= startIndex; i--) { const entry = entries[i]; - if (entry.type === "branch_summary" || entry.type === "custom_message") { + if (entry.type === "branch_summary") { return i; } if (entry.type === "message") { @@ -394,7 +372,7 @@ export interface CutPointResult { /** Find the compaction cut point that keeps approximately the requested recent-token budget. */ export function findCutPoint( - entries: SessionTreeEntry[], + entries: Entry[], startIndex: number, endIndex: number, keepRecentTokens: number, @@ -523,7 +501,7 @@ Keep each section concise. Preserve exact file paths, function names, and error export async function generateSummary( currentMessages: AgentMessage[], models: Models, - model: Model, + model: Model, reserveTokens: number, signal?: AbortSignal, customInstructions?: string, @@ -551,7 +529,7 @@ export async function generateSummary( export async function generateSummaryWithUsage( currentMessages: AgentMessage[], models: Models, - model: Model, + model: Model, reserveTokens: number, signal?: AbortSignal, customInstructions?: string, @@ -616,8 +594,6 @@ export async function generateSummaryWithUsage( /** Prepared inputs for a compaction run. */ export interface CompactionPreparation { - /** Entry id where retained history starts. */ - firstKeptEntryId: string; /** Messages summarized into the history summary. */ messagesToSummarize: AgentMessage[]; /** Prefix messages summarized separately when compaction splits a turn. */ @@ -638,7 +614,7 @@ export interface CompactionPreparation { /** Prepare session entries for compaction, or return undefined when compaction is not applicable. */ export function prepareCompaction( - pathEntries: SessionTreeEntry[], + pathEntries: Entry[], settings: CompactionSettings, ): Result { if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") { @@ -654,42 +630,41 @@ export function prepareCompaction( } let previousSummary: string | undefined; - let boundaryStart = 0; + let compactableEntries = pathEntries; if (prevCompactionIndex >= 0) { const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry; previousSummary = prevCompaction.summary; - const firstKeptEntryIndex = prevCompaction.firstKeptEntryId - ? pathEntries.findIndex((entry) => entry.id === prevCompaction.firstKeptEntryId) - : -1; - boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1; - } - const boundaryEnd = pathEntries.length; + const virtualRetainedEntries: Entry[] = prevCompaction.retainedTail.map((message, index) => ({ + type: "message", + id: `${prevCompaction.id}:retained:${index}`, + parentId: index === 0 ? prevCompaction.id : `${prevCompaction.id}:retained:${index - 1}`, + seq: prevCompaction.seq, + timestamp: message.timestamp, + message, + })); + compactableEntries = [...virtualRetainedEntries, ...pathEntries.slice(prevCompactionIndex + 1)]; + } + const boundaryEnd = compactableEntries.length; const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens; - const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens); - const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex]; - if (!firstKeptEntry?.id) { - return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration")); - } - const firstKeptEntryId = firstKeptEntry.id; - + const cutPoint = findCutPoint(compactableEntries, 0, boundaryEnd, settings.keepRecentTokens); const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex; const messagesToSummarize: AgentMessage[] = []; - for (let i = boundaryStart; i < historyEnd; i++) { - const msg = getMessageFromEntryForCompaction(pathEntries[i]); + for (let i = 0; i < historyEnd; i++) { + const msg = getMessageFromEntryForCompaction(compactableEntries[i]); if (msg) messagesToSummarize.push(msg); } const turnPrefixMessages: AgentMessage[] = []; if (cutPoint.isSplitTurn) { for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) { - const msg = getMessageFromEntryForCompaction(pathEntries[i]); + const msg = getMessageFromEntryForCompaction(compactableEntries[i]); if (msg) turnPrefixMessages.push(msg); } } const retainedTail: AgentMessage[] = []; for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) { - const msg = getMessageFromEntryForCompaction(pathEntries[i]); + const msg = getMessageFromEntryForCompaction(compactableEntries[i]); if (msg) retainedTail.push(msg); } const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex); @@ -700,7 +675,6 @@ export function prepareCompaction( } return ok({ - firstKeptEntryId, messagesToSummarize, turnPrefixMessages, retainedTail, @@ -733,15 +707,14 @@ export { serializeConversation } from "./utils.ts"; export async function compact( preparation: CompactionPreparation, models: Models, - model: Model, + model: Model, customInstructions?: string, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, retry?: RetryPolicy, callbacks?: RetryCallbacks, -): Promise> { +): Promise> { const { - firstKeptEntryId, messagesToSummarize, turnPrefixMessages, retainedTail, @@ -752,10 +725,6 @@ export async function compact( settings, } = preparation; - if (!firstKeptEntryId) { - return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration")); - } - let summary: string; let summaryUsage: Usage; @@ -817,7 +786,6 @@ export async function compact( return ok({ summary, - firstKeptEntryId, tokensBefore, usage: summaryUsage, retainedTail, @@ -827,7 +795,7 @@ export async function compact( async function generateTurnPrefixSummary( messages: AgentMessage[], models: Models, - model: Model, + model: Model, reserveTokens: number, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, diff --git a/packages/agent/src/harness/experimental/session/session.ts b/packages/agent/src/harness/experimental/session/session.ts deleted file mode 100644 index aa19cd7ae02..00000000000 --- a/packages/agent/src/harness/experimental/session/session.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { uuidv7 } from "@earendil-works/pi-ai"; -import type { AgentMessage } from "../../../types.ts"; -import type { - BranchBounds, - Entry, - EntryQuery, - IdGenerator, - LanePointer, - LaneRecord, - LogItem, - LogOptions, - NewRecord, - ProvisionedEntry, - RecordQuery, - SessionMetadata, - SessionStats, - SessionStorage, - SessionTree, -} from "./types.ts"; -import { SessionError } from "./types.ts"; - -type JsonValidationFrame = { value: unknown } | { exit: object }; - -function invalidPayload(reason: string): never { - throw new SessionError("invalid_payload", `Durable payload ${reason}`); -} - -function assertValidLimit(limit: number | undefined): void { - if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) { - throw new SessionError("invalid_query", "limit must be a positive integer"); - } -} - -function assertValidCursor(afterSeq: number | undefined): void { - if (afterSeq !== undefined && (!Number.isInteger(afterSeq) || afterSeq < 0)) { - throw new SessionError("invalid_query", "cursor sequence must be a non-negative integer"); - } -} - -export function assertJsonSerializable(value: unknown): void { - const active = new WeakSet(); - const stack: JsonValidationFrame[] = [{ value }]; - while (stack.length > 0) { - const frame = stack.pop()!; - if ("exit" in frame) { - active.delete(frame.exit); - continue; - } - const candidate = frame.value; - if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") { - continue; - } - if (typeof candidate === "number") { - if (!Number.isFinite(candidate)) invalidPayload("contains a non-finite number"); - continue; - } - if (typeof candidate !== "object") invalidPayload(`contains ${typeof candidate}`); - if (active.has(candidate)) invalidPayload("contains a cycle"); - active.add(candidate); - stack.push({ exit: candidate }); - - if (Array.isArray(candidate)) { - if (Object.getPrototypeOf(candidate) !== Array.prototype) { - invalidPayload("contains a non-standard array"); - } - if ( - Object.getOwnPropertySymbols(candidate).length > 0 || - Object.getOwnPropertyNames(candidate).length !== candidate.length + 1 - ) { - invalidPayload("contains an array with unsupported properties"); - } - for (let index = candidate.length - 1; index >= 0; index--) { - if (!Object.hasOwn(candidate, index)) invalidPayload("contains a sparse array"); - const descriptor = Object.getOwnPropertyDescriptor(candidate, index)!; - if (!("value" in descriptor)) invalidPayload("contains an array accessor"); - stack.push({ value: descriptor.value }); - } - continue; - } - - const prototype = Object.getPrototypeOf(candidate); - if (prototype !== Object.prototype && prototype !== null) { - invalidPayload("contains a non-plain object"); - } - if (Object.getOwnPropertySymbols(candidate).length > 0) { - invalidPayload("contains a symbol-keyed property"); - } - const keys = Object.keys(candidate); - if (Object.getOwnPropertyNames(candidate).length !== keys.length) { - invalidPayload("contains a non-enumerable property"); - } - for (let index = keys.length - 1; index >= 0; index--) { - const descriptor = Object.getOwnPropertyDescriptor(candidate, keys[index]!)!; - if (!("value" in descriptor)) invalidPayload("contains an accessor"); - stack.push({ value: descriptor.value }); - } - } -} - -export class Session implements SessionTree { - private readonly storage: SessionStorage; - readonly idGenerator: IdGenerator; - - constructor(storage: SessionStorage, options: { idGenerator?: IdGenerator } = {}) { - this.storage = storage; - this.idGenerator = options.idGenerator ?? { next: () => uuidv7() }; - } - - async getMetadata(): Promise { - return this.storage.getMetadata(); - } - - view(lane: string): SessionTree { - if (lane === "main") return this; - return { - getLeafId: () => this.getLeafIdForLane(lane), - getEntry: (id) => this.getEntry(id), - getStats: () => this.getStats(), - getName: () => this.getName(), - setName: (name) => this.setName(name), - getLabel: (targetId) => this.getLabel(targetId), - setLabel: (targetId, label) => this.setLabel(targetId, label), - findEntries: (query) => this.queryEntries(query), - findEntry: async (query = {}) => (await this.queryEntries(query, 1))[0], - findEntriesOnBranch: (query) => this.queryBranchEntries(lane, query), - findEntryOnBranch: async (query = {}) => (await this.queryBranchEntries(lane, query, 1))[0], - appendMessage: (message) => this.appendMessageToLane(lane, message), - appendCustomEntry: (customType, data) => this.appendCustomEntryToLane(lane, customType, data), - }; - } - - async getLeafId(): Promise { - return this.getLeafIdForLane("main"); - } - - async getEntry(id: string): Promise { - return this.storage.getEntry(id); - } - - async getStats(): Promise { - return this.storage.getStats(); - } - - async getName(): Promise { - return this.storage.getName(); - } - - async setName(name: string): Promise { - await this.storage.setName(name); - } - - async getLabel(targetId: string): Promise { - return this.storage.getLabel(targetId); - } - - async setLabel(targetId: string, label: string | undefined): Promise { - await this.storage.setLabel(targetId, label); - } - - async findEntries(query?: EntryQuery): Promise { - return this.queryEntries(query); - } - - async findEntry(query: EntryQuery = {}): Promise { - return (await this.queryEntries(query, 1))[0]; - } - - async findEntriesOnBranch(query?: EntryQuery & BranchBounds): Promise { - return this.queryBranchEntries("main", query); - } - - async findEntryOnBranch(query: EntryQuery & BranchBounds = {}): Promise { - return (await this.queryBranchEntries("main", query, 1))[0]; - } - - async appendMessage(message: AgentMessage): Promise { - return this.appendMessageToLane("main", message); - } - - async appendCustomEntry(customType: string, data?: unknown): Promise { - return this.appendCustomEntryToLane("main", customType, data); - } - - async getLanes(): Promise { - return this.storage.getLanes(); - } - - async createLane(lane: string, at: string | null): Promise { - await this.storage.createLane(lane, at); - } - - async moveLane(lane: string, to: string | null): Promise { - await this.storage.moveLane(lane, to); - } - - async appendEntry(entry: ProvisionedEntry, lane: string): Promise { - return this.commitEntry(entry, lane); - } - - async appendRecord( - record: TNewRecord, - ): Promise>; - async appendRecord(record: NewRecord): Promise; - async appendRecord(record: NewRecord): Promise { - return this.commitRecord(record); - } - - async findRecords( - query: RecordQuery & { type: K }, - ): Promise[]>; - async findRecords(query?: RecordQuery): Promise; - async findRecords(query?: RecordQuery): Promise { - return this.queryRecords(query); - } - - async getLog(options?: LogOptions): Promise { - return this.queryLog(options); - } - - private async getLeafIdForLane(lane: string): Promise { - const pointer = (await this.storage.getLanes()).find((candidate) => candidate.lane === lane); - if (!pointer) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); - return pointer.leafId; - } - - private async queryEntries(query: EntryQuery = {}, resultLimit = query.limit): Promise { - assertValidLimit(query.limit); - assertValidCursor(query.cursor?.afterSeq); - return this.storage.findEntries(resultLimit === query.limit ? query : { ...query, limit: resultLimit }); - } - - private async queryBranchEntries( - lane: string, - query: EntryQuery & BranchBounds = {}, - resultLimit = query.limit, - ): Promise { - assertValidLimit(query.limit); - assertValidCursor(query.cursor?.afterSeq); - const start = query.start ?? (await this.getLeafIdForLane(lane)); - if (start === null) return []; - const storageQuery = resultLimit === query.limit ? query : { ...query, limit: resultLimit }; - return this.storage.findEntriesOnBranch({ ...storageQuery, start }); - } - - private async queryRecords(query: RecordQuery = {}): Promise { - assertValidLimit(query.limit); - assertValidCursor(query.afterSeq); - return this.storage.findRecords(query); - } - - private async queryLog(options: LogOptions = {}): Promise { - assertValidLimit(options.limit); - assertValidCursor(options.afterSeq); - return this.storage.getLog(options); - } - - private async appendMessageToLane(lane: string, message: AgentMessage): Promise { - const entry = await this.commitEntry({ type: "message", id: this.idGenerator.next(), message }, lane); - return entry.id; - } - - private async appendCustomEntryToLane(lane: string, customType: string, data?: unknown): Promise { - const entry = await this.commitEntry( - data === undefined - ? { type: "custom", id: this.idGenerator.next(), customType } - : { type: "custom", id: this.idGenerator.next(), customType, data }, - lane, - ); - return entry.id; - } - - private async commitEntry(entry: ProvisionedEntry, lane: string): Promise { - assertJsonSerializable(entry); - return this.storage.appendEntry(entry, lane); - } - - private async commitRecord(record: NewRecord): Promise { - assertJsonSerializable(record); - return this.storage.appendRecord(record); - } -} diff --git a/packages/agent/src/harness/messages.ts b/packages/agent/src/harness/messages.ts index 36ce96a1e43..19d9a764312 100644 --- a/packages/agent/src/harness/messages.ts +++ b/packages/agent/src/harness/messages.ts @@ -78,25 +78,29 @@ export function bashExecutionToText(msg: BashExecutionMessage): string { return text; } -export function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage { +export function createBranchSummaryMessage( + summary: string, + fromId: string, + timestamp: string | number, +): BranchSummaryMessage { return { role: "branchSummary", summary, fromId, - timestamp: new Date(timestamp).getTime(), + timestamp: typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime(), }; } export function createCompactionSummaryMessage( summary: string, tokensBefore: number, - timestamp: string, + timestamp: string | number, ): CompactionSummaryMessage { return { role: "compactionSummary", summary, tokensBefore, - timestamp: new Date(timestamp).getTime(), + timestamp: typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime(), }; } @@ -105,7 +109,7 @@ export function createCustomMessage( content: string | (TextContent | ImageContent)[], display: boolean, details: unknown | undefined, - timestamp: string, + timestamp: string | number, ): CustomMessage { return { role: "custom", @@ -113,7 +117,7 @@ export function createCustomMessage( content, display, details, - timestamp: new Date(timestamp).getTime(), + timestamp: typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime(), }; } diff --git a/packages/agent/src/harness/result.ts b/packages/agent/src/harness/result.ts new file mode 100644 index 00000000000..d4bfda4f4b2 --- /dev/null +++ b/packages/agent/src/harness/result.ts @@ -0,0 +1,63 @@ +export type Result = { ok: true; value: TValue } | { ok: false; error: TError }; + +export const Result = { + ok(value: TValue): Result { + return { ok: true, value }; + }, + err(error: TError): Result { + return { ok: false, error }; + }, + isOk(result: Result): result is { ok: true; value: TValue } { + return result.ok; + }, + isErr(result: Result): result is { ok: false; error: TError } { + return !result.ok; + }, +}; + +export interface TaggedErrorValue extends Error { + readonly _tag: Tag; + toJSON(): { _tag: Tag; message: string } & Record; +} + +export interface TaggedErrorFactory { + new (props: Props): TaggedErrorValue & Readonly; + is(value: unknown): value is TaggedErrorValue; +} + +export function TaggedError(tag: Tag): TaggedErrorFactory { + class TaggedErrorClass extends Error { + readonly _tag = tag; + + constructor(props: { message: string } & Record) { + super(props.message); + this.name = tag; + Object.assign(this, props); + } + + toJSON(): { _tag: Tag; message: string } & Record { + const payload: Record = {}; + for (const key of Object.keys(this)) { + if (key !== "_tag") payload[key] = (this as unknown as Record)[key]; + } + return { _tag: tag, message: this.message, ...payload }; + } + + static is(value: unknown): value is TaggedErrorValue { + return value instanceof TaggedErrorClass; + } + } + return TaggedErrorClass as unknown as TaggedErrorFactory; +} + +export type ErrorMatchers, TValue> = { + [Tag in TError["_tag"]]: (error: Extract) => TValue; +}; + +export function matchError, TValue>( + error: TError, + matchers: ErrorMatchers, +): TValue { + const matcher = (matchers as unknown as Record TValue>)[error._tag]; + return matcher(error); +} diff --git a/packages/agent/src/harness/session/array-session-index.ts b/packages/agent/src/harness/session/array-session-index.ts deleted file mode 100644 index 248982abff9..00000000000 --- a/packages/agent/src/harness/session/array-session-index.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { - SessionBranchQuery, - SessionEntryCursorOptions, - SessionHead, - SessionStats, - SessionTreeEntry, -} from "../types.ts"; -import { SessionError } from "../types.ts"; - -interface SessionEntryProjection { - name: string | undefined; - labelsById: Map; - stats: SessionStats; -} - -function createProjection(): SessionEntryProjection { - return { - name: undefined, - labelsById: new Map(), - stats: { messageCount: 0, cachedTokens: 0, uncachedTokens: 0, totalTokens: 0, costTotal: 0 }, - }; -} - -function applyProjection(projection: SessionEntryProjection, entry: SessionTreeEntry): void { - if (entry.type === "session_info") { - projection.name = entry.name?.trim() || undefined; - } else if (entry.type === "label") { - const label = entry.label?.trim(); - if (label) projection.labelsById.set(entry.targetId, label); - else projection.labelsById.delete(entry.targetId); - } - if (entry.type === "message") projection.stats.messageCount += 1; - const usage = - entry.type === "message" - ? entry.message.role === "assistant" - ? entry.message.usage - : undefined - : entry.type === "compaction" || entry.type === "branch_summary" - ? entry.usage - : undefined; - if ( - !usage || - typeof usage.input !== "number" || - typeof usage.output !== "number" || - typeof usage.cacheRead !== "number" || - typeof usage.cacheWrite !== "number" || - typeof usage.cost?.total !== "number" - ) { - return; - } - projection.stats.cachedTokens += usage.cacheRead; - projection.stats.uncachedTokens += usage.input + usage.cacheWrite; - projection.stats.totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite; - projection.stats.costTotal += usage.cost.total; -} - -/** Ordered entries and derived projections for array-backed session storage. */ -export class ArraySessionIndex { - private entries: SessionTreeEntry[] = []; - private byId = new Map(); - private leafId: string | null = null; - private projection = createProjection(); - - constructor(entries: readonly SessionTreeEntry[] = []) { - this.replace(entries); - } - - has(id: string): boolean { - return this.byId.has(id); - } - - append(entry: SessionTreeEntry): void { - if (this.byId.has(entry.id)) { - throw new SessionError("invalid_entry", `Entry ${entry.id} already exists`); - } - this.entries.push(entry); - this.byId.set(entry.id, entry); - this.leafId = entry.type === "leaf" ? entry.targetId : entry.id; - applyProjection(this.projection, entry); - } - - replace(entries: readonly SessionTreeEntry[]): void { - const nextEntries = [...entries]; - const nextById = new Map(); - const nextProjection = createProjection(); - let nextLeafId: string | null = null; - for (const entry of nextEntries) { - if (nextById.has(entry.id)) { - throw new SessionError("invalid_entry", `Entry ${entry.id} already exists`); - } - nextById.set(entry.id, entry); - nextLeafId = entry.type === "leaf" ? entry.targetId : entry.id; - applyProjection(nextProjection, entry); - } - this.entries = nextEntries; - this.byId = nextById; - this.leafId = nextLeafId; - this.projection = nextProjection; - } - - readHead(): SessionHead { - if (this.leafId !== null && !this.byId.has(this.leafId)) { - throw new SessionError("invalid_session", `Entry ${this.leafId} not found`); - } - return { leafId: this.leafId }; - } - - readEntry(id: string): SessionTreeEntry | undefined { - return this.byId.get(id); - } - - readEntries(options?: SessionEntryCursorOptions): readonly SessionTreeEntry[] { - const start = options?.afterEntrySeq ?? 0; - const end = options?.limit === undefined ? undefined : start + options.limit; - return this.entries.slice(start, end); - } - - findEntriesOnBranch(query: SessionBranchQuery & { start: string | null }): readonly SessionTreeEntry[] { - if (query.limit !== undefined && (!Number.isInteger(query.limit) || query.limit <= 0)) { - throw new RangeError("Session branch query limit must be a positive integer"); - } - if (query.start === null) return []; - const pathFromStart: SessionTreeEntry[] = []; - const visited = new Set(); - let current = this.byId.get(query.start); - if (!current) throw new SessionError("not_found", `Entry ${query.start} not found`); - while (current) { - if (visited.has(current.id)) { - throw new SessionError("invalid_session", `Session branch contains a cycle at ${current.id}`); - } - visited.add(current.id); - pathFromStart.push(current); - if (query.order !== "oldestFirst" && (current.id === query.stopAtId || current.type === query.stopAtType)) { - break; - } - if (!current.parentId) break; - const parent = this.byId.get(current.parentId); - if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); - current = parent; - } - const traversal = query.order === "oldestFirst" ? pathFromStart.reverse() : pathFromStart; - const stopIndex = - query.order === "oldestFirst" - ? traversal.findIndex((entry) => entry.id === query.stopAtId || entry.type === query.stopAtType) - : -1; - const bounded = stopIndex === -1 ? traversal : traversal.slice(0, stopIndex + 1); - const entries = bounded.filter( - (entry) => - (query.type === undefined || entry.type === query.type) && - (query.customType === undefined || (entry.type === "custom" && entry.customType === query.customType)), - ); - return query.limit === undefined ? entries : entries.slice(0, query.limit); - } - - getLabel(id: string): string | undefined { - return this.projection.labelsById.get(id); - } - - getName(): string | undefined { - return this.projection.name; - } - - getStats(): SessionStats { - return { ...this.projection.stats }; - } - - readPathToRootOrCompaction(requestedLeafId: string | null): readonly SessionTreeEntry[] { - if (requestedLeafId === null) return []; - const path: SessionTreeEntry[] = []; - let stopAtEntryId: string | null = null; - let current = this.byId.get(requestedLeafId); - if (!current) throw new SessionError("not_found", `Entry ${requestedLeafId} not found`); - while (current) { - path.push(current); - if (stopAtEntryId !== null && current.id === stopAtEntryId) break; - if (current.type === "compaction") { - if (current.retainedTail) break; - stopAtEntryId = current.firstKeptEntryId ?? null; - } - if (!current.parentId) break; - const parent = this.byId.get(current.parentId); - if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); - current = parent; - } - return path.reverse(); - } -} diff --git a/packages/agent/src/harness/session/context.ts b/packages/agent/src/harness/session/context.ts new file mode 100644 index 00000000000..d219b541ae1 --- /dev/null +++ b/packages/agent/src/harness/session/context.ts @@ -0,0 +1,100 @@ +import type { AgentMessage } from "../../types.ts"; +import { createBranchSummaryMessage, createCompactionSummaryMessage } from "../messages.ts"; +import type { CompactionEntry, CustomEntry, Entry } from "./types.ts"; + +export interface SessionContext { + messages: AgentMessage[]; + thinkingLevel: string; + model: { provider: string; modelId: string } | null; + activeToolNames: string[] | null; +} + +export type ContextEntryTransform = (entries: readonly Entry[]) => readonly Entry[]; + +export type CustomEntryContextMessageProjector = ( + entry: CustomEntry, + index: number, + entries: readonly Entry[], +) => readonly AgentMessage[] | undefined; + +export interface SessionContextBuildOptions { + entryTransforms?: readonly ContextEntryTransform[]; + entryProjectors?: Readonly>; +} + +function deriveSessionContextState(pathEntries: readonly Entry[]): Omit { + let thinkingLevel = "off"; + let model: { provider: string; modelId: string } | null = null; + let activeToolNames: string[] | null = null; + + for (const entry of pathEntries) { + if (entry.type === "thinking_level_change") { + thinkingLevel = entry.thinkingLevel; + } else if (entry.type === "model_change") { + model = { provider: entry.provider, modelId: entry.modelId }; + } else if (entry.type === "message" && entry.message.role === "assistant") { + model = { provider: entry.message.provider, modelId: entry.message.model }; + } else if (entry.type === "active_tools_change") { + activeToolNames = [...entry.activeToolNames]; + } + } + + return { thinkingLevel, model, activeToolNames }; +} + +export function defaultContextEntryTransform(pathEntries: readonly Entry[]): Entry[] { + let compaction: CompactionEntry | undefined; + let compactionIndex = -1; + for (let index = pathEntries.length - 1; index >= 0; index--) { + const entry = pathEntries[index]!; + if (entry.type === "compaction") { + compaction = entry; + compactionIndex = index; + break; + } + } + return compaction === undefined ? [...pathEntries] : [compaction, ...pathEntries.slice(compactionIndex + 1)]; +} + +export function buildContextEntries(pathEntries: readonly Entry[], options: SessionContextBuildOptions = {}): Entry[] { + let entries = defaultContextEntryTransform(pathEntries); + for (const transform of options.entryTransforms ?? []) entries = [...transform(entries)]; + return entries; +} + +export function sessionEntryToContextMessages( + entry: Entry, + index: number, + entries: readonly Entry[], + options: SessionContextBuildOptions = {}, +): AgentMessage[] { + if (entry.type === "message") { + if (entry.message.role === "assistant" && entry.message.stopReason === "deferred") return []; + return [entry.message]; + } + if (entry.type === "compaction") { + return [ + createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp), + ...entry.retainedTail, + ]; + } + if (entry.type === "branch_summary" && entry.summary) { + return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)]; + } + if (entry.type === "custom") { + return [...(options.entryProjectors?.[entry.customType]?.(entry, index, entries) ?? [])]; + } + return []; +} + +export function buildSessionContext( + pathEntries: readonly Entry[], + options: SessionContextBuildOptions = {}, +): SessionContext { + const state = deriveSessionContextState(pathEntries); + const contextEntries = buildContextEntries(pathEntries, options); + const messages = contextEntries.flatMap((entry, index) => + sessionEntryToContextMessages(entry, index, contextEntries, options), + ); + return { ...state, messages }; +} diff --git a/packages/agent/src/harness/experimental/session/index.ts b/packages/agent/src/harness/session/index.ts similarity index 74% rename from packages/agent/src/harness/experimental/session/index.ts rename to packages/agent/src/harness/session/index.ts index 846a90907f4..bc8b4f2bd29 100644 --- a/packages/agent/src/harness/experimental/session/index.ts +++ b/packages/agent/src/harness/session/index.ts @@ -1,3 +1,4 @@ +export * from "./context.ts"; export * from "./memory.ts"; export * from "./session.ts"; export * from "./types.ts"; diff --git a/packages/agent/src/harness/session/jsonl-repo.ts b/packages/agent/src/harness/session/jsonl-repo.ts deleted file mode 100644 index fa074f24fb6..00000000000 --- a/packages/agent/src/harness/session/jsonl-repo.ts +++ /dev/null @@ -1,487 +0,0 @@ -import type { - FileSystem, - JsonlSessionCreateOptions, - JsonlSessionListOptions, - JsonlSessionMetadata, - SessionForkOptions, - SessionForkSelection, - SessionStorage, - SessionTreeEntry, -} from "../types.ts"; -import { SessionError, toError } from "../types.ts"; -import { ArraySessionIndex } from "./array-session-index.ts"; -import { KeyedOperationQueue } from "./keyed-operation-queue.ts"; -import { - createSessionForkSelection, - createSessionId, - createTimestamp, - getFileSystemResultOrThrow, - readSessionEntriesForFork, - type SessionRepository, -} from "./repository.ts"; -import { createSession, type Session, type SessionContextBuildOptions } from "./session.ts"; - -export interface JsonlSessionBackendOptions { - fs: JsonlSessionRepositoryFileSystem; - sessionsRoot: string; - /** Maximum active operations across session keys. Defaults to 4. */ - maxConcurrentOperations?: number; -} -export type JsonlSessionRepositoryFileSystem = Pick< - FileSystem, - | "absolutePath" - | "joinPath" - | "readTextFile" - | "readTextLines" - | "writeFile" - | "appendFile" - | "listDir" - | "exists" - | "createDir" - | "remove" ->; - -type JsonlSessionFileSystem = Pick; - -const DEFAULT_MAX_CONCURRENT_OPERATIONS = 4; - -interface SessionHeader { - type: "session"; - version: 3; - id: string; - timestamp: string; - cwd: string; - parentSession?: string; - metadata?: Record; -} - -interface SessionDocumentDescriptor { - id: string; - timestamp: string; - fileName: string; - operationKey: string; -} - -interface JsonlSessionDocument { - metadata: JsonlSessionMetadata; - entries: SessionTreeEntry[]; -} - -function invalidSession(path: string, message: string, cause?: Error): SessionError { - return new SessionError("invalid_session", `Invalid JSONL session file ${path}: ${message}`, cause); -} - -function invalidEntry(path: string, line: number, message: string, cause?: Error): SessionError { - return new SessionError("invalid_entry", `Invalid JSONL session file ${path}: line ${line} ${message}`, cause); -} - -function parseHeader(line: string, path: string): SessionHeader { - let value: unknown; - try { - value = JSON.parse(line); - } catch (error) { - throw invalidSession(path, "first line is not a valid session header", toError(error)); - } - if (typeof value !== "object" || value === null) - throw invalidSession(path, "first line is not a valid session header"); - const header = value as Partial; - if (header.type !== "session" || header.version !== 3) { - throw invalidSession( - path, - header.type === "session" ? "unsupported session version" : "first line is not a valid session header", - ); - } - if (typeof header.id !== "string" || !header.id) throw invalidSession(path, "session header is missing id"); - if (typeof header.timestamp !== "string" || !header.timestamp) - throw invalidSession(path, "session header is missing timestamp"); - if (typeof header.cwd !== "string" || !header.cwd) throw invalidSession(path, "session header is missing cwd"); - if (header.parentSession !== undefined && typeof header.parentSession !== "string") { - throw invalidSession(path, "session header parentSession must be a string"); - } - if ( - header.metadata !== undefined && - (typeof header.metadata !== "object" || header.metadata === null || Array.isArray(header.metadata)) - ) { - throw invalidSession(path, "session header metadata must be an object"); - } - return { - type: "session", - version: 3, - id: header.id, - timestamp: header.timestamp, - cwd: header.cwd, - parentSession: header.parentSession, - metadata: header.metadata, - }; -} - -function parseEntry(line: string, path: string, lineNumber: number): SessionTreeEntry { - let value: unknown; - try { - value = JSON.parse(line); - } catch (error) { - throw invalidEntry(path, lineNumber, "is not valid JSON", toError(error)); - } - if (typeof value !== "object" || value === null) - throw invalidEntry(path, lineNumber, "is not a valid session entry"); - const entry = value as { - type?: unknown; - id?: unknown; - parentId?: unknown; - timestamp?: unknown; - targetId?: unknown; - }; - if (typeof entry.type !== "string") throw invalidEntry(path, lineNumber, "is missing entry type"); - if (typeof entry.id !== "string" || !entry.id) throw invalidEntry(path, lineNumber, "is missing entry id"); - if (entry.parentId !== null && typeof entry.parentId !== "string") - throw invalidEntry(path, lineNumber, "has invalid parentId"); - if (typeof entry.timestamp !== "string" || !entry.timestamp) - throw invalidEntry(path, lineNumber, "is missing timestamp"); - if (entry.type === "leaf" && entry.targetId !== null && typeof entry.targetId !== "string") { - throw invalidEntry(path, lineNumber, "has invalid targetId"); - } - return entry as SessionTreeEntry; -} - -function metadataFromHeader(header: SessionHeader, path: string): JsonlSessionMetadata { - return { - id: header.id, - createdAt: header.timestamp, - cwd: header.cwd, - path, - parentSessionPath: header.parentSession, - metadata: header.metadata, - }; -} - -export async function loadJsonlSessionMetadata( - fs: JsonlSessionFileSystem, - path: string, -): Promise { - const lines = getFileSystemResultOrThrow( - await fs.readTextLines(path, { maxLines: 1 }), - `Failed to read session header ${path}`, - ); - if (!lines[0]?.trim()) throw invalidSession(path, "missing session header"); - return metadataFromHeader(parseHeader(lines[0], path), path); -} - -async function loadJsonlSession(fs: JsonlSessionFileSystem, path: string): Promise { - const content = getFileSystemResultOrThrow(await fs.readTextFile(path), `Failed to read session ${path}`); - const lines = content.split("\n").filter((line) => line.trim()); - if (lines.length === 0) throw invalidSession(path, "missing session header"); - const header = parseHeader(lines[0]!, path); - const entries = lines.slice(1).map((line, index) => parseEntry(line, path, index + 2)); - const entryIds = new Set(); - for (const entry of entries) { - if (entryIds.has(entry.id)) throw invalidSession(path, `duplicate entry id ${entry.id}`); - entryIds.add(entry.id); - } - return { - metadata: metadataFromHeader(header, path), - entries, - }; -} - -function encodeCwd(cwd: string): string { - return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; -} - -function createDocumentDescriptor(options: JsonlSessionCreateOptions): SessionDocumentDescriptor { - const id = options.id ?? createSessionId(); - if (!id) throw new SessionError("invalid_session", "Session id cannot be empty"); - let encodedId: string; - try { - encodedId = encodeURIComponent(id); - } catch (error) { - throw new SessionError("invalid_session", `Invalid session id ${JSON.stringify(id)}`, toError(error)); - } - const timestamp = createTimestamp(); - const fileName = `${timestamp.replace(/[:.]/g, "-")}_${encodedId}.jsonl`; - return { - id, - timestamp, - fileName, - operationKey: `document:${JSON.stringify([encodeCwd(options.cwd), fileName])}`, - }; -} - -export class JsonlSessionBackend { - private readonly fs: JsonlSessionRepositoryFileSystem; - private readonly sessionsRootInput: string; - private sessionsRoot: string | undefined; - private readonly entryIndexesByPath = new Map(); - private readonly operationKeysByPath = new Map(); - private readonly operations: KeyedOperationQueue; - private disposed = false; - private disposePromise: Promise | undefined; - - constructor(options: JsonlSessionBackendOptions) { - this.fs = options.fs; - this.sessionsRootInput = options.sessionsRoot; - this.operations = new KeyedOperationQueue({ - maxConcurrentOperations: options.maxConcurrentOperations ?? DEFAULT_MAX_CONCURRENT_OPERATIONS, - }); - } - - create(options: JsonlSessionCreateOptions): Promise> { - this.assertOpen(); - const descriptor = createDocumentDescriptor(options); - return this.operations.enqueue(descriptor.operationKey, async () => - this.storage(await this.createDocument(descriptor, options, options.parentSessionPath, options.metadata, [])), - ); - } - - open(metadata: JsonlSessionMetadata): Promise> { - this.assertOpen(); - return this.operations.enqueue(this.operationKey(metadata), async () => - this.storage(await this.loadDocument(metadata)), - ); - } - - private async loadDocument(metadata: JsonlSessionMetadata): Promise { - if ( - !getFileSystemResultOrThrow(await this.fs.exists(metadata.path), `Failed to check session ${metadata.path}`) - ) { - throw new SessionError("not_found", `Session not found: ${metadata.path}`); - } - const document = await loadJsonlSession(this.fs, metadata.path); - const entries = this.entryIndexesByPath.get(metadata.path); - if (entries) entries.replace(document.entries); - else this.entryIndexesByPath.set(metadata.path, new ArraySessionIndex(document.entries)); - return document.metadata; - } - - list(options: JsonlSessionListOptions = {}): Promise { - this.assertOpen(); - return this.operations.enqueueBarrier(() => this.listSessions(options)); - } - - private async listSessions(options: JsonlSessionListOptions): Promise { - const dirs = options.cwd ? [await this.getSessionDir(options.cwd)] : await this.listSessionDirs(); - const sessions: JsonlSessionMetadata[] = []; - for (const dir of dirs) { - if (!getFileSystemResultOrThrow(await this.fs.exists(dir), `Failed to check session directory ${dir}`)) - continue; - const files = getFileSystemResultOrThrow( - await this.fs.listDir(dir), - `Failed to list sessions in ${dir}`, - ).filter((file) => file.kind !== "directory" && file.name.endsWith(".jsonl")); - for (const file of files) sessions.push(await loadJsonlSessionMetadata(this.fs, file.path)); - } - return sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - } - - private appendEntry(metadata: JsonlSessionMetadata, entry: SessionTreeEntry): Promise { - this.assertOpen(); - return this.operations.enqueue(this.operationKey(metadata), async () => { - if ( - !getFileSystemResultOrThrow(await this.fs.exists(metadata.path), `Failed to check session ${metadata.path}`) - ) { - throw new SessionError("not_found", `Session not found: ${metadata.path}`); - } - let entries = this.entryIndexesByPath.get(metadata.path); - if (!entries) { - await this.loadDocument(metadata); - entries = this.entryIndexesByPath.get(metadata.path)!; - } - if (entries.has(entry.id)) throw new SessionError("invalid_entry", `Entry ${entry.id} already exists`); - getFileSystemResultOrThrow( - await this.fs.appendFile(metadata.path, `${JSON.stringify(entry)}\n`), - `Failed to append session entry ${entry.id}`, - ); - entries.append(entry); - }); - } - - delete(metadata: JsonlSessionMetadata): Promise { - this.assertOpen(); - return this.operations.enqueue(this.operationKey(metadata), async () => { - getFileSystemResultOrThrow( - await this.fs.remove(metadata.path, { force: true }), - `Failed to delete session ${metadata.path}`, - ); - this.entryIndexesByPath.delete(metadata.path); - this.operationKeysByPath.delete(metadata.path); - }); - } - - fork( - source: JsonlSessionMetadata, - options: JsonlSessionCreateOptions, - selection: SessionForkSelection, - ): Promise> { - this.assertOpen(); - const descriptor = createDocumentDescriptor(options); - const sourceEntries = this.operations.enqueue(this.operationKey(source), async () => { - if (!getFileSystemResultOrThrow(await this.fs.exists(source.path), `Failed to check session ${source.path}`)) { - throw new SessionError("not_found", `Session not found: ${source.path}`); - } - const document = await loadJsonlSession(this.fs, source.path); - const entries = this.entryIndexesByPath.get(source.path); - if (entries) entries.replace(document.entries); - else this.entryIndexesByPath.set(source.path, new ArraySessionIndex(document.entries)); - return readSessionEntriesForFork(this.entryIndexesByPath.get(source.path)!, selection); - }); - return this.operations.enqueue(descriptor.operationKey, async () => - this.storage( - await this.createDocument( - descriptor, - options, - options.parentSessionPath ?? source.path, - options.metadata ?? source.metadata, - await sourceEntries, - ), - ), - ); - } - - async [Symbol.asyncDispose](): Promise { - if (!this.disposePromise) { - this.disposed = true; - this.disposePromise = this.operations.drain(); - } - await this.disposePromise; - } - - private assertOpen(): void { - if (this.disposed) throw new SessionError("storage", "JSONL session repository is disposed"); - } - - private operationKey(metadata: JsonlSessionMetadata): string { - return this.operationKeysByPath.get(metadata.path) ?? metadata.path; - } - - private async createDocument( - descriptor: SessionDocumentDescriptor, - options: JsonlSessionCreateOptions, - parentSessionPath: string | undefined, - metadata: Record | undefined, - entries: readonly SessionTreeEntry[], - ): Promise { - const dir = await this.getSessionDir(options.cwd); - getFileSystemResultOrThrow( - await this.fs.createDir(dir, { recursive: true }), - `Failed to create session directory ${dir}`, - ); - const path = getFileSystemResultOrThrow( - await this.fs.joinPath([dir, descriptor.fileName]), - `Failed to resolve session file path for ${descriptor.id}`, - ); - if (getFileSystemResultOrThrow(await this.fs.exists(path), `Failed to check session ${path}`)) { - throw new SessionError("invalid_session", `Session already exists: ${path}`); - } - const header: SessionHeader = { - type: "session", - version: 3, - id: descriptor.id, - timestamp: descriptor.timestamp, - cwd: options.cwd, - parentSession: parentSessionPath, - metadata, - }; - const content = [JSON.stringify(header), ...entries.map((entry) => JSON.stringify(entry)), ""].join("\n"); - getFileSystemResultOrThrow(await this.fs.writeFile(path, content), `Failed to create session ${path}`); - this.entryIndexesByPath.set(path, new ArraySessionIndex(entries)); - this.operationKeysByPath.set(path, descriptor.operationKey); - return metadataFromHeader(header, path); - } - - private readIndex(metadata: JsonlSessionMetadata, read: (entries: ArraySessionIndex) => T): Promise { - this.assertOpen(); - return this.operations.enqueue(this.operationKey(metadata), () => read(this.entryIndex(metadata.path))); - } - - private storage(metadata: JsonlSessionMetadata): SessionStorage { - return { - metadata, - readHead: () => this.readIndex(metadata, (entries) => entries.readHead()), - readEntry: (id) => this.readIndex(metadata, (entries) => entries.readEntry(id)), - readEntries: (options) => this.readIndex(metadata, (entries) => entries.readEntries(options)), - appendEntry: (entry) => this.appendEntry(metadata, entry), - findEntriesOnBranch: (query) => this.readIndex(metadata, (entries) => entries.findEntriesOnBranch(query)), - readPathToRootOrCompaction: (leafId) => - this.readIndex(metadata, (entries) => entries.readPathToRootOrCompaction(leafId)), - getLabel: (id) => this.readIndex(metadata, (entries) => entries.getLabel(id)), - getName: () => this.readIndex(metadata, (entries) => entries.getName()), - getStats: () => this.readIndex(metadata, (entries) => entries.getStats()), - }; - } - - private entryIndex(path: string): ArraySessionIndex { - const entries = this.entryIndexesByPath.get(path); - if (!entries) throw new SessionError("not_found", `Session not found: ${path}`); - return entries; - } - - private async getSessionsRoot(): Promise { - this.sessionsRoot ??= getFileSystemResultOrThrow( - await this.fs.absolutePath(this.sessionsRootInput), - `Failed to resolve sessions root ${this.sessionsRootInput}`, - ); - return this.sessionsRoot; - } - - private async getSessionDir(cwd: string): Promise { - return getFileSystemResultOrThrow( - await this.fs.joinPath([await this.getSessionsRoot(), encodeCwd(cwd)]), - `Failed to resolve session directory for ${cwd}`, - ); - } - - private async listSessionDirs(): Promise { - const root = await this.getSessionsRoot(); - if (!getFileSystemResultOrThrow(await this.fs.exists(root), `Failed to check sessions root ${root}`)) return []; - return getFileSystemResultOrThrow(await this.fs.listDir(root), `Failed to list sessions root ${root}`) - .filter((entry) => entry.kind === "directory") - .map((entry) => entry.path); - } -} - -export interface JsonlSessionRepositoryOptions extends JsonlSessionBackendOptions { - contextBuildOptions?: SessionContextBuildOptions; -} - -export class JsonlSessionRepository - implements SessionRepository -{ - private readonly backend: JsonlSessionBackend; - private readonly contextBuildOptions: SessionContextBuildOptions; - - constructor(options: JsonlSessionRepositoryOptions) { - const { contextBuildOptions, ...backendOptions } = options; - this.backend = new JsonlSessionBackend(backendOptions); - this.contextBuildOptions = contextBuildOptions ?? {}; - } - - async create(options: JsonlSessionCreateOptions): Promise> { - return createSession(await this.backend.create(options), this.contextBuildOptions); - } - - async open(metadata: JsonlSessionMetadata): Promise> { - return createSession(await this.backend.open(metadata), this.contextBuildOptions); - } - - async list(options?: JsonlSessionListOptions): Promise { - return await this.backend.list(options); - } - - async delete(metadata: JsonlSessionMetadata): Promise { - await this.backend.delete(metadata); - } - - async fork( - source: JsonlSessionMetadata, - options: SessionForkOptions & JsonlSessionCreateOptions, - ): Promise> { - const { entryId: _entryId, position: _position, ...createOptions } = options; - return createSession( - await this.backend.fork(source, createOptions, createSessionForkSelection(options)), - this.contextBuildOptions, - ); - } - - async [Symbol.asyncDispose](): Promise { - await this.backend[Symbol.asyncDispose](); - } -} diff --git a/packages/agent/src/harness/session/keyed-operation-queue.ts b/packages/agent/src/harness/session/keyed-operation-queue.ts deleted file mode 100644 index 8cfc678926a..00000000000 --- a/packages/agent/src/harness/session/keyed-operation-queue.ts +++ /dev/null @@ -1,69 +0,0 @@ -export class KeyedOperationQueue { - private readonly tails = new Map>(); - private readonly maxConcurrentOperations: number | undefined; - private readonly permitWaiters: Array<() => void> = []; - private activeOperations = 0; - private barrier: Promise = Promise.resolve(); - - constructor(options: { maxConcurrentOperations?: number } = {}) { - if ( - options.maxConcurrentOperations !== undefined && - (!Number.isInteger(options.maxConcurrentOperations) || options.maxConcurrentOperations < 1) - ) { - throw new RangeError("maxConcurrentOperations must be a positive integer"); - } - this.maxConcurrentOperations = options.maxConcurrentOperations; - } - - enqueue(key: TKey, operation: () => Promise | T): Promise { - const previous = this.tails.get(key) ?? Promise.resolve(); - const result = Promise.all([this.barrier, previous]).then(() => this.runOperation(operation)); - const tail = result.then( - () => undefined, - () => undefined, - ); - this.tails.set(key, tail); - void tail.then(() => { - if (this.tails.get(key) === tail) this.tails.delete(key); - }); - return result; - } - - enqueueBarrier(operation: () => Promise | T): Promise { - const result = Promise.all([this.barrier, ...this.tails.values()]).then(() => this.runOperation(operation)); - this.barrier = result.then( - () => undefined, - () => undefined, - ); - return result; - } - - async drain(): Promise { - await Promise.all([this.barrier, ...this.tails.values()]); - } - - private async runOperation(operation: () => Promise | T): Promise { - await this.acquirePermit(); - try { - return await operation(); - } finally { - this.releasePermit(); - } - } - - private async acquirePermit(): Promise { - if (this.maxConcurrentOperations === undefined) return; - if (this.activeOperations < this.maxConcurrentOperations) { - this.activeOperations += 1; - return; - } - await new Promise((resolve) => this.permitWaiters.push(resolve)); - } - - private releasePermit(): void { - if (this.maxConcurrentOperations === undefined) return; - const next = this.permitWaiters.shift(); - if (next) next(); - else this.activeOperations -= 1; - } -} diff --git a/packages/agent/src/harness/session/memory-repo.ts b/packages/agent/src/harness/session/memory-repo.ts deleted file mode 100644 index b28e0369e8f..00000000000 --- a/packages/agent/src/harness/session/memory-repo.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { - SessionForkOptions, - SessionForkSelection, - SessionMetadata, - SessionStorage, - SessionTreeEntry, -} from "../types.ts"; -import { SessionError } from "../types.ts"; -import { ArraySessionIndex } from "./array-session-index.ts"; -import { KeyedOperationQueue } from "./keyed-operation-queue.ts"; -import { - createSessionForkSelection, - createSessionId, - createTimestamp, - readSessionEntriesForFork, - type SessionRepository, -} from "./repository.ts"; -import { createSession, type Session, type SessionContextBuildOptions } from "./session.ts"; - -export type InMemorySessionCreateOptions = { id?: string }; - -interface InMemorySessionState { - metadata: SessionMetadata; - entries: ArraySessionIndex; -} - -export class InMemorySessionBackend { - private readonly sessions = new Map(); - private readonly operations = new KeyedOperationQueue(); - private disposed = false; - private disposePromise: Promise | undefined; - - create(options: InMemorySessionCreateOptions = {}): Promise> { - this.assertOpen(); - const id = options.id ?? createSessionId(); - return this.operations.enqueue(id, () => { - const state: InMemorySessionState = { - metadata: { id, createdAt: createTimestamp() }, - entries: new ArraySessionIndex(), - }; - this.sessions.set(id, state); - return this.storage(state); - }); - } - - open(metadata: SessionMetadata): Promise> { - this.assertOpen(); - return this.operations.enqueue(metadata.id, () => this.storage(this.getState(metadata))); - } - - list(): Promise { - this.assertOpen(); - return this.operations.enqueueBarrier(() => [...this.sessions.values()].map((state) => state.metadata)); - } - - delete(metadata: SessionMetadata): Promise { - this.assertOpen(); - return this.operations.enqueue(metadata.id, () => { - this.sessions.delete(metadata.id); - }); - } - - fork( - source: SessionMetadata, - options: InMemorySessionCreateOptions, - selection: SessionForkSelection, - ): Promise> { - this.assertOpen(); - const id = options.id ?? createSessionId(); - const sourceEntries = this.operations.enqueue(source.id, () => - readSessionEntriesForFork(this.getState(source).entries, selection), - ); - return this.operations.enqueue(id, async () => { - const state: InMemorySessionState = { - metadata: { id, createdAt: createTimestamp() }, - entries: new ArraySessionIndex(await sourceEntries), - }; - this.sessions.set(id, state); - return this.storage(state); - }); - } - - async [Symbol.asyncDispose](): Promise { - if (!this.disposePromise) { - this.disposed = true; - this.disposePromise = this.operations.drain(); - } - await this.disposePromise; - } - - private storage(state: InMemorySessionState): SessionStorage { - const read = (operation: (entries: ArraySessionIndex) => T): Promise => { - this.assertOpen(); - return this.operations.enqueue(state.metadata.id, () => operation(this.getState(state.metadata).entries)); - }; - return { - metadata: state.metadata, - readHead: () => read((entries) => entries.readHead()), - readEntry: (id) => read((entries) => entries.readEntry(id)), - readEntries: (options) => read((entries) => entries.readEntries(options)), - appendEntry: (entry) => this.appendEntry(state.metadata, entry), - findEntriesOnBranch: (query) => read((entries) => entries.findEntriesOnBranch(query)), - readPathToRootOrCompaction: (leafId) => read((entries) => entries.readPathToRootOrCompaction(leafId)), - getLabel: (id) => read((entries) => entries.getLabel(id)), - getName: () => read((entries) => entries.getName()), - getStats: () => read((entries) => entries.getStats()), - }; - } - - private appendEntry(metadata: SessionMetadata, entry: SessionTreeEntry): Promise { - this.assertOpen(); - return this.operations.enqueue(metadata.id, () => { - this.getState(metadata).entries.append(entry); - }); - } - - private assertOpen(): void { - if (this.disposed) throw new SessionError("storage", "In-memory session repository is disposed"); - } - - private getState(metadata: SessionMetadata): InMemorySessionState { - const state = this.sessions.get(metadata.id); - if (!state) throw new SessionError("not_found", `Session not found: ${metadata.id}`); - return state; - } -} - -export interface InMemorySessionRepositoryOptions { - contextBuildOptions?: SessionContextBuildOptions; -} - -export class InMemorySessionRepository - implements SessionRepository -{ - private readonly backend = new InMemorySessionBackend(); - private readonly contextBuildOptions: SessionContextBuildOptions; - - constructor(options: InMemorySessionRepositoryOptions = {}) { - this.contextBuildOptions = options.contextBuildOptions ?? {}; - } - - async create(options: InMemorySessionCreateOptions = {}): Promise> { - return createSession(await this.backend.create(options), this.contextBuildOptions); - } - - async open(metadata: SessionMetadata): Promise> { - return createSession(await this.backend.open(metadata), this.contextBuildOptions); - } - - async list(): Promise { - return await this.backend.list(); - } - - async delete(metadata: SessionMetadata): Promise { - await this.backend.delete(metadata); - } - - async fork( - source: SessionMetadata, - options: SessionForkOptions & InMemorySessionCreateOptions, - ): Promise> { - const { entryId: _entryId, position: _position, ...createOptions } = options; - return createSession( - await this.backend.fork(source, createOptions, createSessionForkSelection(options)), - this.contextBuildOptions, - ); - } - - async [Symbol.asyncDispose](): Promise { - await this.backend[Symbol.asyncDispose](); - } -} diff --git a/packages/agent/src/harness/experimental/session/memory.ts b/packages/agent/src/harness/session/memory.ts similarity index 100% rename from packages/agent/src/harness/experimental/session/memory.ts rename to packages/agent/src/harness/session/memory.ts diff --git a/packages/agent/src/harness/session/repository.ts b/packages/agent/src/harness/session/repository.ts deleted file mode 100644 index 76893efd5a2..00000000000 --- a/packages/agent/src/harness/session/repository.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { uuidv7 } from "@earendil-works/pi-ai"; -import { - type FileError, - type Result, - type SessionCreateOptions, - SessionError, - type SessionForkOptions, - type SessionForkSelection, - type SessionMetadata, - type SessionTreeEntry, -} from "../types.ts"; -import type { Session } from "./session.ts"; - -export function createSessionId(): string { - return uuidv7(); -} - -export function createTimestamp(): string { - return new Date().toISOString(); -} - -export interface SessionRepository< - TMetadata extends SessionMetadata = SessionMetadata, - TCreateOptions extends SessionCreateOptions = SessionCreateOptions, - TListOptions = void, -> extends AsyncDisposable { - create(options: TCreateOptions): Promise>; - open(metadata: TMetadata): Promise>; - list(options?: TListOptions): Promise; - delete(metadata: TMetadata): Promise; - fork(source: TMetadata, options: SessionForkOptions & TCreateOptions): Promise>; -} - -export function getFileSystemResultOrThrow(result: Result, message: string): TValue { - if (!result.ok) { - const code = result.error.code === "not_found" ? "not_found" : "storage"; - throw new SessionError(code, `${message}: ${result.error.message}`, result.error); - } - return result.value; -} - -type MaybePromise = T | Promise; - -interface SessionForkEntrySource { - readEntry(id: string): MaybePromise; - readEntries(): MaybePromise; - readPathToRootOrCompaction(leafId: string | null): MaybePromise; -} - -/** @internal Transitional fork selection shared by built-in repositories. */ -export function createSessionForkSelection(options: SessionForkOptions): SessionForkSelection { - if (!options.entryId) return { kind: "all" }; - return (options.position ?? "before") === "at" - ? { kind: "through_entry", entryId: options.entryId } - : { kind: "before_user_message", entryId: options.entryId }; -} - -/** @internal Shared fork selection validation for built-in repositories. */ -export async function readSessionEntriesForFork( - source: SessionForkEntrySource, - selection: SessionForkSelection, -): Promise { - if (selection.kind === "all") return source.readEntries(); - const target = await source.readEntry(selection.entryId); - if (!target) throw new SessionError("invalid_fork_target", `Entry ${selection.entryId} not found`); - if (selection.kind === "through_entry") return source.readPathToRootOrCompaction(target.id); - if (target.type !== "message" || target.message.role !== "user") { - throw new SessionError("invalid_fork_target", `Entry ${selection.entryId} is not a user message`); - } - return source.readPathToRootOrCompaction(target.parentId); -} diff --git a/packages/agent/src/harness/session/search.ts b/packages/agent/src/harness/session/search.ts index e4eaebf1816..0bd44c44cc1 100644 --- a/packages/agent/src/harness/session/search.ts +++ b/packages/agent/src/harness/session/search.ts @@ -1,19 +1,37 @@ -import type { - SessionCreateOptions, - SessionMetadata, - SessionSearch, - SessionSearchHit, - SessionSearchOptions, -} from "../types.ts"; -import type { SessionRepository } from "./repository.ts"; +import type { FileError, Result } from "../types.ts"; import type { Session } from "./session.ts"; +import { type SessionCreateOptions, SessionError, type SessionMetadata, type SessionRepo } from "./types.ts"; + +export interface SessionSearchOptions { + text: string; + cwd?: string; +} + +export interface SessionSearchHit { + metadata: TMetadata; + entryId: string; + timestamp: string; + snippet?: string; + score?: number; +} + +export interface SessionSearch { + search(options: SessionSearchOptions): Promise[]>; +} + +export function getFileSystemResultOrThrow(result: Result, message: string): TValue { + if (!result.ok) { + const code = result.error.code === "not_found" ? "not_found" : "storage"; + throw new SessionError(code, `${message}: ${result.error.message}`, result.error); + } + return result.value; +} type ScanningSessionSearchSource = { list(): Promise; open(metadata: TMetadata): Promise>; }; -/** Searches canonical sessions directly and therefore has no index to maintain. */ class ScanningSessionSearch implements SessionSearch { private readonly source: ScanningSessionSearchSource; @@ -29,10 +47,15 @@ class ScanningSessionSearch const cwd = (metadata as { cwd?: unknown }).cwd; if (options.cwd !== undefined && cwd !== options.cwd) continue; const session = await this.source.open(metadata); - for (const entry of await session.getEntries()) { + for (const entry of await session.findEntries({ order: "oldestFirst" })) { const payload = JSON.stringify(entry); if (!payload.toLowerCase().includes(normalizedText)) continue; - hits.push({ metadata, entryId: entry.id, timestamp: entry.timestamp, snippet: payload }); + hits.push({ + metadata, + entryId: entry.id, + timestamp: new Date(entry.timestamp).toISOString(), + snippet: payload, + }); } } return hits; @@ -43,6 +66,6 @@ export function createScanningSessionSearch< TMetadata extends SessionMetadata, TCreateOptions extends SessionCreateOptions, TListOptions, ->(source: Pick, "list" | "open">): SessionSearch { +>(source: Pick, "list" | "open">): SessionSearch { return new ScanningSessionSearch(source); } diff --git a/packages/agent/src/harness/session/session.ts b/packages/agent/src/harness/session/session.ts index dd8d1faa1d8..d57d5a9171c 100644 --- a/packages/agent/src/harness/session/session.ts +++ b/packages/agent/src/harness/session/session.ts @@ -1,430 +1,285 @@ -import { type ImageContent, type TextContent, type Usage, uuidv7 } from "@earendil-works/pi-ai"; +import { uuidv7 } from "@earendil-works/pi-ai"; import type { AgentMessage } from "../../types.ts"; -import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.ts"; import type { - ActiveToolsChangeEntry, - BranchSummaryEntry, - CompactionEntry, - CustomEntry, - CustomMessageEntry, - LabelEntry, - LeafEntry, - MessageEntry, - ModelChangeEntry, - SessionBranchQuery, - SessionContext, - SessionEntryCursorOptions, - SessionInfoEntry, + BranchBounds, + Entry, + EntryQuery, + IdGenerator, + LanePointer, + LaneRecord, + LogItem, + LogOptions, + NewRecord, + ProvisionedEntry, + RecordBase, + RecordQuery, SessionMetadata, SessionStats, SessionStorage, - SessionTreeEntry, - ThinkingLevelChangeEntry, -} from "../types.ts"; -import { SessionError } from "../types.ts"; - -export type ContextEntryTransform = (entries: readonly SessionTreeEntry[]) => readonly SessionTreeEntry[]; - -export type CustomEntryContextMessageProjector = ( - entry: CustomEntry, - index: number, - entries: readonly SessionTreeEntry[], -) => readonly AgentMessage[] | undefined; - -export interface SessionContextBuildOptions { - /** Additional entry transforms applied after the default compaction transform. */ - entryTransforms?: readonly ContextEntryTransform[]; - /** Optional custom-entry projectors. Custom entries are omitted from model context by default. */ - entryProjectors?: Readonly>; -} + SessionTree, +} from "./types.ts"; +import { SessionError } from "./types.ts"; -function deriveSessionContextState(pathEntries: readonly SessionTreeEntry[]): Omit { - let thinkingLevel = "off"; - let model: { provider: string; modelId: string } | null = null; - let activeToolNames: string[] | null = null; - - for (const entry of pathEntries) { - if (entry.type === "thinking_level_change") { - thinkingLevel = entry.thinkingLevel; - } else if (entry.type === "model_change") { - model = { provider: entry.provider, modelId: entry.modelId }; - } else if (entry.type === "message" && entry.message.role === "assistant") { - model = { provider: entry.message.provider, modelId: entry.message.model }; - } else if (entry.type === "active_tools_change") { - activeToolNames = [...entry.activeToolNames]; - } - } +type JsonValidationFrame = { value: unknown } | { exit: object }; - return { thinkingLevel, model, activeToolNames }; +function invalidPayload(reason: string): never { + throw new SessionError("invalid_payload", `Durable payload ${reason}`); } -export function defaultContextEntryTransform(pathEntries: readonly SessionTreeEntry[]): SessionTreeEntry[] { - let compaction: CompactionEntry | null = null; - for (const entry of pathEntries) { - if (entry.type === "compaction") { - compaction = entry; - } - } - if (!compaction) { - return [...pathEntries]; - } - - const entries: SessionTreeEntry[] = [compaction]; - const compactionIdx = pathEntries.findIndex((entry) => entry.type === "compaction" && entry.id === compaction.id); - if (compaction.retainedTail) { - for (let i = compactionIdx + 1; i < pathEntries.length; i++) { - entries.push(pathEntries[i]!); - } - return entries; - } - if (compaction.firstKeptEntryId) { - let foundFirstKept = false; - for (let i = 0; i < compactionIdx; i++) { - const entry = pathEntries[i]!; - if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true; - if (foundFirstKept) entries.push(entry); - } - } - for (let i = compactionIdx + 1; i < pathEntries.length; i++) { - entries.push(pathEntries[i]!); +function assertValidLimit(limit: number | undefined): void { + if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) { + throw new SessionError("invalid_query", "limit must be a positive integer"); } - return entries; } -export function buildContextEntries( - pathEntries: readonly SessionTreeEntry[], - options: SessionContextBuildOptions = {}, -): SessionTreeEntry[] { - let entries = defaultContextEntryTransform(pathEntries); - for (const transform of options.entryTransforms ?? []) { - entries = [...transform(entries)]; +function assertValidCursor(afterSeq: number | undefined): void { + if (afterSeq !== undefined && (!Number.isInteger(afterSeq) || afterSeq < 0)) { + throw new SessionError("invalid_query", "cursor sequence must be a non-negative integer"); } - return entries; } -export function sessionEntryToContextMessages( - entry: SessionTreeEntry, - index: number, - entries: readonly SessionTreeEntry[], - options: SessionContextBuildOptions = {}, -): AgentMessage[] { - if (entry.type === "message") { - return [entry.message as AgentMessage]; - } - if (entry.type === "custom_message") { - return [ - createCustomMessage( - entry.customType, - entry.content as string | (TextContent | ImageContent)[], - entry.display, - entry.details, - entry.timestamp, - ), - ]; - } - if (entry.type === "compaction") { - return [ - createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp), - ...(entry.retainedTail ?? []), - ]; - } - if (entry.type === "branch_summary" && entry.summary) { - return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)]; - } - if (entry.type === "custom") { - return [...(options.entryProjectors?.[entry.customType]?.(entry, index, entries) ?? [])]; - } - return []; -} +export function assertJsonSerializable(value: unknown): void { + const active = new WeakSet(); + const stack: JsonValidationFrame[] = [{ value }]; + while (stack.length > 0) { + const frame = stack.pop()!; + if ("exit" in frame) { + active.delete(frame.exit); + continue; + } + const candidate = frame.value; + if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") { + continue; + } + if (typeof candidate === "number") { + if (!Number.isFinite(candidate)) invalidPayload("contains a non-finite number"); + continue; + } + if (typeof candidate !== "object") invalidPayload(`contains ${typeof candidate}`); + if (active.has(candidate)) invalidPayload("contains a cycle"); + active.add(candidate); + stack.push({ exit: candidate }); + + if (Array.isArray(candidate)) { + if (Object.getPrototypeOf(candidate) !== Array.prototype) { + invalidPayload("contains a non-standard array"); + } + if ( + Object.getOwnPropertySymbols(candidate).length > 0 || + Object.getOwnPropertyNames(candidate).length !== candidate.length + 1 + ) { + invalidPayload("contains an array with unsupported properties"); + } + for (let index = candidate.length - 1; index >= 0; index--) { + if (!Object.hasOwn(candidate, index)) invalidPayload("contains a sparse array"); + const descriptor = Object.getOwnPropertyDescriptor(candidate, index)!; + if (!("value" in descriptor)) invalidPayload("contains an array accessor"); + stack.push({ value: descriptor.value }); + } + continue; + } -export function buildSessionContext( - pathEntries: readonly SessionTreeEntry[], - options: SessionContextBuildOptions = {}, -): SessionContext { - const state = deriveSessionContextState(pathEntries); - const contextEntries = buildContextEntries(pathEntries, options); - const messages = contextEntries.flatMap((entry, index) => - sessionEntryToContextMessages(entry, index, contextEntries, options), - ); - return { ...state, messages }; + const prototype = Object.getPrototypeOf(candidate); + if (prototype !== Object.prototype && prototype !== null) { + invalidPayload("contains a non-plain object"); + } + if (Object.getOwnPropertySymbols(candidate).length > 0) { + invalidPayload("contains a symbol-keyed property"); + } + const keys = Object.keys(candidate); + if (Object.getOwnPropertyNames(candidate).length !== keys.length) { + invalidPayload("contains a non-enumerable property"); + } + for (let index = keys.length - 1; index >= 0; index--) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, keys[index]!)!; + if (!("value" in descriptor)) invalidPayload("contains an accessor"); + stack.push({ value: descriptor.value }); + } + } } -export class Session { +export class Session implements SessionTree { private readonly storage: SessionStorage; - private readonly metadata: TMetadata; - private leafId: string | null; - private readonly contextBuildOptions: SessionContextBuildOptions; - private appendTail: Promise = Promise.resolve(); - - /** @internal Construct sessions through SessionRepository. */ - constructor( - storage: SessionStorage, - leafId: string | null, - contextBuildOptions: SessionContextBuildOptions = {}, - ) { + readonly idGenerator: IdGenerator; + + constructor(storage: SessionStorage, options: { idGenerator?: IdGenerator } = {}) { this.storage = storage; - this.metadata = storage.metadata; - this.leafId = leafId; - this.contextBuildOptions = contextBuildOptions; + this.idGenerator = options.idGenerator ?? { next: () => uuidv7() }; } async getMetadata(): Promise { - return this.metadata; + return this.storage.getMetadata(); } + + view(lane: string): SessionTree { + if (lane === "main") return this; + return { + getLeafId: () => this.getLeafIdForLane(lane), + getEntry: (id) => this.getEntry(id), + getStats: () => this.getStats(), + getName: () => this.getName(), + setName: (name) => this.setName(name), + getLabel: (targetId) => this.getLabel(targetId), + setLabel: (targetId, label) => this.setLabel(targetId, label), + findEntries: (query) => this.queryEntries(query), + findEntry: async (query = {}) => (await this.queryEntries(query, 1))[0], + findEntriesOnBranch: (query) => this.queryBranchEntries(lane, query), + findEntryOnBranch: async (query = {}) => (await this.queryBranchEntries(lane, query, 1))[0], + appendMessage: (message) => this.appendMessageToLane(lane, message), + appendCustomEntry: (customType, data) => this.appendCustomEntryToLane(lane, customType, data), + }; + } + async getLeafId(): Promise { - return this.leafId; + return this.getLeafIdForLane("main"); } - async getEntry(id: string): Promise { - return this.storage.readEntry(id); + + async getEntry(id: string): Promise { + return this.storage.getEntry(id); } - async getEntries(options?: SessionEntryCursorOptions): Promise { - return [...(await this.storage.readEntries(options))]; + + async getStats(): Promise { + return this.storage.getStats(); } - async getBranch(fromId?: string | null): Promise { - return [...(await this.storage.readPathToRootOrCompaction(fromId === undefined ? this.leafId : fromId))]; + async getName(): Promise { + return this.storage.getName(); } - async findEntriesOnBranch(query: SessionBranchQuery = {}): Promise { - return [ - ...(await this.storage.findEntriesOnBranch({ - ...query, - start: query.start === undefined ? this.leafId : query.start, - })), - ]; + async setName(name: string): Promise { + await this.storage.setName(name); } - async findEntryOnBranch(query: SessionBranchQuery = {}): Promise { - return (await this.findEntriesOnBranch({ ...query, limit: 1 }))[0]; + async getLabel(targetId: string): Promise { + return this.storage.getLabel(targetId); } - async buildContextEntries(options: SessionContextBuildOptions = {}): Promise { - return buildContextEntries(await this.getBranch(), this.mergeContextBuildOptions(options)); + async setLabel(targetId: string, label: string | undefined): Promise { + await this.storage.setLabel(targetId, label); } - async buildContext(options: SessionContextBuildOptions = {}): Promise { - return buildSessionContext(await this.getBranch(), this.mergeContextBuildOptions(options)); + async findEntries(query?: EntryQuery): Promise { + return this.queryEntries(query); } - private mergeContextBuildOptions(options: SessionContextBuildOptions): SessionContextBuildOptions { - return { - entryTransforms: [...(this.contextBuildOptions.entryTransforms ?? []), ...(options.entryTransforms ?? [])], - entryProjectors: { - ...(this.contextBuildOptions.entryProjectors ?? {}), - ...(options.entryProjectors ?? {}), - }, - }; + async findEntry(query: EntryQuery = {}): Promise { + return (await this.queryEntries(query, 1))[0]; } - async getLabel(id: string): Promise { - return this.storage.getLabel(id); + async findEntriesOnBranch(query?: EntryQuery & BranchBounds): Promise { + return this.queryBranchEntries("main", query); } - async getSessionStats(): Promise { - return this.storage.getStats(); + + async findEntryOnBranch(query: EntryQuery & BranchBounds = {}): Promise { + return (await this.queryBranchEntries("main", query, 1))[0]; } - async getSessionName(): Promise { - return this.storage.getName(); + + async appendMessage(message: AgentMessage): Promise { + return this.appendMessageToLane("main", message); } - private async createEntryId(): Promise { - for (let i = 0; i < 100; i++) { - const id = uuidv7().slice(-8); - if (!(await this.getEntry(id))) return id; - } - return uuidv7(); - } - - private enqueueAppend( - createEntry: (base: Pick) => TEntry, - ): Promise { - const commit = this.appendTail.then(async () => { - const entry = createEntry({ - id: await this.createEntryId(), - parentId: this.leafId, - timestamp: new Date().toISOString(), - }); - await this.storage.appendEntry(entry); - this.leafId = entry.type === "leaf" ? entry.targetId : entry.id; - return entry; - }); - this.appendTail = commit.then( - () => undefined, - () => undefined, - ); - return commit; + async appendCustomEntry(customType: string, data?: unknown): Promise { + return this.appendCustomEntryToLane("main", customType, data); } - private async setLeafId(leafId: string | null): Promise { - if (leafId !== null && !(await this.getEntry(leafId))) { - throw new SessionError("not_found", `Entry ${leafId} not found`); - } - return this.enqueueAppend((base) => { - return { ...base, type: "leaf", targetId: leafId }; - }); + async getLanes(): Promise { + return this.storage.getLanes(); } - private async appendTypedEntry( - createEntry: (base: Pick) => TEntry, - ): Promise { - return (await this.enqueueAppend(createEntry)).id; + async createLane(lane: string, at: string | null): Promise { + await this.storage.createLane(lane, at); } - async appendMessage(message: AgentMessage): Promise { - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "message", - message, - }) satisfies MessageEntry, - ); + async moveLane(lane: string, to: string | null): Promise { + await this.storage.moveLane(lane, to); } - async appendThinkingLevelChange(thinkingLevel: string): Promise { - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "thinking_level_change", - thinkingLevel, - }) satisfies ThinkingLevelChangeEntry, - ); + async appendEntry(entry: ProvisionedEntry, lane: string): Promise { + return this.commitEntry(entry, lane); } - async appendModelChange(provider: string, modelId: string): Promise { - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "model_change", - provider, - modelId, - }) satisfies ModelChangeEntry, - ); + async appendRecord( + record: TNewRecord, + ): Promise>; + async appendRecord(record: NewRecord): Promise { + return this.commitRecord(record); } - async appendActiveToolsChange(activeToolNames: string[]): Promise { - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "active_tools_change", - activeToolNames: [...activeToolNames], - }) satisfies ActiveToolsChangeEntry, - ); + async findRecords( + query: RecordQuery & { type: K }, + ): Promise[]>; + async findRecords(query?: RecordQuery): Promise; + async findRecords(query?: RecordQuery): Promise { + return this.queryRecords(query); } - async appendCompaction( - summary: string, - firstKeptEntryId: string | undefined, - tokensBefore: number, - details?: T, - fromHook?: boolean, - usage?: Usage, - retainedTail?: AgentMessage[], - ): Promise { - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "compaction", - summary, - firstKeptEntryId, - tokensBefore, - retainedTail, - details, - usage, - fromHook, - }) satisfies CompactionEntry, - ); + async getLog(options?: LogOptions): Promise { + return this.queryLog(options); } - async appendCustomEntry(customType: string, data?: unknown): Promise { - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "custom", - customType, - data, - }) satisfies CustomEntry, - ); + private async getLeafIdForLane(lane: string): Promise { + const pointer = (await this.storage.getLanes()).find((candidate) => candidate.lane === lane); + if (!pointer) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); + return pointer.leafId; } - async appendCustomMessageEntry( - customType: string, - content: string | (TextContent | ImageContent)[], - display: boolean, - details?: T, - ): Promise { - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "custom_message", - customType, - content, - display, - details, - }) satisfies CustomMessageEntry, - ); + private async queryEntries(query: EntryQuery = {}, resultLimit = query.limit): Promise { + assertValidLimit(query.limit); + assertValidCursor(query.cursor?.afterSeq); + return this.storage.findEntries(resultLimit === query.limit ? query : { ...query, limit: resultLimit }); } - async appendLabel(targetId: string, label: string | undefined): Promise { - if (!(await this.getEntry(targetId))) { - throw new SessionError("not_found", `Entry ${targetId} not found`); - } - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "label", - targetId, - label, - }) satisfies LabelEntry, - ); + private async queryBranchEntries( + lane: string, + query: EntryQuery & BranchBounds = {}, + resultLimit = query.limit, + ): Promise { + assertValidLimit(query.limit); + assertValidCursor(query.cursor?.afterSeq); + const start = query.start ?? (await this.getLeafIdForLane(lane)); + if (start === null) return []; + const storageQuery = resultLimit === query.limit ? query : { ...query, limit: resultLimit }; + return this.storage.findEntriesOnBranch({ ...storageQuery, start }); } - async appendSessionName(name: string): Promise { - const sanitizedName = name.replace(/[\r\n]+/g, " ").trim(); - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "session_info", - name: sanitizedName, - }) satisfies SessionInfoEntry, - ); + private async queryRecords(query: RecordQuery = {}): Promise { + assertValidLimit(query.limit); + assertValidCursor(query.afterSeq); + return this.storage.findRecords(query); } - async moveTo( - entryId: string | null, - summary?: { summary: string; details?: unknown; usage?: Usage; fromHook?: boolean }, - ): Promise { - if (entryId !== null && !(await this.getEntry(entryId))) { - throw new SessionError("not_found", `Entry ${entryId} not found`); - } - await this.setLeafId(entryId); - if (!summary) return undefined; - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "branch_summary", - fromId: entryId ?? "root", - summary: summary.summary, - details: summary.details, - usage: summary.usage, - fromHook: summary.fromHook, - }) satisfies BranchSummaryEntry, + private async queryLog(options: LogOptions = {}): Promise { + assertValidLimit(options.limit); + assertValidCursor(options.afterSeq); + return this.storage.getLog(options); + } + + private async appendMessageToLane(lane: string, message: AgentMessage): Promise { + const entry = await this.commitEntry({ type: "message", id: this.idGenerator.next(), message }, lane); + return entry.id; + } + + private async appendCustomEntryToLane(lane: string, customType: string, data?: unknown): Promise { + const entry = await this.commitEntry( + data === undefined + ? { type: "custom", id: this.idGenerator.next(), customType } + : { type: "custom", id: this.idGenerator.next(), customType, data }, + lane, ); + return entry.id; } -} -/** Wraps an opened storage connection for a SessionRepository implementation. */ -export async function createSession( - storage: SessionStorage, - contextBuildOptions: SessionContextBuildOptions = {}, -): Promise> { - return new Session(storage, (await storage.readHead()).leafId, contextBuildOptions); + private async commitEntry(entry: ProvisionedEntry, lane: string): Promise { + assertJsonSerializable(entry); + return this.storage.appendEntry(entry, lane); + } + + private async commitRecord( + record: TNewRecord, + ): Promise> { + assertJsonSerializable(record); + return this.storage.appendRecord(record) as unknown as Promise< + TNewRecord & Pick + >; + } } diff --git a/packages/agent/src/harness/experimental/session/testing/conformance.ts b/packages/agent/src/harness/session/testing/conformance.ts similarity index 99% rename from packages/agent/src/harness/experimental/session/testing/conformance.ts rename to packages/agent/src/harness/session/testing/conformance.ts index 62d76627ad0..5d9e0e32305 100644 --- a/packages/agent/src/harness/experimental/session/testing/conformance.ts +++ b/packages/agent/src/harness/session/testing/conformance.ts @@ -1,5 +1,5 @@ import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict"; -import type { AgentMessage } from "../../../../types.ts"; +import type { AgentMessage } from "../../../types.ts"; import type { CustomEntry, Entry, diff --git a/packages/agent/src/harness/experimental/session/testing/index.ts b/packages/agent/src/harness/session/testing/index.ts similarity index 100% rename from packages/agent/src/harness/experimental/session/testing/index.ts rename to packages/agent/src/harness/session/testing/index.ts diff --git a/packages/agent/src/harness/experimental/session/testing/types.ts b/packages/agent/src/harness/session/testing/types.ts similarity index 100% rename from packages/agent/src/harness/experimental/session/testing/types.ts rename to packages/agent/src/harness/session/testing/types.ts diff --git a/packages/agent/src/harness/experimental/session/types.ts b/packages/agent/src/harness/session/types.ts similarity index 97% rename from packages/agent/src/harness/experimental/session/types.ts rename to packages/agent/src/harness/session/types.ts index 066e960b07a..2e466b950f2 100644 --- a/packages/agent/src/harness/experimental/session/types.ts +++ b/packages/agent/src/harness/session/types.ts @@ -1,6 +1,6 @@ import type { StopReason, Usage } from "@earendil-works/pi-ai"; -import "../../messages.ts"; -import type { AgentMessage } from "../../../types.ts"; +import "../messages.ts"; +import type { AgentMessage } from "../../types.ts"; import type { Session } from "./session.ts"; export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; @@ -31,12 +31,12 @@ export interface ModelChangeEntry extends EntryBase { modelId: string; } -export interface ThinkingLevelChangeEntry extends EntryBase { +export interface ThinkingLevelEntry extends EntryBase { type: "thinking_level_change"; thinkingLevel: string; } -export interface ActiveToolsChangeEntry extends EntryBase { +export interface ActiveToolsEntry extends EntryBase { type: "active_tools_change"; activeToolNames: string[]; } @@ -67,8 +67,8 @@ export interface CustomEntry extends EntryBase { export type Entry = | MessageEntry | ModelChangeEntry - | ThinkingLevelChangeEntry - | ActiveToolsChangeEntry + | ThinkingLevelEntry + | ActiveToolsEntry | CompactionEntry | BranchSummaryEntry | CustomEntry; @@ -115,7 +115,6 @@ export interface OperationStartedRecord extends RecordBase { export interface AbortRequestedRecord extends RecordBase { type: "abort_requested"; runId: string; - reason: "user" | "shutdown"; } export interface OperationFinishedRecord extends RecordBase { diff --git a/packages/agent/src/harness/types.ts b/packages/agent/src/harness/types.ts index 868f9b55b38..2e1062ef9fa 100644 --- a/packages/agent/src/harness/types.ts +++ b/packages/agent/src/harness/types.ts @@ -1,24 +1,6 @@ -import type { - ImageContent, - Model, - Models, - RetryPolicy, - SimpleStreamOptions, - TextContent, - Transport, - Usage, -} from "@earendil-works/pi-ai"; +import type { SimpleStreamOptions, Transport } from "@earendil-works/pi-ai"; import type { Static, TSchema } from "typebox"; -import type { - AgentEvent, - AgentMessage, - AgentTool, - AgentToolResult, - AgentToolUpdateCallback, - QueueMode, - ThinkingLevel, -} from "../index.ts"; -import type { Session } from "./session/session.ts"; +import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "../types.ts"; /** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */ export type Result = { ok: true; value: TValue } | { ok: false; error: TError }; @@ -194,7 +176,7 @@ export class ExecutionError extends Error { } /** Stable compaction error codes returned by compaction helpers. */ -export type CompactionErrorCode = "aborted" | "summarization_failed" | "invalid_session" | "unknown"; +export type CompactionErrorCode = "aborted" | "summarization_failed"; /** Error returned by compaction helpers. */ export class CompactionError extends Error { @@ -209,7 +191,7 @@ export class CompactionError extends Error { } /** Stable branch-summary error codes returned by branch summarization helpers. */ -export type BranchSummaryErrorCode = "aborted" | "summarization_failed" | "invalid_session"; +export type BranchSummaryErrorCode = "aborted" | "summarization_failed"; /** Error returned by branch summarization helpers. */ export class BranchSummaryError extends Error { @@ -223,48 +205,6 @@ export class BranchSummaryError extends Error { } } -export type SessionErrorCode = - | "not_found" - | "invalid_session" - | "invalid_entry" - | "invalid_fork_target" - | "storage" - | "unknown"; - -/** Error thrown by session storage, repositories, and session tree operations. */ -export class SessionError extends Error { - /** Session subsystem error code. */ - public code: SessionErrorCode; - - constructor(code: SessionErrorCode, message: string, cause?: Error) { - super(message, cause === undefined ? undefined : { cause }); - this.name = "SessionError"; - this.code = code; - } -} - -export type AgentHarnessErrorCode = - | "busy" - | "invalid_state" - | "invalid_argument" - | "session" - | "hook" - | "auth" - | "compaction" - | "branch_summary" - | "unknown"; - -/** Public AgentHarness failure with a stable top-level classification. */ -export class AgentHarnessError extends Error { - public code: AgentHarnessErrorCode; - - constructor(code: AgentHarnessErrorCode, message: string, cause?: Error) { - super(message, cause === undefined ? undefined : { cause }); - this.name = "AgentHarnessError"; - this.code = code; - } -} - /** Metadata for one filesystem object in a {@link FileSystem}. */ export interface FileInfo { /** Basename of {@link path}. */ @@ -371,618 +311,3 @@ export interface Shell { /** Filesystem and process execution environment used by the harness. */ export interface ExecutionEnv extends FileSystem, Shell {} - -export interface SessionTreeEntryBase { - type: string; - id: string; - parentId: string | null; - timestamp: string; -} - -export interface MessageEntry extends SessionTreeEntryBase { - type: "message"; - message: AgentMessage; -} - -export interface ThinkingLevelChangeEntry extends SessionTreeEntryBase { - type: "thinking_level_change"; - thinkingLevel: string; -} - -export interface ModelChangeEntry extends SessionTreeEntryBase { - type: "model_change"; - provider: string; - modelId: string; -} - -export interface ActiveToolsChangeEntry extends SessionTreeEntryBase { - type: "active_tools_change"; - activeToolNames: string[]; -} - -export interface CompactionEntry extends SessionTreeEntryBase { - type: "compaction"; - summary: string; - firstKeptEntryId?: string; - tokensBefore: number; - retainedTail?: AgentMessage[]; - details?: T; - usage?: Usage; - fromHook?: boolean; -} - -export interface BranchSummaryEntry extends SessionTreeEntryBase { - type: "branch_summary"; - fromId: string; - summary: string; - details?: T; - usage?: Usage; - fromHook?: boolean; -} - -export interface CustomEntry extends SessionTreeEntryBase { - type: "custom"; - customType: string; - data?: T; -} - -export interface CustomMessageEntry extends SessionTreeEntryBase { - type: "custom_message"; - customType: string; - content: string | (TextContent | ImageContent)[]; - details?: T; - display: boolean; -} - -export interface LabelEntry extends SessionTreeEntryBase { - type: "label"; - targetId: string; - label: string | undefined; -} - -export interface SessionInfoEntry extends SessionTreeEntryBase { - type: "session_info"; // legacy name, kept for backwards compatibility - name?: string; -} - -export interface LeafEntry extends SessionTreeEntryBase { - type: "leaf"; - targetId: string | null; -} - -export type SessionTreeEntry = - | MessageEntry - | ThinkingLevelChangeEntry - | ModelChangeEntry - | ActiveToolsChangeEntry - | CompactionEntry - | BranchSummaryEntry - | CustomEntry - | CustomMessageEntry - | LabelEntry - | SessionInfoEntry - | LeafEntry; - -export interface SessionContext { - messages: AgentMessage[]; - thinkingLevel: string; - model: { provider: string; modelId: string } | null; - activeToolNames: string[] | null; -} - -export interface SessionStats { - messageCount: number; - cachedTokens: number; - uncachedTokens: number; - totalTokens: number; - costTotal: number; -} - -export interface SessionMetadata { - id: string; - createdAt: string; -} - -export interface JsonlSessionMetadata extends SessionMetadata { - cwd: string; - path: string; - parentSessionPath?: string; - metadata?: Record; -} - -export interface SessionEntryCursorOptions { - /** Number of entries already consumed; reading starts at this zero-based sequence. */ - afterEntrySeq?: number; - limit?: number; -} - -export type { Session } from "./session/session.ts"; - -export interface SessionCreateOptions { - id?: string; -} - -export interface SessionSearchOptions { - text: string; - cwd?: string; -} - -export interface SessionSearchHit { - metadata: TMetadata; - entryId: string; - timestamp: string; - snippet?: string; - score?: number; -} - -/** Owns session search queries. */ -export interface SessionSearch { - search(options: SessionSearchOptions): Promise[]>; -} - -export interface SessionForkOptions { - entryId?: string; - position?: "before" | "at"; - id?: string; -} - -export type SessionForkSelection = - /** Copy all persisted entries in append order. */ - | { kind: "all" } - /** Copy the target's active path, excluding the target; the target must be a user message. */ - | { kind: "before_user_message"; entryId: string } - /** Copy the target's active path, including the target. */ - | { kind: "through_entry"; entryId: string }; - -export interface SessionBranchQuery { - /** Entry where traversal starts. Session defaults this to its active leaf. */ - start?: string | null; - /** Stop after the first matching entry, inclusive. */ - stopAtType?: SessionTreeEntry["type"]; - /** Stop after the matching entry, inclusive. */ - stopAtId?: string; - /** Filter returned entries by type after determining traversal bounds. */ - type?: SessionTreeEntry["type"]; - /** Filter returned custom entries by custom type. */ - customType?: string; - /** Traversal order. Defaults to newest first. */ - order?: "newestFirst" | "oldestFirst"; - /** Maximum number of filtered entries to return. */ - limit?: number; -} - -export interface SessionHead { - leafId: string | null; -} - -/** Complete storage contract for one opened session. Its lifetime is owned by its repository. */ -export interface SessionStorage { - readonly metadata: TMetadata; - /** Rejects with `invalid_session` when a non-null active leaf does not reference a stored entry. */ - readHead(): Promise; - readEntry(id: string): Promise; - readEntries(options?: SessionEntryCursorOptions): Promise; - appendEntry(entry: SessionTreeEntry): Promise; - findEntriesOnBranch(query: SessionBranchQuery & { start: string | null }): Promise; - readPathToRootOrCompaction(leafId: string | null): Promise; - getLabel(id: string): Promise; - getName(): Promise; - getStats(): Promise; -} - -export interface JsonlSessionCreateOptions extends SessionCreateOptions { - cwd: string; - parentSessionPath?: string; - metadata?: Record; -} - -export interface JsonlSessionListOptions { - cwd?: string; -} - -export type AgentHarnessPhase = "idle" | "turn" | "compaction" | "branch_summary" | "retry"; - -export type PendingSessionWrite = SessionTreeEntry extends infer TEntry - ? TEntry extends SessionTreeEntry - ? Omit - : never - : never; - -export interface QueueUpdateEvent { - type: "queue_update"; - steer: AgentMessage[]; - followUp: AgentMessage[]; - nextTurn: AgentMessage[]; -} - -export interface SavePointEvent { - type: "save_point"; - hadPendingMutations: boolean; -} - -export interface AbortEvent { - type: "abort"; - clearedSteer: AgentMessage[]; - clearedFollowUp: AgentMessage[]; -} - -export interface SettledEvent { - type: "settled"; - nextTurnCount: number; -} - -export interface BeforeAgentStartEvent< - TSkill extends Skill = Skill, - TPromptTemplate extends PromptTemplate = PromptTemplate, -> { - type: "before_agent_start"; - prompt: string; - images?: ImageContent[]; - systemPrompt: string; - resources: AgentHarnessResources; -} - -export interface ContextEvent { - type: "context"; - messages: AgentMessage[]; -} - -export interface BeforeProviderRequestEvent { - type: "before_provider_request"; - model: Model; - sessionId: string; - streamOptions: AgentHarnessStreamOptions; -} - -export interface BeforeProviderPayloadEvent { - type: "before_provider_payload"; - model: Model; - payload: unknown; -} - -export interface AfterProviderResponseEvent { - type: "after_provider_response"; - status: number; - headers: Record; -} - -export interface ToolCallEvent { - type: "tool_call"; - toolCallId: string; - toolName: string; - input: Record; -} - -export interface ToolResultEvent { - type: "tool_result"; - toolCallId: string; - toolName: string; - input: Record; - content: Array; - details: unknown; - isError: boolean; - usage?: Usage; -} - -export interface SessionBeforeCompactEvent { - type: "session_before_compact"; - preparation: CompactionPreparation; - branchEntries: SessionTreeEntry[]; - customInstructions?: string; - signal: AbortSignal; -} - -export interface SessionCompactEvent { - type: "session_compact"; - compactionEntry: CompactionEntry; - fromHook: boolean; -} - -export interface SessionBeforeTreeEvent { - type: "session_before_tree"; - preparation: TreePreparation; - signal: AbortSignal; -} - -export interface SessionTreeEvent { - type: "session_tree"; - newLeafId: string | null; - oldLeafId: string | null; - summaryEntry?: BranchSummaryEntry; - fromHook?: boolean; -} - -export interface RetryScheduledEvent { - type: "retry_scheduled"; - operation: "compaction" | "branch_summary"; - attempt: number; - maxAttempts: number; - delayMs: number; - errorMessage: string; -} - -export interface RetryAttemptStartEvent { - type: "retry_attempt_start"; - operation: "compaction" | "branch_summary"; -} - -export interface RetryFinishedEvent { - type: "retry_finished"; - operation: "compaction" | "branch_summary"; -} - -export interface ModelUpdateEvent { - type: "model_update"; - model: Model; - previousModel: Model | undefined; - source: "set" | "restore"; -} - -export interface ThinkingLevelUpdateEvent { - type: "thinking_level_update"; - level: ThinkingLevel; - previousLevel: ThinkingLevel; -} - -export interface ToolsUpdateEvent { - type: "tools_update"; - toolNames: string[]; - previousToolNames: string[]; - activeToolNames: string[]; - previousActiveToolNames: string[]; - source: "set" | "restore"; -} - -export interface ResourcesUpdateEvent< - TSkill extends Skill = Skill, - TPromptTemplate extends PromptTemplate = PromptTemplate, -> { - type: "resources_update"; - resources: AgentHarnessResources; - previousResources: AgentHarnessResources; -} - -export type AgentHarnessOwnEvent< - TSkill extends Skill = Skill, - TPromptTemplate extends PromptTemplate = PromptTemplate, -> = - | QueueUpdateEvent - | SavePointEvent - | AbortEvent - | SettledEvent - | BeforeAgentStartEvent - | ContextEvent - | BeforeProviderRequestEvent - | BeforeProviderPayloadEvent - | AfterProviderResponseEvent - | ToolCallEvent - | ToolResultEvent - | SessionBeforeCompactEvent - | SessionCompactEvent - | SessionBeforeTreeEvent - | SessionTreeEvent - | RetryScheduledEvent - | RetryAttemptStartEvent - | RetryFinishedEvent - | ModelUpdateEvent - | ThinkingLevelUpdateEvent - | ResourcesUpdateEvent - | ToolsUpdateEvent; - -export type AgentHarnessEvent = - | AgentEvent - | AgentHarnessOwnEvent; - -export interface BeforeAgentStartResult { - messages?: AgentMessage[]; - systemPrompt?: string; -} - -export interface ContextResult { - messages: AgentMessage[]; -} - -export interface BeforeProviderRequestResult { - streamOptions?: AgentHarnessStreamOptionsPatch; -} - -export interface BeforeProviderPayloadResult { - payload: unknown; -} - -export interface ToolCallResult { - block?: boolean; - reason?: string; -} - -export interface ToolResultPatch { - content?: Array; - details?: unknown; - isError?: boolean; - usage?: Usage; - terminate?: boolean; -} - -export interface SessionBeforeCompactResult { - cancel?: boolean; - compaction?: CompactResult; -} - -export interface SessionBeforeTreeResult { - cancel?: boolean; - summary?: { - summary: string; - details?: unknown; - /** Usage from the LLM call that generated this summary, if available. */ - usage?: Usage; - }; - customInstructions?: string; - replaceInstructions?: boolean; - label?: string; -} - -export type AgentHarnessEventResultMap = { - before_agent_start: BeforeAgentStartResult | undefined; - context: ContextResult | undefined; - before_provider_request: BeforeProviderRequestResult | undefined; - before_provider_payload: BeforeProviderPayloadResult | undefined; - after_provider_response: undefined; - tool_call: ToolCallResult | undefined; - tool_result: ToolResultPatch | undefined; - session_before_compact: SessionBeforeCompactResult | undefined; - session_compact: undefined; - session_before_tree: SessionBeforeTreeResult | undefined; - session_tree: undefined; - retry_scheduled: undefined; - retry_attempt_start: undefined; - retry_finished: undefined; - model_update: undefined; - thinking_level_update: undefined; - resources_update: undefined; - tools_update: undefined; - queue_update: undefined; - save_point: undefined; - abort: undefined; - settled: undefined; -}; - -export interface AgentHarnessPromptOptions { - images?: ImageContent[]; -} - -export interface AbortResult { - clearedSteer: AgentMessage[]; - clearedFollowUp: AgentMessage[]; -} - -export interface CompactResult { - summary: string; - firstKeptEntryId?: string; - tokensBefore: number; - /** Usage from the LLM call(s) that generated this summary, if available. */ - usage?: Usage; - retainedTail?: AgentMessage[]; - details?: unknown; -} - -export interface NavigateTreeResult { - cancelled: boolean; - editorText?: string; - summaryEntry?: BranchSummaryEntry; -} - -export interface CompactionSettings { - enabled: boolean; - reserveTokens: number; - keepRecentTokens: number; -} - -export interface CompactionPreparation { - firstKeptEntryId: string; - messagesToSummarize: AgentMessage[]; - turnPrefixMessages: AgentMessage[]; - retainedTail: AgentMessage[]; - isSplitTurn: boolean; - tokensBefore: number; - previousSummary?: string; - fileOps: FileOperations; - settings: CompactionSettings; -} - -export interface FileOperations { - read: Set; - written: Set; - edited: Set; -} - -export interface TreePreparation { - targetId: string; - oldLeafId: string | null; - commonAncestorId: string | null; - entriesToSummarize: SessionTreeEntry[]; - userWantsSummary: boolean; - customInstructions?: string; - replaceInstructions?: boolean; - label?: string; -} - -export interface GenerateBranchSummaryOptions { - model: Model; - apiKey: string; - headers?: Record; - signal: AbortSignal; - customInstructions?: string; - replaceInstructions?: boolean; - reserveTokens?: number; -} - -export interface BranchSummaryResult { - summary: string; - usage?: Usage; - readFiles: string[]; - modifiedFiles: string[]; -} - -export type AgentHarnessSystemPrompt< - TContext extends object | undefined = undefined, - TSkill extends Skill = Skill, - TPromptTemplate extends PromptTemplate = PromptTemplate, - TTool extends AgentHarnessTool = AgentHarnessTool, -> = - | string - | ((context: { - session: Session; - model: Model; - thinkingLevel: ThinkingLevel; - activeTools: TTool[]; - resources: AgentHarnessResources; - }) => string | Promise); - -interface AgentHarnessOptionsBase< - TContext extends object | undefined, - TSkill extends Skill, - TPromptTemplate extends PromptTemplate, - TTool extends AgentHarnessTool, -> { - session: Session; - /** - * Provider collection used for all model requests (turn streaming, - * compaction, branch summarization). Auth resolves through the providers' - * auth. - */ - models: Models; - tools?: TTool[]; - /** - * Concrete resources available to explicit invocation methods and system-prompt callbacks. - * Applications own loading/reloading resources and should call `setResources()` with new values. - */ - resources?: AgentHarnessResources; - systemPrompt?: AgentHarnessSystemPrompt; - /** Curated stream/provider request options. Snapshotted at turn start. */ - streamOptions?: AgentHarnessStreamOptions; - /** Optional retry policy for generated compaction and branch-summary requests. */ - retry?: RetryPolicy; - model: Model; - thinkingLevel?: ThinkingLevel; - activeToolNames?: string[]; - steeringMode?: QueueMode; - followUpMode?: QueueMode; -} - -export type AgentHarnessOptions< - TContext extends object | undefined = undefined, - TSkill extends Skill = Skill, - TPromptTemplate extends PromptTemplate = PromptTemplate, - TTool extends AgentHarnessTool = AgentHarnessTool, -> = AgentHarnessOptionsBase & - ([TContext] extends [undefined] - ? { - /** Context-free harnesses do not need a tool context. */ - toolContext?: undefined; - } - : { - /** Static context or zero-argument context provider resolved for each turn snapshot. */ - toolContext: AgentHarnessToolContextSource; - }); - -export type { AgentHarness } from "./agent-harness.ts"; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 0e47b1dbe07..216bda49b59 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -7,12 +7,18 @@ export * from "./harness/agent-harness.ts"; export { type BranchPreparation, type BranchSummaryDetails, + type BranchSummaryResult, type CollectEntriesResult, collectEntriesForBranchSummary, + type FileOperations, + type GenerateBranchSummaryOptions, generateBranchSummary, prepareBranchEntries, } from "./harness/compaction/branch-summarization.ts"; export { + type CompactionPreparation, + type CompactionSettings, + type CompactResult, calculateContextTokens, compact, DEFAULT_COMPACTION_SETTINGS, @@ -29,34 +35,41 @@ export { } from "./harness/compaction/compaction.ts"; export * from "./harness/messages.ts"; export * from "./harness/prompt-templates.ts"; -export { - JsonlSessionRepository, - type JsonlSessionRepositoryFileSystem, - type JsonlSessionRepositoryOptions, - loadJsonlSessionMetadata, -} from "./harness/session/jsonl-repo.ts"; -export { - type InMemorySessionCreateOptions, - InMemorySessionRepository, - type InMemorySessionRepositoryOptions, -} from "./harness/session/memory-repo.ts"; -export * from "./harness/session/repository.ts"; +// Harness +export * from "./harness/result.ts"; +export * from "./harness/session/index.ts"; export * from "./harness/session/search.ts"; -export { - buildContextEntries, - buildSessionContext, - type ContextEntryTransform, - type CustomEntryContextMessageProjector, - createSession, - defaultContextEntryTransform, - type SessionContextBuildOptions, - sessionEntryToContextMessages, -} from "./harness/session/session.ts"; export * from "./harness/skills.ts"; export * from "./harness/system-prompt.ts"; export * from "./harness/tools/index.ts"; -// Harness -export * from "./harness/types.ts"; +export { + type AgentHarnessResources, + type AgentHarnessStreamOptions, + type AgentHarnessStreamOptionsPatch, + type AgentHarnessTool, + type AgentHarnessToolContextSource, + BranchSummaryError, + type BranchSummaryErrorCode, + CompactionError, + type CompactionErrorCode, + type ExecutionEnv, + ExecutionError, + type ExecutionErrorCode, + err, + FileError, + type FileErrorCode, + type FileInfo, + type FileKind, + type FileSystem, + getOrThrow, + getOrUndefined, + ok, + type PromptTemplate, + type Shell, + type ShellExecOptions, + type Skill, + toError, +} from "./harness/types.ts"; export * from "./harness/utils/shell-output.ts"; export * from "./harness/utils/truncate.ts"; // Proxy utilities diff --git a/packages/agent/test/harness/agent-harness-scaffold.test.ts b/packages/agent/test/harness/agent-harness-scaffold.test.ts new file mode 100644 index 00000000000..8aa57a2e9c6 --- /dev/null +++ b/packages/agent/test/harness/agent-harness-scaffold.test.ts @@ -0,0 +1,34 @@ +import { createModels } from "@earendil-works/pi-ai"; +import { getModel } from "@earendil-works/pi-ai/compat"; +import { describe, expect, it } from "vitest"; +import { AgentHarness, HarnessClosed, HarnessNotImplemented } from "../../src/harness/agent-harness.ts"; +import { InMemorySessionStorage, Session } from "../../src/harness/session/index.ts"; + +describe("AgentHarness v2 scaffold", () => { + it("opens the main lane over a v4 session", async () => { + const session = new Session(new InMemorySessionStorage({ id: "session", createdAt: 1 })); + const { harness, suspended } = await AgentHarness.create({ + session, + models: createModels(), + model: getModel("google", "gemini-2.5-flash"), + }); + + expect(suspended).toEqual([]); + expect(harness.name).toBe("main"); + expect(await harness.getLeafId()).toBeNull(); + expect(await harness.lanes()).toEqual([{ name: "main", leafId: null, operation: null }]); + }); + + it("rejects unimplemented operations explicitly", async () => { + const session = new Session(new InMemorySessionStorage({ id: "session", createdAt: 1 })); + const { harness } = await AgentHarness.create({ + session, + models: createModels(), + model: getModel("google", "gemini-2.5-flash"), + }); + + await expect(harness.prompt("hello")).rejects.toBeInstanceOf(HarnessNotImplemented); + await harness.close(); + await expect(harness.prompt("hello")).rejects.toBeInstanceOf(HarnessClosed); + }); +}); diff --git a/packages/agent/test/harness/agent-harness-stream.test.ts b/packages/agent/test/harness/agent-harness-stream.test.ts deleted file mode 100644 index 0b1e5402f65..00000000000 --- a/packages/agent/test/harness/agent-harness-stream.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { - createModels, - type FauxProviderHandle, - fauxAssistantMessage, - fauxProvider, - fauxToolCall, - type StreamOptions, -} from "@earendil-works/pi-ai"; -import { describe, expect, it } from "vitest"; -import { AgentHarness } from "../../src/harness/agent-harness.ts"; -import type { AgentHarnessOptions } from "../../src/harness/types.ts"; -import { calculateTool } from "../utils/calculate.ts"; -import { createInMemorySession } from "./session-test-utils.ts"; - -/** Shared collection; each faux provider gets a unique id so coexisting fakes route correctly. */ -const models = createModels(); -let fauxCount = 0; - -function newFaux(): FauxProviderHandle { - const faux = fauxProvider({ provider: `faux-${++fauxCount}` }); - models.setProvider(faux.provider); - return faux; -} - -function createHarness(options: AgentHarnessOptions): AgentHarness { - return new AgentHarness(options); -} - -function captureOptions(options: StreamOptions | undefined): StreamOptions { - return { - ...options, - headers: options?.headers ? { ...options.headers } : undefined, - metadata: options?.metadata ? { ...options.metadata } : undefined, - }; -} - -describe("AgentHarness stream configuration", () => { - it("snapshots stream options before provider request hooks", async () => { - let capturedOptions: StreamOptions | undefined; - const registration = newFaux(); - registration.setResponses([ - (_context, options) => { - capturedOptions = options; - return fauxAssistantMessage("ok"); - }, - ]); - - const session = await createInMemorySession("session-1"); - const harness = createHarness({ - models, - session, - model: registration.getModel(), - streamOptions: { - timeoutMs: 1000, - maxRetries: 2, - maxRetryDelayMs: 3000, - headers: { "x-base": "base" }, - metadata: { base: true }, - cacheRetention: "none", - }, - }); - - harness.on("before_provider_request", (event) => { - expect(event.sessionId).toBe("session-1"); - expect(event.streamOptions.headers).toEqual({ "x-base": "base" }); - return { - streamOptions: { - headers: { "x-hook": "hook" }, - metadata: { hook: true }, - }, - }; - }); - - await harness.prompt("hello"); - - expect(capturedOptions).toMatchObject({ - timeoutMs: 1000, - maxRetries: 2, - maxRetryDelayMs: 3000, - sessionId: "session-1", - cacheRetention: "none", - }); - expect(capturedOptions?.headers).toEqual({ "x-base": "base", "x-hook": "hook" }); - expect(capturedOptions?.metadata).toEqual({ base: true, hook: true }); - }); - - it("chains provider request patches and supports deletion semantics", async () => { - let capturedOptions: StreamOptions | undefined; - const registration = newFaux(); - registration.setResponses([ - (_context, options) => { - capturedOptions = options; - return fauxAssistantMessage("ok"); - }, - ]); - - const harness = createHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - streamOptions: { - timeoutMs: 1000, - maxRetries: 2, - headers: { keep: "base", remove: "base" }, - metadata: { keep: "base", remove: "base" }, - }, - }); - - harness.on("before_provider_request", (event) => { - expect(event.streamOptions.headers).toEqual({ keep: "base", remove: "base" }); - return { - streamOptions: { - headers: { first: "1", remove: undefined }, - metadata: { first: 1, remove: undefined }, - }, - }; - }); - harness.on("before_provider_request", (event) => { - expect(event.streamOptions.headers).toEqual({ keep: "base", first: "1" }); - expect(event.streamOptions.metadata).toEqual({ keep: "base", first: 1 }); - return { - streamOptions: { - timeoutMs: undefined, - headers: { second: "2" }, - metadata: undefined, - }, - }; - }); - - await harness.prompt("hello"); - - expect(capturedOptions?.timeoutMs).toBeUndefined(); - expect(capturedOptions?.maxRetries).toBe(2); - expect(capturedOptions?.headers).toEqual({ keep: "base", first: "1", second: "2" }); - expect(capturedOptions?.metadata).toBeUndefined(); - }); - - it("uses updated stream options for save-point snapshots without mutating the active request", async () => { - const capturedOptions: StreamOptions[] = []; - const registration = newFaux(); - registration.setResponses([ - (_context, options) => { - capturedOptions.push(captureOptions(options)); - return fauxAssistantMessage(fauxToolCall("calculate", { expression: "1 + 1" }, { id: "call-1" }), { - stopReason: "toolUse", - }); - }, - (_context, options) => { - capturedOptions.push(captureOptions(options)); - return fauxAssistantMessage("done"); - }, - ]); - - const harness = createHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - tools: [calculateTool], - streamOptions: { timeoutMs: 1000, headers: { turn: "first" } }, - }); - - harness.subscribe((event) => { - if (event.type === "tool_execution_start") { - harness.setStreamOptions({ timeoutMs: 2000, headers: { turn: "second" } }); - } - }); - - await harness.prompt("hello"); - - expect(capturedOptions).toHaveLength(2); - expect(capturedOptions[0].timeoutMs).toBe(1000); - expect(capturedOptions[0].headers).toEqual({ turn: "first" }); - expect(capturedOptions[1].timeoutMs).toBe(2000); - expect(capturedOptions[1].headers).toEqual({ turn: "second" }); - }); - - it("chains provider payload hooks", async () => { - const seenPayloads: unknown[] = []; - let finalPayload: unknown; - const registration = newFaux(); - registration.setResponses([ - async (_context, options, _state, model) => { - finalPayload = await options?.onPayload?.({ steps: ["provider"] }, model); - return fauxAssistantMessage("ok"); - }, - ]); - - const harness = createHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - }); - - harness.on("before_provider_payload", (event) => { - seenPayloads.push(event.payload); - return { payload: { steps: ["provider", "first"] } }; - }); - harness.on("before_provider_payload", (event) => { - seenPayloads.push(event.payload); - return { payload: { steps: ["provider", "first", "second"] } }; - }); - - await harness.prompt("hello"); - - expect(seenPayloads).toEqual([{ steps: ["provider"] }, { steps: ["provider", "first"] }]); - expect(finalPayload).toEqual({ steps: ["provider", "first", "second"] }); - }); -}); diff --git a/packages/agent/test/harness/agent-harness.test.ts b/packages/agent/test/harness/agent-harness.test.ts deleted file mode 100644 index cf68bb97a54..00000000000 --- a/packages/agent/test/harness/agent-harness.test.ts +++ /dev/null @@ -1,1280 +0,0 @@ -import { - createModels, - type FauxProviderHandle, - fauxAssistantMessage, - fauxProvider, - fauxToolCall, - type RegisterFauxProviderOptions, - type Usage, -} from "@earendil-works/pi-ai"; -import { getModel } from "@earendil-works/pi-ai/compat"; -import { describe, expect, it } from "vitest"; -import { AgentHarness } from "../../src/harness/agent-harness.ts"; -import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { InMemorySessionBackend } from "../../src/harness/session/memory-repo.ts"; -import { createSession, type Session } from "../../src/harness/session/session.ts"; -import type { AgentHarnessTool, PromptTemplate, Skill } from "../../src/harness/types.ts"; -import type { AgentMessage, AgentTool } from "../../src/types.ts"; -import { calculateTool, createCalculateToolWithUsage } from "../utils/calculate.ts"; -import { getCurrentTimeTool } from "../utils/get-current-time.ts"; -import { createInMemorySession } from "./session-test-utils.ts"; - -interface AppSkill extends Skill { - source: "project" | "user"; -} - -interface AppPromptTemplate extends PromptTemplate { - source: "project" | "user"; -} - -/** Shared collection; each faux provider gets a unique id so coexisting fakes route correctly. */ -const models = createModels(); -let fauxCount = 0; - -function newFaux(options: RegisterFauxProviderOptions = {}): FauxProviderHandle { - const faux = fauxProvider({ provider: `faux-${++fauxCount}`, ...options }); - models.setProvider(faux.provider); - return faux; -} - -function textFromUserMessages(messages: Array<{ role: string; content: unknown }>): string[] { - return messages.flatMap((message) => { - if (message.role !== "user") return []; - if (typeof message.content === "string") return [message.content]; - if (!Array.isArray(message.content)) return []; - return message.content.flatMap((part) => { - if (!part || typeof part !== "object" || !("type" in part) || part.type !== "text") return []; - return "text" in part && typeof part.text === "string" ? [part.text] : []; - }); - }); -} - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve = () => {}; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -} - -function getReasoning(options: unknown): unknown { - if (!options || typeof options !== "object" || !("reasoning" in options)) return undefined; - return options.reasoning; -} - -function createUsage(input: number, output: number, cacheRead = 0, cacheWrite = 0): Usage { - return { - input, - output, - cacheRead, - cacheWrite, - totalTokens: input + output + cacheRead + cacheWrite, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }; -} - -function createUserMessage(text: string): AgentMessage { - return { role: "user", content: [{ type: "text", text }], timestamp: Date.now() }; -} - -function createAssistantMessage(text: string): AgentMessage { - return { - role: "assistant", - content: [{ type: "text", text }], - api: "faux", - provider: "faux", - model: "faux-1", - usage: createUsage(100, 50), - stopReason: "stop", - timestamp: Date.now(), - }; -} - -async function createBlockingSession(expectedWrites: number): Promise<{ - session: Session; - allWritesStarted: ReturnType; - releaseWrites: ReturnType; - getEntries(): ReturnType; -}> { - const source = new InMemorySessionBackend(); - const allWritesStarted = deferred(); - const releaseWrites = deferred(); - let writesStarted = 0; - const blockWrites = async (storage: Awaited>) => ({ - ...storage, - async appendEntry(entry: Parameters[0]) { - writesStarted++; - if (writesStarted === expectedWrites) allWritesStarted.resolve(); - await releaseWrites.promise; - await storage.appendEntry(entry); - }, - }); - const blockingBackend: Pick< - InMemorySessionBackend, - "create" | "open" | "list" | "delete" | "fork" | typeof Symbol.asyncDispose - > = { - create: async (options) => blockWrites(await source.create(options)), - open: async (metadata) => blockWrites(await source.open(metadata)), - list: () => source.list(), - delete: (metadata) => source.delete(metadata), - fork: async (metadata, options, selection) => blockWrites(await source.fork(metadata, options, selection)), - [Symbol.asyncDispose]: () => source[Symbol.asyncDispose](), - }; - const session = await createSession(await blockingBackend.create({})); - return { - session, - allWritesStarted, - releaseWrites, - getEntries: () => session.getEntries(), - }; -} - -describe("AgentHarness", () => { - it("constructs directly and exposes queue modes", async () => { - const session = await createInMemorySession(); - const initialModel = getModel("anthropic", "claude-sonnet-4-5"); - const harness = new AgentHarness({ - models, - session, - model: initialModel, - thinkingLevel: "high", - systemPrompt: "You are helpful.", - steeringMode: "all", - followUpMode: "all", - }); - expect(harness.getModel()).toBe(initialModel); - expect(harness.getThinkingLevel()).toBe("high"); - expect(harness.getSteeringMode()).toBe("all"); - expect(harness.getFollowUpMode()).toBe("all"); - harness.setSteeringMode("one-at-a-time"); - harness.setFollowUpMode("one-at-a-time"); - expect(harness.getSteeringMode()).toBe("one-at-a-time"); - expect(harness.getFollowUpMode()).toBe("one-at-a-time"); - }); - - it("rejects waiting before shutdown is requested", async () => { - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: getModel("anthropic", "claude-sonnet-4-5"), - }); - - await expect(harness.waitForShutdown()).rejects.toMatchObject({ - code: "invalid_state", - message: "Shutdown has not been requested", - }); - }); - - it("shuts down active work permanently and idempotently", async () => { - const registration = newFaux(); - const entered = deferred(); - const release = deferred(); - let signal: AbortSignal | undefined; - registration.setResponses([ - async (_context, options) => { - signal = options?.signal; - entered.resolve(); - await release.promise; - return fauxAssistantMessage("finished after shutdown"); - }, - ]); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - }); - const prompt = harness.prompt("hello"); - await entered.promise; - await harness.steer("queued steer"); - await harness.followUp("queued follow-up"); - await harness.nextTurn("queued next turn"); - - let firstShutdownSettled = false; - harness.requestShutdown(); - const firstShutdown = harness.waitForShutdown().then(() => { - firstShutdownSettled = true; - }); - harness.requestShutdown(); - const secondShutdown = harness.waitForShutdown(); - await Promise.resolve(); - - expect(signal?.aborted).toBe(true); - expect(firstShutdownSettled).toBe(false); - release.resolve(); - await expect(prompt).resolves.toMatchObject({ role: "assistant" }); - await expect(Promise.all([firstShutdown, secondShutdown])).resolves.toEqual([undefined, undefined]); - await expect(harness.prompt("again")).rejects.toMatchObject({ - code: "invalid_state", - message: "AgentHarness has been shut down", - }); - await expect(harness.nextTurn("again")).rejects.toMatchObject({ code: "invalid_state" }); - await expect(harness.appendMessage(createUserMessage("again"))).rejects.toMatchObject({ - code: "invalid_state", - }); - }); - - it("allows a hook to request shutdown without deadlocking its operation", async () => { - const registration = newFaux(); - let providerCalls = 0; - registration.setResponses([ - () => { - providerCalls++; - return fauxAssistantMessage("must not run"); - }, - ]); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - }); - harness.on("before_agent_start", () => { - harness.requestShutdown(); - return undefined; - }); - - await expect(harness.prompt("hello")).rejects.toMatchObject({ code: "invalid_state" }); - await expect(harness.waitForShutdown()).resolves.toBeUndefined(); - expect(providerCalls).toBe(0); - }); - - it("allows a subscriber to request shutdown without deadlocking its operation", async () => { - const registration = newFaux(); - registration.setResponses([() => fauxAssistantMessage("reply")]); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - }); - let subscriberCalls = 0; - harness.subscribe((event) => { - subscriberCalls++; - if (event.type === "message_start" && event.message.role === "assistant") { - harness.requestShutdown(); - } - }); - - await expect(harness.prompt("hello")).resolves.toMatchObject({ role: "assistant", stopReason: "aborted" }); - await expect(harness.waitForShutdown()).resolves.toBeUndefined(); - expect(subscriberCalls).toBeGreaterThan(1); - }); - - it("does not start a provider request when shutdown occurs during before_agent_start", async () => { - const registration = newFaux(); - const entered = deferred(); - const release = deferred(); - let providerCalls = 0; - registration.setResponses([ - () => { - providerCalls++; - return fauxAssistantMessage("must not run"); - }, - ]); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - }); - harness.on("before_agent_start", async () => { - entered.resolve(); - await release.promise; - return undefined; - }); - const prompt = harness.prompt("hello"); - await entered.promise; - - let shutdownSettled = false; - harness.requestShutdown(); - const shutdown = harness.waitForShutdown().then(() => { - shutdownSettled = true; - }); - await Promise.resolve(); - - expect(shutdownSettled).toBe(false); - release.resolve(); - await expect(prompt).rejects.toMatchObject({ code: "invalid_state" }); - await shutdown; - expect(providerCalls).toBe(0); - }); - - it("aborts and awaits active compaction without persisting its result", async () => { - const registration = newFaux(); - const entered = deferred(); - const release = deferred(); - let signal: AbortSignal | undefined; - registration.setResponses([ - async (_context, options) => { - signal = options?.signal; - entered.resolve(); - await release.promise; - return fauxAssistantMessage("summary produced after shutdown"); - }, - ]); - const session = await createInMemorySession(); - await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - const harness = new AgentHarness({ models, session, model: registration.getModel() }); - const compaction = harness.compact(); - await entered.promise; - - let shutdownSettled = false; - harness.requestShutdown(); - const shutdown = harness.waitForShutdown().then(() => { - shutdownSettled = true; - }); - await Promise.resolve(); - - expect(signal?.aborted).toBe(true); - expect(shutdownSettled).toBe(false); - release.resolve(); - await expect(compaction).rejects.toMatchObject({ code: "compaction" }); - await shutdown; - expect((await session.getEntries()).some((entry) => entry.type === "compaction")).toBe(false); - }); - - it("aborts and awaits active tree navigation without moving the session leaf", async () => { - const registration = newFaux(); - const entered = deferred(); - const release = deferred(); - let signal: AbortSignal | undefined; - registration.setResponses([ - async (_context, options) => { - signal = options?.signal; - entered.resolve(); - await release.promise; - return fauxAssistantMessage("summary produced after shutdown"); - }, - ]); - const session = await createInMemorySession(); - const targetId = await session.appendMessage(createUserMessage("first branch")); - await session.appendMessage(createAssistantMessage("first reply")); - await session.appendMessage(createUserMessage("abandoned work")); - const originalLeafId = await session.appendMessage(createAssistantMessage("abandoned reply")); - const harness = new AgentHarness({ models, session, model: registration.getModel() }); - const navigation = harness.navigateTree(targetId, { summarize: true }); - await entered.promise; - - let shutdownSettled = false; - harness.requestShutdown(); - const shutdown = harness.waitForShutdown().then(() => { - shutdownSettled = true; - }); - await Promise.resolve(); - - expect(signal?.aborted).toBe(true); - expect(shutdownSettled).toBe(false); - release.resolve(); - await expect(navigation).resolves.toEqual({ cancelled: true }); - await shutdown; - expect(await session.getLeafId()).toBe(originalLeafId); - }); - - it("does not treat concurrent mutations as active operations", async () => { - const blocking = await createBlockingSession(1); - const harness = new AgentHarness({ - models, - session: blocking.session, - model: getModel("anthropic", "claude-sonnet-4-5"), - }); - const mutation = harness.appendMessage(createUserMessage("concurrent")); - await blocking.allWritesStarted.promise; - - const firstSettlement = await Promise.race([ - harness.waitForIdle().then(() => "idle" as const), - new Promise<"mutation-pending">((resolve) => setImmediate(() => resolve("mutation-pending"))), - ]); - - expect(firstSettlement).toBe("idle"); - blocking.releaseWrites.resolve(); - await mutation; - }); - - it("awaits concurrent idle session mutations before shutdown resolves", async () => { - const blocking = await createBlockingSession(1); - const harness = new AgentHarness({ - models, - session: blocking.session, - model: getModel("anthropic", "claude-sonnet-4-5"), - }); - const nextModel = getModel("anthropic", "claude-haiku-4-5"); - const mutations = [ - harness.appendMessage(createUserMessage("concurrent")), - harness.setModel(nextModel), - harness.setThinkingLevel("high"), - ]; - await blocking.allWritesStarted.promise; - - harness.requestShutdown(); - const shutdown = harness.waitForShutdown(); - const firstSettlement = await Promise.race([ - shutdown.then(() => "shutdown" as const), - new Promise<"writes-pending">((resolve) => setImmediate(() => resolve("writes-pending"))), - ]); - - expect(firstSettlement).toBe("writes-pending"); - blocking.releaseWrites.resolve(); - await Promise.all([...mutations, shutdown]); - expect(await blocking.getEntries()).toHaveLength(3); - }); - - it("shuts down an idle harness without modifying its durable session", async () => { - const session = await createInMemorySession(); - await session.appendMessage(createUserMessage("existing")); - const harness = new AgentHarness({ - models, - session, - model: getModel("anthropic", "claude-sonnet-4-5"), - }); - - harness.requestShutdown(); - await harness.waitForShutdown(); - - const messages = (await session.getEntries()).flatMap((entry) => - entry.type === "message" ? [entry.message] : [], - ); - expect(messages).toEqual([expect.objectContaining({ role: "user" })]); - await expect(harness.compact()).rejects.toMatchObject({ code: "invalid_state" }); - }); - - it("drains one queued steering message at a time and emits queue updates", async () => { - const registration = newFaux(); - const userCounts: number[] = []; - registration.setResponses([ - (context) => { - userCounts.push(context.messages.filter((message) => message.role === "user").length); - return fauxAssistantMessage("first"); - }, - (context) => { - userCounts.push(context.messages.filter((message) => message.role === "user").length); - return fauxAssistantMessage("second"); - }, - (context) => { - userCounts.push(context.messages.filter((message) => message.role === "user").length); - return fauxAssistantMessage("third"); - }, - ]); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - steeringMode: "one-at-a-time", - }); - const steerQueueLengths: number[] = []; - let queued = false; - harness.subscribe((event) => { - if (event.type === "queue_update") { - steerQueueLengths.push(event.steer.length); - } - if (event.type === "message_start" && event.message.role === "assistant" && !queued) { - queued = true; - harness.steer("one"); - harness.steer("two"); - } - }); - - await harness.prompt("hello"); - - expect(userCounts).toEqual([1, 2, 3]); - expect(steerQueueLengths).toEqual([1, 2, 1, 0]); - }); - - it("appends before_agent_start messages and persists them", async () => { - const registration = newFaux(); - let requestText: string[] = []; - registration.setResponses([ - (context) => { - requestText = textFromUserMessages(context.messages); - return fauxAssistantMessage("ok"); - }, - ]); - const session = await createInMemorySession(); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - }); - harness.on("before_agent_start", () => ({ - messages: [{ role: "user", content: [{ type: "text", text: "hook" }], timestamp: Date.now() }], - })); - - await harness.prompt("hello"); - - const persistedText = (await session.getEntries()).flatMap((entry) => { - if (entry.type !== "message" || entry.message.role !== "user") return []; - const content = entry.message.content; - if (typeof content === "string") return [content]; - return content.flatMap((part) => (part.type === "text" ? [part.text] : [])); - }); - expect(requestText).toEqual(["hello", "hook"]); - expect(persistedText).toEqual(["hello", "hook"]); - }); - - it("abort clears steer and follow-up queues but preserves next-turn messages", async () => { - const registration = newFaux(); - let releaseFirstResponse: (() => void) | undefined; - let abortedSignal: AbortSignal | undefined; - const firstResponseReleased = new Promise((resolve) => { - releaseFirstResponse = resolve; - }); - const secondRequestText: string[] = []; - registration.setResponses([ - async (_context, options) => { - abortedSignal = options?.signal; - await firstResponseReleased; - return fauxAssistantMessage("aborted-ish"); - }, - (context) => { - secondRequestText.push(...textFromUserMessages(context.messages)); - return fauxAssistantMessage("second"); - }, - ]); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - }); - const queueUpdates: Array<{ steer: number; followUp: number; nextTurn: number }> = []; - harness.subscribe((event) => { - if (event.type === "queue_update") { - queueUpdates.push({ - steer: event.steer.length, - followUp: event.followUp.length, - nextTurn: event.nextTurn.length, - }); - } - }); - - const firstPrompt = harness.prompt("first"); - await new Promise((resolve) => setTimeout(resolve, 0)); - harness.steer("steer"); - harness.followUp("follow"); - harness.nextTurn("next"); - const abortResultPromise = harness.abort(); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(abortedSignal?.aborted).toBe(true); - releaseFirstResponse?.(); - const abortResult = await abortResultPromise; - await firstPrompt; - await harness.prompt("second"); - - expect(abortResult.clearedSteer).toHaveLength(1); - expect(abortResult.clearedFollowUp).toHaveLength(1); - expect(queueUpdates).toContainEqual({ steer: 0, followUp: 0, nextTurn: 1 }); - expect(secondRequestText).toEqual(["first", "next", "second"]); - }); - - it("drains follow-up messages one at a time after the agent would otherwise stop", async () => { - const registration = newFaux(); - const userCounts: number[] = []; - registration.setResponses([ - (context) => { - userCounts.push(context.messages.filter((message) => message.role === "user").length); - return fauxAssistantMessage("first"); - }, - (context) => { - userCounts.push(context.messages.filter((message) => message.role === "user").length); - return fauxAssistantMessage("second"); - }, - (context) => { - userCounts.push(context.messages.filter((message) => message.role === "user").length); - return fauxAssistantMessage("third"); - }, - ]); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - followUpMode: "one-at-a-time", - }); - const followUpQueueLengths: number[] = []; - let queued = false; - harness.subscribe((event) => { - if (event.type === "queue_update") { - followUpQueueLengths.push(event.followUp.length); - } - if (event.type === "message_start" && event.message.role === "assistant" && !queued) { - queued = true; - harness.followUp("one"); - harness.followUp("two"); - } - }); - - await harness.prompt("hello"); - - expect(userCounts).toEqual([1, 2, 3]); - expect(followUpQueueLengths).toEqual([1, 2, 1, 0]); - }); - - it("settles thrown hook failures with persisted assistant error messages", async () => { - const registration = newFaux(); - registration.setResponses([() => fauxAssistantMessage("should not be used")]); - const session = await createInMemorySession(); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - }); - const events: string[] = []; - harness.subscribe((event) => { - events.push(event.type); - }); - harness.on("context", () => { - throw new Error("context exploded"); - }); - - const response = await harness.prompt("hello"); - await expect(harness.prompt("after failure")).resolves.toMatchObject({ role: "assistant" }); - - const entries = await session.getEntries(); - const messages = entries.flatMap((entry) => (entry.type === "message" ? [entry.message] : [])); - expect(response.stopReason).toBe("error"); - expect(response.errorMessage).toBe("context exploded"); - expect(messages[0]?.role).toBe("user"); - expect(messages[1]).toMatchObject({ role: "assistant", stopReason: "error", errorMessage: "context exploded" }); - expect(events).toContain("agent_end"); - expect(events).toContain("settled"); - }); - - it("refreshes model, thinking level, resources, system prompt, and active tools at save points", async () => { - const registration = newFaux({ - models: [ - { id: "first", reasoning: true }, - { id: "second", reasoning: true }, - ], - }); - const secondModel = registration.getModel("second"); - if (!secondModel) throw new Error("missing second faux model"); - const captured: Array<{ modelId: string; reasoning: unknown; systemPrompt: string; tools: string[] }> = []; - registration.setResponses([ - (context, options, _state, model) => { - captured.push({ - modelId: model.id, - reasoning: getReasoning(options), - systemPrompt: context.systemPrompt ?? "", - tools: context.tools?.map((tool) => tool.name) ?? [], - }); - return fauxAssistantMessage(fauxToolCall("calculate", { expression: "1 + 1" }, { id: "call-1" }), { - stopReason: "toolUse", - }); - }, - (context, options, _state, model) => { - captured.push({ - modelId: model.id, - reasoning: getReasoning(options), - systemPrompt: context.systemPrompt ?? "", - tools: context.tools?.map((tool) => tool.name) ?? [], - }); - return fauxAssistantMessage("done"); - }, - ]); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - thinkingLevel: "off", - resources: { - skills: [{ name: "prompt", description: "prompt", content: "first prompt", filePath: "/skills/prompt" }], - }, - systemPrompt: ({ resources }) => resources.skills?.[0]?.content ?? "missing prompt", - tools: [calculateTool], - }); - harness.subscribe((event) => { - if (event.type === "tool_execution_start") { - void harness.setModel(secondModel); - void harness.setThinkingLevel("high"); - void harness.setResources({ - skills: [ - { name: "prompt", description: "prompt", content: "second prompt", filePath: "/skills/prompt" }, - ], - }); - void harness.setTools([calculateTool, getCurrentTimeTool], [getCurrentTimeTool.name]); - } - }); - - await harness.prompt("hello"); - - expect(captured).toEqual([ - { modelId: "first", reasoning: undefined, systemPrompt: "first prompt", tools: ["calculate"] }, - { modelId: "second", reasoning: "high", systemPrompt: "second prompt", tools: ["get_current_time"] }, - ]); - }); - - it("orders pending listener session writes after agent-emitted messages", async () => { - const registration = newFaux(); - registration.setResponses([() => fauxAssistantMessage("ok")]); - const session = await createInMemorySession(); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - }); - let wrotePendingMessage = false; - harness.subscribe(async (event) => { - if (event.type === "message_end" && event.message.role === "assistant" && !wrotePendingMessage) { - wrotePendingMessage = true; - await harness.appendMessage({ - role: "custom", - customType: "listener", - content: "listener write", - display: true, - timestamp: Date.now(), - } as AgentMessage); - } - }); - - await harness.prompt("hello"); - - const entries = await session.getEntries(); - const roles = entries.flatMap((entry) => (entry.type === "message" ? [entry.message.role] : [])); - expect(roles).toEqual(["user", "assistant", "custom"]); - }); - - it("waitForIdle waits for external run settlement and awaited listeners", async () => { - const registration = newFaux(); - registration.setResponses([() => fauxAssistantMessage("ok")]); - const barrier = deferred(); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - }); - let listenerFinished = false; - harness.subscribe(async (event) => { - if (event.type === "agent_end") { - await barrier.promise; - listenerFinished = true; - } - }); - - const promptPromise = harness.prompt("hello"); - let idleResolved = false; - const idlePromise = harness.waitForIdle().then(() => { - idleResolved = true; - }); - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(idleResolved).toBe(false); - expect(listenerFinished).toBe(false); - barrier.resolve(); - await Promise.all([promptPromise, idlePromise]); - expect(idleResolved).toBe(true); - expect(listenerFinished).toBe(true); - }); - - it("runs tool_call and tool_result hooks through the direct loop", async () => { - const registration = newFaux(); - registration.setResponses([ - () => - fauxAssistantMessage(fauxToolCall("calculate", { expression: "2 + 2" }, { id: "call-1" }), { - stopReason: "toolUse", - }), - ]); - const session = await createInMemorySession(); - const toolUsage = createUsage(1, 2, 3, 4); - const patchedToolUsage = createUsage(5, 6, 7, 8); - const calculateToolWithUsage = createCalculateToolWithUsage(toolUsage); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - tools: [calculateToolWithUsage], - }); - const seenToolCalls: Array<{ id: string; name: string; expression: unknown }> = []; - let seenToolUsage: Usage | undefined; - harness.on("tool_call", (event) => { - seenToolCalls.push({ id: event.toolCallId, name: event.toolName, expression: event.input.expression }); - return undefined; - }); - harness.on("tool_result", (event) => { - expect(event.toolCallId).toBe("call-1"); - expect(event.toolName).toBe("calculate"); - seenToolUsage = event.usage; - return { - content: [{ type: "text", text: "patched result" }], - details: { patched: true }, - usage: patchedToolUsage, - terminate: true, - }; - }); - - await harness.prompt("hello"); - - const toolResult = (await session.getEntries()).find( - (entry) => entry.type === "message" && entry.message.role === "toolResult", - ); - expect(seenToolCalls).toEqual([{ id: "call-1", name: "calculate", expression: "2 + 2" }]); - expect(seenToolUsage).toEqual(toolUsage); - expect(toolResult).toMatchObject({ - type: "message", - message: { - role: "toolResult", - content: [{ type: "text", text: "patched result" }], - details: { patched: true }, - usage: patchedToolUsage, - }, - }); - }); - - it("passes a static application context to harness tools", async () => { - const registration = newFaux(); - registration.setResponses([ - () => - fauxAssistantMessage(fauxToolCall("context", { expression: "2 + 2" }, { id: "call-1" }), { - stopReason: "toolUse", - }), - ]); - const env = new NodeExecutionEnv({ cwd: process.cwd() }); - const toolContext = { env }; - let receivedContext: typeof toolContext | undefined; - const contextTool: AgentHarnessTool = { - ...calculateTool, - name: "context", - execute: async (toolCallId, params, signal, onUpdate, context) => { - receivedContext = context; - return { ...(await calculateTool.execute(toolCallId, params, signal, onUpdate)), terminate: true }; - }, - }; - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - tools: [contextTool], - toolContext, - }); - - await harness.prompt("hello"); - - expect(receivedContext).toBe(toolContext); - }); - - it("resolves async tool context providers for each turn snapshot", async () => { - const registration = newFaux(); - registration.setResponses([ - () => - fauxAssistantMessage(fauxToolCall("context", { expression: "1 + 1" }, { id: "call-1" }), { - stopReason: "toolUse", - }), - () => - fauxAssistantMessage(fauxToolCall("context", { expression: "2 + 2" }, { id: "call-2" }), { - stopReason: "toolUse", - }), - () => fauxAssistantMessage("done"), - ]); - type ToolContext = { generation: number }; - const generations: number[] = []; - const contextTool: AgentHarnessTool = { - ...calculateTool, - name: "context", - execute: async (toolCallId, params, signal, onUpdate, context) => { - generations.push(context.generation); - return await calculateTool.execute(toolCallId, params, signal, onUpdate); - }, - }; - let generation = 0; - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - tools: [contextTool], - toolContext: async (): Promise => ({ generation: ++generation }), - }); - - await harness.prompt("hello"); - - expect(generations).toEqual([1, 2]); - }); - - it("persists generated compaction usage", async () => { - const registration = newFaux(); - registration.setResponses([fauxAssistantMessage("## Goal\nTest summary")]); - const session = await createInMemorySession(); - await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - }); - - const result = await harness.compact(); - const compaction = (await session.getEntries()).find((entry) => entry.type === "compaction"); - - expect(result.usage?.totalTokens).toBeGreaterThan(0); - expect(compaction?.type === "compaction" ? compaction.usage : undefined).toEqual(result.usage); - }); - - it("persists hook-provided compaction usage", async () => { - const registration = newFaux(); - const usage = createUsage(5, 6, 7, 8); - const session = await createInMemorySession(); - await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - }); - harness.on("session_before_compact", (event) => ({ - compaction: { - summary: "hook summary", - firstKeptEntryId: event.preparation.firstKeptEntryId, - tokensBefore: event.preparation.tokensBefore, - usage, - }, - })); - - const result = await harness.compact(); - const compaction = (await session.getEntries()).find((entry) => entry.type === "compaction"); - - expect(result.usage).toEqual(usage); - expect(compaction?.type === "compaction" ? compaction.usage : undefined).toEqual(usage); - }); - - describe("summarization retries", () => { - it("retries transient compaction errors and emits retry events", async () => { - const registration = newFaux(); - let calls = 0; - registration.setResponses([ - () => { - calls++; - return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }); - }, - () => { - calls++; - return fauxAssistantMessage("## Goal\nRecovered summary"); - }, - ]); - const session = await createInMemorySession(); - await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 }, - }); - const retryEvents: string[] = []; - harness.subscribe((event) => { - if ( - event.type === "retry_scheduled" || - event.type === "retry_attempt_start" || - event.type === "retry_finished" - ) { - retryEvents.push(`${event.type}:${event.operation}`); - } - }); - - const result = await harness.compact(); - - expect(result.summary).toContain("Recovered summary"); - expect(calls).toBe(2); - expect(retryEvents).toEqual([ - "retry_scheduled:compaction", - "retry_attempt_start:compaction", - "retry_finished:compaction", - ]); - }); - - it("does not retry non-retryable compaction errors", async () => { - const registration = newFaux(); - let calls = 0; - registration.setResponses([ - () => { - calls++; - return fauxAssistantMessage("", { stopReason: "error", errorMessage: "insufficient_quota" }); - }, - ]); - const session = await createInMemorySession(); - await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 }, - }); - const retryEvents: string[] = []; - harness.subscribe((event) => { - if ( - event.type === "retry_scheduled" || - event.type === "retry_attempt_start" || - event.type === "retry_finished" - ) { - retryEvents.push(event.type); - } - }); - - await expect(harness.compact()).rejects.toThrow("insufficient_quota"); - - expect(calls).toBe(1); - expect(retryEvents).toEqual([]); - }); - - it("exhausts transient compaction retries after maxRetries failures", async () => { - const registration = newFaux(); - let calls = 0; - registration.setResponses( - Array.from({ length: 4 }, () => () => { - calls++; - return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }); - }), - ); - const session = await createInMemorySession(); - await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - retry: { enabled: true, maxRetries: 3, baseDelayMs: 0 }, - }); - const retryEvents: string[] = []; - harness.subscribe((event) => { - if ( - event.type === "retry_scheduled" || - event.type === "retry_attempt_start" || - event.type === "retry_finished" - ) { - retryEvents.push(`${event.type}:${event.operation}`); - } - }); - - await expect(harness.compact()).rejects.toThrow("terminated"); - - expect(calls).toBe(4); - expect(retryEvents).toEqual([ - "retry_scheduled:compaction", - "retry_attempt_start:compaction", - "retry_scheduled:compaction", - "retry_attempt_start:compaction", - "retry_scheduled:compaction", - "retry_attempt_start:compaction", - "retry_finished:compaction", - ]); - }); - - it("retries transient branch summary errors and emits retry events", async () => { - const registration = newFaux(); - let calls = 0; - registration.setResponses([ - () => { - calls++; - return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }); - }, - () => { - calls++; - return fauxAssistantMessage("## Goal\nRecovered branch summary"); - }, - ]); - const session = await createInMemorySession(); - const targetId = await session.appendMessage(createUserMessage("first branch")); - await session.appendMessage(createAssistantMessage("first reply")); - await session.appendMessage(createUserMessage("abandoned work")); - await session.appendMessage(createAssistantMessage("abandoned reply")); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 }, - }); - const retryEvents: string[] = []; - harness.subscribe((event) => { - if ( - event.type === "retry_scheduled" || - event.type === "retry_attempt_start" || - event.type === "retry_finished" - ) { - retryEvents.push(`${event.type}:${event.operation}`); - } - }); - - const result = await harness.navigateTree(targetId, { summarize: true }); - - expect(result.summaryEntry?.summary).toContain("Recovered branch summary"); - expect(calls).toBe(2); - expect(retryEvents).toEqual([ - "retry_scheduled:branch_summary", - "retry_attempt_start:branch_summary", - "retry_finished:branch_summary", - ]); - }); - }); - - it("persists generated branch summary usage", async () => { - const registration = newFaux(); - registration.setResponses([fauxAssistantMessage("## Goal\nBranch summary")]); - const session = await createInMemorySession(); - const targetId = await session.appendMessage(createUserMessage("first branch")); - await session.appendMessage(createAssistantMessage("first reply")); - await session.appendMessage(createUserMessage("abandoned work")); - await session.appendMessage(createAssistantMessage("abandoned reply")); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - }); - - const result = await harness.navigateTree(targetId, { summarize: true }); - - expect(result.summaryEntry?.usage?.totalTokens).toBeGreaterThan(0); - }); - - it("persists hook-provided branch summary usage", async () => { - const registration = newFaux(); - const usage = createUsage(13, 14, 15, 16); - const session = await createInMemorySession(); - const targetId = await session.appendMessage(createUserMessage("first branch")); - await session.appendMessage(createAssistantMessage("first reply")); - await session.appendMessage(createUserMessage("abandoned work")); - await session.appendMessage(createAssistantMessage("abandoned reply")); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - }); - harness.on("session_before_tree", () => ({ - summary: { summary: "hook branch summary", usage }, - })); - - const result = await harness.navigateTree(targetId, { summarize: true }); - - expect(result.summaryEntry?.usage).toEqual(usage); - }); - - it("preserves app tool types for getters and update events", async () => { - const session = await createInMemorySession(); - const model = getModel("anthropic", "claude-sonnet-4-5"); - type AppTool = AgentTool & { source: "builtin" | "extension" }; - const inspectTool: AppTool = { ...calculateTool, name: "inspect", source: "builtin" }; - const searchTool: AppTool = { ...calculateTool, name: "search", source: "extension" }; - const harness = new AgentHarness({ - models, - session, - model, - tools: [inspectTool, searchTool], - activeToolNames: ["inspect"], - }); - const updates: Array<{ - toolNames: string[]; - previousToolNames: string[]; - activeToolNames: string[]; - previousActiveToolNames: string[]; - source: "set" | "restore"; - }> = []; - harness.subscribe((event) => { - if (event.type === "tools_update") { - updates.push({ - toolNames: event.toolNames, - previousToolNames: event.previousToolNames, - activeToolNames: event.activeToolNames, - previousActiveToolNames: event.previousActiveToolNames, - source: event.source, - }); - expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(event.activeToolNames); - } - }); - - const tools = harness.getTools(); - const activeTools = harness.getActiveTools(); - tools.pop(); - activeTools.pop(); - expect(harness.getTools().map((tool) => tool.name)).toEqual(["inspect", "search"]); - expect(harness.getActiveTools().map((tool) => tool.source)).toEqual(["builtin"]); - - await harness.setActiveTools(["search"]); - await harness.setTools([searchTool], ["search"]); - await expect(harness.setActiveTools(["missing"])).rejects.toMatchObject({ code: "invalid_argument" }); - await expect(harness.setActiveTools(["search", "search"])).rejects.toMatchObject({ code: "invalid_argument" }); - await expect(harness.setTools([inspectTool])).rejects.toMatchObject({ code: "invalid_argument" }); - await expect(harness.setTools([inspectTool, inspectTool], ["inspect"])).rejects.toMatchObject({ - code: "invalid_argument", - }); - - expect(updates).toEqual([ - { - toolNames: ["inspect", "search"], - previousToolNames: ["inspect", "search"], - activeToolNames: ["search"], - previousActiveToolNames: ["inspect"], - source: "set", - }, - { - toolNames: ["search"], - previousToolNames: ["inspect", "search"], - activeToolNames: ["search"], - previousActiveToolNames: ["search"], - source: "set", - }, - ]); - expect(harness.getTools().map((tool) => tool.source)).toEqual(["extension"]); - expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(["search"]); - expect((await session.buildContext()).activeToolNames).toEqual(["search"]); - }); - - it("validates constructor tool names", async () => { - const session = await createInMemorySession(); - const model = getModel("anthropic", "claude-sonnet-4-5"); - expect( - () => new AgentHarness({ session, models, model, tools: [calculateTool], activeToolNames: ["missing"] }), - ).toThrow(/Unknown tool/); - expect( - () => - new AgentHarness({ - models, - session, - model, - tools: [calculateTool, calculateTool], - activeToolNames: [calculateTool.name], - }), - ).toThrow(/Duplicate tool/); - expect( - () => - new AgentHarness({ - models, - session, - model, - tools: [calculateTool], - activeToolNames: [calculateTool.name, calculateTool.name], - }), - ).toThrow(/Duplicate active tool/); - }); - - it("preserves app resource types for getters and update events", async () => { - const session = await createInMemorySession(); - const model = getModel("anthropic", "claude-sonnet-4-5"); - const harness = new AgentHarness({ - session, - models, - model, - }); - const skill: AppSkill = { - name: "inspect", - description: "Inspect things", - content: "Use inspection tools.", - filePath: "/skills/inspect/SKILL.md", - source: "project", - }; - const promptTemplate: AppPromptTemplate = { name: "review", content: "Review $1", source: "user" }; - const resources = { skills: [skill], promptTemplates: [promptTemplate] }; - const updates: Array<{ resourcesSource?: string; previousSource?: string }> = []; - harness.subscribe((event) => { - if (event.type === "resources_update") { - updates.push({ - resourcesSource: event.resources.skills?.[0]?.source, - previousSource: event.previousResources.skills?.[0]?.source, - }); - } - }); - - await harness.setResources(resources); - await harness.setResources(resources); - const resolved = harness.getResources(); - - expect(updates).toEqual([ - { resourcesSource: "project", previousSource: undefined }, - { resourcesSource: "project", previousSource: "project" }, - ]); - expect(resolved.skills?.[0]?.source).toBe("project"); - expect(resolved.promptTemplates?.[0]?.source).toBe("user"); - expect(resolved.skills).not.toBe(resources.skills); - expect(resolved.promptTemplates).not.toBe(resources.promptTemplates); - }); -}); diff --git a/packages/agent/test/harness/branch-query.test.ts b/packages/agent/test/harness/branch-query.test.ts deleted file mode 100644 index d67161c63e4..00000000000 --- a/packages/agent/test/harness/branch-query.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { createNodeSqliteFactory, SqliteSessionRepository } from "../../../storage/sqlite-node/src/index.ts"; -import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { JsonlSessionRepository } from "../../src/harness/session/jsonl-repo.ts"; -import { InMemorySessionBackend, InMemorySessionRepository } from "../../src/harness/session/memory-repo.ts"; -import type { Session } from "../../src/harness/session/session.ts"; -import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts"; - -const ownedRepositories: AsyncDisposable[] = []; - -afterEach(async () => { - for (const repository of ownedRepositories.splice(0)) await repository[Symbol.asyncDispose](); -}); - -async function verifyBranchQueries(session: Session): Promise<{ tail: string; fullPath: string[] }> { - const root = await session.appendMessage(createUserMessage("root")); - const custom = await session.appendCustomEntry("note", { value: 1 }); - const child = await session.appendMessage(createAssistantMessage("child")); - const compaction = await session.appendCompaction("summary", child, 100, undefined, undefined, undefined, [ - createAssistantMessage("child"), - ]); - const recentCustom = await session.appendCustomEntry("note", { value: 2 }); - const tail = await session.appendMessage(createUserMessage("tail")); - await session.moveTo(root); - const sibling = await session.appendMessage(createUserMessage("sibling")); - - expect((await session.findEntriesOnBranch()).map((entry) => entry.id)).toEqual([sibling, root]); - expect(await session.findEntriesOnBranch({ start: null })).toEqual([]); - expect((await session.findEntriesOnBranch({ start: tail, order: "oldestFirst" })).map((entry) => entry.id)).toEqual([ - root, - custom, - child, - compaction, - recentCustom, - tail, - ]); - expect( - (await session.findEntriesOnBranch({ start: tail, stopAtType: "compaction" })).map((entry) => entry.id), - ).toEqual([tail, recentCustom, compaction]); - expect( - (await session.findEntriesOnBranch({ start: tail, stopAtType: "compaction", type: "message" })).map( - (entry) => entry.id, - ), - ).toEqual([tail]); - expect( - (await session.findEntriesOnBranch({ start: tail, stopAtId: child, order: "oldestFirst" })).map( - (entry) => entry.id, - ), - ).toEqual([root, custom, child]); - expect((await session.findEntriesOnBranch({ start: tail, stopAtType: "custom" })).map((entry) => entry.id)).toEqual([ - tail, - recentCustom, - ]); - expect( - ( - await session.findEntriesOnBranch({ - start: tail, - stopAtType: "custom", - order: "oldestFirst", - }) - ).map((entry) => entry.id), - ).toEqual([root, custom]); - expect( - (await session.findEntriesOnBranch({ start: tail, type: "message", order: "oldestFirst" })).map( - (entry) => entry.id, - ), - ).toEqual([root, child, tail]); - expect((await session.findEntriesOnBranch({ start: tail, customType: "note" })).map((entry) => entry.id)).toEqual([ - recentCustom, - custom, - ]); - expect((await session.findEntriesOnBranch({ start: tail, limit: 1 })).map((entry) => entry.id)).toEqual([tail]); - expect( - ( - await session.findEntriesOnBranch({ - start: tail, - type: "message", - order: "oldestFirst", - limit: 1, - }) - ).map((entry) => entry.id), - ).toEqual([root]); - expect(await session.findEntryOnBranch({ start: tail, type: "compaction" })).toMatchObject({ id: compaction }); - await expect(session.findEntriesOnBranch({ start: "missing" })).rejects.toMatchObject({ code: "not_found" }); - await expect(session.findEntriesOnBranch({ limit: 0 })).rejects.toThrow("limit must be a positive integer"); - return { tail, fullPath: [root, custom, child, compaction, recentCustom, tail] }; -} - -describe("bounded session branch queries", () => { - it("provides identical in-memory query semantics", async () => { - const repo = new InMemorySessionRepository(); - ownedRepositories.push(repo); - const session = await repo.create({ id: "memory" }); - const expected = await verifyBranchQueries(session); - const reopened = await repo.open(await session.getMetadata()); - expect( - (await reopened.findEntriesOnBranch({ start: expected.tail, order: "oldestFirst" })).map((entry) => entry.id), - ).toEqual(expected.fullPath); - }); - - it("rejects corrupt parent chains in array-backed readers", async () => { - const backend = new InMemorySessionBackend(); - ownedRepositories.push(backend); - const storage = await backend.create({ id: "corrupt-memory" }); - await storage.appendEntry({ - type: "message", - id: "orphan", - parentId: "missing-parent", - timestamp: "2026-01-01T00:00:00.000Z", - message: createUserMessage("orphan"), - }); - - expect( - (await storage.findEntriesOnBranch({ start: "orphan", stopAtId: "orphan" })).map((entry) => entry.id), - ).toEqual(["orphan"]); - expect( - (await storage.findEntriesOnBranch({ start: "orphan", stopAtType: "message" })).map((entry) => entry.id), - ).toEqual(["orphan"]); - await expect(storage.findEntriesOnBranch({ start: "orphan" })).rejects.toMatchObject({ - code: "invalid_session", - message: "Entry missing-parent not found", - }); - await storage.appendEntry({ - type: "message", - id: "cycle-a", - parentId: "cycle-b", - timestamp: "2026-01-01T00:00:01.000Z", - message: createUserMessage("a"), - }); - await storage.appendEntry({ - type: "message", - id: "cycle-b", - parentId: "cycle-a", - timestamp: "2026-01-01T00:00:02.000Z", - message: createUserMessage("b"), - }); - await expect(storage.findEntriesOnBranch({ start: "cycle-b" })).rejects.toMatchObject({ - code: "invalid_session", - message: "Session branch contains a cycle at cycle-b", - }); - }); - - it("provides identical JSONL query semantics", async () => { - const root = createTempDir(); - const repo = new JsonlSessionRepository({ fs: new NodeExecutionEnv({ cwd: root }), sessionsRoot: root }); - ownedRepositories.push(repo); - const session = await repo.create({ id: "jsonl", cwd: root }); - const expected = await verifyBranchQueries(session); - const reopened = await repo.open(await session.getMetadata()); - expect( - (await reopened.findEntriesOnBranch({ start: expected.tail, order: "oldestFirst" })).map((entry) => entry.id), - ).toEqual(expected.fullPath); - }); - - it("does not decode SQLite branch entries outside query bounds", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ - env: new NodeExecutionEnv({ cwd: root }), - sqlite, - databasePath, - }); - ownedRepositories.push(repo); - const session = await repo.create({ id: "bounded-sqlite", cwd: root }); - const rootId = await session.appendMessage(createUserMessage("root")); - const middleId = await session.appendMessage(createAssistantMessage("middle")); - const tailId = await session.appendMessage(createUserMessage("tail")); - - const db = await sqlite.open(databasePath); - try { - await db - .prepare("UPDATE entries SET payload = ? WHERE session_id = ? AND id = ?") - .run("not json", "bounded-sqlite", middleId); - const branch = await db - .prepare("SELECT branch_id FROM branch_entries WHERE session_id = ? AND entry_id = ?") - .get<{ branch_id: string }>("bounded-sqlite", tailId); - if (!branch) throw new Error("Missing branch cache for bounded SQLite query test"); - await db - .prepare("DELETE FROM branch_entries WHERE session_id = ? AND branch_id = ? AND entry_id = ?") - .run("bounded-sqlite", branch.branch_id, middleId); - } finally { - await db.close(); - } - - expect((await session.findEntriesOnBranch({ start: tailId, stopAtId: tailId })).map((entry) => entry.id)).toEqual( - [tailId], - ); - expect( - ( - await session.findEntriesOnBranch({ - start: tailId, - stopAtId: rootId, - order: "oldestFirst", - limit: 1, - }) - ).map((entry) => entry.id), - ).toEqual([rootId]); - await expect(session.findEntriesOnBranch({ start: tailId, limit: 2 })).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining(`Entry ${middleId} not found`), - }); - }); - - it("validates SQLite entries before filtering and limiting branch results", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ - env: new NodeExecutionEnv({ cwd: root }), - sqlite, - databasePath, - }); - ownedRepositories.push(repo); - const session = await repo.create({ id: "invalid-filtered-sqlite", cwd: root }); - await session.appendMessage(createUserMessage("root")); - const customId = await session.appendCustomEntry("note", { value: 1 }); - const tailId = await session.appendMessage(createAssistantMessage("tail")); - - const db = await sqlite.open(databasePath); - try { - await db - .prepare("UPDATE entries SET payload = ? WHERE session_id = ? AND id = ?") - .run("{}", "invalid-filtered-sqlite", customId); - } finally { - await db.close(); - } - await expect(session.findEntriesOnBranch({ start: tailId, type: "message", limit: 1 })).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining(`failed to decode entry ${customId}`), - }); - - const invalidJsonDb = await sqlite.open(databasePath); - try { - await invalidJsonDb - .prepare("UPDATE entries SET payload = ? WHERE session_id = ? AND id = ?") - .run("not json", "invalid-filtered-sqlite", customId); - } finally { - await invalidJsonDb.close(); - } - await expect(session.findEntriesOnBranch({ start: tailId, customType: "other" })).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining(`failed to decode entry ${customId}`), - }); - }); - - it("does not validate SQLite ancestors beyond newest-first stop bounds", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ - env: new NodeExecutionEnv({ cwd: root }), - sqlite, - databasePath, - }); - ownedRepositories.push(repo); - const session = await repo.create({ id: "bounded-corrupt-sqlite", cwd: root }); - const rootId = await session.appendMessage(createUserMessage("root")); - const childId = await session.appendMessage(createAssistantMessage("child")); - - const db = await sqlite.open(databasePath); - try { - await db - .prepare("UPDATE entries SET parent_id = ? WHERE session_id = ? AND id = ?") - .run("missing-parent", "bounded-corrupt-sqlite", childId); - } finally { - await db.close(); - } - expect( - (await session.findEntriesOnBranch({ start: childId, stopAtId: childId })).map((entry) => entry.id), - ).toEqual([childId]); - expect( - (await session.findEntriesOnBranch({ start: childId, stopAtType: "message" })).map((entry) => entry.id), - ).toEqual([childId]); - await expect(session.findEntriesOnBranch({ start: childId })).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining("Entry missing-parent not found"), - }); - - const cycleDb = await sqlite.open(databasePath); - try { - await cycleDb - .prepare("UPDATE entries SET parent_id = ? WHERE session_id = ? AND id = ?") - .run(rootId, "bounded-corrupt-sqlite", childId); - await cycleDb - .prepare("UPDATE entries SET parent_id = ? WHERE session_id = ? AND id = ?") - .run(childId, "bounded-corrupt-sqlite", rootId); - } finally { - await cycleDb.close(); - } - expect( - (await session.findEntriesOnBranch({ start: childId, stopAtId: childId })).map((entry) => entry.id), - ).toEqual([childId]); - expect( - (await session.findEntriesOnBranch({ start: childId, stopAtType: "message" })).map((entry) => entry.id), - ).toEqual([childId]); - await expect(session.findEntriesOnBranch({ start: childId })).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining(`Entry ${childId} not found`), - }); - }); -}); diff --git a/packages/agent/test/harness/branch-summarization.test.ts b/packages/agent/test/harness/branch-summarization.test.ts new file mode 100644 index 00000000000..bb4edea50f7 --- /dev/null +++ b/packages/agent/test/harness/branch-summarization.test.ts @@ -0,0 +1,39 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import { describe, expect, it } from "vitest"; +import { collectEntriesForBranchSummary } from "../../src/harness/compaction/branch-summarization.ts"; +import { InMemorySessionStorage, Session } from "../../src/harness/session/index.ts"; + +function message(text: string): AgentMessage { + return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; +} + +describe("v4 branch summarization", () => { + it("collects the abandoned side of a branch in chronological order", async () => { + let nextId = 0; + const session = new Session(new InMemorySessionStorage({ id: "session", createdAt: 1 }), { + idGenerator: { next: () => `entry-${++nextId}` }, + }); + const rootId = await session.appendMessage(message("root")); + const commonId = await session.appendMessage(message("common")); + const abandonedIds = [ + await session.appendMessage(message("abandoned 1")), + await session.appendMessage(message("abandoned 2")), + ]; + await session.createLane("target", commonId); + const targetId = await session.view("target").appendMessage(message("target")); + + const result = await collectEntriesForBranchSummary(session, abandonedIds[1]!, targetId); + expect(result.commonAncestorId).toBe(commonId); + expect(result.entries.map((entry) => entry.id)).toEqual(abandonedIds); + expect(result.entries.some((entry) => entry.id === rootId)).toBe(false); + }); + + it("returns no entries when there was no previous leaf", async () => { + const session = new Session(new InMemorySessionStorage({ id: "session", createdAt: 1 })); + const targetId = await session.appendMessage(message("target")); + expect(await collectEntriesForBranchSummary(session, null, targetId)).toEqual({ + entries: [], + commonAncestorId: null, + }); + }); +}); diff --git a/packages/agent/test/harness/compaction.test.ts b/packages/agent/test/harness/compaction.test.ts index e9b25d741e5..eca43dfd7af 100644 --- a/packages/agent/test/harness/compaction.test.ts +++ b/packages/agent/test/harness/compaction.test.ts @@ -1,4 +1,5 @@ import { + type Api, type AssistantMessage, createModels, type FauxProviderHandle, @@ -12,6 +13,7 @@ import { import { beforeEach, describe, expect, it } from "vitest"; import { type CompactionPreparation, + type CompactionSettings, calculateContextTokens, compact, DEFAULT_COMPACTION_SETTINGS, @@ -26,17 +28,15 @@ import { serializeConversation, shouldCompact, } from "../../src/harness/compaction/compaction.ts"; -import { buildSessionContext } from "../../src/harness/session/session.ts"; +import { buildSessionContext } from "../../src/harness/session/context.ts"; import type { BranchSummaryEntry, CompactionEntry, - CompactionSettings, - CustomMessageEntry, + Entry, MessageEntry, ModelChangeEntry, - SessionTreeEntry, - ThinkingLevelChangeEntry, -} from "../../src/harness/types.ts"; + ThinkingLevelEntry, +} from "../../src/harness/session/types.ts"; import { getOrThrow } from "../../src/harness/types.ts"; import type { AgentMessage } from "../../src/types.ts"; @@ -82,14 +82,14 @@ function createMessageEntry(message: AgentMessage, parentId: string | null = nul type: "message", id: createId(), parentId, - timestamp: new Date().toISOString(), + seq: nextId, + timestamp: Date.now(), message, }; } function createCompactionEntry( summary: string, - firstKeptEntryId: string, parentId: string | null = null, retainedTail?: AgentMessage[], ): CompactionEntry { @@ -97,20 +97,21 @@ function createCompactionEntry( type: "compaction", id: createId(), parentId, - timestamp: new Date().toISOString(), + seq: nextId, + timestamp: Date.now(), summary, - firstKeptEntryId, tokensBefore: 1234, - retainedTail, + retainedTail: retainedTail ?? [], }; } -function createThinkingLevelEntry(level: string, parentId: string | null = null): ThinkingLevelChangeEntry { +function createThinkingLevelEntry(level: string, parentId: string | null = null): ThinkingLevelEntry { return { type: "thinking_level_change", id: createId(), parentId, - timestamp: new Date().toISOString(), + seq: nextId, + timestamp: Date.now(), thinkingLevel: level, }; } @@ -120,7 +121,8 @@ function createModelChangeEntry(provider: string, modelId: string, parentId: str type: "model_change", id: createId(), parentId, - timestamp: new Date().toISOString(), + seq: nextId, + timestamp: Date.now(), provider, modelId, }; @@ -130,7 +132,7 @@ function createModelChangeEntry(provider: string, modelId: string, parentId: str const models = createModels(); let fauxCount = 0; -function createFauxModel(reasoning: boolean, maxTokens = 8192): { faux: FauxProviderHandle; model: Model } { +function createFauxModel(reasoning: boolean, maxTokens = 8192): { faux: FauxProviderHandle; model: Model } { const faux = fauxProvider({ provider: `faux-${++fauxCount}`, models: [ @@ -179,7 +181,7 @@ describe("harness compaction", () => { }); it("finds a cut point based on token differences", () => { - const entries: SessionTreeEntry[] = []; + const entries: Entry[] = []; let parentId: string | null = null; for (let i = 0; i < 10; i++) { const user = createMessageEntry(createUserMessage(`User ${i}`), parentId); @@ -209,24 +211,15 @@ describe("harness compaction", () => { type: "branch_summary", id: createId(), parentId: modelChange.id, - timestamp: new Date().toISOString(), + seq: nextId, + timestamp: Date.now(), fromId: "branch", summary: "branch summary", }; - const customMessage: CustomMessageEntry = { - type: "custom_message", - id: createId(), - parentId: branchSummary.id, - timestamp: new Date().toISOString(), - customType: "note", - content: "custom content", - display: true, - }; expect(findTurnStartIndex([thinking, branchSummary], 1, 0)).toBe(1); - expect(findTurnStartIndex([thinking, customMessage], 1, 0)).toBe(1); expect(findTurnStartIndex([thinking, modelChange], 1, 0)).toBe(-1); - const result = findCutPoint([thinking, branchSummary, customMessage], 0, 3, 1); + const result = findCutPoint([thinking, branchSummary], 0, 2, 1); expect(result.firstKeptEntryIndex).toBe(0); const toolResult = createMessageEntry({ @@ -244,7 +237,7 @@ describe("harness compaction", () => { }); const user = createMessageEntry(createUserMessage("user")); - const compaction = createCompactionEntry("summary", user.id, user.id); + const compaction = createCompactionEntry("summary", user.id); const assistant = createMessageEntry(createAssistantMessage("assistant"), compaction.id); expect(findCutPoint([user, compaction, assistant], 0, 3, 1).firstKeptEntryIndex).toBe(2); }); @@ -345,7 +338,7 @@ describe("harness compaction", () => { const a1 = createMessageEntry(createAssistantMessage("a"), u1.id); const u2 = createMessageEntry(createUserMessage("2"), a1.id); const a2 = createMessageEntry(createAssistantMessage("b"), u2.id); - const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id, a2.id, [ + const compaction = createCompactionEntry("Summary of 1,a,2,b", a2.id, [ createUserMessage("2"), createAssistantMessage("b"), ]); @@ -363,22 +356,6 @@ describe("harness compaction", () => { ]); }); - it("falls back to firstKeptEntryId when a compaction has no retained tail", () => { - const u1 = createMessageEntry(createUserMessage("1")); - const a1 = createMessageEntry(createAssistantMessage("a"), u1.id); - const u2 = createMessageEntry(createUserMessage("2"), a1.id); - const a2 = createMessageEntry(createAssistantMessage("b"), u2.id); - const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id, a2.id); - const u3 = createMessageEntry(createUserMessage("3"), compaction.id); - const loaded = buildSessionContext([u1, a1, u2, a2, compaction, u3]); - expect(loaded.messages.map((message) => message.role)).toEqual([ - "compactionSummary", - "user", - "assistant", - "user", - ]); - }); - it("tracks model and thinking level changes in built context", () => { const user = createMessageEntry(createUserMessage("1")); const modelChange = createModelChangeEntry("openai", "gpt-4", user.id); @@ -394,18 +371,39 @@ describe("harness compaction", () => { const a1 = createMessageEntry(createAssistantMessage("assistant msg 1"), u1.id); const u2 = createMessageEntry(createUserMessage("user msg 2"), a1.id); const a2 = createMessageEntry(createAssistantMessage("assistant msg 2", createMockUsage(5000, 1000)), u2.id); - const compaction1 = createCompactionEntry("First summary", u2.id, a2.id); + const compaction1 = createCompactionEntry("First summary", a2.id); const u3 = createMessageEntry(createUserMessage("user msg 3"), compaction1.id); const a3 = createMessageEntry(createAssistantMessage("assistant msg 3", createMockUsage(8000, 2000)), u3.id); const pathEntries = [u1, a1, u2, a2, compaction1, u3, a3]; const preparation = getOrThrow(prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS)); expect(preparation).toBeDefined(); expect(preparation?.previousSummary).toBe("First summary"); - expect(preparation?.firstKeptEntryId).toBeTruthy(); expect(preparation?.retainedTail.length).toBeGreaterThan(0); expect(preparation?.tokensBefore).toBe(estimateContextTokens(buildSessionContext(pathEntries).messages).tokens); }); + it("carries a previous compaction's retained tail into the next preparation", () => { + const retainedUser = createUserMessage("retained user"); + const retainedAssistant = createAssistantMessage("retained assistant"); + const compaction = createCompactionEntry("previous summary", null, [retainedUser, retainedAssistant]); + const user = createMessageEntry(createUserMessage("new user"), compaction.id); + const assistant = createMessageEntry(createAssistantMessage("new assistant"), user.id); + + const preparation = getOrThrow( + prepareCompaction([compaction, user, assistant], { + enabled: true, + reserveTokens: 100, + keepRecentTokens: 1, + }), + ); + expect(preparation?.previousSummary).toBe("previous summary"); + expect([ + ...(preparation?.messagesToSummarize ?? []), + ...(preparation?.turnPrefixMessages ?? []), + ...(preparation?.retainedTail ?? []), + ]).toEqual([retainedUser, retainedAssistant, user.message, assistant.message]); + }); + it("prepares split-turn compaction with prior file-operation details", () => { const u1 = createMessageEntry(createUserMessage("user msg 1")); const assistantMessage: AssistantMessage = { @@ -414,8 +412,8 @@ describe("harness compaction", () => { }; const a1 = createMessageEntry(assistantMessage, u1.id); const compaction1: CompactionEntry = { - ...createCompactionEntry("First summary", u1.id, a1.id), - details: { readFiles: ["old-read.ts"], modifiedFiles: ["old-edit.ts"] }, + ...createCompactionEntry("First summary", a1.id), + details: { readFiles: ["old-read.ts"], modifiedFiles: ["old-edit.ts", "written.ts"] }, }; const u2 = createMessageEntry(createUserMessage("large turn"), compaction1.id); const a2 = createMessageEntry(createAssistantMessage("large assistant message"), u2.id); @@ -431,42 +429,11 @@ describe("harness compaction", () => { expect(preparation?.turnPrefixMessages.map((message) => message.role)).toEqual(["user"]); expect([...preparation!.fileOps.read]).toContain("old-read.ts"); expect([...preparation!.fileOps.edited]).toContain("old-edit.ts"); - expect([...preparation!.fileOps.written]).toContain("written.ts"); - }); - - it("prepares custom and branch summary entries for summarization", () => { - const branchSummary: BranchSummaryEntry = { - type: "branch_summary", - id: createId(), - parentId: null, - timestamp: new Date().toISOString(), - fromId: "branch", - summary: "branch summary", - }; - const customMessage: CustomMessageEntry = { - type: "custom_message", - id: createId(), - parentId: branchSummary.id, - timestamp: new Date().toISOString(), - customType: "note", - content: "custom content", - display: true, - }; - const user = createMessageEntry(createUserMessage("keep"), customMessage.id); - const assistant = createMessageEntry(createAssistantMessage("assistant"), user.id); - const preparation = getOrThrow( - prepareCompaction([branchSummary, customMessage, user, assistant], { - enabled: true, - reserveTokens: 100, - keepRecentTokens: 1, - }), - ); - - expect(preparation?.messagesToSummarize.map((message) => message.role)).toEqual(["branchSummary", "custom"]); + expect([...preparation!.fileOps.edited]).toContain("written.ts"); }); it("does not prepare compaction when there is nothing valid to compact", () => { - const compaction = createCompactionEntry("already compacted", "entry-keep"); + const compaction = createCompactionEntry("already compacted"); expect(getOrThrow(prepareCompaction([compaction], DEFAULT_COMPACTION_SETTINGS))).toBeUndefined(); expect(getOrThrow(prepareCompaction([], DEFAULT_COMPACTION_SETTINGS))).toBeUndefined(); }); @@ -592,7 +559,6 @@ describe("harness compaction", () => { }, ]); const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", messagesToSummarize: messages, turnPrefixMessages: messages, retainedTail: messages, @@ -613,7 +579,6 @@ describe("harness compaction", () => { it("returns compaction error results without throwing", async () => { const messages: AgentMessage[] = [createUserMessage("Summarize this.")]; const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", messagesToSummarize: messages, turnPrefixMessages: [], retainedTail: messages, @@ -628,14 +593,6 @@ describe("harness compaction", () => { ok: false, error: { code: "summarization_failed", message: "Summarization failed: history failed" }, }); - - const { model: invalidModel } = createFauxModel(false); - const invalidResult = await compact( - { ...preparation, messagesToSummarize: [], firstKeptEntryId: "" }, - models, - invalidModel, - ); - expect(invalidResult).toMatchObject({ ok: false, error: { code: "invalid_session" } }); }); it("combines usage for split-turn compaction summaries", async () => { @@ -648,7 +605,6 @@ describe("harness compaction", () => { { ...fauxAssistantMessage("turn prefix summary"), usage: turnPrefixUsage }, ]); const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", messagesToSummarize: messages, turnPrefixMessages: messages, isSplitTurn: true, @@ -674,7 +630,6 @@ describe("harness compaction", () => { }, ]); const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", messagesToSummarize: [], turnPrefixMessages: messages, retainedTail: messages, @@ -692,7 +647,6 @@ describe("harness compaction", () => { it("returns turn-prefix compaction errors without throwing", async () => { const messages: AgentMessage[] = [createUserMessage("Summarize this.")]; const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", messagesToSummarize: [], turnPrefixMessages: messages, retainedTail: messages, @@ -732,7 +686,6 @@ describe("harness compaction", () => { faux.setResponses([fauxAssistantMessage("## Goal\nTest summary")]); const result = getOrThrow(await compact(preparation!, models, model)); expect(result.summary.length).toBeGreaterThan(0); - expect(result.firstKeptEntryId).toBeTruthy(); expect(result.usage?.totalTokens).toBeGreaterThan(0); expect(result.retainedTail?.length).toBeGreaterThan(0); expect(result.details).toBeDefined(); diff --git a/packages/agent/test/harness/repo.test.ts b/packages/agent/test/harness/repo.test.ts deleted file mode 100644 index 5379a77ef3b..00000000000 --- a/packages/agent/test/harness/repo.test.ts +++ /dev/null @@ -1,645 +0,0 @@ -import { existsSync, writeFileSync } from "node:fs"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { JsonlSessionBackend, JsonlSessionRepository } from "../../src/harness/session/jsonl-repo.ts"; -import { InMemorySessionBackend, InMemorySessionRepository } from "../../src/harness/session/memory-repo.ts"; -import { createSession } from "../../src/harness/session/session.ts"; -import type { SessionForkSelection, SessionMetadata, SessionStorage } from "../../src/harness/types.ts"; -import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts"; - -afterEach(() => { - vi.useRealTimers(); -}); - -class CountingReadEnv extends NodeExecutionEnv { - readCount = 0; - - override async readTextFile(path: string, abortSignal?: AbortSignal) { - this.readCount += 1; - return super.readTextFile(path, abortSignal); - } -} - -function createDeferred(): Deferred { - let resolve!: () => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -} - -interface Deferred { - promise: Promise; - resolve: () => void; -} - -function settlesBeforeNextTurn(promise: Promise): Promise { - return Promise.race([ - promise.then(() => true), - new Promise((resolve) => setImmediate(() => resolve(false))), - ]); -} - -class PerPathBlockingAppendEnv extends NodeExecutionEnv { - private readonly appendBlocks = new Map(); - private listBlock: { started: Deferred; release: Deferred } | undefined; - readonly appendCounts = new Map(); - - blockAppend(path: string): { started: Deferred; release: Deferred } { - const block = { started: createDeferred(), release: createDeferred() }; - this.appendBlocks.set(path, block); - return block; - } - - blockNextList(): { started: Deferred; release: Deferred } { - const block = { started: createDeferred(), release: createDeferred() }; - this.listBlock = block; - return block; - } - - override async exists(path: string) { - if (this.appendBlocks.has(path)) return { ok: true as const, value: true }; - return super.exists(path); - } - - override async appendFile(path: string, content: string | Uint8Array) { - this.appendCounts.set(path, (this.appendCounts.get(path) ?? 0) + 1); - const block = this.appendBlocks.get(path); - if (block) { - block.started.resolve(); - await block.release.promise; - } - return super.appendFile(path, content); - } - - override async listDir(path: string) { - const block = this.listBlock; - if (block) { - this.listBlock = undefined; - block.started.resolve(); - await block.release.promise; - } - return super.listDir(path); - } -} - -function createEntry(id: string) { - return { - type: "message" as const, - id, - parentId: null, - timestamp: "2026-01-01T00:00:00.000Z", - message: createUserMessage(id), - }; -} - -class BlockingCreateEnv extends NodeExecutionEnv { - readonly firstWriteStarted = createDeferred(); - readonly secondWriteStarted = createDeferred(); - readonly releaseFirstWrite = createDeferred(); - readonly writtenPaths = new Set(); - readonly writePaths: string[] = []; - - override async createDir() { - return { ok: true as const, value: undefined }; - } - - override async exists(path: string) { - return { ok: true as const, value: this.writtenPaths.has(path) }; - } - - override async writeFile(path: string) { - this.writePaths.push(path); - if (this.writePaths.length === 1) { - this.firstWriteStarted.resolve(); - await this.releaseFirstWrite.promise; - } - if (this.writePaths.length === 2) this.secondWriteStarted.resolve(); - this.writtenPaths.add(path); - return { ok: true as const, value: undefined }; - } -} - -class BlockingAppendEnv extends NodeExecutionEnv { - readonly appendStarted = createDeferred(); - readonly releaseAppend = createDeferred(); - - override async appendFile(path: string, content: string | Uint8Array) { - this.appendStarted.resolve(); - await this.releaseAppend.promise; - return super.appendFile(path, content); - } -} - -interface SessionBackendReadCounter { - openCount: number; - readHeadCount: number; - readEntriesCount: number; - readPathCount: number; - forkSelections: SessionForkSelection[]; -} - -function createCountingInMemorySessionBackend(): { - backend: Pick; - counter: SessionBackendReadCounter; -} { - const source = new InMemorySessionBackend(); - const counter: SessionBackendReadCounter = { - openCount: 0, - readHeadCount: 0, - readEntriesCount: 0, - readPathCount: 0, - forkSelections: [], - }; - const countReads = (storage: SessionStorage): SessionStorage => ({ - metadata: storage.metadata, - readHead: () => { - counter.readHeadCount += 1; - return storage.readHead(); - }, - readEntry: (id) => storage.readEntry(id), - readEntries: (options) => { - counter.readEntriesCount += 1; - return storage.readEntries(options); - }, - appendEntry: (entry) => storage.appendEntry(entry), - findEntriesOnBranch: (query) => storage.findEntriesOnBranch(query), - readPathToRootOrCompaction: (leafId) => { - counter.readPathCount += 1; - return storage.readPathToRootOrCompaction(leafId); - }, - getLabel: (id) => storage.getLabel(id), - getName: () => storage.getName(), - getStats: () => storage.getStats(), - }); - return { - counter, - backend: { - create: (options) => source.create(options), - async open(metadata) { - counter.openCount += 1; - return countReads(await source.open(metadata)); - }, - list: () => source.list(), - delete: (metadata) => source.delete(metadata), - fork: (metadata, options, selection) => { - counter.forkSelections.push(selection); - return source.fork(metadata, options, selection); - }, - [Symbol.asyncDispose]: () => source[Symbol.asyncDispose](), - }, - }; -} - -describe("InMemorySessionBackend", () => { - it("opens, deletes, and forks by metadata", async () => { - const repo = new InMemorySessionRepository(); - const session = await repo.create({ id: "session-1" }); - const metadata = await session.getMetadata(); - const user1 = await session.appendMessage(createUserMessage("one")); - const assistant1 = await session.appendMessage(createAssistantMessage("two")); - const user2 = await session.appendMessage(createUserMessage("three")); - await expect((await repo.open(metadata)).getMetadata()).resolves.toEqual(metadata); - expect((await repo.list()).map((info) => info.id)).toEqual(["session-1"]); - const fork = await repo.fork(metadata, { entryId: user2, id: "session-2" }); - expect((await fork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1]); - const fullFork = await repo.fork(metadata, { id: "session-3" }); - const throughFork = await repo.fork(metadata, { - entryId: assistant1, - position: "at", - id: "session-4", - }); - expect((await throughFork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1]); - await expect(repo.fork(metadata, { entryId: assistant1, id: "session-5" })).rejects.toMatchObject({ - code: "invalid_fork_target", - }); - await expect(repo.fork(metadata, { entryId: "missing", id: "session-6" })).rejects.toMatchObject({ - code: "invalid_fork_target", - }); - expect((await fullFork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1, user2]); - await repo.delete(metadata); - await expect(repo.open(metadata)).rejects.toThrow("Session not found: session-1"); - }); - - it("delegates full-session fork selection without opening the source", async () => { - const { backend, counter } = createCountingInMemorySessionBackend(); - const source = await createSession(await backend.create({ id: "session-1" })); - await source.appendMessage(createUserMessage("one")); - - const fork = await createSession( - await backend.fork(await source.getMetadata(), { id: "session-2" }, { kind: "all" }), - ); - - expect((await fork.getEntries()).map((entry) => entry.id)).toHaveLength(1); - expect(counter).toMatchObject({ - openCount: 0, - readHeadCount: 0, - readEntriesCount: 0, - readPathCount: 0, - forkSelections: [{ kind: "all" }], - }); - }); - - it("retains the opened aggregate instead of reloading for scoped reads", async () => { - const { backend, counter } = createCountingInMemorySessionBackend(); - const session = await createSession(await backend.create({ id: "session-1" })); - const entryId = await session.appendMessage(createUserMessage("one")); - - await session.getMetadata(); - await session.getLeafId(); - await session.getEntry(entryId); - expect(counter.openCount).toBe(0); - }); - - it("builds context from the branch storage without loading complete history", async () => { - const { backend, counter } = createCountingInMemorySessionBackend(); - const created = await createSession(await backend.create({ id: "session-1" })); - await created.appendMessage(createUserMessage("one")); - const opened = await createSession(await backend.open(await created.getMetadata())); - - await opened.buildContext(); - - expect(counter).toMatchObject({ openCount: 1, readHeadCount: 1, readEntriesCount: 0, readPathCount: 1 }); - }); - - it("rejects repository operations and session writes after disposal", async () => { - const repo = new InMemorySessionRepository(); - const session = await repo.create({ id: "session-1" }); - await repo[Symbol.asyncDispose](); - - await expect(repo.list()).rejects.toThrow("In-memory session repository is disposed"); - await expect(session.appendMessage(createUserMessage("late"))).rejects.toThrow( - "In-memory session repository is disposed", - ); - }); - - it("supports lexical ownership with await using", async () => { - let listDisposedRepository: (() => Promise) | undefined; - { - await using repository = new InMemorySessionRepository(); - listDisposedRepository = () => repository.list(); - await repository.create({ id: "session-1" }); - } - - await expect(listDisposedRepository!()).rejects.toThrow("In-memory session repository is disposed"); - }); -}); - -describe("JsonlSessionBackend", () => { - it.each(["create", "fork"] as const)( - "serializes conflicting create and %s destinations", - async (secondOperation) => { - vi.useFakeTimers({ toFake: ["Date"] }); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - const root = createTempDir(); - const env = new BlockingCreateEnv({ cwd: root }); - const sourcePath = `${root}/source.jsonl`; - writeFileSync( - sourcePath, - `${JSON.stringify({ type: "session", version: 3, id: "source", timestamp: "2025-01-01T00:00:00.000Z", cwd: "/tmp/source" })}\n`, - ); - env.writtenPaths.add(sourcePath); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: root }); - const options = { cwd: "/tmp/target", id: "shared-id" }; - const first = backend.create(options); - await env.firstWriteStarted.promise; - const second = - secondOperation === "create" - ? backend.create(options) - : backend.fork( - { - id: "source", - createdAt: "2025-01-01T00:00:00.000Z", - cwd: "/tmp/source", - path: sourcePath, - }, - options, - { kind: "all" }, - ); - const secondStartedBeforeFirstFinished = await settlesBeforeNextTurn(env.secondWriteStarted.promise); - - env.releaseFirstWrite.resolve(); - const results = await Promise.allSettled([first, second]); - - expect(secondStartedBeforeFirstFinished).toBe(false); - expect(env.writePaths).toHaveLength(1); - expect(results.map((result) => result.status)).toEqual(["fulfilled", "rejected"]); - expect(results[1]).toMatchObject({ - status: "rejected", - reason: { code: "invalid_session", message: expect.stringContaining("Session already exists") }, - }); - }, - ); - - it("encodes custom session IDs used in filenames", async () => { - const root = createTempDir(); - const backend = new JsonlSessionBackend({ fs: new NodeExecutionEnv({ cwd: root }), sessionsRoot: root }); - const snapshot = await backend.create({ cwd: root, id: "../unsafe\\id" }); - - expect(snapshot.metadata.path).toContain("..%2Funsafe%5Cid.jsonl"); - expect(existsSync(snapshot.metadata.path)).toBe(true); - }); - - it("allows appends to different sessions to run concurrently", async () => { - const root = createTempDir(); - const env = new PerPathBlockingAppendEnv({ cwd: root }); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: root }); - const first = await backend.create({ cwd: "/tmp/first", id: "first" }); - const second = await backend.create({ cwd: "/tmp/second", id: "second" }); - const firstBlock = env.blockAppend(first.metadata.path); - const secondBlock = env.blockAppend(second.metadata.path); - - const firstAppend = first.appendEntry(createEntry("first-entry")); - await firstBlock.started.promise; - const secondAppend = second.appendEntry(createEntry("second-entry")); - const secondStartedBeforeFirstFinished = await settlesBeforeNextTurn(secondBlock.started.promise); - - firstBlock.release.resolve(); - secondBlock.release.resolve(); - await Promise.all([firstAppend, secondAppend]); - expect(secondStartedBeforeFirstFinished).toBe(true); - }); - - it("caps concurrent operations across JSONL sessions at four by default", async () => { - const root = createTempDir(); - const env = new PerPathBlockingAppendEnv({ cwd: root }); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: root }); - const snapshots = []; - for (let index = 0; index < 6; index++) { - snapshots.push(await backend.create({ cwd: `/tmp/session-${index}`, id: `session-${index}` })); - } - const blocks = snapshots.map((snapshot) => env.blockAppend(snapshot.metadata.path)); - const appends = snapshots.map((snapshot, index) => snapshot.appendEntry(createEntry(`entry-${index}`))); - await Promise.all(blocks.slice(0, 4).map((block) => block.started.promise)); - const fifthStartedWithoutCapacity = await settlesBeforeNextTurn(blocks[4]!.started.promise); - const sixthStartedWithoutCapacity = await settlesBeforeNextTurn(blocks[5]!.started.promise); - - for (const block of blocks) block.release.resolve(); - await Promise.all(appends); - - expect(fifthStartedWithoutCapacity).toBe(false); - expect(sixthStartedWithoutCapacity).toBe(false); - }); - - it("allows overriding the JSONL concurrency limit", async () => { - const root = createTempDir(); - const env = new PerPathBlockingAppendEnv({ cwd: root }); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: root, maxConcurrentOperations: 1 }); - const first = await backend.create({ cwd: "/tmp/first", id: "first" }); - const second = await backend.create({ cwd: "/tmp/second", id: "second" }); - const firstBlock = env.blockAppend(first.metadata.path); - const secondBlock = env.blockAppend(second.metadata.path); - const firstAppend = first.appendEntry(createEntry("first-entry")); - await firstBlock.started.promise; - const secondAppend = second.appendEntry(createEntry("second-entry")); - const secondStartedBeforeFirstFinished = await settlesBeforeNextTurn(secondBlock.started.promise); - - firstBlock.release.resolve(); - secondBlock.release.resolve(); - await Promise.all([firstAppend, secondAppend]); - expect(secondStartedBeforeFirstFinished).toBe(false); - }); - - it.each([0, -1, 1.5, Number.POSITIVE_INFINITY])( - "rejects invalid JSONL concurrency limit %s", - (maxConcurrentOperations) => { - const root = createTempDir(); - expect( - () => - new JsonlSessionBackend({ - fs: new NodeExecutionEnv({ cwd: root }), - sessionsRoot: root, - maxConcurrentOperations, - }), - ).toThrow("maxConcurrentOperations must be a positive integer"); - }, - ); - - it("releases JSONL concurrency capacity after an operation fails", async () => { - const root = createTempDir(); - const backend = new JsonlSessionBackend({ - fs: new NodeExecutionEnv({ cwd: root }), - sessionsRoot: root, - maxConcurrentOperations: 1, - }); - const first = await backend.create({ cwd: "/tmp/first", id: "first" }); - const second = await backend.create({ cwd: "/tmp/second", id: "second" }); - const duplicate = createEntry("duplicate-entry"); - await first.appendEntry(duplicate); - - const failure = first.appendEntry(duplicate); - const nextSession = second.appendEntry(createEntry("next-entry")); - - await expect(failure).rejects.toThrow("Entry duplicate-entry already exists"); - await expect(nextSession).resolves.toBeUndefined(); - }); - - it("serializes appends to the same session", async () => { - const root = createTempDir(); - const env = new PerPathBlockingAppendEnv({ cwd: root }); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: root }); - const snapshot = await backend.create({ cwd: root, id: "session" }); - const block = env.blockAppend(snapshot.metadata.path); - - const firstAppend = snapshot.appendEntry(createEntry("first-entry")); - await block.started.promise; - const secondAppend = snapshot.appendEntry(createEntry("second-entry")); - await new Promise((resolve) => setImmediate(resolve)); - const appendCountWhileFirstWasBlocked = env.appendCounts.get(snapshot.metadata.path); - - block.release.resolve(); - await Promise.all([firstAppend, secondAppend]); - expect(appendCountWhileFirstWasBlocked).toBe(1); - expect((await (await backend.open(snapshot.metadata)).readEntries()).map((entry) => entry.id)).toEqual([ - "first-entry", - "second-entry", - ]); - }); - - it("uses listing as a barrier between accepted session operations", async () => { - const root = createTempDir(); - const env = new PerPathBlockingAppendEnv({ cwd: root }); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: root }); - const first = await backend.create({ cwd: "/tmp/first", id: "first" }); - const second = await backend.create({ cwd: "/tmp/second", id: "second" }); - const firstBlock = env.blockAppend(first.metadata.path); - const secondBlock = env.blockAppend(second.metadata.path); - const listBlock = env.blockNextList(); - - const firstAppend = first.appendEntry(createEntry("first-entry")); - await firstBlock.started.promise; - const list = backend.list(); - const secondAppend = second.appendEntry(createEntry("second-entry")); - const listStartedBeforeFirstFinished = await settlesBeforeNextTurn(listBlock.started.promise); - - firstBlock.release.resolve(); - await firstAppend; - await listBlock.started.promise; - const secondStartedBeforeListFinished = await settlesBeforeNextTurn(secondBlock.started.promise); - listBlock.release.resolve(); - await list; - await secondBlock.started.promise; - secondBlock.release.resolve(); - await secondAppend; - - expect(listStartedBeforeFirstFinished).toBe(false); - expect(secondStartedBeforeListFinished).toBe(false); - }); - - it("waits for every accepted session operation during disposal", async () => { - const root = createTempDir(); - const env = new PerPathBlockingAppendEnv({ cwd: root }); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: root }); - const first = await backend.create({ cwd: "/tmp/first", id: "first" }); - const second = await backend.create({ cwd: "/tmp/second", id: "second" }); - const firstBlock = env.blockAppend(first.metadata.path); - const secondBlock = env.blockAppend(second.metadata.path); - const firstAppend = first.appendEntry(createEntry("first-entry")); - const secondAppend = second.appendEntry(createEntry("second-entry")); - await Promise.all([firstBlock.started.promise, secondBlock.started.promise]); - - let disposed = false; - const dispose = backend[Symbol.asyncDispose]().then(() => { - disposed = true; - }); - firstBlock.release.resolve(); - await firstAppend; - await new Promise((resolve) => setImmediate(resolve)); - const disposedWhileSecondWasBlocked = disposed; - secondBlock.release.resolve(); - await Promise.all([secondAppend, dispose]); - - expect(disposedWhileSecondWasBlocked).toBe(false); - expect(disposed).toBe(true); - }); - - it("waits for accepted appends before disposal and rejects later writes", async () => { - const root = createTempDir(); - const env = new BlockingAppendEnv({ cwd: root }); - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const append = session.appendMessage(createUserMessage("accepted")); - await env.appendStarted.promise; - let closed = false; - const dispose = repo[Symbol.asyncDispose]().then(() => { - closed = true; - }); - await Promise.resolve(); - expect(closed).toBe(false); - env.releaseAppend.resolve(); - await append; - await dispose; - await expect(session.appendMessage(createUserMessage("late"))).rejects.toThrow( - "JSONL session repository is disposed", - ); - }); - - it("parses once when opened and retains state across appends", async () => { - const root = createTempDir(); - const env = new CountingReadEnv({ cwd: root }); - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const created = await repo.create({ cwd: root, id: "session-1" }); - const metadata = await created.getMetadata(); - await created.appendMessage(createUserMessage("initial")); - env.readCount = 0; - const opened = await repo.open(metadata); - for (let i = 0; i < 10; i++) await opened.appendMessage(createUserMessage(`message ${i}`)); - expect(env.readCount).toBe(1); - }); - - it("collects sessions below encoded cwd directories and lists by cwd", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const cwd = "/tmp/my-project"; - const otherCwd = "/tmp/other-project"; - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const session = await repo.create({ cwd, id: "019de8c2-de29-73e9-ae0c-e134db34c447" }); - const otherSession = await repo.create({ cwd: otherCwd, id: "other-session" }); - const metadata = await session.getMetadata(); - const otherMetadata = await otherSession.getMetadata(); - expect(metadata.path).toContain("--tmp-my-project--"); - expect(otherMetadata.path).toContain("--tmp-other-project--"); - expect(existsSync(metadata.path)).toBe(true); - expect((await repo.list({ cwd })).map((sessionMetadata) => sessionMetadata.id)).toEqual([metadata.id]); - expect((await repo.list()).map((sessionMetadata) => sessionMetadata.id).sort()).toEqual( - [metadata.id, otherMetadata.id].sort(), - ); - }); - - it("fails loudly when listing a malformed session file", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const metadata = await session.getMetadata(); - writeFileSync(metadata.path, "not json\n"); - - await expect(repo.list()).rejects.toMatchObject({ code: "invalid_session" }); - }); - - it("rejects a missing active leaf when opened", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: root }); - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const storage = await backend.create({ cwd: root, id: "session-1" }); - await storage.appendEntry({ - type: "leaf", - id: "leaf", - parentId: null, - targetId: "missing", - timestamp: "2026-01-01T00:00:00.000Z", - }); - - await expect(repo.open(storage.metadata)).rejects.toMatchObject({ - code: "invalid_session", - message: "Entry missing not found", - }); - }); - - it("opens, deletes, and forks by metadata", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const source = await repo.create({ cwd: "/tmp/source", id: "source-session" }); - const sourceMetadata = await source.getMetadata(); - const user1 = await source.appendMessage(createUserMessage("one")); - const assistant1 = await source.appendMessage(createAssistantMessage("two")); - const user2 = await source.appendMessage(createUserMessage("three")); - await expect((await repo.open(sourceMetadata)).getMetadata()).resolves.toEqual(sourceMetadata); - const fork = await repo.fork(sourceMetadata, { cwd: "/tmp/target", id: "fork-session", entryId: user2 }); - const forkMetadata = await fork.getMetadata(); - expect(forkMetadata.cwd).toBe("/tmp/target"); - expect(forkMetadata.parentSessionPath).toBe(sourceMetadata.path); - expect((await fork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1]); - const fullFork = await repo.fork(sourceMetadata, { cwd: "/tmp/target", id: "full-fork-session" }); - expect((await fullFork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1, user2]); - await repo.delete(sourceMetadata); - expect(existsSync(sourceMetadata.path)).toBe(false); - await expect(repo.open(sourceMetadata)).rejects.toThrow("Session not found"); - }); - - it("persists header metadata through create, list, and fork", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const source = await repo.create({ - cwd: "/tmp/source", - id: "source-session", - metadata: { profile: "reviewer" }, - }); - const sourceMetadata = await source.getMetadata(); - expect(sourceMetadata.metadata).toEqual({ profile: "reviewer" }); - expect((await repo.list({ cwd: "/tmp/source" })).map((listed) => listed.metadata)).toEqual([ - { profile: "reviewer" }, - ]); - const fork = await repo.fork(sourceMetadata, { cwd: "/tmp/target", id: "fork-session" }); - expect((await fork.getMetadata()).metadata).toEqual({ profile: "reviewer" }); - const overridden = await repo.fork(sourceMetadata, { - cwd: "/tmp/target", - id: "overridden-session", - metadata: { profile: "writer" }, - }); - expect((await overridden.getMetadata()).metadata).toEqual({ profile: "writer" }); - }); -}); diff --git a/packages/agent/test/harness/session-backends.test.ts b/packages/agent/test/harness/session-backends.test.ts deleted file mode 100644 index 7ccc27ffefd..00000000000 --- a/packages/agent/test/harness/session-backends.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; -import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { - JsonlSessionBackend, - JsonlSessionRepository, - loadJsonlSessionMetadata, -} from "../../src/harness/session/jsonl-repo.ts"; -import { InMemorySessionRepository } from "../../src/harness/session/memory-repo.ts"; -import type { Session } from "../../src/harness/session/session.ts"; -import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts"; - -async function appendUsageEntries(session: Session) { - const assistant = createAssistantMessage("reply"); - if (assistant.role !== "assistant") throw new Error("Expected assistant test message"); - assistant.usage = { - input: 10, - output: 20, - cacheRead: 30, - cacheWrite: 40, - totalTokens: 100, - cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, - }; - await session.appendMessage(assistant); - await session.appendCompaction("summary", undefined, 1234, undefined, undefined, { - input: 1, - output: 2, - cacheRead: 3, - cacheWrite: 4, - totalTokens: 10, - cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 }, - }); - await session.moveTo(await session.getLeafId(), { - summary: "branch", - usage: { - input: 5, - output: 6, - cacheRead: 7, - cacheWrite: 8, - totalTokens: 26, - cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 }, - }, - }); -} - -describe("Session aggregate", () => { - it("repository disposal closes its owned storage", async () => { - let session: Session; - { - await using repository = new InMemorySessionRepository(); - session = await repository.create({ id: "session-1" }); - } - - await expect(session!.appendMessage(createUserMessage("late"))).rejects.toThrow( - "In-memory session repository is disposed", - ); - }); - - it("owns leaf navigation, labels, names, stats, and branch traversal", async () => { - const repo = new InMemorySessionRepository(); - const session = await repo.create({ id: "session-1" }); - const root = await session.appendMessage(createUserMessage("root")); - const child = await session.appendMessage(createAssistantMessage("child")); - await session.appendLabel(root, "checkpoint"); - await session.appendSessionName(" review\nname "); - - expect(await session.getLeafId()).not.toBeNull(); - expect(await session.getLabel(root)).toBe("checkpoint"); - expect(await session.getSessionName()).toBe("review name"); - expect((await session.getBranch(child)).map((entry) => entry.id)).toEqual([root, child]); - - await session.moveTo(root); - expect(await session.getLeafId()).toBe(root); - expect((await session.getEntries()).at(-1)).toMatchObject({ type: "leaf", targetId: root }); - await expect(session.moveTo("missing")).rejects.toThrow("Entry missing not found"); - }); - - it("serializes concurrent appends into one parent chain", async () => { - const session = await new InMemorySessionRepository().create({}); - await Promise.all( - Array.from({ length: 20 }, (_, index) => session.appendMessage(createUserMessage(`message ${index}`))), - ); - const entries = await session.getEntries(); - expect(entries).toHaveLength(20); - for (let index = 0; index < entries.length; index++) { - expect(entries[index]!.parentId).toBe(index === 0 ? null : entries[index - 1]!.id); - } - }); - - it("includes assistant and summary usage in statistics", async () => { - const session = await new InMemorySessionRepository().create({}); - await appendUsageEntries(session); - expect(await session.getSessionStats()).toEqual({ - messageCount: 1, - cachedTokens: 40, - uncachedTokens: 68, - totalTokens: 136, - costTotal: 1.36, - }); - }); - - it("stops branch traversal at retained-tail compaction", async () => { - const session = await new InMemorySessionRepository().create({}); - await session.appendMessage(createUserMessage("root")); - const child = await session.appendMessage(createAssistantMessage("child")); - const compaction = await session.appendCompaction("summary", child, 1234, undefined, undefined, undefined, [ - createAssistantMessage("child"), - ]); - const tail = await session.appendMessage(createUserMessage("after")); - expect((await session.getBranch(tail)).map((entry) => entry.id)).toEqual([compaction, tail]); - }); -}); - -describe("JsonlSessionBackend", () => { - it("writes headers and entries and reopens the aggregate", async () => { - const root = createTempDir(); - const repo = new JsonlSessionRepository({ fs: new NodeExecutionEnv({ cwd: root }), sessionsRoot: root }); - const session = await repo.create({ cwd: root, id: "session-1", metadata: { profile: "reviewer" } }); - const metadata = await session.getMetadata(); - const entryId = await session.appendMessage(createUserMessage("one")); - const reopened = await repo.open(metadata); - - expect(existsSync(metadata.path)).toBe(true); - expect((await reopened.getEntries()).map((entry) => entry.id)).toEqual([entryId]); - expect((await loadJsonlSessionMetadata(new NodeExecutionEnv({ cwd: root }), metadata.path)).metadata).toEqual({ - profile: "reviewer", - }); - const lines = readFileSync(metadata.path, "utf8").trim().split("\n"); - expect(JSON.parse(lines[0]!)).toMatchObject({ type: "session", version: 3, id: "session-1" }); - expect(JSON.parse(lines[1]!)).toMatchObject({ id: entryId, type: "message" }); - }); - - it("fails loudly for malformed headers and entries", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const metadata = await session.getMetadata(); - writeFileSync(metadata.path, "not json\n"); - await expect(repo.open(metadata)).rejects.toMatchObject({ code: "invalid_session" }); - - const header = { type: "session", version: 3, id: "session-1", timestamp: metadata.createdAt, cwd: root }; - writeFileSync(metadata.path, `${JSON.stringify(header)}\nnot json\n`); - await expect(repo.open(metadata)).rejects.toMatchObject({ code: "invalid_entry" }); - }); - - it("enforces entry uniqueness and does not recreate deleted files", async () => { - const root = createTempDir(); - const backend = new JsonlSessionBackend({ fs: new NodeExecutionEnv({ cwd: root }), sessionsRoot: root }); - const snapshot = await backend.create({ cwd: root, id: "session-1" }); - const entry = { - type: "message" as const, - id: "entry-1", - parentId: null, - timestamp: "2026-01-01T00:00:00.000Z", - message: createUserMessage("one"), - }; - await snapshot.appendEntry(entry); - await expect(snapshot.appendEntry(entry)).rejects.toThrow("Entry entry-1 already exists"); - await backend.delete(snapshot.metadata); - await expect(snapshot.appendEntry({ ...entry, id: "entry-2" })).rejects.toThrow("Session not found"); - expect(existsSync(snapshot.metadata.path)).toBe(false); - }); - - it("scopes entry uniqueness to the session path", async () => { - const root = createTempDir(); - const backend = new JsonlSessionBackend({ fs: new NodeExecutionEnv({ cwd: root }), sessionsRoot: root }); - const first = await backend.create({ cwd: "/tmp/first", id: "shared-session-id" }); - const second = await backend.create({ cwd: "/tmp/second", id: "shared-session-id" }); - const entry = { - type: "message" as const, - id: "shared-entry-id", - parentId: null, - timestamp: "2026-01-01T00:00:00.000Z", - message: createUserMessage("one"), - }; - - await first.appendEntry(entry); - await expect(second.appendEntry(entry)).resolves.toBeUndefined(); - }); - - it("rejects non-object header metadata", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const metadata = await session.getMetadata(); - const header = { - type: "session", - version: 3, - id: metadata.id, - timestamp: metadata.createdAt, - cwd: root, - metadata: "profile", - }; - writeFileSync(metadata.path, `${JSON.stringify(header)}\n`); - await expect(repo.open(metadata)).rejects.toThrow("session header metadata must be an object"); - }); -}); diff --git a/packages/agent/test/harness/session-test-utils.ts b/packages/agent/test/harness/session-test-utils.ts index 81f98491d6c..ee17d43d288 100644 --- a/packages/agent/test/harness/session-test-utils.ts +++ b/packages/agent/test/harness/session-test-utils.ts @@ -1,26 +1,17 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { + AgentMessage, BranchSummaryEntry, CompactionEntry, Session as CoreSession, Entry, MessageEntry, - ModelChangeEntry, - ThinkingLevelChangeEntry, -} from "@earendil-works/pi-agent-core/experimental"; +} from "@earendil-works/pi-agent-core"; import type { Usage } from "@earendil-works/pi-ai"; import { afterEach } from "vitest"; import type { SqliteSessionMetadata } from "../../../storage/sqlite-node/src/index.ts"; -import { InMemorySessionRepository } from "../../src/harness/session/memory-repo.ts"; -import type { Session } from "../../src/harness/session/session.ts"; - -export async function createInMemorySession(id?: string): Promise { - return new InMemorySessionRepository().create({ id }); -} - export function createUserMessage(text: string): AgentMessage { return { role: "user", @@ -55,10 +46,8 @@ export type SqliteTestMessage = MessageEntry["message"]; export async function appendSqliteCompaction( session: SqliteTestSession, summary: string, - _firstKeptEntryId: string | undefined, tokensBefore: number, details?: unknown, - _fromHook?: boolean, usage?: Usage, retainedTail: SqliteTestMessage[] = [], ): Promise { @@ -78,7 +67,7 @@ export async function appendSqliteCompaction( export async function moveSqliteMainLane( session: SqliteTestSession, entryId: string | null, - summary?: { summary: string; details?: unknown; usage?: Usage; fromHook?: boolean }, + summary?: { summary: string; details?: unknown; usage?: Usage }, ): Promise { await session.moveLane("main", entryId); if (!summary) return undefined; @@ -134,38 +123,6 @@ export async function buildSqliteContext(session: SqliteTestSession): Promise<{ return { messages }; } -export async function appendSqliteThinkingLevelChange( - session: SqliteTestSession, - thinkingLevel: string, -): Promise { - const entry = await session.appendEntry( - { - type: "thinking_level_change", - id: session.idGenerator.next(), - thinkingLevel, - } satisfies Omit, - "main", - ); - return entry.id; -} - -export async function appendSqliteModelChange( - session: SqliteTestSession, - provider: string, - modelId: string, -): Promise { - const entry = await session.appendEntry( - { - type: "model_change", - id: session.idGenerator.next(), - provider, - modelId, - } satisfies Omit, - "main", - ); - return entry.id; -} - const tempDirs: string[] = []; export function createTempDir(): string { diff --git a/packages/agent/test/harness/session.test.ts b/packages/agent/test/harness/session.test.ts deleted file mode 100644 index f7ee674e9e9..00000000000 --- a/packages/agent/test/harness/session.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { JsonlSessionBackend } from "../../src/harness/session/jsonl-repo.ts"; -import { InMemorySessionBackend } from "../../src/harness/session/memory-repo.ts"; -import { - type ContextEntryTransform, - createSession, - type Session, - type SessionContextBuildOptions, -} from "../../src/harness/session/session.ts"; -import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts"; - -function getTextData(data: unknown): string { - if (typeof data !== "object" || data === null || !("text" in data)) { - return ""; - } - const value = (data as { text?: unknown }).text; - return typeof value === "string" ? value : ""; -} - -interface SessionFixture { - createSession(options?: SessionContextBuildOptions): Promise; - reloadSession(options?: SessionContextBuildOptions): Promise; -} - -async function runSessionSuite(name: string, createFixture: () => Promise, inspect?: () => void) { - describe(name, () => { - it("appends messages and builds context in order", async () => { - const session = await (await createFixture()).createSession(); - await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - const context = await session.buildContext(); - expect(context.messages.map((message) => message.role)).toEqual(["user", "assistant"]); - }); - - it("reads entries forward from the requested sequence", async () => { - const session = await (await createFixture()).createSession(); - const ids = [ - await session.appendMessage(createUserMessage("one")), - await session.appendMessage(createUserMessage("two")), - await session.appendMessage(createUserMessage("three")), - ]; - - expect((await session.getEntries({ afterEntrySeq: 0, limit: 2 })).map((entry) => entry.id)).toEqual( - ids.slice(0, 2), - ); - expect((await session.getEntries({ afterEntrySeq: 1, limit: 2 })).map((entry) => entry.id)).toEqual( - ids.slice(1), - ); - expect((await session.getEntries({ afterEntrySeq: 2 })).map((entry) => entry.id)).toEqual(ids.slice(2)); - }); - - it("tracks model and thinking level changes", async () => { - const session = await (await createFixture()).createSession(); - await session.appendMessage(createUserMessage("one")); - await session.appendModelChange("openai", "gpt-4.1"); - await session.appendThinkingLevelChange("high"); - const context = await session.buildContext(); - expect(context.thinkingLevel).toBe("high"); - expect(context.model).toEqual({ provider: "openai", modelId: "gpt-4.1" }); - }); - - it("supports branching by moving the leaf and appending a new branch", async () => { - const session = await (await createFixture()).createSession(); - const user1 = await session.appendMessage(createUserMessage("one")); - const assistant1 = await session.appendMessage(createAssistantMessage("two")); - await session.appendMessage(createUserMessage("three")); - await session.moveTo(user1); - const branched = await session.appendMessage(createAssistantMessage("branched")); - const branch = await session.getBranch(); - expect(branch.map((entry) => entry.id)).toEqual([user1, branched]); - expect(branch.map((entry) => entry.id)).not.toContain(assistant1); - const context = await session.buildContext(); - expect(context.messages.map((message) => message.role)).toEqual(["user", "assistant"]); - }); - - it("supports moving the leaf to root", async () => { - const session = await (await createFixture()).createSession(); - await session.appendMessage(createUserMessage("one")); - await session.moveTo(null); - expect(await session.getLeafId()).toBeNull(); - expect((await session.buildContext()).messages).toEqual([]); - }); - - it("reconstructs compaction summaries in context", async () => { - const session = await (await createFixture()).createSession(); - await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - const user2 = await session.appendMessage(createUserMessage("three")); - await session.appendMessage(createAssistantMessage("four")); - await session.appendCompaction("summary", user2, 1234, undefined, undefined, undefined, [ - createUserMessage("three"), - createAssistantMessage("four"), - ]); - await session.appendMessage(createUserMessage("five")); - const context = await session.buildContext(); - expect(context.messages[0]?.role).toBe("compactionSummary"); - expect(context.messages).toHaveLength(4); - expect(context.messages.map((message) => message.role)).toEqual([ - "compactionSummary", - "user", - "assistant", - "user", - ]); - }); - - it("supports moving with branch summary entries in context", async () => { - const session = await (await createFixture()).createSession(); - const user1 = await session.appendMessage(createUserMessage("one")); - const summaryId = await session.moveTo(user1, { summary: "summary text" }); - expect(summaryId).toBeTruthy(); - const summaryEntry = await session.getEntry(summaryId!); - expect(summaryEntry).toMatchObject({ type: "branch_summary", parentId: user1, fromId: user1 }); - const context = await session.buildContext(); - expect(context.messages[1]?.role).toBe("branchSummary"); - }); - - it("persists compaction usage", async () => { - const session = await (await createFixture()).createSession(); - const firstKeptEntryId = await session.appendMessage(createUserMessage("one")); - const usage = { - input: 1, - output: 2, - cacheRead: 3, - cacheWrite: 4, - totalTokens: 10, - cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, - }; - - const compactionId = await session.appendCompaction( - "summary", - firstKeptEntryId, - 1234, - undefined, - false, - usage, - ); - - const compactionEntry = await session.getEntry(compactionId); - expect(compactionEntry?.type === "compaction" ? compactionEntry.usage : undefined).toEqual(usage); - }); - - it("persists branch summary usage", async () => { - const session = await (await createFixture()).createSession(); - const user1 = await session.appendMessage(createUserMessage("one")); - const usage = { - input: 1, - output: 2, - cacheRead: 3, - cacheWrite: 4, - totalTokens: 10, - cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, - }; - - const summaryId = await session.moveTo(user1, { summary: "summary text", usage }); - - const summaryEntry = await session.getEntry(summaryId!); - expect(summaryEntry?.type === "branch_summary" ? summaryEntry.usage : undefined).toEqual(usage); - }); - - it("supports custom message entries in context", async () => { - const session = await (await createFixture()).createSession(); - await session.appendMessage(createUserMessage("one")); - await session.appendCustomMessageEntry("custom", "hello", true, { ok: true }); - const context = await session.buildContext(); - expect(context.messages[1]?.role).toBe("custom"); - }); - - it("keeps custom entries in context entries but omits them from messages by default", async () => { - const session = await (await createFixture()).createSession(); - await session.appendMessage(createUserMessage("one")); - await session.appendCustomEntry("chat_message", { text: "hello" }); - const contextEntries = await session.buildContextEntries(); - const context = await session.buildContext(); - expect(contextEntries.map((entry) => entry.type)).toEqual(["message", "custom"]); - expect(context.messages).toHaveLength(1); - }); - - it("projects custom entries with configured custom-entry projectors", async () => { - const session = await (await createFixture()).createSession({ - entryProjectors: { - chat_message: (entry) => [createUserMessage(`chat: ${getTextData(entry.data)}`)], - }, - }); - await session.appendMessage(createUserMessage("one")); - await session.appendCustomEntry("chat_message", { text: "hello" }); - const context = await session.buildContext(); - expect(context.messages.map((message) => message.role)).toEqual(["user", "user"]); - expect(context.messages[1]).toMatchObject({ content: [{ type: "text", text: "chat: hello" }] }); - }); - - it("applies context entry transforms after default compaction selection", async () => { - let observedFirstEntryType: string | undefined; - const dropCompaction: ContextEntryTransform = (entries) => { - observedFirstEntryType = entries[0]?.type; - return entries.filter((entry) => entry.type !== "compaction"); - }; - const session = await (await createFixture()).createSession({ entryTransforms: [dropCompaction] }); - await session.appendMessage(createUserMessage("one")); - const kept = await session.appendMessage(createUserMessage("two")); - await session.appendCompaction("summary", kept, 1234); - await session.appendMessage(createUserMessage("three")); - const context = await session.buildContext(); - expect(observedFirstEntryType).toBe("compaction"); - expect(context.messages.map((message) => message.role)).toEqual(["user", "user"]); - }); - - it("normalizes session names", async () => { - const session = await (await createFixture()).createSession(); - await session.appendSessionName(" hello\nworld\r\nagain "); - expect(await session.getSessionName()).toBe("hello world again"); - }); - - it("supports labels and session info entries without affecting context", async () => { - const session = await (await createFixture()).createSession(); - const user1 = await session.appendMessage(createUserMessage("one")); - await session.appendLabel(user1, "checkpoint"); - await session.appendSessionName("name"); - const entries = await session.getEntries(); - expect(entries.some((entry) => entry.type === "label")).toBe(true); - expect(entries.some((entry) => entry.type === "session_info")).toBe(true); - expect(await session.getLabel(user1)).toBe("checkpoint"); - expect(await session.getSessionName()).toBe("name"); - expect((await session.buildContext()).messages).toHaveLength(1); - }); - - it("rejects labels for missing entries", async () => { - const session = await (await createFixture()).createSession(); - await expect(session.appendLabel("missing", "checkpoint")).rejects.toThrow("Entry missing not found"); - }); - - it("persists leaf changes and appended entries through the backend", async () => { - const fixture = await createFixture(); - const session = await fixture.createSession(); - const user1 = await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - await session.appendLabel(user1, "checkpoint"); - await session.appendSessionName("name"); - await session.moveTo(user1); - await session.appendMessage(createAssistantMessage("branched")); - const session2 = await fixture.reloadSession(); - const context = await session2.buildContext(); - expect(context.messages.map((message) => message.role)).toEqual(["user", "assistant"]); - expect(await session2.getLabel(user1)).toBe("checkpoint"); - expect(await session2.getSessionName()).toBe("name"); - inspect?.(); - }); - }); -} - -runSessionSuite("Session with in-memory backend", async () => { - const backend = new InMemorySessionBackend(); - const created = await createSession(await backend.create({})); - const metadata = await created.getMetadata(); - return { - createSession: async (contextBuildOptions) => createSession(await backend.open(metadata), contextBuildOptions), - reloadSession: async (contextBuildOptions) => createSession(await backend.open(metadata), contextBuildOptions), - }; -}); - -let jsonlSessionPath = ""; -runSessionSuite( - "Session with JSONL backend", - async () => { - const dir = createTempDir(); - const env = new NodeExecutionEnv({ cwd: dir }); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: join(dir, "sessions") }); - const created = await createSession(await backend.create({ cwd: dir, id: "session-1" })); - const metadata = await created.getMetadata(); - jsonlSessionPath = metadata.path; - return { - createSession: async (contextBuildOptions) => createSession(await backend.open(metadata), contextBuildOptions), - reloadSession: async (contextBuildOptions) => createSession(await backend.open(metadata), contextBuildOptions), - }; - }, - () => { - const lines = readFileSync(jsonlSessionPath, "utf8").trim().split("\n"); - expect(lines.length).toBeGreaterThan(1); - const header = JSON.parse(lines[0]!); - expect(header.type).toBe("session"); - expect(header.version).toBe(3); - const entries = lines.slice(1).map((line) => JSON.parse(line)); - expect(entries.some((entry) => entry.type === "leaf")).toBe(true); - for (const entry of entries) { - expect(entry.type).not.toBe("entry"); - expect(typeof entry.id).toBe("string"); - } - }, -); diff --git a/packages/agent/test/harness/session/context.test.ts b/packages/agent/test/harness/session/context.test.ts new file mode 100644 index 00000000000..f439b29d0ee --- /dev/null +++ b/packages/agent/test/harness/session/context.test.ts @@ -0,0 +1,124 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AssistantMessage } from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; +import { buildSessionContext } from "../../../src/harness/session/context.ts"; +import type { Entry } from "../../../src/harness/session/types.ts"; + +function userMessage(text: string): AgentMessage { + return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; +} + +function assistantMessage(text: string): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 1, + }; +} + +type EntryWithoutStorage = TEntry extends Entry + ? Omit + : never; + +function entry(value: EntryWithoutStorage, seq: number): TEntry { + return { ...value, seq, timestamp: seq } as unknown as TEntry; +} + +describe("v4 session context", () => { + it("starts at the latest compaction and materializes its retained tail", () => { + const entries: Entry[] = [ + entry({ type: "message", id: "old", parentId: null, message: userMessage("old") }, 1), + entry( + { + type: "compaction", + id: "compact", + parentId: "old", + summary: "summary", + retainedTail: [userMessage("retained"), assistantMessage("answer")], + tokensBefore: 100, + }, + 2, + ), + entry({ type: "model_change", id: "model", parentId: "compact", provider: "openai", modelId: "gpt-5" }, 3), + entry({ type: "thinking_level_change", id: "thinking", parentId: "model", thinkingLevel: "high" }, 4), + entry({ type: "message", id: "tail", parentId: "thinking", message: userMessage("tail") }, 5), + ]; + + const context = buildSessionContext(entries); + expect(context.messages.map((message) => message.role)).toEqual([ + "compactionSummary", + "user", + "assistant", + "user", + ]); + expect(context.model).toEqual({ provider: "openai", modelId: "gpt-5" }); + expect(context.thinkingLevel).toBe("high"); + }); + + it("applies caller transforms after the compaction boundary", () => { + const entries: Entry[] = [ + entry({ type: "message", id: "old", parentId: null, message: userMessage("old") }, 1), + entry( + { + type: "compaction", + id: "compact", + parentId: "old", + summary: "summary", + retainedTail: [], + tokensBefore: 100, + }, + 2, + ), + entry( + { + type: "branch_summary", + id: "branch", + parentId: "compact", + fromId: "abandoned", + summary: "branch summary", + }, + 3, + ), + entry({ type: "message", id: "tail", parentId: "branch", message: userMessage("tail") }, 4), + ]; + + const context = buildSessionContext(entries, { + entryTransforms: [(contextEntries) => contextEntries.filter((candidate) => candidate.type !== "compaction")], + }); + expect(context.messages.map((message) => message.role)).toEqual(["branchSummary", "user"]); + }); + + it("projects custom entries and omits deferred assistant handles", () => { + const deferred: AssistantMessage = { + ...assistantMessage(""), + content: [], + stopReason: "deferred", + deferred: { provider: "openai", modelId: "gpt-5", api: "openai-responses", id: "response-1" }, + }; + const entries: Entry[] = [ + entry({ type: "message", id: "user", parentId: null, message: userMessage("hello") }, 1), + entry({ type: "message", id: "deferred", parentId: "user", message: deferred }, 2), + entry({ type: "custom", id: "custom", parentId: "deferred", customType: "note", data: "project me" }, 3), + ]; + + const context = buildSessionContext(entries, { + entryProjectors: { + note: (custom) => [userMessage(`note: ${String(custom.data)}`)], + }, + }); + expect(context.messages.map((message) => message.role)).toEqual(["user", "user"]); + expect(context.messages[1]).toMatchObject({ content: [{ type: "text", text: "note: project me" }] }); + }); +}); diff --git a/packages/agent/test/harness/experimental/session/memory.test.ts b/packages/agent/test/harness/session/memory.test.ts similarity index 91% rename from packages/agent/test/harness/experimental/session/memory.test.ts rename to packages/agent/test/harness/session/memory.test.ts index ae280dbb250..87c13c58bca 100644 --- a/packages/agent/test/harness/experimental/session/memory.test.ts +++ b/packages/agent/test/harness/session/memory.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { InMemorySessionRepo, InMemorySessionStorage, Session } from "../../../../src/experimental.ts"; +import { InMemorySessionRepo, InMemorySessionStorage, Session } from "../../../src/harness/session/index.ts"; import { createSessionBackendConformance, type SessionBackendFixture, -} from "../../../../src/harness/experimental/session/testing/index.ts"; +} from "../../../src/harness/session/testing/index.ts"; const conformance = createSessionBackendConformance(() => Promise.resolve({ diff --git a/packages/agent/test/harness/experimental/session/sqlite.test.ts b/packages/agent/test/harness/session/sqlite.test.ts similarity index 86% rename from packages/agent/test/harness/experimental/session/sqlite.test.ts rename to packages/agent/test/harness/session/sqlite.test.ts index b5ce7292201..2934dde6886 100644 --- a/packages/agent/test/harness/experimental/session/sqlite.test.ts +++ b/packages/agent/test/harness/session/sqlite.test.ts @@ -1,17 +1,17 @@ import { join } from "node:path"; -import type { SessionMetadata, SessionRepo } from "@earendil-works/pi-agent-core/experimental"; +import type { SessionMetadata, SessionRepo } from "@earendil-works/pi-agent-core"; import { describe, it } from "vitest"; import { createNodeSqliteFactory, type SqliteSessionMetadata, SqliteSessionRepository, -} from "../../../../../storage/sqlite-node/src/index.ts"; -import { NodeExecutionEnv } from "../../../../src/harness/env/nodejs.ts"; +} from "../../../../storage/sqlite-node/src/index.ts"; +import { NodeExecutionEnv } from "../../../src/harness/env/nodejs.ts"; import { createSessionBackendConformance, type SessionBackendFixture, -} from "../../../../src/harness/experimental/session/testing/index.ts"; -import { createTempDir } from "../../session-test-utils.ts"; +} from "../../../src/harness/session/testing/index.ts"; +import { createTempDir } from "../session-test-utils.ts"; function requireSqliteMetadata(metadata: SessionMetadata): SqliteSessionMetadata { const cwd = "cwd" in metadata ? metadata.cwd : undefined; diff --git a/packages/agent/test/harness/sqlite-branch-cache.test.ts b/packages/agent/test/harness/sqlite-branch-cache.test.ts index 30f3dbb7a84..18688ef6da6 100644 --- a/packages/agent/test/harness/sqlite-branch-cache.test.ts +++ b/packages/agent/test/harness/sqlite-branch-cache.test.ts @@ -26,7 +26,7 @@ describe("SQLite branch cache", () => { const session = await repo.create({ cwd: root, id: "session-1" }); const rootId = await session.appendMessage(createUserMessage("root")); const keptId = await session.appendMessage(createUserMessage("kept")); - const compactionId = await appendSqliteCompaction(session, "summary", keptId, 100); + const compactionId = await appendSqliteCompaction(session, "summary", 100); await session.appendMessage(createAssistantMessage("first child")); await moveSqliteMainLane(session, compactionId); const branchedId = await session.appendMessage(createAssistantMessage("branched child")); @@ -54,8 +54,8 @@ describe("SQLite branch cache", () => { const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); const session = await repo.create({ cwd: root, id: "session-1" }); const oldId = await session.appendMessage(createUserMessage("old")); - const keptId = await session.appendMessage(createUserMessage("kept")); - const compactionId = await appendSqliteCompaction(session, "summary", keptId, 100); + await session.appendMessage(createUserMessage("kept")); + const compactionId = await appendSqliteCompaction(session, "summary", 100); const leafId = await session.appendMessage(createAssistantMessage("new")); const db = await sqlite.open(databasePath); @@ -76,19 +76,10 @@ describe("SQLite branch cache", () => { const env = new NodeExecutionEnv({ cwd: root }); const repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath }); const session = await repo.create({ cwd: root, id: "session-1" }); - const rootId = await session.appendMessage(createUserMessage("root")); - const firstCompactionId = await appendSqliteCompaction( - session, - "first summary", - rootId, - 100, - undefined, - false, - undefined, - [], - ); + await session.appendMessage(createUserMessage("root")); + const firstCompactionId = await appendSqliteCompaction(session, "first summary", 100, undefined, undefined, []); const middleId = await session.appendMessage(createUserMessage("middle")); - const secondCompactionId = await appendSqliteCompaction(session, "second summary", rootId, 200); + const secondCompactionId = await appendSqliteCompaction(session, "second summary", 200); const leafId = await session.appendMessage(createAssistantMessage("new")); expect(firstCompactionId).not.toBe(secondCompactionId); diff --git a/packages/agent/test/harness/sqlite-migrations.test.ts b/packages/agent/test/harness/sqlite-migrations.test.ts index 397febe1ca1..76e478be227 100644 --- a/packages/agent/test/harness/sqlite-migrations.test.ts +++ b/packages/agent/test/harness/sqlite-migrations.test.ts @@ -507,15 +507,7 @@ END; totalTokens: 10, cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 }, }; - const compactionId = await appendSqliteCompaction( - session, - "summary", - userId, - 200, - undefined, - false, - compactionUsage, - ); + const compactionId = await appendSqliteCompaction(session, "summary", 200, undefined, compactionUsage); await session.appendRecord({ type: "usage", id: "compaction-usage", diff --git a/packages/agent/test/harness/sqlite-node.test.ts b/packages/agent/test/harness/sqlite-node.test.ts index d7cc7c9e723..c01f6269f64 100644 --- a/packages/agent/test/harness/sqlite-node.test.ts +++ b/packages/agent/test/harness/sqlite-node.test.ts @@ -7,9 +7,7 @@ import { SqliteSessionRepository, } from "../../../storage/sqlite-node/src/index.ts"; import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { JsonlSessionRepository } from "../../src/harness/session/jsonl-repo.ts"; -import { createScanningSessionSearch } from "../../src/harness/session/search.ts"; -import type { SessionSearchOptions } from "../../src/harness/types.ts"; +import type { SessionSearchOptions } from "../../src/harness/session/search.ts"; import { createTempDir, createUserMessage, getSqliteEntries } from "./session-test-utils.ts"; const ownedRepositories: AsyncDisposable[] = []; @@ -24,28 +22,6 @@ function createSqliteFixture(options: ConstructorParameters[0]) { - const repository = new JsonlSessionRepository(options); - ownedRepositories.push(repository); - return { repository, search: createScanningSessionSearch(repository) }; -} - -describe("JsonlSessionBackend with scanning search", () => { - it("searches canonical session entries by scanning", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const { repository: repo, search } = createJsonlFixture({ fs: env, sessionsRoot: join(root, "sessions") }); - const included = await repo.create({ cwd: root, id: "included" }); - const excluded = await repo.create({ cwd: `${root}/other`, id: "excluded" }); - const entryId = await included.appendMessage(createUserMessage("Find the auth defect")); - await excluded.appendMessage(createUserMessage("Find the auth defect")); - - await expect(search.search({ text: "AUTH", cwd: root })).resolves.toEqual([ - expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "included" }) }), - ]); - }); -}); - describe("SqliteSessionRepository writer leases", () => { it("shares one storage queue for repeated opens in the same repository", async () => { const root = createTempDir(); diff --git a/packages/agent/test/harness/tool-context.types.ts b/packages/agent/test/harness/tool-context.types.ts deleted file mode 100644 index 4a27c2e418e..00000000000 --- a/packages/agent/test/harness/tool-context.types.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Api, Model, Models } from "@earendil-works/pi-ai"; -import { AgentHarness } from "../../src/harness/agent-harness.ts"; -import { createReadTool } from "../../src/harness/tools/read.ts"; -import type { ExecutionToolContext } from "../../src/harness/tools/tool-context.ts"; -import type { Session } from "../../src/harness/types.ts"; - -declare const models: Models; -declare const model: Model; -declare const session: Session; -declare const toolContext: ExecutionToolContext; - -const readTool = createReadTool(); - -new AgentHarness({ models, model, session, tools: [readTool], toolContext }); - -// @ts-expect-error Context-requiring tools must be paired with toolContext. -new AgentHarness({ models, model, session, tools: [readTool] }); diff --git a/packages/agent/test/scratch/simple.ts b/packages/agent/test/scratch/simple.ts deleted file mode 100644 index 227b3d6e3d3..00000000000 --- a/packages/agent/test/scratch/simple.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { homedir } from "node:os"; -import { join } from "node:path"; -import { createModels } from "@earendil-works/pi-ai"; -import { cloudflareAIGatewayProvider } from "@earendil-works/pi-ai/providers/cloudflare-ai-gateway"; -import { openaiProvider } from "@earendil-works/pi-ai/providers/openai"; -import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { - AgentHarness, - createBashTool, - createEditTool, - createReadTool, - createWriteTool, - formatSkillsForSystemPrompt, - InMemorySessionRepository, - loadSourcedPromptTemplates, - loadSourcedSkills, - type PromptTemplate, - type Skill, -} from "../../src/index.ts"; - -type Source = { type: "project" | "user" | "path"; dir: string }; -type SourcedSkill = Skill & { source: Source }; -type SourcedPromptTemplate = PromptTemplate & { source: Source }; - -const env = new NodeExecutionEnv({ cwd: process.cwd() }); - -const source = (type: Source["type"], dir: string) => ({ path: dir, source: { type, dir } }); -const { skills: sourcedSkills } = await loadSourcedSkills( - env, - [ - source("project", join(env.cwd, ".pi/skills")), - source("user", join(homedir(), ".pi/agent/skills")), - source("path", join(env.cwd, "../../../pi-skills")), - ], - (skill, source) => ({ ...skill, source }), -); -const { promptTemplates: sourcedPromptTemplates } = await loadSourcedPromptTemplates( - env, - [source("project", join(env.cwd, ".pi/prompts")), source("user", join(homedir(), ".pi/agent/prompts"))], - (promptTemplate, source) => ({ ...promptTemplate, source }), -); - -const models = createModels(); -models.setProvider(openaiProvider()); -models.setProvider(cloudflareAIGatewayProvider()); -const model = models.getModel("openai", "gpt-5.5"); -// const model = models.getModel("cloudflare-ai-gateway", "claude-haiku-4-5"); -if (!model) { - console.log("Model not found"); - process.exit(-1); -} - -await using repository = new InMemorySessionRepository(); -const session = await repository.create({}); -const agent = new AgentHarness({ - session, - models, - model, - thinkingLevel: "low", - tools: [createReadTool(), createWriteTool(), createEditTool(), createBashTool()], - toolContext: { env }, - systemPrompt: ({ resources }) => - [ - "You are a helpful assistant.", - formatSkillsForSystemPrompt(resources.skills ?? []), - `Current working directory: ${env.cwd}`, - ] - .filter((part) => part.length > 0) - .join("\n\n"), - resources: { - promptTemplates: sourcedPromptTemplates.map(({ promptTemplate }) => promptTemplate), - skills: sourcedSkills.map(({ skill }) => skill), - }, -}); - -const response = await agent.prompt( - "What skills do you have? Any duplicates? Also use bash to get the current date and time, then read README.md and tell me what this project is about.", -); -console.log(response); diff --git a/packages/agent/vitest.config.ts b/packages/agent/vitest.config.ts index 5ca2a36facc..ed0ab064ab1 100644 --- a/packages/agent/vitest.config.ts +++ b/packages/agent/vitest.config.ts @@ -4,7 +4,6 @@ import { defineConfig } from "vitest/config"; const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url)); const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url)); const agentSrcIndex = fileURLToPath(new URL("./src/index.ts", import.meta.url)); -const agentSrcExperimental = fileURLToPath(new URL("./src/experimental.ts", import.meta.url)); export default defineConfig({ test: { @@ -17,7 +16,6 @@ export default defineConfig({ resolve: { alias: [ { find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex }, - { find: /^@earendil-works\/pi-agent-core\/experimental$/, replacement: agentSrcExperimental }, { find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex }, { find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat }, ], diff --git a/packages/agent/vitest.harness.config.ts b/packages/agent/vitest.harness.config.ts index be1e8f800f7..8045bb1a628 100644 --- a/packages/agent/vitest.harness.config.ts +++ b/packages/agent/vitest.harness.config.ts @@ -4,7 +4,6 @@ import { defineConfig } from "vitest/config"; const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url)); const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url)); const agentSrcIndex = fileURLToPath(new URL("../agent/src/index.ts", import.meta.url)); -const agentSrcExperimental = fileURLToPath(new URL("../agent/src/experimental.ts", import.meta.url)); export default defineConfig({ test: { @@ -24,7 +23,6 @@ export default defineConfig({ resolve: { alias: [ { find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex }, - { find: /^@earendil-works\/pi-agent-core\/experimental$/, replacement: agentSrcExperimental }, { find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex }, { find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat }, ], diff --git a/packages/storage/sqlite-node/CHANGELOG.md b/packages/storage/sqlite-node/CHANGELOG.md index 17c49d944c4..b7d8f02e871 100644 --- a/packages/storage/sqlite-node/CHANGELOG.md +++ b/packages/storage/sqlite-node/CHANGELOG.md @@ -2,9 +2,13 @@ ## [Unreleased] +### Breaking Changes + +- Replaced the legacy SQLite session schema and repository with the v4 lane-based `SessionRepo` contract. Existing work-in-progress databases are not migrated. + ### Added -- Added bounded active-branch queries to the SQLite session reader. +- Added bounded active-branch queries, durable operation records, global facts, shared sequence allocation, session statistics, and fenced writer leases to the SQLite backend. ## [0.83.0] - 2026-07-29 diff --git a/packages/storage/sqlite-node/src/sqlite/branch-cache.ts b/packages/storage/sqlite-node/src/sqlite/branch-cache.ts index babb2c1a38d..0e5024cb8d3 100644 --- a/packages/storage/sqlite-node/src/sqlite/branch-cache.ts +++ b/packages/storage/sqlite-node/src/sqlite/branch-cache.ts @@ -1,4 +1,4 @@ -import { SessionError } from "@earendil-works/pi-agent-core/experimental"; +import { SessionError } from "@earendil-works/pi-agent-core"; import { uuidv7 } from "@earendil-works/pi-ai"; import { copyBranchEntriesThroughSeq, diff --git a/packages/storage/sqlite-node/src/sqlite/index.ts b/packages/storage/sqlite-node/src/sqlite/index.ts index 8b5fbbaa074..738f086a1f9 100644 --- a/packages/storage/sqlite-node/src/sqlite/index.ts +++ b/packages/storage/sqlite-node/src/sqlite/index.ts @@ -1,8 +1,5 @@ export * from "./migrations.ts"; export { - type SqliteSessionCreateOptions, - type SqliteSessionListOptions, - type SqliteSessionMetadata, SqliteSessionRepository, type SqliteSessionRepositoryOptions, type SqliteWriterLeaseOptions, @@ -12,5 +9,9 @@ export type { SqliteDatabase, SqliteDatabaseFactory, SqliteRunResult, + SqliteSessionCreateOptions, + SqliteSessionListOptions, + SqliteSessionMetadata, + SqliteSessionRepositoryEnv, SqliteStatement, } from "./types.ts"; diff --git a/packages/storage/sqlite-node/src/sqlite/repo.ts b/packages/storage/sqlite-node/src/sqlite/repo.ts index abf78d2e244..f15b7a07157 100644 --- a/packages/storage/sqlite-node/src/sqlite/repo.ts +++ b/packages/storage/sqlite-node/src/sqlite/repo.ts @@ -1,4 +1,4 @@ -import type { FileError, FileSystem, Result } from "@earendil-works/pi-agent-core"; +import type { FileError, Result } from "@earendil-works/pi-agent-core"; import { type BranchBounds, type Entry, @@ -11,13 +11,11 @@ import { type ProvisionedEntry, type RecordQuery, Session, - type SessionCreateOptions, SessionError, - type SessionMetadata, type SessionRepo as SessionRepository, type SessionStats, type SessionStorage, -} from "@earendil-works/pi-agent-core/experimental"; +} from "@earendil-works/pi-agent-core"; import { uuidv7 } from "@earendil-works/pi-ai"; import { appendEntryToBranchCache, buildCachedBranch, deleteBranchCache, rebuildBranchCache } from "./branch-cache.ts"; import { applyMigrations } from "./migrations.ts"; @@ -75,22 +73,14 @@ import { type SessionRow, sessionExists, } from "./storage/sessions.ts"; -import type { SqliteDatabase, SqliteDatabaseFactory } from "./types.ts"; - -export interface SqliteSessionMetadata extends SessionMetadata { - cwd: string; - path: string; - metadata?: Record; -} - -export interface SqliteSessionCreateOptions extends SessionCreateOptions { - cwd: string; - metadata?: Record; -} - -export interface SqliteSessionListOptions { - cwd?: string; -} +import type { + SqliteDatabase, + SqliteDatabaseFactory, + SqliteSessionCreateOptions, + SqliteSessionListOptions, + SqliteSessionMetadata, + SqliteSessionRepositoryEnv, +} from "./types.ts"; export interface SqliteWriterLeaseOptions { /** Time without a successful heartbeat before another writer may take over. Default: 30 seconds. */ @@ -99,8 +89,6 @@ export interface SqliteWriterLeaseOptions { heartbeatIntervalMs?: number; } -export type SqliteSessionRepositoryEnv = Pick; - export interface SqliteSessionRepositoryOptions { env: SqliteSessionRepositoryEnv; sqlite: SqliteDatabaseFactory; @@ -637,8 +625,7 @@ function claimStorage( } function metadataFromRow(row: SessionRow, path: string): SqliteSessionMetadata { - const base = rowToMetadata(row, path); - return { ...base, createdAt: Date.parse(base.createdAt) }; + return rowToMetadata(row, path); } export class SqliteSessionRepository diff --git a/packages/storage/sqlite-node/src/sqlite/storage/branch-entries.ts b/packages/storage/sqlite-node/src/sqlite/storage/branch-entries.ts index dfdadf64c77..9b604976e67 100644 --- a/packages/storage/sqlite-node/src/sqlite/storage/branch-entries.ts +++ b/packages/storage/sqlite-node/src/sqlite/storage/branch-entries.ts @@ -1,4 +1,4 @@ -import type { Entry } from "@earendil-works/pi-agent-core/experimental"; +import type { Entry } from "@earendil-works/pi-agent-core"; import type { SqliteDatabase } from "../types.ts"; /** Derived root-to-tip branch cache membership. Canonical parent links remain in entries. */ diff --git a/packages/storage/sqlite-node/src/sqlite/storage/entries.ts b/packages/storage/sqlite-node/src/sqlite/storage/entries.ts index fc227d45de4..76063c29e36 100644 --- a/packages/storage/sqlite-node/src/sqlite/storage/entries.ts +++ b/packages/storage/sqlite-node/src/sqlite/storage/entries.ts @@ -1,4 +1,4 @@ -import type { Entry, EntryOrder } from "@earendil-works/pi-agent-core/experimental"; +import type { Entry, EntryOrder } from "@earendil-works/pi-agent-core"; import type { SqliteDatabase } from "../types.ts"; export interface EntryRow { diff --git a/packages/storage/sqlite-node/src/sqlite/storage/lanes.ts b/packages/storage/sqlite-node/src/sqlite/storage/lanes.ts index 0b8ca7916a1..645d9179079 100644 --- a/packages/storage/sqlite-node/src/sqlite/storage/lanes.ts +++ b/packages/storage/sqlite-node/src/sqlite/storage/lanes.ts @@ -1,4 +1,4 @@ -import { SessionError } from "@earendil-works/pi-agent-core/experimental"; +import { SessionError } from "@earendil-works/pi-agent-core"; import type { SqliteDatabase } from "../types.ts"; export interface LaneRow { diff --git a/packages/storage/sqlite-node/src/sqlite/storage/session-sequences.ts b/packages/storage/sqlite-node/src/sqlite/storage/session-sequences.ts index 04798f5312c..578005c4dd9 100644 --- a/packages/storage/sqlite-node/src/sqlite/storage/session-sequences.ts +++ b/packages/storage/sqlite-node/src/sqlite/storage/session-sequences.ts @@ -1,4 +1,4 @@ -import { SessionError } from "@earendil-works/pi-agent-core/experimental"; +import { SessionError } from "@earendil-works/pi-agent-core"; import type { SqliteDatabase } from "../types.ts"; export function createSequence(db: SqliteDatabase, sessionId: string, nextSeq = 1) { diff --git a/packages/storage/sqlite-node/src/sqlite/storage/session-stats.ts b/packages/storage/sqlite-node/src/sqlite/storage/session-stats.ts index ce180a0fcd6..7aa6a90eff0 100644 --- a/packages/storage/sqlite-node/src/sqlite/storage/session-stats.ts +++ b/packages/storage/sqlite-node/src/sqlite/storage/session-stats.ts @@ -1,4 +1,4 @@ -import { SessionError, type SessionStats } from "@earendil-works/pi-agent-core/experimental"; +import { SessionError, type SessionStats } from "@earendil-works/pi-agent-core"; import type { Usage } from "@earendil-works/pi-ai"; import type { SqliteDatabase } from "../types.ts"; diff --git a/packages/storage/sqlite-node/src/sqlite/storage/sessions.ts b/packages/storage/sqlite-node/src/sqlite/storage/sessions.ts index 3ea1db3c475..a139d9bed03 100644 --- a/packages/storage/sqlite-node/src/sqlite/storage/sessions.ts +++ b/packages/storage/sqlite-node/src/sqlite/storage/sessions.ts @@ -1,4 +1,4 @@ -import { assertJsonSerializable, SessionError } from "@earendil-works/pi-agent-core/experimental"; +import { assertJsonSerializable, SessionError } from "@earendil-works/pi-agent-core"; import type { SqliteDatabase, SqliteSessionMetadata } from "../types.ts"; export interface SessionRow { @@ -83,7 +83,7 @@ export function deleteSessionRow(db: SqliteDatabase, sessionId: string) { export function rowToMetadata(row: SessionRow, path: string): SqliteSessionMetadata { return { id: row.id, - createdAt: row.created_at, + createdAt: Date.parse(row.created_at), cwd: row.cwd, path, parentSessionId: row.parent_session_id ?? undefined, diff --git a/scripts/browser-smoke-entry.ts b/scripts/browser-smoke-entry.ts index dd8f064c78d..bf17732f536 100644 --- a/scripts/browser-smoke-entry.ts +++ b/scripts/browser-smoke-entry.ts @@ -6,7 +6,7 @@ import { bashExecutionToText, convertToLlm, createCustomMessage, - InMemorySessionRepository, + InMemorySessionRepo, FileError, formatPromptTemplateInvocation, formatSkillInvocation, @@ -28,7 +28,7 @@ const stream = createAssistantMessageEventStream(); const agent = new Agent({ initialState: { model }, streamFn: streamSimple }); agent.steer({ role: "user", content: [{ type: "text", text: "queued" }], timestamp: 0 }); -const repo = new InMemorySessionRepository(); +const repo = new InMemorySessionRepo(); const result = getOrThrow(ok({ value: 1 })); const customMessage = createCustomMessage("note", "hello", true, undefined, "2026-01-01T00:00:00.000Z"); const llmMessages = convertToLlm([customMessage]); diff --git a/tsconfig.json b/tsconfig.json index c139f41c876..a409cdf1107 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,6 @@ "@earendil-works/pi-ai/*": ["./packages/ai/src/*.ts", "./packages/ai/src/providers/*.ts"], "@earendil-works/pi-ai/dist/*": ["./packages/ai/src/*"], "@earendil-works/pi-agent-core": ["./packages/agent/src/index.ts"], - "@earendil-works/pi-agent-core/experimental": ["./packages/agent/src/experimental.ts"], "@earendil-works/pi-agent-core/*": ["./packages/agent/src/*"], "@earendil-works/pi-agent-sqlite-node": ["./packages/storage/sqlite-node/src/index.ts"], "@earendil-works/pi-coding-agent": ["./packages/coding-agent/src/index.ts"], From 4b85cd9786d736e22dc1f3ae91067b4cc5a24b2c Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Tue, 4 Aug 2026 15:08:44 +0000 Subject: [PATCH 20/34] harness-v2 mark task in progress --- packages/agent/docs/harness-v2.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index fbe596c448c..af8c2367278 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -2857,7 +2857,7 @@ Implementation lives directly in `packages/agent/src/harness/`, with v4 session Keep every stage passing before starting the next. Replace scaffold failures only when the corresponding procedure and tests land. 1. **Finish session-level test reconstruction.** Audit the removed legacy session and branch-query suites case by case. Keep semantics already covered by backend conformance in one place; port uncovered v4 behavior, corruption, bounded-query, fork, validation, context, and configuration-state cases to dedicated tests. Do not restore tests of deleted APIs. -2. **Implement JSONL v4 and v3 loading.** Add the v4 backend, torn-tail handling, normalization of supported coding-agent v3 files, and first-write conversion. Run the same storage conformance suite against memory, JSONL, and SQLite, plus v3 fixtures and format-specific corruption tests. +2. **Implement JSONL v4 and v3 loading.** **In progress: @davidbrai.** Add the v4 backend, torn-tail handling, normalization of supported coding-agent v3 files, and first-write conversion. Run the same storage conformance suite against memory, JSONL, and SQLite, plus v3 fixtures and format-specific corruption tests. 3. **Implement the section 7 reducer and validity checks.** Reconstruct idle/suspended lane state from bounded record and branch queries. Add Tier A recovery tests before adding live execution, including invalid logs and idempotent half-completed recovery. 4. **Split `agent-loop.ts` into the section 14 blocks.** Preserve the existing public wrappers and behavior; keep the existing `agent-loop` and `agent` suites unchanged and passing. 5. **Implement `Effects`, lane mutation lines, conditional commits, and manual gating.** Establish automatic/manual equivalence for a no-tool run and verify that parked procedures perform no effects. From 05bf9df65155e047e4ba8459eaee9735e29a2e53 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Tue, 4 Aug 2026 20:03:37 +0300 Subject: [PATCH 21/34] feat: remove legacy server implementation (#7614) --- package-lock.json | 4 - package.json | 4 +- packages/server/README.md | 18 +- packages/server/package.json | 10 +- packages/server/src/index.ts | 1 - packages/server/src/legacy/cli.ts | 161 -------- packages/server/src/legacy/config.ts | 69 ---- packages/server/src/legacy/handler.ts | 161 -------- packages/server/src/legacy/index.ts | 11 - packages/server/src/legacy/ipc/client.ts | 63 --- packages/server/src/legacy/ipc/protocol.ts | 142 ------- packages/server/src/legacy/ipc/server.ts | 209 ---------- packages/server/src/legacy/radius.ts | 440 --------------------- packages/server/src/legacy/rpc-process.ts | 201 ---------- packages/server/src/legacy/serve.ts | 77 ---- packages/server/src/legacy/storage.ts | 70 ---- packages/server/src/legacy/supervisor.ts | 354 ----------------- packages/server/src/legacy/types.ts | 25 -- 18 files changed, 8 insertions(+), 2012 deletions(-) delete mode 100644 packages/server/src/legacy/cli.ts delete mode 100644 packages/server/src/legacy/config.ts delete mode 100644 packages/server/src/legacy/handler.ts delete mode 100644 packages/server/src/legacy/index.ts delete mode 100644 packages/server/src/legacy/ipc/client.ts delete mode 100644 packages/server/src/legacy/ipc/protocol.ts delete mode 100644 packages/server/src/legacy/ipc/server.ts delete mode 100644 packages/server/src/legacy/radius.ts delete mode 100644 packages/server/src/legacy/rpc-process.ts delete mode 100644 packages/server/src/legacy/serve.ts delete mode 100644 packages/server/src/legacy/storage.ts delete mode 100644 packages/server/src/legacy/supervisor.ts delete mode 100644 packages/server/src/legacy/types.ts diff --git a/package-lock.json b/package-lock.json index f48b50c97ae..05fb673cda9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5715,12 +5715,8 @@ "license": "MIT", "dependencies": { "@earendil-works/pi-ai": "^0.83.0", - "@earendil-works/pi-coding-agent": "^0.83.0", "@earendil-works/pi-protocol": "^0.83.0" }, - "bin": { - "server": "dist/legacy/cli.js" - }, "devDependencies": { "shx": "0.4.0", "vitest": "4.1.9" diff --git a/package.json b/package.json index d6320579681..53958623ca9 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ ], "scripts": { "clean": "npm run clean --workspaces", - "build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../coding-agent && npm run build && cd ../server && npm run build", - "build:offline": "cd packages/tui && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../coding-agent && npm run build && cd ../server && npm run build", + "build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", + "build:offline": "cd packages/tui && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", "check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke", "check:browser-smoke": "node scripts/check-browser-smoke.mjs", "check:pinned-deps": "node scripts/check-pinned-deps.mjs", diff --git a/packages/server/README.md b/packages/server/README.md index 3fa91e39502..05539dd395a 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -1,18 +1,12 @@ # @earendil-works/pi-server -Experimental. This package is under active development and may change or be removed without notice. Its CLI, APIs, and behavior are not yet stable. +Experimental. This package is under active development and may change or be removed without notice. Its APIs and behavior are not yet stable. Server package for pi. -## CLI - -```bash -server --help -``` - ## Session server core -The package also exports the new `PiServer` session server. This API is additive while the legacy child-process supervisor and `server` CLI are migrated. +The package exports the `PiServer` session server. ```ts import type { PiSessionBackend } from "@earendil-works/pi-server"; @@ -39,7 +33,9 @@ const server = createUnixServer(backend, { await server.start(); ``` -`PiServer` composes transport listeners through the `PiServerListener` interface. Each listener must complete any transport-specific authentication and authorization before passing a connection to `PiServer`. For example, a WebSocket listener can validate credentials during the HTTP upgrade, while the Unix listener relies on socket filesystem permissions. The Unix submodule exports the `createUnixListener()` building block and `createUnixServer()` preset, keeping the common case concise without coupling the primary server to Unix sockets. The listener uses length-prefixed CBOR messages from `@earendil-works/pi-protocol`. It does not yet replace the legacy JSONL IPC control plane, child-process supervisor, standalone `server` CLI, or Radius presence integration. +`PiServer` composes transport listeners through the `PiServerListener` interface. Each listener must complete any transport-specific authentication and authorization before passing a connection to `PiServer`. For example, a WebSocket listener can validate credentials during the HTTP upgrade, while the Unix listener relies on socket filesystem permissions. The Unix submodule exports the `createUnixListener()` building block and `createUnixServer()` preset, keeping the common case concise without coupling the primary server to Unix sockets. The listener uses length-prefixed CBOR messages from `@earendil-works/pi-protocol`. + +This package does not provide a standalone CLI or coding-agent backend. Applications supply the `PiSessionBackend` implementation. ## Transport testing @@ -50,7 +46,3 @@ Custom transports can use `@earendil-works/pi-server/testing` for deterministic `@earendil-works/pi-ai` domain objects and `@earendil-works/pi-protocol` wire DTOs remain independent. This package owns their boundary and exports `toProtocolModelMetadata()`, `toProtocolAssistantMessage()`, `toProtocolUserMessage()`, and `toProtocolToolResultMessage()`. The adapters reject invalid tool inputs, identifiers, timestamps, and mismatched tool results; `toProtocolToolResultMessage()` requires the original `ToolCall` so it can verify the association and convert its arguments itself. Diagnostic details are explicitly sanitized. Closed `pi-ai` unions are mapped exhaustively, and compile-time field manifests enumerate current `pi-ai` properties so additions require an explicit review. The protocol mirrors `pi-ai` vocabulary such as `toolCall` and `toolUse` where the semantics are identical. Protocol schemas enforce consistent lifecycle states, and tests encode adapter output through the runtime schemas so incompatible changes fail in the bridging package. - -## Legacy server migration - -The existing IPC, supervisor, process management, persistence, and Radius modules remain available during migration. The new Unix session protocol supersedes the legacy socket framing and RPC proxy only after the coding-agent backend and CLI replacement have landed. Radius is presence and registration infrastructure, not a transport, and requires a separate integration with the new server lifecycle before the legacy supervisor can be removed. diff --git a/packages/server/package.json b/packages/server/package.json index a619eec3ced..eb8caf5a5a1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -17,15 +17,8 @@ "./unix": { "types": "./dist/transports/unix/index.d.ts", "import": "./dist/transports/unix/index.js" - }, - "./legacy": { - "types": "./dist/legacy/index.d.ts", - "import": "./dist/legacy/index.js" } }, - "bin": { - "server": "./dist/legacy/cli.js" - }, "files": [ "dist", "README.md", @@ -34,7 +27,7 @@ "scripts": { "clean": "shx rm -rf dist", "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput", - "build": "tsgo -p tsconfig.build.json && shx chmod +x dist/legacy/cli.js", + "build": "tsgo -p tsconfig.build.json", "test": "vitest --run", "typecheck": "tsgo -p tsconfig.test.json", "prepublishOnly": "npm run clean && npm run build" @@ -55,7 +48,6 @@ }, "dependencies": { "@earendil-works/pi-ai": "^0.83.0", - "@earendil-works/pi-coding-agent": "^0.83.0", "@earendil-works/pi-protocol": "^0.83.0" }, "devDependencies": { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 46625268aa5..de4b0d895ae 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,5 +1,4 @@ export * from "./errors.ts"; -export * from "./legacy/index.ts"; export * from "./listener.ts"; export * from "./protocol.ts"; export * from "./server.ts"; diff --git a/packages/server/src/legacy/cli.ts b/packages/server/src/legacy/cli.ts deleted file mode 100644 index 738d416edc2..00000000000 --- a/packages/server/src/legacy/cli.ts +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env node -import { readFileSync } from "node:fs"; -import { createConnection } from "node:net"; -import { dirname, join } from "node:path"; -import { cwd } from "node:process"; -import { fileURLToPath } from "node:url"; -import type { RpcCommand, RpcExtensionUIResponse } from "@earendil-works/pi-coding-agent"; -import { getSocketPath } from "./config.ts"; -import { sendIpcRequest } from "./ipc/client.ts"; -import { encodeMessage } from "./ipc/protocol.ts"; -import { serve } from "./serve.ts"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8")) as { - version: string; -}; - -function printHelp(): void { - console.log( - `server v${packageJson.version}\n\nUsage:\n server serve\n server list\n server spawn [--cwd ] [--label