A reasoning segment's unknown fields are dropped on the way back to the provider #10
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Follow-up from #9 (💡2 there).
What happens
ReasoningDetailand its three subtypes modeltype, the payload (text/summary/data),indexandformat. Anything else a provider sends on a segment is read and discarded, becauseSystem.Text.Jsonignores unmapped properties by default.That is harmless on the way in. It is not harmless on the way out:
Agentcollects the segments and passes them back on the next turn to preserve thinking state, so whatever the provider attached to its own thought does not come back to it.The concrete case is Gemini's
signature, seen in the field ongoogle/gemini-3.5-flash:That segment now deserializes (#9), and it is echoed back as
{"type":"reasoning.text","index":0}— signature gone.What is not yet known
Whether Gemini rejects, degrades, or silently ignores a thought whose signature it issued and did not get back. I have not tested it, which is why #9 deliberately stopped at the crash and left this open rather than guessing at a fix.
Two ways to fix it
Signatureproperty onReasoningTextDetail(and wherever else it appears). Discoverable and typed; only solves the field we happen to know about.[JsonExtensionData]onReasoningDetail— aDictionary<string, JsonElement>catch-all that survives a round-trip. Solvessignatureand every future field a provider invents, at the cost of a bag of untyped values on a public type. Worth checking it composes cleanly with[JsonPolymorphic], since the discriminator must not land in the bag.My preference is (2) for a passthrough client, with (1) layered on top if the signature turns out to be load-bearing enough to deserve a name. Either way the right first step is a test against a real Gemini turn: send a thought's signature back, then omit it, and compare what the provider does.
🤖 Generated with Claude Code
Measured against live
google/gemini-3.5-flashthrough OpenRouter, with rawHttpClientrather than this library — a typed client that drops unknown fields is the one instrument that cannot see them.This is load-bearing, and worse than untidiness: dropping the handle makes the model abandon its task.
What the wire actually carries
A normal reasoning segment from this model:
idis the unmodelled field — an 8-character handle. Five request shapes (effort low/high × tool-forced/plain, plus no reasoning option) produced nine segments:reasoning.textwithtext,reasoning.encryptedwithdata, and no payload-less segment anywhere. So on the happy path nothing is missing, which is why the single-round test showed no difference at all.The experiment that found it
Two identical five-round tool-calling conversations, differing only in whether each echoed assistant turn keeps
id:idid(this library today)Eight paired trials, seven discordant, all in the same direction — a sign test puts that near p ≈ 0.008. One
keeps-idarm ended before round 5 and I did not capture why, so I am counting it as unknown rather than as a clean pass.The mechanism, and the link to the original crash
When the handle does not come back, Google answers with:
finish_reason: stop, zero tool calls, and a final answer that gives up on the task ("a minor background texture or hatching element"). Two things follow:signatureis real after all. I had inferred it from Gemini's docs and #9's fixture guessed the shape{"type":"reasoning.text","index":0,"signature":"…"}— that turns out to be the exact wire shape, but it only appears in the degraded state, which is why the happy-path sweep never showed it. Bothreasoning.encrypted.idandreasoning.text.signatureare dropped today.reasoning.textthat failed deserialization was not a quirk of Gemini — it was Gemini's response to us having thrown its handle away. #9 stopped the crash; it did not stop the cause. Post-#9 the round parses, and the agent quietly abandons its refinement instead.The second half is the part that matters for Orihon: a silent behavioural regression on a multi-round stage is harder to notice than a failed round, and a boxing agent that stops calling
cropand declares the region "background hatching" produces a plausible wrong answer rather than an error.Fix
[JsonExtensionData]onReasoningDetail, so every unmodelled field survives the round-trip —id,signature, and whatever the next provider invents. An explicitSignature/Idpair would fix today's two fields and leave the next one to be found the same expensive way.Worth checking as part of it that the extension bag composes with
[JsonPolymorphic]without thetypediscriminator landing in it, and that a segment we constructed ourselves (no extension data) still serializes clean.Reproduction scripts (three file-based probes: single round-trip, shape sweep, multi-round A/B) are in my scratchpad — say the word and I will attach them here or fold them into the repo as a manual diagnostic.
🤖 Generated with Claude Code