Run an eval (/eval-run)¶
/eval-run is the execution engine of the harness. It takes an eval.yaml (produced
by /eval-analyze) plus a dataset, runs the skill or prompt
headlessly against every case, scores the outputs with your judges,
and writes a scored HTML report under $AGENT_EVAL_RUNS_DIR/<eval-name>/<run-id>/.
What it produces
A per-run directory containing run_result.json, collection.json, summary.yaml,
an analysis.md, and a report.html — plus per-case artifacts, logs, and traces.
The pipeline¶
The local runner walks a fixed sequence of steps. Each step is a script the skill orchestrates — it never duplicates their work.
flowchart TD
A[Step 0: Discover config + parse flags] --> B[Step 1: Find dataset]
B --> C[Step 2: Preflight check]
C --> D[Step 3: Prepare workspace]
D --> E{inputs.tools?}
E -- yes --> F[Step 3a: Resolve tool interception]
E -- no --> G[Step 4: Execute headlessly]
F --> G
G --> H[Step 5: Collect artifacts]
H --> I[Step 6: Score with judges]
I --> J[Step 7: Interpret + build report]
J --> K[Step 8: Log to MLflow - optional]
| Step | Script | What happens |
|---|---|---|
| 0 Discover | discover.py |
Auto-find eval.yaml (or use --config); parse flags; read the skill/prompt, runner, judges, models |
| 1 Dataset | — | Read dataset.path; verify at least one case directory exists |
| 2 Preflight | preflight.py |
Check for stale artifacts and run-id / baseline collisions |
| 3 Workspace | workspace.py |
Build an isolated per-case (or batch) workspace with symlinked project resources |
| 3a Interception | workspace.py |
If inputs.tools is set, resolve tool_handlers.yaml into concrete runtime checks |
| 4 Execute | execute.py |
Invoke the agent headlessly, capture stdout/stderr, write run_result.json |
| 5 Collect | collect.py |
Distribute workspace outputs into per-case dirs; detect in-place edits via git diff |
| 6 Score | score.py |
Run judges (and pairwise, if --baseline); write summary.yaml |
| 7 Report | report.py |
Write analysis.md and report.html |
| 8 MLflow | /eval-mlflow |
Log params, metrics, and artifacts (only if mlflow.experiment is set) |
Usually you just run one command
In practice you invoke the skill and it drives all of the above:
The step-level scripts are worth knowing when a run fails partway and you need to resume from a specific stage.
Case vs batch¶
execute.py auto-detects the execution mode from execution.mode in eval.yaml — there
is no CLI flag for it.
One invocation per test case. The harness loops over cases; each gets its own
workspace, stdout.log, and subagent transcripts.
execution:
mode: case
skill: my-skill
arguments: "{prompt}" # resolved per case from input.yaml
parallelism: 4 # optional: run cases concurrently
- Cases run concurrently when
execution.parallelism > 1(or--parallelism), via a thread pool. Each case gets a log prefix likeeval:case-003. run_result.jsonrecordsexecution_mode: "case", aper_casebreakdown,duration_s(sum of per-case durations), andwall_clock_s(actual elapsed time). Eachper_caseentry also carriespermission_denials— tool calls denied by permissions during that case.
One invocation for all cases via batch.yaml. The skill/agent loops internally.
workspace.pybuildsbatch.yaml— one entry per case, each the full parsed input file — andcase_order.yamlmapping position → case ID.- Collection maps output files back to cases by detected prefix or by position; set
outputs[].batch_pattern(with{n}, 1-based) when files are named by index.
See the execution model for the full case-vs-batch and skill-vs-prompt matrix.
Flags¶
Parsed from $ARGUMENTS at Step 0. CLI flags override the corresponding eval.yaml keys.
| Flag | Default | Effect |
|---|---|---|
--config <path> |
auto-discover | Path to the eval config |
--model <model> |
models.skill |
Model for the skill/agent under test. Required if models.skill is unset. |
--subagent-model <model> |
models.subagent → falls back to --model |
Model for subagents (e.g. sonnet subagents under an opus main) |
--skill <name> |
from config | Override the skill being tested |
--run-id <id> |
YYYY-MM-DD-<model> |
Identifier for this run (names the output directory) |
--cases <id> [<id> …] |
all cases | Run only the listed case IDs |
--baseline <run-id> |
— | Add a pairwise A/B comparison against a prior run under the same eval-name |
--no-llm-judges |
false | Skip every judge that calls a model — llm (prompt, prompt_file, llm_rubric), agent, and LLM builtins; run only deterministic judges |
--gold |
false | After scoring, save collected artifacts back to the dataset cases as gold references |
--effort <level> |
runner.effort |
Reasoning effort. Applied by claude-code (low…max) and codex (minimal…xhigh); ignored by runners without an effort control |
--runner <type> |
local |
local (default pipeline), harbor (containerized), or evalhub (platform) |
--env <name> |
kubernetes |
Harbor execution environment: podman, kubernetes, openshift (only with --runner harbor) |
--no-llm-judges is your fast, cheap dry run
It exercises the full execute → collect → score pipeline while skipping every judge
that calls a model — llm judges, agent judges, and LLM builtins. Deterministic
check scripts, Python builtins, and external module/function judges still run.
Preflight: run-id and baseline collisions¶
Skills write to the project directory (not just the workspace), so stale artifacts from a previous run can contaminate results — wrong IDs, stale reports, inflated file counts. Step 2 guards against this and against overwriting prior runs.
preflight.py inspects tmp/ state files, checks whether
$AGENT_EVAL_RUNS_DIR/<eval-name>/<run-id> already has results, and (with --baseline)
verifies the baseline run-id exists.
| Result | Meaning | What to do |
|---|---|---|
CLEAN |
No stale artifacts, no collision | Proceed to workspace setup |
DIRTY |
Stale artifacts, or the run-id already has results | Force-clean, pick a new run-id, or abort (see below) |
MISSING_BASELINE (exit 2) |
The --baseline run-id wasn't found |
The script lists nearby run-ids; confirm the correct one (typo? different date/variant?) |
When a run is DIRTY, the three resolutions are:
Keep the previous results by choosing a fresh id (e.g. a version suffix), then
re-check. This still requires cleaning project artifacts, so re-run preflight with
--clean and the new id.
Stop and clean up manually.
A colliding run-id overwrites the previous run
The default run-id is YYYY-MM-DD-<model>, so two runs of the same model on the same
day collide. Preflight will flag it — pick a distinct --run-id (a -v2 suffix is the
convention) whenever you want to keep both.
Tool interception¶
If eval.yaml has inputs.tools entries, Step 3a is mandatory. workspace.py emits a
skeleton tool_handlers.yaml; you must resolve each handler's natural-language prompt
into concrete runtime checks (input_filters for Bash, env_checks for services,
case_overrides for deterministic answers) before execution. The workspace is rebuilt
fresh every run, so this happens even when eval.yaml is unchanged.
Headless runs can hang without interception
A skill that calls AskUserQuestion or hits an external API in headless mode will
block forever unless the tool is intercepted. If a tool the skill uses isn't covered,
the run stalls. See tool interception.
Scoring: what judges see¶
score.py loads every case's collected outputs into a record dict and hands it to each
judge. The same record drives inline check scripts (as outputs), and its fields are
exposed to LLM judges as Jinja2 template variables.
| Record key / variable | Contents |
|---|---|
outputs["files"], outputs["<dir>_content"] |
Collected artifact file contents (incl. _modified/ in-place edits) |
outputs["conversation"] / {{ conversation }} |
Root-level assistant text from the event stream |
outputs["events"] |
Flat list of typed events (if traces.events) |
outputs["tool_calls"] |
Tool calls matching outputs[].tool patterns |
outputs["exit_code"], ["duration_s"], ["cost_usd"], ["num_turns"], ["token_usage"] |
Execution metadata (if traces.metrics) |
outputs["stdout"], outputs["stderr"] |
Raw logs (if traces.stdout/stderr) |
outputs["annotations"] / {{ annotations }} |
Parsed annotations.yaml from the dataset case — for outcome-aware scoring |
{{ inputs }}, {{ evidence }}, {{ tool_trace }}, {{ arguments }} |
Case inputs, a verifiable-action summary, a chronological tool trace, and judge arguments: |
Aggregation is by value type: boolean judges become a pass_rate (fraction True),
numeric judges become a mean. Results land in summary.yaml under aggregated and
per_case. LLM judges can be sampled N times (samples:) — bool judges reduce by
majority vote, numeric by median. See pairwise & sampling.
Baseline → pairwise comparison
--baseline <run-id> triggers a position-swapped pairwise judge on top of the
regular judges: each case is judged both A/B and B/A, and only a consistent preference
counts as a win. summary.yaml gains a pairwise section with wins_a, wins_b, and
ties. Gate on it with a min_win_rate threshold.
Reading the results¶
The run directory ($AGENT_EVAL_RUNS_DIR/<eval-name>/<run-id>/) contains:
run_result.json # exit_code, durations, token usage, cost, permission denials, per_case breakdown
collection.json # per-case artifact counts
summary.yaml # judges (mean/pass_rate), per_case, pairwise
analysis.md # LLM interpretation, leading with a Recommendation
report.html # scoring summary, per-case detail, diffs, cost/tokens
cases/<case-id>/ # per-case artifacts, logs, and traces
Open report.html first; it renders the whole run. To understand each panel, see
reading the report.
Other backends¶
The same eval.yaml runs unchanged across substrates — the backend is only ever a flag.
-
Harbor (
--runner harbor)
Containerized execution. Steps 2–6 collapse into one
run.pycall over self-contained task packages. Environments: Podman, Kubernetes, OpenShift. -
EvalHub (
--runner evalhub)
Platform-triggered, in-process execution inside an EvalHub Job pod.
Where to go next¶
-
Review the results
Interactive human feedback that can propose config and prompt changes.
-
Optimize automatically
Iterate on the skill using failures as the signal.
-
Log to MLflow
Sync the dataset and push run metrics, params, and artifacts.