From d94469f1316d0b9b5bb3aa86b1b94343fc6dda9d Mon Sep 17 00:00:00 2001 From: Ricardo-M-L Date: Sun, 26 Apr 2026 01:38:11 +0800 Subject: [PATCH] fix(gemini): stop splitting one tool call into two when signature arrives late MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit translate_stream_event() in agent/gemini_native_adapter.py keys tool_call slots on (part_index, name, thought_signature). Because the thought_signature is part of the dedup key, a single tool call whose chunks carry the signature inconsistently (e.g., empty on early chunks, present on a later one — which Gemini 3 thinking models do) is split into two separate slots: - slot 0: built from the early chunks → no signature, partial args - slot 1: built from the later chunk → has signature, fuller args Both slots are emitted as deltas, so the agent records *two* tool calls for what was logically one. On the next turn the slot without a signature is replayed back to Gemini, which 400s with: Function call is missing a thought_signature in functionCall parts. Fix: dedup on (part_index, name) only. The signature is still surfaced through the per-chunk extra_content field, and the downstream streaming accumulator (run_agent.py) already does latest-non-None-wins on extra_content per slot — so whichever chunk carried the signature gets it merged into the single slot. Adds a regression test that fails on main and passes here: test_stream_event_translation_does_not_split_slot_when_signature_arrives_late Co-Authored-By: Claude Opus 4.7 --- agent/gemini_native_adapter.py | 11 ++++- tests/agent/test_gemini_native_adapter.py | 56 +++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 5f64636f2ffb..6885a762a3d9 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -638,12 +638,19 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: args_str = json.dumps(fc.get("args") or {}, ensure_ascii=False, sort_keys=True) except (TypeError, ValueError): args_str = "{}" - thought_signature = part.get("thoughtSignature") if isinstance(part.get("thoughtSignature"), str) else "" + # Dedup slots by (part_index, name) only — NOT by thought_signature. + # Gemini may send the signature on only some chunks of a tool call + # (e.g., on a later args chunk, or only on the first chunk). Including + # it in the key splits a single tool call into multiple slots when + # the signature changes between chunks, leaving the slot that did + # not receive it without a signature on replay → Gemini 3 thinking + # models then 400 with "Function call is missing a thought_signature" + # because the model that originally emitted the call expects the + # signature back. call_key = json.dumps( { "part_index": part_index, "name": name, - "thought_signature": thought_signature, }, sort_keys=True, ) diff --git a/tests/agent/test_gemini_native_adapter.py b/tests/agent/test_gemini_native_adapter.py index 4b066b4f454c..f68235b19156 100644 --- a/tests/agent/test_gemini_native_adapter.py +++ b/tests/agent/test_gemini_native_adapter.py @@ -4,6 +4,7 @@ import json from types import SimpleNamespace +from typing import Any, Dict import pytest @@ -326,3 +327,58 @@ def test_stream_event_translation_keeps_identical_calls_in_distinct_parts(): assert tool_chunks[0].choices[0].delta.tool_calls[0].index == 0 assert tool_chunks[1].choices[0].delta.tool_calls[0].index == 1 assert tool_chunks[0].choices[0].delta.tool_calls[0].id != tool_chunks[1].choices[0].delta.tool_calls[0].id + + +def test_stream_event_translation_does_not_split_slot_when_signature_arrives_late(): + """Regression: Gemini 3 thinking models may send `thoughtSignature` on a + later chunk of the same tool call (or only on one chunk). The dedup key + must be (part_index, name) only — never including the signature — so a + single tool call stays in a single slot regardless of which chunks carry + the signature. Otherwise the slot that lacks the signature gets replayed + without one and Gemini rejects the request with HTTP 400. + """ + from agent.gemini_native_adapter import translate_stream_event + + tool_call_indices: Dict[str, Dict[str, Any]] = {} + + # Chunk 1 — no signature yet. + event_a = { + "candidates": [ + { + "content": { + "parts": [ + {"functionCall": {"name": "terminal", "args": {}}} + ] + }, + } + ] + } + # Chunk 2 — same tool call, more args, signature now present. + event_b = { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": {"name": "terminal", "args": {"cmd": "ls"}}, + "thoughtSignature": "sig-late", + } + ] + }, + "finishReason": "STOP", + } + ] + } + + chunks_a = translate_stream_event(event_a, model="gemini-3.1-flash", tool_call_indices=tool_call_indices) + chunks_b = translate_stream_event(event_b, model="gemini-3.1-flash", tool_call_indices=tool_call_indices) + + a_tool = [c for c in chunks_a if c.choices[0].delta.tool_calls][0].choices[0].delta.tool_calls[0] + b_tool = [c for c in chunks_b if c.choices[0].delta.tool_calls][0].choices[0].delta.tool_calls[0] + + # Both chunks must address the SAME slot (same index, same id) — otherwise + # the consumer accumulates two tool calls for what is logically one. + assert a_tool.index == b_tool.index == 0 + assert a_tool.id == b_tool.id + # The chunk that carried the signature surfaces it via extra_content. + assert b_tool.extra_content == {"google": {"thought_signature": "sig-late"}}