Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d1f6e98
feat(agent): expose per-turn request preparation on Agent
gold-silver-copper Aug 10, 2026
eba03b2
refactor!(agent): replace prepare_turn with the AgentDriver shell
gold-silver-copper Aug 11, 2026
ae1934b
style: cargo fmt
gold-silver-copper Aug 11, 2026
ce401a6
refactor!(agent): make every step of a driven run a resume point
gold-silver-copper Aug 11, 2026
c97fc3a
fix(agent): close the send-failure gap the resume-point claim left open
gold-silver-copper Aug 11, 2026
02e3324
fix(core): stop is_retryable turning a config typo into an infinite loop
gold-silver-copper Aug 11, 2026
344cac1
test(agent): cassette-back the AgentDriver against real provider traffic
gold-silver-copper Aug 11, 2026
ccef387
test(agent): scale the AgentDriver cassette suite to three providers
gold-silver-copper Aug 11, 2026
67ce854
fix(core): make the cut-stream retryability claim true instead of ass…
gold-silver-copper Aug 11, 2026
f6b5c30
fix(tests): restore the truncated stream cassette to scrubbed form
gold-silver-copper Aug 11, 2026
54a6348
docs: write the release notes for readers of the release, not the branch
gold-silver-copper Aug 11, 2026
76ab0c9
fix(agent): answer "what was this turn allowed to do" from the turn
gold-silver-copper Aug 11, 2026
5a1d177
refactor!(agent): per-turn configuration is an input, not driver state
gold-silver-copper Aug 11, 2026
2322d39
refactor!(agent): streamed turns enter through the driver, not around it
gold-silver-copper Aug 11, 2026
4685f15
test(agent): pin runner/driver parity so divergence fails a test
gold-silver-copper Aug 11, 2026
95a4eba
fix(agent): the runner spends a turn only once its request exists
gold-silver-copper Aug 11, 2026
e25a9e3
fix(agent): name the prompt-cache cost of per-turn tool narrowing
gold-silver-copper Aug 11, 2026
2cc23dd
docs: point the migration guide at the API that exists
gold-silver-copper Aug 12, 2026
96ba87f
fix(agent): the request decides what a streamed turn may call, not it…
gold-silver-copper Aug 12, 2026
7a196e2
refactor!(agent): resume policy is stated at the resume entry point
gold-silver-copper Aug 12, 2026
9a82b86
docs(agent): say what the parity suite guards and what two coordinato…
gold-silver-copper Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -1561,6 +1561,108 @@ response. For intentionally hook-free transport, start from
for custom drivers. It holds no configured model, tools, memory, or hooks and is
not an alternate execution path for configured agents.

Reading a configured agent's request state back out (the 0.40 public fields,
`agent.tool_server_handle`) has a supported replacement:
`Agent::drive(prompt)` returns an `AgentDriver` that hand-drives the sans-IO
`AgentRun` with the agent's own configuration — each `DriveStep::SendRequest`
carries the fully configured completion request for the caller to send, and
each `DriveStep::ExecuteTools` carries the `TurnTools` dispatch snapshot of
the turn that advertised the calls. It is a configuration and pairing layer,
not an execution path — hooks, memory, retrieval policy, and telemetry still
run only under `Agent::runner`. See `examples/agent_run_stepping` and
`examples/agent_with_durable_approval`.

A hand-driven turn gets per-turn configuration from
`AgentDriver::next_step_with`, whose callback receives the prompt, the history
and the turn about to be prepared, and returns a `TurnPreparation` carrying the
turn's `RequestPatch` and optionally a model:

```rust
let step = driver
.next_step_with(|ctx| {
Box::pin(async move {
Ok(TurnPreparation::with_patch(
RequestPatch::new().preamble(preamble_for(ctx.turn)),
))
})
})
.await?;
```

That is the same `RequestPatch` the runner merges from its `CompletionCall`
hooks — per-turn preamble, sampling parameters, `tool_choice`, `active_tools`
narrowing, extra context, substituted history — and the callback runs *before*
the turn is committed, so a decision that fails costs no turn. It is an input
rather than driver state on purpose: configuration for a turn that has not
happened yet is not run state, and a driver holding it would resume a
serialized run with settings the suspending process never recorded. `next_step()`
remains for turns that need none.

A custom run's own `AgentRun::with_tool_choice` also reaches the provider;
before, it governed only the run's internal decisions while the request carried
the agent's baseline.

