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
36 changes: 34 additions & 2 deletions src/google/adk/tools/_function_tool_declarations.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from __future__ import annotations

import collections.abc
import functools
import inspect
import logging
from typing import Any
Expand All @@ -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(
Expand Down Expand Up @@ -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.
Expand All @@ -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]:
Expand Down Expand Up @@ -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,
Expand Down
89 changes: 89 additions & 0 deletions tests/unittests/tools/test_function_tool_declarations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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')
Loading