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
65 changes: 64 additions & 1 deletion src/strands/tools/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def my_tool(param1: str, param2: int = 42) -> dict:
Generic,
ParamSpec,
TypeVar,
Union,
cast,
get_args,
get_origin,
Expand Down Expand Up @@ -367,6 +368,11 @@ def validate_input(self, input_data: dict[str, Any]) -> dict[str, Any]:
This method ensures that the input data meets the expected schema before it's passed to the actual function. It
converts the data to the correct types when possible and raises informative errors when not.

Some model providers (notably Bedrock/Claude) may serialize nested object or array
parameters as JSON strings instead of native dicts/lists. Before running Pydantic
validation we attempt to deserialize any such string values so that they match the
expected type.

Args:
input_data: A dictionary of parameter names and values to validate.

Expand All @@ -377,8 +383,9 @@ def validate_input(self, input_data: dict[str, Any]) -> dict[str, Any]:
ValueError: If the input data fails validation, with details about what failed.
"""
try:
coerced = self._coerce_json_string_params(input_data)
# Validate with Pydantic model
validated = self.input_model(**input_data)
validated = self.input_model(**coerced)

# Return as dict
return validated.model_dump()
Expand All @@ -387,6 +394,62 @@ def validate_input(self, input_data: dict[str, Any]) -> dict[str, Any]:
error_msg = str(e)
raise ValueError(f"Validation failed for input parameters: {error_msg}") from e

def _coerce_json_string_params(self, input_data: dict[str, Any]) -> dict[str, Any]:
"""Deserialize string values that should be dicts or lists.

Some model providers (notably Bedrock/Claude) serialize nested object or array
tool-call parameters as JSON strings. This method inspects the Pydantic input
model to find fields whose annotation accepts ``dict`` or ``list`` and, when
the incoming value is a string, attempts ``json.loads`` to coerce it. If
deserialization fails or the value is already the correct type the original
value is left untouched.
"""
if not input_data:
return input_data

coerced = dict(input_data)
for field_name, field_info in self.input_model.model_fields.items():
value = coerced.get(field_name)
if not isinstance(value, str):
continue

if self._annotation_accepts_mapping_or_sequence(field_info.annotation):
try:
parsed = json.loads(value)
if isinstance(parsed, (dict, list)):
coerced[field_name] = parsed
except (json.JSONDecodeError, ValueError):
pass

return coerced

@staticmethod
def _annotation_accepts_mapping_or_sequence(annotation: Any) -> bool:
"""Return ``True`` if *annotation* can accept a ``dict`` or ``list`` value."""
if annotation is None:
return False

origin = get_origin(annotation)

# Plain dict / list
if annotation is dict or annotation is list:
return True
if origin is dict or origin is list:
return True

# typing.Union / Optional — check each branch
if origin is Union:
return any(
FunctionToolMetadata._annotation_accepts_mapping_or_sequence(arg)
for arg in get_args(annotation)
)

# typing.Any accepts everything
if annotation is Any:
return True

return False

def inject_special_parameters(
self, validated_input: dict[str, Any], tool_use: ToolUse, invocation_state: dict[str, Any]
) -> None:
Expand Down
72 changes: 72 additions & 0 deletions tests/strands/tools/test_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2101,3 +2101,75 @@ 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_validate_input_coerces_json_string_to_dict():
"""Test that JSON-stringified dict params are deserialized before Pydantic validation.

Reproduces: https://github.com/strands-agents/sdk-python/issues/1285
Bedrock/Claude sometimes sends nested object parameters as JSON strings instead
of native dicts, causing Pydantic validation errors.
"""
from typing import Any, Optional

@strands.tool
def ecs_tool(action: str, parameters: Optional[dict[str, Any]] = None) -> str:
"""ECS troubleshooting tool.

Args:
action: The action to perform
parameters: Optional parameters dict
"""
return f"{action}: {parameters}"

metadata = ecs_tool._metadata

# Simulate Bedrock/Claude sending parameters as a JSON string
stringified_input = {
"action": "fetch_service_events",
"parameters": '{"ecs_cluster_name": "my-cluster", "ecs_service_name": "my-service"}',
}
validated = metadata.validate_input(stringified_input)
assert validated["action"] == "fetch_service_events"
assert isinstance(validated["parameters"], dict)
assert validated["parameters"]["ecs_cluster_name"] == "my-cluster"
assert validated["parameters"]["ecs_service_name"] == "my-service"

# Verify that dict values still work normally (no regression)
dict_input = {
"action": "fetch_service_events",
"parameters": {"ecs_cluster_name": "my-cluster"},
}
validated2 = metadata.validate_input(dict_input)
assert isinstance(validated2["parameters"], dict)
assert validated2["parameters"]["ecs_cluster_name"] == "my-cluster"

# Verify that None still works for optional params
none_input = {"action": "fetch_service_events", "parameters": None}
validated3 = metadata.validate_input(none_input)
assert validated3["parameters"] is None

# Verify non-JSON strings are NOT coerced (should still fail validation)
bad_input = {"action": "fetch_service_events", "parameters": "not-json"}
import pytest
with pytest.raises(ValueError, match="Validation failed"):
metadata.validate_input(bad_input)


def test_validate_input_coerces_json_string_to_list():
"""Test that JSON-stringified list params are deserialized before Pydantic validation."""

@strands.tool
def list_tool(items: list[str]) -> str:
"""List tool.

Args:
items: A list of items
"""
return str(items)

metadata = list_tool._metadata

stringified_input = {"items": '["a", "b", "c"]'}
validated = metadata.validate_input(stringified_input)
assert validated["items"] == ["a", "b", "c"]