Skip to content

refactor!: assistant content is tagged and provider extras are a named field - #2277

Merged
gold-silver-copper merged 11 commits into
mainfrom
refactor/tagged-assistant-content
Aug 11, 2026
Merged

refactor!: assistant content is tagged and provider extras are a named field#2277
gold-silver-copper merged 11 commits into
mainfrom
refactor/tagged-assistant-content

Conversation

@gold-silver-copper

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

Copy link
Copy Markdown
Contributor

Follow-up to #2273/#2276, executing the root fix for the serde bug class both hit: AssistantContent was #[serde(untagged)] with provider extras carried by a #[serde(flatten)] — a design 0 of 6 surveyed reference frameworks share (all six tag their content parts; none flatten vendor extras into the part's own key namespace), and one rig was internally inconsistent about, since UserContent has always been tagged.

No legacy or backwards-compatibility shims — maintainer direction. The tagged form is the only form. This breaks released data: 0.41 serialized assistant content untagged (and wrote block extras flattened), so 0.41-persisted histories/runs do not load — MIGRATING carries per-variant tag-insertion and extras-re-nesting recipes instead of a decode fallback. The no-fallback decision is pinned by test.

What changed

  • AssistantContent is #[serde(tag = "type", rename_all = "lowercase")], matching UserContent. Serialized shapes:
    {"type": "text", "text": "hello"}
    {"type": "toolcall", "id": "call_1", "function": {...}}
    {"type": "reasoning", ...}   {"type": "image", ...}
  • The five content-block flattens (Text, Image, Audio, Video, Document) become named additional_params fields with deny_unknown_fields. Two defect classes die at the root:
    • a stray "type":"text" key can no longer be flatten-captured and replayed to providers (the cleanup: post-Vec-migration precision and the pre-Vec serde accommodations go #2276 recipe bug) — now it's either the tag or a decode error;
    • an absent field round-trips as None instead of the flatten's Some({}) artifact, so is_empty_assistant_turn drops its has_no_annotations shim and live vs restored runs classify identically with no special-casing (round-trip test now asserts None).
  • The one wire type that borrowed the rig-level Text — OpenAI Responses output_text — gets its own OutputText wire struct preserving annotations/sibling keys verbatim for value-equal replay. Wire passthrough belongs to wire types.
  • MIGRATING: new section with the tagged shapes; the hand-migration recipe updated to the tagged form.

Cassette churn: zero, proven not assumed

Providers serialize their own wire types into requests, not rig's domain types; the one borrowed-type case (Responses output_text) was decoupled in this PR. The full recorded suite passes under the new tags: modified cassettes 0, added 0.

Verification

  • fmt clean; clippy --all-targets --all-features -D warnings clean
  • cargo nextest run --all-features --no-fail-fast --test-threads=1: 3557/3557 (231 skipped) — count unchanged from base: the three affected tests were updated in place (tag-required pin replaces the stray-key-capture pin 1:1)
  • cargo nextest run -p rig-derive: 43/43; doc tests and rig-vertexai clean
  • Gates: #[serde(flatten in completion/message.rs → 0; untagged in completion/message.rs → 0

…d field

AssistantContent gains #[serde(tag = "type", rename_all = "lowercase")],
the same scheme UserContent always had — rig was the only framework of
seven surveyed (itself included, on the user side) whose content parts
were not discriminated. The tag is required: there is no fallback to the
tagless shape, which was never in a release, and a test pins the
rejection so removing the requirement is a decision, not an accident.

The five content-block flattens (Text, Image, Audio, Video, Document)
become named additional_params fields with deny_unknown_fields. Two
defect classes die at the root: a stray key can no longer be silently
captured and replayed to providers (the round-2 recipe bug), and an
absent field round-trips as None instead of the flatten's Some({})
artifact — so is_empty_assistant_turn drops the has_no_annotations shim
that papered over restored runs classifying differently from live ones,
and the round-trip test now asserts None outright.

The one wire type that borrowed the rig-level Text — the OpenAI
Responses output_text arm — gets its own OutputText wire struct,
preserving annotations and future sibling keys verbatim for value-equal
replay. Wire passthrough belongs to wire types; the domain type no
longer carries it.

Zero cassettes change: providers serialize their own wire types into
requests, and the full recorded suite passing under the new tags is the
proof, not an assumption.
0.41 shipped the untagged AssistantContent, so the claim that the bare
shape 'was never in a release' was false in four places — MIGRATING, the
changelog, the enum doc, and a test comment. All four now say what is
true: 0.41-persisted assistant content does not load, and MIGRATING
carries the tag-insertion recipe per variant. The same honesty for the
other two released-data casualties: 0.41 user-content blocks with
flattened extras (anthropic document title/citations) fail under
deny_unknown_fields — re-nest them under additional_params, recipe
included — and a 0.41-serialized stream item with flattened text extras
decodes as StreamedAssistantContent::Unknown and is dropped from
assembly rather than erroring, which is now documented instead of
implied loud. The deny guarantee is scoped to the five block structs,
with ToolCall/Reasoning's deliberate tolerance named.

OutputText.extras adopts the file's own artifact-free idiom
(flatten + default + skip_serializing_if = Map::is_empty), so a decoded
bare block equals a request-assembled one — the Some({}) inequality this
PR deletes elsewhere no longer sneaks back in through the wire type. The
streaming parts merge drops empty-object metadata alongside null,
making the normalization boundary explicit for out-of-tree providers.
The round-trip test preamble stops describing the deleted flatten
behavior, and the resume-identity test compares messages directly now
that the serialized-form detour has nothing to normalize away.
…he docs diagnose instead of misdirect

message::non_empty_params is the one home for the 'null and {} mean no
extras' rule: the five content-block deserializers route through it
(deserialize_with), so a mechanically migrated "additional_params": {}
— the shape MIGRATING's own re-nesting recipe invites — classifies
exactly like an absent field, and Some(null)'s round-trip flip is gone.
The streaming accumulation guard and anthropic's document-params builder
call the same predicate instead of hand-rolling their thirds of it, and
the round-trip test pins both empty spellings decoding to None.

MIGRATING quotes the error 0.41 data actually produces — missing field
type, the internally-tagged wording — instead of an untagged-enum
message that greps to nothing. The re-nesting recipe covers tool-result
nested blocks (ToolResultContent reuses the strict structs), and the
streaming section says the Unknown fall-through is an ongoing property
of stray sibling keys, not a 0.41-only hazard. The one silent spot gets
loud: the assembler logs a warning when an excluded Unknown payload
carries a text key, since that exclusion loses transcript content.
…e promises match the code

params_carry_data is the read-side spelling of the one rule (None, null,
and {} all mean no extras); the classifier judges annotation through it,
so an uncanonicalized in-memory Some({}) — constructible out-of-tree,
the fields are public — classifies exactly like None. The serialize side
closes too: skip_serializing_if routes through the same rule, so empty
params never reach the wire and live/restored classification agrees for
every value, not just the canonical one. Both directions are pinned in
rig-core next to the contract, and the rig-agent round-trip test covers
the in-memory case.

The assistant migration recipe gains the re-nesting step the tag alone
does not cover (0.41 flattened anthropic citations onto assistant text
as top-level siblings). The excluded-text warning fires at both
exclusion points — the streaming normalizer and the agent assembler —
with the condition narrowed to text-with-no-type (provider-native
unmodeled items always carry their wire tag, so the diagnosis is no
longer hard-coded onto them), and MIGRATING scopes the promise to rig's
assembly points instead of implying universality. The {}-TextStart
ordering change is disclosed in the changelog.
The poll_next warn is deleted: everything reaching that arm is a live
wire frame a provider adapter chose not to model — adapters warn about
those themselves, and gemini interactions frames discriminate on
event_type, so the 'always carries a type tag' premise was false there —
while a persisted item that failed the strict Text decode is created by
consumer-side serde and never re-enters the live stream. That also
un-duplicates the byte-identical warn the agent path emitted twice per
item. The agent assembler, the one rig component that ingests replayed
events, keeps the warning, and MIGRATING/CHANGELOG scope the promise to
it instead of claiming coverage at points that cannot see the hazard.

ToolOutput::as_text judges annotation through params_carry_data like
every other reader, so a publicly constructed Some({}) no longer makes
the read side call a block annotated while the write side serializes it
bare. And the changelog entry gets its missing sentence break.
@gold-silver-copper
gold-silver-copper force-pushed the refactor/tagged-assistant-content branch from f9ae7b5 to 26ac17c Compare August 10, 2026 17:13
…all speak params_carry_data

- The Unknown-arm warn now fires for tagged {"type":"text"} items too (the
  exact shape MIGRATING teaches), extracted as unknown_payload_loses_text and
  pinned by tests; its wording no longer misattributes tagless unmodeled frames.
- assistant_text_items_from_choice judges annotation by params_carry_data, not
  is_some(), restoring live/restored round-trip agreement for Some({}).
- MIGRATING covers flattened extras on assistant image blocks (openrouter) and
  the widened warn condition.
- non_empty_params doc states the real reader contract (params_carry_data)
  instead of promising plain is_none().
…cts or errors

- The lost-content heuristic (now unknown_payload_loses_assistant_content)
  also fires for rig's own toolcall/reasoning/image tags — a replayed tagged
  assistant block is not a stream-item shape, and a silently dropped tool
  call desyncs the turn.
- additional_params must decode as an object or null: a mis-migrated [] or
  bare string is a loud decode error (deny_unknown_fields doctrine), not a
  phantom annotation no extractor can read. MIGRATING says so.
- The two anthropic citation guards route through message::non_empty instead
  of hand-rolling empty-means-None.
- The agent round-trip test pins classification only, citing rig-core's
  empty_params_canonicalize_to_none_in_both_serde_directions for the serde
  mechanics.
…wn keys tolerate instead of reject

The four-helper convention family (non_empty_params / params_carry_data /
params_carry_no_data / params_as_none_when_empty) is replaced by
message::AdditionalParams — a newtype that is a non-empty JSON object by
construction. Some always carries data (plain is_none/is_some everywhere),
non-objects are unrepresentable in memory so serialization can never emit
what deserialization rejects, and the wire shape is unchanged (transparent).
Grep-proofs: the helper family has zero references; every remaining
additional_params.is_some()/is_none() is on the newtype or a
ToolCall/request-level Option<Value> outside the invariant.

deny_unknown_fields drops from the five content blocks (audit: the only
remaining uses are rig-candle protocol/config structs, the legitimate kind).
The doctrine, stated on AdditionalParams: known field wrong shape -> loud;
unknown key -> ignored; unknown tag -> loud. Zero of nine surveyed
frameworks forbid unknown fields on durable conversation types; vercel
shipped that and logged four our-validator-rejected-what-we-wrote regrets.
Fallout: stray-keyed and tagged text stream items now decode as stream text
(text assembled, stray keys dropped) instead of vanishing into Unknown.
Migration loudness moves to the opt-in boundary:
message::keys_lost_in_round_trip is the recipe MIGRATING now pins.

The assembler counts excluded replayed blocks (tagged toolcall/reasoning/
image, judged by the AssistantContent decoder itself, not a mirrored tag
list) and warns once per turn; excluded_assistant_content exposes the count.
OpenAI Responses output_text extras (annotations with data) now survive into
history under additional_params["openai_responses"] and replay only
through the Responses serializer — cassettes pass unchanged, proving the
wire bytes didn't move. Enum-wide additional_params() accessors (or-pattern
collapse, non-carrying variants named) and exhaustive destructures at the
touched conversion boundaries round out the discipline.
…ed extras cannot shadow the wire