**Serialized `AgentRun` state does not survive the upgrade.** Payloads now
carry a `$schemaVersion` tag and a build reads only the version it writes, so a
run suspended by an earlier release fails to deserialize with a named error
rather than being reinterpreted against fields whose meaning moved. Drain
in-flight suspended runs before upgrading, or discard them. In exchange, a run
can now be suspended and resumed at *every* step boundary — including with a
model call in flight — not only while tool calls are pending.

Because the caller owns the send, the caller also owns recovering from a failed
one: `AgentDriver::rollback_model_call()` hands a turn back when its request
could not be sent or its reply is known to be lost, refunding the turn and
returning the run to preparing so the next step yields a freshly prepared
request.

Deciding to use it takes two answers, not one. `CompletionError::is_retryable()`
(new in `rig-core`) says whether a retry *could succeed* — transport failures
that could resolve on their own, and provider statuses of 408, 409, 429 or 5xx.
It does **not** say whether a retry is *safe*: a stream that died after the
request was written is retryable and may already have taken effect, so rolling
back on retryability alone bills a second completion. Only you can settle that,
through provider-side idempotency, your own record of what was transmitted, or a
transport that fails before the write. Bound the attempts yourself. Runs driven
by `Agent::runner` are unaffected; the runner still fails the prompt.

Two things to know about dispatch on a resumed run, since both differ from what
you might assume: `TurnTools::execute` refuses any name the turn did not
advertise, so a tool registered *after* a run was suspended cannot be dispatched
through a turn that never offered it to the model; and the resumed snapshot
resolves the advertised names against the registry directly rather than
re-running retrieval, so a registered dynamic tool is found whether or not a
fresh query would rank it, and absence really does mean the tool is gone.

Absence is reported, not papered over: `Agent::drive_run` fails with the missing
names rather than silently feeding not-found results to the model. To dispatch
anyway, resume with `Agent::resume_run(run, ResumedToolDrift::Dispatch)`. The
policy is an argument at the resume entry point rather than a setting on the
driver, and it is deliberately not serialized with the run — the process that
suspended a run cannot know what registry a later one will have, so it has no
standing to decide how that process handles its own drift.

**`StreamedTurnAssembler::new` takes one argument.** It was
`new(executable_tool_names, allowed_tool_names)` — two same-typed
`BTreeSet<String>` values, transposable at the call site — and is now
`new(&TurnToolNames)`. Under `AgentDriver`, do not spell the names at all: call
`tools.streamed_turn_assembler()` on the `TurnTools` the matching
`DriveStep::SendRequest` carried. Elsewhere, wrap what you passed before:

```rust
// before
StreamedTurnAssembler::new(executable, allowed)
// after
StreamedTurnAssembler::new(&TurnToolNames::new(executable, allowed))
```

Those names now govern *mid-stream* validation only. What a turn is finally
allowed to call is answered by the run, from the metadata committed when its
request was built, so a mis-built assembler can no longer get a tool dispatched
that the request's `tool_choice` forbade — it can only forfeit the early exit
the assembler exists to provide. Runs hand-driven through `AgentRun` commit no
such metadata and keep validating against the names they carry.

