Skip to content

Commit 8bd3314

Browse files
giulio-leoneCopilot
andcommitted
fix: coerce JSON-stringified dict/list tool params before validation
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 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fca208b commit 8bd3314

2 files changed

Lines changed: 136 additions & 1 deletion

File tree

src/strands/tools/decorator.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def my_tool(param1: str, param2: int = 42) -> dict:
5252
Generic,
5353
ParamSpec,
5454
TypeVar,
55+
Union,
5556
cast,
5657
get_args,
5758
get_origin,
@@ -367,6 +368,11 @@ def validate_input(self, input_data: dict[str, Any]) -> dict[str, Any]:
367368
This method ensures that the input data meets the expected schema before it's passed to the actual function. It
368369
converts the data to the correct types when possible and raises informative errors when not.
369370
371+
Some model providers (notably Bedrock/Claude) may serialize nested object or array
372+
parameters as JSON strings instead of native dicts/lists. Before running Pydantic
373+
validation we attempt to deserialize any such string values so that they match the
374+
expected type.
375+
370376
Args:
371377
input_data: A dictionary of parameter names and values to validate.
372378
@@ -377,8 +383,9 @@ def validate_input(self, input_data: dict[str, Any]) -> dict[str, Any]:
377383
ValueError: If the input data fails validation, with details about what failed.
378384
"""
379385
try:
386+
coerced = self._coerce_json_string_params(input_data)
380387
# Validate with Pydantic model
381-
validated = self.input_model(**input_data)
388+
validated = self.input_model(**coerced)
382389

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

397+
def _coerce_json_string_params(self, input_data: dict[str, Any]) -> dict[str, Any]:
398+
"""Deserialize string values that should be dicts or lists.
399+
400+
Some model providers (notably Bedrock/Claude) serialize nested object or array
401+
tool-call parameters as JSON strings. This method inspects the Pydantic input
402+
model to find fields whose annotation accepts ``dict`` or ``list`` and, when
403+
the incoming value is a string, attempts ``json.loads`` to coerce it. If
404+
deserialization fails or the value is already the correct type the original
405+
value is left untouched.
406+
"""
407+
if not input_data:
408+
return input_data
409+
410+
coerced = dict(input_data)
411+
for field_name, field_info in self.input_model.model_fields.items():
412+
value = coerced.get(field_name)
413+
if not isinstance(value, str):
414+
continue
415+
416+
if self._annotation_accepts_mapping_or_sequence(field_info.annotation):
417+
try:
418+
parsed = json.loads(value)
419+
if isinstance(parsed, (dict, list)):
420+
coerced[field_name] = parsed
421+
except (json.JSONDecodeError, ValueError):
422+
pass
423+
424+
return coerced
425+
426+
@staticmethod
427+
def _annotation_accepts_mapping_or_sequence(annotation: Any) -> bool:
428+
"""Return ``True`` if *annotation* can accept a ``dict`` or ``list`` value."""
429+
if annotation is None:
430+
return False
431+
432+
origin = get_origin(annotation)
433+
434+
# Plain dict / list
435+
if annotation is dict or annotation is list:
436+
return True
437+
if origin is dict or origin is list:
438+
return True
439+
440+
# typing.Union / Optional — check each branch
441+
if origin is Union:
442+
return any(
443+
FunctionToolMetadata._annotation_accepts_mapping_or_sequence(arg)
444+
for arg in get_args(annotation)
445+
)
446+
447+
# typing.Any accepts everything
448+
if annotation is Any:
449+
return True
450+
451+
return False
452+
390453
def inject_special_parameters(
391454
self, validated_input: dict[str, Any], tool_use: ToolUse, invocation_state: dict[str, Any]
392455
) -> None:

tests/strands/tools/test_decorator.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2101,3 +2101,75 @@ def my_tool(name: str, tag: str | None = None) -> str:
21012101
# Since tag is not required, anyOf should be simplified away
21022102
assert "anyOf" not in schema["properties"]["tag"]
21032103
assert schema["properties"]["tag"]["type"] == "string"
2104+
2105+
2106+
def test_validate_input_coerces_json_string_to_dict():
2107+
"""Test that JSON-stringified dict params are deserialized before Pydantic validation.
2108+
2109+
Reproduces: https://github.com/strands-agents/sdk-python/issues/1285
2110+
Bedrock/Claude sometimes sends nested object parameters as JSON strings instead
2111+
of native dicts, causing Pydantic validation errors.
2112+
"""
2113+
from typing import Any, Optional
2114+
2115+
@strands.tool
2116+
def ecs_tool(action: str, parameters: Optional[dict[str, Any]] = None) -> str:
2117+
"""ECS troubleshooting tool.
2118+
2119+
Args:
2120+
action: The action to perform
2121+
parameters: Optional parameters dict
2122+
"""
2123+
return f"{action}: {parameters}"
2124+
2125+
metadata = ecs_tool._metadata
2126+
2127+
# Simulate Bedrock/Claude sending parameters as a JSON string
2128+
stringified_input = {
2129+
"action": "fetch_service_events",
2130+
"parameters": '{"ecs_cluster_name": "my-cluster", "ecs_service_name": "my-service"}',
2131+
}
2132+
validated = metadata.validate_input(stringified_input)
2133+
assert validated["action"] == "fetch_service_events"
2134+
assert isinstance(validated["parameters"], dict)
2135+
assert validated["parameters"]["ecs_cluster_name"] == "my-cluster"
2136+
assert validated["parameters"]["ecs_service_name"] == "my-service"
2137+
2138+
# Verify that dict values still work normally (no regression)
2139+
dict_input = {
2140+
"action": "fetch_service_events",
2141+
"parameters": {"ecs_cluster_name": "my-cluster"},
2142+
}
2143+
validated2 = metadata.validate_input(dict_input)
2144+
assert isinstance(validated2["parameters"], dict)
2145+
assert validated2["parameters"]["ecs_cluster_name"] == "my-cluster"
2146+
2147+
# Verify that None still works for optional params
2148+
none_input = {"action": "fetch_service_events", "parameters": None}
2149+
validated3 = metadata.validate_input(none_input)
2150+
assert validated3["parameters"] is None
2151+
2152+
# Verify non-JSON strings are NOT coerced (should still fail validation)
2153+
bad_input = {"action": "fetch_service_events", "parameters": "not-json"}
2154+
import pytest
2155+
with pytest.raises(ValueError, match="Validation failed"):
2156+
metadata.validate_input(bad_input)
2157+
2158+
2159+
def test_validate_input_coerces_json_string_to_list():
2160+
"""Test that JSON-stringified list params are deserialized before Pydantic validation."""
2161+
2162+
@strands.tool
2163+
def list_tool(items: list[str]) -> str:
2164+
"""List tool.
2165+
2166+
Args:
2167+
items: A list of items
2168+
"""
2169+
return str(items)
2170+
2171+
metadata = list_tool._metadata
2172+
2173+
stringified_input = {"items": '["a", "b", "c"]'}
2174+
validated = metadata.validate_input(stringified_input)
2175+
assert validated["items"] == ["a", "b", "c"]

0 commit comments

Comments
 (0)