[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build an AI Agent Evaluation Framework

Build an AI Agent Evaluation Framework

Build an evaluation framework for AI agents with task completion scoring, hallucination detection, tool use accuracy, latency benchmarking, and regression testing for agent quality assurance.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 96/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

redis

v1.1.0

Redis is an in-memory data store used as a cache, session store, message broker and counter service. Use when adding a cache layer, storing sessions, rate limiting an API, running pub/sub or Streams with consumer groups, taking a distributed lock, building a leaderboard, or configuring Redis for production (persistence, maxmemory, eviction).

93/100 quality
1.79× impact
SAFE· stale score
View skill
>

postgresql

v1.1.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

100/100 quality
5.15× impact
SAFE· stale score
View skill
>

hono

v1.1.0

Hono is a small web framework built on Web Standards (Request/Response) that runs unchanged on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda and Vercel. Use it when the user asks to build a Hono API, add Zod validation, JWT auth, CORS or middleware, use the type-safe RPC client (hc), render JSX, stream responses, or deploy one codebase to several runtimes.

100/100 quality
6.20× impact
SAFE· stale score
View skill
>

zod

v1.1.0

Zod is a TypeScript-first schema library that validates data at runtime and infers static types from the same schema. Use when validating API request bodies, form input, environment variables or config files, parsing untrusted JSON, or migrating from Zod 3 to Zod 4. Covers z.object, safeParse, z.infer, coercion, discriminated unions, refinements, JSON Schema output and the v4 API changes.

93/100 quality
3.87× impact
SAFE· stale score
View skill
$

The Problem

Rita leads AI at a 25-person company building customer support agents. They ship agent updates weekly but have no systematic way to measure quality. Last week, a prompt change improved answer accuracy but introduced hallucinated product features. Tool calls (search, order lookup) sometimes fail and nobody knows which ones. Latency swings from 2 seconds to 30 with no tracking. When an agent gets worse, they find out from customer complaints, not from tests. They need automated test suites, checks for invented claims, tool-use accuracy, latency numbers, and an alert when a new version scores worse than the last one.

The Solution

Store eval cases and run history in PostgreSQL. A runner replays each case against the agent, which is any async function returning the reply text and the names of the tools it called. It scores the reply with deterministic checks and writes a run record. If the score drops or forbidden claims rise against the previous run, it pushes an alert onto a Redis list. A small Hono service, validated with Zod, triggers runs from CI and returns the version history.

Deterministic checks are cheap, repeatable and good at pinning known failures: a feature you do not sell, a tool that must be called. They do not judge open-ended quality, and a substring check cannot read negation: a mustNotContain of "dark mode" also fails the reply "we do not offer dark mode", so phrase forbidden claims as the specific wording the agent used when it was wrong. Add model-graded cases separately when you need that, and keep the deterministic ones as the regression floor.

Step-by-Step Walkthrough

Step 1: Create the tables

bash
npm install hono @hono/node-server zod pg ioredis
npm install -D typescript @types/node @types/pg

jsonb columns come back from pg as parsed objects, so no JSON.parse is needed.

sql
CREATE TABLE eval_cases (
  id          text PRIMARY KEY,
  suite       text NOT NULL,
  name        text NOT NULL,
  input       jsonb NOT NULL,   -- {"messages":[{"role":"user","content":"..."}]}
  expected    jsonb NOT NULL,   -- {"mustContain":[],"mustNotContain":[],"expectedTools":[],"maxLatencyMs":8000}
  weight      int NOT NULL DEFAULT 1
);

CREATE TABLE eval_runs (
  id            text PRIMARY KEY,
  suite         text NOT NULL,
  agent_version text NOT NULL,
  summary       jsonb NOT NULL,
  results       jsonb NOT NULL,
  completed_at  timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX eval_runs_suite_time ON eval_runs (suite, completed_at DESC);

Step 2: Build the evaluation engine

typescript
// src/eval.ts
import { randomBytes } from "node:crypto";
import { Pool } from "pg";
import { Redis } from "ioredis";

export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = new Redis(process.env.REDIS_URL!);

export type Message = { role: "user" | "assistant"; content: string };
export type AgentFn = (messages: Message[]) => Promise<{ response: string; toolCalls: string[] }>;

interface Expected {
  mustContain?: string[];
  mustNotContain?: string[];
  expectedTools?: string[];
  maxLatencyMs?: number;
}
interface CaseRow { id: string; name: string; input: { messages: Message[] }; expected: Expected }
interface Result {
  caseId: string; passed: boolean; score: number; latencyMs: number;
  forbiddenClaim: boolean; toolsOk: boolean; issues: string[];
}
export interface Summary {
  total: number; passed: number; avgScore: number;
  forbiddenClaimRate: number; toolAccuracy: number; p95LatencyMs: number;
}

const has = (text: string, phrase: string) => text.toLowerCase().includes(phrase.toLowerCase());

function p95(values: number[]) {
  const sorted = [...values].sort((a, b) => a - b);
  return sorted.length ? sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)] : 0;
}

