Echo Anthropic thinking-block signatures on replay (#821) - #823
Merged
Conversation
Anthropic signs every extended-thinking block and schema-rejects a replayed block without the signature (400: thinking.signature: Field required), so the first in-process replay — the tool-use continuation — failed on every reasoning-on turn that called a tool. - ContentBlock::Thinking gains optional signature + signatureModel fields; both default/skip so legacy session JSON round-trips byte-identically (pinned by test). - rig_stream captures the signature from the complete Reasoning event, including through the dirge-zf35 delta fold (replace and append arms), never clobbering a captured signature with None. - The stream factory stamps the minting model onto signed blocks (the capture layer does not know the model identity). - Replay attaches the signature only for the anthropic provider when the request's model matches the minting model and reasoning is on this turn; otherwise the block is dropped (unsigned or foreign-signed blocks are both rejected). Every other provider keeps the unsigned echo byte-identical to before.
Collaborator
|
awesome thanks! |
This was referenced Aug 27, 2026
yogthos
pushed a commit
that referenced
this pull request
Sep 2, 2026
Same defect as #821, one field over — the gap #823 disclosed when it scoped itself to Anthropic thinking blocks. Gemini mints a `thought_signature` on every `functionCall` part and rejects a replayed call without one ("Function call is missing a thought_signature in functionCall parts"), so the first tool-using turn in any Gemini session failed on the replay. rig hands the value over on capture (`gemini/completion.rs:489`) and sends it straight back out again (`impl From<message::ToolCall> for Part`); dirge discarded it at capture and hardcoded `signature: None` at replay, so the round trip existed end to end and only dirge broke it. Mirrors #823's mechanism, which was built for exactly this: 1. `ContentBlock::ToolCall` gains `signature` / `signatureModel`, both skipped when absent, so a call without one serializes byte-identically to the legacy shape and saved sessions round-trip unchanged. 2. Capture keeps `ToolCall.signature`; the factory stamps the minting model, the same pass #821 added for thinking blocks, now covering both block kinds. 3. Replay attaches the signature when the request's model is the one that minted it. `signature_model` stops being a single-provider guard and becomes the cross-provider one it was built to be. Missing-signature case: a tool call cannot be dropped alone — its paired `tool_result` would be orphaned, which every backend rejects — so the pair is dropped together and the turn is logged with the count. That costs one step of history against a turn that could not be sent at all; sending it unsigned is the bug, and refusing the turn would hard-block every pre-existing Gemini session. Every other provider keeps today's unsigned replay, byte-identical on the wire.
yogthos
pushed a commit
that referenced
this pull request
Sep 2, 2026
Same defect as #821, one field over — the gap #823 disclosed when it scoped itself to Anthropic thinking blocks. Gemini mints a `thought_signature` on every `functionCall` part and rejects a replayed call without one ("Function call is missing a thought_signature in functionCall parts"), so the first tool-using turn in any Gemini session failed on the replay. rig hands the value over on capture (`gemini/completion.rs:489`) and sends it straight back out again (`impl From<message::ToolCall> for Part`); dirge discarded it at capture and hardcoded `signature: None` at replay, so the round trip existed end to end and only dirge broke it. Mirrors #823's mechanism, which was built for exactly this: 1. `ContentBlock::ToolCall` gains `signature` / `signatureModel`, both skipped when absent, so a call without one serializes byte-identically to the legacy shape and saved sessions round-trip unchanged. 2. Capture keeps `ToolCall.signature`; the factory stamps the minting model, the same pass #821 added for thinking blocks, now covering both block kinds. 3. Replay attaches the signature when the request's model is the one that minted it. `signature_model` stops being a single-provider guard and becomes the cross-provider one it was built to be. Missing-signature case: a tool call cannot be dropped alone — its paired `tool_result` would be orphaned, which every backend rejects — so the pair is dropped together and the turn is logged with the count. That costs one step of history against a turn that could not be sent at all; sending it unsigned is the bug, and refusing the turn would hard-block every pre-existing Gemini session. Every other provider keeps today's unsigned replay, byte-identical on the wire.
yogthos
added a commit
that referenced
this pull request
Sep 2, 2026
Same defect as #821, one field over — the gap #823 disclosed when it scoped itself to Anthropic thinking blocks. Gemini mints a `thought_signature` on every `functionCall` part and rejects a replayed call without one ("Function call is missing a thought_signature in functionCall parts"), so the first tool-using turn in any Gemini session failed on the replay. rig hands the value over on capture (`gemini/completion.rs:489`) and sends it straight back out again (`impl From<message::ToolCall> for Part`); dirge discarded it at capture and hardcoded `signature: None` at replay, so the round trip existed end to end and only dirge broke it. Mirrors #823's mechanism, which was built for exactly this: 1. `ContentBlock::ToolCall` gains `signature` / `signatureModel`, both skipped when absent, so a call without one serializes byte-identically to the legacy shape and saved sessions round-trip unchanged. 2. Capture keeps `ToolCall.signature`; the factory stamps the minting model, the same pass #821 added for thinking blocks, now covering both block kinds. 3. Replay attaches the signature when the request's model is the one that minted it. `signature_model` stops being a single-provider guard and becomes the cross-provider one it was built to be. Missing-signature case: a tool call cannot be dropped alone — its paired `tool_result` would be orphaned, which every backend rejects — so the pair is dropped together and the turn is logged with the count. That costs one step of history against a turn that could not be sent at all; sending it unsigned is the bug, and refusing the turn would hard-block every pre-existing Gemini session. Every other provider keeps today's unsigned replay, byte-identical on the wire. Co-authored-by: Yogthos <yogthos@gmail.com>
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.
Fixes #821.
Problem
Anthropic mints a cryptographic
signatureon every thinking block and requires it echoed back verbatim on replay. dirge stored only the text, so the first replay — the tool-use continuation request — failed:Reproduced 100% with any Anthropic entry at reasoning-on plus a single tool call. In an interactive session the bad block replays on every subsequent prompt, wedging the session.
Cause
Three places, all losing the same value:
ContentBlock::Thinking { text: String }had nowhere to store it.ReasoningContent::Text { text, .. } => Some(text.clone())— the..is the signature.Reasoning::new(text), which hardcodessignature: None.rig 0.41 already carries the field and offers
Reasoning::new_with_signature, so no upstream change was needed.Fix
ContentBlock::Thinkinggains two optional fields, both#[serde(default, skip_serializing_if = "Option::is_none")]:signature— the provider-issued signature, extracted at capture from the trailing completeReasoningblock (bothdirge-zf35fold arms adopt it, guarded so a captured signature is never clobbered by a laterNone).signature_model(signatureModelon the wire) — the model that minted it, stamped in the factory, which is the layer that knows the model identity.Replay is governed by a policy computed once per request:
Reasoning::new(text), byte-identical to before;new_with_signatureiff a signature is present andsignatureModelequals this request's model and reasoning is enabled this turn; otherwise drop the block (the rest of the message survives, mirroring the existing openai path).Why the model has to be recorded
provider_name/model_nameat the factory are not sufficient: serialized history carries no model identity, and/model, escalation and subagents switch models in-process. A signature replayed to a different model is rejected withInvalid signature in thinking block— so a naive always-attach fix trades this bug for a different 400. Recording the minting model makes the decision exact.Dropping is safe in every case that reaches it: attach happens whenever the model matches and reasoning is on, which is precisely the tool-use continuation case, so a same-model continuation never loses its thinking block. The remaining paths are an unsigned legacy block (previously a guaranteed 400), a foreign model's signature (likewise), or reasoning off — where the echo isn't required at all.
Back-compat
default+skip_serializing_ifmeans legacy{"type":"thinking","text":"..."}deserializes, and a block with both fieldsNonere-serializes to exactly that byte shape, so existing saved sessions round-trip unchanged. Pinned in both directions bylegacy_thinking_block_json_round_trips_unchanged.Tests (9 new)
Legacy round-trip pin (both directions); signature captured from the complete-block path and from both fold arms; replay attaches on model match; replay drops for a foreign model, an unknown model, reasoning-off, and unsigned; non-Anthropic replay byte-identical; the stamp touches only signed, unstamped blocks.
Full suite 5414 passed, 0 failed, 1 ignored.
clippy --all-targets -- -D warningsclean,fmt --checkclean.End-to-end, with a negative control
Same config and prompt, Opus 4.6 at
effort: medium, tool call in the loop:upstream/mainbinary →400 ... messages.3.content.0.thinking.signature: Field requiredThe control confirms the repro was live in those exact runs (thinking engaged, block present at
content.0), and the patched runs completing with thinking on and a tool_use in flight means the block was attached with a valid signature — a dropped block would have produced a different 400, so this is not passing by dropping.Known limitations / out of scope
messagestable storescontent TEXT, and the resume path constructssignature: None, so cross-process--sessionreuse carries no signatures; those blocks are dropped from Anthropic replays. Safe (they previously 400'd), but it means a resumed session loses its reasoning context. No schema change made here.provider::buildpasses the config entry alias asprovider_namefor those stream fns and canonicalizes only openai, so an Anthropic escalation route configured under an alias falls to the unsigned path and still 400s exactly as before. Fixing that means canonicalizing at the dispatch site — separate change.--no-default-features --features no-plugin(no janet toolchain on this host — Build fails on Linux #712), so the mechanical field additions inplugin_hooks_tests.rsare verified only by CI with default features.rig_stream_factory.rsbut in disjoint regions (~40 lines apart); a rebase should be clean or trivial whichever merges first.