Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/google/adk/flows/llm_flows/contents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
24 changes: 22 additions & 2 deletions src/google/adk/models/anthropic_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
74 changes: 74 additions & 0 deletions tests/unittests/flows/llm_flows/test_contents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
61 changes: 61 additions & 0 deletions tests/unittests/models/test_anthropic_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import base64
import json
import os
import re
import sys
from unittest import mock
from unittest.mock import AsyncMock
Expand Down Expand Up @@ -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"])