Evals from production¶
Turn recorded runs into eval cases, so a failure seen in production becomes a regression case that CI runs on every change.
flowchart LR
R["Record runs<br/>capture_content=True"] --> S["Save as eval case<br/>viewer or CLI"]
S --> F["eval_cases.jsonl<br/>fill in expected"]
F --> C["CI: agent-adapter eval"]
C -->|fails| X["Fix the agent"]
X --> C
C -->|passes| R
1. Record with content¶
A case needs the run's input, so the run must be recorded with its content:
viewer.record(executor, capture_content=True). Only a sink that sets
wants_content = True (the recorder does under capture_content) gets tool arguments and
results, model messages, and a flow's input and final state. Every other sink (logging,
OpenTelemetry, AG-UI, your own) gets none, and content is not even built when no sink asks.
- Content is taken after the guards: a payload a guard blocks is never stored, and a
redacted result or reply is stored redacted. Tool arguments ride on
tool.finished, model messages onllm.finished. - Secret-named keys are masked, and so are secrets and personal data inside text (the
PIIGuardpatterns: keys, tokens, emails, phones, cards, IBANs). - A
confirmtool's arguments are never stored; its result only withrecord(..., capture_content=True, capture_confirm_content=True). Its reply to the model is blanked in later messages, so it is never stored twice. - Model messages name tool calls without their arguments.
Runs imported from another app's OpenTelemetry export (agent-adapter ui --import
traces.json) keep content when that app captured it. A run without content gives no case:
it is listed as skipped, with the reason. No input is ever made up.
What a case takes from a run:
| Field | From |
|---|---|
input |
the first flow's input, else the last user message of the first top-level model call, else the first tool call's arguments |
expected |
an ok run's flow output, the last answer of its top-level calls at that stage, or that tool's result; empty for a failed run |
tags |
the run's tags, status:<status>, flow:<name>, tool:<name>, masked when a value was masked |
source_run |
the run id, so the Evals view can link back |
A run still running is skipped. So is one whose input the recorder cut (over 2,000 characters): a cut input is not the real input.
2. Save cases¶
In the viewer. Open a run in the Runs view and press Save as eval case. Pick the id,
whether the recorded output is the expected value, an expected value of your own, and tags.
A failed run also shows Turn into regression case: one click saves it with the
regression tag and no expected value. Both append to the same eval_cases.jsonl the chat
page writes (in --chat-dir, .agent_adapter/chats by default, also with chat off). Ids
stay unique; a request may be up to 256 KB; the file stops taking cases at 16 MB.
From the command line.
--status (ok, failed, paused, unfinished), --tag and --run KEY filter the runs;
--expected-from none leaves every expected empty. The file is appended to, and ids it
already holds are kept as they are, so running it again is safe. It prints each skipped run
and its reason, and exits 1 when no case was made.
From Python.
from agent_adapter.evals import cases_from_runs, write_cases
found = cases_from_runs(".agent_adapter/runs", status="failed", tags=["vip"])
write_cases("eval_cases.jsonl", found.cases)
for skipped in found.skipped:
print(skipped.run_key, skipped.reason)
3. Fill in what is expected¶
A failed run has no good answer to copy. Open eval_cases.jsonl and write the expected
value a correct agent gives. Cases with an empty expected are skipped by graders that
compare with it (exact, contains, regex); other graders still run.
4. Run them in CI¶
The whole loop, offline (imported runs, then the old and the fixed agent):
def main(folder: Path) -> list[str]: """Turn the recorded runs into cases, evaluate the old and the fixed agent on them.""" source = folder / "traces.json" source.write_text(json.dumps(TRACES), encoding="utf-8") runs = folder / "runs" import_otlp(source, runs) # or runs your app recorded with capture_content=True
found = cases_from_runs(runs) # the failed run's case has no expected value yet
cases_file = folder / "eval_cases.jsonl"
write_cases(cases_file, found.cases)
# A person fills in the expected answer of the regression case (here: in code).
cases = [
case if case.expected is not None else case.model_copy(update={"expected": "cancelled"})
for case in load_cases(cases_file)
]
lines = [f"{case.id}: {case.input!r} -> {case.expected!r} {list(case.tags)}" for case in cases]
for agent in (old_agent, fixed_agent):
report = run_eval_sync(agent, cases, [contains()])
lines.append(f"{agent.__name__}: {report.n_passed}/{report.n_cases} passed")
return lines
Safe by default¶
- Only content the recorder kept is used; secret-named values stay masked.
- The viewer route needs the access token, the CSRF header, this viewer's Origin and a JSON body, like every other POST. The page shows saved text as text, never as HTML.
- The cases file is only appended to.
Files¶
| File | Does |
|---|---|
tracing/_content.py, hooks/builtin/_content.py |
content only for sinks that ask; masking |
exec/_call_content.py |
a tool call's content after the guards; confirm tools withheld |
evals/from_runs.py |
cases_from_runs, case_from_run, write_cases, RunCase, SkippedRun |
evals/_run_files.py |
reads run files and tag sidecars (read-only) |
evals/_run_content.py |
finds the input and output in a run's events |
viewer/_case_routes.py |
POST /api/runs/KEY/eval-case |
viewer/static/runcases.js |
the two buttons and the dialog in the Runs view |
cli/eval.py |
agent-adapter eval cases-from-runs |