An `Agent`'s default model is set at construction. Per-run overrides now go
through `runner(...).using_model(...)`, `Agent::set_model`, or a
`ModelSelection` hook (see the "runtime model swapping" section for the
Expand Down
27 changes: 27 additions & 0 deletions crates/rig-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- *(agent)* [**breaking**] `StreamedTurnAssembler::new` takes the paired `TurnToolNames` instead of two same-typed `BTreeSet<String>` sets a caller could transpose, and `TurnTools::streamed_turn_assembler()` builds one straight from the turn a `DriveStep::SendRequest` carried, so a driven turn never spells the sets. Those names now govern mid-stream validation only: what a turn is finally allowed to call is answered by the run, from the metadata committed when its request was built. A driven streamed turn therefore cannot be widened past its request's `tool_choice` by a mis-built assembler — previously the streamed ingress validated against the caller's sets while the blocking ingress used the committed ones, so a turn narrowed to `Specific(["add"])` would accept and dispatch a streamed call to any tool the assembler happened to list. Runs hand-driven through `AgentRun` commit no metadata and keep validating against their carried sets

- *(agent)* [**breaking**] the resumed-run drift policy is an argument at the resume entry point — `Agent::resume_run(run, ResumedToolDrift)` — instead of the `AgentDriver::allow_missing_resumed_tools()` builder, which `drive_run` reset and which therefore made a resumed run dispatch differently with nothing on the run to say so. `drive_run` keeps meaning `Reject`. The policy is not serialized with the run on purpose: the process that suspended it cannot know what registry a later one will have

- *(agent)* [**breaking**] persisted histories and `AgentRun`/`PromptResponse` JSON carry rig-core's tagged assistant content (`{"type": "text", ...}`); the untagged shape does not load — see rig-core's entry and MIGRATING. The flatten `Some({})` round-trip artifact is gone, so `is_empty_assistant_turn`'s classification is identical before and after a persist/restore with no special-casing

- *(agent)* [**behavior**] the streamed assembler counts the stream items it excludes from assembly that carry assistant content — replayed tagged assistant blocks (the tagged `AssistantContent` serialization is not a stream-item shape) and text items whose `additional_params` is malformed — and logs a single warning per turn, on every termination path, instead of one per stream item; `StreamedTurnAssembler::excluded_assistant_content` exposes the count, and the full decode-outcome contract is pinned by an enum-driven matrix test. A stream item whose text block carries stray sibling keys decodes as stream *text* — the text is assembled and only the stray keys drop
Expand All @@ -17,6 +21,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- *(agent)* [**breaking**] `PromptResponse` JSON serialized before the `content` field existed no longer deserializes — the missing-`content` reconstruction (and the serde shadow repr that carried it) is deleted and `content` is a required field; the JSON wire shape is unchanged

- *(agent)* [**breaking**] `ModelTurnOutcome` is `#[must_use]`. It carries `NeedsResolution`, which must be answered via `resolve_invalid_tool_call` before the run may advance; dropping it lets a hallucinated tool name surface two steps later as an unrelated "next_step called while an invalid tool-call resolution is pending". Code writing `run.model_response(turn)?;` now warns

- *(agent)* [**breaking**] the serialized `AgentRun` carries a `$schemaVersion` tag (`rig_agent::agent::run::RUN_SCHEMA_VERSION`, currently `"1.0"`) and a build reads only the version it writes. A payload from another version — including one written before versioning existed, which has no tag — fails deserialization with a named error instead of being silently reinterpreted. Runs suspended by an earlier build cannot be resumed by this one; drain them before upgrading

- *(agent)* [**breaking**] message content is a plain `Vec<T>`, following rig-core's removal of the non-empty container — every content list the agent constructs, inspects, or hands to hooks is a `Vec`, and the `OneOrMany` re-export is gone from this crate

- *(agent)* [**behavior**] a genuinely empty assistant turn no longer cancels the run and is no longer padded with a fabricated empty-text part — with the non-empty container gone the turn is honestly representable as an empty list, and `is_empty_assistant_turn` neutralizes it instead of the agent inventing content or failing a run that used to succeed
Expand All @@ -31,6 +39,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- *(agent)* add `AgentHook::on_reasoning_delta` with the Rig stream correlator, optional provider reasoning id, current fragment, and per-part aggregate; reasoning hooks share the existing observation-interest and stop-before-yield semantics used by other streaming deltas

- *(agent)* add `Agent::drive` / `Agent::drive_run`, returning the new public `AgentDriver` (with `DriveStep`, `TurnTools` and `TurnToolNames`): hand-drive the sans-IO `AgentRun` machine with the agent's own configuration while owning every side effect. The driver seeds the run from the agent (`default_max_turns`, `tool_choice`, output schema), yields each turn's fully configured completion request for the caller to send (or hand to a custom transport), pairs every pending tool batch with the exact registry snapshot that advertised it, assembles the model turn internally, and maintains the run's committed structured-output tool across turns exactly as the runner does. Dispatch is gated on the turn's advertised names, so a tool registered after a turn was prepared — or after a run was suspended — cannot be dispatched through a turn that never advertised it. Impossible `tool_choice`/tool-set combinations fail at prepare time with no provider round-trip, and such a failure costs nothing: the run advances only once a request exists, so it keeps its state and its turn budget and the step can be retried in place. The driver performs no provider IO and no dispatch of its own: hooks, memory, retrieval policy, and telemetry still run only under `AgentRunner`

- *(agent)* a driven run is resumable at **every** step boundary, not only while tool calls are pending. The turn's advertised tool names travel with the run — `TurnToolNames`, the serializable half of `TurnTools`, readable via `AgentRun::advertised_tools()` and constructible with `TurnToolNames::new` — so a run serialized after `DriveStep::SendRequest`, the natural suspension point for a queued or long-running provider call, resumes in another process and accepts its reply, validated against the set the request actually carried rather than whatever the resuming registry holds. The names describe the turn in flight and are cleared on every route back to preparing, so a run parked for a fresh call never reports a turn that is over. Resuming rebuilds the dispatch target by resolving those names against the registry directly, without re-running retrieval: a registered *dynamic* tool the turn used is found whether or not a fresh query would rank it, absence means the tool is genuinely gone (surfaced as an error, dispatched anyway by resuming with `Agent::resume_run` and `ResumedToolDrift::Dispatch`), and resume does not fail when a vector index is unavailable

- *(agent)* [**behavior**] the runner spends a turn only once its request exists. Its loop advanced the run first and then ran completion-call hooks, model selection and request preparation — three steps that can terminate the run — so a hook stop, a selection stop or a preparation failure consumed a turn against a call that never happened. It now advances with `AgentRun::advance`, peeks the inputs, and commits after preparation succeeds, which is the boundary `AgentDriver` already used. Not observable through `AgentRunner`'s own API, since every stop path ends the run either way; it matters because the two coordinators now agree on when a turn is spent, and because anything that later resumes or retries a runner-driven run would have inherited the discrepancy

- *(agent)* a committed model call records what the turn *resolved to*, not just what it advertised: `PreparedTurnMetadata` (tool names, the tool choice the request actually carried, the synthetic output tool) is committed atomically by `AgentRun::commit_model_call` and readable via `AgentRun::prepared_turn()`. Invalid-tool-call context and the `Skip` rejection now answer from the turn rather than the run's baseline, so a per-turn `RequestPatch` that overrides `tool_choice` — from a `CompletionCall` hook or from a hand-driven turn — can no longer leave the state machine disagreeing with the request that went out. `AgentRun::advertised_tools()` remains as shorthand for the names

- *(agent)* `AgentRun`'s peek/commit halves are public — `peek_model_call`, `commit_model_call`, `advance`, `is_preparing_request`, with `Advance` and `ModelCallInputs` — so any hand-driver whose request preparation can fail gets the same "a failure costs no turn" guarantee the driver has, not just rig's own. Preconditions are `PromptError` protocol violations rather than debug assertions, and the model-call budget is enforced at `commit_model_call`, where the turn is actually spent, as well as at `peek_model_call`

- *(agent)* add `AgentRun::rollback_model_call` / `AgentDriver::rollback_model_call`: a request that could not be sent, or whose reply is known to be lost, hands its turn back and returns the run to preparing, so the next step yields a **freshly prepared** request rather than replaying one whose tool snapshot has gone stale. Refunds the turn, preserves usage the provider already billed (including a streamed turn that reports usage after the failure), and counts attempts via `AgentRun::model_call_rollbacks`. Deciding to use it takes two answers, not one: `CompletionError::is_retryable` says whether a retry *could succeed*, and only the caller can say whether one is *safe* — a stream that died after the request was written is retryable and not replay-safe, and retrying it bills a second completion. Bounding attempts is the caller's job

- *(agent)* add `AgentDriver::next_step_with`, taking a per-turn preparation callback (`TurnPreparation`, `TurnPreparationContext`) that supplies the `RequestPatch` and optionally a model for the turn about to be prepared. This is how a hand-driven run gets what the runner gets from its `CompletionCall` and model-selection hooks — per-turn preamble, sampling parameters, `tool_choice`, `active_tools` narrowing, extra context, substituted history — and the callback runs *before* anything advances, so a decision that fails costs no turn. Per-turn configuration is an input, never driver state: a driver holding it would resume a serialized run with configuration the suspending process never recorded, and nothing on the run would say so. `AgentRun::tool_choice()` is public and a custom run's own choice reaches the provider, rather than governing only the run's internal decisions while the request carries the agent's baseline

- *(agent)* add driver-owned streamed ingress — `AgentDriver::record_stream_usage`, `accept_streamed_turn`, `resolve_streamed_invalid_tool_call` — so a streamed turn enters through the same object that prepared it and stays paired with its dispatch snapshot. Driving a custom streaming transport is a headline use for this type, and it previously required reaching around the driver into the run; the alternative, rebuilding the driver from `into_run()`, discards the per-turn snapshot cache and makes the driver treat a turn prepared in the same process as a resume, drift check included

- *(agent)* add `AgentDriver::max_invalid_tool_call_retries`, mirroring the runner's. `Agent::drive` seeds the budget at zero, so answering a `NeedsResolution` with `InvalidToolCallAction::Retry` needs this raised first
- *(tool)* add `ToolErrorKind::NotExecutable` (`ToolExecutionError::not_executable`): the tool is advertised to the model but not executable by the dispatcher — produced when dispatching the synthetic structured-output tool, whose call carries the final structured answer

- *(agent)* add opaque, cloneable `ModelHandle` values with by-value `ProviderCapabilities` snapshots, plus default replacement, per-run default override (`using_model`), and hook-driven per-call selection via `AgentHook::on_model_select`
- *(agent)* add run-local extractor default-model overrides used across retries

Expand Down
Loading
Loading