Skip to content

Evaluate your agent

Evals run a set of cases on your agent, grade each answer, and report. Run them in a script, in CI with agent-adapter eval, or as pytest tests. Any agent works: a plain function, a chat-page agent, a Flow, or the built-in tool loop. Graders use only what you give them, so an LLM judge runs on your own model.

flowchart LR
  F["eval_cases.jsonl<br/>(the chat page saves these)"] --> L["load_cases"]
  L --> R["run_eval"]
  T["target<br/>fn · chat agent · Flow · tool loop"] --> R
  R --> E["one run per case on the Executor<br/>approvals · budgets · ledger · events"]
  E --> G["graders → Score 0-1 + reason"]
  G --> P["EvalReport"]
  P --> O["to_junit · to_markdown · to_json"]
  P --> C["compare(baseline) → regressions"]
  E -. "recorded runs" .-> V["agent-adapter ui"]

Cases

One JSON object per line, such as {"id": "a", "input": "Where is order 1042?", "expected": "shipped", "tags": ["orders"]}: id and input are required, other fields optional. The chat page's Save as eval case writes this format; load_cases also reads .json and .csv.

Run an eval

def evaluate(model_fn: Any, cases_file: Path) -> EvalReport: """Run every case through an executor with prices, and grade every run.""" agent = CallableModel(model_fn, provider="local", model="scripted") judge = CallableModel(scripted_judge, provider="local", model="scripted") return run_eval_sync( ToolLoopTarget(agent, registry, system="You answer shop order questions."), load_cases(cases_file), { "answer": contains(), "looked_up": tools_called("order_status"), "polite": LLMJudge(judge, "The reply is polite and mentions the order."), "budget": max_cost(0.01), }, executor=Executor(registry, ledger=Ledger(prices=PRICES)), name="shop-support", )

run_eval is the async form; run_eval_sync suits scripts. Each case is its own run on the executor, so its hooks, approvals, budgets, ledger and sinks apply. concurrency= caps cases at once (default 4); timeout_s= caps each case (default 300 s). A timeout cannot stop a sync target's worker thread, which may go on calling tools: prefer async targets.

Target Runs
fn(input) or fn(input, ctx=ctx) your function, sync or async; take ctx so its tool calls join the case's run
fn(message, session) a chat-page agent; each case is a fresh one-turn chat
ToolLoopTarget(model, registry, system=) run_tool_loop; the answer text (or parsed output)
FlowTarget(flow, initial_state=, output=) FlowRunner; output(state) is the answer

A callable with two arguments is a chat agent; it gets the chat page's own ChatSession.

Graders

Each grader returns a Score: value from 0 to 1, passed, and notes (the reason). A grader never raises; when it cannot grade, the case fails ("not graded").

Grader (agent_adapter.evals.graders) Passes when
exact(), contains() the output equals, or contains, expected (or one of a list)
regex() / regex(pattern) the expected pattern, or pattern, matches
json_schema(schema) the output (JSON text, dict or model) fits the schema
tools_called(*names, forbid=, only=) those tools ran (from the run's events)
max_cost(usd), max_latency(seconds) the case stayed within the limit (ledger, events, wall time)
LLMJudge(model, rubric) your model scores the answer at value >= pass_at
code_check(fn), all_of(*graders, weights=) your check; a mix that fails if any part fails or does not grade

Pass graders as a list, or as a dict to name them in the report; a sync fn(case, run) works too. A case passes only when it ran and every grader passed (fail closed). A grader that gives only a value passes at value >= pass_at (default 1.0).

Cost and tool graders need an observed run: an agent that works outside the case's run (its own executor, no ctx) leaves them "not graded", so the case fails.

An LLM judge reads agent text, which can carry injected instructions: data is escaped and its own passed ignored (trust_passed=True keeps it). Pair it with deterministic graders.

Read the report

EvalReport holds every CaseResult (score, grades, cost, latency, tools called, error, and the run_id) plus totals: mean_score, pass_rate, n_errors, cost_usd, by_tag and by_grader. A case's score is the mean of all its grades: an ungraded grade counts 0 and a failed one at most pass_at; an errored case counts 0.

candidate = evaluate(lazy_agent, cases_file) comparison = candidate.compare(baseline)

compare(baseline) lists cases that stopped passing or lost score (tolerance= allows some noise), the ones that improved, and added or removed cases; a removed case also counts as a regression. Save a report with to_json() and read it back with EvalReport.from_json(text).

Reports keep up to 4,000 characters of each case's input, expected value and output, even when runs are recorded with capture_content=False. Pass capture_content=False to run_eval (--no-content on the CLI) to keep none of them, nor the judge's notes.

In CI

agent-adapter eval app.chat:agent eval_cases.jsonl --grader contains --grader max-cost=0.02 \
  --min-score 0.9 --junit evals.xml --markdown evals.md --baseline last-eval.json
Option Does
--grader SPEC exact, contains, regex[=PATTERN], json-schema=PATH, tools=a,b, max-cost=USD, max-latency=S, or module:attr for your own (an LLMJudge)
--min-score 0.9, --min-pass-rate 0.9 exit 1 when the mean score or the share of passing cases is lower (both 1.0)
--junit, --markdown, --json write the reports; --json prints JSON to stdout
--baseline PATH exit 1 on a regression or a missing case against an earlier --json report
--no-content keep no case text or judge notes in the reports
--executor module:attr run on your executor (approvals, budgets, prices)
--concurrency N, --timeout S cases at once; seconds per case
--no-record skip recording; by default runs go to .agent_adapter/runs

The exit code is 1 when no case passed, a bar is missed, any case errors, or the baseline shows a regression; 2 for a bad option or file. The JUnit file follows the Ant/Surefire schema: the run id and score are in each test case's system-out.

In pytest

The plugin is opt-in: run pytest -p agent_adapter.evals.pytest_plugin, or put pytest_plugins = ["agent_adapter.evals.pytest_plugin"] in your root conftest.py. Mark a test with the case file (relative to the test file); it runs once per case:

import pytest

from agent_adapter.evals import assert_eval
from agent_adapter.evals.graders import contains


@pytest.mark.eval_cases("eval_cases.jsonl", tags=["orders"])
def test_support_agent(eval_case):
    assert_eval(answer, eval_case, [contains()])

assert_eval raises AssertionError with each failing grader's reason and the run id.

See the case runs

Each case is a run with its own run_id. Record the executor (viewer.record(executor); the CLI does it), then agent-adapter ui shows each case run; it ends with a custom.eval.case event (eval name, case id, verdict, score). Save reports with --save (or save_to=) to browse and compare them: See your evals. Turn recorded runs into cases: Evals from production.

Safe by default

The default executor has an empty registry and a fresh ledger. With no approval port, confirm tools are denied; a pending approval, a timeout, an unfinished loop or flow, or an exception makes the case an error, and errors fail CI.

Files

File Does
evals/cases.py, model.py, targets.py load_cases; EvalCase, Score, EvalRun; the four targets
evals/graders.py, judge.py graders; LLMJudge
evals/runner.py, report.py run_eval, assert_eval; EvalReport, compare, JUnit, Markdown, JSON
evals/pytest_plugin.py the eval_cases marker and the eval_case fixture
cli/eval.py agent-adapter eval