From 33a3170ca079797b97518048522681b5fe5ad61d Mon Sep 17 00:00:00 2001 From: Yao Lu Date: Fri, 24 Apr 2026 23:44:36 +0800 Subject: [PATCH] fix: handle Field(default_factory=...) in @tool decorator without warning When a @tool-decorated function uses Field(default_factory=list) as a parameter default, the decorator wraps the FieldInfo in another Field() call, producing a non-serialisable default that triggers PydanticJsonSchemaWarning. Detect when param_default is already a FieldInfo instance and extract its default/default_factory/description instead of wrapping it again. Closes #1914 --- src/strands/tools/decorator.py | 26 +++++++++++++++-- tests/strands/tools/test_decorator.py | 42 +++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index 9207df9b8..9bf4cf1ee 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -62,7 +62,7 @@ def my_tool(param1: str, param2: int = 42) -> dict: import docstring_parser from pydantic import BaseModel, Field, create_model from pydantic.fields import FieldInfo -from pydantic_core import PydanticSerializationError +from pydantic_core import PydanticSerializationError, PydanticUndefined from typing_extensions import override from ..interrupt import InterruptException @@ -163,8 +163,28 @@ 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. + # When the caller uses ``Field(default_factory=list)`` (or similar) as a + # parameter default, ``param_default`` is already a ``FieldInfo`` instance. + # Passing it directly to ``Field(default=...)`` would wrap it in *another* + # FieldInfo, producing a non-serialisable default and triggering a + # ``PydanticJsonSchemaWarning``. Detect this case and forward the + # original default / default_factory instead. + field_kwargs: dict[str, Any] = {"description": final_description} + if isinstance(param_default, FieldInfo): + # Prefer the description already extracted from Annotated / docstring, + # but fall back to the one embedded in the FieldInfo when no other + # source provides one. + if final_description == f"Parameter {param_name}" and param_default.description: + field_kwargs["description"] = param_default.description + if param_default.default_factory is not None: + field_kwargs["default_factory"] = param_default.default_factory + elif param_default.default is not PydanticUndefined: + field_kwargs["default"] = param_default.default + else: + field_kwargs["default"] = param_default + + final_field = Field(**field_kwargs) return actual_type, final_field diff --git a/tests/strands/tools/test_decorator.py b/tests/strands/tools/test_decorator.py index cc1158983..87f0ecc54 100644 --- a/tests/strands/tools/test_decorator.py +++ b/tests/strands/tools/test_decorator.py @@ -2101,3 +2101,45 @@ 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_warning(): + """Field(default_factory=...) must not trigger PydanticJsonSchemaWarning. + + Regression test for https://github.com/strands-agents/sdk-python/issues/1914. + """ + import warnings + + from pydantic import Field + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + @strands.tool + def factory_tool(items: list = Field(default_factory=list), tags: list = Field(default_factory=list)): # noqa: B008 + """Tool with default_factory params.""" + return items + tags + + pydantic_warnings = [w for w in caught if "PydanticJsonSchema" in w.category.__name__] + assert pydantic_warnings == [], f"Unexpected warnings: {pydantic_warnings}" + + schema = factory_tool.tool_spec["inputSchema"]["json"] + # items and tags should be optional (not in required) + assert "items" not in schema.get("required", []) + assert "tags" not in schema.get("required", []) + assert schema["properties"]["items"]["type"] == "array" + assert schema["properties"]["tags"]["type"] == "array" + + +def test_tool_field_default_value(): + """Field(default=...) as a parameter default is handled correctly.""" + from pydantic import Field + + @strands.tool + def default_val_tool(name: str = Field(default="world", description="A name")): + """Tool with Field default value.""" + return name + + schema = default_val_tool.tool_spec["inputSchema"]["json"] + assert "name" not in schema.get("required", []) + assert schema["properties"]["name"]["description"] == "A name"