Repository navigation
Eval Suites
An eval suite declares what a run on a live model must do, case by case (#396). The entry
agboss/eval holds the decorator and the builders of its cases and checks only — nothing runs at
import. A suite is assembled on its application before anything runs — by agboss check (Loop 0) and
by its runner, agboss eval run (below, #396 PR 2).
- One class per file:
eval-suites/<name>.eval-suite.ts, itsnamekebab-case ending with-eval-suite, its class withEvalSuite([name.kind-suffix]). -
start: the@WorkflowStartevery turn of every case goes through — a start of the application ([eval.unknown-start]). -
checks: what decides whether a run passed, in the order listed —checkTrajectory()(each case'stools, deterministic, $0) andjudgedBy(Judge)(a@Judgeon the finish's output). -
cascade: required —EvalCascade.StopAtFirstFailure(a failed check skips the rest:CheckStatus.Skipped, and a skipped check fails the run) orEvalCascade.RunAll. -
cases: each one a thread of its own (below), eachnameunique in the suite ([eval.duplicate-case]— a case name keys reports and baselines).
export class RefundRequestDto {
@Text({ prompt: "the order id" })
orderId!: string;
@Integer({ prompt: "the amount in cents", min: 1 })
amountCents!: number;
}
export class RefundDoneDto {
@Text({ prompt: "what was refunded" })
status!: string;
}
@Injectable()
export class RefundLedger {
record(orderId: string): Promise<string> {
return Promise.resolve(`refunded ${orderId}`);
}
}
@Tool({
name: "refund-tool",
description: "Refunds an order",
input: RefundRequestDto,
output: RefundDoneDto,
deps: [RefundLedger],
})
export class RefundTool implements ToolHandler<RefundRequestDto, RefundDoneDto> {
constructor(private readonly refundLedger: RefundLedger) {}
async run({ orderId }: RefundRequestDto): Promise<RefundDoneDto> {
return { status: await this.refundLedger.record(orderId) };
}
}
@Tool({
name: "delete-order-tool",
description: "Deletes an order",
input: RefundRequestDto,
output: RefundDoneDto,
})
export class DeleteOrderTool implements ToolHandler<RefundRequestDto, RefundDoneDto> {
run({ orderId }: RefundRequestDto): Promise<RefundDoneDto> {
return Promise.resolve({ status: `deleted ${orderId}` });
}
}
@Agent({
name: "refund-agent",
description: "Refunds orders",
prompt: "Refund what is asked, within policy.",
model: "moonshotai/kimi-k2.6",
input: { message: WorkflowStartTextDto },
output: RefundDoneDto,
tools: [RefundTool, DeleteOrderTool],
})
export class RefundAgent {}
@WorkflowStart({ name: "refund-workflow-start", description: "A refund request", input: WorkflowStartTextDto })
export class RefundWorkflowStart {}
@WorkflowFinish({ name: "refund-workflow-finish", description: "The refund", output: RefundDoneDto })
export class RefundWorkflowFinish {}
@Workflow({
name: "refunds-workflow",
version: "1.0.0",
flow: [
start(RefundWorkflowStart)
.returns(WorkflowStartTextDto)
.toAgent(RefundAgent)
.returns(RefundDoneDto)
.toFinish(RefundWorkflowFinish),
],
providers: [RefundLedger],
})
export class RefundsWorkflow {}
@Application({
name: "refunds-application",
version: "1.0.0",
environment: EmptyEnvironment,
workflows: [RefundsWorkflow],
})
export class RefundsApplication {}
// a code judge: deterministic, $0
@Judge({ name: "refund-reported-judge", decidedBy: DecidedBy.Code, reply: RefundDoneDto })
export class RefundReportedJudge implements JudgeHandler<RefundDoneDto> {
judge({ reply }: JudgeInput<RefundDoneDto>): Promise<JudgeVerdict> {
return Promise.resolve(
reply.status.startsWith("refunded")
? JudgeVerdict.passed()
: JudgeVerdict.failed({ reason: "the answer does not report a refund" }),
);
}
}
// a model judge: paid
@Judge({ name: "polite-refund-judge", model: "moonshotai/kimi-k2.6", reply: RefundDoneDto, deps: [JudgeModel] })
export class PoliteRefundJudge implements JudgeHandler<RefundDoneDto> {
constructor(private readonly judgeModel: JudgeModel) {}
async judge({ reply }: JudgeInput<RefundDoneDto>): Promise<JudgeVerdict> {
const answer = await this.judgeModel.invoke(`Is "${reply.status}" polite? Answer yes or no.`);
return /^\s*yes/iu.test(answer)
? JudgeVerdict.passed()
: JudgeVerdict.failed({ reason: "be polite" });
}
}import {
EvalCascade,
EvalSuite,
checkTrajectory,
evalCase,
judgedBy,
pastTurn,
toolCall,
toolInvariant,
turn,
} from "agboss/eval";
@EvalSuite({
name: "refunds-eval-suite",
description: "Refunds: looked up once, never deleted, never over $100",
start: RefundWorkflowStart,
checks: [
checkTrajectory(), // 1. each case's `tools`: free
judgedBy(RefundReportedJudge), // 2. a code judge: free
judgedBy(PoliteRefundJudge), // 3. a model judge: paid, runs only on a run the two above passed
],
cascade: EvalCascade.StopAtFirstFailure, // required: no implicit order
cases: [
evalCase({
name: "refund-within-policy",
tenant: { tenantId: "eval-acme" }, // validated like app.execute's
turns: [turn({ text: "Refund order 7, it arrived broken" })],
tools: {
required: [toolCall(RefundTool, { orderId: "7" })], // some call has these arguments
forbidden: [DeleteOrderTool],
invariants: [toolInvariant(RefundTool, (input) => input.amountCents <= 10_000)],
},
}),
evalCase({
name: "refund-after-a-question",
history: [
// written into the thread, not run: what memory and its compaction read
pastTurn(RefundWorkflowStart, { text: "Was order 8 refunded?" }, RefundWorkflowFinish, {
status: "refunded 8",
}),
],
turns: [turn({ text: "Now order 9, please" })],
tools: { required: [toolCall(RefundTool, { orderId: "9" })] },
judges: [judgedBy(RefundReportedJudge)], // after the suite's checks, for this case only
variants: [turn({ text: "Please refund order 9 as well" })], // rewordings: the same tools
}),
],
})
export class RefundsEvalSuite {}-
turnsrun in order on the case's thread through the suite's start; the checks judge the last. At least one. Each turn is the start's input (turn({ text })), typed by the generated.agboss/types.d.ts. -
history— past turns written into the thread before the first turn, not run (no model cost):pastTurn(Start, input, Finish, output), the input typed by that start, the output by that finish. -
tenant/execution— the execution's context values, typed and required as atapp.execute(an application that declares its ownTenantContextsubclass needs its fields). -
tools— whatcheckTrajectory()checks:required(toolCall(Tool, partial?)— a deep partial of the tool's input; without it any call),forbidden(tool classes),invariants(toolInvariant(Tool, (input) => boolean)— every call of the tool, its input typed by the tool's DTO). -
judges— judges of this case only, after the suite's checks. -
variants— rewordings of the last turn, for metamorphic runs.
The order is a declaration, never a convention: free trajectory invariants first, then code judges
(@Judge({ decidedBy: DecidedBy.Code }), Judges), then model judges.
Under EvalCascade.StopAtFirstFailure a paid judge runs only on a run the free checks passed.
When a case passes at least once in k runs but not every time (pass@k high, pass^k low), do not grow
the prompt: move the check into deterministic code — a tool interceptor, a code guardrail
(decidedBy: DecidedBy.Code, Guards), DecidedBy.Code on a @Router or an @Arbiter, or
an @Action.
A suite is assembled on its application before anything runs — every rule violation at once, one
GraphRuleError:
| Code | Means |
|---|---|
[name.kind-suffix] |
the name does not end with -eval-suite, or the class with EvalSuite
|
[eval.unknown-start] |
start is not a start of the application (its starts listed) |
[eval.duplicate-case] |
two cases share a name
|
[eval.judge-wrong-reply] |
a judge judges no finish output the start reaches, or judges a list |
An eval judge judges the output of the finish the run ended at, so its reply is a finish's
output the start reaches — one value; oneOf(…) for several finishes. A judge's own rules apply
too ([judge.not-a-judge], [judge.no-model], [judge.model-on-code-judge]). A suite that assembles may
warn: a model judge of the model family of an agent of the start's workflow is
[judge.same-model-family], printed by agboss check
and an error under --strict.
Then the cases' values: EvalCaseInvalidError (eval.case-invalid, from agboss) lists every
issue at once — case "x": tenant.plan — <reason>, turns[0].text, history[1].output.status,
variants[0], turns (no turn) — the field named, never the value.
agboss eval run --suite refunds-eval-suite --runs 3 --env eval --report .artifacts/eval/main.json-
Where suites are found: every
eval-suites/*.eval-suite.tsunder the folder of the application file (any depth;node_modulesand dot folders skipped), each module's exported@EvalSuiteclasses whosestartis one of the application's starts.--suitenames one by its full name; with a single suite it may be left out. Several and none named, or a name none has →eval.unknown-suite, exit 2, the suites listed. -
The application is built as
createAppbuilds it, on the--envenvironment (defaultdev): its services, its model providers, its stores and budgets. The suite is assembled first — its rules (graph.rules, exit 3), then its cases' values (eval.case-invalid, exit 3) — before any case runs. -
Each case runs
--runstimes (k, default 1), one run after the other, each on a thread of its own: the case'shistoryis written into the thread (each past turn a finished turn of the workflow —start <name>: <input>/finish <name>: <output>— no model call), itsturnsrun in order throughapp.executewith the case'stenant/execution(validated as there), then the checks judge the last turn, in order, under the suite's cascade. A turn that fails, or an earlier one that pauses, ends the run with itserror: { code, message }and no checks. -
checkTrajectory()reads the last turn'sresult.toolCalls(Testing): arequiredtool needs a call the tool ran (Succeeded,FailedorTimedOut) whose input matches the partial; aforbiddenone fails on any call, even one that never ran; an invariant must hold on every call with checked arguments (notInvalidArguments). Every broken expectation is named in the reason:get-order-tool not called with {"orderId":"7"} (required; 0 call(s)); delete-order-tool called (forbidden). -
A judge (
judgedBy(Judge), the suite's or the case's) is created by the application's container and runs in the frame of the case's last execution: its services, an injectedExecutionContext(that execution'sexecutionIdand thread), the project'sTenantContext/ execution fields with the case's values. It gets the finish's output as its DTO (variant= the finish's output class),task(the last turn's start line),attempt: 0and the last turn'stoolCalls. A run that ended at a finish the judge does not judge — or at none (a pause) — fails that check (the run ended at hand-over-workflow-finish, whose output answer-grounded-judge does not judge). A model judge an agent also lists uses that binding; one only a suite lists is bound through the application's models; its calls are counted in the application's budgets and in the check'sspentUsd. A judge that throws fails its check with the error's message. - Pass, pass@k, pass^k (S2 Q6): a run passes when every check that ran passed — a skipped check fails the run. Per case, over its k runs: pass@k = at least one passed, pass^k = every one passed (n = k). A case with pass@k and not pass^k is unstable — a candidate for deterministic code (above).
What it prints:
refunds-eval-suite · desk-workflow 1.0.0 · config 7c1e09 · 3 cases × 3 runs · env eval
case pass@3 pass^3 runs first failure
refund-within-policy yes yes ✓✓✓
prohibition-survives-compaction yes no ✓✗✓ run 2: trajectory: issue-store-credit-tool called (forbidden) unstable
fresh-price-beats-memory yes yes ✓✓✓
pass@3 3/3 · pass^3 2/3 · unstable 1 · judge calls 8 (1 skipped by the cascade) · spent $0.3120
report → .artifacts/eval/main.json
The exit code says whether the suite ran (0), never how well — unless it is compared with a
baseline and --fail-on-regression is given (below).
--profile <name> runs the suite on profiles/<workflow>/<name>.yaml. The history a case writes is
kept ExecutionRecordStatus.Written (#396 S6): turns of their thread, which its memory reads, never
runs agboss eval score scores or agboss golden add saves.
--report <run.json> writes it (folders created); --json carries the same object as the envelope's
result (command: "eval", the file in created). It is also the reference a later run is read
against, so it says what it is and the version of its shape (#396 S3):
| Field | What it holds |
|---|---|
kind |
always "agboss.eval-run"
|
schemaVersion |
4 (#397 PR 5b; 1 to 3 before it, still read): a reader refuses a version it does not know |
suite, start, cascade, runs
|
the suite's name and start, its cascade, k |
application, workflow
|
{ name, version } of each |
configHash |
the workflow's fingerprint (agboss describe --json): what the runs ran on |
environment |
the environment's name |
startedAt, endedAt
|
ISO 8601, on the application's Clock
|
cases[] |
name, caseHash (the case as written, its tools' expectations, judges, the suite's checks and cascade — a changed case is not compared), passed (runs that passed), runs, passAtK, passHatK, isUnstable, runResults[], expectation? (version 2: EvalCaseExpectation — requiredTools by name with their partial arguments, masked, forbiddenTools, invariantTools, checks in order) |
cases[].runResults[] |
run (1…k), threadId, tenantId (version 4: the tenant its turns ran for — the case's, else the default one), executionId and finish of the last turn, passed, checks[], error?, spentUsd, trace? (version 2, below) |
…checks[] |
check (trajectory, the judge's name or metamorphic), status (CheckStatus: passed, failed, skipped), reason?, skippedBy? (the failed check that skipped it), metrics? (a judge's), spentUsd? (a model judge's) |
summary |
cases, passAtK, passHatK, unstable (counts of cases), judgeCalls (judge checks that ran), skippedByCascade
|
spend |
{ totalUsd }: every turn's execution and every judge's model calls |
chaos? |
what --chaos injected (EvalRunChaos, below) |
metamorphic? |
true when run with --metamorphic (below) |
baseline? |
the run against --baseline (EvalRunComparison, below) |
profile?, agbossVersion?, models?, piiPolicies?
|
version 3 (#397 PR 3): what it ran on — the --profile, the AGBoss version, every agent's and model router's model and provider (EvalModelUse: component, kind, model, provider), and the application's and workflow's PII policies that masked its records and traces |
The artifact's types are public in agboss/eval (#396 S6): EvalRunReport, EvalCaseReport,
EvalRunResult, EvalCheckResult, EvalRunError, EvalRunSummary, EvalRunComponent,
EvalRunSpend, with EVAL_RUN_KIND and EVAL_RUN_SCHEMA_VERSION; EvalRunSchemaVersion is 1 | 2.
agboss eval ui --dir .agboss/eval --port 4317eval ui: http://127.0.0.1:4317/?token=9f2c…e1 (Ctrl+C to stop)
runs: .agboss/eval
A folder of run artifacts served until Ctrl+C — read-only but for the labels people give (below): the first page lists every *.json
directly in the folder that is a run artifact, newest first — its suite, file, workflow, config,
environment, start, pass@k, pass^k, unstable cases, regressions against its baseline, and per case a
dot per attempt linked to that run's page at that attempt — then the .json files that are not, with
why. A run's page is the same report --html writes (within the same size bound), with a link back.
It runs nothing, loads no application, needs no key; the folder is read again on every request.
-
Only this machine, only with the token. It listens on
127.0.0.1alone; every request needs the token it printed (a fresh 128-bit one per start,?token=) and aHostof127.0.0.1:<port>orlocalhost:<port>— a page of another site cannot reach it by DNS rebinding — else403. OnlyGET,HEADand its own forms'POST(405otherwise); a file it did not list is404, one that is no artifact422. Pages carry the same Content-Security-Policy,Referrer-Policy: no-referrer,Cache-Control: no-store. -
--dirdefaults to.agboss/eval,--portto4317(0: a free one). A folder that does not exist iseval.no-runs, a port outside 0–65535usage.option-value— exit 2, before it serves.
Each run's page has, under its checks, a form per judge verdict it has (not the trajectory check, not
a skipped one): the labels given so far, and your verdict — passed or failed (what κ reads), a
score from 1 to 4 (optional, the rubric view's), failure tags, a note, the labeler (--labeler,
default the system user). Save label writes it to the suite's labels file — the C4 file
eval/labels/<suite>.jsonl (--labels-dir), one EvalLabel JSON line each, exactly what
agboss eval calibrate reads — then goes back to the
run. A label of the same run, judge and labeler replaces the old one in place; the file is written
whole to a temporary file and renamed over the old, so a reader never sees half of it.
The run page then shows Calibration against people's labels: calibrateJudges on the run and the
file, on every load — for each judge with labels, κ and the agreement of each pair of raters, the
confusion per labeler and the disagreements (with their tags and notes).
The failure taxonomy is the project's closed list of tags, eval/failure-taxonomy.yaml
(--taxonomy):
tags:
- tag: wrong-tool
description: called a tool the task did not need
- tag: missed-policy
description: answered without check_policyEach tag kebab-case and unique; without the file no tags are offered; a file that is not a taxonomy is
eval.taxonomy-invalid (exit 2) before it serves. A form is refused unless it comes from the page:
the token in its body (a token in the URL is not enough), an Origin of the server's own (when the
browser sends one), the same Host check — 403; a label that does not fit (no verdict, a score
outside 1–4, a tag the taxonomy lacks, a judge or run the artifact has no verdict of) is 400; a
labels file that does not read is never rewritten — 409; a form above 64 KB 413. Only the labels
files and the suites' files (below) are written.
A run with an execution has Save as golden case: a name (<case>-run-<n> to start with) and a
button. It reads that execution back from the application's history and drafts it exactly as
agboss generate eval-case --from-execution
does — one builder (evalCaseDraftOf, withEvalCase): its input as the turn, its contexts, the
thread's earlier finished turns as history, the calls it ran as tools.required, masked as kept,
its classes imported — and writes the case into the suite's file (the suite of the artifact, among
the application's eval-suites/), then comes back to the run with Saved as the case "…" of ….
The application is package.json's ("agboss": { "application" }) or --application <path>, its
stores opened on --env <name> (default dev) — nothing is built or run. The history is there
only with @Application({ executionRecordRetention }) (Execution history):
without it — and without an application, or with one that does not load, or for a suite the project
does not have — the run says why in place of the button, and the labels still work.
The file is written whole and atomically (a temporary file renamed over it), and only if it is still
the one the page was made from: the form carries the file's ETag (a hash of its content), and a
file changed since — by another save, an editor, a pull — is 409 (reload the page and save again).
A case of the same name is 409 and the file stays as it was. The execution is the run's own
(#397 PR 5b): the kept record with the run's execution id on the run's thread, of the run's
tenantId (the artifact keeps it from schemaVersion 4) — so an id another tenant kept too is never
taken by mistake. One the history does not have is 404; one kept for several tenants, of a run
recorded before runs carried their tenant, is 409: run the suite again and save from the new run; a form whose run
and execution do not match the artifact, or without a name, 400. The form is protected like a label's: the token in its
body, the page's Origin, the Host — else 403. Review the case in your editor and commit it.
Each run keeps its last turn's trajectory — taken from that execution's record ({ record: true }),
so it is masked by the application's and the workflow's PII policies like the record (the output
and the side-path failures, which the result keeps outside the record, are masked the same way). A
run whose last turn threw before it had a record (a limit, an earlier turn's pause) has none.
trace (EvalExecutionTrace) |
What it holds |
|---|---|
executionId, path
|
the execution and the nodes it went through |
steps[] (EvalStepTrace) |
one per visit, in the order the visits ended: component, kind (ComponentKind), nodeId, visitNumber, copyNumber, itemIndex?, inputHash (a stable hash of what it read: two runs that read the same have the same), validationAttempts, modelCalls[] (caller, model, inputTokens, outputTokens, cacheReadTokens, costUsd), toolCalls[] (tool, toolLoopIteration?, status — ToolCallStatus —, input, output?, error?, isResultTruncated, validationAttempts), guardrailDecisions[] (guardrail, side, isBlocked, probability), judgeVerdicts[] (judge, attempt — 0 is the first reply, so retries show —, passed, reason?, metrics?), spentUsd, startedAt, endedAt, error? (a failure a side path took) |
handledErrors[] |
{ error: { code, message }, at, by }: where a failure was taken and the side path's first node |
pauses[] |
its own resolved pauses: nodeId, role, resolution, responder?, decision?
|
compensations[] |
undo actions: compensated (node), compensator, reason, status, error?
|
children[] (EvalChildTrace) |
each child execution's own trace, nested, with workflow, status, launchedBy, itemIndex?, error?
|
finish?, output?
|
the finish it ended at and its output |
agboss eval run --suite refunds-eval-suite --runs 3 --report .artifacts/eval/main.json --html .artifacts/eval/main.html
agboss eval show .artifacts/eval/main.json --html .artifacts/eval/main.html--html <file> on eval run writes the run as one self-contained HTML page next to (or instead of)
the artifact; agboss eval show <run.json> reads a saved artifact — runs nothing, loads no
application, needs no key — prints the same table as eval run and, with --html <file>, writes the
same page. So CI renders a pull request's report from the artifact it already has, without a second
model bill. Both print html → <file> (<size>, self-contained); under --json the file is in
created (eval show's result is the artifact as read).
The page is one file: its CSS and its one script inline, no fonts, images or requests — its
Content-Security-Policy allows nothing but its own style and script (by hash), so it opens the
same from file://, a CI artifact or a PR attachment. It reads in light and dark
(prefers-color-scheme), at phone width, and without its script; it is semantic HTML (landmarks,
headings, tables, details), every control reachable by keyboard. What it shows:
| Section | From the artifact |
|---|---|
| Header | the suite, the application and workflow with their versions, configHash, the start, the environment, k, the cascade, when it started and ended, how long it took |
| Summary | pass@k and pass^k (cases and share), cases, attempts (cases × k), unstable cases, judge calls and those the cascade skipped, the total spend |
| Against the baseline | only for a run made with --baseline (#397 PR 2): the baseline's file, configHash, start and k, the regressed cases, the count of each verdict, and every case of either run — the baseline's passes, this run's, its verdict (regressed highlighted, flaky on the baseline (quarantined), changed (not compared)), linked to its entry |
| Unstable cases | only when there are some: each case with pass@k and not pass^k, its passed / k, linked to its row — candidates for deterministic code, not a longer prompt — and (#397 PR 2) where its attempts part ways: the runs' steps compared position by position, the first step whose node, input, decisions or tool calls differ (or, with the same steps, the output), linked to that step of the first run — refund-agent (visit 1): the same input, other tool calls
|
| Cases | one per case: pass@k / pass^k, its expectation (version 2: required, forbidden and invariant tools, the checks in order), an attempt dot per run 1…k — passed, failed (a check failed), error (a turn failed or paused), limit (its error is a limit.*) — each a link that opens that run: its executionId, thread, finish, spend, error, every check with its status, reason, the check that skipped it, a judge's metrics and spend, and (version 2) its timeline — each step with its kind, node, visit / copy / item, input hash, spend and time, its model calls (tokens, cache, cost), tool calls (status, input, output, a truncated result), validation attempts, guardrail decisions and judge verdicts per reply; then the failures a side path took, pauses, undo actions, child executions nested, and the output. A filter shows all cases, the failed ones (not pass^k) or the unstable ones |
It shows only what the artifact holds — check reasons and errors as the run wrote them, already
masked by the application's PII policies; the page adds nothing from the project and escapes every
value. The page is bounded: above 16 MB it is not written — eval.report-too-large, exit 2 (the
artifact of eval run is written first and kept). eval show refuses a file that is not a run
artifact this version reads — eval.run-not-found (no such file) or eval.run-invalid (not JSON,
another kind, an unknown schemaVersion, a field missing), exit 2.
One value (a tool's input or output, a decision, an output) shows at most 2,000 characters on the
page, the rest named (… [N more characters in the run artifact]), and one execution at most 200
steps: so a long run stays readable and under the bound (40 cases × 5 runs × 10 steps with 2 KB tool
values each is about 9.5 MB); the artifact keeps every value whole. A page that would still be
above 16 MB with every timeline leaves timelines out — first those of the cases with pass^k, then
all — and says so in its footer and on the CLI line (html → … (… MB, self-contained; timelines of cases without pass^k only)); only a page above the bound without any timeline is
eval.report-too-large. A version 1 artifact renders
as before, each run saying it has no timeline. Not yet on the page: labels and failure tags (#397 PR 4).
agboss eval run --suite refunds-eval-suite --runs 3 --env eval --baseline main.json --fail-on-regressionA baseline is a run artifact — the --report of a run of main (S2 C1: there is no separate
--save-baseline). It is read before anything runs, by the same reader as eval show (one reader, #397 S4): no
such file is eval.run-not-found; another kind, another schemaVersion, a missing field or a run
of another suite is eval.run-invalid (exit 2); --fail-on-regression without --baseline is a
usage error. Each case is compared by name (CaseComparison, agboss/eval):
| Verdict | When |
|---|---|
Regressed |
pass^k on the baseline, not now — the only verdict --fail-on-regression fails on |
Improved |
no pass@k on the baseline (every run failed), pass@k now |
Unchanged |
neither |
FlakyOnBaseline |
pass@k without pass^k on the baseline: it flapped there too — quarantined, never a failure |
New / Removed
|
only in this run / only in the baseline |
Changed |
another caseHash: the case, its tools' expectations, its judges, the suite's checks or cascade changed — not compared |
case baseline candidate verdict
refund-within-policy 3/3 1/3 regressed
prohibition-survives-compaction 2/3 1/3 flaky-on-baseline (quarantined)
fresh-price-beats-memory 3/3 3/3 unchanged
1 regressed · 1 unchanged · 1 quarantined
error: 1 case regressed against main.json: refund-within-policy (eval.regressed, exit 5)
With --fail-on-regression a regression exits 5 (quality, eval.regressed: "the agent got
worse", told apart from 3 "the project does not load"); without it the run exits 0 and shows the
table. The artifact (and --json's result) gains baseline — EvalRunComparison: file, the
baseline's configHash, startedAt and runs, cases (each EvalCaseComparison: name,
comparison, baseline / candidate passes { passed, runs }) and regressed (the names).
agboss eval run --suite refunds-eval-suite --runs 3 --env eval --chaos 0.2 --chaos-seed 7
agboss eval run --suite refunds-eval-suite --chaos 0.2 --chaos-seed 7 --chaos-failures rate-limited,timeoutA suite that passes on a quiet day says nothing about a day of 429s. --chaos <rate> makes that
share of the run's model calls fail on purpose (#396 PR 6, S2 Q11), each with one of
--chaos-failures (default: all) — ChaosFailure in agboss/eval, a set of its own, never a
ModelFailure:
ChaosFailure |
--chaos-failures |
What the call gets |
|---|---|---|
RateLimited |
rate-limited |
a 429 Too Many Requests (a transport provider: ModelFailure.RateLimited) |
Timeout |
timeout |
a timeout (a transport provider: ModelFailure.Timeout) |
TruncatedResponse |
truncated-response |
the answer cut at its token limit: finish_reason: "length", its text and its tool calls' arguments cut short — what the agent loop's invalid answers meet |
The failures are injected where a provider's own would come from — its HTTP client (its POSTs:
completions and decisions; the model lists read at start are never touched) and a transport
provider's complete() — below the attempt runner, so everything above treats them as real: the
provider's retryPolicy retries what it lists, its rate limit's 429 pause slows the queue, its breaker
counts failures; a failure nothing absorbs fails the step, and its
.onError side path runs — with the undo actions (compensateWith, #340) it
reaches. A streamed answer is not truncated.
The rate (above 0, at most 1) and the seed (a whole number) come together — one seed, one sequence of
failures over the run's calls, so a chaos run can be repeated; one without the other is
usage.missing-option, a bad value usage.option-value. A chaos run spends from the application's
real budgets: what is retried is paid as usual. It prints what it did:
chaos: rate 0.2, seed 7 — 5 of 31 model calls failed on purpose (rate-limited 2, timeout 2, truncated-response 1)
and the artifact (and --json's result) gains chaos — EvalRunChaos: rate, seed,
failures, calls (every attempt seen, retries too) and injected by kind (a truncation counts only
when an answer could be cut); agboss eval show prints the same line. Chaos is eval run's own: it is
an option of the internal build path the command uses, never of createApp — a production entry point
carries no fault-injection switch.
A case that passes without chaos and fails with it needs resilience, not a longer prompt: a
retryPolicy on its provider, an .onError side path, an undo for what it changed.
agboss eval run --suite refunds-eval-suite --runs 3 --metamorphicA case that passes for one wording says little about the next customer's. A case's variants are
its last turn reworded (typed by the start's input like a turn); with --metamorphic every run of the
case runs each variant too, on a thread of its own — the case's history written, its earlier turns
run, then the variant in place of the last turn.
The check metamorphic comes after the suite's checks and the case's judges, under the cascade: with
StopAtFirstFailure it is skipped once one failed, and no variant runs. It passes when every
variant called the same set of tool classes as the case's own last turn (the calls that ran) and met
the case's tools.required again — the same partials; otherwise it fails with each reason:
variant 1 calls get-order-tool, not get-order-tool, refund-tool; variant 1: refund-tool not called (required)
Its metrics are { variants: n }, and the variants' spend is the run's. A case without variants
gets no such check. The artifact says metamorphic: true; a case's caseHash covers its variants
only with the flag, so a run with it is Changed against a baseline without it — never Regressed.
The check is no judge: calibration and summary.judgeCalls leave it out.
A case whose rewording calls other tools depends on its wording: let the tool descriptions say when
each tool is for, or move the choice into code (a DecidedBy.Code @Router).
agboss generate eval-case refund-from-prod --suite refunds-eval-suite --from-execution 0f3c… [--tenant acme] [--dry-run]A failure seen in production is the best next case. With the application keeping its executions
(Execution history, @Application({ executionRecordRetention })), generate eval-case reads one from the store — it runs nothing and calls no model — and adds one evalCase({ … }) to the suite's cases:
evalCase({
name: "refund-from-prod",
tenant: { tenantId: "acme", plan: "pro" },
execution: { userId: "u-1" },
history: [pastTurn(DeskWorkflowStart, { text: "Where is order 7?" }, DeskWorkflowFinish, { text: "Order 7 arrived broken." })],
turns: [turn({ text: "Refund order 7 for <email>" })],
tools: { required: [toolCall(GetOrderTool, { orderId: "7" }), toolCall(RefundTool, { orderId: "7", amountCents: 5000 })] },
}),-
The turn is the execution's start input; the contexts its tenant and execution values (none
for the default tenant without fields); the history the thread's earlier turns that finished,
oldest first;
tools.requiredthe calls it ran — succeeded, failed or timed out, not declined or blocked —, each tool with the same arguments once. - The classes it names are found among the project's by their full names and imported into the
suite's file, merged into an existing import of the same module, with
pastTurn,toolCallandturn. A tool without a class in the project is left out and named in the notes. -
Values are masked as the history keeps them — read the case, replace what a turn needs real,
tighten
requiredto the partials that matter, addforbidden,invariantsand judges, thenagboss check(the case is assembled with its suite,eval.case-invalid). -
--suiteis optional when the application has one; itsstartmust be the execution's ([eval.case-start-mismatch]). Errors: an id two tenants kept needs--tenant(records.ambiguous-execution, exit 2), an unknown one isrecords.not-found(exit 3), an application that keeps nothingrecords.not-kept(exit 3), a case name the suite has exit 4. - The draft is plain data by component name (
evalCaseDraftOf,agboss/internal), so a page can save a trace as a case the same way.
agboss eval calibrate --report .artifacts/eval/main.json # labels: eval/labels/<suite>.jsonl
agboss eval calibrate --report .artifacts/eval/main.json --labels labels/refunds.jsonlA model judge in the cascade is only as good as its agreement with people. People label the judged
runs of an artifact — one JSON line each (EvalLabel, agboss/eval), in eval/labels/<suite>.jsonl
by default, a file that grows over many runs:
{"runId":"refund-within-policy#2","executionId":"0f3c…","judge":"answer-grounded-judge","labeler":"anna","passed":false,"score":2,"failureTags":["invents-fee"],"note":"invents a fee"}| Field | |
|---|---|
runId |
the case and its run as the artifact numbers them, <case>#<run>
|
executionId |
the run's last execution — the one its judges judged (executionId of the run in the artifact) |
judge |
the judge's name
|
labeler |
who labelled it |
passed |
the person's verdict — what κ reads |
score? |
1–4, how good the answer is: only for the scores view |
failureTags |
why it failed, in the labeler's words ([] for none) |
note? |
free text |
agboss eval calibrate reads the artifact and the labels — it loads no application and calls no
model — and prints, per judge that has labels:
refunds-eval-suite against eval/labels/refunds-eval-suite.jsonl: 120 labels, 118 matched, 2 of other runs or unjudged
answer-grounded-judge n=60 κ(judge, anna)=0.71 κ(judge, ben)=0.64 κ(anna, ben)=0.78
anna anna passed anna failed
judge passed 38 7
judge failed 2 13
…
9 disagreements: judge passed / labeler failed 7, judge failed / labeler passed 2
refund-within-policy#2 anna: judge passed, labeler failed [invents-fee] — invents a fee
length: 9 disagreements, 51 agreements — mean 940.00 on disagreements, 410.00 on agreements
grounded: 9 disagreements, 51 agreements — disagreed/agreed by value: false 6/3, true 3/48
scores 1–4: judge passed 6·3·9·27, judge failed 10·4·1·0
-
κ is Cohen's, pairwise (#396 S2 Q8): the judge with each labeler, then every two labelers on the
runs both labelled — how far they agree beyond what their own pass rates give by chance;
κ=—when a rater gave one verdict only. The labelers' κ is the ceiling: a judge cannot be held to more agreement than people have with each other. - The confusion matrix per labeler, and every disagreement (the run, the labeler, the judge's metrics, the tags and the note).
-
The rubric: each key of the judge's
metrics(JudgeVerdict.passed({ metrics })) over the disagreements and the agreements — a text or flag metric by value, a numeric one by its mean on each side — so the measure that goes with the judge being wrong stands out (above: long answers). -
Scores: with a
scorein any label, how the scores spread over the runs the judge passed and failed.
Labels of executions the artifact does not have, or of a judge that gave no verdict there (skipped
by the cascade), are counted as unmatched — never an error. A line that is not a label, a second
label of one run by one labeler for one judge, or a runId that is not its execution's run in this
artifact is eval.labels-invalid (exit 2, the line named); no file is eval.labels-not-found.
--json carries the calibration — EvalCalibration (suite, labels, matched, unmatched,
judges: each EvalJudgeCalibration with n, labelers, agreements (EvalRaterAgreement:
raters, n, observed, kappa?), confusion, disagreements, metrics, scores?), all public
types in agboss/eval.
AGBoss
- Home
- Delivery Pipeline
- Architecture
- Configuration
- Environments
- Workflow — the paths
- Path scope
- Workflow as a step
- Parallel — quorums and arbiters
- Routing patterns
- Rollback — declared compensation
- Routers
- Guards
- CLI — agboss
- App — @Application and createApp
- Several instances
- Execution history
- Mesh Hub — agboss hub
- Health checks
- Context and Authorization
- Testing
- The test split
- Suites and Comparisons
- Eval suites — @EvalSuite
- Components
- Naming
- Standard DTOs
- Examples: job-scout, flow-patterns
- Agents — the loop
- Model providers
- Tools
- Knowledge bases (RAG)
- A2A — agent to agent
- FinOps
- Tracing
- Observability
- Terns and evaluation
- Profiles and comparisons
- Pauses — pending, resume by id
- Pause seam
- Decisions (ADR)