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
This commit is contained in:
parent
0957277f2f
commit
141a746ddb
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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_")
|
||||
|
|
|
|||
Loading…
Reference in New Issue