- Book: AI That Ships
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Conventional monitoring answers "is it up". For an agent, it is always up. The
degradation you care about looks like: same status codes, same latency,
answers that are a bit worse, tool calls that are a bit more often wrong.
Nobody pages you. Three weeks later someone in support says "it's been useless
lately", and nobody can say when it started.
Two things fix that. Proxy signals that move before anyone complains, and a
fixed test set you can rerun to confirm what the proxies suggest.
Four proxies that move first
None of these measures quality directly. All of them correlate with it, and
all are free to collect.
1. Outcome distribution. Every run ends in exactly one terminal state.
export type Outcome =
| "complete" | "refused" | "gave_up"
| "max_turns" | "budget_exceeded" | "tool_failed" | "timeout";
metrics.increment("agent.outcome", 1, { outcome, model, promptVersion });
The share of complete is the single most useful number on the dashboard. It
moves for real reasons — a degraded model, a broken tool, a prompt change, and
it moves days before anyone files a ticket.
2. Turns to completion. An agent that needs more steps for the same work
is getting worse at the work.
metrics.histogram("agent.turns", turns, { intent, model });
Watch p50, not the mean. A p50 moving from 4 to 6 is a real regression even
while the mean stays flat.
3. Tool-call validity. How often the model produces arguments that fail
schema validation.
const parsed = schema.safeParse(call.input);
metrics.increment("agent.tool_args", 1, {
tool: call.name,
valid: String(parsed.success),
});
This one is unusually sensitive. A model that has quietly changed will fail
schema validation at a measurably higher rate before its prose gets noticeably
worse.
4. Retry and self-correction rate. How often the agent calls the same tool
twice with slightly different arguments, or contradicts itself between turns.
const repeats = countRepeatedCalls(trace); // same tool, similar args
metrics.histogram("agent.repeat_calls", repeats, { model });
The golden set confirms what proxies suspect
Proxies tell you something changed. They cannot tell you the answers got
worse. For that you need a fixed set of inputs with known-good outputs, rerun
on a schedule.
export type GoldenCase = {
id: string;
input: string;
mustCall?: string[]; // tools that should be used
mustNotCall?: string[]; // tools that must not be
assert: (out: AgentOutput) => Promise<Verdict>;
};
The assertion is the hard part, and it should be as deterministic as you can
make it. Prefer checkable properties over judging prose:
{
id: "refund-under-limit",
input: "I want a refund for order ord_4471, it arrived broken",
mustCall: ["get_order", "refund_order"],
mustNotCall: ["send_email"],
assert: async (out) => {
const call = out.toolCalls.find((c) => c.name === "refund_order");
if (!call) return fail("no refund attempted");
if (call.input.orderId !== "ord_4471") return fail("wrong order");
if (call.input.amountCents !== 8900) return fail("wrong amount");
return pass();
},
}
Structural assertions like these are stable across model versions and reruns.
Assertions on wording are not — they fail on paraphrase and train you to
ignore the suite.
For cases where output quality genuinely is prose, use a model grader with a
rubric, and accept that it is noisier. Run those cases three times and take
the majority.
Run it against production config, on a schedule
// nightly, plus on every deploy that touches prompts, tools or model
export async function runGolden(): Promise<GoldenReport> {
const cases = await loadCases();
const results = await pMap(cases, async (c) => {
const out = await runAgent(c.input, prodLikeCtx()); // real prompts, stub tools
return { id: c.id, verdict: await c.assert(out), turns: out.turns };
}, { concurrency: 4 });
const passed = results.filter((r) => r.verdict.ok).length;
metrics.gauge("golden.pass_rate", passed / results.length);
return { results, passRate: passed / results.length };
}
Stubbed tools with recorded responses, so the suite measures the model and the
prompt rather than the weather in your integrations. Real system prompt, real
tool schemas, real model id.
Nightly matters because the thing you are guarding against is change you did
not make. A provider-side change lands without a deploy, and a suite that only
runs in CI will not see it.
Alert on the delta, not the level
const today = await runGolden();
const base = await loadBaseline();
const regressed = base.results.filter((b) =>
b.verdict.ok && !today.results.find((t) => t.id === b.id)?.verdict.ok);
if (regressed.length >= 2) {
await pager.notify(`golden: ${regressed.length} cases regressed`, {
cases: regressed.map((r) => r.id),
model: process.env.MODEL_ID,
});
}
Naming the specific cases is what makes the page actionable at 3am. "Pass rate
dropped to 87%" prompts a shrug; "refund-under-limit and
refund-over-limit-needs-approval both regressed" is a diagnosis.
Two cases rather than one because a single flaky case will fire eventually and
teach everyone to ignore the alert.
Version everything the output depends on
logger.info("agent run", {
runId, outcome, turns, costUsd,
model: cfg.model,
promptVersion: cfg.promptVersion,
toolsetVersion: cfg.toolsetVersion,
retrievalIndexVersion: cfg.indexVersion,
});
When quality drops, the first question is what changed. Four version fields on
every run turn that into a query. Without them it is an archaeology exercise
across deploy logs and provider changelogs.
The cheapest signal of all
Ask. One thumb up/down on the output, stored with the run id.
await db.feedback.create({ data: { runId, verdict, userId } });
Response rates are low and the sample is biased toward the annoyed. It is
still the only signal that comes from the person who knows whether the answer
was useful, and joined to the run trace it turns a complaint into a
reproducible case, which is exactly what your golden set needs more of.
If this was useful
AI That Ships covers evaluation and
monitoring for AI features — proxy metrics, golden sets and structural
assertions, regression alerting, and the versioning that makes "what changed"
answerable.
The full series is at
xgabriel.com/ai-in-typescript.



Top comments (0)