From 6994de4c651274a6592905c1f5d3348f303ff0b6 Mon Sep 17 00:00:00 2001 From: Chenghao Liu Date: Fri, 11 Sep 2026 01:08:43 +0800 Subject: [PATCH] fix: support unhashable callable tools Signed-off-by: Chenghao Liu --- src/google/adk/tools/function_tool.py | 8 ++++- src/google/adk/utils/_callable_utils.py | 9 +++++- tests/unittests/tools/test_function_tool.py | 33 +++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index 266a97c2d62..3f8eb1530df 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -130,7 +130,13 @@ def _get_declaration(self) -> Optional[types.FunctionDeclaration]: # `ignore_params` drops the function context and input_stream (for streaming # tools), which the model doesn't understand. Return a copy: the cached # declaration is shared and callers (e.g. toolset prefixing) mutate it. - declaration = _build_declaration_cached( + try: + hash(self.func) + except TypeError: + build_declaration = _build_declaration_cached.__wrapped__ + else: + build_declaration = _build_declaration_cached + declaration = build_declaration( self.func, tuple(self._ignore_params), self._api_variant, diff --git a/src/google/adk/utils/_callable_utils.py b/src/google/adk/utils/_callable_utils.py index 44b221d7919..5c85b50cebd 100644 --- a/src/google/adk/utils/_callable_utils.py +++ b/src/google/adk/utils/_callable_utils.py @@ -145,7 +145,14 @@ def __init__(self, func: Callable[..., Any] | None) -> None: self.doc = doc # Context parameter detection - self.context_param_name = context_utils.find_context_parameter(func) + try: + hash(func) + except TypeError: + # Callable instances (e.g. dataclasses) need not be hashable. + find_context = context_utils.find_context_parameter.__wrapped__ + else: + find_context = context_utils.find_context_parameter + self.context_param_name = find_context(func) # Resolve signature presence at initialization try: diff --git a/tests/unittests/tools/test_function_tool.py b/tests/unittests/tools/test_function_tool.py index eaa97531cd5..e5d64314565 100644 --- a/tests/unittests/tools/test_function_tool.py +++ b/tests/unittests/tools/test_function_tool.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import dataclasses import inspect from typing import Any from typing import Optional @@ -20,6 +21,7 @@ from google.adk.agents.context import Context from google.adk.agents.invocation_context import InvocationContext +from google.adk.models.llm_request import LlmRequest from google.adk.sessions.session import Session from google.adk.tools.function_tool import _build_declaration_cached from google.adk.tools.function_tool import FunctionTool @@ -567,6 +569,37 @@ def sample_tool(a: int, b: str) -> str: assert d3.name == "sample_tool" +@pytest.mark.parametrize("bound_method", [False, True]) +async def test_unhashable_callable_can_be_declared_and_invoked( + bound_method, mock_tool_context +): + """Dataclass tools and their bound methods remain usable in LLM requests.""" + + @dataclasses.dataclass + class Search: + prefix: str + + def __call__(self, query: str) -> str: + """Search for a query.""" + return self.prefix + query + + search = Search(prefix="found: ") + tool = FunctionTool(search.__call__ if bound_method else search) + first = LlmRequest() + first.append_tools([tool]) + first.config.tools[0].function_declarations[0].name = "prefixed_search" + second = LlmRequest() + second.append_tools([tool]) + result = await tool.run_async( + args={"query": "hello"}, tool_context=mock_tool_context + ) + + declaration = second.config.tools[0].function_declarations[0] + assert declaration.name == tool.name + assert "query" in declaration.parameters_json_schema["properties"] + assert result == "found: hello" + + @pytest.mark.asyncio async def test_run_async_with_async_generator_streaming_tool(mock_tool_context): """Test that run_async returns an AsyncGenerator when wrapped function is an async generator."""