From c9370f952e480b143b1d74e0d63940475c738c24 Mon Sep 17 00:00:00 2001 From: giulio-leone Date: Fri, 13 Mar 2026 01:18:02 +0100 Subject: [PATCH] fix: coerce JSON-stringified dict/list tool params before validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bedrock/Claude sometimes serializes nested object or array tool-call parameters as JSON strings instead of native Python dicts/lists. This causes Pydantic validation errors like: Input should be a valid dictionary [type=dict_type, input_value='{...}', input_type=str] Root cause: the Converse API returns tool_use.input as a parsed dict, but for nested object/array parameters the model may stringify the value before embedding it in the outer dict. The SDK's Pydantic-based validate_input() then receives a str where it expects a dict/list and rejects it. The fix adds a pre-validation coercion step in FunctionToolMetadata.validate_input() that: 1. Inspects each field's type annotation on the input_model 2. If the annotation accepts dict or list (including Optional/Union) and the incoming value is a string, attempts json.loads() 3. Only replaces the value if deserialization produces a dict or list 4. Leaves non-JSON strings untouched (they still fail validation) This is model-provider-agnostic and has zero impact on correctly-typed inputs — the coercion only activates when a string value is received for a dict/list field. Closes #1285 --- src/strands/tools/decorator.py | 65 +++++++++++++++++++++++- tests/strands/tools/test_decorator.py | 72 +++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) 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"]