Skip to content

Commit 6ebfe82

Browse files
authored
Merge pull request #165 from vercel-labs/jude/agent-eval-eval-staleness
[agent-eval] Incremental eval staleness: status / run + content-aware fingerprints
2 parents b8bd4ec + b35873c commit 6ebfe82

8 files changed

Lines changed: 740 additions & 124 deletions

File tree

.changeset/eval-staleness.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@vercel/agent-eval": minor
3+
---
4+
5+
Incremental eval-staleness workflow, so adopting a changed or new eval doesn't force re-running every experiment.
6+
7+
- Fingerprint split: each result stores a content-only hash next to the combined (content+config) one. A real eval change is never masked; a benign config change (e.g. a `timeout` bump, or pinning a judge) is carried forward by `refingerprint` instead of re-running. Existing `fingerprint` values are byte-identical, so caches stay valid. (Fixes the previous re-fingerprinting that silently re-stamped every result and hid eval changes.)
8+
- `agent-eval status` — read-only: which evals are new vs changed, per experiment (classified by content). `--check` exits non-zero on any new/changed eval (a simple CI gate); `--json` emits per-experiment new/changed so a consumer can apply its own "which staleness is acceptable" policy.
9+
- `agent-eval run <experiments...>` — run the named experiments' new/changed evals (auto-carries config-only changes first).
10+
- Bare `agent-eval` shows status, then (in a terminal) lets you multi-select which experiments to run — it never re-runs everything.
11+
- Removes `run-all` and `--dry` (the run-everything-when-stale behavior). There is no in-framework "acknowledge/keep" — staleness acceptance is the consumer's policy (e.g. filter `status --json` against an accepted-stale list in CI).

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,54 @@ On subsequent runs, evals with a matching fingerprint and a valid cached result
541541

542542
Use `--force` to bypass fingerprinting and re-run everything. Functions like `setup` and `editPrompt` cannot be hashed, so use `--force` when you change those.
543543

