diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index 9207df9b8..be26ac575 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -52,6 +52,7 @@ def my_tool(param1: str, param2: int = 42) -> dict: Generic, ParamSpec, TypeVar, + Union, cast, get_args, get_origin, @@ -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. @@ -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() @@ -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: diff --git a/tests/strands/tools/test_decorator.py b/tests/strands/tools/test_decorator.py index cc1158983..0f3e1a5c4 100644 --- a/tests/strands/tools/test_decorator.py +++ b/tests/strands/tools/test_decorator.py @@ -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"]