Skip to content

Commit b8bd4ec

Browse files
authored
Merge pull request #164 from vercel-labs/jude/agent-eval-pinnable-judge
[agent-eval] Pin the agentic judge to a fixed agent and model
2 parents 6867dc1 + 2905905 commit b8bd4ec

20 files changed

Lines changed: 393 additions & 21 deletions

File tree

.changeset/pinnable-judge.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@vercel/agent-eval": minor
3+
---
4+
5+
Pin the agentic LLM judge to a fixed agent + model via `ExperimentConfig.judge`. By default the `expect(environment|transcript)` matchers still self-grade with the codegen agent+model; setting `judge: { agent?, model }` grades every run with one fixed judge — the apples-to-apples choice for cross-model comparisons (judge quality no longer varies with the model under test, and a model never grades itself). When `judge.agent` names a different agent, its CLI is installed in the sandbox and its key is resolved from its own env var (falling back to `VERCEL_OIDC_TOKEN`). Pinning is reflected in the eval fingerprint, so pinned runs don't reuse self-graded cached results.

README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,22 @@ Two matchers, on either subject:
200200

201201
You supply only the **criterion** string; the framework owns the judge prompt and the verdict contract. On failure the assertion message carries the judge's reasoning, e.g. `[judge:environment] FAIL (score 0.42): product list is a Client Component`, so a failed judge clause is distinguishable from a failed deterministic test or a crash.
202202

203-
The judge uses the same agent and model as the run under test. Because each assertion is a real agent run, it costs time and tokens — keep criteria focused.
203+
By default the judge uses the **same agent and model** as the run under test (self-grading). Because each assertion is a real agent run, it costs time and tokens — keep criteria focused.
204+
205+
**Pin the judge** to grade every run with one fixed agent + model — the apples-to-apples choice when comparing models, since the judge quality no longer varies with the model under test (and a model never grades itself):
206+
207+
```typescript
208+
const config: ExperimentConfig = {
209+
agent: 'codex',
210+
model: 'gpt-5.4',
211+
// Grade with a fixed Claude judge regardless of the model under test.
212+
judge: { agent: 'vercel-ai-gateway/claude-code', model: 'claude-opus-4-8' },
213+
};
214+
```
215+
216+
- `judge.model` is required (pinning the model is the point).
217+
- `judge.agent` is optional and defaults to the codegen agent — omit it to keep the same harness and only pin the model. When it names a different agent, that agent's CLI is installed in the sandbox automatically and its key is resolved from its own env var (falling back to `VERCEL_OIDC_TOKEN`).
218+
- Pinning changes the eval fingerprint, so a pinned run won't reuse self-graded cached results.
204219

205220
> **Note**: requires `validation: 'vitest'` (the default). The framework gives the eval process the run's credentials automatically so the judge can call the agent CLI in-sandbox.
206221
@@ -279,6 +294,10 @@ const config: ExperimentConfig = {
279294
// 'changed' - copy only files modified by the agent
280295
// 'all' - copy the entire project including original fixture files
281296
copyFiles: 'changed',
297+
298+
// Pin the agentic LLM judge (see "Agentic LLM judge" above). Omit to self-grade
299+
// with the codegen agent+model. `model` required; `agent` defaults to codegen.
300+
judge: { agent: 'vercel-ai-gateway/claude-code', model: 'claude-opus-4-8' },
282301
};
283302

284303
export default config;