544+
Each result also stores a `contentFingerprint` — a hash of the eval files **only**, independent of config. This separates "the eval itself changed" from "a config field changed."
545+
546+
### Carrying forward config-only changes
547+
548+
A benign config change (e.g. bumping `timeout`) changes the combined fingerprint and would otherwise re-run every eval. `agent-eval refingerprint` carries those forward in the cached results **without masking a real eval change**:
549+
550+
```bash
551+
agent-eval refingerprint # all experiments
552+
agent-eval refingerprint cc --dry # preview one experiment
553+
```
554+
555+
For each cached result it compares the eval's current `contentFingerprint` to the stored one: if the content is unchanged it re-stamps the combined fingerprint (the result stays cached); if the content **changed** it leaves the result stale so it re-runs. `agent-eval status` already classifies by eval *content*, so it never reports a config-only change as work — run `refingerprint` after editing an experiment config to carry that change into the cache (`run` does this automatically).
556+
557+
### After changing or syncing evals: status → pick what to run
558+
559+
Run `agent-eval` with no arguments. It shows the work, then — in a terminal — lets you multi-select which experiments to run. It never re-runs everything:
560+
561+
```bash
562+
agent-eval
563+
```
564+
```
565+
Evals needing work:
566+
new agent-026-no-serial-await
567+
changed agent-024-avoid-redundant-usestate
568+
569+
Work to do — 6 run(s) across 3 experiment(s):
570+
claude-opus-4.6 2 to run (22 up to date)
571+
...
572+
573+
Pick experiments to run:
574+
1 claude-opus-4.6
575+
2 claude-sonnet-4.6
576+
Numbers (e.g. 1,3), "all", or Enter to skip:
577+
```
578+
579+
Status classifies each eval by **content**, so a benign config change (e.g. pinning a judge) is never reported as work. The same building blocks work non-interactively:
580+
581+
```bash
582+
agent-eval status # read-only: what's new/changed, per experiment
583+
agent-eval status --check # exit non-zero if anything is new/changed (simple CI gate)
584+
agent-eval status --json # machine-readable, for custom CI policy
585+
agent-eval run claude-sonnet-4.6 # run the named experiment(s) — new/changed evals only
586+
```
587+
588+
**Accepting staleness is the consumer's call, not the framework's.** `agent-eval` only *reports* — it has no `keep`/`acknowledge`. If you want to leave some experiments on an older eval while keeping others fresh, do that in your own CI: read `agent-eval status --json` (per-experiment `new`/`changed`) and fail only on experiments not in your accepted-stale list. (See next-evals-oss's `scripts/check-stale.mjs` for an example.)
589+
590+
> `refingerprint` (carry config-only changes forward) runs automatically inside `run`; your sync script should call `agent-eval refingerprint` after pulling evals so committed results pick up benign config changes without re-running.
591+
544592
## Failure Classification
545593

546594
When evals fail, the framework optionally classifies each failure as one of:

packages/agent-eval/src/cli.test.ts

Lines changed: 166 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
22
import { execSync } from 'child_process';
3-
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs';
3+
import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs';
44
import { join, resolve } from 'path';
55
import { fileURLToPath } from 'url';
66
import { dirname } from 'path';
7+
import { computeContentFingerprint, computeFingerprint } from './lib/fingerprint.js';
8+
import { loadConfig } from './lib/config.js';
79

810
const __filename = fileURLToPath(import.meta.url);
911
const __dirname = dirname(__filename);
@@ -51,9 +53,9 @@ describe('CLI', () => {
5153
});
5254

5355
describe('run command', () => {
54-
it('shows error when config file does not exist', () => {
55-
const result = runCli(['run', '/non/existent/config.ts']);
56-
expect(result.stderr).toContain('not found');
56+
it('shows error when the named experiment does not exist', () => {
57+
const result = runCli(['run', 'no-such-experiment']);
58+
expect(result.stderr.toLowerCase()).toMatch(/no experiments matched|required/);
5759
expect(result.exitCode).toBe(1);
5860
});
5961

@@ -170,4 +172,164 @@ describe('CLI', () => {
170172
expect(result.exitCode).toBe(1);
171173
});
172174
});
175+
176+
describe('refingerprint command', () => {
177+
function setupProject(): { projectDir: string; evalPath: string; summaryPath: string } {
178+
const projectDir = join(TEST_DIR, 'refp');
179+
const experimentsDir = join(projectDir, 'experiments');
180+
mkdirSync(experimentsDir, { recursive: true });
181+
writeFileSync(join(experimentsDir, 'cc.ts'), `export default { agent: 'claude-code', model: 'opus' };`);
182+
const evalDir = join(projectDir, 'evals', 'eval-1');
183+
mkdirSync(evalDir, { recursive: true });
184+
writeFileSync(join(evalDir, 'PROMPT.md'), 'do it');
185+
writeFileSync(join(evalDir, 'EVAL.ts'), 'test code');
186+
writeFileSync(join(evalDir, 'package.json'), '{"type":"module"}');
187+
const summaryDir = join(projectDir, 'results', 'cc', '2026-01-01T00-00-00.000Z', 'eval-1');
188+
mkdirSync(summaryDir, { recursive: true });
189+
return { projectDir, evalPath: evalDir, summaryPath: join(summaryDir, 'summary.json') };
190+
}
191+
192+
it('carries forward a config-only change but never masks a content change', () => {
193+
const { projectDir, evalPath, summaryPath } = setupProject();
194+
const currentContent = computeContentFingerprint(evalPath);
195+
196+
// Config-only change: stored content fp matches current, combined is stale.
197+
writeFileSync(
198+
summaryPath,
199+
JSON.stringify({
200+
totalRuns: 1, passedRuns: 1, passRate: '100%', meanDuration: 1,
201+
fingerprint: 'STALE_COMBINED', contentFingerprint: currentContent,
202+
})
203+
);
204+
let r = runCli(['refingerprint'], projectDir);
205+
expect(r.exitCode).toBe(0);
206+
let summary = JSON.parse(readFileSync(summaryPath, 'utf-8'));
207+
expect(summary.fingerprint).not.toBe('STALE_COMBINED'); // carried forward
208+
expect(summary.fingerprint).toMatch(/^[a-f0-9]{64}$/);
209+
expect(summary.contentFingerprint).toBe(currentContent); // content untouched
210+
211+
// Content change: stored content fp differs → must be left stale, NOT masked.
212+
writeFileSync(
213+
summaryPath,
214+
JSON.stringify({
215+
totalRuns: 1, passedRuns: 1, passRate: '100%', meanDuration: 1,
216+
fingerprint: 'OLD_COMBINED', contentFingerprint: 'OLD_CONTENT',
217+
})
218+
);
219+
r = runCli(['refingerprint'], projectDir);
220+
expect(r.exitCode).toBe(0);
221+
summary = JSON.parse(readFileSync(summaryPath, 'utf-8'));
222+
expect(summary.fingerprint).toBe('OLD_COMBINED'); // NOT re-stamped
223+
expect(summary.contentFingerprint).toBe('OLD_CONTENT');
224+
});
225+
226+
it('--dry does not write', () => {
227+
const { projectDir, evalPath, summaryPath } = setupProject();
228+
const currentContent = computeContentFingerprint(evalPath);
229+
writeFileSync(
230+
summaryPath,
231+
JSON.stringify({
232+
totalRuns: 1, passedRuns: 1, passRate: '100%', meanDuration: 1,
233+
fingerprint: 'STALE_COMBINED', contentFingerprint: currentContent,
234+
})
235+
);
236+
const r = runCli(['refingerprint', '--dry'], projectDir);
237+
expect(r.exitCode).toBe(0);
238+
const summary = JSON.parse(readFileSync(summaryPath, 'utf-8'));
239+
expect(summary.fingerprint).toBe('STALE_COMBINED'); // unchanged under --dry
240+
});
241+
});
242+
243+
describe('staleness flow (status / refingerprint / --check / --json)', () => {
244+
it('fresh → change → status + --check + --json flag it; refingerprint stays honest; a rerun clears it', async () => {
245+
const projectDir = join(TEST_DIR, 'flow');
246+
const experimentsDir = join(projectDir, 'experiments');
247+
mkdirSync(experimentsDir, { recursive: true });
248+
writeFileSync(join(experimentsDir, 'cc.ts'), `export default { agent: 'claude-code', model: 'opus' };`);
249+
const evalDir = join(projectDir, 'evals', 'eval-1');
250+
mkdirSync(evalDir, { recursive: true });
251+
writeFileSync(join(evalDir, 'PROMPT.md'), 'do it');
252+
writeFileSync(join(evalDir, 'EVAL.ts'), 'v1');
253+
writeFileSync(join(evalDir, 'package.json'), '{"type":"module"}');
254+
const summaryPath = join(projectDir, 'results', 'cc', '2026-01-01T00-00-00.000Z', 'eval-1', 'summary.json');
255+
mkdirSync(dirname(summaryPath), { recursive: true });
256+
257+
const config = await loadConfig(join(experimentsDir, 'cc.ts'));
258+
const modelConfig = { ...config, model: Array.isArray(config.model) ? config.model[0] : config.model };
259+
const seedFresh = () =>
260+
writeFileSync(
261+
summaryPath,
262+
JSON.stringify({
263+
totalRuns: 1, passedRuns: 1, passRate: '100%', meanDuration: 1,
264+
fingerprint: computeFingerprint(evalDir, modelConfig as never),
265+
contentFingerprint: computeContentFingerprint(evalDir),
266+
})
267+
);
268+
269+
// 1. Fresh → status clean, --check passes.
270+
seedFresh();
271+
expect(runCli(['status'], projectDir).stdout).toContain('up to date');
272+
expect(runCli(['status', '--check'], projectDir).exitCode).toBe(0);
273+
274+
// 2. Eval content changes → status flags it, --check fails, --json reports it.
275+
writeFileSync(join(evalDir, 'EVAL.ts'), 'v2');
276+
const s = runCli(['status'], projectDir).stdout;
277+
expect(s).toContain('changed');
278+
expect(s).toContain('eval-1');
279+
expect(runCli(['status', '--check'], projectDir).exitCode).toBe(1);
280+
const json = JSON.parse(runCli(['status', '--json'], projectDir).stdout);
281+
expect(json.work).toEqual([{ experiment: 'cc', new: [], changed: ['eval-1'] }]);
282+
283+
// 3. refingerprint must NOT mask a content change — still failing.
284+
runCli(['refingerprint'], projectDir);
285+
expect(runCli(['status', '--check'], projectDir).exitCode).toBe(1);
286+
287+
// 4. A rerun (simulated by re-seeding fresh for the new content) clears it.
288+
seedFresh();
289+
expect(runCli(['status', '--check'], projectDir).exitCode).toBe(0);
290+
});
291+
292+
it('status reports new and changed evals as the work to do', async () => {
293+
const projectDir = join(TEST_DIR, 'status');
294+
const experimentsDir = join(projectDir, 'experiments');
295+
mkdirSync(experimentsDir, { recursive: true });
296+
writeFileSync(join(experimentsDir, 'cc.ts'), `export default { agent: 'claude-code', model: 'opus' };`);
297+
const evalDir = join(projectDir, 'evals', 'eval-1');
298+
mkdirSync(evalDir, { recursive: true });
299+
writeFileSync(join(evalDir, 'PROMPT.md'), 'do it');
300+
writeFileSync(join(evalDir, 'EVAL.ts'), 'v1');
301+
writeFileSync(join(evalDir, 'package.json'), '{"type":"module"}');
302+
303+
const config = await loadConfig(join(experimentsDir, 'cc.ts'));
304+
const modelConfig = { ...config, model: Array.isArray(config.model) ? config.model[0] : config.model };
305+
const sp = join(projectDir, 'results', 'cc', '2026-01-01T00-00-00.000Z', 'eval-1', 'summary.json');
306+
mkdirSync(dirname(sp), { recursive: true });
307+
writeFileSync(
308+
sp,
309+
JSON.stringify({
310+
totalRuns: 1, passedRuns: 1, passRate: '100%', meanDuration: 1,
311+
fingerprint: computeFingerprint(evalDir, modelConfig as never),
312+
contentFingerprint: computeContentFingerprint(evalDir),
313+
})
314+
);
315+
316+
// Up to date.
317+
expect(runCli(['status'], projectDir).stdout).toContain('up to date');
318+
319+
// Add a NEW eval (no result) + CHANGE the existing one.
320+
const evalDir2 = join(projectDir, 'evals', 'eval-2');
321+
mkdirSync(evalDir2, { recursive: true });
322+
writeFileSync(join(evalDir2, 'PROMPT.md'), 'do it');
323+
writeFileSync(join(evalDir2, 'EVAL.ts'), 'x');
324+
writeFileSync(join(evalDir2, 'package.json'), '{"type":"module"}');
325+
writeFileSync(join(evalDir, 'EVAL.ts'), 'v2');
326+
327+
const out = runCli(['status'], projectDir).stdout;
328+
expect(out).toContain('new'); // eval-2
329+
expect(out).toContain('eval-2');
330+
expect(out).toContain('changed'); // eval-1
331+
expect(out).toContain('eval-1');
332+
expect(out).toContain('to run');
333+
});
334+
});
173335
});

0 commit comments

Comments
 (0)