From 06404a5b51d6ce42b928a233e85a22e627d17613 Mon Sep 17 00:00:00 2001 From: knQzx <75641500+knQzx@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:13:45 +0200 Subject: [PATCH 1/2] fix tool_use id handling for anthropic models on session resume --- src/google/adk/flows/llm_flows/contents.py | 5 +- src/google/adk/models/anthropic_llm.py | 24 +++++- .../flows/llm_flows/test_contents.py | 74 +++++++++++++++++++ tests/unittests/models/test_anthropic_llm.py | 61 +++++++++++++++ 4 files changed, 161 insertions(+), 3 deletions(-) diff --git a/src/google/adk/flows/llm_flows/contents.py b/src/google/adk/flows/llm_flows/contents.py index 9b7ef9e121..925ea27ee6 100644 --- a/src/google/adk/flows/llm_flows/contents.py +++ b/src/google/adk/flows/llm_flows/contents.py @@ -41,13 +41,16 @@ class _ContentLlmRequestProcessor(BaseLlmRequestProcessor): async def run_async( self, invocation_context: InvocationContext, llm_request: LlmRequest ) -> AsyncGenerator[Event, None]: + from ...models.anthropic_llm import AnthropicLlm from ...models.google_llm import Gemini agent = invocation_context.agent preserve_function_call_ids = False if hasattr(agent, 'canonical_model'): canonical_model = agent.canonical_model - preserve_function_call_ids = ( + preserve_function_call_ids = isinstance( + canonical_model, AnthropicLlm + ) or ( isinstance(canonical_model, Gemini) and canonical_model.use_interactions_api ) diff --git a/src/google/adk/models/anthropic_llm.py b/src/google/adk/models/anthropic_llm.py index a14c767f23..d6d7041fc6 100644 --- a/src/google/adk/models/anthropic_llm.py +++ b/src/google/adk/models/anthropic_llm.py @@ -100,6 +100,26 @@ def _is_pdf_part(part: types.Part) -> bool: ) +def _sanitize_tool_use_id(tool_id: Optional[str]) -> str: + """Sanitize a tool_use ID to match Anthropic's required pattern. + + Anthropic requires tool_use IDs to match ^[a-zA-Z0-9_-]+$. + If the ID is None, empty, or contains invalid characters, generate + a valid fallback ID. + + Args: + tool_id: The original tool_use ID. + + Returns: + A valid tool_use ID string. + """ + if tool_id and re.fullmatch(r"[a-zA-Z0-9_-]+", tool_id): + return tool_id + import uuid + + return "toolu_" + uuid.uuid4().hex[:24] + + def part_to_message_block( part: types.Part, ) -> Union[ @@ -115,7 +135,7 @@ def part_to_message_block( assert part.function_call.name return anthropic_types.ToolUseBlockParam( - id=part.function_call.id or "", + id=_sanitize_tool_use_id(part.function_call.id), name=part.function_call.name, input=part.function_call.args, type="tool_use", @@ -155,7 +175,7 @@ def part_to_message_block( content = json.dumps(response_data) return anthropic_types.ToolResultBlockParam( - tool_use_id=part.function_response.id or "", + tool_use_id=_sanitize_tool_use_id(part.function_response.id), type="tool_result", content=content, is_error=False, diff --git a/tests/unittests/flows/llm_flows/test_contents.py b/tests/unittests/flows/llm_flows/test_contents.py index b762068d31..7c0b3fe708 100644 --- a/tests/unittests/flows/llm_flows/test_contents.py +++ b/tests/unittests/flows/llm_flows/test_contents.py @@ -19,6 +19,7 @@ from google.adk.flows.llm_flows.contents import request_processor from google.adk.flows.llm_flows.functions import REQUEST_CONFIRMATION_FUNCTION_CALL_NAME from google.adk.flows.llm_flows.functions import REQUEST_EUC_FUNCTION_CALL_NAME +from google.adk.models.anthropic_llm import AnthropicLlm from google.adk.models.google_llm import Gemini from google.adk.models.llm_request import LlmRequest from google.genai import types @@ -1068,3 +1069,76 @@ async def test_adk_function_call_ids_preserved_for_interactions_model(): user_fr_part = llm_request.contents[2].parts[0] assert user_fr_part.function_response is not None assert user_fr_part.function_response.id == function_call_id + + +@pytest.mark.asyncio +async def test_anthropic_model_preserves_function_call_ids(): + """AnthropicLlm should preserve function call IDs during session replay.""" + anthropic_model = AnthropicLlm(model="claude-sonnet-4-20250514") + agent = Agent( + model=anthropic_model, + name="test_agent", + include_contents="default", + ) + llm_request = LlmRequest(model="claude-sonnet-4-20250514") + invocation_context = await testing_utils.create_invocation_context( + agent=agent + ) + + function_call_id = "toolu_test123" + events = [ + Event( + invocation_id="inv1", + author="user", + content=types.Content( + role="user", + parts=[types.Part.from_text(text="Use the tool")], + ), + ), + Event( + invocation_id="inv2", + author="test_agent", + content=types.Content( + role="model", + parts=[ + types.Part( + function_call=types.FunctionCall( + id=function_call_id, + name="my_tool", + args={"arg": "value"}, + ) + ) + ], + ), + ), + Event( + invocation_id="inv3", + author="user", + content=types.Content( + role="user", + parts=[ + types.Part( + function_response=types.FunctionResponse( + id=function_call_id, + name="my_tool", + response={"result": "done"}, + ) + ) + ], + ), + ), + ] + invocation_context.session.events = events + + async for _ in contents.request_processor.run_async( + invocation_context, llm_request + ): + pass + + model_fc_part = llm_request.contents[1].parts[0] + assert model_fc_part.function_call is not None + assert model_fc_part.function_call.id == function_call_id + + user_fr_part = llm_request.contents[2].parts[0] + assert user_fr_part.function_response is not None + assert user_fr_part.function_response.id == function_call_id diff --git a/tests/unittests/models/test_anthropic_llm.py b/tests/unittests/models/test_anthropic_llm.py index 2b510c426b..d29cd013d8 100644 --- a/tests/unittests/models/test_anthropic_llm.py +++ b/tests/unittests/models/test_anthropic_llm.py @@ -15,6 +15,7 @@ import base64 import json import os +import re import sys from unittest import mock from unittest.mock import AsyncMock @@ -1350,3 +1351,63 @@ async def test_non_streaming_does_not_pass_stream_param(): mock_client.messages.create.assert_called_once() _, kwargs = mock_client.messages.create.call_args assert "stream" not in kwargs + + +# --- Tests for _sanitize_tool_use_id --- + + +def test_sanitize_tool_use_id_valid_id_unchanged(): + """A valid tool_use ID should pass through unchanged.""" + from google.adk.models.anthropic_llm import _sanitize_tool_use_id + + assert _sanitize_tool_use_id("toolu_abc123") == "toolu_abc123" + assert _sanitize_tool_use_id("my-tool_id-99") == "my-tool_id-99" + + +def test_sanitize_tool_use_id_none_generates_valid(): + """None ID should be replaced with a valid generated ID.""" + from google.adk.models.anthropic_llm import _sanitize_tool_use_id + + result = _sanitize_tool_use_id(None) + assert result.startswith("toolu_") + assert re.fullmatch(r"[a-zA-Z0-9_-]+", result) + + +def test_sanitize_tool_use_id_empty_string_generates_valid(): + """Empty string ID should be replaced with a valid generated ID.""" + from google.adk.models.anthropic_llm import _sanitize_tool_use_id + + result = _sanitize_tool_use_id("") + assert result.startswith("toolu_") + assert re.fullmatch(r"[a-zA-Z0-9_-]+", result) + + +def test_sanitize_tool_use_id_invalid_chars_generates_valid(): + """ID with invalid characters should be replaced.""" + from google.adk.models.anthropic_llm import _sanitize_tool_use_id + + result = _sanitize_tool_use_id("invalid id with spaces!") + assert result.startswith("toolu_") + assert re.fullmatch(r"[a-zA-Z0-9_-]+", result) + + +def test_part_to_message_block_function_call_none_id(): + """Function call with None ID should get a valid generated ID.""" + part = types.Part.from_function_call( + name="test_tool", args={"key": "value"} + ) + part.function_call.id = None + + result = part_to_message_block(part) + assert re.fullmatch(r"[a-zA-Z0-9_-]+", result["id"]) + + +def test_part_to_message_block_function_response_none_id(): + """Function response with None ID should get a valid generated ID.""" + part = types.Part.from_function_response( + name="test_tool", response={"result": "ok"} + ) + part.function_response.id = None + + result = part_to_message_block(part) + assert re.fullmatch(r"[a-zA-Z0-9_-]+", result["tool_use_id"]) From ef58682e82e7834f5247f4f806ff25009716c574 Mon Sep 17 00:00:00 2001 From: knQzx <75641500+knQzx@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:42:42 +0200 Subject: [PATCH 2/2] retrigger ci