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
95 changes: 95 additions & 0 deletions src/google/adk/tools/set_model_response_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,51 @@
from .tool_context import ToolContext


def _merge_json_schema_descriptions(
target: dict[str, Any], source: dict[str, Any]
) -> None:
"""Copies ``description`` values from ``source`` onto ``target`` in place.

Walks ``properties`` / ``items`` so nested object and list schemas keep the
Field(description=...) metadata from the original Pydantic output schema.
"""
source_props = source.get('properties')
target_props = target.get('properties')
if isinstance(source_props, dict) and isinstance(target_props, dict):
for name, source_prop in source_props.items():
if name not in target_props or not isinstance(source_prop, dict):
continue
target_prop = target_props[name]
if not isinstance(target_prop, dict):
continue
description = source_prop.get('description')
if isinstance(description, str) and description:
target_prop['description'] = description
_merge_json_schema_descriptions(target_prop, source_prop)

source_items = source.get('items')
target_items = target.get('items')
if isinstance(source_items, dict) and isinstance(target_items, dict):
description = source_items.get('description')
if isinstance(description, str) and description:
target_items['description'] = description
_merge_json_schema_descriptions(target_items, source_items)


def _apply_descriptions_to_schema_properties(
properties: dict[str, types.Schema] | None,
model_fields: dict[str, Any],
) -> None:
"""Sets Schema.description from Pydantic FieldInfo.description when present."""
if not properties:
return
for name, field_info in model_fields.items():
prop = properties.get(name)
description = getattr(field_info, 'description', None)
if prop is not None and isinstance(description, str) and description:
prop.description = description


class SetModelResponseTool(BaseTool):
"""Internal tool used for output schema workaround.

Expand Down Expand Up @@ -136,6 +181,55 @@ def set_model_response() -> str:
description=self.func.__doc__.strip() if self.func.__doc__ else '',
)

def _preserve_output_schema_field_descriptions(
self, function_decl: types.FunctionDeclaration
) -> None:
"""Restores Field(description=...) lost during function-declaration build.

``build_function_declaration`` rebuilds parameters from ``inspect.Parameter``
objects, which cannot carry Pydantic field descriptions. Re-apply them from
the original ``output_schema`` so the model still sees the semantic hints.
"""
if self._is_basemodel:
source_schema = self.output_schema.model_json_schema()
if function_decl.parameters_json_schema is not None:
_merge_json_schema_descriptions(
function_decl.parameters_json_schema, source_schema
)
elif function_decl.parameters is not None:
_apply_descriptions_to_schema_properties(
function_decl.parameters.properties,
self.output_schema.model_fields,
)
return

if self._is_list_of_basemodel:
inner_type = get_list_inner_type(self.output_schema)
if not is_basemodel_schema(inner_type):
return
source_schema = {
'properties': {
'items': {
'type': 'array',
'items': inner_type.model_json_schema(),
}
}
}
if function_decl.parameters_json_schema is not None:
_merge_json_schema_descriptions(
function_decl.parameters_json_schema, source_schema
)
elif (
function_decl.parameters is not None
and function_decl.parameters.properties
and 'items' in function_decl.parameters.properties
):
items_schema = function_decl.parameters.properties['items']
if items_schema.items is not None:
_apply_descriptions_to_schema_properties(
items_schema.items.properties, inner_type.model_fields
)

@override
def _get_declaration(self) -> Optional[types.FunctionDeclaration]:
"""Gets the OpenAPI specification of this tool."""
Expand All @@ -146,6 +240,7 @@ def _get_declaration(self) -> Optional[types.FunctionDeclaration]:
variant=self._api_variant,
)
)
self._preserve_output_schema_field_descriptions(function_decl)
return function_decl

@override
Expand Down
33 changes: 33 additions & 0 deletions tests/unittests/tools/test_set_model_response_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,39 @@ def test_get_declaration_preserves_field_defaults():
assert properties['is_active']['default'] is True


def test_get_declaration_preserves_field_descriptions():
"""Field(description=...) from output_schema must reach the tool schema."""
tool = SetModelResponseTool(PersonSchema)

declaration = tool._get_declaration()

assert declaration is not None
properties = declaration.model_dump(exclude_none=True)[
'parameters_json_schema'
]['properties']
assert properties['name']['description'] == "A person's name"
assert properties['age']['description'] == "A person's age"
assert properties['city']['description'] == 'The city they live in'


def test_get_declaration_preserves_list_item_field_descriptions():
"""list[BaseModel] item Field descriptions remain available to the model."""

class Item(BaseModel):
id: int = Field(description='Item ID')
name: str = Field(description='Item name')

tool = SetModelResponseTool(list[Item])
declaration = tool._get_declaration()

assert declaration is not None
schema = declaration.model_dump(exclude_none=True)['parameters_json_schema']
# list[BaseModel] is emitted via $ref into $defs; descriptions live there.
item_props = schema['$defs']['Item']['properties']
assert item_props['id']['description'] == 'Item ID'
assert item_props['name']['description'] == 'Item name'


@pytest.mark.asyncio
async def test_run_async_valid_data():
"""Test tool execution with valid data."""
Expand Down