Evals are repeatable test suites for agents: a set of cases (a prompt plus a way to check the result), run against one or more variants (agent, model, permission mode) for however many repeats you want, so you can answer "does model X actually do better than model Y on tasks like this" with a matrix instead of a vibe.
A run is cases × variants × repeats. Every cell is dispatched as an ordinary
task attempt in its own worktree — the same Best-of-N machinery that powers
POST /api/tasks/{id}/dispatch's variants array — so an eval cell gets a
real agent run, a real diff, and a real check, not a simulation. Eval-generated
tasks carry the label eval and created_by: "eval" so they don't clutter the
normal board; open them from the Agent tests page (More → Agent tests) instead.
A case can also be built automatically from a project's own merged-PR history instead of hand-written — see Replay evals for "New replay suite from merged PRs", which grades against the PR's own accepted diff as ground truth and adds similarity/judge scoring on top of pass/fail.
Agent tests → pick a project → + Create suite → add cases. Each case has:
| Field | Meaning |
|---|---|
name |
Shown in the matrix header |
prompt |
What the agent is asked to do |
base_ref |
Branch/ref to start the worktree from (empty = project default) |
check_command |
Shell command run in the worktree after the agent finishes; exit 0 = pass. Empty falls back to the project's own verify command (Project.VerifyCmd); if neither is set, the case passes whenever the agent's own run succeeds |
setup_command |
Optional; run for real on the target, in the attempt's own worktree, after the worktree is created and before the agent starts (there is no prompt involved). Bounded by min(timeout_s, 600) seconds. A non-zero exit (or the target being unreachable) fails the cell as error, with the setup command's output tail as check_output_tail, and the agent is never launched |
timeout_s |
How long, in seconds, the case's attempt may run once it actually starts (waiting for a target concurrency slot doesn't count). An attempt that overruns it is cancelled through the scheduler's normal cancel path and the cell is recorded as timeout — distinct from failed (ran to completion, wrong result) and error (the agent or its launch broke) |
Put one or more YAML files under .lectern/evals/ in the project's repo (read
on the target, not the control plane — a remote project's suite files live
on its own machine) and click Import from repo. Re-importing replaces any
existing suite of the same name, so editing the file and re-importing is safe.
name: Health endpoint suite
description: Checks the agent can add a working health endpoint
cases:
- name: add health endpoint
# quote a prompt that contains a colon or braces — plain YAML parses
# `{"ok": true}` as a flow mapping otherwise
prompt: 'Add a GET /health endpoint that returns {"ok": true}'
base_ref: main
check_command: pytest tests/test_health.py
timeout_s: 600
- name: handle missing route gracefully
prompt: Make unknown routes return a 404 with a JSON body
check_command: pytest tests/test_404.pyFields match the table above 1:1; name and prompt are required on every
case, base_ref/check_command/setup_command default to empty and
timeout_s defaults to 900.
Open a suite → New run → add variants (agent + model + permission mode,
1–8 of them — the same shape a Best-of-N dispatch's variants array takes) →
set repeats → Run suite. A concurrency setting (eval_concurrency in
Settings, default 2) caps how many cells are in flight for a run at once, so a
20-cell run doesn't try to launch 20 agents simultaneously; the scheduler's own
per-target max_concurrent still applies on top of that.
A run can be cancelled mid-flight: every cell still queued or running is
stopped and marked error, and the run reads as finished rather than stuck.
- Matrix: cases (rows) × variants (columns), each cell showing
passed/totalacross repeats. Click a cell to see that attempt's check output and diff. - Leaderboard: per-variant pass rate, mean duration, total cost, mean input/output tokens.
- Compare two runs: pick two runs of the same suite and see which cases regressed or improved (by pass-rate delta).
GET/POST /api/evals/suites (?project_id= to filter)
POST /api/evals/suites/import {project_id}
GET/DELETE /api/evals/suites/{id} (GET returns {suite, cases})
POST /api/evals/suites/{id}/cases
DELETE /api/evals/cases/{id}
GET/POST /api/evals/suites/{id}/runs (POST body: {variants, repeats, notes})
GET /api/evals/runs/{id} ({run, suite, cases, variants, results, leaderboard})
POST /api/evals/runs/{id}/cancel
GET /api/evals/runs/{id}/compare/{other}
- Timeouts. The engine tick that already polls running cells
(
internal/api/evals_engine.go'stickEvalRun) checks each running cell's attempt againststore.Now() - attempt.StartedAt; once that exceeds the case'stimeout_sit cancels the attempt throughScheduler.CancelAttempt— the same path the board's Cancel button and a cancelled run use — and records the cell aseval_results.status = "timeout". There is no per-cell goroutine or extra timer; it rides the tick that was already running. - Setup. A case's
setup_commandis copied onto the dispatched task asstore.Task.SetupCommand/SetupTimeoutS(set bydispatchEvalCell, analogous to howCheckCommandis already copied onto the task for auto-verify). The scheduler runs it for real (Scheduler.runSetupCommand, called from bothlaunchandlaunchSandboxInner) in the attempt's own worktree, on its target, right after the worktree exists and beforestageRuntimewrites anything the agent reads — so a fixture it creates is visible on the agent's first turn. A non-zero exit (or an unreachable target) returns an error that aborts the launch before the agent ever starts; the attempt is recordedfailedwith that error, whichgradeEvalResultturns intoeval_results.status = "error"carrying the setup command's output tail. - Checks. A cell's check still runs via the same task-level auto-verify
path Best-of-N uses (
internal/scheduler'scaptureAndFinalize), with the case'scheck_commandoverriding the project's for that one task (store.Task.CheckCommand) — it is not a separate sandboxed check step.