From fdb4183b57288f262cf9cbd789ba57128373d4b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B2=9A=E4=B9=8B=E5=A4=8F?= <108566281+Blueteemo@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:04:21 +0800 Subject: [PATCH 1/2] fix: preserve Gemini tool response function names Resolve opaque tool call IDs back to their original function names when rebuilding Gemini history. Refs #9876 --- astrbot/core/provider/sources/gemini_source.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index 17285ad354..d98a30fc06 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -340,6 +340,7 @@ def append_or_extend( contents.append(content_cls(parts=part)) gemini_contents: list[types.Content] = [] + tool_call_names: dict[str, str] = {} for message in payloads["messages"]: role, content = message["role"], message.get("content") @@ -406,8 +407,12 @@ def append_or_extend( if "tool_calls" in message: for tool in message["tool_calls"]: + tool_call_id = tool.get("id") + function_name = tool["function"]["name"] + if tool_call_id: + tool_call_names[tool_call_id] = function_name part = types.Part.from_function_call( - name=tool["function"]["name"], + name=function_name, args=json.loads(tool["function"]["arguments"]), ) # we should set thought_signature back to part if exists @@ -429,7 +434,12 @@ def append_or_extend( append_or_extend(gemini_contents, parts, types.ModelContent) elif role == "tool": - func_name = message.get("name", message["tool_call_id"]) + tool_call_id = message["tool_call_id"] + func_name = ( + message.get("name") + or tool_call_names.get(tool_call_id) + or tool_call_id + ) part = types.Part.from_function_response( name=func_name, response={ From 766149652f5e3879f00e7b77653fbfdcf342c4ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B2=9A=E4=B9=8B=E5=A4=8F?= <108566281+Blueteemo@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:08:02 +0800 Subject: [PATCH 2/2] test: cover Gemini tool response name resolution Add regression coverage for opaque tool call IDs and preserve explicit or legacy function-name fallbacks. --- tests/test_gemini_source.py | 71 +++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/test_gemini_source.py b/tests/test_gemini_source.py index 38a742f260..f562ebfd57 100644 --- a/tests/test_gemini_source.py +++ b/tests/test_gemini_source.py @@ -114,6 +114,77 @@ async def test_gemini_prepare_conversation_preserves_user_model_history(): assert contents[-1].parts[-1].text == "assistant turn" +@pytest.mark.asyncio +async def test_gemini_prepare_conversation_maps_tool_call_id_to_function_name(): + provider = ProviderGoogleGenAI.__new__(ProviderGoogleGenAI) + + contents = await provider._prepare_conversation( + { + "messages": [ + {"role": "user", "content": "check the weather"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_opaque_123", + "function": { + "name": "get_weather", + "arguments": '{"city": "Shenyang"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_opaque_123", + "content": "sunny", + }, + ] + } + ) + + assert contents[-1].parts is not None + function_response = contents[-1].parts[0].function_response + assert function_response is not None + assert function_response.name == "get_weather" + assert function_response.response == { + "name": "get_weather", + "content": "sunny", + } + + +@pytest.mark.asyncio +async def test_gemini_prepare_conversation_keeps_tool_name_fallbacks(): + provider = ProviderGoogleGenAI.__new__(ProviderGoogleGenAI) + + contents = await provider._prepare_conversation( + { + "messages": [ + {"role": "user", "content": "run tools"}, + { + "role": "tool", + "name": "explicit_name", + "tool_call_id": "call_explicit", + "content": "first result", + }, + { + "role": "tool", + "tool_call_id": "legacy_function_name", + "content": "second result", + }, + ] + } + ) + + assert contents[-1].parts is not None + function_responses = [part.function_response for part in contents[-1].parts] + assert [response.name for response in function_responses if response] == [ + "explicit_name", + "legacy_function_name", + ] + + @pytest.mark.asyncio async def test_gemini_prepare_conversation_resolves_local_history_image(tmp_path): image_path = tmp_path / "history.webp"