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:
Ramnique Singh 2026-07-02 13:17:30 +05:30
parent 97169be93e
commit 32e02d588c
14 changed files with 759 additions and 77 deletions

View file

@ -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(

View file

@ -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: [],