Skip to content

feat(ai): Ai model registry + fluent record() testing DSL#184

Merged
tmgbedu merged 8 commits into
devfrom
task/ai-trajectory-evals-1127
Jul 17, 2026
Merged

feat(ai): Ai model registry + fluent record() testing DSL#184
tmgbedu merged 8 commits into
devfrom
task/ai-trajectory-evals-1127

Conversation

@tmgbedu

@tmgbedu tmgbedu commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #183 (squash-merged): lands the commits pushed after that merge — model_builder.py -> ai.py rename, the fluent record() testing DSL (assert_text_response/assert_tool_called/assert_tool_not_called/assert_response_time_lt/assert_response_judged), and the async prompt() fix. TestJudgeAgent/evals.py removed (unused outside its own tests).

tmgbedu added 6 commits July 16, 2026 15:47
…r fake registry

Add a deterministic evals harness for testing AI agents, modeled on
LangChain's agentevals create_trajectory_match_evaluator.

TestJudgeAgent(trajectory_match_mode=...).record() yields a callable
evaluator that compares an actual message trajectory against a reference
trajectory with no live LLM call — pure structural comparison, typically
driven by Agent.fake() output:

    with TestJudgeAgent(trajectory_match_mode="strict").record() as evaluator:
        evaluation = evaluator(outputs=response["messages"], reference_outputs=reference)
    assert evaluation["score"] is True

Supported modes mirror agentevals semantics:
- strict: same messages in order, same role and tool calls per position
  (message content is not compared)
- unordered: same set of tool calls, any order
- subset: output tool calls all appear in the reference
- superset: output tool calls cover all reference tool calls (extras ok)

The evaluator returns a subscriptable result with key/score/comment,
following the LangSmith evaluator-result convention.

Also add a model-level fake registry to ModelBuilder: ModelBuilder.fake()
registers a GenericFakeChatModel for an agent (by class name or instance),
and get_model_for() returns it when present, otherwise builds a real
provider model as before. Agent._build_model() now routes through
get_model_for(), so a faked agent runs the same message-building /
pipeline / tool-execution path as a real one with only the underlying
model swapped. The existing Agent.fake()/FakeAgent/AgentBinding path is
unchanged.
…nd model-level fakes

Ai (formerly ModelBuilder) drops the per-instance agent binding: fake_agent_models/
fake_agent_responses are class-level registries keyed by agent class name, and
get_model_for()/build() take the agent as an argument instead of storing it in
the constructor.

Agent.fake() now registers a fixed, ordered list of replies as a deterministic
chat model (via Ai.fake()) instead of binding a whole FakeAgent stand-in into
the container. Replies flow through the real message-building/pipeline/tool-
execution path, so faked tool calls actually execute — only the model at the
bottom is swapped. Agent.record()/RecordingAgent/AgentBinding are unchanged.

Drops the dead _match_fake()/self._fakes machinery on Agent (never populated
by any code path) along with the now-unreachable FakeAgent/NoFakeResponse
testing helpers.
Bind record() with `as agent` to get a synchronous prompt() plus
assert_text_response(), assert_tool_called()/assert_tool_not_called(),
assert_response_time_lt(), and assert_response_judged() (LLM-as-judge,
verdict cached in the cassette) that all judge the most recent turn.

record(cassette, messages=...) seeds a session's prior history; cassette
keys now fold in the conversation so far (not just the literal message
text) so two sessions with different histories but the same follow-up
text don't collide. The pre-existing bare context-manager usage from
task #327 is unchanged.

Removes TestJudgeAgent/evals.py: its deterministic structural trajectory
matching is unused anywhere outside its own tests, and this DSL now
covers response judging via assert_response_judged.
RecordingAgent.prompt() no longer wraps the real async call with
asyncio.run() — it's now the same async method the bare context-manager
path already awaited internally. Wrapping every call in a fresh event
loop was unnecessary and would break inside any already-running loop.
Fluent tests now use IsolatedAsyncioTestCase + await agent.prompt(...).
dev's only unique commit (556f612, the squash-merge of this same PR's
earlier state) is tree-identical to e29efc9, already an ancestor of this
branch — nothing to bring in, this just records the merge so the branch
fast-forwards cleanly.
@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.00000% with 1 line in your changes missing coverage. Please review.

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

📢 Thoughts on this report? Let us know!

tmgbedu added 2 commits July 16, 2026 19:05
AgentBinding only ever wraps a RecordingAgent (from record()), which
always implements stream() — the hasattr(swapped, "stream") check and
its buffered-response fallback could never actually run.
@tmgbedu
tmgbedu merged commit 4bf8f38 into dev Jul 17, 2026
5 of 6 checks passed
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