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
24 changes: 22 additions & 2 deletions src/strands/tools/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
32 changes: 32 additions & 0 deletions tests/strands/tools/test_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"