Skip to content

Commit 05647ee

Browse files
authored
refactor(ai): back assert_response_judged with a JudgeAgent (#185)
* refactor(ai): back assert_response_judged with a JudgeAgent Replace RecordingAgent._judge_live's hand-rolled init_chat_model()/invoke() call with JudgeAgent, an Agent subclass. The judge now goes through the same provider/model resolution pipeline as any other agent, and is fakeable via JudgeAgent.fake() and replayable via JudgeAgent.record() instead of only being testable by mocking langchain directly. assert_response_judged() is now async (the judge call is a real Agent.prompt() under the hood) and accepts an optional provider kwarg, forwarded through to JudgeAgent and folded into the cached verdict's cache key. * refactor(ai): drop JudgeAgent's constructor, use plain Agent attributes model/provider are never set via constructor args anywhere else in the framework -- always class attributes or the @model()/@Provider() decorators. Match that: JudgeAgent has no __init__ override, and _judge_live() sets .model/.provider directly on the instance, same as any other Agent. * refactor(ai): parse JudgeAgent verdicts via schema(), not hand-rolled JSON Give JudgeAgent a Verdict pydantic schema() and put the grading rubric in instructions() -- the JSON reply is now turned into a typed result through the same structured-output path any Agent gets from schema()/response.parsed. Drops the bespoke _build_prompt()/_parse_verdict() helpers. * feat(ai): enforce schema() via with_structured_output on real model calls When an Agent declares schema() and has no tools, build the model with chat_model.with_structured_output(schema, include_raw=True) so the provider enforces the shape, instead of relying on prompt instructions + post-hoc JSON parsing. include_raw keeps the raw message so content/usage/cassettes still work; the Runner passes the structured result through without executing the synthetic tool call, and _to_agent_response unwraps it into response.parsed. Streaming opts out (needs raw token chunks), tools take precedence over a schema in a single call, and the fake/record paths are unchanged (they keep exercising the JSON-string parse path for deterministic replay). Also drops two stale docstrings from Ai. * feat(ai): pass tools and schema in one payload; model picks per turn When an agent declares both tools() and schema(), bind them together via bind_tools([*tools, schema]) so a single model call offers both. The model returns either a real tool call (which the Runner executes) or the schema as its structured answer (which the Runner parses into response.parsed). Schema without tools still uses with_structured_output() for enforcement. _apply_schema no longer coerces content when the agent has tools, so a tool's plain-text output is not force-parsed into the schema. * 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. * refactor(ai): use with_structured_output for schema; leave tool binding as-is Bind tools exactly as before, then wrap the model with with_structured_output(schema, include_raw=True) when a schema is declared. The wrapped model returns {raw, parsed, parsing_error}; the Runner passes it through and _to_agent_response unwraps it into response.parsed. Reverts the schema-as-a-tool detection and the _apply_schema tools guard.
1 parent 4bf8f38 commit 05647ee

10 files changed

Lines changed: 400 additions & 80 deletions

File tree

example/agents/tests/units/agents/test_router_agent.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
from langchain_core.messages import AIMessage, HumanMessage
22

33
from app.agents.chat import RouterAgent
4+
from tests.test_case import TestCase
45

56

6-
class TestRouterAgent:
7+
class TestRouterAgent(TestCase):
78
async def test_the_router_agent(self):
89
with RouterAgent.record("record_stream.json") as agent:
910
await agent.prompt("hello")
1011
agent.assert_text_response()
1112
agent.assert_tool_not_called(["job_search_tool"])
12-
agent.assert_response_judged(
13-
model="gpt-3.5-turbo",
13+
await agent.assert_response_judged(
14+
model="gemini-3.5-flash-lite",
15+
provider="google",
1416
expectation="The llm should respond with greetings",
1517
)
1618
agent.assert_response_time_lt(5)

fastapi_startkit/src/fastapi_startkit/ai/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .image import Image, ImageResponse
1111
from .image_factory import ImageFactory
1212
from .ai import Ai
13+
from .judge import JudgeAgent
1314
from .providers.ai_provider import AIProvider
1415
from .response import AgentResponse, AgentSnapshot
1516
from .testing import AgentBinding, AgentModelFake, RecordingAgent, ToolCallView
@@ -25,6 +26,7 @@
2526
"AIConfig",
2627
"AIProvider",
2728
"AnthropicConfig",
29+
"JudgeAgent",
2830
"RecordingAgent",
2931
"ToolCallView",
3032
"Audio",

fastapi_startkit/src/fastapi_startkit/ai/agent.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -188,27 +188,37 @@ def _build_messages(
188188

189189
return messages
190190

191-
def _build_model(self, model: str | None = None, provider_options: dict | None = None) -> Any:
191+
def _build_model(
192+
self, model: str | None = None, provider_options: dict | None = None, structured: bool = True
193+
) -> Any:
192194
from .ai import Ai # noqa: PLC0415
193195

194-
return Ai().get_model_for(self, model, provider_options)
196+
return Ai().get_model_for(self, model, provider_options, structured)
195197

196198
def _to_agent_response(self, result: Any) -> AgentResponse:
199+
parsed = None
200+
structured = isinstance(result, dict) and "parsed" in result and "raw" in result
201+
if structured:
202+
parsed = result.get("parsed")
203+
result = result.get("raw")
204+
197205
messages = result.get("messages", []) if isinstance(result, dict) else []
198206
final = messages[-1] if messages else result
199207

200208
content = getattr(final, "content", "")
201209
if not isinstance(content, str):
202210
content = str(content)
211+
if structured and not content and hasattr(parsed, "model_dump_json"):
212+
content = parsed.model_dump_json()
203213

204-
tool_calls = list(getattr(final, "tool_calls", None) or [])
214+
tool_calls = [] if structured else list(getattr(final, "tool_calls", None) or [])
205215

206216
usage: dict[str, Any] = {}
207217
meta = getattr(final, "usage_metadata", None)
208218
if meta:
209219
usage = {"input": meta.get("input_tokens", 0), "output": meta.get("output_tokens", 0)}
210220

211-
return AgentResponse(content=content, tool_calls=tool_calls, usage=usage, raw=result)
221+
return AgentResponse(content=content, tool_calls=tool_calls, usage=usage, raw=result, parsed=parsed)
212222

213223
def _apply_schema(self, response: AgentResponse) -> AgentResponse:
214224
schema = self.schema()
@@ -253,7 +263,7 @@ async def _stream(
253263
from .runner import StreamRunner # noqa: PLC0415
254264

255265
messages = self._build_messages(message)
256-
chat_model = self._build_model(model, provider_options)
266+
chat_model = self._build_model(model, provider_options, structured=False)
257267
chain = list(self.middleware())
258268

259269
def core(m: Any) -> Response:

fastapi_startkit/src/fastapi_startkit/ai/ai.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,6 @@ def _key(agent: "Agent | str") -> str:
2121

2222
@classmethod
2323
def fake(cls, agent: "Agent | str", messages: list) -> Any:
24-
"""Register a deterministic stand-in chat model for ``agent``.
25-
26-
Replays ``messages`` in order via a GenericFakeChatModel — no live
27-
LLM call. Plain strings are coerced into ``AIMessage(content=...)``.
28-
"""
2924
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
3025
from langchain_core.messages import AIMessage
3126

@@ -51,14 +46,24 @@ def reset_fakes(cls) -> None:
5146
cls.fake_agent_models.clear()
5247
cls.fake_agent_responses.clear()
5348

54-
def get_model_for(self, agent: "Agent", model: str | None = None, provider_options: dict | None = None) -> Any:
55-
"""Resolve the model to run: a registered fake if one exists for
56-
``agent``, otherwise a freshly-built provider model."""
49+
def get_model_for(
50+
self,
51+
agent: "Agent",
52+
model: str | None = None,
53+
provider_options: dict | None = None,
54+
structured: bool = True,
55+
) -> Any:
5756
if self.has_fake_model_for(agent):
5857
return self.get_fake_model_for(agent)
59-
return self.build(agent, model, provider_options)
60-
61-
def build(self, agent: "Agent", model: str | None = None, provider_options: dict | None = None) -> Any:
58+
return self.build(agent, model, provider_options, structured)
59+
60+
def build(
61+
self,
62+
agent: "Agent",
63+
model: str | None = None,
64+
provider_options: dict | None = None,
65+
structured: bool = True,
66+
) -> Any:
6267
from langchain.chat_models import init_chat_model # noqa: PLC0415
6368

6469
lab = Lab.get_provider(agent.provider)
@@ -79,7 +84,13 @@ def build(self, agent: "Agent", model: str | None = None, provider_options: dict
7984
chat_model = init_chat_model(self._resolve_model(agent, model), **kwargs)
8085

8186
tools = list(agent.tools())
82-
return chat_model.bind_tools(tools) if tools else chat_model
87+
chat_model = chat_model.bind_tools(tools) if tools else chat_model
88+
89+
schema = agent.schema()
90+
if structured and schema is not None:
91+
chat_model = chat_model.with_structured_output(schema, include_raw=True)
92+
93+
return chat_model
8394

8495
def _resolve_model(self, agent: "Agent", override: str | None = None) -> str:
8596
return Lab.get_provider(agent.provider).get_model(override or agent.model or None)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
from pydantic import BaseModel
4+
5+
from .agent import Agent
6+
7+
8+
class Verdict(BaseModel):
9+
passed: bool
10+
reasoning: str = ""
11+
12+
13+
class JudgeAgent(Agent):
14+
"""Grades a response against a natural-language expectation.
15+
16+
A plain ``Agent`` whose ``schema()`` is a ``Verdict`` model, so the JSON
17+
reply is parsed into a typed result through the standard structured-output
18+
path — no hand-rolled verdict parsing. Set ``.model``/``.provider`` like
19+
any other agent; it's fakeable via ``fake()`` and replayable via
20+
``record()`` for free.
21+
"""
22+
23+
def instructions(self) -> str:
24+
return (
25+
"You are grading whether an AI agent's response satisfies an expectation. "
26+
'Reply with strict JSON only, no prose: {"passed": true|false, "reasoning": "<one sentence>"}'
27+
)
28+
29+
def schema(self):
30+
return Verdict
31+
32+
async def judge(self, expectation: str, content: str) -> dict:
33+
response = await self.prompt(f"Expectation: {expectation}\n\nResponse to grade:\n{content}")
34+
return response.parsed.model_dump()

fastapi_startkit/src/fastapi_startkit/ai/runner.py

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

30+
if isinstance(response, dict) and "parsed" in response:
31+
return response # type: ignore[return-value]
32+
3033
if not response.tool_calls:
3134
return response
3235

fastapi_startkit/src/fastapi_startkit/ai/testing.py

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import hashlib
66
import inspect
77
import json
8-
import re
98
import sys
109
import time
1110
from collections.abc import AsyncIterator
@@ -244,49 +243,38 @@ def assert_response_time_lt(self, seconds: float) -> None:
244243
assert self.last_elapsed is not None, "No prompt() call has been made yet."
245244
assert self.last_elapsed < seconds, f"Expected response time < {seconds}s, took {self.last_elapsed:.3f}s"
246245

247-
def assert_response_judged(self, *, model: str, expectation: str) -> None:
246+
async def assert_response_judged(self, *, model: str, expectation: str, provider: str | None = None) -> None:
248247
response = self._require_response()
249-
verdict = self._judge(model, expectation, response.content)
248+
verdict = await self._judge(model, expectation, response.content, provider)
250249
assert verdict.get("passed"), (
251250
f"Judge ({model}) rejected the response for expectation {expectation!r}: "
252251
f"{verdict.get('reasoning', '')!r} — response was {response.content!r}"
253252
)
254253

255-
def _judge(self, model: str, expectation: str, content: str) -> dict:
254+
async def _judge(self, model: str, expectation: str, content: str, provider: str | None = None) -> dict:
256255
cassette, store = self._load()
257-
key = self._judge_key(model, expectation, content)
256+
key = self._judge_key(model, expectation, content, provider)
258257
if key in store:
259258
return store[key]
260-
verdict = self._judge_live(model, expectation, content)
259+
verdict = await self._judge_live(model, expectation, content, provider)
261260
self._save(cassette, store, key, verdict)
262261
return verdict
263262

264263
@staticmethod
265-
def _judge_key(model: str, expectation: str, content: str) -> str:
264+
def _judge_key(model: str, expectation: str, content: str, provider: str | None = None) -> str:
266265
payload = json.dumps(
267-
{"judge_model": model, "expectation": expectation, "content": content},
266+
{"judge_model": model, "judge_provider": provider, "expectation": expectation, "content": content},
268267
sort_keys=True,
269268
)
270269
return "judge:" + hashlib.sha256(payload.encode()).hexdigest()
271270

272-
def _judge_live(self, model: str, expectation: str, content: str) -> dict:
273-
from langchain.chat_models import init_chat_model # noqa: PLC0415
271+
async def _judge_live(self, model: str, expectation: str, content: str, provider: str | None = None) -> dict:
272+
from .judge import JudgeAgent # noqa: PLC0415
274273

275-
prompt = (
276-
"You are grading whether an AI agent's response satisfies an expectation.\n"
277-
f"Expectation: {expectation}\n"
278-
f"Response: {content}\n\n"
279-
'Reply with strict JSON only, no prose: {"passed": true|false, "reasoning": "<one sentence>"}'
280-
)
281-
chat_model = init_chat_model(model)
282-
result = chat_model.invoke(prompt)
283-
return self._parse_verdict(result.content)
284-
285-
@staticmethod
286-
def _parse_verdict(raw: str) -> dict:
287-
match = re.search(r"\{.*\}", raw, re.DOTALL)
288-
data = json.loads(match.group(0) if match else raw)
289-
return {"passed": bool(data.get("passed")), "reasoning": data.get("reasoning", "")}
274+
judge = JudgeAgent()
275+
judge.model = model
276+
judge.provider = provider
277+
return await judge.judge(expectation, content)
290278

291279

292280
class AgentBinding:

fastapi_startkit/tests/ai/test_agent_record_fluent.py

Lines changed: 27 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
import unittest
2121
from unittest import mock
2222

23-
import langchain.chat_models as chat_models
2423
from langchain_core.messages import AIMessage, HumanMessage
2524

2625
from fastapi_startkit.ai.agent import Agent
@@ -255,86 +254,79 @@ async def test_passes_when_judge_approves(self):
255254
self.setup_agent("Hello there, welcome!")
256255
with tempfile.TemporaryDirectory() as tmp:
257256
with mock.patch.object(
258-
RecordingAgent, "_judge_live", return_value={"passed": True, "reasoning": "greets the user"}
257+
RecordingAgent,
258+
"_judge_live",
259+
mock.AsyncMock(return_value={"passed": True, "reasoning": "greets the user"}),
259260
):
260261
with SimpleAgent.record(os.path.join(tmp, "c.json")) as agent:
261262
await agent.prompt("hello")
262-
agent.assert_response_judged(
263+
await agent.assert_response_judged(
263264
model="gpt-3.5-turbo", expectation="The llm should respond with greetings"
264265
)
265266

266267
async def test_fails_when_judge_rejects(self):
267268
self.setup_agent("Completely unrelated content")
268269
with tempfile.TemporaryDirectory() as tmp:
269270
with mock.patch.object(
270-
RecordingAgent, "_judge_live", return_value={"passed": False, "reasoning": "not a greeting"}
271+
RecordingAgent,
272+
"_judge_live",
273+
mock.AsyncMock(return_value={"passed": False, "reasoning": "not a greeting"}),
271274
):
272275
with SimpleAgent.record(os.path.join(tmp, "c.json")) as agent:
273276
await agent.prompt("hello")
274277
with self.assertRaises(AssertionError):
275-
agent.assert_response_judged(
278+
await agent.assert_response_judged(
276279
model="gpt-3.5-turbo", expectation="The llm should respond with greetings"
277280
)
278281

279282
async def test_verdict_is_cached_in_the_cassette_and_not_re_judged(self):
280283
self.setup_agent("Hello there!")
281-
judge = mock.Mock(return_value={"passed": True, "reasoning": "ok"})
284+
judge = mock.AsyncMock(return_value={"passed": True, "reasoning": "ok"})
282285
with tempfile.TemporaryDirectory() as tmp:
283286
cassette = os.path.join(tmp, "c.json")
284287
with mock.patch.object(RecordingAgent, "_judge_live", judge):
285288
with SimpleAgent.record(cassette) as agent:
286289
await agent.prompt("hello")
287-
agent.assert_response_judged(model="gpt-3.5-turbo", expectation="greet")
288-
agent.assert_response_judged(model="gpt-3.5-turbo", expectation="greet")
290+
await agent.assert_response_judged(model="gpt-3.5-turbo", expectation="greet")
291+
await agent.assert_response_judged(model="gpt-3.5-turbo", expectation="greet")
289292

290293
judge.assert_called_once()
291294

292295
async def test_verdict_persists_to_disk_for_a_later_replay(self):
293296
self.setup_agent("Hello there!")
294297
with tempfile.TemporaryDirectory() as tmp:
295298
cassette = os.path.join(tmp, "c.json")
296-
with mock.patch.object(RecordingAgent, "_judge_live", return_value={"passed": True, "reasoning": "ok"}):
299+
with mock.patch.object(
300+
RecordingAgent, "_judge_live", mock.AsyncMock(return_value={"passed": True, "reasoning": "ok"})
301+
):
297302
with SimpleAgent.record(cassette) as agent:
298303
await agent.prompt("hello")
299-
agent.assert_response_judged(model="gpt-3.5-turbo", expectation="greet")
304+
await agent.assert_response_judged(model="gpt-3.5-turbo", expectation="greet")
300305

301-
judge = mock.Mock(side_effect=AssertionError("must not be called on replay"))
306+
judge = mock.AsyncMock(side_effect=AssertionError("must not be called on replay"))
302307
with mock.patch.object(RecordingAgent, "_judge_live", judge):
303308
with SimpleAgent.record(cassette) as agent:
304309
await agent.prompt("hello")
305-
agent.assert_response_judged(model="gpt-3.5-turbo", expectation="greet")
310+
await agent.assert_response_judged(model="gpt-3.5-turbo", expectation="greet")
306311

307312
judge.assert_not_called()
308313

309314
async def test_fails_when_no_prompt_has_been_made(self):
310315
with tempfile.TemporaryDirectory() as tmp:
311316
with SimpleAgent.record(os.path.join(tmp, "c.json")) as agent:
312317
with self.assertRaises(AssertionError):
313-
agent.assert_response_judged(model="gpt-3.5-turbo", expectation="greet")
314-
315-
316-
class TestJudgeLiveModelCall(unittest.TestCase):
317-
def test_calls_init_chat_model_and_parses_json_verdict(self):
318-
captured = {}
319-
320-
class FakeResult:
321-
content = '{"passed": true, "reasoning": "Greets the user politely."}'
322-
323-
class FakeModel:
324-
def invoke(self, prompt):
325-
captured["prompt"] = prompt
326-
return FakeResult()
327-
328-
patcher = mock.patch.object(chat_models, "init_chat_model", lambda *a, **k: FakeModel())
329-
patcher.start()
330-
self.addCleanup(patcher.stop)
318+
await agent.assert_response_judged(model="gpt-3.5-turbo", expectation="greet")
331319

332-
agent = RecordingAgent(SimpleAgent())
333-
verdict = agent._judge_live("gpt-3.5-turbo", "The llm should respond with greetings", "Hello there!")
320+
async def test_provider_is_forwarded_to_the_judge(self):
321+
self.setup_agent("Hello there!")
322+
judge = mock.AsyncMock(return_value={"passed": True, "reasoning": "ok"})
323+
with tempfile.TemporaryDirectory() as tmp:
324+
with mock.patch.object(RecordingAgent, "_judge_live", judge):
325+
with SimpleAgent.record(os.path.join(tmp, "c.json")) as agent:
326+
await agent.prompt("hello")
327+
await agent.assert_response_judged(model="gpt-3.5-turbo", provider="openai", expectation="greet")
334328

335-
self.assertTrue(verdict["passed"])
336-
self.assertIn("Greets", verdict["reasoning"])
337-
self.assertIn("Hello there!", captured["prompt"])
329+
judge.assert_called_once_with("gpt-3.5-turbo", "greet", "Hello there!", "openai")
338330

339331

340332
class TestExistingRecordApiIsUnaffected(unittest.IsolatedAsyncioTestCase):

0 commit comments

Comments
 (0)