Skip to content

Commit 9114b25

Browse files
committed
feat(tools): Preserve Field descriptions in set_model_response schema
build_function_declaration rebuilds parameters from inspect.Parameter, which cannot carry Pydantic Field(description=...) metadata, so semantic hints in output_schema were dropped before reaching the model. Re-apply those descriptions onto the generated tool declaration. Fixes #6707
1 parent 19c9455 commit 9114b25

2 files changed

Lines changed: 128 additions & 0 deletions

File tree

src/google/adk/tools/set_model_response_tool.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,51 @@
3434
from .tool_context import ToolContext
3535

3636

37+
def _merge_json_schema_descriptions(
38+
target: dict[str, Any], source: dict[str, Any]
39+
) -> None:
40+
"""Copies ``description`` values from ``source`` onto ``target`` in place.
41+
42+
Walks ``properties`` / ``items`` so nested object and list schemas keep the
43+
Field(description=...) metadata from the original Pydantic output schema.
44+
"""
45+
source_props = source.get('properties')
46+
target_props = target.get('properties')
47+
if isinstance(source_props, dict) and isinstance(target_props, dict):
48+
for name, source_prop in source_props.items():
49+
if name not in target_props or not isinstance(source_prop, dict):
50+
continue
51+
target_prop = target_props[name]
52+
if not isinstance(target_prop, dict):
53+
continue
54+
description = source_prop.get('description')
55+
if isinstance(description, str) and description:
56+
target_prop['description'] = description
57+
_merge_json_schema_descriptions(target_prop, source_prop)
58+
59+
source_items = source.get('items')
60+
target_items = target.get('items')
61+
if isinstance(source_items, dict) and isinstance(target_items, dict):
62+
description = source_items.get('description')
63+
if isinstance(description, str) and description:
64+
target_items['description'] = description
65+
_merge_json_schema_descriptions(target_items, source_items)
66+
67+
68+
def _apply_descriptions_to_schema_properties(
69+
properties: dict[str, types.Schema] | None,
70+
model_fields: dict[str, Any],
71+
) -> None:
72+
"""Sets Schema.description from Pydantic FieldInfo.description when present."""
73+
if not properties:
74+
return
75+
for name, field_info in model_fields.items():
76+
prop = properties.get(name)
77+
description = getattr(field_info, 'description', None)
78+
if prop is not None and isinstance(description, str) and description:
79+
prop.description = description
80+
81+
3782
class SetModelResponseTool(BaseTool):
3883
"""Internal tool used for output schema workaround.
3984
@@ -136,6 +181,55 @@ def set_model_response() -> str:
136181
description=self.func.__doc__.strip() if self.func.__doc__ else '',
137182
)
138183

184+
def _preserve_output_schema_field_descriptions(
185+
self, function_decl: types.FunctionDeclaration
186+
) -> None:
187+
"""Restores Field(description=...) lost during function-declaration build.
188+
189+
``build_function_declaration`` rebuilds parameters from ``inspect.Parameter``
190+
objects, which cannot carry Pydantic field descriptions. Re-apply them from
191+
the original ``output_schema`` so the model still sees the semantic hints.
192+
"""
193+
if self._is_basemodel:
194+
source_schema = self.output_schema.model_json_schema()
195+
if function_decl.parameters_json_schema is not None:
196+
_merge_json_schema_descriptions(
197+
function_decl.parameters_json_schema, source_schema
198+
)
199+
elif function_decl.parameters is not None:
200+
_apply_descriptions_to_schema_properties(
201+
function_decl.parameters.properties,
202+
self.output_schema.model_fields,
203+
)
204+
return
205+
206+
if self._is_list_of_basemodel:
207+
inner_type = get_list_inner_type(self.output_schema)
208+
if not is_basemodel_schema(inner_type):
209+
return
210+
source_schema = {
211+
'properties': {
212+
'items': {
213+
'type': 'array',
214+
'items': inner_type.model_json_schema(),
215+
}
216+
}
217+
}
218+
if function_decl.parameters_json_schema is not None:
219+
_merge_json_schema_descriptions(
220+
function_decl.parameters_json_schema, source_schema
221+
)
222+
elif (
223+
function_decl.parameters is not None
224+
and function_decl.parameters.properties
225+
and 'items' in function_decl.parameters.properties
226+
):
227+
items_schema = function_decl.parameters.properties['items']
228+
if items_schema.items is not None:
229+
_apply_descriptions_to_schema_properties(
230+
items_schema.items.properties, inner_type.model_fields
231+
)
232+
139233
@override
140234
def _get_declaration(self) -> Optional[types.FunctionDeclaration]:
141235
"""Gets the OpenAPI specification of this tool."""
@@ -146,6 +240,7 @@ def _get_declaration(self) -> Optional[types.FunctionDeclaration]:
146240
variant=self._api_variant,
147241
)
148242
)
243+
self._preserve_output_schema_field_descriptions(function_decl)
149244
return function_decl
150245

151246
@override

tests/unittests/tools/test_set_model_response_tool.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,39 @@ def test_get_declaration_preserves_field_defaults():
137137
assert properties['is_active']['default'] is True
138138

139139

140+
def test_get_declaration_preserves_field_descriptions():
141+
"""Field(description=...) from output_schema must reach the tool schema."""
142+
tool = SetModelResponseTool(PersonSchema)
143+
144+
declaration = tool._get_declaration()
145+
146+
assert declaration is not None
147+
properties = declaration.model_dump(exclude_none=True)[
148+
'parameters_json_schema'
149+
]['properties']
150+
assert properties['name']['description'] == "A person's name"
151+
assert properties['age']['description'] == "A person's age"
152+
assert properties['city']['description'] == 'The city they live in'
153+
154+
155+
def test_get_declaration_preserves_list_item_field_descriptions():
156+
"""list[BaseModel] item Field descriptions remain available to the model."""
157+
158+
class Item(BaseModel):
159+
id: int = Field(description='Item ID')
160+
name: str = Field(description='Item name')
161+
162+
tool = SetModelResponseTool(list[Item])
163+
declaration = tool._get_declaration()
164+
165+
assert declaration is not None
166+
schema = declaration.model_dump(exclude_none=True)['parameters_json_schema']
167+
# list[BaseModel] is emitted via $ref into $defs; descriptions live there.
168+
item_props = schema['$defs']['Item']['properties']
169+
assert item_props['id']['description'] == 'Item ID'
170+
assert item_props['name']['description'] == 'Item name'
171+
172+
140173
@pytest.mark.asyncio
141174
async def test_run_async_valid_data():
142175
"""Test tool execution with valid data."""

0 commit comments

Comments
 (0)