Evals¶
Unit tests assert wiring; they can't tell you whether the agent behaves —
answers correctly, calls the right tool, stays concise — especially when
behavior is non-deterministic. lovia.eval turns "does my agent behave?"
into a declarative suite with three ideas: a Case pairs an input with
checks; a check is any callable over a RunResult; evaluate()
returns a Report you can print, assert on, and diff against a baseline.
from lovia.eval import Case, contains, evaluate, llm_judge, tool_called
cases = [
Case("What is the capital of France?", checks=[contains("Paris")]),
Case("What's 23.4 * 91?", checks=[tool_called("calculator")]),
Case(
"Write a haiku about spring",
checks=[llm_judge("A 5-7-5 haiku that evokes spring")],
samples=4, # non-determinism is measured, not retried away:
pass_threshold=0.75, # pass if at least 3 of 4 samples pass
),
]
report = await evaluate(agent, cases)
print(report)
assert report.passed
eval: 2/3 cases passed (67%) · 6 samples · 4,812 tokens · 21.4s
✓ What is the capital of France? 1/1
✓ What's 23.4 * 91? 1/1
✗ Write a haiku about spring 2/4 llm_judge (score 0.55) — third line has eight syllables
Cases¶
Case field |
Default | Meaning |
|---|---|---|
input |
required | a string, or list[Message] |
checks |
() |
the pass criteria (all must pass for a sample to pass) |
name |
derived from input | the report label and the compare() join key |
samples |
1 |
run the case N times; non-determinism becomes a number |
pass_threshold |
1.0 |
fraction of samples that must pass for the case to pass |
context |
None |
forwarded as the run's deps |
output_type / max_turns |
agent's / 50 |
per-case run settings |
model |
agent's | clone the agent onto another model for this case |
timeout |
None |
wall-clock cap per sample — a timeout is a failed sample, not a crashed suite |
metadata |
{} |
carried untouched into the result |
Case(model=...) is the workhorse: live suites pin a case to a different
model; offline suites give every case its own scripted transcript:
from lovia.testing import ScriptedProvider, call, text
Case(
"What is 2 + 3?",
checks=[contains("5"), tool_called("add")],
model=ScriptedProvider([call("add", {"a": 2, "b": 3}), text("2 + 3 = 5")]),
)
The agent is cloned per sample when model= is set, so single-use scripted
providers work with samples > 1 only via a factory (below).
Checks¶
Any callable (RunResult) -> CheckResult | bool, sync or async — built-in
matchers, the LLM judge, and your own functions are all the same thing:
A check that raises fails itself (with the exception as the reason),
never the suite — run_check normalizes every outcome into a
CheckResult(name=..., passed=..., score=..., reason=...); return one
yourself for graded results.
Built-ins: contains(value, ignore_case=False) / not_contains,
regex(pattern), equals(value), matches(spec) (recursive
subset-match for structured output — extra fields ignored, list lengths
exact; or pass a predicate), tool_called(name) / tool_not_called(name),
max_turns(n), max_tokens(n), no_error() (no failed tool calls in the
run). Compose with all_of(...), any_of(...), and
weighted({check: weight, ...}, threshold=0.7) — weighted converts child
scores (or pass/fail) into one graded verdict.
The LLM judge¶
llm_judge(rubric, *, model=None, threshold=0.7) grades semantics a
matcher can't:
Under the hood it is just another check running just another agent
(output_type=Verdict{score, reasoning}, temperature 0). The judge model
resolves from model= or $LOVIA_EVAL_JUDGE_MODEL — never silently from
the agent under test — and passed = score >= threshold. Pass a
ScriptedProvider as model and the judge is offline too, which is how
the whole suite runs in CI for free.
Running suites¶
report = await evaluate(agent_or_factory, cases, concurrency=4, fail_fast=False,
price=lambda u: u.input_tokens * 3e-6 + u.output_tokens * 15e-6)
- Agent or factory (the
AgentSourceunion). A zero-arg factory is invoked per sample — pass one whenever the agent is stateful (scripted providers, stateful tools). - Concurrency is across cases (default 4); a case's samples run sequentially; a sample's checks run concurrently.
- Errors are data. A sample that raises (or times out) records its
errorand fails alone; the suite always completes.fail_fast=Trueruns cases sequentially and stops after the first failing case. price=turns usage into cost; the report then shows· $0.0421.
Reports and baselines¶
A Report holds one CaseResult per case, each holding one
SampleResult per sample (checks, output, usage, latency, optional cost,
error). Report.passed, .pass_rate, and per-case CaseResult.pass_rate
/ .pass_at_k(k) (the unbiased estimator) cover the numbers;
print(report) gives the summary above. For CI:
report.save("eval-baseline.json") # once, on a good run
current = await evaluate(agent, cases)
diff = current.compare(Report.load("eval-baseline.json"))
print(diff) # regressions / improvements / added / removed
assert diff.ok # truthy ⇔ no regressions
compare returns a Diff and joins by case name (duplicate names are
an error — name your cases when inputs repeat); improvements and
added/removed cases are reported but don't fail diff.ok.
Sharp edges¶
- Judge cost scales as
samples × judge-checks × cases— each judge evaluation is a model call. Keep judges on the cases that need semantics; matchers are free. - A ready
Agentinstance is reused across samples unlessmodel=is set or a factory is passed. Fine for stateless agents; wrong for scripted ones — the second sample finds an empty script. samplesmeasures, it doesn't fix.pass_threshold < 1.0is a statement about acceptable flakiness; if a case needs retries to pass, that's a finding, not noise.no_error()sees tool errors only. A run that raises never reaches checks — it's already a failed sample witherrorset.
See also¶
- Testing —
ScriptedProvider, the offline engine under both suites and judges - Guardrails — the runtime twin of an output check
- Example:
28_eval.py— a fully offline, scripted suite with a scripted judge