From 9114b254452d800bf1d62549a3c6bc85920ba41c Mon Sep 17 00:00:00 2001 From: Aarav Mittal <137450929+a2105z@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:52:04 -0700 Subject: [PATCH] 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 --- .../adk/tools/set_model_response_tool.py | 95 +++++++++++++++++++ .../tools/test_set_model_response_tool.py | 33 +++++++ 2 files changed, 128 insertions(+) diff --git a/src/google/adk/tools/set_model_response_tool.py b/src/google/adk/tools/set_model_response_tool.py index cdd94d0ebdc..6b0d554fffb 100644 --- a/src/google/adk/tools/set_model_response_tool.py +++ b/src/google/adk/tools/set_model_response_tool.py @@ -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. @@ -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.""" @@ -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 diff --git a/tests/unittests/tools/test_set_model_response_tool.py b/tests/unittests/tools/test_set_model_response_tool.py index c6d5059fb84..f1f87d1fcd7 100644 --- a/tests/unittests/tools/test_set_model_response_tool.py +++ b/tests/unittests/tools/test_set_model_response_tool.py @@ -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."""