eval-core
pytest/jest, but for LLM agents. Write test cases whose inputs are prompts and whose assertions are built-in checks on what the agent did — which tools it called, with which parameters, and what it finally said or computed.
eval-core is an agent testing framework: prompts in, assertions on behavior out; bring your own
harness. You implement ONE method that runs a prompt against your agent and returns what it did;
you write a tiny suite of assertions (in RON or inline); the framework runs every case, times it,
isolates panics, and hands you a report (plus a self-contained HTML dashboard). It is
game-agnostic and has zero dependency on any LLM/engine crate, so a case file is just a prompt and
a list of expectations.
Quickstart (the 5-minute path)
1. Implement Agent over your harness
One method: run a prompt, return what the agent did as [RunArtifacts]. Build the artifacts with
the chainable with_* builders and ToolCall::new.
use ;
use json;
; // wraps your real LLM loop / tools
Every RunArtifacts field is optional — a minimal agent can return
RunArtifacts::new().with_final_text(...) and nothing else.
2. Write a RON suite of built-in Expectations
A case is a name, an instruction (the prompt), and a list of expect predicates. A file may
hold a single case or a list of them:
[
(
name: "adds-two-numbers",
instruction: "what is 2 + 2?",
expect: [
CalledToolWith(tool: "calculator", args: { "op": "add" }),
FinalNumberEquals(value: 4.0),
],
),
(
name: "no-tools-for-chitchat",
instruction: "hello there",
expect: [
NoToolCalls,
FinalTextContains(text: "hello", case_insensitive: true),
],
),
]
There is no setup field on the easy path — it defaults to ().
3. Run the suite and read the report
use ;
use generate_report;
use Path;
#
That's the whole loop: Agent::run → author Expectations → run_suite. No World, no
Setup, no Scorer to implement.
Prefer inline cases for a first run? Build a
Vec<EvalCase<(), Expectation>>directly — seeexamples/calculator.rsfor a complete, dependency-free agent (no real LLM) tested entirely with the built-in assertions.
Run the shipped baseline
eval-core ships a ready-to-run baseline capability suite (18 cases). Hand it straight to
run_suite:
#
The baseline covers three capability areas:
| File | Cases | What it checks |
|---|---|---|
arithmetic.ron |
6 | Mental/calculator math (number assertions, plus an opt-in tool subset). |
language.ron |
5 | Instruction-following over the agent's final text (fully portable). |
tool_use.ron |
7 | Tool-calling with the right name/args/count/order. |
Portable vs. adapt-me, honestly: the number/text assertions only inspect the agent's final
reply, so they hold whether the agent uses tools or reasons inline. The tool_use.ron cases (and
a small, clearly-labelled subset of arithmetic.ron) additionally assert specific tool names and
args — they assume a documented convention (a search, calculator, and send_email tool) that
you must adapt to your own agent. Do not read a tool-name mismatch there as a capability failure.
To start from the baseline as a template, dump the raw embedded files into your own suite
directory and edit them — same files that baseline() runs:
#
Assertion catalog
Every built-in Expectation variant, its exact RON form, and what it asserts over the run's
RunArtifacts. A case passes iff the run did not error AND every expectation holds.
| Variant | RON syntax | Asserts |
|---|---|---|
CalledTool |
CalledTool(tool: "search") |
The agent called tool at least once (any args). |
DidNotCallTool |
DidNotCallTool(tool: "search") |
The agent never called tool. |
CalledToolWith |
CalledToolWith(tool: "calc", args: { "op": "add" }) |
The agent called tool at least once with args that superset the given args (subset match — see below). |
ToolCallCount |
ToolCallCount(tool: Some("search"), min: Some(1), max: Some(1)) |
The number of calls is within [min, max] (each optional). tool: Some(name) counts only that tool; omit tool (defaults None) to count all calls. Either bound may be omitted. |
CalledToolsInOrder |
CalledToolsInOrder(tools: ["search", "send_email"]) |
The named tools appear as a subsequence of the call order — in this relative order, but not necessarily contiguous (other calls may interleave). Empty tools trivially holds. |
NoToolCalls |
NoToolCalls |
The agent made no tool calls at all (pure-reasoning / refusal check). |
FinalTextContains |
FinalTextContains(text: "paris", case_insensitive: true) |
final_text contains text. case_insensitive defaults to false (exact-case substring). Fails when there is no final text. |
FinalTextEquals |
FinalTextEquals(text: "OK") |
final_text equals text exactly, after trimming surrounding whitespace on both sides. Fails when there is no final text. |
FinalTextMatches |
FinalTextMatches(regex: "(?i)^\\s*(yes|no)\\b") |
final_text matches regex anywhere. Fails when there is no final text. A malformed regex is an authoring error (a hard EvalError::Regex), surfaced as a clearly-labelled failed predicate rather than a silent miss. |
FinalNumberEquals |
FinalNumberEquals(value: 3.33, tolerance: 0.01) |
The last number in final_text equals value within tolerance (see below). tolerance defaults to 0.0 (exact). Fails when there is no final text or it contains no number. |
NoError |
NoError |
The run reported no error. (The runner already fails a case on any run error; this is an explicit, labelled "the run was clean" predicate.) |
Two matching rules to know
CalledToolWithargs are a SUBSET match. The expectedargsJSON must be a subset of the actual call's args: objects recurse key-by-key, and every other JSON value (string/number/bool/null/array) must match exactly. So{ "op": "add" }matches a call made with{ "op": "add", "a": 2, "b": 2 }, but{ "op": "sub" }does not, and an extra key the call didn't have ({ "op": "add", "c": 9 }) does not. Arrays are compared whole, not element-subset —{ "at": [1, 2] }does not match a call with"at": [1, 2, 3]. This keeps positional args like a[x, y, z]coordinate predictable.FinalNumberEqualsextracts the LAST number. The last numeric token infinal_textis taken as the agent's answer (models typically end with the answer), then compared tovaluewithintolerance. "Number" = an optionally-signed integer or decimal, with ASCII thousands-separator commas tolerated inside the integer part (-1,024.50→-1024.5). A lone-/.is not a number.
serde defaults
FinalTextContains.case_insensitive→falseFinalNumberEquals.tolerance→0.0(exact)ToolCallCount.tool/.min/.max→None(no restriction / unbounded)
Authoring cases
- One file, one or many cases. A
.ronfile may hold either a singleEvalCase(...)or a list[EvalCase(...), EvalCase(...)]of related cases. Group a whole capability in one file without one-file-per-case sprawl. setupdefaults to()on the easy path, so a case omits it entirely (see the examples above). On the advanced path it defaults to yourSetup::default().load_cases(dir)reads every*.ronindirin sorted (filename) order — deterministic across runs and machines — and flattens single- and multi-case files together, so a directory may freely mix the two shapes. Non-.ronentries and subdirectories are ignored.- Fail-loud. A malformed
.ronis a hardEvalErrornaming the offending file (so a typo fails the load, and any CI load test, rather than silently dropping a case).
Advanced: domain-state assertions
The easy path scores what the agent did (tool calls, params, final text/number). When you need to assert on state the agent actually changed — not just the calls it emitted — there is an escape hatch:
- Implement [
Harness] over your ownWorld+Setup:setup(&setup) -> Worldbuilds a fresh world per case,run(&instruction, &mut world) -> Result<RunArtifacts>runs the prompt against it (mutating the world). - Implement a custom [
Scorer] over the sameWorld:score(expect, &artifacts, &world)returns(label, passed)for each of a case's predicates, inspecting the post-run world. - Run with [
run_eval] (orrun_eval_with_meta) instead ofrun_suite.
use ;
;
;
This is the advanced ~10%. The reference consumer (the AetherCore voxel engine) uses it for
"did the world actually change" checks — e.g. asserting N solid voxels were placed after a build
command, by scoring the post-run voxel world rather than the agent's tool calls. See
examples/minimal.rs for a complete, dependency-free Harness + Scorer.
Output
run_suite / run_eval return an EvalReport:
- Accuracy —
passed()/total()cases, plusaccuracy()in0.0..=1.0. - Latency —
mean_latency(),p50_latency(),p95_latency()(nearest-rank quantiles). - Tokens —
total_tokens()/mean_tokens()over cases that reported a count (Nonewhen none did, so the summary never prints a misleading0). - Per-case detail — each
CaseOutcomecarriespassed, the per-predicate(label, passed)list (pinpointing which predicate failed),latency,tokens, the tool-call display strings,final_text, any runerror, and the transcript.
println!("{report}") prints an aligned summary table with the failed predicates spelled out per
case. For comparing many models/runs at a glance, persist RunRecords as JSON to a directory and
call report_html::generate_report(dir) — it writes a single, fully self-contained report.html
(no CDN, no external scripts; opens offline by double-click) with a sortable leaderboard, a
model × case heatmap, and per-case transcript expanders.
Automatic persistence
Pass a persist target on the run metadata and every run saves itself — the run JSON is written and
report.html is regenerated as part of the run, no extra wiring:
use ;
let meta = new
.persist_to // dir + grouping key
.backend_kind // shown in the report's Backend column
.cases_dir;
let report = run_suite_with_meta;
// → eval/results/my-model_<timestamp>.json written, eval/results/report.html regenerated
Without persist_to, runs are compute-only (no disk I/O) — the bare run_suite / run_eval paths
stay pure, so examples and unit tests don't write files.
Install
This is a pre-release 0.2 — the API may still shift between minor versions.
License
MIT OR Apache-2.0.
Status / roadmap
Young but usable; the next planned step is a hosted results dashboard on top of the existing self-contained HTML report.