From 141a746ddba7899dbe321d4f9224330e4fdcd29f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:52:54 -0700 Subject: [PATCH] Port from earendil-works/pi#7494: preserve Gemini 3 tool call IDs Gemini 3+ models require explicit tool call IDs on functionCall / functionResponse parts in replayed history; without them parallel tool calls can be rejected or mispaired. The native adapter now: - threads the model id into request building and includes ids for Gemini >= 3 (version-gated: 2.x rejects unexpected id fields) - preserves provider-returned functionCall.id on both non-streaming and streaming responses instead of always minting a random one --- agent/gemini_native_adapter.py | 80 +++++++++++++--- tests/agent/test_gemini_native_adapter.py | 107 ++++++++++++++++++++++ 2 files changed, 176 insertions(+), 11 deletions(-) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 0fb31b7aa3431..baeea6eaa8fcf 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -20,6 +20,7 @@ import asyncio import base64 import json import logging +import re import time import uuid from types import SimpleNamespace @@ -59,6 +60,31 @@ def bare_gemini_model_id(model: str) -> str: return name +def _gemini_major_version(model: str) -> Optional[int]: + """Extract the major version from a Gemini model id (``gemini-3.6-flash`` → 3).""" + name = bare_gemini_model_id(model).lower() + match = re.match(r"gemini-(\d+)", name) + if not match: + return None + try: + return int(match.group(1)) + except ValueError: + return None + + +def gemini_requires_tool_call_ids(model: str) -> bool: + """Whether functionCall/functionResponse parts must carry explicit ids. + + Gemini 3+ models require explicit tool call IDs in replayed history — + without them, multi-tool turns can be rejected or mismatched. Older + Gemini models (2.x) reject unexpected ``id`` fields, so this is gated on + the major version. Mirrors earendil-works/pi#7494 (their fix for the same + class of bug in the google-shared converter). + """ + version = _gemini_major_version(model) + return version is not None and version >= 3 + + def is_native_gemini_base_url(base_url: str) -> bool: """Return True when the endpoint speaks Gemini's native REST API.""" normalized = str(base_url or "").strip().rstrip("/").lower() @@ -299,7 +325,10 @@ _INTERRUPTED_RESPONSE_PLACEHOLDER = ( ) -def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]: +def _translate_tool_call_to_gemini( + tool_call: Dict[str, Any], + include_ids: bool = False, +) -> Dict[str, Any]: fn = tool_call.get("function") or {} args_raw = fn.get("arguments", "") try: @@ -315,6 +344,12 @@ def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]: "args": args, } } + if include_ids: + # Gemini 3+ requires explicit tool call IDs so replayed parallel tool + # calls pair with their functionResponses (earendil-works/pi#7494). + tool_call_id = str(tool_call.get("id") or tool_call.get("call_id") or "") + if tool_call_id: + part["functionCall"]["id"] = tool_call_id thought_signature = _tool_call_extra_signature(tool_call) # Fallback sentinel for cross-provider tool_calls (e.g. fallback from # xAI/Anthropic to Gemini, where the original tool_call carries no @@ -328,6 +363,7 @@ def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]: def _translate_tool_result_to_gemini( message: Dict[str, Any], tool_name_by_call_id: Optional[Dict[str, str]] = None, + include_ids: bool = False, ) -> Dict[str, Any]: tool_name_by_call_id = tool_name_by_call_id or {} tool_call_id = str(message.get("tool_call_id") or "") @@ -347,15 +383,19 @@ def _translate_tool_result_to_gemini( except json.JSONDecodeError: parsed = None response = parsed if isinstance(parsed, dict) else {"output": content} - return { - "functionResponse": { - "name": name, - "response": response, - } + function_response: Dict[str, Any] = { + "name": name, + "response": response, } + if include_ids and tool_call_id: + function_response["id"] = tool_call_id + return {"functionResponse": function_response} -def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]: +def _build_gemini_contents( + messages: List[Dict[str, Any]], + include_tool_call_ids: bool = False, +) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]: system_text_parts: List[str] = [] contents: List[Dict[str, Any]] = [] tool_name_by_call_id: Dict[str, str] = {} @@ -377,6 +417,7 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st _translate_tool_result_to_gemini( msg, tool_name_by_call_id=tool_name_by_call_id, + include_ids=include_tool_call_ids, ) ], } @@ -397,7 +438,11 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st tool_name = str(((tool_call.get("function") or {}).get("name") or "")) if tool_call_id and tool_name: tool_name_by_call_id[tool_call_id] = tool_name - parts.append(_translate_tool_call_to_gemini(tool_call)) + parts.append( + _translate_tool_call_to_gemini( + tool_call, include_ids=include_tool_call_ids + ) + ) if parts: contents.append({"role": gemini_role, "parts": parts}) @@ -525,8 +570,12 @@ def build_gemini_request( top_p: Optional[float] = None, stop: Any = None, thinking_config: Any = None, + model: str = "", ) -> Dict[str, Any]: - contents, system_instruction = _build_gemini_contents(messages) + contents, system_instruction = _build_gemini_contents( + messages, + include_tool_call_ids=gemini_requires_tool_call_ids(model), + ) request: Dict[str, Any] = {"contents": contents} if system_instruction: request["systemInstruction"] = system_instruction @@ -642,7 +691,11 @@ def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespa except (TypeError, ValueError): args_str = "{}" tool_call = SimpleNamespace( - id=f"call_{uuid.uuid4().hex[:12]}", + id=( + str(fc["id"]) + if isinstance(fc.get("id"), str) and fc.get("id") + else f"call_{uuid.uuid4().hex[:12]}" + ), type="function", index=index, function=SimpleNamespace(name=str(fc["name"]), arguments=args_str), @@ -793,7 +846,11 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: if slot is None: slot = { "index": len(tool_call_indices), - "id": f"call_{uuid.uuid4().hex[:12]}", + "id": ( + str(fc["id"]) + if isinstance(fc.get("id"), str) and fc.get("id") + else f"call_{uuid.uuid4().hex[:12]}" + ), "last_arguments": "", } tool_call_indices[call_key] = slot @@ -1048,6 +1105,7 @@ class GeminiNativeClient: top_p=top_p, stop=stop, thinking_config=thinking_config, + model=model, ) model = bare_gemini_model_id(model) diff --git a/tests/agent/test_gemini_native_adapter.py b/tests/agent/test_gemini_native_adapter.py index ab98fed3719d0..5892aae3a1eb7 100644 --- a/tests/agent/test_gemini_native_adapter.py +++ b/tests/agent/test_gemini_native_adapter.py @@ -309,3 +309,110 @@ def test_stream_event_translation_emits_tool_call_delta_with_stable_index(): + + +class TestGemini3ToolCallIds: + """Gemini 3+ requires explicit tool call IDs in replayed history + (port of earendil-works/pi#7494).""" + + def _history(self): + return [ + {"role": "user", "content": "Read a.txt and b.txt"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": "call_1", "type": "function", + "function": {"name": "read_file", "arguments": '{"path": "a.txt"}'}}, + {"id": "call_2", "type": "function", + "function": {"name": "read_file", "arguments": '{"path": "b.txt"}'}}, + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "AAA"}, + {"role": "tool", "tool_call_id": "call_2", "content": "BBB"}, + ] + + def test_requires_ids_gate(self): + from agent.gemini_native_adapter import gemini_requires_tool_call_ids + + assert gemini_requires_tool_call_ids("gemini-3.6-flash") + assert gemini_requires_tool_call_ids("google/gemini-3.6-pro") + assert gemini_requires_tool_call_ids("gemini-3-flash-preview") + assert not gemini_requires_tool_call_ids("gemini-2.5-flash") + assert not gemini_requires_tool_call_ids("gemini-1.5-pro") + assert not gemini_requires_tool_call_ids("claude-opus-4.6") + assert not gemini_requires_tool_call_ids("") + + def test_ids_preserved_for_gemini3(self): + from agent.gemini_native_adapter import _build_gemini_contents + + contents, _ = _build_gemini_contents( + self._history(), include_tool_call_ids=True + ) + call_ids = [ + p["functionCall"]["id"] + for c in contents for p in c["parts"] if "functionCall" in p + ] + response_ids = [ + p["functionResponse"]["id"] + for c in contents for p in c["parts"] if "functionResponse" in p + ] + assert call_ids == ["call_1", "call_2"] + assert response_ids == ["call_1", "call_2"] + + def test_ids_omitted_for_older_gemini(self): + from agent.gemini_native_adapter import _build_gemini_contents + + contents, _ = _build_gemini_contents(self._history()) + for c in contents: + for p in c["parts"]: + if "functionCall" in p: + assert "id" not in p["functionCall"] + if "functionResponse" in p: + assert "id" not in p["functionResponse"] + + def test_build_request_threads_model_gate(self): + from agent.gemini_native_adapter import build_gemini_request + + request = build_gemini_request( + messages=self._history(), model="gemini-3.6-flash" + ) + parts = [p for c in request["contents"] for p in c["parts"]] + assert any(p.get("functionCall", {}).get("id") == "call_1" for p in parts) + + request_old = build_gemini_request( + messages=self._history(), model="gemini-2.5-flash" + ) + parts_old = [p for c in request_old["contents"] for p in c["parts"]] + assert all("id" not in p.get("functionCall", {}) for p in parts_old) + + def test_response_preserves_provider_tool_call_id(self): + from agent.gemini_native_adapter import translate_gemini_response + + resp = { + "candidates": [{ + "content": {"parts": [{ + "functionCall": {"id": "call_native_7", "name": "read_file", + "args": {"path": "a.txt"}}, + }]}, + "finishReason": "STOP", + }], + } + result = translate_gemini_response(resp, model="gemini-3.6-flash") + tool_calls = result.choices[0].message.tool_calls + assert tool_calls[0].id == "call_native_7" + + def test_response_generates_id_when_absent(self): + from agent.gemini_native_adapter import translate_gemini_response + + resp = { + "candidates": [{ + "content": {"parts": [{ + "functionCall": {"name": "read_file", "args": {}}, + }]}, + "finishReason": "STOP", + }], + } + result = translate_gemini_response(resp, model="gemini-2.5-flash") + tool_calls = result.choices[0].message.tool_calls + assert tool_calls[0].id.startswith("call_")