feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
import { z } from "zod";
|
|
|
|
|
import {
|
|
|
|
|
AssistantMessage,
|
|
|
|
|
ToolCallPart,
|
|
|
|
|
ToolMessage,
|
|
|
|
|
UserMessage,
|
|
|
|
|
} from "./message.js";
|
feat(x): per-turn reasoning effort — core plumbing
Adds a canonical reasoning-effort ladder (low|medium|high, absent =
auto/provider default) that travels: send config → turn_created.config →
every model call's persisted parameters (§8.3) → provider-specific
options at invoke time. Behavior-neutral: nothing sends an effort yet,
and auto produces byte-identical requests to today.
- shared: ReasoningEffort enum (single source, reused by models.json
provider config), TurnCreated.config.reasoningEffort (additive
optional on a non-strict object — old builds strip it, no
schemaVersion bump), sessions:sendMessage IPC schema.
- turns: CreateTurnInput/SendMessageConfig/HeadlessAgentOptions carry
the value; runModelStep stamps it on each call's parameters so every
step durably records what it ran with.
- bridge: maps canonical effort to provider options, transport-only
like prompt caching — OpenAI reasoningEffort, Anthropic thinking
budgets (raising maxOutputTokens to the budget floor, never lowering
an explicit value), Gemini thinkingLevel/thinkingBudget by
generation, OpenRouter/rowboat reasoning.effort. Capability-gated
via a cache-only models.dev lookup (never blocks a turn on the
network); unknown support fails closed on strict flavors, while
OpenRouter-shaped flavors map permissively since OpenRouter drops
the field for non-reasoning models. Explicit persisted
providerOptions win over the mapping. Ollama keeps its existing
provider-level think rewrite; openai-compatible endpoints get
nothing (no safe universal parameter).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:57:53 +05:30
|
|
|
import { ReasoningEffort } from "./models.js";
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
|
|
|
|
|
// Durable turn contract for the turn runtime (see
|
|
|
|
|
// packages/core/docs/turn-runtime-design.md). This module is the
|
|
|
|
|
// cross-boundary source of truth shared by core and renderer: event schemas,
|
|
|
|
|
// the pure reducer, and pure derivations over the reduced state. It must
|
|
|
|
|
// stay free of I/O and node-only imports so the renderer can consume it
|
|
|
|
|
// directly.
|
|
|
|
|
|
|
|
|
|
export type JsonValue = z.infer<ReturnType<typeof z.json>>;
|
|
|
|
|
|
|
|
|
|
export const DEFAULT_MAX_MODEL_CALLS = 20;
|
|
|
|
|
export const MODEL_CALL_LIMIT_ERROR_CODE = "model-call-limit";
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Agent snapshot
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export const ModelDescriptor = z.object({
|
|
|
|
|
provider: z.string(),
|
|
|
|
|
model: z.string(),
|
|
|
|
|
});
|
|
|
|
|
|
feat(x): spawn-agent — run sub-agents as headless child turns
Adds the agent-as-tool capability deferred in turn-runtime-design §29.2:
a `spawn-agent` builtin that runs a sub-agent in its own standalone
headless turn and returns its final answer to the parent. Multiple
spawn calls in one assistant batch run concurrently (previous commit).
- RequestedAgent is now a union: by-id (unchanged shape) | inline
{name, instructions, model?, tools?}. Inline definitions persist
verbatim in turn_created and resolve to the same immutable snapshot.
- Agent resolution splits by variant: DispatchingAgentResolver narrows
the union once; InlineAgentResolver materializes inline specs
(builtin catalog validation, headless default profile when tools are
omitted); RealAgentResolver keeps the by-id path byte-identical. The
builtin→ToolDescriptor conversion is extracted to a shared helper.
- The spawn handler (RealToolRegistry branch → runSpawnedAgent) runs
the child via HeadlessAgentRunner on the parent's model by default,
clamps the model-call budget at 20, cascades the parent's abort
signal, and records {kind:"subagent", childTurnId} as durable tool
progress — the only parent→child link; no parentTurnId is added to
the schema. Task-level failures return as conversational isError
results, never terminal.
- Depth is capped at 1: both resolvers strip spawn-agent from children
(inline always; by-id via the new subagent composition flag) and the
handler refuses child-shaped parents outright.
- Renderer: spawn-agent calls render as a SubAgentBlock — a collapsed
status card that expands to the child's live transcript
(CompactConversation over sessions:getTurn, polled at 1s while
running; standalone child turns don't reach the session bus).
- The BuiltinTools entry gives copilot (and other catalog-attached
agents) the tool automatically; its execute is the degraded legacy
path only, since the turn runtime intercepts builtin:spawn-agent.
Schema note: RequestedAgent widened under schemaVersion 1 (pre-release)
— requires wiping ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 23:31:41 +05:30
|
|
|
export const AgentByIdRequest = z.object({
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
agentId: z.string(),
|
|
|
|
|
overrides: z
|
|
|
|
|
.object({
|
|
|
|
|
model: ModelDescriptor.optional(),
|
2026-07-02 10:45:35 +05:30
|
|
|
// Opaque composition hints interpreted by the agent resolver
|
|
|
|
|
// (e.g. work-dir id, voice/search/code modes). Persisted verbatim
|
|
|
|
|
// for audit. The resolver decides which keys affect the system
|
|
|
|
|
// prompt; keeping prompt-affecting inputs session-sticky is what
|
|
|
|
|
// preserves provider prefix caching across turns.
|
|
|
|
|
composition: z.json().optional(),
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
})
|
|
|
|
|
.optional(),
|
|
|
|
|
});
|
|
|
|
|
|
feat(x): spawn-agent — run sub-agents as headless child turns
Adds the agent-as-tool capability deferred in turn-runtime-design §29.2:
a `spawn-agent` builtin that runs a sub-agent in its own standalone
headless turn and returns its final answer to the parent. Multiple
spawn calls in one assistant batch run concurrently (previous commit).
- RequestedAgent is now a union: by-id (unchanged shape) | inline
{name, instructions, model?, tools?}. Inline definitions persist
verbatim in turn_created and resolve to the same immutable snapshot.
- Agent resolution splits by variant: DispatchingAgentResolver narrows
the union once; InlineAgentResolver materializes inline specs
(builtin catalog validation, headless default profile when tools are
omitted); RealAgentResolver keeps the by-id path byte-identical. The
builtin→ToolDescriptor conversion is extracted to a shared helper.
- The spawn handler (RealToolRegistry branch → runSpawnedAgent) runs
the child via HeadlessAgentRunner on the parent's model by default,
clamps the model-call budget at 20, cascades the parent's abort
signal, and records {kind:"subagent", childTurnId} as durable tool
progress — the only parent→child link; no parentTurnId is added to
the schema. Task-level failures return as conversational isError
results, never terminal.
- Depth is capped at 1: both resolvers strip spawn-agent from children
(inline always; by-id via the new subagent composition flag) and the
handler refuses child-shaped parents outright.
- Renderer: spawn-agent calls render as a SubAgentBlock — a collapsed
status card that expands to the child's live transcript
(CompactConversation over sessions:getTurn, polled at 1s while
running; standalone child turns don't reach the session bus).
- The BuiltinTools entry gives copilot (and other catalog-attached
agents) the tool automatically; its execute is the degraded legacy
path only, since the turn runtime intercepts builtin:spawn-agent.
Schema note: RequestedAgent widened under schemaVersion 1 (pre-release)
— requires wiping ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 23:31:41 +05:30
|
|
|
// An agent constructed at request time (sub-agents spawned by a parent turn).
|
|
|
|
|
// Persisted verbatim in turn_created.agent.requested, so the definition
|
|
|
|
|
// self-documents in the turn file; the resolver materializes it into the same
|
|
|
|
|
// immutable ResolvedAgent snapshot as a stored agent.
|
|
|
|
|
export const InlineAgentSpec = z.object({
|
|
|
|
|
name: z.string(),
|
|
|
|
|
instructions: z.string(),
|
|
|
|
|
model: ModelDescriptor.optional(),
|
|
|
|
|
// Builtin tool names; resolution validates against the live catalog and
|
|
|
|
|
// substitutes the default headless profile when omitted.
|
|
|
|
|
tools: z.array(z.string()).optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const InlineAgentRequest = z.object({
|
|
|
|
|
inline: InlineAgentSpec,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The builtin that spawns sub-agent turns. Named here (not in core) because
|
|
|
|
|
// resolvers, the tool registry, and the renderer's card dispatch all key on
|
|
|
|
|
// it, and children must never receive it (depth is capped at 1).
|
|
|
|
|
export const SPAWN_AGENT_TOOL_NAME = "spawn-agent";
|
|
|
|
|
|
|
|
|
|
// The ResolvedAgent.agentId convention for inline agents; also what sessions
|
|
|
|
|
// denormalize into their index for inline-agent turns.
|
|
|
|
|
export function inlineAgentId(name: string): string {
|
|
|
|
|
return `inline:${name}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const RequestedAgent = z.union([AgentByIdRequest, InlineAgentRequest]);
|
|
|
|
|
|
|
|
|
|
export function isInlineAgentRequest(
|
|
|
|
|
requested: z.infer<typeof RequestedAgent>,
|
|
|
|
|
): requested is z.infer<typeof InlineAgentRequest> {
|
|
|
|
|
return "inline" in requested;
|
|
|
|
|
}
|
|
|
|
|
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
export const ToolDescriptor = z.object({
|
|
|
|
|
toolId: z.string(),
|
|
|
|
|
name: z.string(),
|
|
|
|
|
description: z.string(),
|
|
|
|
|
inputSchema: z.json(),
|
|
|
|
|
execution: z.enum(["sync", "async"]),
|
|
|
|
|
requiresHuman: z.boolean(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const ResolvedAgent = z.object({
|
|
|
|
|
agentId: z.string(),
|
|
|
|
|
systemPrompt: z.string(),
|
|
|
|
|
model: ModelDescriptor,
|
|
|
|
|
tools: z.array(ToolDescriptor),
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-07-02 13:17:30 +05:30
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Context
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
// Context excludes system-role messages: the resolved agent owns the single
|
|
|
|
|
// authoritative system prompt.
|
|
|
|
|
export const ConversationMessage = z.discriminatedUnion("role", [
|
|
|
|
|
UserMessage,
|
|
|
|
|
AssistantMessage,
|
|
|
|
|
ToolMessage,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// A reference points at the immediately preceding turn; its materialized
|
|
|
|
|
// value is that turn's transcript (context + input + produced messages).
|
|
|
|
|
// Inline arrays are used by standalone turns and by callers that assemble
|
|
|
|
|
// context themselves. Resolution is a runtime concern; the reducer treats
|
|
|
|
|
// context as opaque.
|
|
|
|
|
export const TurnContextRef = z.object({
|
|
|
|
|
previousTurnId: z.string(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const TurnContext = z.union([
|
|
|
|
|
TurnContextRef,
|
|
|
|
|
z.array(ConversationMessage),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Usage
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export const TurnUsage = z.object({
|
|
|
|
|
inputTokens: z.number().optional(),
|
|
|
|
|
outputTokens: z.number().optional(),
|
|
|
|
|
totalTokens: z.number().optional(),
|
|
|
|
|
reasoningTokens: z.number().optional(),
|
|
|
|
|
cachedInputTokens: z.number().optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Durable events
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export const TurnCreated = z.object({
|
|
|
|
|
type: z.literal("turn_created"),
|
|
|
|
|
schemaVersion: z.literal(1),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
sessionId: z.string().nullable(),
|
|
|
|
|
agent: z.object({
|
|
|
|
|
requested: RequestedAgent,
|
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>
2026-07-02 13:17:30 +05:30
|
|
|
resolved: ResolvedAgentSnapshot,
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
}),
|
|
|
|
|
context: TurnContext,
|
|
|
|
|
input: UserMessage,
|
|
|
|
|
config: z.object({
|
|
|
|
|
autoPermission: z.boolean(),
|
|
|
|
|
humanAvailable: z.boolean(),
|
|
|
|
|
maxModelCalls: z.number().int().positive(),
|
feat(x): per-turn reasoning effort — core plumbing
Adds a canonical reasoning-effort ladder (low|medium|high, absent =
auto/provider default) that travels: send config → turn_created.config →
every model call's persisted parameters (§8.3) → provider-specific
options at invoke time. Behavior-neutral: nothing sends an effort yet,
and auto produces byte-identical requests to today.
- shared: ReasoningEffort enum (single source, reused by models.json
provider config), TurnCreated.config.reasoningEffort (additive
optional on a non-strict object — old builds strip it, no
schemaVersion bump), sessions:sendMessage IPC schema.
- turns: CreateTurnInput/SendMessageConfig/HeadlessAgentOptions carry
the value; runModelStep stamps it on each call's parameters so every
step durably records what it ran with.
- bridge: maps canonical effort to provider options, transport-only
like prompt caching — OpenAI reasoningEffort, Anthropic thinking
budgets (raising maxOutputTokens to the budget floor, never lowering
an explicit value), Gemini thinkingLevel/thinkingBudget by
generation, OpenRouter/rowboat reasoning.effort. Capability-gated
via a cache-only models.dev lookup (never blocks a turn on the
network); unknown support fails closed on strict flavors, while
OpenRouter-shaped flavors map permissively since OpenRouter drops
the field for non-reasoning models. Explicit persisted
providerOptions win over the mapping. Ollama keeps its existing
provider-level think rewrite; openai-compatible endpoints get
nothing (no safe universal parameter).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:57:53 +05:30
|
|
|
// Canonical per-turn reasoning effort; absent = auto (provider
|
|
|
|
|
// default). Stamped into every model call's parameters and mapped
|
|
|
|
|
// to provider-specific options at invoke time.
|
|
|
|
|
reasoningEffort: ReasoningEffort.optional(),
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
feat(x): reference-based model requests + wire-form composer
model_call_requested carried three duplications that dominated turn-file
size (measured on a real 647KB two-step turn): the system prompt (28KB)
and the 38-tool snapshot (40KB) repeated per call despite being byte-
identical to turn_created.agent.resolved, and messages re-inlining the
whole current-turn transcript (230KB re-stating a 207KB tool result).
ModelRequest is now a list of references into the turn's own events —
call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that
batch's toolResults in source order]; re-issue after an interruption:
[]. Every referenced byte exists exactly once in the file; the measured
turn drops to ~285KB and each further model call costs ~200 bytes. The
reducer's ordering invariant got stricter: reference lists are matched
exactly against the transcript.
Debuggability is now byte-for-byte at the wire level: ResolvedModel
gains encodeMessages (the structural->wire conversion — user-message
context weaving, attachment rendering, tool-result enveloping, i.e.
convertFromMessages), and composeModelRequest rebuilds the exact
provider payload (resolved system prompt + wrapped tools + materialized
prefix + resolved refs, encoded). The loop transmits exactly the
composer's output, so the durable file plus the composer reproduce what
the model received — pinned by a property test asserting composed ==
sent for every call, and a 2KB size guard on request events. A bridge
test demonstrates the woven wire form (no raw userMessageContext).
Breaking for existing dev turn files (same schemaVersion, pre-release):
wipe ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
|
|
|
// A model request is a list of REFERENCES into the turn's own events; every
|
|
|
|
|
// referenced byte exists exactly once in the file. The request records only
|
|
|
|
|
// what is NEW since the previous model call:
|
|
|
|
|
// call 0: [{context}?, {input}] (context only when inline and nonempty;
|
|
|
|
|
// cross-turn prefixes ride contextRef)
|
|
|
|
|
// call N: [{assistant: N-1}, ...that batch's toolResults in source order]
|
|
|
|
|
// re-issue after an interrupted call: []
|
|
|
|
|
// The system prompt and tool set are NOT repeated here — they are byte-
|
|
|
|
|
// identical to turn_created.agent.resolved by construction. The exact
|
|
|
|
|
// provider payload is rebuilt deterministically by the request composer
|
|
|
|
|
// (core), which is the same code path the loop sends through.
|
2026-07-02 13:00:33 +05:30
|
|
|
// Compact string refs so raw JSONL reads naturally:
|
|
|
|
|
// "context" | "input" | "assistant:<modelCallIndex>" | "toolResult:<toolCallId>"
|
|
|
|
|
export const ModelRequestMessageRef = z
|
|
|
|
|
.string()
|
|
|
|
|
.regex(/^(context|input|assistant:\d+|toolResult:.+)$/);
|
|
|
|
|
|
|
|
|
|
export type ParsedRequestRef =
|
|
|
|
|
| { kind: "context" }
|
|
|
|
|
| { kind: "input" }
|
|
|
|
|
| { kind: "assistant"; modelCallIndex: number }
|
|
|
|
|
| { kind: "toolResult"; toolCallId: string };
|
|
|
|
|
|
|
|
|
|
export const assistantRef = (modelCallIndex: number): string =>
|
|
|
|
|
`assistant:${modelCallIndex}`;
|
|
|
|
|
export const toolResultRef = (toolCallId: string): string =>
|
|
|
|
|
`toolResult:${toolCallId}`;
|
|
|
|
|
|
|
|
|
|
export function parseRequestRef(ref: string): ParsedRequestRef {
|
|
|
|
|
if (ref === "context" || ref === "input") {
|
|
|
|
|
return { kind: ref };
|
|
|
|
|
}
|
|
|
|
|
if (ref.startsWith("assistant:")) {
|
|
|
|
|
const modelCallIndex = Number(ref.slice("assistant:".length));
|
|
|
|
|
if (!Number.isInteger(modelCallIndex) || modelCallIndex < 0) {
|
|
|
|
|
throw new Error(`malformed request ref: ${ref}`);
|
|
|
|
|
}
|
|
|
|
|
return { kind: "assistant", modelCallIndex };
|
|
|
|
|
}
|
|
|
|
|
if (ref.startsWith("toolResult:")) {
|
|
|
|
|
return { kind: "toolResult", toolCallId: ref.slice("toolResult:".length) };
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`malformed request ref: ${ref}`);
|
|
|
|
|
}
|
feat(x): reference-based model requests + wire-form composer
model_call_requested carried three duplications that dominated turn-file
size (measured on a real 647KB two-step turn): the system prompt (28KB)
and the 38-tool snapshot (40KB) repeated per call despite being byte-
identical to turn_created.agent.resolved, and messages re-inlining the
whole current-turn transcript (230KB re-stating a 207KB tool result).
ModelRequest is now a list of references into the turn's own events —
call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that
batch's toolResults in source order]; re-issue after an interruption:
[]. Every referenced byte exists exactly once in the file; the measured
turn drops to ~285KB and each further model call costs ~200 bytes. The
reducer's ordering invariant got stricter: reference lists are matched
exactly against the transcript.
Debuggability is now byte-for-byte at the wire level: ResolvedModel
gains encodeMessages (the structural->wire conversion — user-message
context weaving, attachment rendering, tool-result enveloping, i.e.
convertFromMessages), and composeModelRequest rebuilds the exact
provider payload (resolved system prompt + wrapped tools + materialized
prefix + resolved refs, encoded). The loop transmits exactly the
composer's output, so the durable file plus the composer reproduce what
the model received — pinned by a property test asserting composed ==
sent for every call, and a 2KB size guard on request events. A bridge
test demonstrates the woven wire form (no raw userMessageContext).
Breaking for existing dev turn files (same schemaVersion, pre-release):
wipe ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
|
|
|
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
export const ModelRequest = z.object({
|
|
|
|
|
contextRef: TurnContextRef.optional(),
|
feat(x): reference-based model requests + wire-form composer
model_call_requested carried three duplications that dominated turn-file
size (measured on a real 647KB two-step turn): the system prompt (28KB)
and the 38-tool snapshot (40KB) repeated per call despite being byte-
identical to turn_created.agent.resolved, and messages re-inlining the
whole current-turn transcript (230KB re-stating a 207KB tool result).
ModelRequest is now a list of references into the turn's own events —
call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that
batch's toolResults in source order]; re-issue after an interruption:
[]. Every referenced byte exists exactly once in the file; the measured
turn drops to ~285KB and each further model call costs ~200 bytes. The
reducer's ordering invariant got stricter: reference lists are matched
exactly against the transcript.
Debuggability is now byte-for-byte at the wire level: ResolvedModel
gains encodeMessages (the structural->wire conversion — user-message
context weaving, attachment rendering, tool-result enveloping, i.e.
convertFromMessages), and composeModelRequest rebuilds the exact
provider payload (resolved system prompt + wrapped tools + materialized
prefix + resolved refs, encoded). The loop transmits exactly the
composer's output, so the durable file plus the composer reproduce what
the model received — pinned by a property test asserting composed ==
sent for every call, and a 2KB size guard on request events. A bridge
test demonstrates the woven wire form (no raw userMessageContext).
Breaking for existing dev turn files (same schemaVersion, pre-release):
wipe ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
|
|
|
messages: z.array(ModelRequestMessageRef),
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
parameters: z.record(z.string(), z.json()),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const ModelCallRequested = z.object({
|
|
|
|
|
type: z.literal("model_call_requested"),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
modelCallIndex: z.number().int().nonnegative(),
|
|
|
|
|
request: ModelRequest,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Normalized provider step events kept for debugging. Raw text/reasoning
|
|
|
|
|
// deltas are stream-only and never appear here.
|
|
|
|
|
export const DurableLlmStepStreamEvent = z.discriminatedUnion("type", [
|
|
|
|
|
z.object({ type: z.literal("text_start") }),
|
|
|
|
|
z.object({ type: z.literal("text_end"), text: z.string() }),
|
|
|
|
|
z.object({ type: z.literal("reasoning_start") }),
|
|
|
|
|
z.object({ type: z.literal("reasoning_end"), text: z.string() }),
|
|
|
|
|
z.object({ type: z.literal("tool_call"), toolCall: ToolCallPart }),
|
|
|
|
|
z.object({
|
|
|
|
|
type: z.literal("finish_step"),
|
|
|
|
|
finishReason: z.string(),
|
|
|
|
|
usage: TurnUsage.optional(),
|
|
|
|
|
providerMetadata: z.json().optional(),
|
|
|
|
|
}),
|
|
|
|
|
z.object({ type: z.literal("provider_error"), error: z.string() }),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
export const ModelStepEvent = z.object({
|
|
|
|
|
type: z.literal("model_step_event"),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
modelCallIndex: z.number().int().nonnegative(),
|
|
|
|
|
event: DurableLlmStepStreamEvent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const ModelCallCompleted = z.object({
|
|
|
|
|
type: z.literal("model_call_completed"),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
modelCallIndex: z.number().int().nonnegative(),
|
|
|
|
|
message: AssistantMessage,
|
|
|
|
|
finishReason: z.string(),
|
|
|
|
|
usage: TurnUsage,
|
|
|
|
|
providerMetadata: z.json().optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const ModelCallFailed = z.object({
|
|
|
|
|
type: z.literal("model_call_failed"),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
modelCallIndex: z.number().int().nonnegative(),
|
|
|
|
|
error: z.string(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const ToolPermissionRequired = z.object({
|
|
|
|
|
type: z.literal("tool_permission_required"),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
toolCallId: z.string(),
|
|
|
|
|
toolName: z.string(),
|
|
|
|
|
request: z.json(),
|
|
|
|
|
checkerError: z.string().optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const ToolPermissionClassified = z.object({
|
|
|
|
|
type: z.literal("tool_permission_classified"),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
toolCallId: z.string(),
|
|
|
|
|
decision: z.enum(["allow", "deny", "defer"]),
|
|
|
|
|
reason: z.string(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const ToolPermissionClassificationFailed = z.object({
|
|
|
|
|
type: z.literal("tool_permission_classification_failed"),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
toolCallIds: z.array(z.string()),
|
|
|
|
|
error: z.string(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The only effective execution decision; classifier records are provenance.
|
|
|
|
|
export const ToolPermissionResolved = z.object({
|
|
|
|
|
type: z.literal("tool_permission_resolved"),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
toolCallId: z.string(),
|
|
|
|
|
decision: z.enum(["allow", "deny"]),
|
|
|
|
|
source: z.enum(["classifier", "human", "human_unavailable"]),
|
|
|
|
|
reason: z.string().optional(),
|
|
|
|
|
metadata: z.json().optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const ToolInvocationRequested = z.object({
|
|
|
|
|
type: z.literal("tool_invocation_requested"),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
toolCallId: z.string(),
|
|
|
|
|
toolId: z.string(),
|
|
|
|
|
toolName: z.string(),
|
|
|
|
|
execution: z.enum(["sync", "async"]),
|
|
|
|
|
input: z.json(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const ToolProgress = z.object({
|
|
|
|
|
type: z.literal("tool_progress"),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
toolCallId: z.string(),
|
|
|
|
|
source: z.enum(["sync", "async"]),
|
|
|
|
|
progress: z.json(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const ToolResultData = z.object({
|
|
|
|
|
output: z.json(),
|
|
|
|
|
isError: z.boolean(),
|
|
|
|
|
metadata: z.json().optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// `runtime` results cover permission denial, unknown tools, unusable calls,
|
|
|
|
|
// human unavailability, cancellation, and interrupted sync execution. A
|
|
|
|
|
// result may exist without an invocation when execution was rejected before
|
|
|
|
|
// dispatch.
|
|
|
|
|
export const ToolResult = z.object({
|
|
|
|
|
type: z.literal("tool_result"),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
toolCallId: z.string(),
|
|
|
|
|
toolName: z.string(),
|
|
|
|
|
source: z.enum(["sync", "async", "runtime"]),
|
|
|
|
|
result: ToolResultData,
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-09 11:00:13 +05:30
|
|
|
// A successful sync tool extended the turn's toolset mid-turn (e.g. loadSkill
|
|
|
|
|
// attaching a skill's declared tools). The descriptors are snapshotted in the
|
|
|
|
|
// event so replay needs no external lookups; they apply to every model call
|
|
|
|
|
// requested after this point. This is the one sanctioned exception to the
|
|
|
|
|
// tool-set immutability rule — explicit and durable, never silent.
|
|
|
|
|
export const ToolsExtended = z.object({
|
|
|
|
|
type: z.literal("tools_extended"),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
// The tool result that carried these additions (metadata.addTools).
|
|
|
|
|
toolCallId: z.string(),
|
|
|
|
|
// Human-readable origin, e.g. the skill id that declared the tools.
|
|
|
|
|
source: z.string(),
|
|
|
|
|
tools: z.array(ToolDescriptor).min(1),
|
|
|
|
|
});
|
|
|
|
|
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
export const TurnSuspended = z.object({
|
|
|
|
|
type: z.literal("turn_suspended"),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
pendingPermissions: z.array(
|
|
|
|
|
z.object({
|
|
|
|
|
toolCallId: z.string(),
|
|
|
|
|
toolName: z.string(),
|
|
|
|
|
request: z.json(),
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
pendingAsyncTools: z.array(
|
|
|
|
|
z.object({
|
|
|
|
|
toolCallId: z.string(),
|
|
|
|
|
toolId: z.string(),
|
|
|
|
|
toolName: z.string(),
|
|
|
|
|
input: z.json(),
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
usage: TurnUsage,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const TurnCompleted = z.object({
|
|
|
|
|
type: z.literal("turn_completed"),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
output: AssistantMessage,
|
|
|
|
|
finishReason: z.string(),
|
|
|
|
|
usage: TurnUsage,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const TurnFailed = z.object({
|
|
|
|
|
type: z.literal("turn_failed"),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
error: z.string(),
|
|
|
|
|
// Machine-readable discriminator for failures callers must tell apart;
|
|
|
|
|
// MODEL_CALL_LIMIT_ERROR_CODE is defined by the spec.
|
|
|
|
|
code: z.string().optional(),
|
|
|
|
|
usage: TurnUsage,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const TurnCancelled = z.object({
|
|
|
|
|
type: z.literal("turn_cancelled"),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
reason: z.string().optional(),
|
|
|
|
|
usage: TurnUsage,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const TurnEvent = z.discriminatedUnion("type", [
|
|
|
|
|
TurnCreated,
|
|
|
|
|
ModelCallRequested,
|
|
|
|
|
ModelStepEvent,
|
|
|
|
|
ModelCallCompleted,
|
|
|
|
|
ModelCallFailed,
|
|
|
|
|
ToolPermissionRequired,
|
|
|
|
|
ToolPermissionClassified,
|
|
|
|
|
ToolPermissionClassificationFailed,
|
|
|
|
|
ToolPermissionResolved,
|
|
|
|
|
ToolInvocationRequested,
|
|
|
|
|
ToolProgress,
|
|
|
|
|
ToolResult,
|
2026-07-09 11:00:13 +05:30
|
|
|
ToolsExtended,
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
TurnSuspended,
|
|
|
|
|
TurnCompleted,
|
|
|
|
|
TurnFailed,
|
|
|
|
|
TurnCancelled,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Ephemeral stream-only deltas (never persisted)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export type TextDelta = {
|
|
|
|
|
type: "text_delta";
|
|
|
|
|
turnId: string;
|
|
|
|
|
modelCallIndex: number;
|
|
|
|
|
delta: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type ReasoningDelta = {
|
|
|
|
|
type: "reasoning_delta";
|
|
|
|
|
turnId: string;
|
|
|
|
|
modelCallIndex: number;
|
|
|
|
|
delta: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type TurnStreamEvent =
|
|
|
|
|
| z.infer<typeof TurnEvent>
|
|
|
|
|
| TextDelta
|
|
|
|
|
| ReasoningDelta;
|
|
|
|
|
|
2026-07-09 11:42:22 +05:30
|
|
|
// One entry on the process-wide turn event bus: every event of every turn the
|
|
|
|
|
// runtime executes, tagged with its origin so consumers can subscribe without
|
|
|
|
|
// knowing who started the turn. `offset` is the 1-based line index of a
|
|
|
|
|
// durable event in the turn's JSONL file — a late subscriber can fetch the
|
|
|
|
|
// turn snapshot and discard bus events with offset <= snapshot length to join
|
|
|
|
|
// a live turn without gaps or duplicates. Deltas are not durable and carry no
|
|
|
|
|
// offset.
|
|
|
|
|
export interface TurnBusEvent {
|
|
|
|
|
turnId: string;
|
|
|
|
|
sessionId: string | null;
|
|
|
|
|
event: TurnStreamEvent;
|
|
|
|
|
offset?: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function isDurableTurnEvent(
|
|
|
|
|
event: TurnStreamEvent,
|
|
|
|
|
): event is z.infer<typeof TurnEvent> {
|
|
|
|
|
return event.type !== "text_delta" && event.type !== "reasoning_delta";
|
|
|
|
|
}
|
|
|
|
|
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Derived turn state
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export class TurnCorruptionError extends Error {
|
|
|
|
|
constructor(message: string) {
|
|
|
|
|
super(message);
|
|
|
|
|
this.name = "TurnCorruptionError";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ModelCallState {
|
|
|
|
|
index: number;
|
|
|
|
|
request: z.infer<typeof ModelRequest>;
|
|
|
|
|
stepEvents: Array<z.infer<typeof DurableLlmStepStreamEvent>>;
|
|
|
|
|
response?: z.infer<typeof AssistantMessage>;
|
|
|
|
|
finishReason?: string;
|
|
|
|
|
usage?: z.infer<typeof TurnUsage>;
|
|
|
|
|
providerMetadata?: JsonValue;
|
|
|
|
|
error?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ToolCallState {
|
|
|
|
|
modelCallIndex: number;
|
|
|
|
|
order: number;
|
|
|
|
|
toolCallId: string;
|
|
|
|
|
toolName: string;
|
|
|
|
|
input: unknown;
|
|
|
|
|
toolId?: string;
|
|
|
|
|
execution?: "sync" | "async";
|
|
|
|
|
permission?: {
|
|
|
|
|
required: z.infer<typeof ToolPermissionRequired>;
|
|
|
|
|
classification?: z.infer<typeof ToolPermissionClassified>;
|
feat(x/core): turn runtime with event-sourced execution loop (stage 2)
The TurnRuntime per turn-runtime-design.md: constructor-injected,
container-ignorant, no per-turn state — every advanceTurn reconstructs
from the JSONL log, so normal execution and crash recovery share one
path.
- runtime.ts: createTurn/advanceTurn/getTurn; durable-barrier appends
before side effects; sequential sync tools with progress; async tool
exposure + durable suspension; permission pipeline (checker →
optional classifier batch → human/deny fallback, fail-closed on
checker errors); cancellation with synthetic results; recovery that
re-issues interrupted model calls and continues after interrupted
sync tools; model-call-limit exhaustion with machine-readable code.
- fs-repo.ts: date-partitioned append-only JSONL, strict line
validation, path-traversal rejection, per-turn in-process locking.
- stream.ts: hot execution stream — buffers before the consumer
attaches, outcome independent of consumption, close-drops-events.
- context-resolver.ts: chain-walking materialization of context
references with cycle detection.
- Seam interfaces for stage-4 bridges: model/tool registries,
permission checker/classifier, agent resolver, lifecycle bus, clock.
- shared reducer: track classification failures durably so failed
classifications are never re-run.
67 new tests: all nine §26 end-to-end scenarios (mocked deps,
recovery boundaries seeded directly) plus repo/stream/resolver suites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:32:27 +05:30
|
|
|
// Set when a tool_permission_classification_failed event named this
|
|
|
|
|
// call; such calls are treated as defer and never re-classified.
|
|
|
|
|
classificationFailed?: boolean;
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
resolved?: z.infer<typeof ToolPermissionResolved>;
|
|
|
|
|
};
|
|
|
|
|
invocation?: z.infer<typeof ToolInvocationRequested>;
|
|
|
|
|
progress: Array<z.infer<typeof ToolProgress>>;
|
|
|
|
|
result?: z.infer<typeof ToolResult>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 11:00:13 +05:30
|
|
|
export interface ToolExtensionState {
|
|
|
|
|
event: z.infer<typeof ToolsExtended>;
|
|
|
|
|
// Extensions land between model calls; every call requested from this
|
|
|
|
|
// index onward sees the added tools.
|
|
|
|
|
firstAffectedModelCallIndex: number;
|
|
|
|
|
}
|
|
|
|
|
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
export interface TurnState {
|
|
|
|
|
definition: z.infer<typeof TurnCreated>;
|
|
|
|
|
modelCalls: ModelCallState[];
|
|
|
|
|
toolCalls: ToolCallState[];
|
2026-07-09 11:00:13 +05:30
|
|
|
toolExtensions: ToolExtensionState[];
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
suspension?: z.infer<typeof TurnSuspended>;
|
|
|
|
|
terminal?:
|
|
|
|
|
| z.infer<typeof TurnCompleted>
|
|
|
|
|
| z.infer<typeof TurnFailed>
|
|
|
|
|
| z.infer<typeof TurnCancelled>;
|
|
|
|
|
usage: z.infer<typeof TurnUsage>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fail(message: string): never {
|
|
|
|
|
throw new TurnCorruptionError(message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isContextRef(
|
|
|
|
|
context: z.infer<typeof TurnContext>,
|
|
|
|
|
): context is z.infer<typeof TurnContextRef> {
|
|
|
|
|
return !Array.isArray(context);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findToolCall(state: TurnState, toolCallId: string): ToolCallState {
|
|
|
|
|
const toolCall = state.toolCalls.find(
|
|
|
|
|
(tc) => tc.toolCallId === toolCallId,
|
|
|
|
|
);
|
|
|
|
|
if (!toolCall) {
|
|
|
|
|
fail(`event targets unknown tool call: ${toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
return toolCall;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openModelCall(state: TurnState): ModelCallState | undefined {
|
|
|
|
|
const last = state.modelCalls[state.modelCalls.length - 1];
|
|
|
|
|
if (last && last.response === undefined && last.error === undefined) {
|
|
|
|
|
return last;
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function batchToolCalls(state: TurnState, modelCallIndex: number): ToolCallState[] {
|
|
|
|
|
return state.toolCalls
|
|
|
|
|
.filter((tc) => tc.modelCallIndex === modelCallIndex)
|
|
|
|
|
.sort((a, b) => a.order - b.order);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addUsage(
|
|
|
|
|
total: z.infer<typeof TurnUsage>,
|
|
|
|
|
usage: z.infer<typeof TurnUsage>,
|
|
|
|
|
): void {
|
|
|
|
|
const keys = [
|
|
|
|
|
"inputTokens",
|
|
|
|
|
"outputTokens",
|
|
|
|
|
"totalTokens",
|
|
|
|
|
"reasoningTokens",
|
|
|
|
|
"cachedInputTokens",
|
|
|
|
|
] as const;
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
const value = usage[key];
|
|
|
|
|
if (value !== undefined) {
|
|
|
|
|
total[key] = (total[key] ?? 0) + value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyModelCallRequested(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
event: z.infer<typeof ModelCallRequested>,
|
|
|
|
|
): void {
|
|
|
|
|
const expectedIndex = state.modelCalls.length;
|
|
|
|
|
if (event.modelCallIndex !== expectedIndex) {
|
|
|
|
|
fail(
|
|
|
|
|
`model call index ${event.modelCallIndex} out of order; expected ${expectedIndex}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (expectedIndex >= state.definition.config.maxModelCalls) {
|
|
|
|
|
fail(
|
|
|
|
|
`model call index ${event.modelCallIndex} exceeds maxModelCalls ${state.definition.config.maxModelCalls}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (openModelCall(state)) {
|
|
|
|
|
fail("concurrent unresolved model call requests");
|
|
|
|
|
}
|
|
|
|
|
const unresolved = state.toolCalls.filter((tc) => !tc.result);
|
|
|
|
|
if (unresolved.length > 0) {
|
|
|
|
|
fail(
|
|
|
|
|
`model call requested while tool calls are unresolved: ${unresolved
|
|
|
|
|
.map((tc) => tc.toolCallId)
|
|
|
|
|
.join(", ")}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const context = state.definition.context;
|
2026-07-02 13:00:33 +05:30
|
|
|
let expectedRefs: string[];
|
feat(x): reference-based model requests + wire-form composer
model_call_requested carried three duplications that dominated turn-file
size (measured on a real 647KB two-step turn): the system prompt (28KB)
and the 38-tool snapshot (40KB) repeated per call despite being byte-
identical to turn_created.agent.resolved, and messages re-inlining the
whole current-turn transcript (230KB re-stating a 207KB tool result).
ModelRequest is now a list of references into the turn's own events —
call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that
batch's toolResults in source order]; re-issue after an interruption:
[]. Every referenced byte exists exactly once in the file; the measured
turn drops to ~285KB and each further model call costs ~200 bytes. The
reducer's ordering invariant got stricter: reference lists are matched
exactly against the transcript.
Debuggability is now byte-for-byte at the wire level: ResolvedModel
gains encodeMessages (the structural->wire conversion — user-message
context weaving, attachment rendering, tool-result enveloping, i.e.
convertFromMessages), and composeModelRequest rebuilds the exact
provider payload (resolved system prompt + wrapped tools + materialized
prefix + resolved refs, encoded). The loop transmits exactly the
composer's output, so the durable file plus the composer reproduce what
the model received — pinned by a property test asserting composed ==
sent for every call, and a 2KB size guard on request events. A bridge
test demonstrates the woven wire form (no raw userMessageContext).
Breaking for existing dev turn files (same schemaVersion, pre-release):
wipe ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
|
|
|
if (event.modelCallIndex === 0) {
|
|
|
|
|
if (isContextRef(context)) {
|
|
|
|
|
if (event.request.contextRef?.previousTurnId !== context.previousTurnId) {
|
|
|
|
|
fail("model request contextRef inconsistent with turn context");
|
|
|
|
|
}
|
2026-07-02 13:00:33 +05:30
|
|
|
expectedRefs = ["input"];
|
feat(x): reference-based model requests + wire-form composer
model_call_requested carried three duplications that dominated turn-file
size (measured on a real 647KB two-step turn): the system prompt (28KB)
and the 38-tool snapshot (40KB) repeated per call despite being byte-
identical to turn_created.agent.resolved, and messages re-inlining the
whole current-turn transcript (230KB re-stating a 207KB tool result).
ModelRequest is now a list of references into the turn's own events —
call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that
batch's toolResults in source order]; re-issue after an interruption:
[]. Every referenced byte exists exactly once in the file; the measured
turn drops to ~285KB and each further model call costs ~200 bytes. The
reducer's ordering invariant got stricter: reference lists are matched
exactly against the transcript.
Debuggability is now byte-for-byte at the wire level: ResolvedModel
gains encodeMessages (the structural->wire conversion — user-message
context weaving, attachment rendering, tool-result enveloping, i.e.
convertFromMessages), and composeModelRequest rebuilds the exact
provider payload (resolved system prompt + wrapped tools + materialized
prefix + resolved refs, encoded). The loop transmits exactly the
composer's output, so the durable file plus the composer reproduce what
the model received — pinned by a property test asserting composed ==
sent for every call, and a 2KB size guard on request events. A bridge
test demonstrates the woven wire form (no raw userMessageContext).
Breaking for existing dev turn files (same schemaVersion, pre-release):
wipe ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
|
|
|
} else {
|
|
|
|
|
if (event.request.contextRef !== undefined) {
|
|
|
|
|
fail("model request has contextRef but turn context is inline");
|
|
|
|
|
}
|
2026-07-02 13:00:33 +05:30
|
|
|
expectedRefs = context.length > 0 ? ["context", "input"] : ["input"];
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
}
|
feat(x): reference-based model requests + wire-form composer
model_call_requested carried three duplications that dominated turn-file
size (measured on a real 647KB two-step turn): the system prompt (28KB)
and the 38-tool snapshot (40KB) repeated per call despite being byte-
identical to turn_created.agent.resolved, and messages re-inlining the
whole current-turn transcript (230KB re-stating a 207KB tool result).
ModelRequest is now a list of references into the turn's own events —
call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that
batch's toolResults in source order]; re-issue after an interruption:
[]. Every referenced byte exists exactly once in the file; the measured
turn drops to ~285KB and each further model call costs ~200 bytes. The
reducer's ordering invariant got stricter: reference lists are matched
exactly against the transcript.
Debuggability is now byte-for-byte at the wire level: ResolvedModel
gains encodeMessages (the structural->wire conversion — user-message
context weaving, attachment rendering, tool-result enveloping, i.e.
convertFromMessages), and composeModelRequest rebuilds the exact
provider payload (resolved system prompt + wrapped tools + materialized
prefix + resolved refs, encoded). The loop transmits exactly the
composer's output, so the durable file plus the composer reproduce what
the model received — pinned by a property test asserting composed ==
sent for every call, and a 2KB size guard on request events. A bridge
test demonstrates the woven wire form (no raw userMessageContext).
Breaking for existing dev turn files (same schemaVersion, pre-release):
wipe ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
|
|
|
} else {
|
|
|
|
|
if (event.request.contextRef !== undefined) {
|
|
|
|
|
fail("model request has contextRef on a non-initial model call");
|
|
|
|
|
}
|
|
|
|
|
const previous = state.modelCalls[event.modelCallIndex - 1];
|
|
|
|
|
expectedRefs =
|
|
|
|
|
previous.response !== undefined
|
|
|
|
|
? [
|
2026-07-02 13:00:33 +05:30
|
|
|
assistantRef(previous.index),
|
|
|
|
|
...batchToolCalls(state, previous.index).map((tc) =>
|
|
|
|
|
toolResultRef(tc.toolCallId),
|
|
|
|
|
),
|
feat(x): reference-based model requests + wire-form composer
model_call_requested carried three duplications that dominated turn-file
size (measured on a real 647KB two-step turn): the system prompt (28KB)
and the 38-tool snapshot (40KB) repeated per call despite being byte-
identical to turn_created.agent.resolved, and messages re-inlining the
whole current-turn transcript (230KB re-stating a 207KB tool result).
ModelRequest is now a list of references into the turn's own events —
call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that
batch's toolResults in source order]; re-issue after an interruption:
[]. Every referenced byte exists exactly once in the file; the measured
turn drops to ~285KB and each further model call costs ~200 bytes. The
reducer's ordering invariant got stricter: reference lists are matched
exactly against the transcript.
Debuggability is now byte-for-byte at the wire level: ResolvedModel
gains encodeMessages (the structural->wire conversion — user-message
context weaving, attachment rendering, tool-result enveloping, i.e.
convertFromMessages), and composeModelRequest rebuilds the exact
provider payload (resolved system prompt + wrapped tools + materialized
prefix + resolved refs, encoded). The loop transmits exactly the
composer's output, so the durable file plus the composer reproduce what
the model received — pinned by a property test asserting composed ==
sent for every call, and a 2KB size guard on request events. A bridge
test demonstrates the woven wire form (no raw userMessageContext).
Breaking for existing dev turn files (same schemaVersion, pre-release):
wipe ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
|
|
|
]
|
|
|
|
|
: []; // re-issue after an interrupted call adds nothing new
|
|
|
|
|
}
|
|
|
|
|
if (JSON.stringify(event.request.messages) !== JSON.stringify(expectedRefs)) {
|
|
|
|
|
fail(
|
|
|
|
|
`model request references do not match the transcript: expected ${JSON.stringify(
|
|
|
|
|
expectedRefs,
|
|
|
|
|
)}, got ${JSON.stringify(event.request.messages)}`,
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.modelCalls.push({
|
|
|
|
|
index: event.modelCallIndex,
|
|
|
|
|
request: event.request,
|
|
|
|
|
stepEvents: [],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyModelStepEvent(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
event: z.infer<typeof ModelStepEvent>,
|
|
|
|
|
): void {
|
|
|
|
|
const call = state.modelCalls[event.modelCallIndex];
|
|
|
|
|
if (!call) {
|
|
|
|
|
fail(`step event without matching model call request: ${event.modelCallIndex}`);
|
|
|
|
|
}
|
|
|
|
|
if (call.response !== undefined || call.error !== undefined) {
|
|
|
|
|
fail(`step event after model call ${event.modelCallIndex} settled`);
|
|
|
|
|
}
|
|
|
|
|
call.stepEvents.push(event.event);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyModelCallCompleted(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
event: z.infer<typeof ModelCallCompleted>,
|
|
|
|
|
): void {
|
|
|
|
|
const call = state.modelCalls[event.modelCallIndex];
|
|
|
|
|
if (!call) {
|
|
|
|
|
fail(`model call completion without matching request: ${event.modelCallIndex}`);
|
|
|
|
|
}
|
|
|
|
|
if (call.response !== undefined || call.error !== undefined) {
|
|
|
|
|
fail(`duplicate settlement for model call ${event.modelCallIndex}`);
|
|
|
|
|
}
|
|
|
|
|
call.response = event.message;
|
|
|
|
|
call.finishReason = event.finishReason;
|
|
|
|
|
call.usage = event.usage;
|
|
|
|
|
call.providerMetadata = event.providerMetadata;
|
|
|
|
|
addUsage(state.usage, event.usage);
|
|
|
|
|
|
|
|
|
|
const parts = Array.isArray(event.message.content)
|
|
|
|
|
? event.message.content
|
|
|
|
|
: [];
|
|
|
|
|
let order = 0;
|
|
|
|
|
for (const part of parts) {
|
|
|
|
|
if (part.type !== "tool-call") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (state.toolCalls.some((tc) => tc.toolCallId === part.toolCallId)) {
|
|
|
|
|
fail(`duplicate tool call id: ${part.toolCallId}`);
|
|
|
|
|
}
|
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>
2026-07-02 13:17:30 +05:30
|
|
|
const resolved = state.definition.agent.resolved;
|
2026-07-09 11:00:13 +05:30
|
|
|
// Mid-turn extensions are searched first (their descriptors are
|
|
|
|
|
// always in the log); inherited base snapshots resolve outside the
|
|
|
|
|
// reducer, so identity fields for those tools arrive via
|
|
|
|
|
// tool_invocation_requested events instead.
|
|
|
|
|
const descriptor =
|
|
|
|
|
extendedToolsFor(state, event.modelCallIndex).find(
|
|
|
|
|
(tool) => tool.name === part.toolName,
|
|
|
|
|
) ??
|
|
|
|
|
(isInheritedSnapshot(resolved)
|
|
|
|
|
? undefined
|
|
|
|
|
: resolved.tools.find((tool) => tool.name === part.toolName));
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
state.toolCalls.push({
|
|
|
|
|
modelCallIndex: event.modelCallIndex,
|
|
|
|
|
order: order++,
|
|
|
|
|
toolCallId: part.toolCallId,
|
|
|
|
|
toolName: part.toolName,
|
|
|
|
|
input: part.arguments,
|
|
|
|
|
toolId: descriptor?.toolId,
|
|
|
|
|
execution: descriptor?.execution,
|
|
|
|
|
progress: [],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyModelCallFailed(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
event: z.infer<typeof ModelCallFailed>,
|
|
|
|
|
): void {
|
|
|
|
|
const call = state.modelCalls[event.modelCallIndex];
|
|
|
|
|
if (!call) {
|
|
|
|
|
fail(`model call failure without matching request: ${event.modelCallIndex}`);
|
|
|
|
|
}
|
|
|
|
|
if (call.response !== undefined || call.error !== undefined) {
|
|
|
|
|
fail(`duplicate settlement for model call ${event.modelCallIndex}`);
|
|
|
|
|
}
|
|
|
|
|
call.error = event.error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyToolPermissionRequired(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
event: z.infer<typeof ToolPermissionRequired>,
|
|
|
|
|
): void {
|
|
|
|
|
const toolCall = findToolCall(state, event.toolCallId);
|
|
|
|
|
if (toolCall.result) {
|
|
|
|
|
fail(`permission requirement after tool result: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (toolCall.invocation) {
|
|
|
|
|
fail(`permission requirement after invocation: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (toolCall.permission) {
|
|
|
|
|
fail(`duplicate permission requirement: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
toolCall.permission = { required: event };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyToolPermissionClassified(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
event: z.infer<typeof ToolPermissionClassified>,
|
|
|
|
|
): void {
|
|
|
|
|
const toolCall = findToolCall(state, event.toolCallId);
|
|
|
|
|
if (!toolCall.permission) {
|
|
|
|
|
fail(`classification without permission requirement: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (toolCall.permission.resolved) {
|
|
|
|
|
fail(`classification after permission resolution: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (toolCall.permission.classification) {
|
|
|
|
|
fail(`duplicate permission classification: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
toolCall.permission.classification = event;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyToolPermissionClassificationFailed(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
event: z.infer<typeof ToolPermissionClassificationFailed>,
|
|
|
|
|
): void {
|
|
|
|
|
for (const toolCallId of event.toolCallIds) {
|
|
|
|
|
const toolCall = findToolCall(state, toolCallId);
|
|
|
|
|
if (!toolCall.permission) {
|
|
|
|
|
fail(`classification failure without permission requirement: ${toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (toolCall.permission.resolved) {
|
|
|
|
|
fail(`classification failure after permission resolution: ${toolCallId}`);
|
|
|
|
|
}
|
feat(x/core): turn runtime with event-sourced execution loop (stage 2)
The TurnRuntime per turn-runtime-design.md: constructor-injected,
container-ignorant, no per-turn state — every advanceTurn reconstructs
from the JSONL log, so normal execution and crash recovery share one
path.
- runtime.ts: createTurn/advanceTurn/getTurn; durable-barrier appends
before side effects; sequential sync tools with progress; async tool
exposure + durable suspension; permission pipeline (checker →
optional classifier batch → human/deny fallback, fail-closed on
checker errors); cancellation with synthetic results; recovery that
re-issues interrupted model calls and continues after interrupted
sync tools; model-call-limit exhaustion with machine-readable code.
- fs-repo.ts: date-partitioned append-only JSONL, strict line
validation, path-traversal rejection, per-turn in-process locking.
- stream.ts: hot execution stream — buffers before the consumer
attaches, outcome independent of consumption, close-drops-events.
- context-resolver.ts: chain-walking materialization of context
references with cycle detection.
- Seam interfaces for stage-4 bridges: model/tool registries,
permission checker/classifier, agent resolver, lifecycle bus, clock.
- shared reducer: track classification failures durably so failed
classifications are never re-run.
67 new tests: all nine §26 end-to-end scenarios (mocked deps,
recovery boundaries seeded directly) plus repo/stream/resolver suites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:32:27 +05:30
|
|
|
toolCall.permission.classificationFailed = true;
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyToolPermissionResolved(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
event: z.infer<typeof ToolPermissionResolved>,
|
|
|
|
|
): void {
|
|
|
|
|
const toolCall = findToolCall(state, event.toolCallId);
|
|
|
|
|
if (!toolCall.permission) {
|
|
|
|
|
fail(`permission resolution without requirement: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (toolCall.permission.resolved) {
|
|
|
|
|
fail(`conflicting permission decisions: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (toolCall.result) {
|
|
|
|
|
fail(`permission resolution after tool result: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
toolCall.permission.resolved = event;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyToolInvocationRequested(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
event: z.infer<typeof ToolInvocationRequested>,
|
|
|
|
|
): void {
|
|
|
|
|
const toolCall = findToolCall(state, event.toolCallId);
|
|
|
|
|
if (toolCall.invocation) {
|
|
|
|
|
fail(`duplicate tool invocation: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (toolCall.result) {
|
|
|
|
|
fail(`tool invocation after result: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (toolCall.toolName !== event.toolName) {
|
|
|
|
|
fail(`tool invocation name mismatch: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (toolCall.permission && toolCall.permission.resolved?.decision !== "allow") {
|
|
|
|
|
fail(`tool invocation without permission allowance: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (toolCall.execution !== undefined && toolCall.execution !== event.execution) {
|
|
|
|
|
fail(`tool invocation execution mismatch: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (toolCall.toolId !== undefined && toolCall.toolId !== event.toolId) {
|
|
|
|
|
fail(`tool invocation toolId mismatch: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
toolCall.execution = event.execution;
|
|
|
|
|
toolCall.toolId = event.toolId;
|
|
|
|
|
toolCall.invocation = event;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyToolProgress(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
event: z.infer<typeof ToolProgress>,
|
|
|
|
|
): void {
|
|
|
|
|
const toolCall = findToolCall(state, event.toolCallId);
|
|
|
|
|
if (!toolCall.invocation) {
|
|
|
|
|
fail(`tool progress without invocation: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (toolCall.result) {
|
|
|
|
|
fail(`tool progress after terminal result: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (event.source !== toolCall.execution) {
|
|
|
|
|
fail(`tool progress source mismatch: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
toolCall.progress.push(event);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyToolResult(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
event: z.infer<typeof ToolResult>,
|
|
|
|
|
): void {
|
|
|
|
|
const toolCall = findToolCall(state, event.toolCallId);
|
|
|
|
|
if (toolCall.result) {
|
|
|
|
|
fail(`duplicate tool result: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (toolCall.toolName !== event.toolName) {
|
|
|
|
|
fail(`tool result name mismatch: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (event.source !== "runtime") {
|
|
|
|
|
if (!toolCall.invocation) {
|
|
|
|
|
fail(`${event.source} tool result without invocation: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (event.source !== toolCall.execution) {
|
|
|
|
|
fail(`tool result source mismatch: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
toolCall.result = event;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 11:00:13 +05:30
|
|
|
function applyToolsExtended(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
event: z.infer<typeof ToolsExtended>,
|
|
|
|
|
): void {
|
|
|
|
|
if (openModelCall(state)) {
|
|
|
|
|
fail("tools extended while a model call is unsettled");
|
|
|
|
|
}
|
|
|
|
|
const toolCall = findToolCall(state, event.toolCallId);
|
|
|
|
|
if (!toolCall.result) {
|
|
|
|
|
fail(`tools extended without a tool result: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
if (toolCall.result.result.isError) {
|
|
|
|
|
fail(`tools extended by a failed tool call: ${event.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
// Collision check covers prior extensions and (when visible) the base
|
|
|
|
|
// snapshot; inherited snapshots are opaque to the reducer, so their base
|
|
|
|
|
// names are enforced by the runtime's dedupe instead.
|
|
|
|
|
const existing = new Set(
|
|
|
|
|
extendedToolsFor(state, state.modelCalls.length).map((t) => t.name),
|
|
|
|
|
);
|
|
|
|
|
const resolved = state.definition.agent.resolved;
|
|
|
|
|
if (!isInheritedSnapshot(resolved)) {
|
|
|
|
|
for (const tool of resolved.tools) {
|
|
|
|
|
existing.add(tool.name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const tool of event.tools) {
|
|
|
|
|
if (existing.has(tool.name)) {
|
|
|
|
|
fail(`tools extended with a colliding tool name: ${tool.name}`);
|
|
|
|
|
}
|
|
|
|
|
existing.add(tool.name);
|
|
|
|
|
}
|
|
|
|
|
state.toolExtensions.push({
|
|
|
|
|
event,
|
|
|
|
|
firstAffectedModelCallIndex: state.modelCalls.length,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
function sortedIds(ids: string[]): string {
|
|
|
|
|
return [...ids].sort().join(",");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyTurnSuspended(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
event: z.infer<typeof TurnSuspended>,
|
|
|
|
|
): void {
|
|
|
|
|
if (openModelCall(state)) {
|
|
|
|
|
fail("suspension while a model call is unsettled");
|
|
|
|
|
}
|
|
|
|
|
const expectedPermissions = outstandingPermissions(state).map(
|
|
|
|
|
(tc) => tc.toolCallId,
|
|
|
|
|
);
|
|
|
|
|
const expectedAsync = outstandingAsyncTools(state).map((tc) => tc.toolCallId);
|
|
|
|
|
if (expectedPermissions.length + expectedAsync.length === 0) {
|
|
|
|
|
fail("suspension without pending external work");
|
|
|
|
|
}
|
|
|
|
|
const claimedPermissions = event.pendingPermissions.map((p) => p.toolCallId);
|
|
|
|
|
const claimedAsync = event.pendingAsyncTools.map((p) => p.toolCallId);
|
|
|
|
|
if (
|
|
|
|
|
sortedIds(claimedPermissions) !== sortedIds(expectedPermissions) ||
|
|
|
|
|
sortedIds(claimedAsync) !== sortedIds(expectedAsync)
|
|
|
|
|
) {
|
|
|
|
|
fail("suspension snapshot inconsistent with pending state");
|
|
|
|
|
}
|
|
|
|
|
state.suspension = event;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertTerminalPreconditions(state: TurnState, kind: string): void {
|
|
|
|
|
if (openModelCall(state)) {
|
|
|
|
|
fail(`${kind} while a model call is unsettled`);
|
|
|
|
|
}
|
|
|
|
|
const unresolved = state.toolCalls.filter((tc) => !tc.result);
|
|
|
|
|
if (unresolved.length > 0) {
|
|
|
|
|
fail(
|
|
|
|
|
`${kind} while tool calls lack terminal results: ${unresolved
|
|
|
|
|
.map((tc) => tc.toolCallId)
|
|
|
|
|
.join(", ")}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyTurnCompleted(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
event: z.infer<typeof TurnCompleted>,
|
|
|
|
|
): void {
|
|
|
|
|
assertTerminalPreconditions(state, "completion");
|
|
|
|
|
const last = state.modelCalls[state.modelCalls.length - 1];
|
|
|
|
|
if (!last || last.response === undefined) {
|
|
|
|
|
fail("completion without a completed model response");
|
|
|
|
|
}
|
|
|
|
|
if (batchToolCalls(state, last.index).length > 0) {
|
|
|
|
|
fail("completion while the final response has tool calls");
|
|
|
|
|
}
|
|
|
|
|
state.terminal = event;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function reduceTurn(
|
|
|
|
|
events: Array<z.infer<typeof TurnEvent>>,
|
|
|
|
|
): TurnState {
|
|
|
|
|
if (events.length === 0) {
|
|
|
|
|
fail("turn log is empty");
|
|
|
|
|
}
|
|
|
|
|
const [first, ...rest] = events;
|
|
|
|
|
if (first.type !== "turn_created") {
|
|
|
|
|
fail(`first event must be turn_created, got ${first.type}`);
|
|
|
|
|
}
|
|
|
|
|
if (first.schemaVersion !== 1) {
|
|
|
|
|
fail(`unsupported turn schema version: ${String(first.schemaVersion)}`);
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-07-02 13:17:30 +05:30
|
|
|
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",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
const state: TurnState = {
|
|
|
|
|
definition: first,
|
|
|
|
|
modelCalls: [],
|
|
|
|
|
toolCalls: [],
|
2026-07-09 11:00:13 +05:30
|
|
|
toolExtensions: [],
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
usage: {},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (const event of rest) {
|
|
|
|
|
if (event.turnId !== first.turnId) {
|
|
|
|
|
fail(
|
|
|
|
|
`event turnId ${event.turnId} does not match turn ${first.turnId}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (state.terminal) {
|
|
|
|
|
fail(`event after terminal turn event: ${event.type}`);
|
|
|
|
|
}
|
|
|
|
|
switch (event.type) {
|
|
|
|
|
case "turn_created":
|
|
|
|
|
fail("duplicate turn_created event");
|
|
|
|
|
break;
|
|
|
|
|
case "model_call_requested":
|
|
|
|
|
applyModelCallRequested(state, event);
|
|
|
|
|
break;
|
|
|
|
|
case "model_step_event":
|
|
|
|
|
applyModelStepEvent(state, event);
|
|
|
|
|
break;
|
|
|
|
|
case "model_call_completed":
|
|
|
|
|
applyModelCallCompleted(state, event);
|
|
|
|
|
break;
|
|
|
|
|
case "model_call_failed":
|
|
|
|
|
applyModelCallFailed(state, event);
|
|
|
|
|
break;
|
|
|
|
|
case "tool_permission_required":
|
|
|
|
|
applyToolPermissionRequired(state, event);
|
|
|
|
|
break;
|
|
|
|
|
case "tool_permission_classified":
|
|
|
|
|
applyToolPermissionClassified(state, event);
|
|
|
|
|
break;
|
|
|
|
|
case "tool_permission_classification_failed":
|
|
|
|
|
applyToolPermissionClassificationFailed(state, event);
|
|
|
|
|
break;
|
|
|
|
|
case "tool_permission_resolved":
|
|
|
|
|
applyToolPermissionResolved(state, event);
|
|
|
|
|
break;
|
|
|
|
|
case "tool_invocation_requested":
|
|
|
|
|
applyToolInvocationRequested(state, event);
|
|
|
|
|
break;
|
|
|
|
|
case "tool_progress":
|
|
|
|
|
applyToolProgress(state, event);
|
|
|
|
|
break;
|
|
|
|
|
case "tool_result":
|
|
|
|
|
applyToolResult(state, event);
|
|
|
|
|
break;
|
2026-07-09 11:00:13 +05:30
|
|
|
case "tools_extended":
|
|
|
|
|
applyToolsExtended(state, event);
|
|
|
|
|
break;
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
case "turn_suspended":
|
|
|
|
|
applyTurnSuspended(state, event);
|
|
|
|
|
break;
|
|
|
|
|
case "turn_completed":
|
|
|
|
|
applyTurnCompleted(state, event);
|
|
|
|
|
break;
|
|
|
|
|
case "turn_failed":
|
|
|
|
|
assertTerminalPreconditions(state, "failure");
|
|
|
|
|
state.terminal = event;
|
|
|
|
|
break;
|
|
|
|
|
case "turn_cancelled":
|
|
|
|
|
assertTerminalPreconditions(state, "cancellation");
|
|
|
|
|
state.terminal = event;
|
|
|
|
|
break;
|
|
|
|
|
default: {
|
|
|
|
|
const unknown: never = event;
|
|
|
|
|
fail(`unknown turn event type: ${(unknown as { type: string }).type}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Pure derivations over TurnState
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-07-09 11:00:13 +05:30
|
|
|
// Descriptors added by durable extensions in effect for a given model call.
|
|
|
|
|
export function extendedToolsFor(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
modelCallIndex: number,
|
|
|
|
|
): Array<z.infer<typeof ToolDescriptor>> {
|
|
|
|
|
return state.toolExtensions
|
|
|
|
|
.filter((ext) => ext.firstAffectedModelCallIndex <= modelCallIndex)
|
|
|
|
|
.flatMap((ext) => ext.event.tools);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The full toolset a given model call sees: the agent snapshot's base tools
|
|
|
|
|
// plus every extension recorded before that call was requested. The base set
|
|
|
|
|
// comes from the materialized agent (inherited snapshots are opaque here),
|
|
|
|
|
// so callers pass it in.
|
|
|
|
|
export function effectiveTools(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
modelCallIndex: number,
|
|
|
|
|
baseTools: Array<z.infer<typeof ToolDescriptor>>,
|
|
|
|
|
): Array<z.infer<typeof ToolDescriptor>> {
|
|
|
|
|
const extended = extendedToolsFor(state, modelCallIndex);
|
|
|
|
|
return extended.length === 0 ? baseTools : [...baseTools, ...extended];
|
|
|
|
|
}
|
|
|
|
|
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
export function outstandingPermissions(state: TurnState): ToolCallState[] {
|
|
|
|
|
return state.toolCalls.filter(
|
|
|
|
|
(tc) => tc.permission && !tc.permission.resolved && !tc.result,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function outstandingAsyncTools(state: TurnState): ToolCallState[] {
|
|
|
|
|
return state.toolCalls.filter(
|
|
|
|
|
(tc) => tc.invocation && tc.execution === "async" && !tc.result,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type TurnStatus =
|
|
|
|
|
| "completed"
|
|
|
|
|
| "failed"
|
|
|
|
|
| "cancelled"
|
|
|
|
|
| "suspended"
|
|
|
|
|
| "idle";
|
|
|
|
|
|
|
|
|
|
// No durable running status exists by design: an "idle" turn is simply
|
|
|
|
|
// non-terminal with no outstanding external work. Whether it is actively
|
|
|
|
|
// being advanced right now is ephemeral bus state.
|
|
|
|
|
export function deriveTurnStatus(state: TurnState): TurnStatus {
|
|
|
|
|
if (state.terminal) {
|
|
|
|
|
switch (state.terminal.type) {
|
|
|
|
|
case "turn_completed":
|
|
|
|
|
return "completed";
|
|
|
|
|
case "turn_failed":
|
|
|
|
|
return "failed";
|
|
|
|
|
case "turn_cancelled":
|
|
|
|
|
return "cancelled";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
outstandingPermissions(state).length > 0 ||
|
|
|
|
|
outstandingAsyncTools(state).length > 0
|
|
|
|
|
) {
|
|
|
|
|
return "suspended";
|
|
|
|
|
}
|
|
|
|
|
return "idle";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toolResultContent(result: z.infer<typeof ToolResult>): string {
|
|
|
|
|
const output = result.result.output;
|
|
|
|
|
return typeof output === "string" ? output : JSON.stringify(output);
|
|
|
|
|
}
|
|
|
|
|
|
feat(x): reference-based model requests + wire-form composer
model_call_requested carried three duplications that dominated turn-file
size (measured on a real 647KB two-step turn): the system prompt (28KB)
and the 38-tool snapshot (40KB) repeated per call despite being byte-
identical to turn_created.agent.resolved, and messages re-inlining the
whole current-turn transcript (230KB re-stating a 207KB tool result).
ModelRequest is now a list of references into the turn's own events —
call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that
batch's toolResults in source order]; re-issue after an interruption:
[]. Every referenced byte exists exactly once in the file; the measured
turn drops to ~285KB and each further model call costs ~200 bytes. The
reducer's ordering invariant got stricter: reference lists are matched
exactly against the transcript.
Debuggability is now byte-for-byte at the wire level: ResolvedModel
gains encodeMessages (the structural->wire conversion — user-message
context weaving, attachment rendering, tool-result enveloping, i.e.
convertFromMessages), and composeModelRequest rebuilds the exact
provider payload (resolved system prompt + wrapped tools + materialized
prefix + resolved refs, encoded). The loop transmits exactly the
composer's output, so the durable file plus the composer reproduce what
the model received — pinned by a property test asserting composed ==
sent for every call, and a 2KB size guard on request events. A bridge
test demonstrates the woven wire form (no raw userMessageContext).
Breaking for existing dev turn files (same schemaVersion, pre-release):
wipe ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
|
|
|
// The canonical model-facing tool message for a resolved tool call.
|
|
|
|
|
export function toolResultMessage(
|
|
|
|
|
toolCall: ToolCallState,
|
|
|
|
|
): z.infer<typeof ConversationMessage> {
|
|
|
|
|
if (!toolCall.result) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`tool call ${toolCall.toolCallId} has no terminal result`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
role: "tool",
|
|
|
|
|
content: toolResultContent(toolCall.result),
|
|
|
|
|
toolCallId: toolCall.toolCallId,
|
|
|
|
|
toolName: toolCall.toolName,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolves one model call's request references to structural messages (the
|
|
|
|
|
// NEW portion that call added relative to the previous one). Concatenating
|
|
|
|
|
// calls 0..N yields the full current-turn conversation for call N; the
|
|
|
|
|
// request composer (core) prepends the resolved cross-turn prefix and
|
|
|
|
|
// encodes to the provider wire form.
|
|
|
|
|
export function requestMessagesFor(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
modelCallIndex: number,
|
|
|
|
|
): Array<z.infer<typeof ConversationMessage>> {
|
|
|
|
|
const call = state.modelCalls[modelCallIndex];
|
|
|
|
|
if (!call) {
|
|
|
|
|
throw new Error(`no model call at index ${modelCallIndex}`);
|
|
|
|
|
}
|
2026-07-02 13:00:33 +05:30
|
|
|
return call.request.messages.flatMap((raw) => {
|
|
|
|
|
const ref = parseRequestRef(raw);
|
feat(x): reference-based model requests + wire-form composer
model_call_requested carried three duplications that dominated turn-file
size (measured on a real 647KB two-step turn): the system prompt (28KB)
and the 38-tool snapshot (40KB) repeated per call despite being byte-
identical to turn_created.agent.resolved, and messages re-inlining the
whole current-turn transcript (230KB re-stating a 207KB tool result).
ModelRequest is now a list of references into the turn's own events —
call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that
batch's toolResults in source order]; re-issue after an interruption:
[]. Every referenced byte exists exactly once in the file; the measured
turn drops to ~285KB and each further model call costs ~200 bytes. The
reducer's ordering invariant got stricter: reference lists are matched
exactly against the transcript.
Debuggability is now byte-for-byte at the wire level: ResolvedModel
gains encodeMessages (the structural->wire conversion — user-message
context weaving, attachment rendering, tool-result enveloping, i.e.
convertFromMessages), and composeModelRequest rebuilds the exact
provider payload (resolved system prompt + wrapped tools + materialized
prefix + resolved refs, encoded). The loop transmits exactly the
composer's output, so the durable file plus the composer reproduce what
the model received — pinned by a property test asserting composed ==
sent for every call, and a 2KB size guard on request events. A bridge
test demonstrates the woven wire form (no raw userMessageContext).
Breaking for existing dev turn files (same schemaVersion, pre-release):
wipe ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
|
|
|
switch (ref.kind) {
|
|
|
|
|
case "context": {
|
|
|
|
|
const context = state.definition.context;
|
|
|
|
|
if (!Array.isArray(context)) {
|
|
|
|
|
throw new Error("context ref on a turn without inline context");
|
|
|
|
|
}
|
|
|
|
|
return context;
|
|
|
|
|
}
|
|
|
|
|
case "input":
|
|
|
|
|
return [state.definition.input];
|
|
|
|
|
case "assistant": {
|
|
|
|
|
const response = state.modelCalls[ref.modelCallIndex]?.response;
|
|
|
|
|
if (response === undefined) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`assistant ref to unsettled model call ${ref.modelCallIndex}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return [response];
|
|
|
|
|
}
|
|
|
|
|
case "toolResult": {
|
|
|
|
|
const toolCall = state.toolCalls.find(
|
|
|
|
|
(tc) => tc.toolCallId === ref.toolCallId,
|
|
|
|
|
);
|
|
|
|
|
if (!toolCall) {
|
|
|
|
|
throw new Error(`toolResult ref to unknown call ${ref.toolCallId}`);
|
|
|
|
|
}
|
|
|
|
|
return [toolResultMessage(toolCall)];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
// The messages this turn contributed to the conversation: its input plus, per
|
|
|
|
|
// completed model call, the assistant response and its tool results in source
|
|
|
|
|
// order. Failed model calls contribute nothing. The turn's context prefix is
|
|
|
|
|
// NOT included; materializing the full conversation is the context resolver's
|
|
|
|
|
// job (core). Requires every tool call to have a terminal result, which holds
|
|
|
|
|
// for all terminal turns.
|
|
|
|
|
export function turnTranscript(
|
|
|
|
|
state: TurnState,
|
|
|
|
|
): Array<z.infer<typeof ConversationMessage>> {
|
|
|
|
|
const messages: Array<z.infer<typeof ConversationMessage>> = [
|
|
|
|
|
state.definition.input,
|
|
|
|
|
];
|
|
|
|
|
for (const call of state.modelCalls) {
|
|
|
|
|
if (call.response === undefined) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
messages.push(call.response);
|
|
|
|
|
for (const toolCall of batchToolCalls(state, call.index)) {
|
|
|
|
|
if (!toolCall.result) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`turnTranscript requires terminal tool results; ${toolCall.toolCallId} is unresolved`,
|
|
|
|
|
);
|
|
|
|
|
}
|
feat(x): reference-based model requests + wire-form composer
model_call_requested carried three duplications that dominated turn-file
size (measured on a real 647KB two-step turn): the system prompt (28KB)
and the 38-tool snapshot (40KB) repeated per call despite being byte-
identical to turn_created.agent.resolved, and messages re-inlining the
whole current-turn transcript (230KB re-stating a 207KB tool result).
ModelRequest is now a list of references into the turn's own events —
call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that
batch's toolResults in source order]; re-issue after an interruption:
[]. Every referenced byte exists exactly once in the file; the measured
turn drops to ~285KB and each further model call costs ~200 bytes. The
reducer's ordering invariant got stricter: reference lists are matched
exactly against the transcript.
Debuggability is now byte-for-byte at the wire level: ResolvedModel
gains encodeMessages (the structural->wire conversion — user-message
context weaving, attachment rendering, tool-result enveloping, i.e.
convertFromMessages), and composeModelRequest rebuilds the exact
provider payload (resolved system prompt + wrapped tools + materialized
prefix + resolved refs, encoded). The loop transmits exactly the
composer's output, so the durable file plus the composer reproduce what
the model received — pinned by a property test asserting composed ==
sent for every call, and a 2KB size guard on request events. A bridge
test demonstrates the woven wire form (no raw userMessageContext).
Breaking for existing dev turn files (same schemaVersion, pre-release):
wipe ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
|
|
|
messages.push(toolResultMessage(toolCall));
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return messages;
|
|
|
|
|
}
|