11"""Structured output.
22
3- An Agent's schema() and tools() are passed to the model in a single payload:
4-
5- - schema + tools -> bind_tools([*tools, schema]); the model picks a real tool
6- call OR the schema as its structured answer. If it picks the schema, we parse
7- and return the structured result; otherwise we run the tool it asked for.
8- - schema only -> with_structured_output(); the shape is enforced.
9- - tools only / neither -> unchanged .
3+ An Agent's schema() is appended to its tools() and the whole set is bound to
4+ the model in a single payload via bind_tools([*tools, schema]). The model picks
5+ per turn: a real tool call (which the Runner executes) or the schema as its
6+ structured answer (which the Runner parses into response.parsed). If a
7+ schema-only agent replies with plain JSON text instead of the tool call,
8+ _apply_schema still parses it, so the structured result comes through either
9+ way .
1010
1111The fake/record paths bypass build(), so they keep parsing the JSON-string
1212content via schema() for deterministic replay.
@@ -76,16 +76,11 @@ def _patch_init(self, fake):
7676 def _fake_model (self ):
7777 class FakeModel :
7878 bound = None
79- structured = None
8079
8180 def bind_tools (self , tools , ** kwargs ):
8281 self .bound = list (tools )
8382 return "BOUND"
8483
85- def with_structured_output (self , schema , ** kwargs ):
86- self .structured = (schema , kwargs )
87- return "STRUCTURED"
88-
8984 fake = FakeModel ()
9085 self ._patch_init (fake )
9186 return fake
@@ -96,16 +91,15 @@ def test_schema_and_tools_are_bound_together_in_one_payload(self):
9691 result = Ai ().build (ToolMovieAgent ())
9792
9893 self .assertEqual (result , "BOUND" )
99- self .assertIn (noop , fake .bound )
100- self .assertIn (Movie , fake .bound )
94+ self .assertEqual (fake .bound , [noop , Movie ])
10195
102- def test_schema_only_uses_enforced_structured_output (self ):
96+ def test_schema_only_binds_the_schema_as_a_tool (self ):
10397 fake = self ._fake_model ()
10498
10599 result = Ai ().build (MovieAgent ())
106100
107- self .assertEqual (result , "STRUCTURED " )
108- self .assertEqual (fake .structured , ( Movie , { "include_raw" : True }) )
101+ self .assertEqual (result , "BOUND " )
102+ self .assertEqual (fake .bound , [ Movie ] )
109103
110104 def test_tools_only_binds_just_the_tools (self ):
111105 fake = self ._fake_model ()
@@ -152,18 +146,6 @@ async def ainvoke(self, messages):
152146
153147 self .assertEqual (result .content , "hello" )
154148
155- async def test_passes_structured_output_dict_through (self ):
156- parsed = Movie (title = "Inception" , year = 2010 )
157- payload = {"raw" : AIMessage (content = "" ), "parsed" : parsed , "parsing_error" : None }
158-
159- class Model :
160- async def ainvoke (self , messages ):
161- return payload
162-
163- result = await Runner (MovieAgent (), Model ()).run (["hi" ])
164-
165- self .assertEqual (result , payload )
166-
167149
168150class TestResponseMapping (unittest .TestCase ):
169151 def test_unwraps_include_raw_into_parsed_and_content (self ):
@@ -191,23 +173,33 @@ def _patch(self, model):
191173 patcher .start ()
192174 self .addCleanup (patcher .stop )
193175
194- async def test_schema_only_populates_parsed (self ):
195- parsed = Movie (title = "Inception" , year = 2010 )
176+ async def test_schema_only_populates_parsed_from_the_schema_tool_call (self ):
177+ class FakeModel :
178+ def bind_tools (self , tools , ** kwargs ):
179+ return self
196180
197- class Structured :
198181 async def ainvoke (self , messages ):
199- return {"raw" : AIMessage (content = "" ), "parsed" : parsed , "parsing_error" : None }
182+ return AIMessage (content = "" , tool_calls = [_schema_tool_call (title = "Inception" , year = 2010 )])
183+
184+ self ._patch (FakeModel ())
185+
186+ response = await MovieAgent ().prompt ("best nolan movie" )
187+
188+ self .assertEqual (response .parsed , Movie (title = "Inception" , year = 2010 ))
200189
190+ async def test_schema_only_parses_plain_json_text_when_model_skips_the_tool (self ):
201191 class FakeModel :
202- def with_structured_output (self , schema , ** kwargs ):
203- return Structured ()
192+ def bind_tools (self , tools , ** kwargs ):
193+ return self
194+
195+ async def ainvoke (self , messages ):
196+ return AIMessage (content = '{"title": "Inception", "year": 2010}' )
204197
205198 self ._patch (FakeModel ())
206199
207200 response = await MovieAgent ().prompt ("best nolan movie" )
208201
209- self .assertEqual (response .parsed , parsed )
210- self .assertEqual (response .content , parsed .model_dump_json ())
202+ self .assertEqual (response .parsed , Movie (title = "Inception" , year = 2010 ))
211203
212204 async def test_schema_plus_tools_returns_structured_when_model_chooses_it (self ):
213205 class FakeModel :
0 commit comments