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
8 changes: 7 additions & 1 deletion src/google/adk/tools/function_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion src/google/adk/utils/_callable_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
33 changes: 33 additions & 0 deletions tests/unittests/tools/test_function_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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."""
Expand Down