From cd07550e27a6ee0ef9657caf725051a7937607a4 Mon Sep 17 00:00:00 2001 From: aslamalkarywk7 Date: Fri, 18 Sep 2026 22:58:00 +0000 Subject: [PATCH] fix(tools): name partial-backed tools after wrapped func, use __call__ doc for instances (WIP, awaiting issue approval) Do not open PR before maintainer approval on google/adk-python#7190. --- .../adk/tools/_function_tool_declarations.py | 36 +++++++- .../tools/test_function_tool_declarations.py | 89 +++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/src/google/adk/tools/_function_tool_declarations.py b/src/google/adk/tools/_function_tool_declarations.py index ebb6af7de5..3d74ad1bd0 100644 --- a/src/google/adk/tools/_function_tool_declarations.py +++ b/src/google/adk/tools/_function_tool_declarations.py @@ -25,6 +25,7 @@ from __future__ import annotations import collections.abc +import functools import inspect import logging from typing import Any @@ -42,6 +43,7 @@ from ..utils.variant_utils import get_google_llm_variant from ..utils.variant_utils import GoogleLLMVariant +from ..utils._callable_utils import unwrap_callable def _get_function_fields( @@ -104,6 +106,9 @@ def get_callable_name(func: Callable[..., Any]) -> str: """Returns the name a callable is advertised and registered under. Callable objects carry no `__name__`, so they fall back to their class name. + `functools.partial` objects likewise carry no `__name__`, so they are + unwrapped (handling nested partials) to the underlying function instead of + all collapsing to `'partial'`. This is the single source of truth for both the declaration sent to the model and the key the tool is registered under: if the two disagree, the model is told about a tool it cannot invoke. @@ -114,7 +119,10 @@ def get_callable_name(func: Callable[..., Any]) -> str: Returns: The name to use for the callable. """ - return getattr(func, '__name__', None) or func.__class__.__name__ + target = func + while isinstance(target, functools.partial): + target = target.func + return getattr(target, '__name__', None) or func.__class__.__name__ def _flatten_optional_any_of(schema: dict[str, Any]) -> dict[str, Any]: @@ -334,7 +342,31 @@ def build_function_declaration_with_json_schema( ) # Handle Callable functions - description = inspect.cleandoc(func.__doc__) if func.__doc__ else None + # functools.partial instances expose the partial *type* docstring via + # __doc__, which would misdescribe the tool: describe the wrapped function + # instead. Callable instances documented only on __call__ fall back to it. + # See https://github.com/google/adk-python/issues/7190. + description_target = ( + unwrap_callable(func) if isinstance(func, functools.partial) else func + ) + description = ( + inspect.cleandoc(description_target.__doc__) + if getattr(description_target, '__doc__', None) + else None + ) + if ( + not description + and not isinstance(func, (type, functools.partial)) + and not inspect.isroutine(func) + ): + # Plain functions, builtins and methods expose a slot-wrapper __call__ + # whose docstring ("Call self as a function.") would misdescribe the + # tool, so only callable *instances* documented on __call__ fall back + # to it. Partials are excluded for the same reason: their description + # comes from the wrapped function above. + call_method = getattr(func, '__call__', None) + if call_method is not None and getattr(call_method, '__doc__', None): + description = inspect.cleandoc(call_method.__doc__) func_name = get_callable_name(func) declaration = types.FunctionDeclaration( name=func_name, diff --git a/tests/unittests/tools/test_function_tool_declarations.py b/tests/unittests/tools/test_function_tool_declarations.py index 408a371fe1..3451fe96a7 100644 --- a/tests/unittests/tools/test_function_tool_declarations.py +++ b/tests/unittests/tools/test_function_tool_declarations.py @@ -23,6 +23,7 @@ from collections.abc import Sequence import dataclasses from enum import Enum +import functools import os from typing import Any from typing import AsyncGenerator @@ -33,6 +34,7 @@ from absl.testing import parameterized from google.adk.tools._function_tool_declarations import build_function_declaration_with_json_schema +from google.adk.tools._function_tool_declarations import get_callable_name from google.adk.tools.tool_context import ToolContext from pydantic import BaseModel from pydantic import Field @@ -1034,3 +1036,90 @@ def sync_counter(start: int) -> Generator[int, None, None]: self.assertEqual(decl.name, "sync_counter") # Should extract int from Generator[int, None, None] self.assertEqual(decl.response_json_schema, {"type": "integer"}) + + +class TestPartialAndCallableNaming(parameterized.TestCase): + """Naming and description for functools.partial and callable objects. + + Regression tests for https://github.com/google/adk-python/issues/7190: + every partial used to be advertised as 'partial' (so only the last one + stayed registered) and callable objects documented only on __call__ lost + their description in the declaration. + """ + + def test_partial_uses_wrapped_function_name_and_doc(self): + def get_weather(key: str, city: str) -> str: + """Current weather for a city.""" + return city + + decl = build_function_declaration_with_json_schema( + functools.partial(get_weather, 'k') + ) + + self.assertEqual(decl.name, 'get_weather') + self.assertEqual(decl.description, 'Current weather for a city.') + + def test_nested_partial_uses_wrapped_function_name(self): + def get_weather(key: str, city: str) -> str: + """Current weather for a city.""" + return city + + decl = build_function_declaration_with_json_schema( + functools.partial(functools.partial(get_weather, 'k')) + ) + + self.assertEqual(decl.name, 'get_weather') + self.assertEqual(decl.description, 'Current weather for a city.') + + def test_distinct_partials_get_distinct_names(self): + def get_weather(key: str, city: str) -> str: + """Current weather for a city.""" + return city + + def get_forecast(key: str, city: str) -> str: + """Forecast for a city.""" + return city + + weather_decl = build_function_declaration_with_json_schema( + functools.partial(get_weather, 'k') + ) + forecast_decl = build_function_declaration_with_json_schema( + functools.partial(get_forecast, 'k') + ) + + self.assertEqual(weather_decl.name, 'get_weather') + self.assertEqual(forecast_decl.name, 'get_forecast') + + def test_callable_instance_uses_class_name_and_call_doc(self): + class OrderLookup: + def __call__(self, order_id: str) -> str: + """Finds an order by id.""" + return order_id + + decl = build_function_declaration_with_json_schema(OrderLookup()) + + self.assertEqual(decl.name, 'OrderLookup') + self.assertEqual(decl.description, 'Finds an order by id.') + + def test_callable_instance_keeps_class_doc_when_call_undocumented(self): + class Documented: + """Class-level docs.""" + + def __call__(self, x: int) -> int: + return x + + decl = build_function_declaration_with_json_schema(Documented()) + + self.assertEqual(decl.name, 'Documented') + self.assertEqual(decl.description, 'Class-level docs.') + + def test_get_callable_name_directly(self): + def get_weather(key: str, city: str) -> str: + """Current weather for a city.""" + return city + + self.assertEqual( + get_callable_name(functools.partial(get_weather, 'k')), + 'get_weather', + ) + self.assertEqual(get_callable_name(get_weather), 'get_weather')