packages/agent-eval/src/cli.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { Dashboard, createConsoleProgressHandler } from './lib/dashboard.js';
1717
import type { ProgressEvent, Classification } from './lib/types.js';
1818
import { initProject, getPostInitInstructions } from './lib/init.js';
1919
import { getAgent } from './lib/agents/index.js';
20+
import { resolveAgentApiKey } from './lib/agents/shared.js';
2021
import { getSandboxBackendInfo } from './lib/sandbox.js';
2122
import { computeFingerprint } from './lib/fingerprint.js';
2223
import { scanReusableResults } from './lib/results.js';
@@ -147,7 +148,7 @@ async function runExperimentCommand(configInput: string, options: { dry?: boolea
147148
// Get the agent to check for required API key
148149
const agent = getAgent(config.agent);
149150
const apiKeyEnvVar = agent.getApiKeyEnvVar();
150-
const apiKey = process.env[apiKeyEnvVar] ?? process.env.VERCEL_OIDC_TOKEN;
151+
const apiKey = resolveAgentApiKey(agent.getApiKeyEnvVar);
151152
if (!apiKey) {
152153
console.error(chalk.red(`${apiKeyEnvVar} (or VERCEL_OIDC_TOKEN) environment variable is required`));
153154
console.error(chalk.gray(`Get your API key at: https://vercel.com/dashboard -> AI Gateway`));
@@ -474,7 +475,7 @@ async function runAllCommand(experimentArgs: string[], options: { dry?: boolean;
474475

475476
const agent = getAgent(config.agent);
476477
const apiKeyEnvVar = agent.getApiKeyEnvVar();
477-
const apiKey = process.env[apiKeyEnvVar] ?? process.env.VERCEL_OIDC_TOKEN;
478+
const apiKey = resolveAgentApiKey(agent.getApiKeyEnvVar);
478479
if (!apiKey) {
479480
console.error(chalk.red(`${apiKeyEnvVar} (or VERCEL_OIDC_TOKEN) not set, skipping ${baseExperimentName}`));
480481
return;

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,38 @@ test('environment judge (false)', async () => {
386386
expect(result.result.status).toBe('failed');
387387
expect(result.outputContent?.eval ?? '').toContain('[judge:environment] FAIL');
388388
}, 900000);
389+
390+
it('PASSES with a judge pinned to a different model than codegen', async () => {
391+
// Codegen runs sonnet; the judge is pinned to a fixed model (opus). This is the
392+
// cross-model dashboard config — grade every model with one fixed judge. Proves
393+
// the pinned model flows: judge-config.model = the pinned model, not codegen's.
394+
writeJudgeFixture(
395+
'judge-pinned',
396+
`
397+
import { test, expect } from 'vitest';
398+
import { environment } from '@vercel/agent-eval/eval';
399+
400+
test('environment judge (pinned, true)', async () => {
401+
await expect(environment).toSatisfyCriterion(
402+
'exports a greet() function that returns a non-empty greeting string'
403+
);
404+
});
405+
`
406+
);
407+
const fixture = loadFixture(TEST_DIR, 'judge-pinned');
408+
const result = await runSingleEval(fixture, {
409+
agent: 'vercel-ai-gateway/claude-code',
410+
model: 'sonnet',
411+
timeout: 900,
412+
apiKey: process.env.AI_GATEWAY_API_KEY!,
413+
scripts: [],
414+
judge: { model: 'claude-opus-4-8' },
415+
});
416+
if (result.result.status === 'failed') {
417+
console.error('judge-pinned eval output:\n', result.outputContent?.eval);
418+
}
419+
expect(result.result.status).toBe('passed');
420+
}, 900000);
389421
});
390422

391423
describe.skipIf(!hasAnthropicCredentials)('Claude Code (Direct API) sandbox execution', () => {

packages/agent-eval/src/lib/agents/claude-code/agent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,5 +84,6 @@ export function createClaudeCodeAgent({ useVercelAiGateway }: { useVercelAiGatew
8484
return definition.defaultModel;
8585
},
8686
run: (fixturePath, options) => runWithDefinition(definition, fixturePath, options),
87+
definition,
8788
};
8889
}

packages/agent-eval/src/lib/agents/codex/agent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,5 +237,6 @@ export function createCodexAgent({ useVercelAiGateway }: { useVercelAiGateway: b
237237
return definition.defaultModel;
238238
},
239239
run: (fixturePath, options) => runWithDefinition(definition, fixturePath, options),
240+
definition,
240241
};
241242
}

packages/agent-eval/src/lib/agents/cursor/agent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,5 +92,6 @@ export function createCursorAgent(): Agent {
9292
return definition.defaultModel;
9393
},
9494
run: (fixturePath, options) => runWithDefinition(definition, fixturePath, options),
95+
definition,
9596
};
9697
}

packages/agent-eval/src/lib/agents/eval-helper.mjs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,9 @@ function readJudgeConfig() {
119119
try {
120120
return JSON.parse(readFileSync(JUDGE_CONFIG_PATH, 'utf8'));
121121
} catch {
122-
// No config (e.g. run outside the orchestrator) → let the agent CLI default.
123-
return { model: null, extra: null };
122+
// No config (e.g. run outside the orchestrator) → reuse the codegen runner and
123+
// let the agent CLI pick its default model.
124+
return { runnerPath: RUNNER_PATH, model: null, extra: null };
124125
}
125126
}
126127

@@ -136,9 +137,12 @@ function runJudge(subject, criterion, opts = {}) {
136137
const resultPath = `${JUDGE_IO_DIR}/${id}-result.json`;
137138
const cfg = readJudgeConfig();
138139

139-
// Same AgentRunInput contract run.mjs already understands. Reuse the codegen
140-
// model + host-computed extra (e.g. codex's resolved model/effort) so the judge
141-
// is the SAME agent. No secrets here — the key rides in process.env (inherited).
140+
// Same AgentRunInput contract the runner already understands. The judge model +
141+
// host-computed extra come from judge-config.json: by default they match the
142+
// codegen run (self-grade); when the experiment pins a judge, runnerPath points at
143+
// the pinned agent's judge-run.mjs and model is the pinned model. No secrets here —
144+
// the key rides in process.env (inherited from the orchestrator's validation env).
145+
const runnerPath = cfg.runnerPath ?? RUNNER_PATH;
142146
const input = {
143147
prompt: buildJudgePrompt(subject, criterion, verdictPath, opts),
144148
model: cfg.model ?? undefined,
@@ -147,7 +151,7 @@ function runJudge(subject, criterion, opts = {}) {
147151
extra: cfg.extra ?? undefined,
148152
};
149153

150-
const res = spawnSync('node', [RUNNER_PATH, JSON.stringify(input)], {
154+
const res = spawnSync('node', [runnerPath, JSON.stringify(input)], {
151155
cwd: process.cwd(),
152156
env: process.env,
153157
encoding: 'utf8',

packages/agent-eval/src/lib/agents/gemini/agent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,5 +75,6 @@ export function createGeminiAgent(): Agent {
7575
return definition.defaultModel;
7676
},
7777
run: (fixturePath, options) => runWithDefinition(definition, fixturePath, options),
78+
definition,
7879
};
7980
}

packages/agent-eval/src/lib/agents/opencode/agent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,5 +207,6 @@ export function createOpenCodeAgent(): Agent {
207207
return definition.defaultModel;
208208
},
209209
run: (fixturePath, options) => runWithDefinition(definition, fixturePath, options),
210+
definition,
210211
};
211212
}

0 commit comments

Comments
 (0)