Skip to content

feat(agent): hand-drive a configured AgentAgentDriver + DriveStep/TurnTools - #2278

Open
gold-silver-copper wants to merge 21 commits into
mainfrom
feat/agent-prepare-turn
Open

feat(agent): hand-drive a configured AgentAgentDriver + DriveStep/TurnTools#2278
gold-silver-copper wants to merge 21 commits into
mainfrom
feat/agent-prepare-turn

Conversation

@gold-silver-copper

@gold-silver-copper gold-silver-copper commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Round 2 note: the first commit shipped Agent::prepare_turn + PreparedTurn. A verified multi-agent review found ten defects, five of which were symptoms of one missing abstraction (the run/turn pairing living in every caller). Since nothing had shipped, round 2 (da8f620a) fixes the architecture instead of the symptoms: prepare_turn/PreparedTurn are gone, replaced by the driver below. The findings and their disposition are listed at the end.

Problem

An Agent is a configuration bundle, but AgentRunner was the only thing that could read it. Hand-driving the sans-IO AgentRun state machine is a documented supported use case (custom provider transport, durable suspend/resume — the run state is Serialize + Deserialize), yet a hand-driver had to re-derive everything: our own agent_run_stepping example registered its tool twice, copy-pasted the preamble, hand-assembled the 5-argument ModelTurn with two transposable BTreeSets, and restated max_turns the agent already knew.

What this adds

Agent::drive(prompt) / Agent::drive_run(run)AgentDriver — a sans-IO shell that owns the run/turn pairing and nothing else:

let mut driver = agent.drive("What is 2 + 5?");
loop {
    match driver.next_step().await? {
        DriveStep::SendRequest { request, turn, .. } => {
            let response = request.send().await?;      // caller owns provider IO
            driver.model_response(&response)?;         // sets assembled internally
        }
        DriveStep::ExecuteTools { calls, tools } => {  // paired by construction
            let mut ctx = ToolContext::new();
            let mut results = Vec::new();
            for call in &calls { results.push(tools.execute_call(call, &mut ctx).await); }
            driver.tool_results(results)?;
        }
        DriveStep::Done(response) => break,
    }
}
  • Seeded from the agentdefault_max_turns, tool_choice, and the output schema flow into the run; nothing is restated (per-run overrides via .history() / .max_turns() or a custom AgentRun through drive_run).
  • Advertise/dispatch consistency by constructionExecuteTools carries the TurnTools snapshot of the turn that advertised the calls; registry mutations between advertise and dispatch cannot skew a turn.
  • Structured output just works — the driver arms the run's output-tool intercept and threads the committed name through every turn exactly as the runner does (bug: output_schema is applied to every agent turn, suppressing tool calls in tool + structured-output agents #1928 pinning), so a Tool-mode agent driven by the documented pattern finalizes with its structured answer, and the mode can't flip or re-pick a name mid-run.
  • Durable resume, honestly — serialize driver.run(), rebuild the agent in a fresh process, drive_run the deserialized run: the driver re-derives a fresh dispatch snapshot (implementations are live objects) and surfaces missing pending tools as an error before dispatch rather than silently feeding not-found to the model (allow_missing_resumed_tools() to opt out). Prior-art note: openai-agents' RunState resumes exactly this way and raises on missing tools by default; langgraph and pydantic-ai degrade silently — we take the loud behavior.
  • Not a second execution path — the driver performs no provider IO, no dispatch, and runs no hooks/memory/retrieval/telemetry (AgentRunner remains the only path with those). It is built strictly on AgentRun's own step methods and the same build_agent_run/build_prepared_completion_request the runner uses — no duplicated turn-decision logic.

TurnTools (cheap Clone, Arc-shared) — the turn's executable/allowed name sets, output_tool_name(), execute() (snapshot-pinned, mirrors ToolServerHandle::execute), and execute_call(&PendingToolCall) handling preresolved results and result assembly.

