Skip to content

[ENHANCEMENT] Replace custom tool validation with Pydantic's validate_call decorator #1246

@kazmer97

Description

@kazmer97

Summary

Replace the custom validation flow in src/strands/tools/decorator.py with Pydantic's built-in @validate_call decorator to simplify the codebase, improve maintainability, and unlock full Pydantic validation features for tool authors.

Motivation

The current tool decorator implements ~130 lines of custom validation logic (lines 80-220) that:

  1. Duplicates Pydantic functionality - Manually creates Pydantic models, extracts type hints, and handles validation
  2. Has JSON serialization issues - Recently discovered that model_dump() without mode='json' causes failures when storing data in agent.state
  3. Blocks Pydantic features - Explicitly raises NotImplementedError for pydantic.Field constraints in Annotated types (lines 139-160)
  4. Causes type hint inconsistency - Parameters typed as Pydantic models are actually dicts at runtime (see issue [FEATURE] Strict Typing option for Tool Decorator #917)

Recent Bug Example

class BankCheck(BaseModel):
    date: date  # datetime.date object
    amount: Decimal

# Current decorator flow:
validated = input_model(**input_data)
return validated.model_dump()  # Keeps date/Decimal as Python objects!

# When tool tries to store in state:
agent.state.set("result", extraction_dict)  # JSON validation fails!
# Error: Value is not JSON serializable: dict

Workaround applied: Changed to model_dump(mode='json'), but this is a band-aid on a deeper architectural issue.

Type Hint Inconsistency (Issue #917)

class Person(BaseModel):
    name: str
    age: int

@tool
def create_person(input: Person) -> Person:
    input.name  # ❌ Fails - input is a dict, not a Person instance!
    input["name"]  # ✅ Works - but type hints are lies

Proposed Solution

Replace custom validation with Pydantic's @validate_call:

from pydantic import validate_call, ConfigDict

def create_validated_tool_function(func: Callable, special_params: set[str]) -> Callable:
    """
    Wrap function with validate_call, excluding special parameters.
    
    Args:
        func: The function to validate
        special_params: Parameters to exclude (agent, tool_context, self, cls)
    """
    @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
    def validated_wrapper(**validated_kwargs):
        return validated_kwargs
    
    def final_wrapper(**all_kwargs):
        validated_kwargs = {k: v for k, v in all_kwargs.items() if k not in special_params}
        special_kwargs = {k: v for k, v in all_kwargs.items() if k in special_params}
        
        validated_data = validated_wrapper(**validated_kwargs)
        return func(**validated_data, **special_kwargs)
    
    return final_wrapper

Benefits

  1. ~100 lines of code removed - Delete _create_input_model(), _extract_annotated_metadata(), custom validation logic
  2. Full Pydantic feature support - Users can now use Field(ge=0, le=100), custom validators, etc.
  3. Better error messages - Leverage Pydantic's detailed validation errors
  4. Automatic JSON serialization - No more manual mode='json' workarounds
  5. Type hint correctness - Parameters typed as Pydantic models are actual model instances at runtime
  6. Future-proof - Automatically benefits from Pydantic improvements
  7. Aligns with Development Tenets:

Example: What This Enables

from pydantic import Field
from typing import Annotated

# Currently raises NotImplementedError ❌
# After this change, works perfectly ✅
@tool
def my_tool(
    age: Annotated[int, Field(ge=0, le=120, description="Person's age")],
    email: Annotated[str, Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")],
) -> dict:
    """Tool with field validation constraints."""
    return {"age": age, "email": email}

# Type hints now reflect reality ✅
@tool
def create_person(input: Person) -> Person:
    input.name  # ✅ NOW WORKS - input IS a Person instance!
    return Person(name=input.name.upper(), age=input.age)

Implementation Plan

Phase 1: Parameter Exclusion Wrapper

Create wrapper function that applies @validate_call while excluding special parameters (agent, tool_context, self, cls)

Phase 2: Simplify FunctionToolMetadata

Remove validation methods:

  • Delete _create_input_model() (~50 lines)
  • Delete _extract_annotated_metadata() (~50 lines)
  • Delete validate_input() method
  • Keep only schema extraction and metadata generation

Phase 3: Update DecoratedFunctionTool

  • Remove calls to metadata.validate_input()
  • Handle Pydantic's ValidationError in .stream() method

Phase 4: Testing

  • Ensure all existing tests pass (no breaking changes)
  • Add tests for Field() constraints (previously unsupported)
  • Test complex scenarios (nested models, datetime/Decimal, etc.)

Migration Impact

For users: No breaking changes expected. All existing tools continue to work, but now behave more intuitively.

New capability unlocked: Users can now use full Pydantic validation:

@tool
def process_data(
    count: Annotated[int, Field(gt=0, le=1000)],  # Now works!
    name: Annotated[str, Field(min_length=1, max_length=100)],  # Now works!
) -> dict:
    ...

Related Issues

This proposal fully resolves issue #917 - [FEATURE] Strict Typing option for Tool Decorator.

Issue #917 requests that tool parameters typed as Pydantic models should actually BE Pydantic model instances at runtime, not dicts. The issue specifically suggests using @validate_call to achieve this.

Our proposal implements exactly this, making strict typing the default behavior (as #917 comments suggest should happen in 2.0).

Before (Current Behavior - Issue #917 Problem):

@tool
def create_person(input: Person) -> Person:
    input.name  # ❌ Fails - input is a dict
    input["name"]  # ✅ Works - but type hints lie

After (With This Proposal - Issue #917 Solved):

@tool
def create_person(input: Person) -> Person:
    input.name  # ✅ Works - input IS a Person instance!

By adopting @validate_call throughout, we solve #917's core issue while also unlocking additional benefits (Field validation, JSON serialization, simpler codebase).

Closes #917

References

Open Questions

  1. Should we expose Pydantic's validate_return option to tool authors?
  2. Do we need a deprecation period for any edge cases?
  3. Should parameter exclusion wrapper be a public utility?

This proposal aligns with our development tenets, particularly "Embrace common standards" and "Simple at any scale". By leveraging Pydantic's standard validation approach instead of custom code, we make Strands simpler to maintain and more powerful for users.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions