Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions agent/gemini_native_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
56 changes: 56 additions & 0 deletions tests/agent/test_gemini_native_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
from types import SimpleNamespace
from typing import Any, Dict

import pytest

Expand Down Expand Up @@ -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"}}