Skip to content

refactor(ai): back assert_response_judged with a JudgeAgent#185

Merged
tmgbedu merged 7 commits into
devfrom
task/judge-agent-record
Jul 17, 2026
Merged

refactor(ai): back assert_response_judged with a JudgeAgent#185
tmgbedu merged 7 commits into
devfrom
task/judge-agent-record

Conversation

@tmgbedu

@tmgbedu tmgbedu commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add JudgeAgent(Agent) — the LLM-as-judge call now runs through the standard Agent provider/model-resolution pipeline instead of RecordingAgent._judge_live hand-rolling its own init_chat_model()/.invoke() call.
  • JudgeAgent is fakeable via JudgeAgent.fake() and replayable via JudgeAgent.record(), same as any other agent.
  • assert_response_judged() is now async (the judge call is a real Agent.prompt() under the hood) and takes an optional provider kwarg, forwarded to JudgeAgent and folded into the cached verdict's cache key.
  • Updated the fluent-DSL test suite and the example app's test_router_agent.py for the await + provider change.

Test plan

  • uv run pytest --ignore=tests/masoniteorm/postgres — 1874 passed, 7 skipped
  • uv run ruff check . / uv run ruff format --check . — clean
  • New tests/ai/test_judge_agent.py covers JudgeAgent directly (verdict parsing, prose-tolerant JSON extraction, constructor model/provider, and usable via .record())

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.
@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.58333% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...astapi_startkit/src/fastapi_startkit/ai/testing.py 58.33% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

@tmgbedu tmgbedu left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Code Review Verdict: APPROVE ✅ (posting as comment — GH blocks self-approval)

Clean, well-scoped refactor. It replaces the hand-rolled init_chat_model().invoke() judge with a JudgeAgent(Agent) that runs through the standard Agent pipeline — and as a bonus it fixes the latent type bug I flagged on #183.

✅ Async signature change — no silent breakage (the key risk)

assert_response_judged() going sync→async is dangerous for a public assert API: an un-awaited call returns a truthy coroutine and the assertion silently never runs (false green). I grepped every caller in the repo — all 9 are awaited: the example test_router_agent.py and 8 sites in test_agent_record_fluent.py. No un-awaited caller exists anywhere (src, tests, example). The internal _judge/_judge_live chain is consistently async. ✓

✅ JudgeAgent reuses the standard Agent pipeline

JudgeAgent.judge()await self.prompt(...) → normal _build_messages/_build_model (Ai().get_model_for) model+provider resolution. So it's fakeable via JudgeAgent.fake([...]) and replayable via JudgeAgent.record() by class name, exactly like any agent (covered by test_judge_is_usable_via_the_record_fluent_dsl). Constructor correctly overrides model/provider only when provided. _parse_verdict moved cleanly to judge.py (no duplicate left in testing.py). provider is folded into the verdict cache key (judge_provider), so verdicts don't collide across providers. ✓

✅ Fixes my earlier #183 Blocker #2

The old _judge_live passed chat_model.invoke(...).content (str | list[...]) into _parse_verdict(raw: str) — a reportArgumentType error + latent TypeError on list-style content. Now the judge returns an AgentResponse (whose .content is typed str), so _parse_verdict(str) is type-clean. pyright drops 660 (dev baseline) → 659 here; 0 errors in judge.py/testing.py.

✅ Verification

  • ruff check + format clean; no type: ignore/pyright: ignore added.
  • Full suite 1867 passed / 7 skipped locally (excluding env-only Postgres). Your reported 1874 differs by 7 — consistent with optional/API-key-gated tests that are skipped in my env; no failures either way.
  • New test_judge_agent.py (7 tests): verdict parse pass/fail, prose-tolerant JSON extraction, prompt contents, constructor model/provider, fluent-DSL integration. Good direct coverage.

Notes (non-blocking)

  1. Unplanned scope (as you flagged): this is a follow-on refactor, but a net improvement — removes hand-rolled langchain code, fixes a real latent bug, unifies the judge into the standard pipeline. No objection to it landing on merit.
  2. Mixed-async DSL ergonomics: assert_response_judged is now async while the sibling asserts (assert_text_response, assert_tool_called, assert_response_time_lt) stay sync — so some asserts need await and some don't. Justified (only the judge does I/O) but worth a one-line docstring note so users don't forget the await.
  3. Carry-over (not this PR): AgentSnapshot remains orphaned in dev (from #183, merged directly with that unaddressed). Out of scope here; flagging so it's not forgotten for a future cleanup.

Not merging per instructions — approve only.

tmgbedu added 6 commits July 16, 2026 21:46
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.
… 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.
…alls

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.
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.
…ut 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.
…ng 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.
@tmgbedu
tmgbedu merged commit 05647ee into dev Jul 17, 2026
5 of 6 checks passed
@tmgbedu
tmgbedu deleted the task/judge-agent-record branch July 17, 2026 18:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant