mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(x): agent snapshot inheritance, session inspection, runtime docs
Third application of the reference mechanism: session turns whose
system prompt + tools are byte-identical to the context predecessor's
materialized snapshot persist { agentId, model, inheritedFrom } instead
of ~70KB per turn (measured: turn 2 of a session is now ~1.1KB total).
Inheritance is decided at createTurn by equality against the
materialized predecessor; the model stays concrete, and on
materialization the inherited record's own agentId/model win — a rule
the new test matrix caught as a real bug (the chain base's model was
overriding a mid-session model switch). Reducer invariants: inherited
snapshots must reference the context predecessor; tool identity arrives
via invocation events.
Test matrix per review: prompt-diff -> full snapshot, tools-diff ->
full snapshot, model-switch -> inherits with concrete model, multi-hop
chains materialize, standalone turns never inherit, unreadable
predecessor falls back to full, cyclic inheritance is corruption,
sessions denormalize the model from inherited snapshots.
The inspector now handles sessions too (auto-detected): overview with
per-turn status/size/input preview, --turns to cascade full turn
inspection. Documented in a new repo-root AGENTS.md (storage layout,
reference model, inspector usage, invariants) with a CLAUDE.md pointer.
Breaking for dev turn files: wipe ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
97169be93e
commit
32e02d588c
14 changed files with 759 additions and 77 deletions
74
AGENTS.md
Normal file
74
AGENTS.md
Normal file
|
|
@ -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:<index>"`, `"toolResult:<toolCallId>"` — 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 -- <sessionId | path/to/session.jsonl>
|
||||
|
||||
# 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 -- <turnId | path/to/turn.jsonl> [modelCallIndex] [--full]
|
||||
|
||||
# Cascade full turn inspection across a session
|
||||
npm run inspect -- <sessionId> --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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -187,6 +187,9 @@ class FakeTurnRuntime implements ITurnRuntime {
|
|||
logs = new Map<string, TEvent[]>();
|
||||
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<string> {
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<z.infer<typeof ConversationMessage>>,
|
||||
// The materialized agent snapshot (contextResolver.resolveAgent output —
|
||||
// inherited snapshots must be resolved before composing).
|
||||
agent: z.infer<typeof ResolvedAgent>,
|
||||
encode: (messages: Array<z.infer<typeof ConversationMessage>>) => 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<z.infer<typeof ConversationMessage>>) => JsonValue[],
|
||||
): Promise<ComposedModelRequest> {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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<typeof TurnContext>,
|
||||
): Promise<Array<z.infer<typeof ConversationMessage>>>;
|
||||
// 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<typeof ResolvedAgentSnapshot>,
|
||||
): Promise<z.infer<typeof ResolvedAgent>>;
|
||||
}
|
||||
|
||||
export class TurnRepoContextResolver implements IContextResolver {
|
||||
|
|
@ -52,4 +61,28 @@ export class TurnRepoContextResolver implements IContextResolver {
|
|||
segments.reverse();
|
||||
return segments.flat();
|
||||
}
|
||||
|
||||
async resolveAgent(
|
||||
resolved: z.infer<typeof ResolvedAgentSnapshot>,
|
||||
): Promise<z.infer<typeof ResolvedAgent>> {
|
||||
if (!isInheritedSnapshot(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
const visited = new Set<string>();
|
||||
let current: z.infer<typeof ResolvedAgentSnapshot> = 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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -- <turnId | path/to/turn.jsonl> [modelCallIndex] [--full]
|
||||
// npm run inspect -- <turnId | sessionId | path/to/*.jsonl> [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<typeof convertFromMessages>[0]) =>
|
||||
convertFromMessages(messages) as unknown as JsonValue[];
|
||||
|
||||
function usage(): never {
|
||||
console.error(
|
||||
"usage: inspect-turn <turnId | path/to/turn.jsonl> [modelCallIndex] [--full]",
|
||||
"usage: inspect <turnId | sessionId | path/to/*.jsonl> [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<void> {
|
||||
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<z.infer<typeof TurnEvent>>;
|
||||
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<typeof TurnEvent>);
|
||||
} else {
|
||||
events = await repo.read(turnId);
|
||||
}
|
||||
async function inspectTurn(
|
||||
turnId: string,
|
||||
events: Array<z.infer<typeof TurnEvent>>,
|
||||
onlyIndex: number | undefined,
|
||||
full: boolean,
|
||||
): Promise<void> {
|
||||
const state = reduceTurn(events);
|
||||
const resolver = new TurnRepoContextResolver({ turnRepo: repo });
|
||||
const prefix = await resolver.resolve(state.definition.context);
|
||||
const encode = (messages: Parameters<typeof convertFromMessages>[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<void> {
|
|||
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<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function inspectSession(
|
||||
sessionId: string,
|
||||
full: boolean,
|
||||
cascade: boolean,
|
||||
): Promise<void> {
|
||||
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 -- <turnId>; whole session: --turns)`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
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<typeof TurnEvent>);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ function hangUntilAbort(onStarted?: () => void): ScriptedCall {
|
|||
|
||||
class FakeModelRegistry implements IModelRegistry {
|
||||
requests: ModelStreamRequest[] = [];
|
||||
resolved: Array<ResolvedModel["descriptor"]> = [];
|
||||
private next = 0;
|
||||
|
||||
constructor(private readonly calls: ScriptedCall[]) {}
|
||||
|
|
@ -153,6 +154,7 @@ class FakeModelRegistry implements IModelRegistry {
|
|||
async resolve(
|
||||
descriptor: ResolvedModel["descriptor"],
|
||||
): Promise<ResolvedModel> {
|
||||
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<string> {
|
||||
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<typeof ResolvedAgent>;
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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<typeof ResolvedAgentSnapshot> = 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<string, RuntimeTool>();
|
||||
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<TurnStreamEvent, TurnOutcome>;
|
||||
private readonly resolvedContext: Array<z.infer<typeof ConversationMessage>>;
|
||||
private readonly resolvedAgent: z.infer<typeof ResolvedAgent>;
|
||||
private readonly model: ResolvedModel;
|
||||
private readonly toolsByName: Map<string, RuntimeTool>;
|
||||
private readonly signal: AbortSignal;
|
||||
|
|
@ -270,6 +304,7 @@ class TurnAdvance {
|
|||
state: TurnState;
|
||||
stream: HotStream<TurnStreamEvent, TurnOutcome>;
|
||||
resolvedContext: Array<z.infer<typeof ConversationMessage>>;
|
||||
resolvedAgent: z.infer<typeof ResolvedAgent>;
|
||||
model: ResolvedModel;
|
||||
toolsByName: Map<string, RuntimeTool>;
|
||||
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),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<typeof ResolvedAgentSnapshot>,
|
||||
): resolved is z.infer<typeof InheritedAgentSnapshot> {
|
||||
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: [],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue