Lessons learned

The one-line model swap that broke every multi-turn run

Swapping a Gemini model alias for a concrete id looked like housekeeping. It jumped a major model version. Gemini 3 requires echoing back an opaque thought_signature field on every replayed tool call, and no test caught the gap because it only surfaces on the second turn of a run.

On June 7 I changed one line: the default agent model went from the alias google/gemini-flash-latest to the concrete id google/gemini-3.5-flash. Nothing else in the diff. Two mornings later, every multi-step tool-using agent run in prod was failing with an HTTP 400 and rolling back. A model id reads like a label. It’s a contract version, and the new contract required my requests to carry a field my code had never captured.

The entire diff was one model id

The pin looked like housekeeping. The -latest alias had been resolving to Gemini 2.5, which made billing for grounding (Google’s search-grounding add-on) ambiguous, so the pin reversed the earlier alias choice and named the model explicitly. What it actually did was jump a major family: the concrete pin served 3.5, not the 2.5 it replaced.

The pin shipped in the prod deploy on June 8 at 22:27. The first real multi-step run hit it at 06:30 the next morning:

400 INVALID_ARGUMENT: Function call is missing a thought_signature in
functionCall parts ... function call `default_api:glob`, position 2.

The user-visible version was worse and vaguer: “workhorse model request failed” (the workhorse is the default model my agent loop runs for everyday steps), followed by a full rollback of the run. Nothing in the pin’s diff mentioned tool calls, signatures, or request shape. The new requirement lived entirely on the provider’s side.

Gemini 3 attaches a signature you must echo back

The Gemini 3 generation (3.5-flash included), unlike 2.5, attaches a thought signature to every tool call it emits: an opaque token you never decode, but must send back untouched whenever you replay that tool call on a later turn. Agent loops replay constantly. Turn 2’s request includes turn 1’s assistant message, tool calls and all, so the model can see what it already did. Drop the signature from that replay and Gemini 3 rejects the entire request.

Over the Vertex OpenAI-compat layer (Vertex serving Gemini behind a Chat Completions-shaped API), the signature arrives as an SDK extra on the streamed tool-call delta. It sits as a sibling of function, not inside it:

"tool_calls": [{
  "id": "function-call-…",
  "type": "function",
  "function": {"name": "glob", "arguments": "…"},
  "extra_content": {"google": {"thought_signature": "Cv…=="}}   // <- I ignored this
}]

My pipeline dropped it twice. stream_turn_openai reassembles each streamed tool call from id, function.name, and function.arguments, so extra_content never reached the internal block. Then openai_request._reshape_assistant rebuilt the assistant turn for replay from id plus function only:

# before — openai_request._reshape_assistant
tool_calls.append({
    "id": sdk_get(block, "id"),
    "type": "function",
    "function": {"name": sdk_get(block, "name"), "arguments": arguments},
})  # no extra_content -> Gemini 3 returns 400 on replay

Turn 1 succeeds. Turn 2 replays a signature-less functionCall, and Vertex returns the 400 above.

Open diagram in a new tab ↗

The 2.5-to-3.5 swap was only the trigger. The latent bug was that this normalization path never round-tripped provider-specific tool-call metadata at all. Any provider field I didn’t parse, I silently threw away.

Every gate was green because the bug only fires on turn two

Everything I checked before shipping passed. The model was registered, smoke tests passed, and single-turn calls worked. The 400 only exists on the second request of a run that calls a tool and loops, and no offline check exercised that replay. Acceptance and runtime are different layers: a green single-turn call proves the provider accepts my requests, not that a multi-turn conversation survives.

Open diagram in a new tab ↗

Two more things slowed the diagnosis. The error was double-wrapped: Sentry recorded the raw BadRequestError and the sanitized wrapper, OpenAIVertexError: workhorse model request failed, as two separate issues. The wrapper raises from None, so the loud, specific provider message survived only because it went unhandled at the SDK layer.

And my first code-read guessed the wrong field: tc.function.thought_signature is the plausible home, but the real signature is a sibling of function. Only Google’s docs and repro threads gave the correct path. A wire shape you reasoned out offline is a hypothesis, not a fact, until you’ve checked it against a real response.

The fix echoes a field it never decodes

The fix is small: capture the signature per tool-call index while streaming, stash it on the assembled ToolUseBlock, and echo it back in the exact place the response carried it. The internal blocks are Anthropic SDK models, whose BaseModel is extra="allow", so the extra attribute survives both the in-loop replay and model_dump persistence without new plumbing.

# stream_turn_openai — capture
signature = _thought_signature_from_tool_call(tc)  # extra_content.google.thought_signature
if signature is not None:
    accum.thought_signature = signature

# openai_request._reshape_assistant — echo
signature = sdk_get(block, "thought_signature")
if signature:
    call["extra_content"] = {"google": {"thought_signature": signature}}

Only Gemini-origin turns carry the field. gpt-oss and Anthropic turns resolve to None, the echo is skipped, and those providers see requests identical to before.

Unit tests prove the capture-and-echo plumbing, but they can’t prove that Vertex accepts extra_content on a request-side tool call and stops returning the 400. The merge gate was a live probe on June 9, a real two-turn tool call against google/gemini-3.5-flash. With the echo, Vertex accepted the request. With the signature deliberately stripped as a negative control, the original 400 came back.

Treat a model-id change as a contract change

The rule I took away: treat any model-id change as a contract change, even when it looks like renaming the same model more precisely. A provider version bump can add mandatory requirements on the requests you send, not just change the outputs you get. So a loop-model id change now has to clear a multi-turn, tool-using run against the real provider before it ships, and I no longer trust a -latest alias to stay within a model family. A regression test now replays a two-turn Gemini tool call and fails if the signature doesn’t round-trip.

The transferable invariant is bigger than Gemini. When you normalize a provider’s tool call into your own internal shape, the opaque metadata you don’t interpret still carries meaning: signatures, ids, server-side state. Preserve it and echo it back verbatim, because dropping it is a silent correctness bug that only surfaces on replay, two turns deep.

The parts of a provider response you don’t understand aren’t noise. Some of them are state the provider expects you to carry, and the only way to find out which is to make the multi-turn call against the real provider before your users do.

Found a mistake, or want to argue about an invariant? eng@shannon.dev or send a PR on the blog repo.