Agents · 2026-06-05 · By RedDB team · 4 min read
Eval datasets in RedDB — from ad-hoc prompts to versioned regression sets
Most teams evaluate agents with a notebook full of one-off prompts that nobody re-runs. Capture every interesting turn into a RedDB table, tag it, version it, and your eval becomes a query that any model upgrade can be diffed against in seconds.
The notebook eval is a dead end
Pick any team running an agent in production for more than a quarter and look at how they evaluate it. You will find a Jupyter notebook, an evals.py, or a folder of .txt prompts. Someone wrote them the week the agent shipped. Nobody has re-run them in two months. When a new model drops — Claude Opus 4.7 last month, Sonnet 4.6 the week before — the same notebook gets opened, a few cells get edited, results are eyeballed, “looks fine,” done.
That is not an eval. That is a memory of an eval.
The problems compound:
- No baseline. The output of last month’s run is gone. You cannot diff.
- No coverage signal. Which production scenarios are covered? No one knows. The prompts that matter — the ones that broke last quarter — are not in the file.
- No tagging. “Show me the long-context cases” requires
grepover a folder. - No fresh data. Every interesting failure the agent had this sprint is in a Slack thread, not in the eval set.
The fix is the one a database engineer would write on a napkin: every interesting interaction is a row, tagging is a column, versioning is a dataset_version table, replay is a SELECT … WHERE tag = 'long-context', and the model upgrade is a JOIN between two run tables. Builds on the memory schema from D1, the observability tables from D9, and the dispatch queue from D12 (workers replay the set in parallel).
The schema: cases, datasets, runs
Three tables. Cases are the facts. Datasets are versioned snapshots. Runs are the result of replaying a dataset against one model + prompt revision.
-- A single captured interaction. Append-only.
CREATE TABLE eval_case (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source text NOT NULL, -- 'production' | 'incident' | 'handcrafted'
prompt text NOT NULL, -- full user turn, including system context
context jsonb NOT NULL DEFAULT '{}', -- files, tool history, env
reference text, -- gold answer if known; NULL = judged at run time
tags text[] NOT NULL DEFAULT '{}', -- ['long-context','tool-use','regression-#1284']
captured_at timestamptz NOT NULL DEFAULT now(),
captured_by text NOT NULL, -- agent id or user
embedding vector(1024) -- voyage-3, for "find similar cases"
);
CREATE INDEX eval_case_tags_idx ON eval_case USING gin (tags);
CREATE INDEX eval_case_embed_idx ON eval_case USING hnsw (embedding vector_cosine_ops);
-- A frozen, named snapshot of cases. Immutable once published.
CREATE TABLE eval_dataset (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL, -- 'core-v3', 'long-context-2026-q2'
version int NOT NULL,
case_ids uuid[] NOT NULL, -- locked at publish
notes text,
published_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (name, version)
);
-- One row per (dataset_version, case, model+prompt revision) replay.
CREATE TABLE eval_run (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
dataset_id uuid NOT NULL REFERENCES eval_dataset(id),
case_id uuid NOT NULL REFERENCES eval_case(id),
model text NOT NULL, -- 'claude-opus-4-7'
prompt_rev text NOT NULL, -- git sha of the system prompt
output text NOT NULL,
pass boolean, -- NULL if judged later
judge_score numeric(3,2), -- 0.00–1.00
judge_reason text,
latency_ms int NOT NULL,
input_tokens int NOT NULL,
output_tokens int NOT NULL,
cost_usd numeric(10,6) NOT NULL,
ran_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX eval_run_dataset_idx ON eval_run (dataset_id, model, prompt_rev); Three design choices to call out:
- Cases are append-only. A captured case never mutates. If the gold answer was wrong, you write a new case and tag the old one
superseded. This is how you get clean diffs across versions. - Datasets freeze
case_ids. Publishing a dataset is a snapshot. Six months from now you can reruncore-v3and the membership has not drifted. prompt_revis the git SHA of the system prompt. Without it, a regression looks like a model regression when it is actually a prompt edit. Always log both.
Capture: the hook that fills the table
Capturing has to be cheap or nobody does it. A PostToolUse hook with a slash-command escape hatch covers both flows:
- Auto-capture any turn that ends with an explicit user thumbs-down (
/eval-bad) or a hook-detected failure (timeout, retry-exhausted, judge score < threshold). - Manual capture via
/eval-capture <tag1,tag2>when the human notices something worth keeping.
/eval-capture is one shell script. It reads the current conversation tail, embeds the prompt with voyage-3, and INSERTs:
#!/usr/bin/env bash
# ~/.claude/commands/eval-capture.sh
set -euo pipefail
tags="${1:-handcrafted}"
prompt="$(claude-cli conversation tail --turns 1 --format text)"
context="$(claude-cli context dump --format json)"
embedding="$(curl -s https://api.voyageai.com/v1/embeddings \
-H "Authorization: Bearer $VOYAGE_API_KEY" \
-H 'Content-Type: application/json' \
-d "$(jq -nc --arg i "$prompt" \
'{model:"voyage-3", input:[$i]}')" \
| jq -c '.data[0].embedding')"
psql "$REDDB_URL" -v ON_ERROR_STOP=1 <<SQL
INSERT INTO eval_case (source, prompt, context, tags, captured_by, embedding)
VALUES (
'handcrafted',
\$\$${prompt//\$\$/\$\$\$\$}\$\$,
'${context}'::jsonb,
string_to_array('${tags}', ',')::text[],
'${USER}',
'${embedding}'::vector
);
SQL
echo "captured to eval_case (tags: ${tags})" PostToolUse is the same idea wrapped around a failure detector — drop in the transactional-hook pattern from D3 so a half-written embedding never lands without its row.
Publish: freezing a dataset
A dataset is a one-line query plus an insert:
-- Pin everything captured this quarter that is not superseded.
WITH picked AS (
SELECT id FROM eval_case
WHERE captured_at >= '2026-04-01'
AND NOT ('superseded' = ANY(tags))
)
INSERT INTO eval_dataset (name, version, case_ids, notes)
SELECT 'core', 4,
array_agg(id ORDER BY captured_at),
'Q2 2026 baseline, includes the 1284 long-context regression'
FROM picked; Membership is now frozen. Anyone — six months, three model upgrades, two prompt rewrites from now — can rerun core v4 and get the same case set.
Replay: the eval is a query
Replay is a function over (dataset_id, model, prompt_rev). The naive single-process version is fine for ~200 cases; for anything bigger, push each case into the dispatch queue from D12 and let N workers chew through it in parallel.
// scripts/replay.ts
import { Client } from 'pg'
import Anthropic from '@anthropic-ai/sdk'
const db = new Client({ connectionString: process.env.REDDB_URL })
const ai = new Anthropic()
const MODEL = process.env.MODEL ?? 'claude-opus-4-7'
const PROMPT_REV = process.env.PROMPT_REV ?? execSyncSha('prompts/system.md')
const DATASET = process.env.DATASET ?? 'core'
const VERSION = Number(process.env.VERSION ?? 4)
await db.connect()
const { rows: [ds] } = await db.query(
`SELECT id, case_ids FROM eval_dataset WHERE name=$1 AND version=$2`,
[DATASET, VERSION],
)
const { rows: cases } = await db.query(
`SELECT id, prompt, reference FROM eval_case WHERE id = ANY($1::uuid[])`,
[ds.case_ids],
)
for (const c of cases) {
const t0 = Date.now()
const res = await ai.messages.create({
model: MODEL,
max_tokens: 2048,
system: await fs.readFile('prompts/system.md', 'utf8'),
messages: [{ role: 'user', content: c.prompt }],
})
const out = res.content.map(b => b.type === 'text' ? b.text : '').join('')
const { pass, score, reason } = c.reference
? exactMatch(out, c.reference)
: await llmJudge(out, c.prompt) // separate small-model call
await db.query(
`INSERT INTO eval_run
(dataset_id, case_id, model, prompt_rev, output,
pass, judge_score, judge_reason, latency_ms,
input_tokens, output_tokens, cost_usd)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
[ds.id, c.id, MODEL, PROMPT_REV, out,
pass, score, reason, Date.now() - t0,
res.usage.input_tokens, res.usage.output_tokens, priceUsd(res.usage, MODEL)],
)
} There is no eval framework here. There is a for loop and an INSERT. That is the whole point — the eval is the SQL you write afterwards.
The eval is the SQL afterwards
This is what you actually wanted all along.
-- Pass rate per model on the latest core dataset
SELECT model, prompt_rev,
count(*) AS n,
avg(pass::int)::numeric(4,3) AS pass_rate,
avg(judge_score)::numeric(4,3) AS mean_score,
sum(cost_usd)::numeric(8,2) AS run_cost
FROM eval_run r
JOIN eval_dataset d ON d.id = r.dataset_id
WHERE d.name = 'core' AND d.version = 4
GROUP BY model, prompt_rev
ORDER BY pass_rate DESC; -- Regressions: cases the new model fails that the old one passed
WITH old AS (
SELECT case_id, pass FROM eval_run
WHERE model = 'claude-opus-4-6' AND prompt_rev = 'a1b2c3'
), new AS (
SELECT case_id, pass, output FROM eval_run
WHERE model = 'claude-opus-4-7' AND prompt_rev = 'a1b2c3'
)
SELECT c.id, c.prompt, c.tags, new.output
FROM eval_case c
JOIN old ON old.case_id = c.id
JOIN new ON new.case_id = c.id
WHERE old.pass AND NOT new.pass
ORDER BY 'regression' = ANY(c.tags) DESC; -- Coverage by tag — which areas of the dataset are thin?
SELECT unnest(tags) AS tag, count(*) AS cases
FROM eval_case
WHERE id = ANY((SELECT case_ids FROM eval_dataset WHERE name='core' AND version=4))
GROUP BY 1
ORDER BY 2 DESC; -- "Find me cases like the one that just broke production"
SELECT id, prompt, tags
FROM eval_case
ORDER BY embedding <=> (SELECT embedding FROM eval_case WHERE id = $1)
LIMIT 10; That last one is the killer feature CLI evals do not have. When a production failure lands, you embed the failing prompt and the database hands you the ten most-similar historical cases — your regression set writes itself.
When this is overkill
- Solo prototype, two prompts. Stay in a notebook. You will outgrow it; you have not yet.
- No judge signal. If neither exact-match nor an LLM judge can score the output, the table is just a log. Fix the judging problem before the storage problem.
- Daily replay against a frontier model. Cost discipline matters more than capture discipline. Sample first, run nightly on a small slice, full runs only on model bumps and prompt-revision PRs.
The shape we end up with — cases are facts, datasets are snapshots, runs are joins — is the same shape every mature evaluation system has, from ML research benchmarks to compiler test suites. The novelty is just that the agent feeds itself: every interesting turn it ever has becomes a row that the next model release has to answer for.
RedDB Cloud
Join the private beta.
One-click managed deploy. Free during beta. Founding pricing locked at GA.
Request access →