From 5d18f8fdf10a15c9362e26c774302aca03755716 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 30 Sep 2025 23:24:08 +0000 Subject: [PATCH 1/5] Raise error when StructuredDict is used with recursive JSON schema refs --- pydantic_ai_slim/pydantic_ai/_json_schema.py | 2 ++ pydantic_ai_slim/pydantic_ai/output.py | 6 +++++- tests/test_agent.py | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/_json_schema.py b/pydantic_ai_slim/pydantic_ai/_json_schema.py index cde9eeb215..a93d63b9e1 100644 --- a/pydantic_ai_slim/pydantic_ai/_json_schema.py +++ b/pydantic_ai_slim/pydantic_ai/_json_schema.py @@ -77,6 +77,8 @@ def _handle(self, schema: JsonSchema) -> JsonSchema: if self.prefer_inlined_defs: while ref := schema.get('$ref'): key = re.sub(r'^#/\$defs/', '', ref) + if key in self.recursive_refs: + break if key in self.refs_stack: self.recursive_refs.add(key) break # recursive ref can't be unpacked diff --git a/pydantic_ai_slim/pydantic_ai/output.py b/pydantic_ai_slim/pydantic_ai/output.py index 2a4c821dc4..beb0b84adb 100644 --- a/pydantic_ai_slim/pydantic_ai/output.py +++ b/pydantic_ai_slim/pydantic_ai/output.py @@ -9,7 +9,7 @@ from pydantic_core import core_schema from typing_extensions import TypeAliasType, TypeVar, deprecated -from . import _utils +from . import _utils, exceptions from ._json_schema import InlineDefsJsonSchemaTransformer from .messages import ToolCallPart from .tools import DeferredToolRequests, ObjectJsonSchema, RunContext, ToolDefinition @@ -316,6 +316,10 @@ def StructuredDict( # See https://github.com/pydantic/pydantic/issues/12145 if '$defs' in json_schema: json_schema = InlineDefsJsonSchemaTransformer(json_schema).walk() + if '$defs' in json_schema: + raise exceptions.UserError( + '`StructuredDict` does not currently support recursive `$ref`s and `$defs`. See https://github.com/pydantic/pydantic/issues/12145 for more information.' + ) if name: json_schema['title'] = name diff --git a/tests/test_agent.py b/tests/test_agent.py index c382725c9d..ce39a969b2 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1466,6 +1466,20 @@ def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse: assert result.output == snapshot({'make': 'Toyota', 'model': 'Camry', 'tires': [{'brand': 'Michelin', 'size': 17}]}) +def test_structured_dict_recursive_refs(): + class Node(BaseModel): + nodes: list['Node'] + + schema = Node.model_json_schema() + with pytest.raises( + UserError, + match=re.escape( + '`StructuredDict` does not currently support recursive `$ref`s and `$defs`. See https://github.com/pydantic/pydantic/issues/12145 for more information.' + ), + ): + StructuredDict(schema) + + def test_default_structured_output_mode(): def hello(_: list[ModelMessage], _info: AgentInfo) -> ModelResponse: return ModelResponse(parts=[TextPart(content='hello')]) # pragma: no cover From 41d262e19d4ea696a0e08ca605e9cadc6f940959 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 30 Sep 2025 23:26:47 +0000 Subject: [PATCH 2/5] update generate_dataset comment --- pydantic_evals/pydantic_evals/generation.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pydantic_evals/pydantic_evals/generation.py b/pydantic_evals/pydantic_evals/generation.py index c1e68a6ea8..fd3b034573 100644 --- a/pydantic_evals/pydantic_evals/generation.py +++ b/pydantic_evals/pydantic_evals/generation.py @@ -59,7 +59,8 @@ async def generate_dataset( """ output_schema = dataset_type.model_json_schema_with_evaluators(custom_evaluator_types) - # TODO(DavidM): Update this once we add better response_format and/or ResultTool support to Pydantic AI + # TODO: Use `output_type=StructuredDict(output_schema)` (and `from_dict` below) once https://github.com/pydantic/pydantic/issues/12145 + # is fixed and `StructuredDict` no longer needs to use `InlineDefsJsonSchemaTransformer`. agent = Agent( model, system_prompt=( From bcf9e12215041bd53709d2c2f4cab5a6f3c43a7d Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 30 Sep 2025 23:46:35 +0000 Subject: [PATCH 3/5] coverage --- tests/test_agent.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/test_agent.py b/tests/test_agent.py index ce39a969b2..fcffd2b846 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1468,9 +1468,30 @@ def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse: def test_structured_dict_recursive_refs(): class Node(BaseModel): - nodes: list['Node'] + nodes: list['Node'] | dict[str, 'Node'] schema = Node.model_json_schema() + assert schema == snapshot( + { + '$defs': { + 'Node': { + 'properties': { + 'nodes': { + 'anyOf': [ + {'items': {'$ref': '#/$defs/Node'}, 'type': 'array'}, + {'additionalProperties': {'$ref': '#/$defs/Node'}, 'type': 'object'}, + ], + 'title': 'Nodes', + } + }, + 'required': ['nodes'], + 'title': 'Node', + 'type': 'object', + } + }, + '$ref': '#/$defs/Node', + } + ) with pytest.raises( UserError, match=re.escape( From bc32fd2d48ebce23c06de6797ca47f08fe0a3e73 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 30 Sep 2025 23:54:32 +0000 Subject: [PATCH 4/5] coverage --- pydantic_ai_slim/pydantic_ai/_json_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/_json_schema.py b/pydantic_ai_slim/pydantic_ai/_json_schema.py index a93d63b9e1..97a1e73b54 100644 --- a/pydantic_ai_slim/pydantic_ai/_json_schema.py +++ b/pydantic_ai_slim/pydantic_ai/_json_schema.py @@ -54,7 +54,7 @@ def walk(self) -> JsonSchema: if not self.prefer_inlined_defs and self.defs: handled['$defs'] = {k: self._handle(v) for k, v in self.defs.items()} - elif self.recursive_refs: # pragma: no cover + elif self.recursive_refs: # If we are preferring inlined defs and there are recursive refs, we _have_ to use a $defs+$ref structure # We try to use whatever the original root key was, but if it is already in use, # we modify it to avoid collisions. From 8bca9625758e59927b5396a7edb5f012474de1fe Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Wed, 1 Oct 2025 00:05:24 +0000 Subject: [PATCH 5/5] coverage --- pydantic_ai_slim/pydantic_ai/_json_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/_json_schema.py b/pydantic_ai_slim/pydantic_ai/_json_schema.py index 97a1e73b54..cbaa180208 100644 --- a/pydantic_ai_slim/pydantic_ai/_json_schema.py +++ b/pydantic_ai_slim/pydantic_ai/_json_schema.py @@ -61,7 +61,7 @@ def walk(self) -> JsonSchema: defs = {key: self.defs[key] for key in self.recursive_refs} root_ref = self.schema.get('$ref') root_key = None if root_ref is None else re.sub(r'^#/\$defs/', '', root_ref) - if root_key is None: + if root_key is None: # pragma: no cover root_key = self.schema.get('title', 'root') while root_key in defs: # Modify the root key until it is not already in use