Evals
An eval is a test case for an agent. It lives in evals/, sends the agent one
or more messages, and checks what came back.
Assertions are graded in two tiers. A bullet that names a known key is checked in code — free, exact, identical on every run. Everything else is prose and goes to an LLM judge. Prefer the first tier: a judge costs a model call, varies between runs, and can only check what you thought to ask it about.
Layout
One directory per eval, mirroring agents/<name>/ and tools/<name>/:
evals/
├── refund-over-cap/
│ ├── eval.json # manifest — metadata, never assertions
│ └── eval.md # the case
├── ingest-opord/
│ ├── eval.json
│ ├── eval.ts # code-authored instead of Markdown
│ └── opord.pdf # a fixture the case sends
├── triage.md # flat shorthand — fine for a one-off
└── README.md # skipped, not runThe eval's id is its path relative to evals/, minus the extension —
refund-over-cap, ingest-opord. Ids key baselines and trends, so they are
derived rather than authored and a layout change must not move them.
Use the flat triage.md shorthand for a single-file case. Use a directory when
the eval owns anything else: a fixture, a longer manifest, or a .ts body.
Nest as deeply as you like. Discovery is recursive and the id is just the
path, so evals/journeys/onboarding/first-run/eval.md is the id
journeys/onboarding/first-run. How you group — by feature, by risk, by test
level, not at all — is yours to decide; nothing in the runtime depends on it.
Select by path or by tag when you want a slice.
eval.json — metadata only
Structured config lives here so a report, a CI gate, or the optimizer can read it without parsing prose or executing code.
{
"tags": ["refusal", "write-path"],
"objectives": { "turns": { "max": 4 }, "hitlRequests": { "min": 1 } },
"attachments": [{ "path": "opord.pdf" }],
"runs": 5,
"passRate": 0.8,
"judgeModel": "claude-sonnet-5"
}| Field | Meaning |
|---|---|
tags | Grouping for filtering and holdout stratification |
objectives | Soft bounds on the run — see Objectives |
attachments | Files the case sends — see Attachments |
runs | Repetitions — see Repeating an eval |
passRate | Fraction of runs that must pass. Defaults to 1 |
judgeModel | Pinned judge, for a stable grader across runs |
A flat file can carry the same fields in YAML front matter. Declaring one field
in both front matter and eval.json is an error rather than a silent
precedence rule — config that needs two files to agree will eventually
disagree.
eval.md — the case
---
tags: [happy-path]
---
# Eval: Looks the order up before answering
The floor on `toolCalls` is the point: answering from memory would satisfy a
ceiling perfectly while being exactly the failure this checks for.
## Setup
Context: The operator is viewing order ORD-1001.
## Query
"What's the status of ORD-1001?"
## Assertions
- tool_called_with: lookup_order {"order_id": "ORD-1001"}
- contains: delivered
- no_failed_actions
- Should state the status without hedging.
- Should NOT invent a delivery date the tool did not return.| Section | Meaning |
|---|---|
# Eval: … | Title. The text beneath it, before the first ##, is the description |
## Setup | Context: is folded into the message the agent receives |
## Query | The single user message. Mutually exclusive with ## Conversation |
## Conversation | A multi-turn script — see below |
## Assertions | One bullet per check |
There is no ## Objectives section: objectives are metadata and belong in the
manifest. Writing one is a load error that says so.
Repeating an eval
An agent is not deterministic. A case that fails one run in five is not the same as one that fails every run, and grading a single sample cannot tell them apart — the suite reports whichever way the coin landed.
{ "runs": 5, "passRate": 0.8 }Five runs, four of which must pass. passRate defaults to 1, so runs: 5
alone means all five must pass — the strictest setting, and the right one for a
safety invariant where an intermittent violation must not be averaged away.
The required count is rounded up: 0.8 over 5 runs needs 4, never 3.99
rounded down into a weaker gate than you wrote.
Repeats stop as soon as the verdict is settled. With 5 runs needing 4, a second failure decides it — no arrangement of the rest reaches 4 — so the remaining runs are not paid for. A case that fails immediately costs two model calls, not five.
Repeats run inside the eval's timeoutMs, not around it, so runs: 5
cannot outlive the budget the case declares: five runs share one ceiling rather
than getting five of them. If the deadline cuts the repeats short, the verdict
is judged against the declared total, so a partial sample cannot pass.
A failed eval reports its first failing run, not a passing one — surfacing the run that went wrong is the only reason to repeat. The tally rides along:
FAIL refund-over-cap 4/5 3/5 runs 8210msThe session an eval runs in
By default an eval runs the way the default chat does. These fields let it run the way a specific surface does — which is the only way to test surfaces that are reached by rooting a session in them rather than by asking nicely.
{
"agent": "request-intake",
"scopeId": "user-a",
"maxSessionTokens": 4000,
"scope": { "mission_id": "m-42" }
}| Field | What it does |
|---|---|
agent | Roots the session in an agent under agents/. An unknown name is an error, not a silent fallback |
scopeId | Per-user isolation — what lets an eval prove one user's data does not surface for another |
scope | The per-turn context object the client would send (mission_id, page state) |
maxSessionTokens | Session-wide token ceiling; the loop stops with budget_exceeded, which is the only way to test what the agent does when it runs out mid-task |
All of them work on EvalDefinition too, so one .ts file can target several
surfaces.
There is no per-eval answer model, on purpose. Which model runs is a
run-wide choice (amodal eval --model …), because a suite exists to be
compared — across prompts, across models — and a case that pinned its own model
would opt out of the comparison it belongs to. A case whose result depends on
the model is a finding about the model, and should surface when the suite runs
on it. judgeModel is the exception: pinning the grader stabilises grading
without changing what is under test.
There is no per-eval skill, on purpose. Pinning one does not activate a
skill — it removes every other skill from the prompt, a state no user reaches.
What is worth testing is whether the agent loads the right skill on its own, and
skill_loaded: asserts exactly that without pre-deciding the answer. An eval
that force-injected a skill could pass on content the agent never reaches in
production, which is worse than no coverage at all.
Assertions
Deterministic keys
A bullet shaped key: value where key is known is graded in code against
facts derived from the run's event stream.
| Key | Checks |
|---|---|
contains: <text> | The reply contains the text |
regex: /pattern/flags | The reply matches. Bare patterns work too |
starts_with: <text> | The reply begins with the text |
length_between: [min, max] | Reply length in characters |
tool_called: <name> | The tool ran |
tool_not_called: <name> | It did not |
tool_called_with: <name> {json} | Called with arguments matching a JSON subset |
tool_returned: <name> {json} | Its return value matches a JSON subset |
tool_result_contains: <name> <text> | Its return value contains the text — a substring, for when the exact value is not the point |
tool_succeeded: <name> | It ran and did not error |
tool_order: a, b, c | Those tools ran in that relative order |
subagent_called: <name> | A subagent was delegated to |
skill_loaded: <name> | A skill activated |
hitl_requested | The run asked a human — by any mechanism |
no_failed_actions | No tool, subagent, or warning failed |
max_turns: <n> | Model round-trips, not tool calls |
max_hitl: <n> / max_compactions: <n> | Caps |
max_latency: <ms> | Wall clock |
scorer: <name> | Defers to a named scorer — see Scorers |
not <any key> | Inverts it — see Negating a key |
Negating a key
Any deterministic key can be negated with a not prefix. It passes exactly when
the plain form fails.
- not tool_called_with: issue_refund {"amount": 940}
- not tool_returned: check_conflicts {"ok": false}
- not skill_loaded: escalationThis matters most for argument-level negatives — "the tool ran, but never with
those arguments" — which no other key expresses. tool_not_called bans the tool
outright, which is wrong when the tool legitimately runs for a different subject:
- tool_called_with: find_clear_frequency {"emitter_id": "EM-16"}
- not tool_called_with: find_clear_frequency {"emitter_id": "EM-11"}That pair says retune the one, never the other — a rule a whole-tool ban cannot state and a judged bullet grades unreliably.
Should NOT <key>: … works too and means the same thing. It used to not: the key
lookup saw Should NOT tool_called, missed, and quietly routed a deterministic
assertion to the LLM judge. That is the historical reason tool_not_called exists
as its own key. It stays — - tool_not_called: present reads better than the
negated form — but it is now an alias rather than the only way to say no.
no_failed_actions is worth putting on almost every eval. Without it, a run
where the agent's tools were broken and it improvised an answer gets graded on
the improvisation.
tool_succeeded says a tool did not throw; tool_returned says what it
produced. On a model-free tool: step the difference is the whole test — there
is no reply for contains: to read, so without tool_returned a composite that
returns the wrong number passes everything.
- tool_returned: combine {"sum": 14}Prefer it to tool_result_contains for structured returns: 14 is a substring
of 1400, and a subset match on the parsed object is not.
A handler that returns something other than an object is wrapped as
{result: <value>}, so a tool returning a plain string is still reachable by
subset — under result, not by matching the bare string:
- tool_returned: summarize {"result": "2 emitters remain in mission m-42."}Use tool_result_contains when you want a substring of the output rather than
a whole value — a long report where only one phrase matters, or output whose
exact wording is not the thing under test.
A bullet that looks like a key (snake_case:) but names an unknown one is a
load error, not prose. A typo would otherwise become a judged assertion
that usually passes, quietly degrading the suite from deterministic to judged.
Judged prose
Anything else goes to the LLM judge.
- Should …— required.- Should NOT …— required to be absent.- May …— permitted, not required. Graded and reported, but excluded from the pass/fail verdict, so an optional behaviour cannot fail a run.
Failures quote the observed state back, not just "failed":
✗ tool_called: issue_refund
tool "issue_refund" was never called; observed: [lookup_order, load_skill]Conversation
A run is not always one message. Answering a confirmation is part of the scenario, and an eval that cannot answer one can never exercise a write path.
## Conversation
- user: "Refund ORD-1002 in full, $940."
- deny: issue_refund
- user: "What would it take to approve it?"| Step | Meaning |
|---|---|
user: | Send a message |
approve: / deny: | Answer the confirmation raised by that tool |
choose: | Answer an ask_choice by option label |
tool: | Invoke a tool directly, with no model in the loop — see below |
Confirmations default to denied. A script that does not say otherwise should not be silently approving destructive writes.
## Setup context is folded into the first user turn only — restating it every
turn repeats a premise the agent already has.
Calling a tool directly
A composite tool is a pipeline of code: it fans out to subagents and nested tools along a fixed path. Reaching it through a prompt tests two things at once, and when it fails the eval cannot say which one broke — a bad result might be a broken pipeline or a model that never called it.
- tool: invokes one directly. No model is involved.
## Conversation
- tool: build_from_packet {"packet_id": "PK-1"}Arguments are a JSON object, and optional if the tool takes none. The tool's
events — tool_call_start, its nested subagent cards, tool_call_result — land
on the stream exactly as if the model had called it, so every assertion works
unchanged:
- subagent_called: package-reviewer
- tool_called: check_conflicts
- no_failed_actionsA script may be tool steps only; it does not need a user turn. And because the call lands in session history, a tool step composes as a setup step for a conversation that follows:
- tool: seed_mission {"id": "m-42"}
- user: "What is in mission m-42?"In a code eval the same lane is t.callTool, which returns a turn like send:
const built = await t.callTool('build_from_packet', {packet_id: 'PK-1'});
built.calledSubagent('package-reviewer');
built.calledTool('build_from_packet', {output: {status: 'staged'}});calledTool takes an output matcher alongside input — a JSON subset, or a
predicate over the raw output string for prose returns.
This lane is ungated
Permission checks, preToolUse / postToolUse hooks and
requiresConfirmation all gate what the model chose to do. A direct
invocation chose nothing, so none of them run — a tool that would normally stop
for approval executes here without stopping.
So a direct call proves the pipeline works. It cannot prove a gate holds, and an
eval that asserts one from a tool: step is asserting against a path the gate
was never on. Writing deny: after a tool: step is a load error for that
reason. Test a gate by driving the model — user: plus deny:.
Arguments are checked against the tool's declared parameters before it runs: a
missing required argument or a name the tool does not declare is an error, not a
call that passes undefined. Hand-written JSON has nothing upstream constraining
it the way the provider constrains the model, and a typo would otherwise send
the handler down its "nothing was asked for" branch — which the eval would then
grade as though it were the real one.
Attachments
An eval whose scenario is "the operator uploaded an OPORD" has to be able to send one. Declare files in the manifest, reference them by name:
{ "attachments": [{ "path": "opord.pdf" }, { "name": "annex", "path": "fixtures/annex-b.pdf" }] }- user: "Draft the mission record from this." [attach: opord.pdf, annex]Paths resolve against the eval's own directory, which is why an eval that sends files wants the directory layout. The name defaults to the basename.
Supported: PDF, PNG, JPEG, GIF, WebP. Anything else needs an explicit
mimeType — guessing changes how a provider reads the bytes.
It is a path, never inline base64: a 2 MB document pasted into a manifest stops the eval being reviewable in a diff. Three things fail at load rather than at grading, because each would otherwise produce a plausible-looking run against an empty-handed turn — a name the manifest does not declare, a file that cannot be read, and an unknown extension. Only a user turn may attach.
A useful habit: assert on a string that appears only inside the file. Then the eval proves the document arrived instead of assuming it.
Objectives
Soft bounds on the run, used for ranking rather than pass/fail.
{ "objectives": { "turns": { "max": 4 }, "hitlRequests": { "min": 1 } } }Metrics: turns, userTurns, toolCalls, redundantToolCalls,
subagentCalls, hitlRequests, failedActions, replyChars, latencyMs,
costMicros.
min matters as much as max. An optimizer told to reduce interruptions is
rewarded for an agent that stops asking before it refunds — a floor on
hitlRequests is what stops that. An unknown metric is a load error, so a
typo'd objective is never silently ignored.
Code-authored evals
Use eval.ts when the case needs control flow — a condition between turns, a
predicate over the reply, or an assertion scoped to one turn rather than the
whole run. One file may define several evals; a named export becomes part of
the id (ingest/withAnnex).
// Imports nothing — the loader introspects the export.
export const disclosesAsDocumentsArrive = {
description: 'Findings appear only once the document supporting them arrives.',
objectives: { turns: { max: 8 } },
async test(t) {
const first = await t.send('Here is the OPORD.', { attachments: ['opord.pdf'] });
// Everything after this measures the wrong thing if the file did not land.
t.require('the OPORD reached the model', () => /EASTERN ANVIL/.test(first.reply ?? ''));
first.satisfies('does not yet know the annex frequency', () => !/1572/.test(first.reply ?? ''));
const second = await t.send('And the annex.', { attachments: ['annex.pdf'] });
second.satisfies('reads the frequency from the annex', () => /1572/.test(second.reply ?? ''));
t.noFailedActions();
t.notCalledTool('present');
},
};t carries the same vocabulary as the Markdown keys — calledTool,
notCalledTool, calledSubagent, loadedSkill, toolOrder,
messageIncludes, maxTurns, maxToolCalls, noFailedActions, succeeded,
plus satisfies(label, predicate) for anything else. The same methods exist on
the object returned by send, where they are scoped to that turn.
t.send(msg, { approve, attachments })— drive one turn.t.require(label, predicate)— a precondition. Records like any assertion, then stops the script, because everything after a failed precondition fails for the same reason and the cascade hides which one mattered. It is a failure, not a skip.t.skip(reason)— abandon as not-applicable.t.log(message)— a note in the run's output.
Assertions are collected, never thrown. One failed check must not hide the four behind it.
Scorers
A scorer is a reusable, named check — deterministic, or a narrowly-scoped model
call. Put it in scorers/<name>/scorer.ts and reference it as
- scorer: <name>.
export default {
description: 'Every section number the reply cites exists in the loaded corpus.',
requires: ['finalMessage'],
preprocess: ({ facts }) => {
const cited = [...(facts.finalMessage ?? '').matchAll(/§\s*([\d.]+)/g)].map((m) => m[1]);
return { cited, missing: cited.filter((id) => !KNOWN.has(id)) };
},
score: ({ pre }) => (pre.cited.length === 0 ? 1 : 1 - pre.missing.length / pre.cited.length),
reason: ({ pre }) => (pre.missing.length ? `not in the corpus: ${pre.missing.join(', ')}` : 'all cited sections exist'),
};Scorers earn their keep where a property is important, checkable, and would
otherwise be graded by a model that shares the agent's blind spots — "did it
fabricate a citation" being the clearest example. requires declares the facts
it reads, so a scorer needing an unavailable field is rejected at registration
rather than silently scoring wrong.
Running
amodal eval # every eval in the agent
amodal eval refund # ids containing "refund"
amodal eval journeys/ # everything under a directory, any depth
amodal eval '/^(smoke|journeys)\//' # a regex — slashes make it one
amodal eval --tag safety # evals carrying a manifest tag
amodal eval --tag safety --tag p0 # several tags are OR'd
amodal eval journeys/ --tag safety # path AND tag
amodal eval --json # machine-readable
amodal eval --record-dir out/ # write training records for the optimizer
amodal eval --url https://… # target a deployed runtime instead of booting oneThe positional filter is an id, a directory prefix, a substring, or — wrapped
in slashes — a regex, the same convention regex: assertions use. A malformed
regex is an error rather than a silent zero-match, which would otherwise read
as "nothing to run".
Without --url it boots the same local runtime amodal dev uses, on an
ephemeral port. It exits non-zero if any eval fails or if zero evals ran —
a filter that matches nothing is a mistake, not a pass.
A suite that exceeds --timeout keeps the results it already produced and
reports how many of how many finished, rather than discarding the run.