Skip to content

Conversation

shaheerzaman
Copy link
Contributor

@shaheerzaman shaheerzaman commented Sep 8, 2025

This PR fixes #2829

Summary

  • Problem: Bedrock sometimes returns output tool args with the payload under response as a JSON string (e.g., '"[...]"'), causing Pydantic to error (“Input should be a valid list/object”).
  • Root Cause: output_type schemas that are not plain objects (e.g., list[T], tuples, unions) are wrapped under an outer_typed_dict_key (response). When the nested value is a stringified JSON, the validator sees a str instead of the expected list/object.
  • Fix: Pre-normalize output tool args by JSON-decoding the nested response value if it’s a string before running Pydantic validation.

Changes

  • pydantic_ai_slim/pydantic_ai/_tool_manager.py
  • In _call_tool, when tool.tool_def.outer_typed_dict_key is set:
  • If call.args is a JSON string: parse top-level, then json.loads the nested response if it’s a string, and validate via validate_python.
  • If call.args is a dict: attempt json.loads on response when it’s a string, then validate.
  • Graceful fallback to existing validation paths if decoding fails.

Impact

  • Stabilizes output_type=list[T] (and tuples/unions) with Bedrock by preventing spurious validation errors.
  • Backwards compatible and scoped to output tools only; function tools unaffected.
  • If decoding fails or payload is malformed, existing validation errors still surface clearly.

Notes for Reviewers

Applies only when outer_typed_dict_key is present (e.g., non-object output schemas).
No API changes or doc updates required.

@DouweM
Copy link
Collaborator

DouweM commented Sep 8, 2025

@shaheerzaman Thanks! I think we can implement this more cleanly using https://docs.pydantic.dev/2.0/usage/types/json/.

Specifically, I think we can change this section:

self.outer_typed_dict_key = 'response'
response_data_typed_dict = TypedDict( # noqa: UP013
'response_data_typed_dict',
{'response': cast(type[OutputDataT], output)}, # pyright: ignore[reportInvalidTypeForm]
)
type_adapter = TypeAdapter(response_data_typed_dict)
# Really a PluggableSchemaValidator, but it's API-compatible
self.validator = cast(SchemaValidator, type_adapter.validator)
json_schema = _utils.check_object_json_schema(
type_adapter.json_schema(schema_generator=GenerateToolJsonSchema)
)

We can use the existing type adapter for the JSON schema generation, which should be strict, but for the self.validator, use a more lenient schema that's effectively response: OutputDataT | Json[OutputDataT], so that it will also correctly validate a JSON string.

Can you give that a try please? Let me know if you'd like any additional pointers.

@shaheerzaman
Copy link
Contributor Author

@shaheerzaman Thanks! I think we can implement this more cleanly using https://docs.pydantic.dev/2.0/usage/types/json/.

Specifically, I think we can change this section:

self.outer_typed_dict_key = 'response'
response_data_typed_dict = TypedDict( # noqa: UP013
'response_data_typed_dict',
{'response': cast(type[OutputDataT], output)}, # pyright: ignore[reportInvalidTypeForm]
)
type_adapter = TypeAdapter(response_data_typed_dict)
# Really a PluggableSchemaValidator, but it's API-compatible
self.validator = cast(SchemaValidator, type_adapter.validator)
json_schema = _utils.check_object_json_schema(
type_adapter.json_schema(schema_generator=GenerateToolJsonSchema)
)

We can use the existing type adapter for the JSON schema generation, which should be strict, but for the self.validator, use a more lenient schema that's effectively response: OutputDataT | Json[OutputDataT], so that it will also correctly validate a JSON string.

Can you give that a try please? Let me know if you'd like any additional pointers.

@DouweM I have made the changes as you suggested.


return tool_result

def _validate_tool_args(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shaheerzaman I don't think we need these changes anymore now that we use Json[]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

json_schema['description'] = self._function_schema.description
else:
type_adapter: TypeAdapter[Any]
schema_validator: SchemaValidator
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use the PluggableSchemaValidator type here, so that we only need to do the cast once on the self.validator = line?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

'response_validation_typed_dict',
{'response': cast(type[OutputDataT], output) | Json[cast(type[OutputDataT], output)]}, # pyright: ignore[reportInvalidTypeForm]
)
validation_type_adapter: TypeAdapter[Any] = TypeAdapter(response_validation_typed_dict)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need the type hint here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

{'response': cast(type[OutputDataT], output) | Json[cast(type[OutputDataT], output)]}, # pyright: ignore[reportInvalidTypeForm]
)
validation_type_adapter: TypeAdapter[Any] = TypeAdapter(response_validation_typed_dict)
schema_validator = cast(SchemaValidator, validation_type_adapter.validator)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we don't use validation_type_adapter elsewhere, we can inline it here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

type_adapter = TypeAdapter(response_data_typed_dict)

# More lenient validator: allow either the native type or a JSON string containing it
# i.e., response: OutputDataT | Json[OutputDataT]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's mention the specific model that does this wrong

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also add a test for the case where the model returns nested JSON and verifies that it's parsed correctly

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mentioned bedrock model in the comment and added a test file

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was hoping to see a single test like this one:

def test_result_tuple():
def return_tuple(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
args_json = '{"response": ["foo", "bar"]}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(return_tuple), output_type=tuple[str, str])
result = agent.run_sync('Hello')
assert result.output == ('foo', 'bar')

Can you create one like it, for the output type you ran into issues with, with the nested-JSON API response that has been failing but will now work?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

# More lenient validator: allow either the native type or a JSON string containing it
# i.e., response: OutputDataT | Json[OutputDataT] for some models that respond well to
# instructions. in this case BedrockConverseModel - 'us.meta.llama3-2-11b-instruct-v1:0'model
response_data_typed_dict = TypedDict( # noqa: UP013 # pyright: ignore[reportGeneralTypeIssues]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please show me the error we were seeing without this ignore?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TypedDict must be assigned to a variable named "response_validation_typed_dict"PylancereportGeneralTypeIssues
Convert response_data_typed_dict from TypedDict functional to class syntaxRuffUP013
(variable) response_data_typed_dict: type[response_validation_typed_dict]

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shaheerzaman Ah interesting. Then maybe we can use the original variable name for the new type adapter, that will actually be used for validation, and have a new variable name for the one only used for JSON.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

@DouweM DouweM changed the title Decode stringified JSON for output tool args (Bedrock lists/objects) Support models that return output tool args as {"response': "<JSON string>"} Sep 10, 2025
@DouweM DouweM enabled auto-merge (squash) September 10, 2025 17:17
@DouweM DouweM merged commit 95b80fa into pydantic:main Sep 10, 2025
29 checks passed
@DouweM
Copy link
Collaborator

DouweM commented Sep 10, 2025

@shaheerzaman Thanks Shaheer!

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

Successfully merging this pull request may close these issues.

Tool Output with Bedrock struggles with lists of objects
2 participants