Skip to content

fix: resolve wrapped callable annotations in tool schemas - #7090

Open
MrCapricornLiu wants to merge 1 commit into
google:mainfrom
MrCapricornLiu:fix-callable-return-schema
Open

fix: resolve wrapped callable annotations in tool schemas#7090
MrCapricornLiu wants to merge 1 commit into
google:mainfrom
MrCapricornLiu:fix-callable-return-schema

Conversation

@MrCapricornLiu

Copy link
Copy Markdown

With postponed annotations, functools.partial and callable instances fail to build a tool declaration for a module-level Pydantic model. Resolve hints on the existing unwrapped callable, while retaining the original signature so bound arguments stay excluded. Use the shared callable-name helper when logging response-schema fallback errors.

Related: #6879 preserves Annotated metadata in the same hint-resolution calls; this patch addresses unwrapping partials and callable instances.

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_with_import_annotations.py tests/unittests/tools/test_function_tool_declarations.py tests/unittests/tools/test_function_tool.py tests/unittests/tools/test_build_function_declaration.py tests/unittests/tools/test_from_function_with_options.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

Type checking matches the unmodified upstream baseline (831 existing errors; no new errors).

  • Relevant unit tests on current main (da10185e): 187 passed.
  • The Runner reproduction passes on current main. 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,247 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.

from __future__ import annotations

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

import functools
import os
from google.adk.tools import FunctionTool

class Item(BaseModel):
    name: str
    quantity: int

def lookup(prefix: str, item: Item) -> Item:
    assert prefix == "catalog"
    assert isinstance(item, Item)
    return item

class Lookup:
    def __call__(self, item: Item) -> Item:
        assert isinstance(item, Item)
        return item

async def main():
    os.environ["GOOGLE_GENAI_USE_VERTEXAI"] = "true"
    for func in [functools.partial(lookup, "catalog"), Lookup()]:
        tool = FunctionTool(func)
        model = ScriptedModel(responses=[response(types.Part(function_call=types.FunctionCall(name=tool.name, args={"item": {"name": "book", "quantity": 2}}))), response(types.Part(text="done"))])
        events, _ = await run_agent(model, [tool])
        declaration = model.requests[0].config.tools[0].function_declarations[0]
        assert declaration.response_json_schema == Item.model_json_schema()
        assert set(declaration.parameters_json_schema["properties"]) == {"item"}
        replies = [part.function_response.model_dump()["response"] for event in events if event.content for part in event.content.parts or [] if part.function_response]
        assert replies == [{"result": {"name": "book", "quantity": 2}}], replies
        print("PASS:", tool.name, "resolved postponed input/output annotations, converted JSON to Item, returned", replies)

asyncio.run(main())
PASS: partial resolved postponed input/output annotations, converted JSON to Item, returned [{'result': {'name': 'book', 'quantity': 2}}]
PASS: Lookup resolved postponed input/output annotations, converted JSON to Item, returned [{'result': {'name': 'book', 'quantity': 2}}]

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.

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