Skip to content

Commit a67bd4a

Browse files
committed
refactor(ai): always bind schema as a tool, drop with_structured_output branch
schema() is just appended to tools() and the whole set is bound in one call. This collapses the schema-only special case: with_structured_output only added tool_choice="any" enforcement, which is redundant now that _apply_schema parses plain JSON text for tool-less agents -- so a schema-only agent gets its parsed result whether the model emits the schema tool call or replies in JSON text. Also drops the now-dead structured-dict passthrough in Runner.run.
1 parent ea9e586 commit a67bd4a

3 files changed

Lines changed: 31 additions & 48 deletions

File tree

fastapi_startkit/src/fastapi_startkit/ai/ai.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,10 @@ def build(
8585

8686
tools = list(agent.tools())
8787
schema = agent.schema()
88-
8988
if structured and schema is not None:
90-
if tools:
91-
return chat_model.bind_tools([*tools, schema])
92-
return chat_model.with_structured_output(schema, include_raw=True)
93-
94-
if tools:
95-
return chat_model.bind_tools(tools)
89+
tools.append(schema)
9690

97-
return chat_model
91+
return chat_model.bind_tools(tools) if tools else chat_model
9892

9993
def _resolve_model(self, agent: "Agent", override: str | None = None) -> str:
10094
return Lab.get_provider(agent.provider).get_model(override or agent.model or None)

fastapi_startkit/src/fastapi_startkit/ai/runner.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,6 @@ async def run(self, messages: Sequence[Message]) -> BaseMessage:
2929
history: list[Message] = list(messages)
3030
response: AIMessage = await self.model.ainvoke(history) # type: ignore[assignment]
3131

32-
if isinstance(response, dict) and "parsed" in response:
33-
return response # type: ignore[return-value]
34-
3532
tool_calls = list(getattr(response, "tool_calls", None) or [])
3633
if not tool_calls:
3734
return response

fastapi_startkit/tests/ai/test_structured_output.py

Lines changed: 29 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
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
1111
The fake/record paths bypass build(), so they keep parsing the JSON-string
1212
content 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

168150
class 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

Comments
 (0)