From 296853b912d9addd4564df3dc408a1f404305f44 Mon Sep 17 00:00:00 2001 From: Ratish1 Date: Fri, 24 Oct 2025 21:00:58 +0400 Subject: [PATCH 01/11] feat(tools): Add support for Annotated type hints in @tool decorator --- src/strands/tools/decorator.py | 71 +++++++- tests/strands/tools/test_decorator.py | 232 +++++++++++++++++++++++++- 2 files changed, 297 insertions(+), 6 deletions(-) diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index 5c49f4b58..f7de287e3 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -44,7 +44,9 @@ def my_tool(param1: str, param2: int = 42) -> dict: import functools import inspect import logging +from copy import copy from typing import ( + Annotated, Any, Callable, Generic, @@ -54,12 +56,15 @@ def my_tool(param1: str, param2: int = 42) -> dict: TypeVar, Union, cast, + get_args, + get_origin, get_type_hints, overload, ) import docstring_parser from pydantic import BaseModel, Field, create_model +from pydantic.fields import FieldInfo from typing_extensions import override from ..interrupt import InterruptException @@ -97,7 +102,12 @@ def __init__(self, func: Callable[..., Any], context_param: str | None = None) - """ self.func = func self.signature = inspect.signature(func) - self.type_hints = get_type_hints(func) + # Preserve Annotated extras when possible (Python 3.9+ / 3.10+ support include_extras) + try: + self.type_hints = get_type_hints(func, include_extras=True) + except TypeError: + # Older Python versions / typing implementations may not accept include_extras + self.type_hints = get_type_hints(func) self._context_param = context_param self._validate_signature() @@ -114,6 +124,32 @@ def __init__(self, func: Callable[..., Any], context_param: str | None = None) - # Create a Pydantic model for validation self.input_model = self._create_input_model() + def _extract_annotated_metadata(self, annotation: Any) -> tuple[Any, Optional[Any]]: + """Extract type and metadata from Annotated type hint. + + Returns: + (actual_type, metadata) where metadata is either: + - a string description + - a pydantic.fields.FieldInfo instance (from Field(...)) + - None if no Annotated extras were found + """ + if get_origin(annotation) is Annotated: + args = get_args(annotation) + actual_type = args[0] # Keep the type as-is (including Optional[T]) + + # Look through metadata for description + for meta in args[1:]: + if isinstance(meta, str): + return actual_type, meta + if isinstance(meta, FieldInfo): + return actual_type, meta + + # Annotated but no useful metadata + return actual_type, None + + # Not annotated + return annotation, None + def _validate_signature(self) -> None: """Verify that ToolContext is used correctly in the function signature.""" for param in self.signature.parameters.values(): @@ -146,13 +182,38 @@ def _create_input_model(self) -> Type[BaseModel]: if self._is_special_parameter(name): continue - # Get parameter type and default + # Get parameter type hint and any Annotated metadata param_type = self.type_hints.get(name, Any) + actual_type, annotated_meta = self._extract_annotated_metadata(param_type) + + # Determine parameter default value default = ... if param.default is inspect.Parameter.empty else param.default - description = self.param_descriptions.get(name, f"Parameter {name}") - # Create Field with description and default - field_definitions[name] = (param_type, Field(default=default, description=description)) + # Determine description (priority: Annotated > docstring > generic) + description: str + if isinstance(annotated_meta, str): + description = annotated_meta + elif isinstance(annotated_meta, FieldInfo) and annotated_meta.description is not None: + description = annotated_meta.description + elif name in self.param_descriptions: + description = self.param_descriptions[name] + else: + description = f"Parameter {name}" + + # Create Field definition for create_model + if isinstance(annotated_meta, FieldInfo): + # Create a defensive copy to avoid mutating a shared FieldInfo instance. + field_info_copy = copy(annotated_meta) + field_info_copy.description = description + + # Update default if specified in the function signature. + if default is not ...: + field_info_copy.default = default + + field_definitions[name] = (actual_type, field_info_copy) + else: + # For non-FieldInfo metadata, create a new Field. + field_definitions[name] = (actual_type, Field(default=default, description=description)) # Create model name based on function name model_name = f"{self.func.__name__.capitalize()}Tool" diff --git a/tests/strands/tools/test_decorator.py b/tests/strands/tools/test_decorator.py index 25f9bc39e..8e64384b2 100644 --- a/tests/strands/tools/test_decorator.py +++ b/tests/strands/tools/test_decorator.py @@ -3,10 +3,11 @@ """ from asyncio import Queue -from typing import Any, AsyncGenerator, Dict, Optional, Union +from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union from unittest.mock import MagicMock import pytest +from pydantic import Field import strands from strands import Agent @@ -1450,3 +1451,232 @@ def test_function_tool_metadata_validate_signature_missing_context_config(): @strands.tool def my_tool(tool_context: ToolContext): pass + + +def test_tool_decorator_annotated_string_description(): + """Test tool decorator with Annotated type hints for descriptions.""" + + @strands.tool + def annotated_tool( + name: Annotated[str, "The user's full name"], + age: Annotated[int, "The user's age in years"], + city: str, # No annotation - should use docstring or generic + ) -> str: + """Tool with annotated parameters. + + Args: + city: The user's city (from docstring) + """ + return f"{name}, {age}, {city}" + + spec = annotated_tool.tool_spec + schema = spec["inputSchema"]["json"] + + # Check that annotated descriptions are used + assert schema["properties"]["name"]["description"] == "The user's full name" + assert schema["properties"]["age"]["description"] == "The user's age in years" + + # Check that docstring is still used for non-annotated params + assert schema["properties"]["city"]["description"] == "The user's city (from docstring)" + + # Verify all are required + assert set(schema["required"]) == {"name", "age", "city"} + + +def test_tool_decorator_annotated_pydantic_field_constraints(): + """Test tool decorator with Pydantic Field in Annotated.""" + + @strands.tool + def field_annotated_tool( + email: Annotated[str, Field(description="User's email address", pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")], + score: Annotated[int, Field(description="Score between 0-100", ge=0, le=100)] = 50, + ) -> str: + """Tool with Pydantic Field annotations.""" + return f"{email}: {score}" + + spec = field_annotated_tool.tool_spec + schema = spec["inputSchema"]["json"] + + # Check descriptions from Field + assert schema["properties"]["email"]["description"] == "User's email address" + assert schema["properties"]["score"]["description"] == "Score between 0-100" + + # Check that constraints are preserved + assert schema["properties"]["score"]["minimum"] == 0 + assert schema["properties"]["score"]["maximum"] == 100 + + # Check required fields + assert "email" in schema["required"] + assert "score" not in schema["required"] # Has default + + +def test_tool_decorator_annotated_overrides_docstring(): + """Test that Annotated descriptions override docstring descriptions.""" + + @strands.tool + def override_tool(param: Annotated[str, "Description from annotation"]) -> str: + """Tool with both annotation and docstring. + + Args: + param: Description from docstring (should be overridden) + """ + return param + + spec = override_tool.tool_spec + schema = spec["inputSchema"]["json"] + + # Annotated description should win + assert schema["properties"]["param"]["description"] == "Description from annotation" + + +def test_tool_decorator_annotated_optional_type(): + """Test tool with Optional types in Annotated.""" + + @strands.tool + def optional_annotated_tool( + required: Annotated[str, "Required parameter"], optional: Annotated[Optional[str], "Optional parameter"] = None + ) -> str: + """Tool with optional annotated parameter.""" + return f"{required}, {optional}" + + spec = optional_annotated_tool.tool_spec + schema = spec["inputSchema"]["json"] + + # Check descriptions + assert schema["properties"]["required"]["description"] == "Required parameter" + assert schema["properties"]["optional"]["description"] == "Optional parameter" + + # Check required list + assert "required" in schema["required"] + assert "optional" not in schema["required"] + + +def test_tool_decorator_annotated_complex_types(): + """Test tool with complex types in Annotated.""" + + @strands.tool + def complex_annotated_tool( + tags: Annotated[List[str], "List of tag strings"], config: Annotated[Dict[str, Any], "Configuration dictionary"] + ) -> str: + """Tool with complex annotated types.""" + return f"Tags: {len(tags)}, Config: {len(config)}" + + spec = complex_annotated_tool.tool_spec + schema = spec["inputSchema"]["json"] + + # Check descriptions + assert schema["properties"]["tags"]["description"] == "List of tag strings" + assert schema["properties"]["config"]["description"] == "Configuration dictionary" + + # Check types are preserved + assert schema["properties"]["tags"]["type"] == "array" + assert schema["properties"]["config"]["type"] == "object" + + +def test_tool_decorator_annotated_mixed_styles(): + """Test tool with mixed annotation styles.""" + + @strands.tool + def mixed_tool( + plain: str, + annotated_str: Annotated[str, "String description"], + annotated_field: Annotated[int, Field(description="Field description", ge=0)], + docstring_only: int, + ) -> str: + """Tool with mixed parameter styles. + + Args: + plain: Plain parameter description + docstring_only: Docstring description for this param + """ + return "mixed" + + spec = mixed_tool.tool_spec + schema = spec["inputSchema"]["json"] + + # Check each style works correctly + assert schema["properties"]["plain"]["description"] == "Plain parameter description" + assert schema["properties"]["annotated_str"]["description"] == "String description" + assert schema["properties"]["annotated_field"]["description"] == "Field description" + assert schema["properties"]["docstring_only"]["description"] == "Docstring description for this param" + + +@pytest.mark.asyncio +async def test_tool_decorator_annotated_execution(alist): + """Test that annotated tools execute correctly.""" + + @strands.tool + def execution_test(name: Annotated[str, "User name"], count: Annotated[int, "Number of times"] = 1) -> str: + """Test execution with annotations.""" + return f"Hello {name} " * count + + # Test tool use + tool_use = {"toolUseId": "test-id", "input": {"name": "Alice", "count": 2}} + stream = execution_test.stream(tool_use, {}) + + result = (await alist(stream))[-1] + assert result["tool_result"]["status"] == "success" + assert "Hello Alice Hello Alice" in result["tool_result"]["content"][0]["text"] + + # Test direct call + direct_result = execution_test("Bob", 3) + assert direct_result == "Hello Bob Hello Bob Hello Bob " + + +def test_tool_decorator_annotated_no_description_fallback(): + """Test that Annotated without description falls back to docstring.""" + + @strands.tool + def no_desc_annotated( + param: Annotated[str, Field()], # Field without description + ) -> str: + """Tool with Annotated but no description. + + Args: + param: Docstring description + """ + return param + + spec = no_desc_annotated.tool_spec + schema = spec["inputSchema"]["json"] + + # Should fall back to docstring + assert schema["properties"]["param"]["description"] == "Docstring description" + + +def test_tool_decorator_annotated_empty_string_description(): + """Test handling of empty string descriptions in Annotated.""" + + @strands.tool + def empty_desc_tool( + param: Annotated[str, ""], # Empty string description + ) -> str: + """Tool with empty annotation description. + + Args: + param: Docstring description + """ + return param + + spec = empty_desc_tool.tool_spec + schema = spec["inputSchema"]["json"] + + # Empty string is still a valid description, should not fall back + assert schema["properties"]["param"]["description"] == "" + + +@pytest.mark.asyncio +async def test_tool_decorator_annotated_validation_error(alist): + """Test that validation works correctly with annotated parameters.""" + + @strands.tool + def validation_tool(age: Annotated[int, "User age"]) -> str: + """Tool for validation testing.""" + return f"Age: {age}" + + # Test with wrong type + tool_use = {"toolUseId": "test-id", "input": {"age": "not an int"}} + stream = validation_tool.stream(tool_use, {}) + + result = (await alist(stream))[-1] + assert result["tool_result"]["status"] == "error" From 8637653697b647374f30f59a29e150992d98fbe8 Mon Sep 17 00:00:00 2001 From: Ratish1 Date: Thu, 30 Oct 2025 18:02:31 +0400 Subject: [PATCH 02/11] feat(tools): address nit --- src/strands/tools/decorator.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index f7de287e3..8e7ed400d 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -102,12 +102,7 @@ def __init__(self, func: Callable[..., Any], context_param: str | None = None) - """ self.func = func self.signature = inspect.signature(func) - # Preserve Annotated extras when possible (Python 3.9+ / 3.10+ support include_extras) - try: - self.type_hints = get_type_hints(func, include_extras=True) - except TypeError: - # Older Python versions / typing implementations may not accept include_extras - self.type_hints = get_type_hints(func) + self.type_hints = get_type_hints(func, include_extras=True) self._context_param = context_param self._validate_signature() From bdea5529af788a4b82aba633c6858675d1c4241b Mon Sep 17 00:00:00 2001 From: Ratish1 Date: Fri, 31 Oct 2025 21:32:11 +0400 Subject: [PATCH 03/11] feat(tools): refactor Annotated metadata extraction in decorator --- src/strands/tools/decorator.py | 102 ++++++++++++-------------- tests/strands/tools/test_decorator.py | 18 ++++- 2 files changed, 65 insertions(+), 55 deletions(-) diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index 8e7ed400d..0c262bf31 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -102,48 +102,73 @@ def __init__(self, func: Callable[..., Any], context_param: str | None = None) - """ self.func = func self.signature = inspect.signature(func) + # include_extras=True is key for reading Annotated metadata self.type_hints = get_type_hints(func, include_extras=True) self._context_param = context_param self._validate_signature() - # Parse the docstring with docstring_parser + # Parse the docstring once for all parameters doc_str = inspect.getdoc(func) or "" self.doc = docstring_parser.parse(doc_str) - - # Get parameter descriptions from parsed docstring - self.param_descriptions = { - param.arg_name: param.description or f"Parameter {param.arg_name}" for param in self.doc.params - } + self.param_descriptions = {param.arg_name: param.description for param in self.doc.params if param.description} # Create a Pydantic model for validation self.input_model = self._create_input_model() - def _extract_annotated_metadata(self, annotation: Any) -> tuple[Any, Optional[Any]]: - """Extract type and metadata from Annotated type hint. + def _extract_annotated_metadata( + self, annotation: Any, param_name: str, param_default: Any + ) -> tuple[Any, FieldInfo]: + """Extract type and create FieldInfo from Annotated type hint. Returns: - (actual_type, metadata) where metadata is either: - - a string description - - a pydantic.fields.FieldInfo instance (from Field(...)) - - None if no Annotated extras were found + (actual_type, field_info) where field_info is always a FieldInfo instance """ + actual_type = annotation + field_info: FieldInfo | None = None + description: str | None = None + if get_origin(annotation) is Annotated: args = get_args(annotation) - actual_type = args[0] # Keep the type as-is (including Optional[T]) + actual_type = args[0] - # Look through metadata for description + # Look through metadata for FieldInfo and string descriptions for meta in args[1:]: - if isinstance(meta, str): - return actual_type, meta if isinstance(meta, FieldInfo): - return actual_type, meta + field_info = meta + elif isinstance(meta, str): + description = meta + + # Determine Final Description + # Priority: 1. Annotated string, 2. FieldInfo description, 3. Docstring + final_description = description + + # An empty string is a valid description; only fall back if no description was found in the annotation. + if final_description is None: + if field_info and field_info.description: + final_description = field_info.description + else: + final_description = self.param_descriptions.get(param_name) + + # Final fallback if no description was found anywhere + if final_description is None: + final_description = f"Parameter {param_name}" + + # Create Final FieldInfo + if field_info: + # If a Field was in Annotated, use it as the base + final_field = copy(field_info) + else: + # Otherwise, create a new default Field + final_field = Field() - # Annotated but no useful metadata - return actual_type, None + final_field.description = final_description - # Not annotated - return annotation, None + # Override default from function signature if present + if param_default is not ...: + final_field.default = param_default + + return actual_type, final_field def _validate_signature(self) -> None: """Verify that ToolContext is used correctly in the function signature.""" @@ -173,51 +198,20 @@ def _create_input_model(self) -> Type[BaseModel]: field_definitions: dict[str, Any] = {} for name, param in self.signature.parameters.items(): - # Skip parameters that will be automatically injected if self._is_special_parameter(name): continue - # Get parameter type hint and any Annotated metadata param_type = self.type_hints.get(name, Any) - actual_type, annotated_meta = self._extract_annotated_metadata(param_type) - - # Determine parameter default value default = ... if param.default is inspect.Parameter.empty else param.default - # Determine description (priority: Annotated > docstring > generic) - description: str - if isinstance(annotated_meta, str): - description = annotated_meta - elif isinstance(annotated_meta, FieldInfo) and annotated_meta.description is not None: - description = annotated_meta.description - elif name in self.param_descriptions: - description = self.param_descriptions[name] - else: - description = f"Parameter {name}" - - # Create Field definition for create_model - if isinstance(annotated_meta, FieldInfo): - # Create a defensive copy to avoid mutating a shared FieldInfo instance. - field_info_copy = copy(annotated_meta) - field_info_copy.description = description - - # Update default if specified in the function signature. - if default is not ...: - field_info_copy.default = default - - field_definitions[name] = (actual_type, field_info_copy) - else: - # For non-FieldInfo metadata, create a new Field. - field_definitions[name] = (actual_type, Field(default=default, description=description)) + actual_type, field_info = self._extract_annotated_metadata(param_type, name, default) + field_definitions[name] = (actual_type, field_info) - # Create model name based on function name model_name = f"{self.func.__name__.capitalize()}Tool" - # Create and return the model if field_definitions: return create_model(model_name, **field_definitions) else: - # Handle case with no parameters return create_model(model_name) def extract_metadata(self) -> ToolSpec: diff --git a/tests/strands/tools/test_decorator.py b/tests/strands/tools/test_decorator.py index 8e64384b2..3d02bd5b3 100644 --- a/tests/strands/tools/test_decorator.py +++ b/tests/strands/tools/test_decorator.py @@ -1488,7 +1488,7 @@ def test_tool_decorator_annotated_pydantic_field_constraints(): @strands.tool def field_annotated_tool( - email: Annotated[str, Field(description="User's email address", pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")], + email: Annotated[str, Field(description="User's email address", pattern=r"^[\w\.-]+@[\w\.-]+\\.\w+$")], score: Annotated[int, Field(description="Score between 0-100", ge=0, le=100)] = 50, ) -> str: """Tool with Pydantic Field annotations.""" @@ -1680,3 +1680,19 @@ def validation_tool(age: Annotated[int, "User age"]) -> str: result = (await alist(stream))[-1] assert result["tool_result"]["status"] == "error" + + +def test_tool_decorator_annotated_field_with_inner_default(): + """Test that a default value in an Annotated Field is respected.""" + + @strands.tool + def inner_default_tool(name: str, level: Annotated[int, Field(description="A level value", default=10)]) -> str: + return f"{name} is at level {level}" + + spec = inner_default_tool.tool_spec + schema = spec["inputSchema"]["json"] + + # 'level' should not be required because its Field has a default + assert "name" in schema["required"] + assert "level" not in schema["required"] + assert schema["properties"]["level"]["default"] == 10 From 787c0dd5e027e856f22c39aecd59caeaecb1c852 Mon Sep 17 00:00:00 2001 From: Ratish1 Date: Fri, 31 Oct 2025 22:44:59 +0400 Subject: [PATCH 04/11] feat(tools): refactor Annotated metadata extraction in decorator --- src/strands/tools/decorator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index 0c262bf31..b8a514225 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -102,7 +102,6 @@ def __init__(self, func: Callable[..., Any], context_param: str | None = None) - """ self.func = func self.signature = inspect.signature(func) - # include_extras=True is key for reading Annotated metadata self.type_hints = get_type_hints(func, include_extras=True) self._context_param = context_param From 9a4b2e634f4a6b5b4ea526bc959c6b0455ad14dd Mon Sep 17 00:00:00 2001 From: Ratish1 Date: Fri, 31 Oct 2025 22:48:13 +0400 Subject: [PATCH 05/11] fix: correct default value handling in Field extraction --- src/strands/tools/decorator.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index b8a514225..4d54568a4 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -139,33 +139,28 @@ def _extract_annotated_metadata( description = meta # Determine Final Description - # Priority: 1. Annotated string, 2. FieldInfo description, 3. Docstring + # Priority: 1. Annotated string, 2. FieldInfo description, 3. Docstring, 4. Fallback final_description = description - # An empty string is a valid description; only fall back if no description was found in the annotation. if final_description is None: if field_info and field_info.description: final_description = field_info.description else: final_description = self.param_descriptions.get(param_name) - # Final fallback if no description was found anywhere if final_description is None: final_description = f"Parameter {param_name}" - # Create Final FieldInfo + # Create Final FieldInfo with proper default handling if field_info: - # If a Field was in Annotated, use it as the base final_field = copy(field_info) - else: - # Otherwise, create a new default Field - final_field = Field() - - final_field.description = final_description + final_field.description = final_description - # Override default from function signature if present - if param_default is not ...: - final_field.default = param_default + # Function signature default takes priority + if param_default is not ...: + final_field.default = param_default + else: + final_field = Field(default=param_default, description=final_description) return actual_type, final_field From 616ec5a437a177f80b00d1f322e3838969323aea Mon Sep 17 00:00:00 2001 From: Ratish1 Date: Sat, 1 Nov 2025 00:59:39 +0400 Subject: [PATCH 06/11] fix(tools): resolve mutation errors on FieldInfo with deepcopy --- src/strands/tools/decorator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index 4d54568a4..f87842aab 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -44,7 +44,7 @@ def my_tool(param1: str, param2: int = 42) -> dict: import functools import inspect import logging -from copy import copy +from copy import deepcopy from typing import ( Annotated, Any, @@ -153,7 +153,7 @@ def _extract_annotated_metadata( # Create Final FieldInfo with proper default handling if field_info: - final_field = copy(field_info) + final_field = deepcopy(field_info) final_field.description = final_description # Function signature default takes priority From 662c168a12188129983886eec46439f1206dd74b Mon Sep 17 00:00:00 2001 From: Ratish1 Date: Mon, 3 Nov 2025 22:16:44 +0400 Subject: [PATCH 07/11] fix(tools): implement correct default and description precedence --- src/strands/tools/decorator.py | 37 +++++++++++++++++----------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index f87842aab..957c52cce 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -44,7 +44,7 @@ def my_tool(param1: str, param2: int = 42) -> dict: import functools import inspect import logging -from copy import deepcopy +from copy import copy from typing import ( Annotated, Any, @@ -65,6 +65,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 PydanticUndefined from typing_extensions import override from ..interrupt import InterruptException @@ -130,34 +131,32 @@ def _extract_annotated_metadata( if get_origin(annotation) is Annotated: args = get_args(annotation) actual_type = args[0] - - # Look through metadata for FieldInfo and string descriptions for meta in args[1:]: if isinstance(meta, FieldInfo): field_info = meta elif isinstance(meta, str): description = meta - # Determine Final Description - # Priority: 1. Annotated string, 2. FieldInfo description, 3. Docstring, 4. Fallback - final_description = description - - if final_description is None: - if field_info and field_info.description: - final_description = field_info.description - else: - final_description = self.param_descriptions.get(param_name) - - if final_description is None: - final_description = f"Parameter {param_name}" + # Final description — always a string, never None + final_description = ( + description + if description is not None + else ( + field_info.description + if field_info and field_info.description is not None + else self.param_descriptions.get(param_name) or f"Parameter {param_name}" + ) + ) - # Create Final FieldInfo with proper default handling + # Build final FieldInfo if field_info: - final_field = deepcopy(field_info) + final_field = copy(field_info) final_field.description = final_description - # Function signature default takes priority - if param_default is not ...: + # ONLY override default if Field has no default AND signature has one. + # Pydantic uses `PydanticUndefined` to signify no default was provided, + # which is distinct from an explicit default of `None`. + if field_info.default is PydanticUndefined and param_default is not ...: final_field.default = param_default else: final_field = Field(default=param_default, description=final_description) From f187b29202dd3da8d9ebeed3798447a1a521b239 Mon Sep 17 00:00:00 2001 From: Ratish1 Date: Tue, 4 Nov 2025 23:00:15 +0400 Subject: [PATCH 08/11] feat(tools): add support for string descriptions in Annotated --- src/strands/tools/decorator.py | 70 +++++++++------- tests/strands/tools/test_decorator.py | 114 +++++++++----------------- 2 files changed, 79 insertions(+), 105 deletions(-) diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index 957c52cce..c1785eafa 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -44,7 +44,6 @@ def my_tool(param1: str, param2: int = 42) -> dict: import functools import inspect import logging -from copy import copy from typing import ( Annotated, Any, @@ -65,7 +64,6 @@ 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 PydanticUndefined from typing_extensions import override from ..interrupt import InterruptException @@ -119,47 +117,57 @@ def __init__(self, func: Callable[..., Any], context_param: str | None = None) - def _extract_annotated_metadata( self, annotation: Any, param_name: str, param_default: Any ) -> tuple[Any, FieldInfo]: - """Extract type and create FieldInfo from Annotated type hint. + """Extracts type and a simple string description from an Annotated type hint. Returns: - (actual_type, field_info) where field_info is always a FieldInfo instance + A tuple of (actual_type, field_info), where field_info is a new, simple + Pydantic FieldInfo instance created from the extracted metadata. """ actual_type = annotation - field_info: FieldInfo | None = None description: str | None = None if get_origin(annotation) is Annotated: args = get_args(annotation) actual_type = args[0] + + # Look through metadata for a string description or a FieldInfo object. for meta in args[1:]: - if isinstance(meta, FieldInfo): - field_info = meta - elif isinstance(meta, str): + if isinstance(meta, str): description = meta + elif isinstance(meta, FieldInfo): + # --- Future Contributor Note --- + # We are explicitly blocking the use of `pydantic.Field` within `Annotated` + # because of the complexities of Pydantic v2's immutable Core Schema. + # + # Once a Pydantic model's schema is built, its `FieldInfo` objects are + # effectively frozen. Attempts to mutate a `FieldInfo` object after + # creation (e.g., by copying it and setting `.description` or `.default`) + # are unreliable because the underlying Core Schema does not see these changes. + # + # The correct way to support this would be to reliably extract all + # constraints (ge, le, pattern, etc.) from the original FieldInfo and + # rebuild a new one from scratch. However, these constraints are not + # stored as public attributes, making them difficult to inspect reliably. + # + # Deferring this complexity until there is clear demand and a robust + # pattern for inspecting FieldInfo constraints is established. + raise NotImplementedError( + "Using pydantic.Field within Annotated is not yet supported for tool decorators. " + "Please use a simple string for the description, or define constraints in the function's " + "docstring." + ) - # Final description — always a string, never None - final_description = ( - description - if description is not None - else ( - field_info.description - if field_info and field_info.description is not None - else self.param_descriptions.get(param_name) or f"Parameter {param_name}" - ) - ) - - # Build final FieldInfo - if field_info: - final_field = copy(field_info) - final_field.description = final_description - - # ONLY override default if Field has no default AND signature has one. - # Pydantic uses `PydanticUndefined` to signify no default was provided, - # which is distinct from an explicit default of `None`. - if field_info.default is PydanticUndefined and param_default is not ...: - final_field.default = param_default - else: - final_field = Field(default=param_default, description=final_description) + # Determine the final description with a clear priority order. + # Priority: 1. Annotated string -> 2. Docstring -> 3. Fallback + final_description = description + if final_description is None: + final_description = self.param_descriptions.get(param_name) + if final_description is None: + final_description = f"Parameter {param_name}" + + # Create a new, simple FieldInfo object from scratch. + # This avoids all the immutability and mutation issues we encountered previously. + final_field = Field(default=param_default, description=final_description) return actual_type, final_field diff --git a/tests/strands/tools/test_decorator.py b/tests/strands/tools/test_decorator.py index 3d02bd5b3..7733d27e5 100644 --- a/tests/strands/tools/test_decorator.py +++ b/tests/strands/tools/test_decorator.py @@ -1484,30 +1484,16 @@ def annotated_tool( def test_tool_decorator_annotated_pydantic_field_constraints(): - """Test tool decorator with Pydantic Field in Annotated.""" + """Test that using pydantic.Field in Annotated raises a NotImplementedError.""" + with pytest.raises(NotImplementedError, match="Using pydantic.Field within Annotated is not yet supported"): - @strands.tool - def field_annotated_tool( - email: Annotated[str, Field(description="User's email address", pattern=r"^[\w\.-]+@[\w\.-]+\\.\w+$")], - score: Annotated[int, Field(description="Score between 0-100", ge=0, le=100)] = 50, - ) -> str: - """Tool with Pydantic Field annotations.""" - return f"{email}: {score}" - - spec = field_annotated_tool.tool_spec - schema = spec["inputSchema"]["json"] - - # Check descriptions from Field - assert schema["properties"]["email"]["description"] == "User's email address" - assert schema["properties"]["score"]["description"] == "Score between 0-100" - - # Check that constraints are preserved - assert schema["properties"]["score"]["minimum"] == 0 - assert schema["properties"]["score"]["maximum"] == 100 - - # Check required fields - assert "email" in schema["required"] - assert "score" not in schema["required"] # Has default + @strands.tool + def field_annotated_tool( + email: Annotated[str, Field(description="User's email address", pattern=r"^[\w\.-]+@[\w\.-]+\\.w+$")], + score: Annotated[int, Field(description="Score between 0-100", ge=0, le=100)] = 50, + ) -> str: + """Tool with Pydantic Field annotations.""" + return f"{email}: {score}" def test_tool_decorator_annotated_overrides_docstring(): @@ -1574,31 +1560,23 @@ def complex_annotated_tool( def test_tool_decorator_annotated_mixed_styles(): - """Test tool with mixed annotation styles.""" - - @strands.tool - def mixed_tool( - plain: str, - annotated_str: Annotated[str, "String description"], - annotated_field: Annotated[int, Field(description="Field description", ge=0)], - docstring_only: int, - ) -> str: - """Tool with mixed parameter styles. - - Args: - plain: Plain parameter description - docstring_only: Docstring description for this param - """ - return "mixed" + """Test that using pydantic.Field in a mixed-style annotation raises NotImplementedError.""" + with pytest.raises(NotImplementedError, match="Using pydantic.Field within Annotated is not yet supported"): - spec = mixed_tool.tool_spec - schema = spec["inputSchema"]["json"] + @strands.tool + def mixed_tool( + plain: str, + annotated_str: Annotated[str, "String description"], + annotated_field: Annotated[int, Field(description="Field description", ge=0)], + docstring_only: int, + ) -> str: + """Tool with mixed parameter styles. - # Check each style works correctly - assert schema["properties"]["plain"]["description"] == "Plain parameter description" - assert schema["properties"]["annotated_str"]["description"] == "String description" - assert schema["properties"]["annotated_field"]["description"] == "Field description" - assert schema["properties"]["docstring_only"]["description"] == "Docstring description for this param" + Args: + plain: Plain parameter description + docstring_only: Docstring description for this param + """ + return "mixed" @pytest.mark.asyncio @@ -1624,24 +1602,19 @@ def execution_test(name: Annotated[str, "User name"], count: Annotated[int, "Num def test_tool_decorator_annotated_no_description_fallback(): - """Test that Annotated without description falls back to docstring.""" + """Test that Annotated with a Field raises NotImplementedError.""" + with pytest.raises(NotImplementedError, match="Using pydantic.Field within Annotated is not yet supported"): - @strands.tool - def no_desc_annotated( - param: Annotated[str, Field()], # Field without description - ) -> str: - """Tool with Annotated but no description. - - Args: - param: Docstring description - """ - return param - - spec = no_desc_annotated.tool_spec - schema = spec["inputSchema"]["json"] + @strands.tool + def no_desc_annotated( + param: Annotated[str, Field()], # Field without description + ) -> str: + """Tool with Annotated but no description. - # Should fall back to docstring - assert schema["properties"]["param"]["description"] == "Docstring description" + Args: + param: Docstring description + """ + return param def test_tool_decorator_annotated_empty_string_description(): @@ -1683,16 +1656,9 @@ def validation_tool(age: Annotated[int, "User age"]) -> str: def test_tool_decorator_annotated_field_with_inner_default(): - """Test that a default value in an Annotated Field is respected.""" - - @strands.tool - def inner_default_tool(name: str, level: Annotated[int, Field(description="A level value", default=10)]) -> str: - return f"{name} is at level {level}" - - spec = inner_default_tool.tool_spec - schema = spec["inputSchema"]["json"] + """Test that a default value in an Annotated Field raises NotImplementedError.""" + with pytest.raises(NotImplementedError, match="Using pydantic.Field within Annotated is not yet supported"): - # 'level' should not be required because its Field has a default - assert "name" in schema["required"] - assert "level" not in schema["required"] - assert schema["properties"]["level"]["default"] == 10 + @strands.tool + def inner_default_tool(name: str, level: Annotated[int, Field(description="A level value", default=10)]) -> str: + return f"{name} is at level {level}" From 07b837b41dcebfb8d6118651551d227a05280523 Mon Sep 17 00:00:00 2001 From: Ratish1 Date: Tue, 4 Nov 2025 23:02:51 +0400 Subject: [PATCH 09/11] feat(tools): add support for string descriptions in Annotated --- src/strands/tools/decorator.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index c1785eafa..410c30684 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -130,7 +130,7 @@ def _extract_annotated_metadata( args = get_args(annotation) actual_type = args[0] - # Look through metadata for a string description or a FieldInfo object. + # Look through metadata for a string description or a FieldInfo object for meta in args[1:]: if isinstance(meta, str): description = meta @@ -157,7 +157,7 @@ def _extract_annotated_metadata( "docstring." ) - # Determine the final description with a clear priority order. + # Determine the final description with a clear priority order # Priority: 1. Annotated string -> 2. Docstring -> 3. Fallback final_description = description if final_description is None: @@ -165,8 +165,7 @@ def _extract_annotated_metadata( if final_description is None: final_description = f"Parameter {param_name}" - # Create a new, simple FieldInfo object from scratch. - # This avoids all the immutability and mutation issues we encountered previously. + # Create FieldInfo object from scratch final_field = Field(default=param_default, description=final_description) return actual_type, final_field From c8818d171dc9f5e82d8cbe325c4ce94156bc01b7 Mon Sep 17 00:00:00 2001 From: Ratish1 Date: Wed, 5 Nov 2025 00:45:34 +0400 Subject: [PATCH 10/11] feat(tools): add support for string descriptions in Annotated --- src/strands/tools/decorator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index 410c30684..29b20e76b 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -201,7 +201,11 @@ def _create_input_model(self) -> Type[BaseModel]: if self._is_special_parameter(name): continue - param_type = self.type_hints.get(name, Any) + # Fallback to Any for params without type hints to prevent Pydantic errors + param_type = self.type_hints.get(name, param.annotation) + if param_type is inspect.Parameter.empty: + param_type = Any + default = ... if param.default is inspect.Parameter.empty else param.default actual_type, field_info = self._extract_annotated_metadata(param_type, name, default) From 96ed97cf566ba622894a4e70dc2bbb34d6bfffe5 Mon Sep 17 00:00:00 2001 From: Ratish1 Date: Wed, 5 Nov 2025 11:38:16 +0400 Subject: [PATCH 11/11] feat(tools): add support for string descriptions in Annotated --- src/strands/tools/decorator.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index 29b20e76b..009cf76d7 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -57,7 +57,6 @@ def my_tool(param1: str, param2: int = 42) -> dict: cast, get_args, get_origin, - get_type_hints, overload, ) @@ -101,7 +100,6 @@ def __init__(self, func: Callable[..., Any], context_param: str | None = None) - """ self.func = func self.signature = inspect.signature(func) - self.type_hints = get_type_hints(func, include_extras=True) self._context_param = context_param self._validate_signature() @@ -201,8 +199,9 @@ def _create_input_model(self) -> Type[BaseModel]: if self._is_special_parameter(name): continue - # Fallback to Any for params without type hints to prevent Pydantic errors - param_type = self.type_hints.get(name, param.annotation) + # Use param.annotation directly to get the raw type hint. Using get_type_hints() + # can cause inconsistent behavior across Python versions for complex Annotated types. + param_type = param.annotation if param_type is inspect.Parameter.empty: param_type = Any