- keys_lost_in_round_trip no longer reports the blessed
  "additional_params": {} spelling as a loss — a missing key whose original
  the loader canonicalizes to absence (empty object; null was already
  skipped) is not a loss, so a correctly migrated history passes its own
  verification.
- OutputText::from_message_text filters the reserved text/type keys before
  the serde flatten (a duplicate JSON key would let persisted history data
  shadow the block's real text or tag on the wire — probe-verified
  last-wins hazard) and warns instead of silently dropping a non-object
  openai_responses value. Annotation-only empty text blocks now survive
  request assembly at both sites: only a bare empty block is skipped.
- The per-turn lost-content warning moves into Drop, so it survives every
  termination path — finish, stream errors, hook cancellation, abandonment —
  not just the happy path (finish now mem::takes its fields).
- unknown_payload_loses_assistant_content probes via &Value as Deserializer:
  no per-item deep clone on the Unknown path.
- The null/{}/object canonicalization policy has one home: both serde
  routes and the test fixture delegate to try_from_value, and the direct
  Deserialize impl's error text no longer promises "(or null)" while
  rejecting null. AdditionalParams::merge delegates top level and nested
  maps to one merge_maps routine.
…ks one gate

Contract completions, not scenario patches — with the red-green proof the
matrix has teeth: decode_outcome_matrix_is_total_and_no_shape_is_silent
failed on exactly the MalformedParamsText cell (counted 0, mandated 1)
before the predicate fix, and on nothing else. The matrix is enum-driven
(wildcard-free expected() so a new shape class cannot compile without a
mandated outcome; coverage and vacuity asserts so it cannot pass
vacuously), and it surfaced one more truth while being written: a
provider-native frame carrying a string text key is assembled — the
documented tolerance tradeoff — now its own named row.

