Skip to content

Commit f187b29

Browse files
committed
feat(tools): add support for string descriptions in Annotated
1 parent 662c168 commit f187b29

File tree

2 files changed

+79
-105
lines changed

2 files changed

+79
-105
lines changed

src/strands/tools/decorator.py

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ def my_tool(param1: str, param2: int = 42) -> dict:
4444
import functools
4545
import inspect
4646
import logging
47-
from copy import copy
4847
from typing import (
4948
Annotated,
5049
Any,
@@ -65,7 +64,6 @@ def my_tool(param1: str, param2: int = 42) -> dict:
6564
import docstring_parser
6665
from pydantic import BaseModel, Field, create_model
6766
from pydantic.fields import FieldInfo
68-
from pydantic_core import PydanticUndefined
6967
from typing_extensions import override
7068

7169
from ..interrupt import InterruptException
@@ -119,47 +117,57 @@ def __init__(self, func: Callable[..., Any], context_param: str | None = None) -
119117
def _extract_annotated_metadata(
120118
self, annotation: Any, param_name: str, param_default: Any
121119
) -> tuple[Any, FieldInfo]:
122-
"""Extract type and create FieldInfo from Annotated type hint.
120+
"""Extracts type and a simple string description from an Annotated type hint.
123121
124122
Returns:
125-
(actual_type, field_info) where field_info is always a FieldInfo instance
123+
A tuple of (actual_type, field_info), where field_info is a new, simple
124+
Pydantic FieldInfo instance created from the extracted metadata.
126125
"""
127126
actual_type = annotation
128-
field_info: FieldInfo | None = None
129127
description: str | None = None
130128

131129
if get_origin(annotation) is Annotated:
132130
args = get_args(annotation)
133131
actual_type = args[0]
132+
133+
# Look through metadata for a string description or a FieldInfo object.
134134
for meta in args[1:]:
135-
if isinstance(meta, FieldInfo):
136-
field_info = meta
137-
elif isinstance(meta, str):
135+
if isinstance(meta, str):
138136
description = meta
137+
elif isinstance(meta, FieldInfo):
138+
# --- Future Contributor Note ---
139+
# We are explicitly blocking the use of `pydantic.Field` within `Annotated`
140+
# because of the complexities of Pydantic v2's immutable Core Schema.
141+
#
142+
# Once a Pydantic model's schema is built, its `FieldInfo` objects are
143+
# effectively frozen. Attempts to mutate a `FieldInfo` object after
144+
# creation (e.g., by copying it and setting `.description` or `.default`)
145+
# are unreliable because the underlying Core Schema does not see these changes.
146+
#
147+
# The correct way to support this would be to reliably extract all
148+
# constraints (ge, le, pattern, etc.) from the original FieldInfo and
149+
# rebuild a new one from scratch. However, these constraints are not
150+
# stored as public attributes, making them difficult to inspect reliably.
151+
#
152+
# Deferring this complexity until there is clear demand and a robust
153+
# pattern for inspecting FieldInfo constraints is established.
154+
raise NotImplementedError(
155+
"Using pydantic.Field within Annotated is not yet supported for tool decorators. "
156+
"Please use a simple string for the description, or define constraints in the function's "
157+
"docstring."
158+
)
139159

140-
# Final description — always a string, never None
141-
final_description = (
142-
description
143-
if description is not None
144-
else (
145-
field_info.description
146-
if field_info and field_info.description is not None
147-
else self.param_descriptions.get(param_name) or f"Parameter {param_name}"
148-
)
149-
)
150-
151-
# Build final FieldInfo
152-
if field_info:
153-
final_field = copy(field_info)
154-
final_field.description = final_description
155-
156-
# ONLY override default if Field has no default AND signature has one.
157-
# Pydantic uses `PydanticUndefined` to signify no default was provided,
158-
# which is distinct from an explicit default of `None`.
159-
if field_info.default is PydanticUndefined and param_default is not ...:
160-
final_field.default = param_default
161-
else:
162-
final_field = Field(default=param_default, description=final_description)
160+
# Determine the final description with a clear priority order.
161+
# Priority: 1. Annotated string -> 2. Docstring -> 3. Fallback
162+
final_description = description
163+
if final_description is None:
164+
final_description = self.param_descriptions.get(param_name)
165+
if final_description is None:
166+
final_description = f"Parameter {param_name}"
167+
168+
# Create a new, simple FieldInfo object from scratch.
169+
# This avoids all the immutability and mutation issues we encountered previously.
170+
final_field = Field(default=param_default, description=final_description)
163171

164172
return actual_type, final_field
165173

tests/strands/tools/test_decorator.py

Lines changed: 40 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1484,30 +1484,16 @@ def annotated_tool(
14841484

14851485

14861486
def test_tool_decorator_annotated_pydantic_field_constraints():
1487-
"""Test tool decorator with Pydantic Field in Annotated."""
1487+
"""Test that using pydantic.Field in Annotated raises a NotImplementedError."""
1488+
with pytest.raises(NotImplementedError, match="Using pydantic.Field within Annotated is not yet supported"):
14881489

1489-
@strands.tool
1490-
def field_annotated_tool(
1491-
email: Annotated[str, Field(description="User's email address", pattern=r"^[\w\.-]+@[\w\.-]+\\.\w+$")],
1492-
score: Annotated[int, Field(description="Score between 0-100", ge=0, le=100)] = 50,
1493-
) -> str:
1494-
"""Tool with Pydantic Field annotations."""
1495-
return f"{email}: {score}"
1496-
1497-
spec = field_annotated_tool.tool_spec
1498-
schema = spec["inputSchema"]["json"]
1499-
1500-
# Check descriptions from Field
1501-
assert schema["properties"]["email"]["description"] == "User's email address"
1502-
assert schema["properties"]["score"]["description"] == "Score between 0-100"
1503-
1504-
# Check that constraints are preserved
1505-
assert schema["properties"]["score"]["minimum"] == 0
1506-
assert schema["properties"]["score"]["maximum"] == 100
1507-
1508-
# Check required fields
1509-
assert "email" in schema["required"]
1510-
assert "score" not in schema["required"] # Has default
1490+
@strands.tool
1491+
def field_annotated_tool(
1492+
email: Annotated[str, Field(description="User's email address", pattern=r"^[\w\.-]+@[\w\.-]+\\.w+$")],
1493+
score: Annotated[int, Field(description="Score between 0-100", ge=0, le=100)] = 50,
1494+
) -> str:
1495+
"""Tool with Pydantic Field annotations."""
1496+
return f"{email}: {score}"
15111497

15121498

15131499
def test_tool_decorator_annotated_overrides_docstring():
@@ -1574,31 +1560,23 @@ def complex_annotated_tool(
15741560

15751561

15761562
def test_tool_decorator_annotated_mixed_styles():
1577-
"""Test tool with mixed annotation styles."""
1578-
1579-
@strands.tool
1580-
def mixed_tool(
1581-
plain: str,
1582-
annotated_str: Annotated[str, "String description"],
1583-
annotated_field: Annotated[int, Field(description="Field description", ge=0)],
1584-
docstring_only: int,
1585-
) -> str:
1586-
"""Tool with mixed parameter styles.
1587-
1588-
Args:
1589-
plain: Plain parameter description
1590-
docstring_only: Docstring description for this param
1591-
"""
1592-
return "mixed"
1563+
"""Test that using pydantic.Field in a mixed-style annotation raises NotImplementedError."""
1564+
with pytest.raises(NotImplementedError, match="Using pydantic.Field within Annotated is not yet supported"):
15931565

1594-
spec = mixed_tool.tool_spec
1595-
schema = spec["inputSchema"]["json"]
1566+
@strands.tool
1567+
def mixed_tool(
1568+
plain: str,
1569+
annotated_str: Annotated[str, "String description"],
1570+
annotated_field: Annotated[int, Field(description="Field description", ge=0)],
1571+
docstring_only: int,
1572+
) -> str:
1573+
"""Tool with mixed parameter styles.
15961574
1597-
# Check each style works correctly
1598-
assert schema["properties"]["plain"]["description"] == "Plain parameter description"
1599-
assert schema["properties"]["annotated_str"]["description"] == "String description"
1600-
assert schema["properties"]["annotated_field"]["description"] == "Field description"
1601-
assert schema["properties"]["docstring_only"]["description"] == "Docstring description for this param"
1575+
Args:
1576+
plain: Plain parameter description
1577+
docstring_only: Docstring description for this param
1578+
"""
1579+
return "mixed"
16021580

16031581

16041582
@pytest.mark.asyncio
@@ -1624,24 +1602,19 @@ def execution_test(name: Annotated[str, "User name"], count: Annotated[int, "Num
16241602

16251603

16261604
def test_tool_decorator_annotated_no_description_fallback():
1627-
"""Test that Annotated without description falls back to docstring."""
1605+
"""Test that Annotated with a Field raises NotImplementedError."""
1606+
with pytest.raises(NotImplementedError, match="Using pydantic.Field within Annotated is not yet supported"):
16281607

1629-
@strands.tool
1630-
def no_desc_annotated(
1631-
param: Annotated[str, Field()], # Field without description
1632-
) -> str:
1633-
"""Tool with Annotated but no description.
1634-
1635-
Args:
1636-
param: Docstring description
1637-
"""
1638-
return param
1639-
1640-
spec = no_desc_annotated.tool_spec
1641-
schema = spec["inputSchema"]["json"]
1608+
@strands.tool
1609+
def no_desc_annotated(
1610+
param: Annotated[str, Field()], # Field without description
1611+
) -> str:
1612+
"""Tool with Annotated but no description.
16421613
1643-
# Should fall back to docstring
1644-
assert schema["properties"]["param"]["description"] == "Docstring description"
1614+
Args:
1615+
param: Docstring description
1616+
"""
1617+
return param
16451618

16461619

16471620
def test_tool_decorator_annotated_empty_string_description():
@@ -1683,16 +1656,9 @@ def validation_tool(age: Annotated[int, "User age"]) -> str:
16831656

16841657

16851658
def test_tool_decorator_annotated_field_with_inner_default():
1686-
"""Test that a default value in an Annotated Field is respected."""
1687-
1688-
@strands.tool
1689-
def inner_default_tool(name: str, level: Annotated[int, Field(description="A level value", default=10)]) -> str:
1690-
return f"{name} is at level {level}"
1691-
1692-
spec = inner_default_tool.tool_spec
1693-
schema = spec["inputSchema"]["json"]
1659+
"""Test that a default value in an Annotated Field raises NotImplementedError."""
1660+
with pytest.raises(NotImplementedError, match="Using pydantic.Field within Annotated is not yet supported"):
16941661

1695-
# 'level' should not be required because its Field has a default
1696-
assert "name" in schema["required"]
1697-
assert "level" not in schema["required"]
1698-
assert schema["properties"]["level"]["default"] == 10
1662+
@strands.tool
1663+
def inner_default_tool(name: str, level: Annotated[int, Field(description="A level value", default=10)]) -> str:
1664+
return f"{name} is at level {level}"

0 commit comments

Comments
 (0)