From 6999aaf099621abebc31e769c20affba5131ac2a Mon Sep 17 00:00:00 2001 From: Frank Goldfish Date: Mon, 16 Mar 2026 21:31:15 -0700 Subject: [PATCH] fix: unwrap FieldInfo default_factory/default in @tool decorator to prevent PydanticJsonSchemaWarning When a @tool parameter uses Field(default_factory=...) or Field(default=...), param.default is a FieldInfo object. Previously this was passed as: Field(default=, description=...) Since FieldInfo is not JSON-serializable, Pydantic emitted: PydanticJsonSchemaWarning: Default value ... is not JSON serializable Fix: in _extract_annotated_metadata(), detect when param_default is a FieldInfo and forward its default_factory or default to the new Field() correctly. Also inherit the FieldInfo's description when no higher-priority description exists. Adds regression test covering default_factory, plain default, required, and description forwarding cases. Fixes #1914 --- src/strands/tools/decorator.py | 24 ++++++++++++++++++-- tests/strands/tools/test_decorator.py | 32 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index 9207df9b8..a6452d889 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -63,6 +63,7 @@ def my_tool(param1: str, param2: int = 42) -> dict: from pydantic import BaseModel, Field, create_model from pydantic.fields import FieldInfo from pydantic_core import PydanticSerializationError +from pydantic_core import PydanticUndefined from typing_extensions import override from ..interrupt import InterruptException @@ -163,8 +164,25 @@ def _extract_annotated_metadata( final_description = description if final_description is None: final_description = self.param_descriptions.get(param_name) or f"Parameter {param_name}" - # Create FieldInfo object from scratch - final_field = Field(default=param_default, description=final_description) + # Create FieldInfo object from scratch. + # If param_default is itself a FieldInfo (e.g. the user wrote + # `items: list[str] = Field(default_factory=list, description="...")`) + # we must forward its default/default_factory rather than wrapping the + # FieldInfo object as a default value, which would make it non-JSON- + # serializable and trigger PydanticJsonSchemaWarning. + if isinstance(param_default, FieldInfo): + # Also inherit description from the FieldInfo if no higher-priority + # description was found (Annotated string or docstring). + if final_description == f"Parameter {param_name}" and param_default.description: + final_description = param_default.description + if param_default.default_factory is not None: + final_field = Field(default_factory=param_default.default_factory, description=final_description) + elif param_default.default is not PydanticUndefined: + final_field = Field(default=param_default.default, description=final_description) + else: + final_field = Field(..., description=final_description) + else: + final_field = Field(default=param_default, description=final_description) return actual_type, final_field @@ -361,6 +379,8 @@ def _clean_pydantic_schema(self, schema: dict[str, Any]) -> None: if key in prop_schema: del prop_schema[key] + + def validate_input(self, input_data: dict[str, Any]) -> dict[str, Any]: """Validate input data using the Pydantic model. diff --git a/tests/strands/tools/test_decorator.py b/tests/strands/tools/test_decorator.py index cc1158983..c99c5ed53 100644 --- a/tests/strands/tools/test_decorator.py +++ b/tests/strands/tools/test_decorator.py @@ -2101,3 +2101,35 @@ def my_tool(name: str, tag: str | None = None) -> str: # Since tag is not required, anyOf should be simplified away assert "anyOf" not in schema["properties"]["tag"] assert schema["properties"]["tag"]["type"] == "string" + + +def test_tool_field_default_factory_no_pydantic_warning(): + """Test that Field(default_factory=...) does not emit PydanticJsonSchemaWarning. + + Regression test for https://github.com/strands-agents/sdk-python/issues/1914. + When a parameter default is a FieldInfo with default_factory, the decorator + must unwrap it and pass default_factory to the new Field() rather than passing + the FieldInfo object itself as default= (which is not JSON-serializable). + """ + import warnings + from pydantic import Field + + @strands.tool + def example( + items: list[str] = Field(default_factory=list, description="items"), + ) -> int: + """Example tool.""" + return len(items) + + with warnings.catch_warnings(): + warnings.filterwarnings("error", category=UserWarning) + # Accessing tool_spec triggers schema generation — must not raise + spec = example.tool_spec + + schema = spec["inputSchema"]["json"] + # items should not be required (has a default) + assert "items" not in schema.get("required", []) + # The non-serializable default should have been stripped, not present + assert "default" not in schema["properties"]["items"] + # Description and type should still be present + assert schema["properties"]["items"]["description"] == "items"