The replay gate is one primitive: AdditionalParams::wire_extras /
into_wire_extras (capture unconditional at ingest, replay only the key a
wire owns, never a hard error at serialization time). Both Responses
empty-text guards, from_message_text (now by value — no per-call extras
clone), and openrouter's response-image reader all go through it; the
guards also encode deliverability — the id-less AssistantInput form cannot
carry extras, so an annotated empty block replays only when the
id-carrying form is available, and a foreign-annotated one produces no
item at all (test pins both, plus the wire bytes of the delivered extras).
Anthropic's document params are top-level keys, not wire-namespaced, and
deliberately stay on get.

The per-turn lost-content warning lives on a one-field ExclusionCount
guard's Drop — every termination path, once, zero stays silent — and the
assembler no longer implements Drop, so finish moves its fields freely
again. MIGRATING's verification snippet is now the compiled doc example on
keys_lost_in_round_trip (doctest), so the recipe and the behavior cannot
drift. fixture_additional_params delegates to try_from_value. The
zero-caller enum accessors are deleted: one honest call-site fit was found,
below the keep bar of two.

Grep-proofs: no additional_params.is_none() in responses_api (both guards
go through wire_extras); 4 non-test wire_extras/into_wire_extras callers
across 2 providers; no impl Drop for StreamedTurnAssembler; the finish
mem::take contortion is gone (two remaining takes are pre-existing
reasoning/delta drains); no fn additional_params(&self) on the content
enums.
@gold-silver-copper
gold-silver-copper force-pushed the refactor/tagged-assistant-content branch from 6939469 to ce1b81c Compare August 10, 2026 23:49
… the rule has one home