async function runCase(tc: CaseRow, agent: AgentFn): Promise<Result> {
  const issues: string[] = [];
  const start = Date.now();
  let score = 1;
  let forbiddenClaim = false;
  let toolsOk = true;
  try {
    const { response, toolCalls } = await agent(tc.input.messages);
    const latencyMs = Date.now() - start; // measured here, not trusted from the agent
    for (const p of tc.expected.mustContain ?? []) {
      if (!has(response, p)) { issues.push(`Missing: "${p}"`); score -= 0.2; }
    }
    for (const p of tc.expected.mustNotContain ?? []) {
      if (has(response, p)) { issues.push(`Forbidden claim: "${p}"`); forbiddenClaim = true; score -= 0.3; }
    }
    for (const t of tc.expected.expectedTools ?? []) {
      if (!toolCalls.includes(t)) { issues.push(`Tool not called: ${t}`); toolsOk = false; score -= 0.2; }
    }
    if (tc.expected.maxLatencyMs && latencyMs > tc.expected.maxLatencyMs) {
      issues.push(`Latency ${latencyMs}ms > ${tc.expected.maxLatencyMs}ms`); score -= 0.1;
    }
    score = Math.max(0, score);
    return { caseId: tc.id, passed: score >= 0.7 && !forbiddenClaim, score, latencyMs, forbiddenClaim, toolsOk, issues };
  } catch (err) {
    return {
      caseId: tc.id, passed: false, score: 0, latencyMs: Date.now() - start,
      forbiddenClaim: false, toolsOk: false, issues: [`Error: ${(err as Error).message}`],
    };
  }
}

export async function runEvalSuite(suite: string, agent: AgentFn, agentVersion: string) {
  const { rows } = await pool.query<CaseRow>(
    "SELECT id, name, input, expected FROM eval_cases WHERE suite = $1 ORDER BY weight DESC, id", [suite]);
  if (rows.length === 0) throw new Error(`No eval cases for suite "${suite}"`);

  const results: Result[] = [];
  for (const tc of rows) results.push(await runCase(tc, agent)); // sequential: keeps latency numbers honest

  const withTools = rows.filter((r) => r.expected.expectedTools?.length);
  const toolHits = results.filter((r, i) => rows[i].expected.expectedTools?.length && r.toolsOk).length;
  const summary: Summary = {
    total: results.length,
    passed: results.filter((r) => r.passed).length,
    avgScore: results.reduce((s, r) => s + r.score, 0) / results.length,
    forbiddenClaimRate: (results.filter((r) => r.forbiddenClaim).length / results.length) * 100,
    toolAccuracy: withTools.length ? (toolHits / withTools.length) * 100 : 100,
    p95LatencyMs: p95(results.map((r) => r.latencyMs)),
  };

  // Read the previous run before inserting this one
  const { rows: [prev] } = await pool.query<{ summary: Summary }>(
    "SELECT summary FROM eval_runs WHERE suite = $1 ORDER BY completed_at DESC LIMIT 1", [suite]);

  const id = `eval-${randomBytes(6).toString("hex")}`;
  await pool.query(
    "INSERT INTO eval_runs (id, suite, agent_version, summary, results) VALUES ($1, $2, $3, $4, $5)",
    [id, suite, agentVersion, JSON.stringify(summary), JSON.stringify(results)]);

  const regressed = !!prev && (
    summary.avgScore < prev.summary.avgScore - 0.05 ||
    summary.forbiddenClaimRate > prev.summary.forbiddenClaimRate + 2 ||
    summary.toolAccuracy < prev.summary.toolAccuracy - 5);
  if (regressed) {
    await redis.rpush("notification:queue", JSON.stringify({ type: "eval_regression", suite, agentVersion, current: summary, previous: prev.summary }));
  }
  return { id, summary, results, regressed };
}

export async function versionHistory(suite: string, limit = 10) {
  const { rows } = await pool.query(
    "SELECT agent_version AS version, summary, completed_at AS date FROM eval_runs WHERE suite = $1 ORDER BY completed_at DESC LIMIT $2",
    [suite, limit]);
  return rows;
}

Step 3: Trigger runs from CI

agentUnderTest is your own wrapper around the real agent. Bind the service to loopback or keep it on a private network, since a run spends model tokens.

typescript
// src/server.ts
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { z } from "zod";
import { runEvalSuite, versionHistory, type AgentFn } from "./eval";

const agentUnderTest: AgentFn = async (messages) => {
  // call your agent here and return its reply plus the names of the tools it invoked
  return { response: messages.at(-1)?.content ?? "", toolCalls: [] };
};

const app = new Hono();

app.post("/eval/run", async (c) => {
  const { suite, agentVersion } = z.object({ suite: z.string(), agentVersion: z.string() }).parse(await c.req.json());
  const run = await runEvalSuite(suite, agentUnderTest, agentVersion);
  return c.json({ id: run.id, regressed: run.regressed, summary: run.summary }, run.regressed ? 409 : 200);
});

app.get("/eval/:suite/history", async (c) => c.json(await versionHistory(c.req.param("suite"))));

serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 3300 });

In CI, call POST /eval/run after deploying a preview build and fail the job on the 409 response.

Real-World Example

Rita's team seeds the support suite from real failures: one case per customer complaint, each with the order-lookup tool it should have called and the product features the agent must never promise. The next prompt change adds a feature claim that does not exist; the mustNotContain check fails that case, the run comes back 409 in CI, and the change never ships. Over the following weeks /eval/support/history shows the p95 latency and tool accuracy per version, so a slow or tool-skipping version is visible the day it is built rather than when a customer writes in.

Related Skills

  • typescript - typed cases, results and the agent contract
  • postgresql - cases and run history
  • redis - regression alert queue
  • hono - the CI-facing service
  • zod - request validation

Related use cases