Gemini's Second Tool Call Needed the Thought Signature Back
A Gemini native-tool turn executed the first shell call, then died with HTTP 400. The model had attached an opaque thought_signature. Flattening the call to text threw it away.
A bounded Gemini eval on gemini/gemini-4-argon ran a native-format shell call, got the result back, and then the follow-up request failed:
Error code: 400 - Function call is missing a thought_signature
in functionCall parts. This is required for tools to work correctly
... function call `default_api:shell`, position 2.
The first call worked. The second one never left the gate. Markdown tool format was fine. Only --tool-format tool against Gemini’s OpenAI-compatible endpoint blew up.
That is a protocol field, not a model-quality problem.
What Gemini attaches
Gemini’s compat API puts an opaque signature on every function call:
"tool_calls": [{
"id": "call_300473",
"function": {"name": "shell", "arguments": "{\"command\": \"pwd\"}"},
"extra_content": {"google": {"thought_signature": "ElkKVwFp..."}}
}]
The next request has to send that same signature back on the same call. Gemini will not continue the native tool loop without it.
gptme does not keep provider tool_calls objects around as the conversation. It flattens them into @shell(call_300473): {"command": "pwd"} text, because that is the format the rest of the stack reads, logs, and edits. Flattening dropped extra_content. msgs2dicts then dropped remaining message metadata before _handle_tools rebuilt the tool_calls array for the next request. The signature had two chances to die, and it took both.
Streaming made it worse. Continuation chunks can carry the signature without the call id. If you only look at tool_calls[].id, those chunks look empty.
Capture, persist, replay
The repair is gptme/gptme#4276, merged 2026-10-14.
- Capture. Streamed and non-streamed Chat Completions paths read
extra_content.google.thought_signatureand store{call_id: signature}onMessageMetadata.thought_signatures. Streamed continuation chunks are attributed byindex. - Persist. Message metadata already round-trips through
conversation.jsonland TOML editing. Reloaded conversations keep their signatures. - Replay.
_prepare_messages_for_apicollects signatures before the prep passes that can merge consecutive messages._handle_toolsre-attachesextra_contentonly when the provider isgemini.
Other providers never see the field. A mid-conversation model switch off Gemini does not leak Google metadata into OpenAI or Anthropic requests. Signatures are opaque: none are invented, and there is no bypass sentinel. If a call_id is reused, the later message wins, because Gemini validates the current turn.
The tests that made this a real fix
Four tests, three of them red before the patch:
- streamed capture, with the signature on an id-less continuation chunk
- non-streamed capture
conversation.jsonlwrite → reload →_prepare_messages_for_apireplaysextra_contenton the right call- negative control:
openai/gpt-4ogets noextra_content
That last one matters as much as the first three. A Gemini-only field that leaks onto every provider is a different bug.
What this is not
This is not a live paid Gemini smoke test. The eval runner cannot budget-guard Gemini spend, so the bounded native shell round-trip still waits on an explicit budget. The unit tests prove capture, persistence, and replay. They do not prove the live Argon endpoint accepts the reconstructed payload.
It is also not the native-tool parser bug from earlier this month. That one was “the model called the tool, the server saw text.” This one is “the server saw the tool, then forgot the provider’s receipt.”
Provider-compat layers keep growing receipts like this. Flattening to a shared internal format is the right architecture. The receipts still have to travel with the call.