The round-9 review's four confirmed corridors, closed as two contract
completions:

- assistant_text_replay_message is the one home for the Responses
  assistant-text replay rule — deliverability gate, empty-block skip, both
  message forms — shared by both request conversions (they were duplicated
  token-for-token 2400 lines apart). Loudness moves into it: a malformed
  value under the wire's key warns even when the block is skipped
  (previously unreachable for empty blocks), and own-wire extras stranded
  on the id-less form warn as they drop (previously silent).
  from_message_text is now a silent gate+filter with one production caller.
- The empty-block rule extends to the Anthropic serializer: an empty text
  block with no anthropic-deliverable content (foreign-annotated blocks —
  the shape Responses ingest newly mints — included) produces no Content at
  all instead of an empty text block the API 400s on; a message left with
  no blocks fails loudly and locally at the existing non-empty check.
  Test pins block-level skip and whole-message survival of siblings.

The streaming capture gap is a named limitation, not an overclaim: the
extras-key doc and CHANGELOG now say capture happens on the blocking
response path and the streaming adapter does not yet route annotation
events into params (follow-up work; nothing is silently dropped at replay
because nothing was captured). The logprobs-replay candidate stays open as
an accepted edge pending live-API confirmation.
@gold-silver-copper
gold-silver-copper added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 0364316 Aug 11, 2026
13 checks passed
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