Evals are regression tests for agent behaviour: which tools the agent called,
in what order, with which arguments, how many steps it took, and what it said.
They are written with defineEval(), live in *.eval.ts files, run under
vitest, and are best run in CI with lousho eval, which
prints a summary and writes JUnit and JSON reports.
vitest is required.
lousho evaldoes not run eval files itself: it runs your*.eval.tsfiles through vitest, resolved from your project's ownnode_modules(Lousho does not bundle it). Install it as a dev dependency first; without it,lousho evalexits 1 withvitest was not found from <dir>and the install command.
npm install --save-dev vitest
npx lousho evalGive defineEval() an agent (from createAgent()) and a test function. Send
messages with t.send(), then assert on the run. Using mockModel as the
agent's provider makes the eval deterministic: no network, no API key, no
flakiness. This is the default way to write an eval; use a real model only
for the judge evals below.
// refund.eval.ts
import { z } from 'zod';
import { createAgent, defineEval, defineTool, includes } from '@lousho/build-ai-agent';
import { mockModel } from '@lousho/build-ai-agent/testing';
const lookupOrder = defineTool({
name: 'lookup_order',
description: 'Look up an order by id',
input: z.object({ orderId: z.string() }),
execute: ({ orderId }) => ({ orderId, status: 'delivered' }),
});
defineEval({
name: 'refund flow',
tags: ['smoke'],
// A factory builds a fresh agent (and a fresh script) for every case.
agent: () =>
createAgent({
instructions: 'You handle refund requests.',
tools: [lookupOrder],
provider: mockModel([
{ toolCalls: [{ name: 'lookup_order', args: { orderId: '42' } }] },
{ text: 'Order 42 can be refunded within 30 days.' },
]),
}),
async test(t) {
await t.send('Refund order 42');
t.completed();
t.calledTool('lookup_order', { args: { orderId: '42' } });
t.notCalledTool('issue_refund');
t.check('mentions policy', t.reply, includes('30 days'));
t.maxSteps(4);
},
});defineEval() also still accepts the original form
({ name, agent, input, provider, score, threshold }): it keeps working
unchanged, and its result shows up in lousho eval as one score assertion.
Every assertion is recorded; the case fails at the end and lists every failed gate, so one run shows all that is wrong.
| Call | Kind | Passes when |
|---|---|---|
t.completed() |
gate | the latest run ended with a normal stop (not an error, abort, pending approval, or maxSteps cut-off) |
t.calledTool(name, { args?, times?, subagent? }) |
gate | the tool was called; args is a partial deep match (the call's arguments must contain these keys with equal values); times is an exact count of matching calls; subagent counts only that sub-agent's calls |
t.notCalledTool(name, { subagent? }) |
gate | the tool was never called, by the lead or any sub-agent |
t.toolOrder([a, b], { subagent? }) |
gate | the tools were called in this order (other calls may sit in between) |
t.maxSteps(n) |
gate | the agent took at most n model steps |
t.maxTokens(n) |
gate | the agent used at most n tokens |
t.maxCostUsd(n, { unknownCost? }) |
gate | the run's reported cost is at most n USD. When the SDK reports no cost (the model has no known price; see registerModel) the gate fails, since the budget cannot be checked; with unknownCost: 'skip' it is skipped instead (counts as passed, marked skipped, with a console.warn) |
t.check(name, value, check) |
gate | check passes for value |
t.soft(name, value, check) |
soft | never fails the run (unless --strict); the score is recorded and reported |
await t.judge(rubric) |
n/a | returns a 0 to 1 score from an LLM judge (see Judge evals) |
Checks, for t.check() and t.soft(): includes(text), matches(regex),
equals(value) (yes/no, score 1 or 0), and atLeast(n), atMost(n) (the
number is the score).
Failure messages say what was actually seen:
calledTool('lookup_order') failed: tools called were [search_docs, issue_refund]
calledTool('lookup_order', {"args":{"orderId":"41"}}) failed: 'lookup_order' was called 1 time(s) but no call matched the expected args; closest call differs in orderId: expected "41", got "42"
completed() failed: finishReason was 'max-steps' (the run hit maxSteps while still calling tools)
Other context members: t.reply (latest reply text), t.result (latest
ExecutionResult), t.toolCalls (every call so far, with parsed arguments).
You can send() more than once; steps, tokens and tool calls add up.
When the agent delegates with subagents, the calls a sub-agent makes are part
of t.toolCalls. Each follows the task call that started it and carries a
subagentPath (['researcher'], or ['researcher', 'fetcher'] when it
delegated again); the lead's own calls have none. The assertions search the whole
tree by default, so t.notCalledTool('delete_repo') fails if a sub-agent called
it. Pass { subagent: 'researcher' } to look at one sub-agent (and the
sub-agents under it) only:
import { createAgent, defineEval } from '@lousho/build-ai-agent';
const researcher = createAgent({ model: 'openai/gpt-4o-mini', instructions: 'You research.', description: 'Finds sources' });
const agent = createAgent({ model: 'openai/gpt-4o-mini', subagents: { researcher } });
defineEval({
name: 'research delegates the search',
agent,
async test(t) {
await t.send('Research electric bikes');
t.calledTool('web_search', { subagent: 'researcher' });
t.notCalledTool('delete_repo');
},
});The calls come from the run's tool.start events, which t.send() collects
through send(message, { onEvent }). A remote target (--url) streams no
sub-agent events, so it only shows the lead's calls. The classic form
({ agent, input, provider, score }) forwards every AgentExecutor.execute()
option, subagents and skills included, and its EvalResult.toolCalls
includes the sub-agent calls too.
A model is not deterministic, so one run says little. repeat: n runs every
case n times; the case passes when all n runs pass, or when at least
passAt: k of them do:
import { createAgent, defineEval } from '@lousho/build-ai-agent';
const agent = createAgent({ model: 'openai/gpt-4o-mini', instructions: 'You handle refunds.' });
defineEval({
name: 'refund flow',
agent,
repeat: 5,
passAt: 4, // pass@4 of 5
async test(t) {
await t.send('Refund order 42');
t.calledTool('issue_refund');
},
});Each run is its own row in the report (refund flow [refund flow #1], ...) with
its own cassette. timeoutMs covers all runs of a case, so raise it with repeat.
Pass cases and test runs once per case, each reported separately. A case is
any object; its label (or name, or its input) names it.
import { z } from 'zod';
import { createAgent, defineEval, defineTool } from '@lousho/build-ai-agent';
import { mockModel } from '@lousho/build-ai-agent/testing';
const lookupOrder = defineTool({
name: 'lookup_order',
description: 'Look up an order by id',
input: z.object({ orderId: z.string() }),
execute: ({ orderId }) => ({ orderId }),
});
defineEval({
name: 'order lookups',
agent: () =>
createAgent({
tools: [lookupOrder],
provider: mockModel([
{ toolCalls: [{ name: 'lookup_order', args: { orderId: '7' } }] },
{ text: 'Found it.' },
]),
}),
cases: [
{ input: 'Where is order 7?', orderId: '7' },
{ input: 'Status of #7', orderId: '7', label: 'hash syntax' },
],
async test(t, c) {
await t.send(c.input);
t.calledTool('lookup_order', { args: { orderId: c.orderId } });
},
});Pass a factory (agent: () => createAgent(...)) so each case gets its own
agent and its own mockModel script. With a single shared agent, a scripted
model runs out of turns on the second case.
t.judge(rubric) grades the latest reply with an LLM and returns a score from
0 to 1. It needs a judge provider, and lousho never calls a real LLM unless you
configured one: without judge, t.judge() throws an error saying how to fix
it.
// tone.judge.eval.ts: run with `lousho eval --judge`
import { createAgent, defineEval, atLeast } from '@lousho/build-ai-agent';
import { mockModel } from '@lousho/build-ai-agent/testing';
defineEval({
name: 'tone',
agent: createAgent({ provider: mockModel(['Happy to help with that refund!']) }),
// A real provider in practice; mockModel keeps this example offline.
judge: { provider: mockModel(['0.9']), model: 'judge-model' },
async test(t) {
await t.send('I want my money back');
t.soft('polite', await t.judge('Is the reply polite?'), atLeast(0.7));
},
});judge.model is optional: without it the judge runs on its provider's own
model. The judge sees the rubric, the user's request and the agent's reply, runs
at temperature 0 unless you set temperature, and is asked to end with a
SCORE: <0..1> line. Its reply is read by the last score-labelled number
(SCORE: 0.9, **Score:** 8/10, {"score": 0.8}), so a number in its
reasoning ("1 of 5 criteria") is ignored. Without a label the whole reply must
be one number (0.9, **0.9**). A number is scaled into 0 to 1 when it is
written as 8/10, 8 out of 10, 85% or a bare whole number from 2 to 100
(8 reads as 8/10, 85 as 85/100). A reply with no labelled or bare number
scores 0. parseJudgeScore(text) exposes the same parsing.
Files named *.judge.eval.ts are never picked up by a normal run (and
npm test); only lousho eval --judge (or npm run test:evals:judge in this
repository) runs them.
lousho eval [globs...] [--tag t] [--junit path] [--json path] [--strict] [--judge] [--record | --replay | --drift [--drift-usage]] [--url <base> [--token <bearer>]] [--config path] [--timeout ms]
| Option | Meaning |
|---|---|
globs... |
eval files to run (default: every **/*.eval.ts, or **/*.judge.eval.ts with --judge) |
--tag t |
only run evals whose tags include t (repeat or comma-separate for several) |
--junit path |
write a JUnit XML report |
--json path |
write the summary and every structured result as JSON |
--strict |
soft failures fail the run |
--judge |
run *.judge.eval.ts files instead of the normal ones |
--url base |
run every case against the deployed agent at base instead of in-process; --token (or LOUSHO_EVAL_TOKEN) is the bearer token. See Run evals against a deployment |
--config path |
use your own vitest config instead of the generated one |
--timeout ms |
time limit per case in milliseconds (0 = none); see Time limits |
--record |
run against the real provider and write one cassette per case (below) |
--replay |
run every case from its cassette, with no network; a missing cassette fails the case |
--drift |
re-record into a temp directory and report how each case's trajectory changed (--drift-usage also compares tokens) |
It spawns the vitest installed in your project (vitest is your dependency; if
it is missing the command prints npm install --save-dev vitest and exits 1),
then prints one row per case with its scores and duration, totals, and the gate
failures and soft failures listed separately. The "with soft failures" total (softFailed in the JSON summary) counts every case with a failed soft assertion, including cases that also failed a gate.
Exit code: 1 on any gate failure (or soft failure with --strict, or when
vitest itself fails, for example a file that does not load, or is not installed,
or a --junit/--json report cannot be written, or when no case ran at all:
no eval file matched, or a --tag matched no case), 2 for a usage error (bad
arguments), otherwise 0. A case that vitest fails after the case itself
finished (it ran past its time limit) is reported as that case failing.
How results are collected: each case appends one JSON line to a file named by
the LOUSHO_EVAL_RESULTS environment variable, which lousho eval sets and
reads back. This is more robust than a custom vitest reporter: it works across
vitest versions and worker pools, and needs no module loaded from your project.
An agent run on a real model takes far longer than vitest's 5 s default test
timeout. The config lousho eval generates gives every case 10 minutes, except
under --replay, which keeps vitest's default because nothing waits on a
model. --timeout ms sets the limit for every case (0 turns it off). With
--config, your config's testTimeout applies unless you pass --timeout.
One eval can set its own limit, which wins over both:
import { createAgent, defineEval } from '@lousho/build-ai-agent';
defineEval({
name: 'research report',
agent: createAgent({ model: 'openai/gpt-4o-mini' }),
timeoutMs: 15 * 60_000,
async test(t) {
await t.send('Summarize the latest release notes.');
t.completed();
},
});A case that runs out of time is reported as an error naming the limit, in the summary and in the JUnit and JSON reports.
The report is standard JUnit: one testsuite per eval, one testcase per case.
A failed gate is a <failure message="..."> carrying the diagnostic message; a
case that threw or timed out is an <error>; soft failures are <system-out>
notes (or failures under --strict), also on a case that failed a gate. When vitest fails something no eval case
reported - an eval file that does not load, a plain test() that fails - the
report gets an <error> testcase for it under a vitest suite, one per file
(or one for the whole run when --config is used and vitest names no file), so
a broken run never writes an empty, green-looking report.
# .github/workflows/evals.yml
name: evals
on: [pull_request]
jobs:
evals:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
- run: npx lousho eval --junit reports/evals.xml --json reports/evals.json
- uses: actions/upload-artifact@v4
if: always()
with:
name: eval-reports
path: reports/
- uses: mikepenz/action-junit-report@v4
if: always()
with:
report_paths: reports/evals.xmlRun --tag smoke on every pull request and the full set nightly; run
--judge only where you accept real LLM calls and their cost.
lousho eval passes its flags to the vitest process that runs the eval files
through environment variables. You do not set them yourself; they are listed
so you recognise them in a CI log or a custom runner:
| Variable | Set from | Meaning |
|---|---|---|
LOUSHO_EVAL_CASSETTES |
--record, --replay, CI |
Cassette mode for the run: record, replay or auto (replay a case whose cassette exists, run the rest live). Under replay and auto a model: spec resolves without its API key. |
LOUSHO_EVAL_DRIFT_DIR |
--drift |
With record: where the drift run writes its cassettes instead of next to the eval file. |
LOUSHO_EVAL_CONFIG |
--config |
The config path, so re-record hints can repeat it. |
LOUSHO_EVAL_TAGS |
--tag |
Comma-separated tags; only cases with one of them run. |
LOUSHO_EVAL_RESULTS |
always | The file each case appends its result line to, which lousho eval reads back (see lousho eval). |
LOUSHO_EVAL_URL |
--url |
Base URL of the deployed agent to run the cases against. |
LOUSHO_EVAL_TOKEN |
--token |
Bearer token for --url. |
LOUSHO_ALLOW_LLM_JUDGE |
--judge |
Set to 1 inside the judge runner. Without it, llmJudge() / llmCritique() called outside that runner throw LOUSHO_EVALS_INVALID, so a misplaced judge eval never makes a paid model call in a normal test run. |
An eval against a real model is slow, costs money and needs a key; the same
eval on mockModel only tests the script you wrote. lousho eval sits in
between: it records each case once against the real provider through
recordReplay and replays the recording in CI.
Your eval files do not change.
npx lousho eval --record # real provider: writes the cassettes, commit them
npx lousho eval --replay # every case from its cassette, no network, no key
npx lousho eval --drift # re-record and diff each case's trajectory--recordruns every case with its agent's real provider and writes one cassette per case next to the eval file:__cassettes__/<eval-name>/<case>-<hash>.json. The name is the slugged case label plus a short hash of the full label, for examplerefund-flow/polite-1f3a9c2e.json, so two cases whose labels slug or truncate alike still get their own file; an eval withoutcaseswritesdefault.json. Give each case a uniquelabelso the names stay stable: two cases that would still share a cassette (the same label twice) fail at record time instead of overwriting each other. A case whose agent uses more than one provider (a sub-agent on another model) gets one named after the sub-agent,<case>-<hash>.<agent>.json(older.2.jsonfiles are still replayed). Replay matches each call by its request, not its position, so parallel sub-agent tasks replay whatever order the live model answered in; a mismatch stops the run withLOUSHO_CASSETTE_INVALIDeven when it happens inside a sub-agent. The files use the normal cassette format, with API keys redacted; review and commit them. Re-recording a case whose exchanges did not change leaves its file untouched (recordedAtis kept), so only real changes show in a diff. Cassettes recorded by an older SDK under the unhashed<case>.jsonname are still replayed; the next--recordwrites the new name, after which the old file can be deleted.--replaynever calls the model. A case with no cassette fails withno cassette for "<eval> [<case>]" at ...and the--recordcommand to run (with your--config, if you gave one); when the agent's requests changed, the replayed model call fails with aCassetteMismatchErrornaming the first difference and the samelousho eval --recordcommand. Plainlousho evalwithCIset replays every case that has a cassette and runs the rest live; withoutCIit runs live as before.--driftre-records every case into a temp directory (the committed cassettes are not touched) and compares each with its committed cassette: the ordered tool names, each call's arguments (JSON-normalized, so key order does not count), the step count and the finish reason. Token usage changes on every real run, so it is compared only with--drift-usage. The summary gets aDrift:table (eval, case, field, committed, current); each drifted case gets a softdriftassertion, so it shows asPASS (soft fail)and as a<system-out>note in the JUnit report. With--strict, drift is a gate failure: the case fails, JUnit has a<failure>, and the run exits 1.
Drift:
EVAL CASE FIELD COMMITTED CURRENT
refund flow polite args lookup_order {"orderId":"42"} lookup_order {"orderId":"43"}
Run --drift nightly or before a model upgrade, and --replay (or plain
lousho eval in CI) on every pull request. Judges are not recorded: grade with
a real judge only in *.judge.eval.ts files.
How it works: the run loop sends every model call (generate or stream, from
a plain run, a streamed run, a sub-agent or an approval resume) through one
provider-interception seam, setProviderInterceptor() (exported from
@lousho/build-ai-agent/testing; src/providers/interception.ts in the
source). Nothing is installed by default, so the seam is
free. While a case runs under one of these modes, lousho eval answers it with
the provider wrapped by recordReplay() for that case's cassette. You can use
the seam yourself to put any wrapper at the model boundary: the interceptor gets
the run's provider and returns the one to call (it must return the same wrapper
for the same provider, and leave a provider it already wrapped alone). A
cassette failure it raises - a replay mismatch or a missing cassette - reaches
send() typed (CassetteMismatchError, LOUSHO_CASSETTE_INVALID), not as a
compacted provider error.
The same eval file that gates CI in-process can smoke-test a deployed agent (the
node server or Cloudflare Worker). Point lousho eval at its base
URL and every case runs against the deployment instead of the in-process agent:
npx lousho eval --url https://agent.example.com --token "$DEPLOY_TOKEN" # or LOUSHO_EVAL_TOKENEach case gets its own remote session (POST /chat { sessionId, input }, one
session per case, shared by its t.send() calls) and the SSE stream is read to
run.done. The events become the same result an in-process run produces: tool
calls, final reply, finish reason, steps and usage. So t.calledTool(),
t.completed(), scorers, t.judge(), the summary and --junit work
unchanged. The agent in the file is not used; give the deployment's own model
and tools the behaviour you want to check.
- A check that needs data the stream does not carry fails and says so (for
example
maxTokenswhenrun.donehas no usage);maxCostUsdfails when there is no cost, unless you pass{ unknownCost: 'skip' }. --urlcannot be combined with--record,--replayor--drift(LOUSHO_CONFIG_CONFLICTING_OPTIONS): cassettes record a provider in-process. Score/threshold evals (score:form) also need an in-process provider and fail with the same code; use trajectory evals.- An unreachable deployment, a non-2xx answer or a truncated stream fails that
case with
LOUSHO_REMOTE_REQUEST_FAILED, and401withLOUSHO_REMOTE_UNAUTHORIZED; the run goes on. The token is never printed or written to a report.
In code, pass a target (this overrides agent; --url overrides both):
import { defineEval, remoteTarget } from '@lousho/build-ai-agent';
defineEval({
name: 'deployed refund flow',
target: remoteTarget({ url: 'https://agent.example.com', auth: process.env.LOUSHO_EVAL_TOKEN }),
async test(t) {
await t.send('Refund order 42');
t.completed();
t.calledTool('lookup_order');
},
});remoteTarget({ url, auth?, fetch? }) takes an injectable fetch, which is how
the SDK's own tests run the real routes in-process without a socket.