The effect corpus: golden logs replayed by every interpreter - #2449
Merged
Conversation
A run replays from its effect log only if every value the program mints
is a function of what it received. Tool-call handles for wires that carry
no id drew from fastrand: ToolCallId::for_provider(None) on every id-less
wire (gemini streaming and unary, ollama, the responses bridge), the two
hand-built message constructors, and rig-agent's streamed-repair
diagnostic. They now derive from the block that assembled the call
(streams: from_block) or the call's index in the response (unary:
from_wire_indexed / minted(index)), so two id-less calls in one response
stay distinct and a re-run of the same wire yields the same ids.
ToolCallId::{mint, new_or_mint} are gone; From<ToolResultContent> for
Message (which minted) is gone. Guard tests/core/no_random_ids.rs pins
fastrand to id.rs and the LSH index, and id::generate to the copilot and
chatgpt transport headers.
… the run A replayer that receives a different effect than it recorded answered with an `Internal` report, and for a tool the engine folded that report into a model-visible tool failure: the run went on, the model was asked again with the failure in its history, and the divergence surfaced — if at all — one record later as a model divergence. A divergence is now `ErrorKind::Divergence` (never retryable), the engine aborts a run at a tool outcome of that kind the way it does for a closed bus, and a tool call's whole payload is compared, so a call under a different dispatch context is a divergence too, not only a different name or arguments.
An effect log's header now carries what the replayer needs to refuse a different program before the first dispatch: the hook stack's names in registration order, the required row (every handler key the agent needs, with its family) and the bus policy the run was served under. The format is bumped to 2 with no tolerant decode. `Agent::check_replayable` refuses on a hook mismatch, on a required key the log never served (or served as another family) and on a bus policy mismatch, each with both sides shown. Hooks are named by their type's last path segment, so the same program in another crate — a suite replaying a golden its producer recorded — names the same hooks. `BusConfig` is serializable; the rig-effect-log dependency guard admits that one data type outside the replayer.
Six golden effect logs, each recorded by one root-suite test against a cassette transport with `record_effects()` (regenerated under `RIG_REGENERATE_GOLDEN=1`, never edited) and replayed in rig-verify by both interpreters — the bus engine and a hand driver of `AgentRun` — with no provider, tool or memory behind any key. The oracle is the record as data: kind, outcome and, for a stream recorded with its events, the event sequence, position by position. The corpus covers a plain completion, a conversation over memory, a streamed turn with its events, two tool calls served serially under `tool_concurrency(2)`, two tool-call turns on id-less (Gemini) calls, and an invalid tool call retried once by a hook. What replaying them found: - a handler installed after recording started (every tool the agent's registrar attaches) was missing from the header's handler list; the driver now describes each installation to the recorder; - a replayer serves the header's required row, so a tool the program advertised and the model never called is advertised on replay too; - folding a recorded completion back into a stream re-derived a tool call's provider id from its block and dropped its signature; the fold now carries the call's ids and signature verbatim. `AgentBuilder::record_effects_with_events` keeps a streamed dispatch's events on its record; `AgentBuilder::memory_handler` serves memory from any memory-family handler.
…efuse A guard in the root suite pairs the corpus with its producers: every committed golden is named by exactly one `golden_effects` site and every site names a committed golden, so no golden can only be hand-edited and no producer can only fail. In rig-verify, a golden whose run spec, hook stack, required row or bus policy differs from the agent's is refused before the first dispatch with both sides in the message — the required row is now compared as a whole, not only served-by — and a golden whose recorded request differs in one field fails the run at that record, naming the JSON pointer, and never reaches the record after it. The crate docs state the loop: record once against the cassette transport, commit, replay with no provider, fix forward, re-record live, never hand-edit.
A root guard scans every cassette and every effect-log golden: the exported secrets (each *_API_KEY, *_TOKEN and *_SECRET in the environment) compared in memory and reported by env name and path:line, never by value; tokens of the providers' documented key formats; and any sensitive header or query parameter whose recorded value is not the recorder's placeholder. It passes on the tree as committed and precedes the recordings that follow.
Four new recordings under each provider's `effect_corpus` cassette suite, each the producer of a golden effect log: an Anthropic tool-call turn; an Anthropic streamed turn whose consumer drops the stream after the first text delta, recorded as the `Cancelled` outcome the bus promises (the corpus prompt's risk 6, now with evidence); an OpenAI streamed turn with its events kept; and two OpenAI tool-call turns. The OpenAI Responses wire gives every call a `call_id` and an item id, so these two goldens are the proof that a dual provider id survives record, fold and replay verbatim. Two scenarios stopped at the two-recording cap and stay as findings: Claude Sonnet 4.6 would not emit a call to a tool it was told exists but was not offered (it called `add` once, then explained), so the mock-scripted recovery golden remains; Gemini 2.5 Flash would not emit two calls in one turn for the two-lookup program (sequential turns, then under forced function calling never a final answer), so the Anthropic serial golden remains the concurrent scenario. A golden is generated from the replayed cassette, never from the live recording: the cassette holds placeholders for provider ids and the golden must hold the same, or the first replay diverges. `golden_effects` refuses to write in record mode. The recovery hook moves to `tests/common` so every producer that records a recovery names the one type.
Four rows in the replay table — the Anthropic tool-call turn, the cancelled stream, and the two OpenAI goldens — each replayed by the bus engine and the hand driver. A cancelled record is answered by the replayer as the cancel it was, so on replay the consumer sees the cancel as its first item and the log matches the golden without a delta ever arriving. The OpenAI rows hold on the third wire without a change to the fold.
The rig-verify crate docs state how a scenario enters the corpus: record the cassette on the producer's exact filter, then generate the golden in replay mode so it holds the cassette's placeholders.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The effect corpus — golden logs replayed by every interpreter (stacked on #2443)
Branch
feat/effect-corpus, anchored to355266ccd(the head of #2443). Executesmany_rigs/effect-bus/effect-bus-corpus-pr.md. Five commits, each gated on its own:fix(core)!: minted tool-call ids are deterministic per responsefix(effect-log)!: a replay divergence is its own error kind and fails the runfeat(effect-log)!: the log header names the programtest(verify): the effect corpustest(verify): goldens and their producers are paired; stale goldens refuseWhat this PR proves
An agent program recorded once against a cassette transport (real provider bytes over HTTP) produces an effect log that both interpreters of the program — the bus engine and a hand driver of
AgentRun— reproduce from the log alone: no provider, no tool, no memory behind any key. Reproduce means the record as data — every dispatch's kind, outcome and, for a stream recorded with its events, its event sequence — position by position. Six such goldens are committed undercrates/rig-verify/fixtures/; a change in what a program asks or is answered fails the replay naming the record and the JSON pointer of the difference, and a golden of another program is refused before the first dispatch.Anchor-table findings (verified before writing code)
divergence()on the anchor already compared the whole payload as data for every family except tool calls, which compared name and args only (the prompt's table said "family alone"; that row was stale). E1 compares the tool context too and changes nothing else.Internalreport became a failed tool result the model saw, the history changed, and the model replayer diverged one record later. E1 addsErrorKind::Divergenceand the engine fails the run at the tool record.SyntheticIds→BlockId::Minted); the random sources wereToolCallId::for_provider(None)(every id-less wire: gemini streaming and unary, ollama, the responses-API bridge), the two hand-built message constructors and rig-agent's streamed-repair diagnostic. E0 replaces all of them.Theory, and where each obligation landed
divergencecompares every family's payload as data, tool context included (E1)LogHeader::required, compared as a whole and against the served handlers (E2, E4)no_random_ids); hooks are program, tools are record (E2 header names the hooks; E3 replays tools)record_effects_with_events()keeps a streamed dispatch's events; the oracle compares the sequence (E3)RIG_REGENERATE_GOLDEN=1, paired with one producer each, stale goldens refuse (E3, E4)Rulings (from the prompt, as applied)
tool-<index>/ the assembling block), not cross-turn uniqueness — providers without ids never echo them and rig matches results to calls within a turn.ErrorKind::Divergence, never retryable, and fails the run at its record.Migration
E0
ToolCallId::mint()andnew_or_mint()are gone.ToolCallId::minted(index)(tool-<index>),ToolCallId::from_block(&BlockId),ToolCallId::new_or_minted(id, index),ToolCallId::for_provider_or(provider, minted).ToolCall::from_wire_indexed(id, index, function)for theindex-th call of a response whose wire may omit ids;from_wireisfrom_wire_indexed(_, 0, _).From<ToolResultContent> for Messageis deleted (it minted a random id for a hand-built result; useMessage::tool_result(call, name, content)).tool-<n>); a unary Gemini response numbers its id-less calls in wire order. Two id-less calls in one response stay distinct; a re-run of the same wire yields the same ids. Cross-turn uniqueness is not promised: a test that scanned a whole history for a result by id alone (generate_sessions.rs) now takes the first result after its call.tests/core/no_random_ids.rs:fastrandonly inid.rsand the LSH index;id::generateonly from the copilot and chatgpt transport headers; nofn mint()inmessage.rs.E1
ErrorKind::Divergence(never retryable). The replayer reports it;ToolDispatchAbort::Failednow coversDivergencealongsideBusClosedandHandlerUnavailable, so a diverging tool fails the run withPromptError::Reportat the tool record instead of feeding the model a failed result.divergencecompares a tool call's whole payload (name, args, context).E2
EFFECT_LOG_FORMAT = 2.LogHeadergainshooks: Vec<String>,required: BTreeMap<HandlerKey, EffectFamily>,bus: Option<BusConfig>. A format-1 log is refused (check_header), not decoded.HookStack::names(); hooks are named by their type's last path segment (generics kept), so the same program in another crate names the same hooks.Agent::required_row(),Agent::bus_config();check_replayablerefuses on hook, required-row and bus-policy mismatch with both sides shown. The bus policy is checked by an agent that owns its bus; over a host's bus the policy is the host's andbus_config()isNone.BusConfigderivesSerialize/Deserialize; the rig-effect-log dependency guard admitsrig_bus::BusConfigoutsidereplay.rsas the one data type.EffectLogRecorder::set_program(hooks, required, bus);ToolServerHandle::retrieval_keys().E3
AgentBuilder::record_effects_with_events()— record and keep a streamed dispatch's events on its record.AgentBuilder::memory_handler(impl Serve)— serve conversation memory from any memory-family handler (a replayer) under the agent's memory key.EffectLogReplayer::for_key/for_logserve the header's required row, not only recorded keys: a tool the program advertised and the model never called is advertised on replay from the definition the recorded requests hold and answers any dispatch with a divergence.Recorder::handlersis now called for every handler installed after recording started (every tool the agent's registrar attaches), soLogHeader::handlerslists what actually served.BlockId::from_minted_name.The corpus
anthropic_completion_smoketests/providers/anthropic/cassette/agent.rscompletion_smoke_effect_log_is_the_golden_fixtureagent/completion_smokeanthropic_memory_conversationtests/providers/anthropic/cassette/agent.rsmemory_conversation_effect_log_is_the_golden_fixtureagent/completion_smokeanthropic_streaming_with_eventstests/providers/anthropic/cassette/streaming_tools.rsstreaming_tools_effect_log_is_the_golden_fixturestreaming_tools/…anthropic_concurrent_tools_serialtests/providers/anthropic/cassette/streaming_tools.rsconcurrent_tools_serial_effect_log_is_the_golden_fixturestreaming_tools/streaming_tool_concurrency_…serial_per_handler: true,tool_concurrency(2)gemini_tool_call_turnstests/providers/gemini/cassette/hook_stress.rstool_call_turns_effect_log_is_the_golden_fixturehook_stress/streaming_lifecycle_…tool-0each), the scenario E0 makes possiblemock_invalid_tool_call_recoverytests/core/golden_recovery.rsinvalid_tool_call_recovery_effect_log_is_the_golden_fixtureScenario substitutions from the prompt's list, each because no cassette of the named shape exists and no new HTTP recordings were allowed: the tool-call-turn scenario is gemini's
hook_stressprogram rather than an anthropic one; the concurrent-serial scenario is anthropic's two-tool stream rather than gemini's; the streamed-events scenario is anthropic rather than openai; memory runs over thecompletion_smokecassette; the invalid-call recovery has no engine-driven cassette anywhere in the corpus and is scripted from the mock model.Replayed in
crates/rig-verify/tests/golden_replay.rs: twelve tests, each golden by the bus engine and by the hand driver. The hand driver stepsAgentRun, prepares each request withprepare_request, streams or completes over theModelHandle, assembles streamed turns withStreamedTurnAssembler, dispatches tools overToolHandle::callwith the engine's own result shaping (rig_core::transcript::tool_result_output), loads and appends memory over theMemoryHandle, and resolves the invalid call with the same retry the hook returns.What replaying the corpus found (fixed in E3)
BusDriver::record_todescribed the handlers registered at that moment; every tool the agent's registrar attaches arrives later, soLogHeader::handlersheld only the model (and memory). The driver now describes each installation to the recorder as it installs it.rig_core::servere-emits a unary answer as events for a stream consumer; it published every tool call under a wire block keyed by rig's id, so an id-less (Gemini) call came back with a provider id it never had, and an Anthropic single-id call came back with anitem_id. The fold now carries the call's ids verbatim (a mintedtool-<n>as the minted block it names) and itssignature/additional_params, which it also dropped.The cassette matcher and minted ids (risk 1)
E0 changes what gemini and ollama see on the wire for id-less calls (
tool-<n>where a 21-character random id was). The cassette corpus still matches because the matcher never compared those fields literally:tests/common/cassettes.rsreplaces the value of every id-bearing JSON field —id, any*_id, any*Id(in_id_field_position), plus the explicitGENERATED_ID_KEYS(call_id,item_id,tool_call_id,tool_use_id, …) — with a numbered placeholder on the recorded body and on the live body before comparing. Byte-identical requests were never possible for those fields; the rule is the proof, and the gemini, ollama and llamacpp cassette suites pass unchanged.Churn contract (risk 2)
A golden holds every
CompletionRequestthe program made. Aschemarsbump, a preamble edit or a tool-definition change re-records the affected scenarios — the same contract as cassettes. Re-recording is one env var (RIG_REGENERATE_GOLDEN=1 cargo test -p rig --test anthropic --test gemini --test core -- golden_fixture), and a stale golden refuses instead of passing.Remaining risks, stated
anthropic_streaming_with_events.tool-0). Rig never does one; the one test that did was corrected.Not in this PR
No new HTTP recordings; no openai golden (no engine-driven openai cassette with events); MIGRATING/CHANGELOG untouched per the repo policy for this series.
Gates
Run on the branch head
a7aef7051(2026-09-03):cargo fmt --all -- --check,cargo clippy --workspace --tests --examples(-D warnings),cargo check -p rig-core --target wasm32-unknown-unknown,cargo test --workspace --no-fail-fast— all clean. E1 and E2 were additionally checked in isolation (cargo check --workspace --testsat each commit).Follow-up: the corpus records its own scenarios (commits F0–F3 on this PR)
Executes
many_rigs/effect-bus/effect-corpus-followup-pr.mdon top of E0–E4. New HTTP recordings were allowed for this part; nothing existing was re-recorded.test(core): committed fixtures never hold a keytest(providers): the corpus scenarios, recorded as namedtest(verify): the corpus replays its recorded scenariosdocs(verify): the recording loopKey handling, as followed
Record mode is
RIG_PROVIDER_TEST_MODE=record; the recorder reads a key from its env name only in that mode and writes none of it (allow-listed request headers, scrubbed sensitive headers and Gemini'skeyparameter, placeholdered ids). Every recording ran on the producer's exact test filter. No key value was printed, passed on a command line, or written anywhere. F0's guard (tests/core/fixtures_hold_no_key.rs) scans every committed cassette and golden for the exported secrets (compared in memory, reported by env name andpath:line), for the providers' documented key shapes, and for a sensitive header or parameter carrying anything but the placeholder; it was proven to bite on a planted token before the first recording and passes on the tree as committed.The corpus now
Ten goldens, three providers, each replayed by both interpreters (twenty replay tests):
anthropic_completion_smokeanthropic/cassette/agent.rsanthropic_memory_conversationanthropic/cassette/agent.rsanthropic_streaming_with_eventsanthropic/cassette/streaming_tools.rsanthropic_concurrent_tools_serialanthropic/cassette/streaming_tools.rsgemini_tool_call_turnsgemini/cassette/hook_stress.rsmock_invalid_tool_call_recoverycore/golden_recovery.rsanthropic_tool_call_turnanthropic/cassette/effect_corpus.rsanthropic_cancelled_streamanthropic/cassette/effect_corpus.rsopenai_streaming_with_eventsopenai/cassette/effect_corpus.rsopenai_tool_call_turnsopenai/cassette/effect_corpus.rsAll substitutes from the first part stay.
What the recordings proved
call_idand an item id; both goldens assert every call carries both, and both replay on the bus engine and the hand driver with no change to the E3 fold. Third wire, same three findings holding.Cancelled(risk 6 from the corpus prompt, previously without evidence). The first attempt, a ten-line answer, completed before the drop landed; the second, a 600-word essay, records one completion whose outcome isCancelledwith the bus's own message. On replay the replayer answers the record as the cancel it was, so the consumer sees the cancel as its first item and the log still matches.toolu_…/msg_…/req_…ids while its cassette held placeholders, and diverged on the first replay.golden_effectsnow refuses to write in record mode; the loop is record on the filter, then regenerate in replay. This rule was missing from the corpus prompt and is now in the crate docs.Two scenarios stopped at the two-recording cap (ruling 2)
multiplytool exists while onlyaddis offered, calledaddon the first attempt and on the second calledaddthen declined to answer a product with an addition tool. It never emits a call to a tool that is not in the request. The mock-scripted recovery golden stays as the scenario; its producer's hook moved totests/common/goldens.rsso a future live producer names the same type.functionCallingConfig.mode = ANYand an explicit "in parallel" instruction it called tools until the turn budget ran out and never answered. The Anthropic serial golden stays as the concurrent scenario; the id-less two-call case is not in the corpus, and E0's per-response numbering is pinned by the unit test and the sequential Gemini golden instead.Gates
Run on
0e8ea42a9(2026-09-03), in replay mode:cargo fmt --all -- --check,cargo clippy --workspace --tests --examples(-D warnings),cargo check -p rig-core --target wasm32-unknown-unknown,cargo test --workspace --no-fail-fast— all clean.