Skip to content

fix: coerce JSON-stringified dict/list tool params before Pydantic validation#1882

Closed
giulio-leone wants to merge 1 commit intostrands-agents:mainfrom
giulio-leone:fix/bedrock-json-string-tool-params
Closed

fix: coerce JSON-stringified dict/list tool params before Pydantic validation#1882
giulio-leone wants to merge 1 commit intostrands-agents:mainfrom
giulio-leone:fix/bedrock-json-string-tool-params

Conversation

@giulio-leone
Copy link
Copy Markdown
Contributor

Issue

Closes #1285

Problem

When using strands with Bedrock/Claude and MCP tools that have nested object parameters (e.g., parameters: dict[str, Any] | None), the model incorrectly serializes the parameter value as a JSON string instead of a native Python dictionary:

# Expected (works with Gemini):
parameters={'ecs_cluster_name': 'my-cluster', 'time_window': 3600}

# Actual (Bedrock/Claude):
parameters='{"ecs_cluster_name": "my-cluster", "time_window": 3600}'

This causes a Pydantic validation error:

ValidationError: 1 validation error for call[...]
parameters
  Input should be a valid dictionary [type=dict_type, input_value='{...}', input_type=str]

Root Cause

The Bedrock 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.

Solution

Added a pre-validation coercion step in FunctionToolMetadata.validate_input() via _coerce_json_string_params() that:

  1. Inspects each field's type annotation on the input model
  2. If the annotation accepts dict or list (including Optional/Union/Any) 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 Pydantic validation with a clear error)

Design Notes

  • Model-provider-agnostic: The fix is at the tool decorator level, not Bedrock-specific, so it benefits any provider that exhibits this behavior
  • Zero impact on correct inputs: The coercion only activates when a string value is received for a dict/list field; dict/list values pass through unchanged
  • Backward compatible: No changes to any public API or schema generation
  • Conservative: Only coerces to dict or list — won't coerce a string that json.loads to an int, bool, etc.

Testing

  • Added test_validate_input_coerces_json_string_to_dict: Tests dict coercion with Optional[dict[str, Any]], verifies dict values still work, None still works, and non-JSON strings still raise ValueError
  • Added test_validate_input_coerces_json_string_to_list: Tests list coercion with list[str]
  • All 82 decorator tests pass (80 existing + 2 new)
  • All 439 tools module tests pass

Changes

  • src/strands/tools/decorator.py: Added _coerce_json_string_params(), _annotation_accepts_mapping_or_sequence() methods to FunctionToolMetadata; updated validate_input() to call coercion before Pydantic validation
  • tests/strands/tools/test_decorator.py: Added 2 test cases for JSON string coercion

@giulio-leone
Copy link
Copy Markdown
Contributor Author

Friendly ping — coerces JSON-stringified dict/list tool parameters back to native types before Pydantic validation, fixing crashes when LLMs return string-wrapped JSON.

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
@giulio-leone
Copy link
Copy Markdown
Contributor Author

Refreshed onto main @ fd8168a (v1.32.0+2) — 2026-03-23

Root cause confirmed still live: Bedrock/Claude sometimes serializes nested dict/list tool-call parameters as JSON strings instead of native Python types. Pydantic's TypeAdapter then fails validation with a type error, even though the content is valid — the string is never attempted to be parsed.

Fix: FunctionToolMetadata._coerce_json_string_params is called before Pydantic validation. It inspects each field's type annotation and, if the annotation accepts a dict or list and the incoming value is a str, attempts json.loads. Non-parseable strings and already-correct types are left untouched.

Runtime proof on rebased branch 9009177:

# Input: JSON-string-encoded dict and list (as Bedrock sends them)
meta.validate_input({'params': '{"key": "val"}', 'items': '["a", "b"]'})
# Output:
Coerced dict: {'key': 'val'}
Coerced list: ['a', 'b']
Types: dict  list

Test results:

  • test_validate_input_coerces_json_string_to_dict: PASSED
  • test_validate_input_coerces_json_string_to_list: PASSED
  • Full decorator tool test suite: 82/82 PASSED

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Bedrock/Claude model incorrectly serializes nested object parameters as JSON string causing Pydantic validation errors

1 participant