ToolErrorKind::NotExecutable (rig-core, #[non_exhaustive] enum, additive) — dispatching the synthetic output tool is now machine-readably distinct from a hallucinated tool name. No inspected framework ships this discriminator; all of them route around it upstream.

Internals

  • The clear → dispatch → publish result-metadata sequence is single-sourced (ToolDispatch::publish_to, ToolRegistrySnapshot::execute); the previously-triplicated wrapper had already diverged once (a rejection path skipping clear_dispatch_result — regression-tested now).
  • PreparedCompletionRequest composes TurnTools directly; the internal/public mirror struct and its repack are gone. ToolRegistrySnapshot stays private.
  • Runner and streaming paths behaviorally unchanged — all 518 pre-existing rig-agent tests pass as-is.

Examples

Both agent_run_stepping and agent_with_durable_approval are rewritten with zero pairing logic — no Option<TurnTools> juggling, no ModelTurn assembly, no restated configuration; the tools arm is decision logic (approve/deny/edit) plus execute_call. Both demonstrate genuine fresh-driver resume from serialized state.

Tests

15 new tests: full drive loop with registry mutation between advertise and dispatch; Tool-mode finalization via the intercept; two-turn committed-name pinning across a tool-set change; resume from serialized state alone; loud drift on a missing resumed tool + the opt-out; seeding from agent config (incl. the implicit budget of one); local tool_choice validation with zero provider requests; output tool allowed-but-NotExecutable; stale-context regression on the rejection path.

cargo clippy --all-features --all-targets clean, cargo doc zero warnings, both examples compile.

Review findings → disposition (round 1 → round 2)

# Finding Disposition
1 Documented pattern never armed the output-tool intercept structural: driver arms it every turn
2 committed_output_tool hardcoded None; pin lost structural: driver threads it like the runner
3 Examples bailed on cross-process resume structural: drive_run re-derives dispatch; drift is loud
4 Rejection path skipped clear_dispatch_result fixed + regression test; wrapper single-sourced
5 Rejection was ambiguous NotFound ToolErrorKind::NotExecutable
6 "No telemetry" doc overclaim doc corrected; opt-out documented on SendRequest
7 Third copy of the dispatch wrapper single-sourced via publish_to
8 5-arg ModelTurn incantation structural: assembled inside model_response
9 TurnTools mirrored PreparedCompletionRequest 1:1 composed; repack deleted
10 Dead tools() accessor; false "cheap to clone" PreparedTurn removed entirely; sets Arcd, claim now true
11 Run construction restated agent config (found in round 2 review) structural: drive seeds from the agent

gold-silver-copper added a commit that referenced this pull request Aug 11, 2026
Review of the unreleased prepare_turn API (PR #2278 round 1) verified ten
findings; five of them - the unarmed output-tool intercept, the lost
Tool-mode pin, the examples bailing on cross-process resume, the
transposable ModelTurn incantation, and run construction restating agent
config - were symptoms of one missing abstraction: the pairing between
run-state (AgentRun) and turn-state (TurnTools) lived in every caller.
With the API unreleased, the architecture is fixed instead of the
symptoms.

Agent::drive(prompt) / Agent::drive_run(run) return an AgentDriver that
owns the pairing and nothing else. It seeds the run from the agent's
configuration (default_max_turns, tool_choice, output schema), yields
DriveStep::SendRequest with the fully configured request for the caller
to send (or hand to a custom transport), pairs DriveStep::ExecuteTools
with the exact registry snapshot that advertised the calls, assembles
the model turn internally, and maintains the run's committed
structured-output tool across turns exactly as the runner does. The
driver performs no provider IO and no dispatch of its own: hooks,
memory, retrieval policy, and telemetry remain AgentRunner-only, and it
is built strictly on AgentRun's own step methods and the runner's shared
build_agent_run/build_prepared_completion_request - no duplicated
turn-decision logic.

Resume in a fresh process re-derives a fresh dispatch snapshot from the
rebuilt agent; a pending call whose tool is missing from this process's
registry is surfaced as an error before dispatch (opt out with
allow_missing_resumed_tools). PreparedTurn and Agent::prepare_turn are
removed; the public surface is AgentDriver + DriveStep + TurnTools, with
TurnTools now cheaply cloneable (Arc-shared sets) and gaining
execute_call for pending-call dispatch with preresolved-result handling.

Standalone review fixes in the same pass:
- ToolErrorKind::NotExecutable (rig-core, non_exhaustive enum): the
  output-tool dispatch rejection is machine-readable instead of an
  ambiguous NotFound.
- The clear -> dispatch -> publish sequence is single-sourced
  (ToolDispatch::publish_to + ToolRegistrySnapshot::execute); the
  rejection path can no longer skip clear_dispatch_result (regression
  test included).
- PreparedCompletionRequest composes TurnTools directly; the 1:1 mirror
  struct and its repack are gone.
- The "no telemetry" overclaim is corrected: the prepared request honors
  the agent's record_telemetry_content, documented with the opt-out.

Both examples now contain zero pairing logic - the CallTools arm is
decision logic plus execute_call - and demonstrate true fresh-driver
resume. Runner and streaming paths are behaviorally unchanged; all 518
pre-existing rig-agent tests pass as-is.
@gold-silver-copper gold-silver-copper changed the title feat(agent): expose per-turn request preparation on Agent (prepare_turn + PreparedTurn/TurnTools) feat(agent): hand-drive a configured AgentAgentDriver + DriveStep/TurnTools Aug 11, 2026
gold-silver-copper added a commit that referenced this pull request Aug 11, 2026
Review of the AgentDriver shell (PR #2278 round 2) found four defects,
three of them one structural fact: the driver held turn_tools outside
the serialized AgentRun. Everything downstream followed from that.

A model call was committed - turn consumed, state moved to
AwaitingModel - before the fallible request preparation ran, so a
transiently unreachable tool server left the run wedged: next_step
rejected it as a pending response, model_response rejected it for
having no prepared turn, and no third door existed. There was no
AwaitingModel counterpart to resume_tools, so a run serialized after
SendRequest - the natural suspension point for a caller who owns the
transport - could never be resumed. resume_tools validated a
tool_choice against a request it was not building, masking the
actionable drift message and making allow_missing_resumed_tools
unreachable, contradicting its documented contract. Separately, a
custom run's tool_choice reached only the run's internal decisions and
never the wire, while its sibling output_tool_name was threaded
correctly.

TurnTools splits at the durability line. TurnToolNames (new, public,
Serialize) is data and is recorded on the run at commit;
TurnTools::from_parts pairs it back with a live snapshot, which cannot
be serialized in any design. AgentDriver keeps only that snapshot, as
a cache: it exists so a turn prepared in this process dispatches
through the implementations the provider was shown, and is rebuilt on
demand after a resume. model_response reads the names from the run, so
a resumed run validates its reply against the set the request actually
carried rather than against whatever the registry holds now.

AgentRun::next_step's PreparingRequest arm decomposes into a pure
peek_model_call, an infallible commit_model_call, and advance() ->
NeedsModelCall | CallTools | Done. reprompt_for_output no longer
recurses into next_step - it parks the run and lets advance loop -
which is what closes the path by which the machine could commit a
model call the driver had not prepared a request for. next_step is
advance plus an immediate commit, so the runner, the streaming path,
the conformance suite and the module-doc example are unchanged; the
driver uses the two halves and a preparation failure now leaves the
run byte-identical, ready to retry.

Per-turn configuration gets one seam. The driver was passing None for
the request_patch parameter it already had; AgentDriver::request_patch
/ set_request_patch now supply it, giving a hand-driven run the
per-turn preamble, sampling parameters, tool_choice, active_tools
narrowing, extra context and substituted history the runner gets from
its CompletionCall hooks. The patch's tool_choice is seeded from the
new public AgentRun::tool_choice() when the caller sets none, so a
custom run's choice reaches the provider. build_prepared_completion_
request's 17 positional parameters collapse into TurnBaseline and
TurnRequest, retiring the transposition hazard for the parameters
TurnTools had only fixed two of.

Serialized runs carry $schemaVersion (RUN_SCHEMA_VERSION = "1.0") and a
build reads only the version it writes, with no serde(default) so an
untagged payload fails on the missing field. This is breaking: runs
suspended by an earlier build cannot be resumed, and the prose warning
it replaces could not fail a load. In exchange a run is now suspendable
and resumable at every step boundary, model call in flight included.

Five driver regression tests (one per defect plus the patch seam) and
three format-tag tests; the pre-monoid fixture test is re-stamped and
split, since asserting that an untagged payload loads is what the
version tag deliberately reverses. 495 lib tests pass, clippy and fmt
clean across the workspace, both examples check.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
gold-silver-copper added a commit that referenced this pull request Aug 11, 2026
Round-2 review of PR #2278 found seven defects, six of them in the
round-2 redesign itself. The headline one: `7dbc0d66` documented every
step of a driven run as a resume point, but only the rarer of a turn's
two failures was actually recoverable.

Preparation could fail without cost, because nothing advanced until a
request existed. The send could not. `commit_model_call` moved the run
to AwaitingModel before handing the caller the request;
CompletionRequestBuilder is not Clone and send(self) consumes it;
retry_model_turn accepts only AwaitingAdvance; and next_step in
AwaitingModel is a protocol violation. A transient transport error - or
a queued provider job that vanished - left the turn consumed and the run
with no public transition out of AwaitingModel. The claim was false for
the failure that actually happens.

rollback_model_call adds the missing transition, on AgentRun and on
AgentDriver. It refunds the turn, drops the advertised names, clears the
driver's snapshot cache, and returns the run to PreparingRequest, so the
next step prepares the request *again* from current configuration.
Re-deriving rather than replaying is the point: a cached request carries
its attempt's tool snapshot, and replaying it would advertise one set of
implementations while a later turn dispatches another - the skew
TurnTools exists to prevent. Tokens the provider already billed stay
billed, and a streamed turn that learns its usage only after the failure
can still record it, reusing the window invalid tool-call recovery
already opens. This is at-least-once and says so: nothing in the run can
distinguish "never arrived" from "arrived, reply lost", so bounding
attempts stays with the caller who owns the transport.
CompletionError::is_retryable classifies whether an attempt is worth
making at all - transport failures with no status, and preserved
provider statuses of 408, 409, 429 or 5xx - closing the asymmetry with
tool errors, which rig has classified for some time.

Two narrowings on the resume path. TurnTools::execute now refuses any
name the turn did not advertise, instead of trusting its snapshot to be
narrowed: in-process the two agree by construction, but a resumed turn
pairs names carried on the run with a snapshot rebuilt locally, so a
tool registered after suspension could be dispatched through a turn that
never advertised it. And a resumed snapshot resolves the advertised
names explicitly (snapshot_tool_defs_including) rather than re-running
retrieval for them, so a registered *dynamic* tool the new query does
not rank is no longer reported as unregistered - advice that could not
be followed - nor fed to the model as not-found.

The peek/commit halves are now public - peek_model_call,
commit_model_call, advance, is_preparing_request, with Advance and
ModelCallInputs - so any hand-driver whose preparation can fail gets the
same guarantee rig's own driver has. Their preconditions become
PromptError protocol violations: a debug_assert is an acceptable
internal contract and an unacceptable public one. TurnToolNames is
exported for real; the changelog announced it as public while the module
was private and only TurnTools re-exported.

Four rustdoc warnings introduced by 7dbc0d6 are gone, and the count is
now a gate: 0 in rig-agent and rig-core.

510 lib tests (+15), 1239 rig-core tests, clippy -D warnings clean, fmt
clean. The two dispatch fixes were each confirmed to fail before their
fix rather than passing on the other's narrowing.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
gold-silver-copper added a commit that referenced this pull request Aug 11, 2026
Round-3 review of PR #2278 found five defects, all on the round-2 fix
commit. The one that matters is `CompletionError::is_retryable`, shipped
last round as "optional" and wrong in both directions.

Its statusless arm keyed on the error *variant*, and `HttpError` is not a
semantic class - it is a transport enum whose variants mix transient and
deterministic failures. An API key read with a trailing newline reaches
`bearer_auth_header`, returns `Error::InvalidHeaderValue`, carries no
status, and was therefore classified retryable. A driver following the
pattern in that method's own documentation - roll back on a retryable
error - then re-prepares forever on a failure that can never succeed.
Round 2 made the rollback cheap, which is exactly what makes this
unbounded. The same arm erred the other way: `from_provider_body`
produces a statusless `ProviderResponse` for Bedrock, Vertex and the gRPC
Gemini client, so throttling from a non-HTTP transport was classified
non-retryable and hard-failed the run.

Classification now happens where the variants live.
`http_client::Error::is_transient` matches exhaustively - no wildcard, so
a variant added later must be classified deliberately - and excludes
`Protocol`, `InvalidHeaderValue`, `NoHeaders` and `InvalidContentType` by
name, the same class langgraph's `default_retry_on` and pydantic-ai's
Temporal denylist exclude. `StreamEnded` and the client's own opaque
`Instance` failures default to transient, because rig cannot see inside a
`Box<dyn Error>` and a dropped connection is the overwhelming case. The
non-HTTP-transport gap is not papered over: statusless provider bodies
stay conservatively non-retryable, and the doc names the limitation, says
providers that can surface a status should use `from_http_response`, and
points at the provider adapter as where this belongs. A test asserts the
gap deliberately so nobody "fixes" it blind.

That fix forces a correction round 2 got wrong in prose. Retryable and
replay-safe are different questions, and round 2's docs let the first
answer the second - `Err(err) if err.is_retryable() =>
driver.rollback_model_call()?` was the recommended pattern. But a stream
that died *after* the request was written is retryable and not
replay-safe at all: rolling back bills a second completion and repeats
whatever the model already caused. `is_transient` classifies exactly
those as retryable, correctly for that axis. The two questions are now
separated wherever the pattern appeared - `is_retryable`,
`rollback_model_call` on both types, the driver's durability section, the
changelogs and MIGRATING - and the recommended pattern gained the
`nothing_was_sent` term the caller alone can supply. openai-agents
carries this as data (`replay_safety` on its retry advice, set from the
SDK's "the request may have been accepted"); rig has no such signal, so
it says so instead of implying otherwise.

Three smaller fixes. `execute_call`'s pre-resolved path now clears the
context's dispatch result like every other dispatch surface, so a call
suppressed by invalid tool-call recovery no longer leaves the previous
call's metadata readable to a loop reading it per call - the same hazard
round 2 closed for `execute` and missed on its sibling. `TurnToolNames`
gains a constructor: it is `#[non_exhaustive]` serialized state and
`commit_model_call` is public and takes one, so the "any hand-driver, not
just rig's own" claim was false. And both of rig's own `ModelCallInputs`
destructurings dropped their `..`, which had been silently opting out of
the compiler signal the type's own doc claims to provide.

512 rig-agent tests (+2), 1241 rig-core (+2), clippy -D warnings clean,
fmt clean, 0 rustdoc warnings in both crates. Both new behavioral tests
were confirmed to fail before their fix by deliberate revert.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
gold-silver-copper added a commit that referenced this pull request Aug 11, 2026
Review of the unreleased prepare_turn API (PR #2278 round 1) verified ten
findings; five of them - the unarmed output-tool intercept, the lost
Tool-mode pin, the examples bailing on cross-process resume, the
transposable ModelTurn incantation, and run construction restating agent
config - were symptoms of one missing abstraction: the pairing between
run-state (AgentRun) and turn-state (TurnTools) lived in every caller.
With the API unreleased, the architecture is fixed instead of the
symptoms.

Agent::drive(prompt) / Agent::drive_run(run) return an AgentDriver that
owns the pairing and nothing else. It seeds the run from the agent's
configuration (default_max_turns, tool_choice, output schema), yields
DriveStep::SendRequest with the fully configured request for the caller
to send (or hand to a custom transport), pairs DriveStep::ExecuteTools
with the exact registry snapshot that advertised the calls, assembles
the model turn internally, and maintains the run's committed
structured-output tool across turns exactly as the runner does. The
driver performs no provider IO and no dispatch of its own: hooks,
memory, retrieval policy, and telemetry remain AgentRunner-only, and it
is built strictly on AgentRun's own step methods and the runner's shared
build_agent_run/build_prepared_completion_request - no duplicated
turn-decision logic.

Resume in a fresh process re-derives a fresh dispatch snapshot from the
rebuilt agent; a pending call whose tool is missing from this process's
registry is surfaced as an error before dispatch (opt out with
allow_missing_resumed_tools). PreparedTurn and Agent::prepare_turn are
removed; the public surface is AgentDriver + DriveStep + TurnTools, with
TurnTools now cheaply cloneable (Arc-shared sets) and gaining
execute_call for pending-call dispatch with preresolved-result handling.

Standalone review fixes in the same pass:
- ToolErrorKind::NotExecutable (rig-core, non_exhaustive enum): the
  output-tool dispatch rejection is machine-readable instead of an
  ambiguous NotFound.
- The clear -> dispatch -> publish sequence is single-sourced
  (ToolDispatch::publish_to + ToolRegistrySnapshot::execute); the
  rejection path can no longer skip clear_dispatch_result (regression
  test included).
- PreparedCompletionRequest composes TurnTools directly; the 1:1 mirror
  struct and its repack are gone.
- The "no telemetry" overclaim is corrected: the prepared request honors
  the agent's record_telemetry_content, documented with the opt-out.

Both examples now contain zero pairing logic - the CallTools arm is
decision logic plus execute_call - and demonstrate true fresh-driver
resume. Runner and streaming paths are behaviorally unchanged; all 518
pre-existing rig-agent tests pass as-is.
gold-silver-copper added a commit that referenced this pull request Aug 11, 2026
Review of the AgentDriver shell (PR #2278 round 2) found four defects,
three of them one structural fact: the driver held turn_tools outside
the serialized AgentRun. Everything downstream followed from that.

A model call was committed - turn consumed, state moved to
AwaitingModel - before the fallible request preparation ran, so a
transiently unreachable tool server left the run wedged: next_step
rejected it as a pending response, model_response rejected it for
having no prepared turn, and no third door existed. There was no
AwaitingModel counterpart to resume_tools, so a run serialized after
SendRequest - the natural suspension point for a caller who owns the
transport - could never be resumed. resume_tools validated a
tool_choice against a request it was not building, masking the
actionable drift message and making allow_missing_resumed_tools
unreachable, contradicting its documented contract. Separately, a
custom run's tool_choice reached only the run's internal decisions and
never the wire, while its sibling output_tool_name was threaded
correctly.

TurnTools splits at the durability line. TurnToolNames (new, public,
Serialize) is data and is recorded on the run at commit;
TurnTools::from_parts pairs it back with a live snapshot, which cannot
be serialized in any design. AgentDriver keeps only that snapshot, as
a cache: it exists so a turn prepared in this process dispatches
through the implementations the provider was shown, and is rebuilt on
demand after a resume. model_response reads the names from the run, so
a resumed run validates its reply against the set the request actually
carried rather than against whatever the registry holds now.

AgentRun::next_step's PreparingRequest arm decomposes into a pure
peek_model_call, an infallible commit_model_call, and advance() ->
NeedsModelCall | CallTools | Done. reprompt_for_output no longer
recurses into next_step - it parks the run and lets advance loop -
which is what closes the path by which the machine could commit a
model call the driver had not prepared a request for. next_step is
advance plus an immediate commit, so the runner, the streaming path,
the conformance suite and the module-doc example are unchanged; the
driver uses the two halves and a preparation failure now leaves the
run byte-identical, ready to retry.

Per-turn configuration gets one seam. The driver was passing None for
the request_patch parameter it already had; AgentDriver::request_patch
/ set_request_patch now supply it, giving a hand-driven run the
per-turn preamble, sampling parameters, tool_choice, active_tools
narrowing, extra context and substituted history the runner gets from
its CompletionCall hooks. The patch's tool_choice is seeded from the
new public AgentRun::tool_choice() when the caller sets none, so a
custom run's choice reaches the provider. build_prepared_completion_
request's 17 positional parameters collapse into TurnBaseline and
TurnRequest, retiring the transposition hazard for the parameters
TurnTools had only fixed two of.

Serialized runs carry $schemaVersion (RUN_SCHEMA_VERSION = "1.0") and a
build reads only the version it writes, with no serde(default) so an
untagged payload fails on the missing field. This is breaking: runs
suspended by an earlier build cannot be resumed, and the prose warning
it replaces could not fail a load. In exchange a run is now suspendable
and resumable at every step boundary, model call in flight included.

Five driver regression tests (one per defect plus the patch seam) and
three format-tag tests; the pre-monoid fixture test is re-stamped and
split, since asserting that an untagged payload loads is what the
version tag deliberately reverses. 495 lib tests pass, clippy and fmt
clean across the workspace, both examples check.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
gold-silver-copper added a commit that referenced this pull request Aug 11, 2026
Round-2 review of PR #2278 found seven defects, six of them in the
round-2 redesign itself. The headline one: `7dbc0d66` documented every
step of a driven run as a resume point, but only the rarer of a turn's
two failures was actually recoverable.

Preparation could fail without cost, because nothing advanced until a
request existed. The send could not. `commit_model_call` moved the run
to AwaitingModel before handing the caller the request;
CompletionRequestBuilder is not Clone and send(self) consumes it;
retry_model_turn accepts only AwaitingAdvance; and next_step in
AwaitingModel is a protocol violation. A transient transport error - or
a queued provider job that vanished - left the turn consumed and the run
with no public transition out of AwaitingModel. The claim was false for
the failure that actually happens.

rollback_model_call adds the missing transition, on AgentRun and on
AgentDriver. It refunds the turn, drops the advertised names, clears the
driver's snapshot cache, and returns the run to PreparingRequest, so the
next step prepares the request *again* from current configuration.
Re-deriving rather than replaying is the point: a cached request carries
its attempt's tool snapshot, and replaying it would advertise one set of
implementations while a later turn dispatches another - the skew
TurnTools exists to prevent. Tokens the provider already billed stay
billed, and a streamed turn that learns its usage only after the failure
can still record it, reusing the window invalid tool-call recovery
already opens. This is at-least-once and says so: nothing in the run can
distinguish "never arrived" from "arrived, reply lost", so bounding
attempts stays with the caller who owns the transport.
CompletionError::is_retryable classifies whether an attempt is worth
making at all - transport failures with no status, and preserved
provider statuses of 408, 409, 429 or 5xx - closing the asymmetry with
tool errors, which rig has classified for some time.

Two narrowings on the resume path. TurnTools::execute now refuses any
name the turn did not advertise, instead of trusting its snapshot to be
narrowed: in-process the two agree by construction, but a resumed turn
pairs names carried on the run with a snapshot rebuilt locally, so a
tool registered after suspension could be dispatched through a turn that
never advertised it. And a resumed snapshot resolves the advertised
names explicitly (snapshot_tool_defs_including) rather than re-running
retrieval for them, so a registered *dynamic* tool the new query does
not rank is no longer reported as unregistered - advice that could not
be followed - nor fed to the model as not-found.

The peek/commit halves are now public - peek_model_call,
commit_model_call, advance, is_preparing_request, with Advance and
ModelCallInputs - so any hand-driver whose preparation can fail gets the
same guarantee rig's own driver has. Their preconditions become
PromptError protocol violations: a debug_assert is an acceptable
internal contract and an unacceptable public one. TurnToolNames is
exported for real; the changelog announced it as public while the module
was private and only TurnTools re-exported.

Four rustdoc warnings introduced by 7dbc0d6 are gone, and the count is
now a gate: 0 in rig-agent and rig-core.

510 lib tests (+15), 1239 rig-core tests, clippy -D warnings clean, fmt
clean. The two dispatch fixes were each confirmed to fail before their
fix rather than passing on the other's narrowing.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
gold-silver-copper added a commit that referenced this pull request Aug 11, 2026
Round-3 review of PR #2278 found five defects, all on the round-2 fix
commit. The one that matters is `CompletionError::is_retryable`, shipped
last round as "optional" and wrong in both directions.

Its statusless arm keyed on the error *variant*, and `HttpError` is not a
semantic class - it is a transport enum whose variants mix transient and
deterministic failures. An API key read with a trailing newline reaches
`bearer_auth_header`, returns `Error::InvalidHeaderValue`, carries no
status, and was therefore classified retryable. A driver following the
pattern in that method's own documentation - roll back on a retryable
error - then re-prepares forever on a failure that can never succeed.
Round 2 made the rollback cheap, which is exactly what makes this
unbounded. The same arm erred the other way: `from_provider_body`
produces a statusless `ProviderResponse` for Bedrock, Vertex and the gRPC
Gemini client, so throttling from a non-HTTP transport was classified
non-retryable and hard-failed the run.

Classification now happens where the variants live.
`http_client::Error::is_transient` matches exhaustively - no wildcard, so
a variant added later must be classified deliberately - and excludes
`Protocol`, `InvalidHeaderValue`, `NoHeaders` and `InvalidContentType` by
name, the same class langgraph's `default_retry_on` and pydantic-ai's
Temporal denylist exclude. `StreamEnded` and the client's own opaque
`Instance` failures default to transient, because rig cannot see inside a
`Box<dyn Error>` and a dropped connection is the overwhelming case. The
non-HTTP-transport gap is not papered over: statusless provider bodies
stay conservatively non-retryable, and the doc names the limitation, says
providers that can surface a status should use `from_http_response`, and
points at the provider adapter as where this belongs. A test asserts the
gap deliberately so nobody "fixes" it blind.

That fix forces a correction round 2 got wrong in prose. Retryable and
replay-safe are different questions, and round 2's docs let the first
answer the second - `Err(err) if err.is_retryable() =>
driver.rollback_model_call()?` was the recommended pattern. But a stream
that died *after* the request was written is retryable and not
replay-safe at all: rolling back bills a second completion and repeats
whatever the model already caused. `is_transient` classifies exactly
those as retryable, correctly for that axis. The two questions are now
separated wherever the pattern appeared - `is_retryable`,
`rollback_model_call` on both types, the driver's durability section, the
changelogs and MIGRATING - and the recommended pattern gained the
`nothing_was_sent` term the caller alone can supply. openai-agents
carries this as data (`replay_safety` on its retry advice, set from the
SDK's "the request may have been accepted"); rig has no such signal, so
it says so instead of implying otherwise.

Three smaller fixes. `execute_call`'s pre-resolved path now clears the
context's dispatch result like every other dispatch surface, so a call
suppressed by invalid tool-call recovery no longer leaves the previous
call's metadata readable to a loop reading it per call - the same hazard
round 2 closed for `execute` and missed on its sibling. `TurnToolNames`
gains a constructor: it is `#[non_exhaustive]` serialized state and
`commit_model_call` is public and takes one, so the "any hand-driver, not
just rig's own" claim was false. And both of rig's own `ModelCallInputs`
destructurings dropped their `..`, which had been silently opting out of
the compiler signal the type's own doc claims to provide.

512 rig-agent tests (+2), 1241 rig-core (+2), clippy -D warnings clean,
fmt clean, 0 rustdoc warnings in both crates. Both new behavioral tests
were confirmed to fail before their fix by deliberate revert.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
Add Agent::prepare_turn, returning the new public PreparedTurn/TurnTools:
the agent's baseline configuration resolved into one turn's completion
request plus the turn's executable and allowed tool-name sets, the
synthetic output-tool name, and tool dispatch pinned to the turn's
registry snapshot.

Hand-driving the sans-IO AgentRun machine is a documented supported use
case (custom provider transport, durable suspend/resume), but until now
the only way to do it was to re-derive the agent's configuration by hand
- our own agent_run_stepping example registered its tool twice and
copy-pasted its preamble into the loop. A prepared turn is a
configuration read plus a dispatch target, not a second execution path:
AgentRunner remains the only path that executes an agent with hooks,
memory, retrieval policy, and telemetry, and a prepared turn reflects
the baseline configuration with no hook patches applied.

Design notes:

- ToolRegistrySnapshot stays private. The public dispatch surface is the
  new TurnTools wrapper, whose execute() mirrors ToolServerHandle::
  execute; retain_names and ToolDispatch remain internal.
- The advertised sets and the dispatch snapshot are resolved together at
  one instant and carried as one value, so a hand-driver cannot mix a
  snapshot from one turn with name sets from another.
- Impossible tool_choice/tool-set combinations fail at prepare time with
  no provider round-trip, including Required against an empty advertised
  set.
- Dispatching the synthetic output tool is rejected with an error
  explaining that its call carries the final structured answer.

Both hand-driven examples (agent_run_stepping,
agent_with_durable_approval) are rewritten on the new API: each builds
one Agent and drives AgentRun from it, with tool calls executing through
the same snapshot the provider saw advertised.

No behavior change on the runner or streaming paths; the shared
build_prepared_completion_request serves both callers unchanged.
Review of the unreleased prepare_turn API (PR #2278 round 1) verified ten
findings; five of them - the unarmed output-tool intercept, the lost
Tool-mode pin, the examples bailing on cross-process resume, the
transposable ModelTurn incantation, and run construction restating agent
config - were symptoms of one missing abstraction: the pairing between
run-state (AgentRun) and turn-state (TurnTools) lived in every caller.
With the API unreleased, the architecture is fixed instead of the
symptoms.

Agent::drive(prompt) / Agent::drive_run(run) return an AgentDriver that
owns the pairing and nothing else. It seeds the run from the agent's
configuration (default_max_turns, tool_choice, output schema), yields
DriveStep::SendRequest with the fully configured request for the caller
to send (or hand to a custom transport), pairs DriveStep::ExecuteTools
with the exact registry snapshot that advertised the calls, assembles
the model turn internally, and maintains the run's committed
structured-output tool across turns exactly as the runner does. The
driver performs no provider IO and no dispatch of its own: hooks,
memory, retrieval policy, and telemetry remain AgentRunner-only, and it
is built strictly on AgentRun's own step methods and the runner's shared
build_agent_run/build_prepared_completion_request - no duplicated
turn-decision logic.

Resume in a fresh process re-derives a fresh dispatch snapshot from the
rebuilt agent; a pending call whose tool is missing from this process's
registry is surfaced as an error before dispatch (opt out with
allow_missing_resumed_tools). PreparedTurn and Agent::prepare_turn are
removed; the public surface is AgentDriver + DriveStep + TurnTools, with
TurnTools now cheaply cloneable (Arc-shared sets) and gaining
execute_call for pending-call dispatch with preresolved-result handling.

Standalone review fixes in the same pass:
- ToolErrorKind::NotExecutable (rig-core, non_exhaustive enum): the
  output-tool dispatch rejection is machine-readable instead of an
  ambiguous NotFound.
- The clear -> dispatch -> publish sequence is single-sourced
  (ToolDispatch::publish_to + ToolRegistrySnapshot::execute); the
  rejection path can no longer skip clear_dispatch_result (regression
  test included).
- PreparedCompletionRequest composes TurnTools directly; the 1:1 mirror
  struct and its repack are gone.
- The "no telemetry" overclaim is corrected: the prepared request honors
  the agent's record_telemetry_content, documented with the opt-out.

Both examples now contain zero pairing logic - the CallTools arm is
decision logic plus execute_call - and demonstrate true fresh-driver
resume. Runner and streaming paths are behaviorally unchanged; all 518
pre-existing rig-agent tests pass as-is.
Review of the AgentDriver shell (PR #2278 round 2) found four defects,
three of them one structural fact: the driver held turn_tools outside
the serialized AgentRun. Everything downstream followed from that.

A model call was committed - turn consumed, state moved to
AwaitingModel - before the fallible request preparation ran, so a
transiently unreachable tool server left the run wedged: next_step
rejected it as a pending response, model_response rejected it for
having no prepared turn, and no third door existed. There was no
AwaitingModel counterpart to resume_tools, so a run serialized after
SendRequest - the natural suspension point for a caller who owns the
transport - could never be resumed. resume_tools validated a
tool_choice against a request it was not building, masking the
actionable drift message and making allow_missing_resumed_tools
unreachable, contradicting its documented contract. Separately, a
custom run's tool_choice reached only the run's internal decisions and
never the wire, while its sibling output_tool_name was threaded
correctly.

TurnTools splits at the durability line. TurnToolNames (new, public,
Serialize) is data and is recorded on the run at commit;
TurnTools::from_parts pairs it back with a live snapshot, which cannot
be serialized in any design. AgentDriver keeps only that snapshot, as
a cache: it exists so a turn prepared in this process dispatches
through the implementations the provider was shown, and is rebuilt on
demand after a resume. model_response reads the names from the run, so
a resumed run validates its reply against the set the request actually
carried rather than against whatever the registry holds now.

AgentRun::next_step's PreparingRequest arm decomposes into a pure
peek_model_call, an infallible commit_model_call, and advance() ->
NeedsModelCall | CallTools | Done. reprompt_for_output no longer
recurses into next_step - it parks the run and lets advance loop -
which is what closes the path by which the machine could commit a
model call the driver had not prepared a request for. next_step is
advance plus an immediate commit, so the runner, the streaming path,
the conformance suite and the module-doc example are unchanged; the
driver uses the two halves and a preparation failure now leaves the
run byte-identical, ready to retry.

Per-turn configuration gets one seam. The driver was passing None for
the request_patch parameter it already had; AgentDriver::request_patch
/ set_request_patch now supply it, giving a hand-driven run the
per-turn preamble, sampling parameters, tool_choice, active_tools
narrowing, extra context and substituted history the runner gets from
its CompletionCall hooks. The patch's tool_choice is seeded from the
new public AgentRun::tool_choice() when the caller sets none, so a
custom run's choice reaches the provider. build_prepared_completion_
request's 17 positional parameters collapse into TurnBaseline and
TurnRequest, retiring the transposition hazard for the parameters
TurnTools had only fixed two of.

Serialized runs carry $schemaVersion (RUN_SCHEMA_VERSION = "1.0") and a
build reads only the version it writes, with no serde(default) so an
untagged payload fails on the missing field. This is breaking: runs
suspended by an earlier build cannot be resumed, and the prose warning
it replaces could not fail a load. In exchange a run is now suspendable
and resumable at every step boundary, model call in flight included.

Five driver regression tests (one per defect plus the patch seam) and
three format-tag tests; the pre-monoid fixture test is re-stamped and
split, since asserting that an untagged payload loads is what the
version tag deliberately reverses. 495 lib tests pass, clippy and fmt
clean across the workspace, both examples check.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
Round-2 review of PR #2278 found seven defects, six of them in the
round-2 redesign itself. The headline one: `7dbc0d66` documented every
step of a driven run as a resume point, but only the rarer of a turn's
two failures was actually recoverable.

Preparation could fail without cost, because nothing advanced until a
request existed. The send could not. `commit_model_call` moved the run
to AwaitingModel before handing the caller the request;
CompletionRequestBuilder is not Clone and send(self) consumes it;
retry_model_turn accepts only AwaitingAdvance; and next_step in
AwaitingModel is a protocol violation. A transient transport error - or
a queued provider job that vanished - left the turn consumed and the run
with no public transition out of AwaitingModel. The claim was false for
the failure that actually happens.

rollback_model_call adds the missing transition, on AgentRun and on
AgentDriver. It refunds the turn, drops the advertised names, clears the
driver's snapshot cache, and returns the run to PreparingRequest, so the
next step prepares the request *again* from current configuration.
Re-deriving rather than replaying is the point: a cached request carries
its attempt's tool snapshot, and replaying it would advertise one set of
implementations while a later turn dispatches another - the skew
TurnTools exists to prevent. Tokens the provider already billed stay
billed, and a streamed turn that learns its usage only after the failure
can still record it, reusing the window invalid tool-call recovery
already opens. This is at-least-once and says so: nothing in the run can
distinguish "never arrived" from "arrived, reply lost", so bounding
attempts stays with the caller who owns the transport.
CompletionError::is_retryable classifies whether an attempt is worth
making at all - transport failures with no status, and preserved
provider statuses of 408, 409, 429 or 5xx - closing the asymmetry with
tool errors, which rig has classified for some time.

Two narrowings on the resume path. TurnTools::execute now refuses any
name the turn did not advertise, instead of trusting its snapshot to be
narrowed: in-process the two agree by construction, but a resumed turn
pairs names carried on the run with a snapshot rebuilt locally, so a
tool registered after suspension could be dispatched through a turn that
never advertised it. And a resumed snapshot resolves the advertised
names explicitly (snapshot_tool_defs_including) rather than re-running
retrieval for them, so a registered *dynamic* tool the new query does
not rank is no longer reported as unregistered - advice that could not
be followed - nor fed to the model as not-found.

The peek/commit halves are now public - peek_model_call,
commit_model_call, advance, is_preparing_request, with Advance and
ModelCallInputs - so any hand-driver whose preparation can fail gets the
same guarantee rig's own driver has. Their preconditions become
PromptError protocol violations: a debug_assert is an acceptable
internal contract and an unacceptable public one. TurnToolNames is
exported for real; the changelog announced it as public while the module
was private and only TurnTools re-exported.

Four rustdoc warnings introduced by 7dbc0d6 are gone, and the count is
now a gate: 0 in rig-agent and rig-core.

510 lib tests (+15), 1239 rig-core tests, clippy -D warnings clean, fmt
clean. The two dispatch fixes were each confirmed to fail before their
fix rather than passing on the other's narrowing.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
Round-3 review of PR #2278 found five defects, all on the round-2 fix
commit. The one that matters is `CompletionError::is_retryable`, shipped
last round as "optional" and wrong in both directions.

Its statusless arm keyed on the error *variant*, and `HttpError` is not a
semantic class - it is a transport enum whose variants mix transient and
deterministic failures. An API key read with a trailing newline reaches
`bearer_auth_header`, returns `Error::InvalidHeaderValue`, carries no
status, and was therefore classified retryable. A driver following the
pattern in that method's own documentation - roll back on a retryable
error - then re-prepares forever on a failure that can never succeed.
Round 2 made the rollback cheap, which is exactly what makes this
unbounded. The same arm erred the other way: `from_provider_body`
produces a statusless `ProviderResponse` for Bedrock, Vertex and the gRPC
Gemini client, so throttling from a non-HTTP transport was classified
non-retryable and hard-failed the run.

Classification now happens where the variants live.
`http_client::Error::is_transient` matches exhaustively - no wildcard, so
a variant added later must be classified deliberately - and excludes
`Protocol`, `InvalidHeaderValue`, `NoHeaders` and `InvalidContentType` by
name, the same class langgraph's `default_retry_on` and pydantic-ai's
Temporal denylist exclude. `StreamEnded` and the client's own opaque
`Instance` failures default to transient, because rig cannot see inside a
`Box<dyn Error>` and a dropped connection is the overwhelming case. The
non-HTTP-transport gap is not papered over: statusless provider bodies
stay conservatively non-retryable, and the doc names the limitation, says
providers that can surface a status should use `from_http_response`, and
points at the provider adapter as where this belongs. A test asserts the
gap deliberately so nobody "fixes" it blind.

That fix forces a correction round 2 got wrong in prose. Retryable and
replay-safe are different questions, and round 2's docs let the first
answer the second - `Err(err) if err.is_retryable() =>
driver.rollback_model_call()?` was the recommended pattern. But a stream
that died *after* the request was written is retryable and not
replay-safe at all: rolling back bills a second completion and repeats
whatever the model already caused. `is_transient` classifies exactly
those as retryable, correctly for that axis. The two questions are now
separated wherever the pattern appeared - `is_retryable`,
`rollback_model_call` on both types, the driver's durability section, the
changelogs and MIGRATING - and the recommended pattern gained the
`nothing_was_sent` term the caller alone can supply. openai-agents
carries this as data (`replay_safety` on its retry advice, set from the
SDK's "the request may have been accepted"); rig has no such signal, so
it says so instead of implying otherwise.

Three smaller fixes. `execute_call`'s pre-resolved path now clears the
context's dispatch result like every other dispatch surface, so a call
suppressed by invalid tool-call recovery no longer leaves the previous
call's metadata readable to a loop reading it per call - the same hazard
round 2 closed for `execute` and missed on its sibling. `TurnToolNames`
gains a constructor: it is `#[non_exhaustive]` serialized state and
`commit_model_call` is public and takes one, so the "any hand-driver, not
just rig's own" claim was false. And both of rig's own `ModelCallInputs`
destructurings dropped their `..`, which had been silently opting out of
the compiler signal the type's own doc claims to provide.

512 rig-agent tests (+2), 1241 rig-core (+2), clippy -D warnings clean,
fmt clean, 0 rustdoc warnings in both crates. Both new behavioral tests
were confirmed to fail before their fix by deliberate revert.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
Four review rounds and 18 findings on this PR, and the unit suite grew to
513 tests without catching several of them. The reason is structural:
unit tests assert against rig's own model of a request. Round 1's finding
3 - a run's tool_choice never reaching the provider, governing only the
run's internal decisions - was invisible to every test that inspected
`CompletionRequest`, because the code and the test shared the same wrong
model of what was being sent.

`tests/README.md` already names this: "a corpus written alongside an
abstraction encodes the team's model of the wire and structurally cannot
falsify it". The driver is precisely that kind of component - its whole
job is to build a request and pair it with the state that validates the
reply - and the cassette harness matches request bodies, so a
request-shape regression fails as a mock miss with a diff.

Five tests, recorded against live OpenAI and replaying offline: the
two-turn tool loop (both request bodies), a custom run's tool_choice on
the wire, suspend-mid-model-call and resume in a fresh driver, a streamed
turn driven through the driver against real SSE, and `is_retryable`
against a real provider rejection. Each was falsified against the finding
it guards before committing - reverting the tool_choice fix produces
`body_matches: false` naming the missing field, and dropping the
advertised names from `commit_model_call` fails the resume test. Round 2
of this PR shipped two tests that would have passed without the code they
guarded; a test that passes both ways reads as coverage and is worse than
none.

Writing them turned up three things.

A test that could not fail: a cassette mock miss returns 404, and so does
a real provider rejection, so asserting only on status would have passed
against a cassette that never matched. It now pins the provider's own
error envelope via `provider_response_body()`. Any provider-error
cassette test anywhere has this hazard.

The driver could not drive a streamed turn at all. `AgentRun`'s streamed
entry points take `&mut self` and the driver exposed only `run(&self)`
and a consuming `into_run()`, so a streaming caller had to rebuild the
driver - discarding the per-turn snapshot cache, which makes the driver
treat a turn prepared in this very process as a resume, drift check
included. That is round 4's finding 3, and it blocked the whole streaming
tranche. `run_mut()` closes it, documented with the one invariant it must
not break: feed responses through it, never commit or roll back a model
call, or the cached dispatch target falls out of step with the turn.

`advertised_tools` outlived its turn on five of six paths - round 4's
finding 2. Only `rollback_model_call` cleared it; `reprompt_for_output`,
`retry_model_turn`, `resolve_invalid_tool_call(Retry)`, `tool_results`
and both streamed-abandon paths did not, so a run serialized while parked
for a fresh call reported a turn that was over, contradicting the
accessor's own doc about being an audit record. Every route back to
`PreparingRequest` now goes through one `park_for_new_request` helper so
the invariant cannot be half-applied.

Scope is deliberately honest: of the 18 findings, this shape of suite
would have caught about five. Six are documentation or API-surface
defects wanting `cargo doc` and public-API gates, and seven are
state-machine or local-failure defects where a unit test is the right
instrument - including the one still-open finding, a deterministic
transport failure classified retryable, which happens before a request
exists and so leaves no traffic to record.

108 openai cassette tests pass (103 pre-existing + 5), 513 rig-agent unit
tests, 1241 rig-core, clippy -D warnings clean, fmt clean, 0 rustdoc
warnings. No credentials or authorization headers in the recordings.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
53 cassettes, 53 tests, ~60 live model calls: 25 OpenAI, 14 Anthropic, 14
Gemini. Shared driving helpers in tests/common/driver_support.rs, so each
provider module carries only the client, the model id, and the wire-shape
assertions.

Coverage: the tool loop and parallel calls; every per-run and per-turn
configuration path that reaches a request (all three tool_choice modes,
and RequestPatch's preamble, tool_choice, active_tools, sampling params,
extra_context and history); both resume points; rollback and
re-preparation after a rejected send; a preparation failure costing no
turn and no interaction; max_turns exhaustion; both output modes; and
three streaming shapes.

Three providers is not redundancy. The driver is provider-agnostic and
the request it builds is not - tool_choice, tool declarations and the
system prompt are spelled differently by each - so a per-turn patch that
reaches an OpenAI request can silently fail to reach an Anthropic one.
Falsification confirms it: with the run-tool_choice fix reverted, all
three providers' tests fail, three independent wire encodings producing
three independent mock misses.

Recording against real providers found two test defects that no amount of
local reasoning would have:

Anthropic resolves a per-model default max_tokens, and an *unknown* model
has no default - so the provider-rejection test, which used a bogus model
name deliberately, failed locally with `max_tokens must be set` and never
reached the wire. It was asserting on a local failure while claiming to
assert on a provider one. (Incidentally a small vindication of the
classification work: that local RequestError is correctly not retryable.)

And a model forbidden from its only useful tool returns nothing at all.
The ToolChoice::None test asserted a non-empty answer; against Anthropic
the model returned content [] - honest behavior that
is_empty_assistant_turn handles - because the prompt was arithmetic and
the tool that would answer it had just been forbidden. The same test
passed on OpenAI, whose model chose prose instead. Rewritten to assert
what it exists for: the run finalizes with no tool call surviving the
constraint. Exactly the doctrine's "assert on structure, not wording".

128 openai / 85 anthropic / 141 gemini cassette tests pass, 513 rig-agent
unit tests, 1241 rig-core, clippy -D warnings clean, fmt clean, 0 rustdoc
warnings. Secret scan clean across all 53 recordings; no authorization or
x-api-key headers captured.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
…erted

Round-5 review found six defects. Two were the same one seen from both
sides, and together they mean the retryability work shipped in rounds 3
and 4 did not cover the failure it was written for.

`from_stream_transport` folded every statusless transport error into
`ProviderError(String)` - rig's unclassifiable bucket - and every
streaming provider routes mid-stream failures through it: anthropic,
openai, gemini's two APIs, cohere, the openai-compatible shim. So
`is_retryable` answered false for a cut stream on every provider, while
its own docs, the changelog and MIGRATING all said otherwise, and
`rollback_model_call`'s guidance was built on it.

The test that claimed to cover this hand-built `HttpError(StreamEnded)`,
which nothing produces. In the SSE layer `StreamEnded` is the *normal*
end-of-stream sentinel that every provider matches and breaks on; its
only error-producing sites are the wasm stub clients, where it means the
transport cannot send at all. So the arm classifying it transient existed
only to satisfy that test. Round 2's rule - falsify against the finding -
passes here and is not enough; the tightened rule is that a test asserting
a provider-facing failure must reach it through a path production uses,
and must say in its doc comment what that path is.

The fix is codex's: a semantic variant survives stringification where a
generic one does not. `CodexErrorDetails::Stream(String)` carries the same
lossy payload and stays classifiable because the variant does.
`CompletionError::StreamInterrupted` is that, gated on the transport's own
`is_transient` - the first draft routed *every* statusless failure there
and two pre-existing tests caught it, since `InvalidContentType` reaches
the same call sites and is deterministic. Deterministic statusless
failures keep `ProviderError`; those tests pass unchanged.

`Instance` is now classified by what it wraps rather than assumed
transient - round 4's still-open finding. It is the client's catch-all and
holds both a dropped connection and a request the client refused to build,
and a `base_url` missing its scheme loops any caller driving off
`is_retryable`. The `source()` chain is walked for a `reqwest::Error` that
`is_builder()` or `is_redirect()`, following openai-agents'
`iter_error_chain`; anything unrecognised keeps the transient default.

Four smaller fixes. `commit_model_call` now enforces `max_turns`: the
check lived only in `peek_model_call` while commit - public, and
documented as "the only place a turn is consumed" - incremented
unconditionally, so a hand-driver that peeked once and re-committed drove
calls forever. `AgentDriver::max_invalid_tool_call_retries` exists, since
`drive()` seeded zero and the documented `Retry` resolution could never
succeed. `ModelTurnOutcome` is `#[must_use]`, which immediately caught
every drop site including this PR's own test helper - a hallucinated tool
name used to surface two steps later as an unrelated protocol violation.
And resume no longer runs a retrieval query whose result is discarded in
full by the narrowing that follows it, which also removes a resume-time
failure when the index is down.

One honest limit recorded rather than papered over: the cassette derived
for `StreamInterrupted` cannot produce it. A mock that stops writing
closes cleanly, so the SSE layer sees end-of-stream, not a transport
failure; only a connection severed mid-frame does, and no mock can do
that. The test was kept and inverted to pin the clean close and say why
the classification stays unit-tested.

518 rig-agent, 1251 rig-core, 129/86/141 cassette, clippy -D warnings
clean, fmt clean, 0 rustdoc warnings. Every new behavioral test was
falsified against its fix by deliberate revert.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
CI's `stable / test` failed on `cassettes_do_not_contain_obvious_secrets`,
which is an idempotence check: the scrubber must be a no-op on a
committed cassette. `streamed_interrupted.yaml` was hand-derived from
`streamed_turn.yaml` by deleting the events after a partial arguments
delta, and the derivation stripped the trailing newline the `body: |+`
block scalar preserves - so re-scrubbing produced a different file and
the guard fired.

No secret was ever present; the scan reports any non-canonical form
through the same assertion. Rebuilt keeping the blank line that
terminates the last retained SSE event, which is part of the block
scalar's value.

The guard was right to fire, and worth noting for the next hand-derived
cassette: a scrub has to leave the file in exactly the form the recorder
would have written, not merely remove data.

`cargo nextest run --locked --features bedrock` - the CI invocation -
passes 3558/3558.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
Round-6 review found no correctness defects and two documentation ones,
both from writing notes as if for a reviewer of this branch rather than a
user of the release.

Five `[**behavior**]` entries in rig-agent's `### Added` block amended
APIs that the same block introduces. `TurnTools` was added by the first
bullet and its `execute` behavior "changed" four bullets later;
`commit_model_call` was made public in one entry and "previously" checked
the budget only in `peek_model_call` in another; `advertised_tools()` and
the resume path likewise had a "previously" no user can have experienced.
rig-core was starker still - two behavior bullets amending `is_retryable`
sat directly above the bullet that adds it. Someone upgrading from 0.40
would audit their code for a dispatch change that cannot reach them, or
conclude `advertised_tools()` existed and their persisted state needed
review.

Each behavior note is folded into the entry that introduces its API, so
every bullet now describes the thing as shipped. Two entries were sorted
rather than merged: `ModelTurnOutcome` predates this release, so
`#[must_use]` is a genuine breaking change to an existing type and moves
to `### Changed`, and `ToolErrorKind::NotExecutable` stays its own
addition. MIGRATING's resume-path paragraph drops its "previously" for
the same reason.

`DriveStep::SendRequest`'s `tools` field was documented as
"informational", which is true for a blocking send and false for a
streamed one: `StreamedTurnAssembler::new` needs the turn's executable and
allowed name sets, and this field is the only place a driven turn can get
them. Every streamed cassette test in this PR uses it that way while the
module example and both rewritten examples destructure `{ request, .. }`,
so a caller following the docs into a streaming loop had nowhere to go.
The field doc now says which send needs it and names the assembler.

3558/3558 on the CI invocation, 0 rustdoc warnings, fmt clean.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
A per-turn RequestPatch may override the run's tool choice, and until now
nothing recorded that it had. `commit_model_call` stored the advertised
tool names and the output-tool name; four sites - invalid-tool-call hook
context and the Skip rejection, on the unary and streamed paths - read
`AgentRun::tool_choice`, the run's baseline. So the state machine could
disagree with the request that actually went out, in both directions: a
Skip permitted under a baseline of Required when the request carried
None, or a hook told the choice was None when the request required a
tool.

This is reachable on main today through a CompletionCall hook -
RequestPatch::tool_choice predates the driver, preparation already
prefers it, and the runner seeds the run's choice from the agent. The
driver's patch seam adds a second door to the same room, which is why it
is worth fixing here rather than filing.

PreparedTurnMetadata folds the advertised names together with the
effective tool choice and the output tool, and is what a commit records.
The resolved choice comes back from preparation itself
(`PreparedCompletionRequest::tool_choice`) rather than being re-derived
at the call site: preparation is where the baseline and the patch are
reconciled, so it is the only place that can answer without repeating the
merge rule. `effective_tool_choice()` prefers the committed turn and
falls back to the baseline for runs hand-driven through `next_step`,
which commit no metadata because their names arrive with the ModelTurn.

Being committed state, it survives serialization: a run suspended with a
resolution pending resumes answering the way the process that sent the
request would have.

Four tests, three of them falsified against the fix by reverting the four
read sites; the fourth pins the baseline fallback. 522 rig-agent tests,
54 driver cassettes across three providers replay unchanged, clippy and
fmt clean, 0 rustdoc warnings.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
`AgentDriver` held a sticky `RequestPatch` that `drive_run` silently
reset to empty, while the module docs claimed "the serializable state is
*all* of the state: the driver holds nothing it could lose". Both cannot
be true. A multi-turn run resumed after a patched turn advertised
different tools, sent different history, or used a different preamble on
the next request - and no resume test combined resume with a patch, which
is why six review rounds missed it.

The fix is to stop holding it. `next_step_with` takes a preparation
callback that receives the prompt, the history and the prospective turn
index, and returns the turn's patch and optionally a model. It runs
before anything advances, so a caller decision that fails costs no turn -
the same guarantee preparation already had. `next_step` remains, and
delegates with an empty preparation.

This is the AI SDK's `prepareStep` with the callback made async, and it
is what the runner already does internally: compute per-turn config from
hooks, pass it to preparation. Policy for a turn that has not happened
yet is not run state, so there is nothing to serialize and nothing to
lose. `TurnPreparation` carries a model as well as a patch, so folding
the runner's model selection into the same seam is a call-site change
rather than a signature change.

One cassette was re-recorded deliberately. `patch_active_tools` encoded
the sticky behavior - both recorded turns carried the narrowed tool set,
because the patch leaked into the second. Under the new contract only the
turn given the patch is narrowed, so the test now drives both turns and
asserts the second advertises the full set again. Both request bodies are
the assertion: a patch that outlived its turn would narrow the second
body and fail as a mock miss. That test is now the regression guard for
the defect this commit fixes.

522 rig-agent tests, 54 driver cassettes across three providers, clippy
-D warnings clean, fmt clean, 0 rustdoc warnings.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
`run_mut()` existed because `AgentRun`'s streamed entry points take
`&mut self` and the driver wrapped none of them. Its own documentation
carried a "do not commit or roll back through this" clause, which is the
tell: a safe API whose primary streaming path needs an escape hatch
around its invariants is unfinished, and every streamed cassette test in
this PR used it.

`record_stream_usage`, `accept_streamed_turn` and
`resolve_streamed_invalid_tool_call` replace it. Streaming is now a mode
of the same object rather than a second protocol - pydantic-ai's
`ModelRequestNode` carries a `stream()` method for the same reason - and
the turn stays paired with the snapshot that prepared it without asking
the caller to be careful.

The nine streamed cassette tests across three providers are the proof the
API is sufficient: they were the only callers, and they replay unchanged
through it.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
`AgentRun` has two configured coordinators, and six review rounds kept
finding places where they had drifted. Consolidating them is the fix;
this is the measurement, and it comes first so the refactor has a safety
net rather than a hope.

Four scenarios, each recorded twice into one cassette - once through
`AgentRunner`, once through `AgentDriver`. The harness matches request
bodies, so a coordinator that builds a different request for the same
configuration fails as a mock miss with a body diff naming the field.

The first recording establishes something worth writing down: diffing the
runner half of each cassette against the driver half, the request bodies
are identical byte for byte, except the provider-assigned tool-call id -
which differs between any two live runs and is renumbered by the
scrubber. The two coordinators already agree completely on *what* they
send.

Where they differ is *when* they commit. The runner spends the turn
before running its completion-call hooks, its model selection and its
request preparation, each of which can terminate the run; the driver
prepares first and commits last. That changes no request, which is
exactly why it survived review, and why the wire tests are paired here
with unit tests for the boundary: a preparation callback returning `Err`
leaves `run.turn()` at zero and reaches no provider, and the same step
succeeds once the decision does.

Two scenarios needed rethinking on contact with a live provider.
`ToolChoice::Required` forbids the model from ever answering in text, so
a run under it always ends by exhausting its budget - on both
coordinators. That is a parity claim too, and a sharper one than "both
finish": same configuration, same first request, same terminal error,
same accounting.

3568/3568 on the CI invocation, 133 openai cassette tests, 524 rig-agent,
1251 rig-core, clippy and fmt clean, 0 rustdoc warnings.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
`drive_agent` called `run.next_step()`, which commits, and then ran three
things that can terminate the run: completion-call hooks, model selection
and request preparation. A stop or a failure in any of them consumed a
turn against a call that never happened - and `AgentRun::next_step`'s own
doc says it is for a caller with nothing fallible in between.

The loop now uses the peek/commit halves `AgentDriver` already used and
this PR made public: `advance` reports that the run wants a model call
without spending it, `peek_model_call` reads the inputs, and
`commit_model_call` runs once the request exists - recording what the
turn resolved to, which also retires the separate `set_output_tool_name`
bookkeeping.

This is not observable through `AgentRunner`'s API: every stop path ends
the run, so no caller can see the turn count afterwards. It matters for
two reasons. The two coordinators now agree on when a turn is spent,
which is the invariant the parity suite exists to hold. And anything that
later resumes or retries a runner-driven run - the direction the ECS work
points - would otherwise have inherited a discrepancy that is invisible
until it isn't.

Deliberately *not* the whole consolidation. `drive_agent` still owns its
own `AgentRun` and its own pending tool snapshot rather than driving an
`AgentDriver`, because `TurnSource::run_model_turn` and `run_tool_calls`
take `&mut AgentRun` and moving them onto the driver means changing the
trait and both implementations - 650 lines of stream-macro code whose
borrow structure deserves its own change. The commit boundary was the
correctness half and it is done; the deduplication half is a follow-up
with a clear path, and the parity suite is now in place to guard it.

3568/3568 on the CI invocation. Every existing runner and streaming test
passes unchanged, which is the bar for a refactor of this loop.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
Rebasing onto main brought in `cassette_cache_prefix`, a guard that fails
when a recorded conversation moves the wire prefix providers cache on. It
immediately caught `patch_active_tools`, and it was right to.

That cassette narrows the advertised tools on turn 1 and re-advertises the
full set on turn 2 - which is the assertion, since a patch that outlived
its turn would keep the second turn narrowed. But the tools array is part
of the cached prefix, so growing it between turns busts the cache. The
guard's own message names dynamic tool disclosure as a legitimate case
for the exemption list, so the cassette is added there with that reason.

The more useful half is what it says about the feature.
`RequestPatch::active_tools` now documents the cost where a caller will
see it: the saving from advertising fewer tools is paid back, and then
some, on every later turn of the run, so narrow for a reason - a turn
that must not reach a destructive tool - rather than to trim tokens.

3588/3588 on the CI invocation after the rebase onto 3de43b9.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
`5a1d1774` deleted `AgentDriver::request_patch` / `set_request_patch` in
favour of `next_step_with`, updated the changelog, and left MIGRATING.md
telling users to call the removed methods. That paragraph is the only
user-facing prose describing how a hand-driven turn gets a preamble,
`tool_choice` or `active_tools` override, so a reader following it hits
`no method named request_patch` with no pointer to the mechanism that
replaced it.

Rewritten around `next_step_with`, with a worked snippet, and saying why
it is a callback rather than a setter: the callback runs before the turn
is committed, so a decision that fails costs no turn, and configuration
for a turn that has not happened yet is not run state to be serialized.

`TurnRequest::patch` carried the same stale intra-doc link. It never
failed `cargo doc` because `agent::completion` is private and the type is
`pub(crate)`, so rustdoc never resolved it — which is why the doc gate
stayed green while the link rotted.

Claude-Session: https://claude.ai/code/session_0119GvrZzdUge8Q11SxHx4PA
…s caller

`AgentDriver::model_response` built its `ModelTurn` from the metadata
committed when the request was built, but `AgentRun::streamed_turn`
validated against the sets a caller handed `StreamedTurnAssembler::new` —
two same-typed `BTreeSet<String>` arguments, transposable and widenable.
A turn whose effective choice was `Specific(["add"])` therefore accepted
and dispatched a streamed `subtract` if the assembler was built wide,
against a policy the provider was told to enforce.

`PreparedTurnMetadata` exists to answer *what was this turn allowed to
do*; it was consulted on one ingress and ignored on the other. Add
`effective_tool_names`, the name-set counterpart of the existing
`effective_tool_choice`, and read it wherever a turn's tool sets are
validated or reported: streamed ingestion, the mid-stream invalid-call
hook context and its `Repair` check, and blocking ingestion too, so one
sentence describes both paths. Runs hand-driven through `AgentRun`
commit no metadata and keep validating against their carried sets.

Then close the constructor a caller could get wrong:
`StreamedTurnAssembler::new` now takes the paired `TurnToolNames`, and
`TurnTools::streamed_turn_assembler` — the streaming counterpart of
`TurnToolNames::model_turn` — is how a driven turn builds one, so the
driver path never spells the sets at all.
`allow_missing_resumed_tools` was a builder field on `AgentDriver` that
`Agent::drive_run` reset to `false`, so a serialized run resumed without
reapplying it dispatched differently and nothing on the run said so —
the same shape as the sticky `RequestPatch` this branch already deleted,
one field narrower. The module's durability paragraph claimed the driver
"holds nothing it could lose", which was one field short of true.

Replace it with `Agent::resume_run(run, ResumedToolDrift)`, following
`RunState.from_json`'s keyword-only resume policy in openai-agents: a
process resuming a payload states how it handles drift at the point of
resuming, where it cannot be omitted by forgetting a builder call.
`drive_run` stays for the common case and means `Reject`.

The policy is deliberately not serialized with the run: the process that
suspended it cannot know what registry a later one will have, so it has
no standing to decide how that process handles its own drift.

The durability paragraph now says what the driver actually holds — a
rebuildable snapshot cache and this process's own resume policy — rather
than claiming it holds nothing.
…rs cost

The suite's module docs described a commit-timing divergence that the very
next commit fixed, so the harness misdescribed itself. Replace it with the
claim it actually supports: the coordinators agree on the wire, the commit
boundary is now shared and pinned by unit tests, and what remains is
structural duplication these cassettes *detect* but cannot prevent — which
is why the suite is worth extending as either coordinator grows behavior.

The driver's module docs said it owns the run/turn pairing "in one place"
without noting that `AgentRunner` still implements the same protocol
internally. One place for callers is not one place in the crate; say so,
name the parity cassettes as what keeps the duplication honest, and state
that folding the runner onto the driver is the intended end state.

Also record both API breaks from this round in MIGRATING and the changelog.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant