Skip to content

fix: support unhashable callable tools - #7089

Open
MrCapricornLiu wants to merge 3 commits into
google:mainfrom
MrCapricornLiu:fix-unhashable-function-tool
Open

MrCapricornLiu wants to merge 3 commits into
google:mainfrom
MrCapricornLiu:fix-unhashable-function-tool

Conversation

@MrCapricornLiu

@MrCapricornLiu MrCapricornLiu commented Sep 11, 2026 •

Copy link
Copy Markdown

FunctionTool accepts callable instances, but a normal dataclass instance raises TypeError: unhashable type during construction. Both context detection and declaration generation use the callable as an LRU cache key. Bypass those caches for unhashable instances while keeping caching and independent declaration copies for hashable functions.

Reproduction and expected behavior

On ADK 2.8.0 / main (2284c581), Linux, Python 3.12.13, run the added regression cases in:

pytest tests/unittests/tools/test_function_tool.py tests/unittests/utils/test_callable_utils.py tests/unittests/utils/test_context_utils.py -q

The added cases fail on the original implementation and pass with this change. No LiteLLM or live model endpoint is required; Runner verification uses a scripted BaseLlm response.

Testing Plan

  • After syncing main: 92 tool/callable unit tests passed. The three modules implicated in the previous CI failures passed 557 tests, with 1 expected failure.

  • Latest validation against main (c11060cc), Python 3.12.11: the production-source Mypy diagnostic set matches the unmodified baseline (817 existing errors; no new errors). The Runner reproduction below also passes against the updated source tree; the full tox matrix was not rerun.

  • Earlier unit tests against da10185e: 74 passed on each of Python 3.10, 3.11, 3.12, 3.13 and 3.14.

  • The earlier Runner reproduction passed against da10185e. A wheel built against 2284c581 was also verified in a clean environment.

  • Earlier full-suite validation on 2284c581, Python 3.10–3.14: tests passed with the two GC-introspection cases run in separate processes (14,246 passing cases on Python 3.12). One unrelated MCP stdio teardown case was deselected after it also failed to finish on the unmodified base. The default uninterrupted tox run did not complete.

Runner setup and reproduction output

Save the following as repro.py, then run python repro.py after installing the locally built wheel.

import asyncio
import json
from google.adk.agents import Agent
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.genai import types
from pydantic import BaseModel, Field

class ScriptedModel(BaseLlm):
    model: str = "local-repro"
    responses: list[types.Content]
    requests: list[LlmRequest] = Field(default_factory=list)

    async def generate_content_async(self, llm_request, stream=False):
        index = len(self.requests)
        self.requests.append(llm_request.model_copy(deep=True))
        yield LlmResponse(content=self.responses[index])

def response(*parts):
    return types.Content(role="model", parts=list(parts))

async def run_agent(model, tools=(), artifacts=None, setup=None):
    sessions = InMemorySessionService()
    session = await sessions.create_session(app_name="repro", user_id="user")
    if setup:
        await setup(session)
    async with Runner(app_name="repro", agent=Agent(name="repro_agent", model=model, tools=list(tools)), session_service=sessions, artifact_service=artifacts) as runner:
        events = [event async for event in runner.run_async(user_id="user", session_id=session.id, new_message=types.Content(role="user", parts=[types.Part(text="Run the check.")]))]
        stored = await sessions.get_session(app_name="repro", user_id="user", session_id=session.id)
    return events, stored

from dataclasses import dataclass
from google.adk.tools import FunctionTool
from google.adk.tools.tool_context import ToolContext

@dataclass
class Search:
    prefix: str
    def __call__(self, query: str, ctx: ToolContext) -> str:
        assert ctx is not None
        return self.prefix + query

async def main():
    tool = FunctionTool(Search("found: "))
    model = ScriptedModel(responses=[response(types.Part(function_call=types.FunctionCall(name="Search", args={"query": "hello"}))), response(types.Part(text="done"))])
    events, _ = await run_agent(model, [tool])
    replies = [part.function_response for event in events if event.content for part in event.content.parts or [] if part.function_response]
    assert replies[0].response == {"result": "found: hello"}, replies
    declaration = model.requests[0].config.tools[0].function_declarations[0]
    assert "ctx" not in declaration.parameters_json_schema["properties"]
    assert len(model.requests) == 2
    print("PASS: dataclass tool constructed, advertised, invoked with injected context, and returned", replies[0].response)

asyncio.run(main())
PASS: dataclass tool constructed, advertised, invoked with injected context, and returned {'result': 'found: hello'}

Signed-off-by: Chenghao Liu <chliu@stu.pku.edu.cn>
@google-cla

google-cla Bot commented Sep 11, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

Signed-off-by: Chenghao Liu <chliu@stu.pku.edu.cn>
Signed-off-by: Chenghao Liu <chliu@stu.pku.edu.cn>
@MrCapricornLiu

Copy link
Copy Markdown
Author

Updated against main (c11060cc). The 92 tool/callable tests and the Runner reproduction pass. I also reran the three modules behind the previous CI failures: 557 passed, 1 expected failure. The production-source Mypy diagnostics match the unmodified baseline. CLA passes on the new head; could a maintainer approve the pending CI run?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants