Skip to content

Commit 086a5b3

Browse files
committed
fix: normalize Gemini fallback tool call ids
1 parent e077836 commit 086a5b3

3 files changed

Lines changed: 86 additions & 4 deletions

File tree

astrbot/core/agent/runners/tool_loop_agent_runner.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1098,7 +1098,7 @@ async def _handle_function_tools(
10981098
def _append_tool_call_result(tool_call_id: str, content: str) -> None:
10991099
content = self._merge_follow_up_notice(content)
11001100
if (
1101-
tool_call_result_blocks
1101+
len(tool_call_result_blocks) > tool_result_blocks_start
11021102
and tool_call_result_blocks[-1].tool_call_id == tool_call_id
11031103
):
11041104
previous = tool_call_result_blocks[-1]

astrbot/core/provider/sources/gemini_source.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@ def append_or_extend(
322322
contents.append(content_cls(parts=part))
323323

324324
gemini_contents: list[types.Content] = []
325+
tool_name_by_call_id: dict[str, str] = {}
325326
for message in payloads["messages"]:
326327
role, content = message["role"], message.get("content")
327328

@@ -392,6 +393,11 @@ def append_or_extend(
392393

393394
if "tool_calls" in message:
394395
for tool in message["tool_calls"]:
396+
tool_call_id = tool.get("id")
397+
if isinstance(tool_call_id, str) and tool_call_id:
398+
tool_name_by_call_id[tool_call_id] = tool["function"][
399+
"name"
400+
]
395401
part = types.Part.from_function_call(
396402
name=tool["function"]["name"],
397403
args=json.loads(tool["function"]["arguments"]),
@@ -415,7 +421,12 @@ def append_or_extend(
415421
append_or_extend(gemini_contents, parts, types.ModelContent)
416422

417423
elif role == "tool":
418-
func_name = message.get("name", message["tool_call_id"])
424+
tool_call_id = message["tool_call_id"]
425+
func_name = (
426+
message.get("name")
427+
or tool_name_by_call_id.get(tool_call_id)
428+
or tool_call_id
429+
)
419430
part = types.Part.from_function_response(
420431
name=func_name,
421432
response={
@@ -547,8 +558,13 @@ def _process_content_parts(
547558
llm_response.role = "tool"
548559
llm_response.tools_call_name.append(part.function_call.name)
549560
llm_response.tools_call_args.append(part.function_call.args)
550-
# function_call.id might be None, use name as fallback
551-
tool_call_id = part.function_call.id or part.function_call.name
561+
# function_call.id might be None, use a unique name-based fallback.
562+
base_tool_call_id = part.function_call.id or part.function_call.name
563+
tool_call_id = base_tool_call_id
564+
duplicate_index = 2
565+
while tool_call_id in llm_response.tools_call_ids:
566+
tool_call_id = f"{base_tool_call_id}__astrbot_{duplicate_index}"
567+
duplicate_index += 1
552568
llm_response.tools_call_ids.append(tool_call_id)
553569
# extra_content
554570
if part.thought_signature:

tests/test_gemini_source.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import httpx
44
import pytest
5+
from google.genai import types
56

67
from astrbot.core.exceptions import EmptyModelOutputError
78
import astrbot.core.provider.sources.request_retry as request_retry
@@ -33,6 +34,71 @@ def test_gemini_reasoning_only_output_is_allowed():
3334
)
3435

3536

37+
def test_gemini_parallel_same_function_fallback_ids_are_unique():
38+
provider = ProviderGoogleGenAI.__new__(ProviderGoogleGenAI)
39+
candidate = types.Candidate(
40+
content=types.Content(
41+
parts=[
42+
types.Part.from_function_call(name="weather", args={"city": "A"}),
43+
types.Part.from_function_call(name="weather", args={"city": "B"}),
44+
]
45+
),
46+
finish_reason=types.FinishReason.STOP,
47+
)
48+
llm_response = LLMResponse(role="assistant")
49+
50+
provider._process_content_parts(candidate, llm_response)
51+
52+
assert llm_response.tools_call_ids == ["weather", "weather__astrbot_2"]
53+
54+
55+
def test_gemini_tool_responses_restore_function_name_from_unique_call_ids():
56+
provider = ProviderGoogleGenAI.__new__(ProviderGoogleGenAI)
57+
payloads = {
58+
"messages": [
59+
{"role": "user", "content": "Check both cities"},
60+
{
61+
"role": "assistant",
62+
"content": "",
63+
"tool_calls": [
64+
{
65+
"id": "weather",
66+
"type": "function",
67+
"function": {
68+
"name": "weather",
69+
"arguments": '{"city":"A"}',
70+
},
71+
},
72+
{
73+
"id": "weather__astrbot_2",
74+
"type": "function",
75+
"function": {
76+
"name": "weather",
77+
"arguments": '{"city":"B"}',
78+
},
79+
},
80+
],
81+
},
82+
{"role": "tool", "tool_call_id": "weather", "content": "city A"},
83+
{
84+
"role": "tool",
85+
"tool_call_id": "weather__astrbot_2",
86+
"content": "city B",
87+
},
88+
]
89+
}
90+
91+
contents = provider._prepare_conversation(payloads)
92+
function_response_names = [
93+
part.function_response.name
94+
for content in contents
95+
for part in content.parts or []
96+
if part.function_response
97+
]
98+
99+
assert function_response_names == ["weather", "weather"]
100+
101+
36102
@pytest.mark.asyncio
37103
async def test_gemini_get_models_retries_transient_request_error(monkeypatch):
38104
monkeypatch.setattr(request_retry, "REQUEST_RETRY_WAIT_MIN_S", 0)

0 commit comments

Comments
 (0)