diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..9a9b0a73 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,74 @@ +# AGENTS.md — Working on the Rowboat runtime + +Context for AI coding agents (and humans) working on this repo. General +codebase orientation lives in `CLAUDE.md`; this file covers the **new +turn/session runtime** in `apps/x` — its storage, its debugging tools, and +the invariants you must not break. + +## The runtime in one paragraph + +Chats are **sessions**; each user message starts a **turn**. Both are +append-only JSONL event logs under `~/.rowboat/storage/{turns,sessions}/YYYY/MM/DD/`. +All state is derived by pure reducers (`reduceTurn`, `reduceSession` in +`@x/shared/src/turns.ts` / `sessions.ts`) shared byte-for-byte between the +main process and the renderer. Design specs: +`apps/x/packages/core/docs/turn-runtime-design.md` and `session-design.md` — +read the relevant spec before changing runtime behavior. + +## Storage is reference-based — files store each fact once + +Three applications of the same mechanism: + +1. **Context**: a session turn's `context` is `{ previousTurnId }`; the + conversation prefix is materialized by walking the chain + (`TurnRepoContextResolver`). +2. **Model requests**: `model_call_requested.request.messages` is a list of + string refs into the turn's own events — `"context"`, `"input"`, + `"assistant:"`, `"toolResult:"` — recording only what + is NEW since the previous call. +3. **Agent snapshots**: when a turn's system prompt + tools are + byte-identical to its predecessor's, `turn_created.agent.resolved` is + `{ agentId, model, inheritedFrom }` instead of re-persisting ~70KB. The + model stays concrete (mid-session model switches still inherit). + +The exact provider payload is rebuilt by `composeModelRequest` +(`packages/core/src/turns/compose-model-request.ts`) — **the same code path +the loop transmits through**, so the file plus the composer reproduce the +wire bytes exactly (there is a property test asserting composed == sent). + +## Inspecting turns and sessions + +```bash +cd apps/x/packages/core + +# A whole session: title, per-turn status/size/input preview +npm run inspect -- + +# One turn: per model call, the EXACT provider payload — resolved system +# prompt, tool list, wire-form messages (user-message context woven in, +# tool-result envelopes), and the response/failure +npm run inspect -- [modelCallIndex] [--full] + +# Cascade full turn inspection across a session +npm run inspect -- --turns +``` + +`--full` prints untruncated system prompts and message contents. Turn vs +session ids are auto-detected. This is the intended way to see "what did the +model actually receive" — the raw JSONL deliberately stores structural facts +and references, never the derived wire form. + +## Invariants to respect + +- Turn/session files are **append-only**; reducers reject impossible + histories loudly (`TurnCorruptionError`). Never hand-edit files. +- Durable events are persisted **before** side effects (model calls, tool + invocations). Deltas (`text_delta`, …) are stream-only, never persisted. +- The reducers in `@x/shared` must stay pure (no I/O, no node imports) — + the renderer imports them directly. +- Every behavior change needs tests: reducers in `packages/shared`, + runtime/sessions in `packages/core`, renderer stores/views in + `apps/renderer` (all vitest; run `npm test` per package). +- Schema changes: the schema is pre-release (`schemaVersion: 1` throughout); + breaking changes are acceptable but require wiping `~/.rowboat/storage` + and a note in the commit message. diff --git a/CLAUDE.md b/CLAUDE.md index 75a0f6a5..bf34bd90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,7 @@ Long-form docs for specific features. Read the relevant file before making chang |---------|-----| | Live Notes — single `live:` frontmatter block (one objective + optional cron / windows / eventMatchCriteria) that turns a note into a self-updating artifact, panel UI, Copilot skill, prompts catalog | `apps/x/LIVE_NOTE.md` | | Analytics — PostHog event catalog, person properties, use-case taxonomy, how to add a new event | `apps/x/ANALYTICS.md` | +| Turn/session runtime — event-sourced storage, reference model, the `npm run inspect` debugger | `AGENTS.md` (repo root), `apps/x/packages/core/docs/turn-runtime-design.md`, `session-design.md` | ## Common Tasks diff --git a/apps/x/packages/core/docs/turn-runtime-design.md b/apps/x/packages/core/docs/turn-runtime-design.md index 88290b7e..091168f2 100644 --- a/apps/x/packages/core/docs/turn-runtime-design.md +++ b/apps/x/packages/core/docs/turn-runtime-design.md @@ -390,6 +390,35 @@ Rules: It rejects the execution and does not append `turn_failed`. - The reducer treats `context` as opaque data and never resolves references. +### 6.7 Agent snapshot inheritance + +Session turns whose resolved system prompt and tool set are byte-identical +to the context predecessor's materialized snapshot persist an inherited +form instead of repeating ~tens of KB per turn: + +```ts +type ResolvedAgentSnapshot = + | ResolvedAgent + | { agentId: string; model: ModelDescriptor; inheritedFrom: string }; +``` + +Rules: + +- Inheritance is decided at `createTurn` by comparing against the + predecessor's materialized snapshot; any difference in system prompt or + tools persists the full snapshot. An unreadable predecessor falls back to + the full snapshot. +- The model stays concrete: it is small, the session index denormalizes it, + and a mid-session model switch must not block inheritance. On + materialization the inherited record's own `agentId`/`model` win; only the + heavy fields come from the chain base. +- `inheritedFrom` must equal the turn's `context.previousTurnId` (reducer + invariant); materialization walks the chain with cycle detection + (`IContextResolver.resolveAgent`), exactly like context references. +- The reducer treats inherited snapshots as opaque: tool identity for + extraction arrives via `tool_invocation_requested` events instead of the + descriptor lookup. + ## 7. Turn creation schema The authoritative initial event is: diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index 8ba46048..18c32a8c 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -9,7 +9,8 @@ "dev": "tsc -w -p tsconfig.build.json", "test": "vitest run", "test:watch": "vitest", - "inspect-turn": "node dist/turns/inspect-cli.js" + "inspect-turn": "node dist/turns/inspect-cli.js", + "inspect": "node dist/turns/inspect-cli.js" }, "dependencies": { "@agentclientprotocol/claude-agent-acp": "^0.39.0", diff --git a/apps/x/packages/core/src/sessions/sessions.test.ts b/apps/x/packages/core/src/sessions/sessions.test.ts index 6ff498d8..d4efdaa1 100644 --- a/apps/x/packages/core/src/sessions/sessions.test.ts +++ b/apps/x/packages/core/src/sessions/sessions.test.ts @@ -187,6 +187,9 @@ class FakeTurnRuntime implements ITurnRuntime { logs = new Map(); createError?: Error; script?: AdvanceScript; + // When set, session turns get an inherited agent snapshot (like the real + // runtime does for identical predecessors). + inheritSnapshots = false; private n = 0; async createTurn(input: CreateTurnInput): Promise { @@ -196,7 +199,22 @@ class FakeTurnRuntime implements ITurnRuntime { this.createTurnInputs.push(input); this.n += 1; const turnId = `2026-07-02T10-00-00Z-${String(this.n).padStart(7, "0")}-000`; - this.logs.set(turnId, [createdEvent(turnId, input)]); + const created = createdEvent(turnId, input); + if ( + this.inheritSnapshots && + created.type === "turn_created" && + !Array.isArray(input.context) + ) { + created.agent = { + ...created.agent, + resolved: { + agentId: FIXTURE_AGENT.agentId, + model: FIXTURE_MODEL, + inheritedFrom: input.context.previousTurnId, + }, + }; + } + this.logs.set(turnId, [created]); return turnId; } @@ -430,6 +448,31 @@ describe("sendMessage (13.3)", () => { } }); + it("denormalizes the model correctly for inherited agent snapshots", async () => { + const { sessions, repo, fake } = makeSessions(); + fake.inheritSnapshots = true; + const sessionId = await sessions.createSession(); + const first = await sessions.sendMessage(sessionId, user("one"), { + agent: { agentId: "copilot" }, + }); + fake.setLog(first.turnId, turnLog(first.turnId, sessionId, "completed")); + + const second = await sessions.sendMessage(sessionId, user("two"), { + agent: { agentId: "copilot" }, + }); + // The second turn's created event carries an inherited snapshot; the + // session still denormalizes the concrete model onto turn_appended, + // and status derivation reduces the inherited log fine. + const created = fake.logs.get(second.turnId)?.[0]; + expect( + created?.type === "turn_created" && "inheritedFrom" in created.agent.resolved, + ).toBe(true); + const events = await (repo as InMemorySessionRepo).read(sessionId); + const appended = events.filter((e) => e.type === "turn_appended"); + expect(appended[1]).toMatchObject({ model: FIXTURE_MODEL }); + expect(sessions.listSessions()[0].lastModel).toEqual(FIXTURE_MODEL); + }); + it("serializes concurrent sends: exactly one wins", async () => { const { sessions, repo } = makeSessions(); const sessionId = await sessions.createSession(); diff --git a/apps/x/packages/core/src/turns/compose-model-request.ts b/apps/x/packages/core/src/turns/compose-model-request.ts index ec0a7ef7..9811e9c0 100644 --- a/apps/x/packages/core/src/turns/compose-model-request.ts +++ b/apps/x/packages/core/src/turns/compose-model-request.ts @@ -2,6 +2,7 @@ import type { z } from "zod"; import { type ConversationMessage, type JsonValue, + type ResolvedAgent, type ToolDescriptor, type TurnState, requestMessagesFor, @@ -29,6 +30,9 @@ export function composeModelRequest( // The materialized cross-turn prefix (contextResolver output). Ignored // for inline-context turns, whose context rides the {context} ref. resolvedPrefix: Array>, + // The materialized agent snapshot (contextResolver.resolveAgent output — + // inherited snapshots must be resolved before composing). + agent: z.infer, encode: (messages: Array>) => JsonValue[], ): ComposedModelRequest { const call = state.modelCalls[modelCallIndex]; @@ -41,9 +45,9 @@ export function composeModelRequest( structural.push(...requestMessagesFor(state, index)); } return { - systemPrompt: state.definition.agent.resolved.systemPrompt, + systemPrompt: agent.systemPrompt, messages: encode(structural), - tools: state.definition.agent.resolved.tools, + tools: agent.tools, parameters: call.request.parameters, }; } @@ -57,5 +61,8 @@ export async function materializeModelRequest( encode: (messages: Array>) => JsonValue[], ): Promise { const prefix = await contextResolver.resolve(state.definition.context); - return composeModelRequest(state, modelCallIndex, prefix, encode); + const agent = await contextResolver.resolveAgent( + state.definition.agent.resolved, + ); + return composeModelRequest(state, modelCallIndex, prefix, agent, encode); } diff --git a/apps/x/packages/core/src/turns/context-resolver.test.ts b/apps/x/packages/core/src/turns/context-resolver.test.ts index 6ccfe671..35621178 100644 --- a/apps/x/packages/core/src/turns/context-resolver.test.ts +++ b/apps/x/packages/core/src/turns/context-resolver.test.ts @@ -241,6 +241,82 @@ describe("TurnRepoContextResolver", () => { ).rejects.toThrowError(/turn not found/); }); + it("resolveAgent materializes inherited snapshots through the chain", async () => { + const repo = new InMemoryTurnRepo(); + repo.seed(completedTurnLog(T1, [], "q1", "a1")); // concrete base + const inheritedLog = completedTurnLog(T2, { previousTurnId: T1 }, "q2", "a2").map( + (event) => + event.type === "turn_created" + ? { + ...event, + agent: { + ...event.agent, + resolved: { + agentId: "copilot", + model: { provider: "fake", model: "m" }, + inheritedFrom: T1, + }, + }, + } + : event, + ); + repo.seed(inheritedLog); + const resolver = new TurnRepoContextResolver({ turnRepo: repo }); + + const concrete = await resolver.resolveAgent({ + agentId: "copilot", + systemPrompt: "SYS", + model: { provider: "fake", model: "m" }, + tools: [], + }); + expect(concrete.systemPrompt).toBe("SYS"); // passthrough + + const materialized = await resolver.resolveAgent({ + agentId: "copilot", + model: { provider: "fake", model: "m2" }, + inheritedFrom: T2, // hops T2 -> T1 + }); + expect(materialized.systemPrompt).toBe("SYS"); + expect(materialized.tools).toEqual([]); + // The inherited record's own model wins over the chain base's model. + expect(materialized.model).toEqual({ provider: "fake", model: "m2" }); + }); + + it("resolveAgent rejects cyclic inheritance as corruption", async () => { + const repo = new InMemoryTurnRepo(); + const cyclic = completedTurnLog(T1, [], "q1", "a1").map((event) => + event.type === "turn_created" + ? { + ...event, + context: { previousTurnId: T1 }, + agent: { + ...event.agent, + resolved: { + agentId: "copilot", + model: { provider: "fake", model: "m" }, + inheritedFrom: T1, + }, + }, + } + : event, + ); + // Fix the request contextRef to match the now-ref context. + const fixed = cyclic.map((event) => + event.type === "model_call_requested" + ? { ...event, request: { ...event.request, contextRef: { previousTurnId: T1 } } } + : event, + ); + repo.seed(fixed); + const resolver = new TurnRepoContextResolver({ turnRepo: repo }); + await expect( + resolver.resolveAgent({ + agentId: "copilot", + model: { provider: "fake", model: "m" }, + inheritedFrom: T1, + }), + ).rejects.toThrowError(TurnCorruptionError); + }); + it("rejects cyclic reference chains as corruption", async () => { const repo = new InMemoryTurnRepo(); // Two turns referencing each other (only constructable by corruption). diff --git a/apps/x/packages/core/src/turns/context-resolver.ts b/apps/x/packages/core/src/turns/context-resolver.ts index 0dce3181..3785cc26 100644 --- a/apps/x/packages/core/src/turns/context-resolver.ts +++ b/apps/x/packages/core/src/turns/context-resolver.ts @@ -1,8 +1,11 @@ import type { z } from "zod"; import { type ConversationMessage, + type ResolvedAgent, + type ResolvedAgentSnapshot, type TurnContext, TurnCorruptionError, + isInheritedSnapshot, reduceTurn, turnTranscript, } from "@x/shared/dist/turns.js"; @@ -17,6 +20,12 @@ export interface IContextResolver { resolve( context: z.infer, ): Promise>>; + // Materializes an inherited agent snapshot by walking inheritedFrom to + // the nearest concrete snapshot (same discipline as context references: + // deterministic, from durable state, cycle-checked). + resolveAgent( + resolved: z.infer, + ): Promise>; } export class TurnRepoContextResolver implements IContextResolver { @@ -52,4 +61,28 @@ export class TurnRepoContextResolver implements IContextResolver { segments.reverse(); return segments.flat(); } + + async resolveAgent( + resolved: z.infer, + ): Promise> { + if (!isInheritedSnapshot(resolved)) { + return resolved; + } + const visited = new Set(); + let current: z.infer = resolved; + while (isInheritedSnapshot(current)) { + const turnId = current.inheritedFrom; + if (visited.has(turnId)) { + throw new TurnCorruptionError( + `cyclic agent snapshot inheritance at turn ${turnId}`, + ); + } + visited.add(turnId); + const events = await this.turnRepo.read(turnId); + current = reduceTurn(events).definition.agent.resolved; + } + // Only the heavy fields inherit; the turn's own concrete identity + // (agentId, model — a mid-session model switch) always wins. + return { ...current, agentId: resolved.agentId, model: resolved.model }; + } } diff --git a/apps/x/packages/core/src/turns/inspect-cli.ts b/apps/x/packages/core/src/turns/inspect-cli.ts index 3329d99d..b662e407 100644 --- a/apps/x/packages/core/src/turns/inspect-cli.ts +++ b/apps/x/packages/core/src/turns/inspect-cli.ts @@ -1,31 +1,48 @@ -// Turn inspector: prints, per model call, the EXACT provider payload the -// loop sent — rebuilt from the durable file by the same composer the loop -// transmits through (compose-model-request.ts). This is where the woven -// wire-form messages (user-message context, attachments, tool-result -// envelopes) are visible; the file itself stores only structural facts and -// references. +// Runtime storage inspector. Works on turns AND sessions (auto-detected): // -// Usage: -// npm run inspect-turn -- [modelCallIndex] [--full] +// npm run inspect -- [modelCallIndex] [--full] [--turns] // -// --full prints the entire system prompt and untruncated message contents. +// Turn mode prints, per model call, the EXACT provider payload the loop sent +// — rebuilt from the durable file by the same composer the loop transmits +// through (compose-model-request.ts). This is where the woven wire-form +// messages (user-message context, attachments, tool-result envelopes) are +// visible; the file itself stores only structural facts and references. +// +// Session mode prints the session overview (title, turns, statuses, sizes); +// pass --turns to cascade full turn inspection for every turn. +// +// --full prints untruncated system prompts and message contents. import fs from "node:fs"; import path from "node:path"; import { + deriveTurnStatus, reduceTurn, type JsonValue, type TurnEvent, + type TurnState, } from "@x/shared/dist/turns.js"; +import { reduceSession } from "@x/shared/dist/sessions.js"; import type { z } from "zod"; import { convertFromMessages } from "../agents/runtime.js"; import { WorkDir } from "../config/config.js"; +import { FSSessionRepo } from "../sessions/fs-repo.js"; import { composeModelRequest } from "./compose-model-request.js"; import { TurnRepoContextResolver } from "./context-resolver.js"; import { FSTurnRepo } from "./fs-repo.js"; +const turnRepo = new FSTurnRepo({ + turnsRootDir: path.join(WorkDir, "storage", "turns"), +}); +const sessionRepo = new FSSessionRepo({ + sessionsRootDir: path.join(WorkDir, "storage", "sessions"), +}); +const resolver = new TurnRepoContextResolver({ turnRepo }); +const encode = (messages: Parameters[0]) => + convertFromMessages(messages) as unknown as JsonValue[]; + function usage(): never { console.error( - "usage: inspect-turn [modelCallIndex] [--full]", + "usage: inspect [modelCallIndex] [--full] [--turns]", ); process.exit(1); } @@ -35,46 +52,43 @@ function clip(text: string, full: boolean, limit = 400): string { return `${text.slice(0, limit)}… [${text.length} chars total; pass --full]`; } -async function main(): Promise { - const args = process.argv.slice(2).filter((a) => a !== "--full"); - const full = process.argv.includes("--full"); - const target = args[0]; - if (!target) usage(); - const onlyIndex = args[1] !== undefined ? Number(args[1]) : undefined; +function inputPreview(state: TurnState): string { + const content = state.definition.input.content; + const text = + typeof content === "string" + ? content + : content + .map((part) => (part.type === "text" ? part.text : `<${part.type}>`)) + .join(" "); + return text.replace(/\s+/g, " ").slice(0, 60); +} - const turnsRootDir = path.join(WorkDir, "storage", "turns"); - const repo = new FSTurnRepo({ turnsRootDir }); - const turnId = target.endsWith(".jsonl") - ? path.basename(target, ".jsonl") - : target; - - let events: Array>; - if (target.endsWith(".jsonl") && fs.existsSync(target)) { - // Direct path: parse in place (still strict via the reducer below). - events = fs - .readFileSync(target, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line) as z.infer); - } else { - events = await repo.read(turnId); - } +async function inspectTurn( + turnId: string, + events: Array>, + onlyIndex: number | undefined, + full: boolean, +): Promise { const state = reduceTurn(events); - const resolver = new TurnRepoContextResolver({ turnRepo: repo }); const prefix = await resolver.resolve(state.definition.context); - const encode = (messages: Parameters[0]) => - convertFromMessages(messages) as unknown as JsonValue[]; + const agent = await resolver.resolveAgent(state.definition.agent.resolved); - console.log(`turn ${turnId}`); + const inherited = + "inheritedFrom" in state.definition.agent.resolved + ? ` (snapshot inherited from ${state.definition.agent.resolved.inheritedFrom})` + : ""; + console.log(`turn ${turnId} status ${deriveTurnStatus(state)}`); console.log( - `agent ${state.definition.agent.resolved.agentId} model ${state.definition.agent.resolved.model.provider}/${state.definition.agent.resolved.model.model} calls ${state.modelCalls.length}`, + `agent ${agent.agentId} model ${agent.model.provider}/${agent.model.model} calls ${state.modelCalls.length}${inherited}`, ); for (const call of state.modelCalls) { if (onlyIndex !== undefined && call.index !== onlyIndex) continue; - const composed = composeModelRequest(state, call.index, prefix, encode); + const composed = composeModelRequest(state, call.index, prefix, agent, encode); console.log(`\n━━ model call ${call.index} ━━ (as sent to the provider)`); - console.log(`system (${composed.systemPrompt.length} chars): ${clip(composed.systemPrompt, full)}`); + console.log( + `system (${composed.systemPrompt.length} chars): ${clip(composed.systemPrompt, full)}`, + ); console.log( `tools (${composed.tools.length}): ${composed.tools.map((t) => t.name).join(", ")}`, ); @@ -82,9 +96,7 @@ async function main(): Promise { for (const message of composed.messages) { const m = message as { role?: string; content?: unknown }; const content = - typeof m.content === "string" - ? m.content - : JSON.stringify(m.content); + typeof m.content === "string" ? m.content : JSON.stringify(m.content); console.log(` [${m.role}] ${clip(content, full)}`); } if (call.error !== undefined) { @@ -99,6 +111,82 @@ async function main(): Promise { } } +async function inspectSession( + sessionId: string, + full: boolean, + cascade: boolean, +): Promise { + const state = reduceSession(await sessionRepo.read(sessionId)); + console.log(`session ${sessionId}`); + console.log( + `title "${state.title ?? ""}" turns ${state.turns.length} created ${state.createdAt} updated ${state.updatedAt}`, + ); + for (const ref of state.turns) { + try { + const events = await turnRepo.read(ref.turnId); + const turnState = reduceTurn(events); + const bytes = JSON.stringify(events).length; + console.log( + ` ${String(ref.sessionSeq).padStart(3)}. ${ref.turnId} ${deriveTurnStatus(turnState).padEnd(9)} ${turnState.modelCalls.length} calls ~${Math.round(bytes / 1024)}KB "${inputPreview(turnState)}"`, + ); + } catch (error) { + console.log( + ` ${String(ref.sessionSeq).padStart(3)}. ${ref.turnId} UNREADABLE: ${error instanceof Error ? error.message : error}`, + ); + } + } + if (cascade) { + for (const ref of state.turns) { + console.log(`\n════════ turn ${ref.sessionSeq} ════════`); + try { + await inspectTurn(ref.turnId, await turnRepo.read(ref.turnId), undefined, full); + } catch (error) { + console.log(`unreadable: ${error instanceof Error ? error.message : error}`); + } + } + } else { + console.log(`\n(inspect a turn: npm run inspect -- ; whole session: --turns)`); + } +} + +async function main(): Promise { + const flags = new Set(process.argv.slice(2).filter((a) => a.startsWith("--"))); + const args = process.argv.slice(2).filter((a) => !a.startsWith("--")); + const full = flags.has("--full"); + const cascade = flags.has("--turns"); + const target = args[0]; + if (!target) usage(); + const onlyIndex = args[1] !== undefined ? Number(args[1]) : undefined; + + const id = target.endsWith(".jsonl") ? path.basename(target, ".jsonl") : target; + + // Direct file path: session files live under storage/sessions. + if (target.endsWith(".jsonl") && fs.existsSync(target)) { + if (path.resolve(target).includes(`${path.sep}sessions${path.sep}`)) { + await inspectSession(id, full, cascade); + return; + } + const events = fs + .readFileSync(target, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as z.infer); + await inspectTurn(id, events, onlyIndex, full); + return; + } + + // Bare id: try turn first, then session. + try { + const events = await turnRepo.read(id); + await inspectTurn(id, events, onlyIndex, full); + } catch (error) { + if (!(error instanceof Error) || !/turn not found/.test(error.message)) { + throw error; + } + await inspectSession(id, full, cascade); + } +} + main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : error); process.exit(1); diff --git a/apps/x/packages/core/src/turns/runtime.test.ts b/apps/x/packages/core/src/turns/runtime.test.ts index 1d2671ed..170ba61b 100644 --- a/apps/x/packages/core/src/turns/runtime.test.ts +++ b/apps/x/packages/core/src/turns/runtime.test.ts @@ -146,6 +146,7 @@ function hangUntilAbort(onStarted?: () => void): ScriptedCall { class FakeModelRegistry implements IModelRegistry { requests: ModelStreamRequest[] = []; + resolved: Array = []; private next = 0; constructor(private readonly calls: ScriptedCall[]) {} @@ -153,6 +154,7 @@ class FakeModelRegistry implements IModelRegistry { async resolve( descriptor: ResolvedModel["descriptor"], ): Promise { + this.resolved.push(descriptor); return { descriptor, // Identity encoding: tests assert on structural messages directly. @@ -275,7 +277,10 @@ class FakeBus { } class FakeIdGen { - private n = 0; + private n: number; + constructor(start = 0) { + this.n = start; + } async next(): Promise { this.n += 1; return `2026-07-02T10-00-00Z-${String(this.n).padStart(7, "0")}-000`; @@ -296,6 +301,7 @@ function makeRuntime(opts: { agent?: z.infer; agentError?: string; repo?: InMemoryTurnRepo; + idStart?: number; } = {}) { const repo = opts.repo ?? new InMemoryTurnRepo(); const models = new FakeModelRegistry(opts.models ?? []); @@ -304,7 +310,7 @@ function makeRuntime(opts: { const bus = new FakeBus(); const runtime = new TurnRuntime({ turnRepo: repo, - idGenerator: new FakeIdGen(), + idGenerator: new FakeIdGen(opts.idStart ?? 0), clock: new FakeClock(), agentResolver: new FakeAgentResolver(opts.agent ?? defaultAgent, opts.agentError), modelRegistry: models, @@ -540,7 +546,7 @@ describe("mixed sync and async tools (26.2)", () => { // reproduces exactly what the model received. const state = reduceTurn(log); for (const index of [0, 1]) { - const composed = composeModelRequest(state, index, [], (m) => m as never); + const composed = composeModelRequest(state, index, [], defaultAgent, (m) => m as never); expect(composed.messages).toEqual(models.requests[index].messages); expect(composed.systemPrompt).toBe(models.requests[index].systemPrompt); expect(composed.tools).toEqual(models.requests[index].tools); @@ -1420,6 +1426,193 @@ describe("crash recovery (26.7)", () => { // §26.8 Historical and live reconstruction // --------------------------------------------------------------------------- +describe("agent snapshot inheritance", () => { + it("inherits system prompt + tools from an identical context predecessor", async () => { + const { runtime, repo, models } = makeRuntime({ + models: [ + respond(completedResp(assistantText("first"))), + respond(completedResp(assistantText("second"))), + ], + }); + const first = await newTurn(runtime, { sessionId: "S" }); + await advanceAndSettle(runtime, first); + + const second = await runtime.createTurn({ + agent: { agentId: "copilot" }, + sessionId: "S", + context: { previousTurnId: first }, + input: user("again"), + config: { humanAvailable: true }, + }); + const created = (await persisted(repo, second))[0]; + expect(created.type === "turn_created" ? created.agent.resolved : null).toEqual({ + agentId: "copilot", + model: defaultAgent.model, + inheritedFrom: first, + }); + + // The inherited turn still sends the full materialized snapshot. + const { outcome } = await advanceAndSettle(runtime, second); + expect(outcome?.status).toBe("completed"); + expect(models.requests[1].systemPrompt).toBe("SYS"); + expect(models.requests[1].tools).toEqual(defaultAgent.tools); + }); + + it("a model switch still inherits the prompt and tools (model stays concrete)", async () => { + const repo = new InMemoryTurnRepo(); + const a = makeRuntime({ + repo, + models: [respond(completedResp(assistantText("first")))], + }); + const first = await newTurn(a.runtime, { sessionId: "S" }); + await advanceAndSettle(a.runtime, first); + + const switched = { + ...defaultAgent, + model: { provider: "anthropic", model: "claude-x" }, + }; + const b = makeRuntime({ + repo, + agent: switched, + idStart: 200, + models: [respond(completedResp(assistantText("second")))], + }); + const second = await b.runtime.createTurn({ + agent: { agentId: "copilot" }, + sessionId: "S", + context: { previousTurnId: first }, + input: user("again"), + config: { humanAvailable: true }, + }); + const created = (await persisted(repo, second))[0]; + expect(created.type === "turn_created" ? created.agent.resolved : null).toEqual({ + agentId: "copilot", + model: { provider: "anthropic", model: "claude-x" }, + inheritedFrom: first, + }); + const { outcome } = await advanceAndSettle(b.runtime, second); + expect(outcome?.status).toBe("completed"); + // The switched model was resolved; prompt/tools came through the chain. + expect(b.models.resolved[0]).toEqual({ provider: "anthropic", model: "claude-x" }); + expect(b.models.requests[0].systemPrompt).toBe("SYS"); + expect(b.models.requests[0].tools).toEqual(defaultAgent.tools); + }); + + it("a tools difference forces a full snapshot", async () => { + const repo = new InMemoryTurnRepo(); + const a = makeRuntime({ + repo, + models: [respond(completedResp(assistantText("first")))], + }); + const first = await newTurn(a.runtime, { sessionId: "S" }); + await advanceAndSettle(a.runtime, first); + + const fewerTools = { ...defaultAgent, tools: [echoDescriptor] }; + const b = makeRuntime({ repo, agent: fewerTools, idStart: 300 }); + const second = await b.runtime.createTurn({ + agent: { agentId: "copilot" }, + sessionId: "S", + context: { previousTurnId: first }, + input: user("again"), + config: { humanAvailable: true }, + }); + const created = (await persisted(repo, second))[0]; + expect( + created.type === "turn_created" && "tools" in created.agent.resolved + ? created.agent.resolved.tools + : null, + ).toEqual([echoDescriptor]); + }); + + it("inheritance chains across turns and materializes through multiple hops", async () => { + const { runtime, repo, models } = makeRuntime({ + models: [ + respond(completedResp(assistantText("one"))), + respond(completedResp(assistantText("two"))), + respond(completedResp(assistantText("three"))), + ], + }); + const t1 = await newTurn(runtime, { sessionId: "S" }); + await advanceAndSettle(runtime, t1); + const t2 = await runtime.createTurn({ + agent: { agentId: "copilot" }, + sessionId: "S", + context: { previousTurnId: t1 }, + input: user("two"), + config: { humanAvailable: true }, + }); + await advanceAndSettle(runtime, t2); + const t3 = await runtime.createTurn({ + agent: { agentId: "copilot" }, + sessionId: "S", + context: { previousTurnId: t2 }, + input: user("three"), + config: { humanAvailable: true }, + }); + const created3 = (await persisted(repo, t3))[0]; + // t3 inherits from t2, which itself inherits from t1 (the concrete base). + expect( + created3.type === "turn_created" && "inheritedFrom" in created3.agent.resolved + ? created3.agent.resolved.inheritedFrom + : null, + ).toBe(t2); + const { outcome } = await advanceAndSettle(runtime, t3); + expect(outcome?.status).toBe("completed"); + expect(models.requests[2].systemPrompt).toBe("SYS"); + expect(models.requests[2].tools).toEqual(defaultAgent.tools); + }); + + it("standalone (inline-context) turns always persist a full snapshot", async () => { + const { runtime, repo } = makeRuntime(); + const turnId = await newTurn(runtime); + const created = (await persisted(repo, turnId))[0]; + expect( + created.type === "turn_created" && "systemPrompt" in created.agent.resolved, + ).toBe(true); + }); + + it("falls back to a full snapshot when the predecessor is unreadable", async () => { + const { runtime, repo } = makeRuntime(); + const turnId = await runtime.createTurn({ + agent: { agentId: "copilot" }, + sessionId: "S", + context: { previousTurnId: "2026-07-02T09-00-00Z-0000404-000" }, + input: user("orphan ref"), + config: { humanAvailable: true }, + }); + const created = (await persisted(repo, turnId))[0]; + expect( + created.type === "turn_created" && "systemPrompt" in created.agent.resolved, + ).toBe(true); + }); + + it("persists a full snapshot when the resolved agent differs", async () => { + const repo = new InMemoryTurnRepo(); + const a = makeRuntime({ + repo, + models: [respond(completedResp(assistantText("first")))], + }); + const first = await newTurn(a.runtime, { sessionId: "S" }); + await advanceAndSettle(a.runtime, first); + + const changedAgent = { ...defaultAgent, systemPrompt: "SYS v2" }; + const b = makeRuntime({ repo, agent: changedAgent, idStart: 100 }); + const second = await b.runtime.createTurn({ + agent: { agentId: "copilot" }, + sessionId: "S", + context: { previousTurnId: first }, + input: user("again"), + config: { humanAvailable: true }, + }); + const created = (await persisted(repo, second))[0]; + expect( + created.type === "turn_created" && "systemPrompt" in created.agent.resolved + ? created.agent.resolved.systemPrompt + : null, + ).toBe("SYS v2"); + }); +}); + describe("historical and live reconstruction (26.8)", () => { it("getTurn is read-only and matches live durable events", async () => { const { runtime, repo } = makeRuntime({ diff --git a/apps/x/packages/core/src/turns/runtime.ts b/apps/x/packages/core/src/turns/runtime.ts index d82e575a..80f657b9 100644 --- a/apps/x/packages/core/src/turns/runtime.ts +++ b/apps/x/packages/core/src/turns/runtime.ts @@ -6,6 +6,8 @@ import { type JsonValue, type ModelCallFailed, type ModelRequest, + type ResolvedAgent, + type ResolvedAgentSnapshot, assistantRef, toolResultRef, type ToolCallState, @@ -109,13 +111,42 @@ export class TurnRuntime implements ITurnRuntime { async createTurn(input: CreateTurnInput): Promise { const resolved = await this.agentResolver.resolve(input.agent); const turnId = await this.idGenerator.next(); + // Inherit the heavy snapshot fields (system prompt + tools) when they + // are byte-identical to the context predecessor's materialized + // snapshot — the same reference mechanism as context and requests. + // The model stays concrete for the session index and so a model + // switch never blocks inheritance. + let snapshot: z.infer = resolved; + if (!Array.isArray(input.context)) { + try { + const previousEvents = await this.turnRepo.read( + input.context.previousTurnId, + ); + const previous = await this.contextResolver.resolveAgent( + reduceTurn(previousEvents).definition.agent.resolved, + ); + if ( + previous.systemPrompt === resolved.systemPrompt && + JSON.stringify(previous.tools) === JSON.stringify(resolved.tools) + ) { + snapshot = { + agentId: resolved.agentId, + model: resolved.model, + inheritedFrom: input.context.previousTurnId, + }; + } + } catch { + // Unreadable predecessor: persist the full snapshot; context + // resolution surfaces the real problem at advance time. + } + } const event = TurnCreated.parse({ type: "turn_created", schemaVersion: 1, turnId, ts: this.clock.now(), sessionId: input.sessionId ?? null, - agent: { requested: input.agent, resolved }, + agent: { requested: input.agent, resolved: snapshot }, context: input.context, input: input.input, config: { @@ -188,11 +219,12 @@ export class TurnRuntime implements ITurnRuntime { const resolvedContext = await this.contextResolver.resolve( definition.context, ); - const model = await this.modelRegistry.resolve( - definition.agent.resolved.model, + const resolvedAgent = await this.contextResolver.resolveAgent( + definition.agent.resolved, ); + const model = await this.modelRegistry.resolve(resolvedAgent.model); const toolsByName = new Map(); - for (const descriptor of definition.agent.resolved.tools) { + for (const descriptor of resolvedAgent.tools) { const tool = await this.toolRegistry.resolve(descriptor); if ( tool.descriptor.toolId !== descriptor.toolId || @@ -223,6 +255,7 @@ export class TurnRuntime implements ITurnRuntime { state, stream, resolvedContext, + resolvedAgent, model, toolsByName, signal: controller.signal, @@ -250,6 +283,7 @@ class TurnAdvance { private state: TurnState; private readonly stream: HotStream; private readonly resolvedContext: Array>; + private readonly resolvedAgent: z.infer; private readonly model: ResolvedModel; private readonly toolsByName: Map; private readonly signal: AbortSignal; @@ -270,6 +304,7 @@ class TurnAdvance { state: TurnState; stream: HotStream; resolvedContext: Array>; + resolvedAgent: z.infer; model: ResolvedModel; toolsByName: Map; signal: AbortSignal; @@ -283,6 +318,7 @@ class TurnAdvance { this.state = init.state; this.stream = init.stream; this.resolvedContext = init.resolvedContext; + this.resolvedAgent = init.resolvedAgent; this.model = init.model; this.toolsByName = init.toolsByName; this.signal = init.signal; @@ -893,6 +929,7 @@ class TurnAdvance { this.state, index, this.resolvedContext, + this.resolvedAgent, (messages) => this.model.encodeMessages(messages), ); diff --git a/apps/x/packages/shared/package.json b/apps/x/packages/shared/package.json index 5a1a9c45..31f99cdd 100644 --- a/apps/x/packages/shared/package.json +++ b/apps/x/packages/shared/package.json @@ -1,19 +1,19 @@ { - "name": "@x/shared", - "private": true, - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json", - "dev": "tsc -w", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "zod": "^4.2.1" - }, - "devDependencies": { - "vitest": "catalog:" - } -} \ No newline at end of file + "name": "@x/shared", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "dev": "tsc -w", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "zod": "^4.2.1" + }, + "devDependencies": { + "vitest": "catalog:" + } +} diff --git a/apps/x/packages/shared/src/turns.test.ts b/apps/x/packages/shared/src/turns.test.ts index 844d76b2..84a5a9c7 100644 --- a/apps/x/packages/shared/src/turns.test.ts +++ b/apps/x/packages/shared/src/turns.test.ts @@ -627,6 +627,67 @@ describe("context references", () => { }); }); +describe("inherited agent snapshots", () => { + const inherited = { + agentId: "copilot", + model: { provider: "openai", model: "gpt-test" }, + inheritedFrom: PREV_TURN_ID, + }; + + it("accepts inheritance referencing the context predecessor; identity arrives via invocation", () => { + const call0 = assistantCalls(toolCallPart("tc1", "echo")); + const state = reduceTurn([ + created({ + context: { previousTurnId: PREV_TURN_ID }, + agent: { requested: { agentId: "copilot" }, resolved: inherited }, + }), + requested(0, ["input"], { contextRef: { previousTurnId: PREV_TURN_ID } }), + completed(0, call0), + ]); + // No descriptor lookup without the concrete snapshot… + expect(state.toolCalls[0].toolId).toBeUndefined(); + expect(state.toolCalls[0].execution).toBeUndefined(); + + // …until tool_invocation_requested supplies identity. + const withInvocation = reduceTurn([ + created({ + context: { previousTurnId: PREV_TURN_ID }, + agent: { requested: { agentId: "copilot" }, resolved: inherited }, + }), + requested(0, ["input"], { contextRef: { previousTurnId: PREV_TURN_ID } }), + completed(0, call0), + invocation("tc1"), + result("tc1", "echo"), + ]); + expect(withInvocation.toolCalls[0].toolId).toBe("tool.echo"); + expect(withInvocation.toolCalls[0].execution).toBe("sync"); + }); + + it("rejects an inherited snapshot on an inline-context turn", () => { + expectCorruption( + [ + created({ + context: [], + agent: { requested: { agentId: "copilot" }, resolved: inherited }, + }), + ], + /inherited agent snapshot must reference the turn's context predecessor/, + ); + }); + + it("rejects an inherited snapshot pointing at a different turn than the context", () => { + expectCorruption( + [ + created({ + context: { previousTurnId: "some-other-turn" }, + agent: { requested: { agentId: "copilot" }, resolved: inherited }, + }), + ], + /inherited agent snapshot must reference the turn's context predecessor/, + ); + }); +}); + describe("suspension", () => { it("accepts a snapshot matching pending permissions and async tools", () => { const call0 = assistantCalls( diff --git a/apps/x/packages/shared/src/turns.ts b/apps/x/packages/shared/src/turns.ts index 6d28e035..df9a65f4 100644 --- a/apps/x/packages/shared/src/turns.ts +++ b/apps/x/packages/shared/src/turns.ts @@ -58,6 +58,30 @@ export const ResolvedAgent = z.object({ tools: z.array(ToolDescriptor), }); +// Session turns whose system prompt and tool set are byte-identical to the +// previous turn's materialized snapshot inherit it by reference instead of +// re-persisting ~tens of KB per turn (same mechanism as context references). +// The model stays concrete: it is tiny, the session index denormalizes it, +// and a mid-session model switch must not block inheritance. Written only +// when equality holds at creation; materialization walks inheritedFrom to +// the nearest concrete snapshot. +export const InheritedAgentSnapshot = z.object({ + agentId: z.string(), + model: ModelDescriptor, + inheritedFrom: z.string(), +}); + +export const ResolvedAgentSnapshot = z.union([ + ResolvedAgent, + InheritedAgentSnapshot, +]); + +export function isInheritedSnapshot( + resolved: z.infer, +): resolved is z.infer { + return "inheritedFrom" in resolved; +} + // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- @@ -108,7 +132,7 @@ export const TurnCreated = z.object({ sessionId: z.string().nullable(), agent: z.object({ requested: RequestedAgent, - resolved: ResolvedAgent, + resolved: ResolvedAgentSnapshot, }), context: TurnContext, input: UserMessage, @@ -614,9 +638,12 @@ function applyModelCallCompleted( if (state.toolCalls.some((tc) => tc.toolCallId === part.toolCallId)) { fail(`duplicate tool call id: ${part.toolCallId}`); } - const descriptor = state.definition.agent.resolved.tools.find( - (tool) => tool.name === part.toolName, - ); + const resolved = state.definition.agent.resolved; + // Inherited snapshots resolve outside the reducer; identity fields + // then arrive via tool_invocation_requested events. + const descriptor = isInheritedSnapshot(resolved) + ? undefined + : resolved.tools.find((tool) => tool.name === part.toolName); state.toolCalls.push({ modelCallIndex: event.modelCallIndex, order: order++, @@ -850,6 +877,18 @@ export function reduceTurn( fail(`unsupported turn schema version: ${String(first.schemaVersion)}`); } + if (isInheritedSnapshot(first.agent.resolved)) { + const context = first.context; + if ( + Array.isArray(context) || + context.previousTurnId !== first.agent.resolved.inheritedFrom + ) { + fail( + "inherited agent snapshot must reference the turn's context predecessor", + ); + } + } + const state: TurnState = { definition: first, modelCalls: [],