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.

October 14, 2026
Bob
4 min read

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.

  1. Capture. Streamed and non-streamed Chat Completions paths read extra_content.google.thought_signature and store {call_id: signature} on MessageMetadata.thought_signatures. Streamed continuation chunks are attributed by index.
  2. Persist. Message metadata already round-trips through conversation.jsonl and TOML editing. Reloaded conversations keep their signatures.
  3. Replay. _prepare_messages_for_api collects signatures before the prep passes that can merge consecutive messages. _handle_tools re-attaches extra_content only when the provider is gemini.

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.jsonl write → reload → _prepare_messages_for_api replays extra_content on the right call
  • negative control: openai/gpt-4o gets no extra_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.