Skip to content

Commit a096dd1

Browse files
authored
fix(grader): select grader providers from shared pool (#1718)
* fix(grader): select grader providers from shared pool * fix(grader): validate dependencies before grader preflight
1 parent ce6b35d commit a096dd1

11 files changed

Lines changed: 626 additions & 62 deletions

File tree

apps/cli/src/commands/eval/run-eval.ts

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ interface NormalizedOptions {
303303
/** Removed: the run directory always uses index.jsonl */
304304
readonly outputFormat?: string;
305305
readonly graderTarget?: string;
306-
/** Config-level fallback grader target name, from `.agentv/config.yaml`'s `defaults.grader`. */
306+
/** Config-level fallback grader provider name, from `.agentv/config.yaml`'s `defaults.grader`. */
307307
readonly defaultGraderTarget?: string;
308308
readonly model?: string;
309309
readonly outputMessages: number | 'all';
@@ -1405,7 +1405,12 @@ async function prepareFileMetadata(params: {
14051405
: suite.tests;
14061406
const testIds = testCases.map((value) => value.id);
14071407
const suiteTargetSpec = suite.targetSpec;
1408+
const suiteDefaults = suite.defaults;
14081409
const suiteTargets = suiteTargetSpec ? [suiteTargetSpec.name] : suite.targets;
1410+
const fileOptions =
1411+
suiteDefaults?.grader && !effectiveOptions.graderTarget
1412+
? { ...effectiveOptions, defaultGraderTarget: suiteDefaults.grader }
1413+
: effectiveOptions;
14091414
const defaultBudgetUsd =
14101415
effectiveOptions.cliBudgetUsd === undefined
14111416
? (effectiveOptions.budgetUsd ?? suite.budgetUsd)
@@ -1414,11 +1419,11 @@ async function prepareFileMetadata(params: {
14141419

14151420
if (testCases.length === 0) {
14161421
return {
1417-
options: effectiveOptions,
1422+
options: fileOptions,
14181423
testIds,
14191424
testCases,
14201425
selections: [],
1421-
trialsConfig: effectiveOptions.experimentTrialsConfig,
1426+
trialsConfig: fileOptions.experimentTrialsConfig,
14221427
suiteTargets,
14231428
yamlCache: suite.cacheConfig?.enabled,
14241429
yamlCachePath: suite.cacheConfig?.cachePath,
@@ -1432,7 +1437,7 @@ async function prepareFileMetadata(params: {
14321437

14331438
let selections: { selection: TargetSelection; inlineTargetLabel: string }[];
14341439

1435-
if (effectiveOptions.transcript) {
1440+
if (fileOptions.transcript) {
14361441
// --transcript mode: bypass target resolution entirely.
14371442
// Create a synthetic TargetSelection for the transcript provider.
14381443
const transcriptSelection: TargetSelection = {
@@ -1444,15 +1449,15 @@ async function prepareFileMetadata(params: {
14441449
},
14451450
targetName: 'transcript',
14461451
targetSource: 'cli',
1447-
targetsFilePath: effectiveOptions.transcript,
1452+
targetsFilePath: fileOptions.transcript,
14481453
};
14491454
selections = [
14501455
{
14511456
selection: transcriptSelection,
1452-
inlineTargetLabel: `transcript (${path.basename(effectiveOptions.transcript)})`,
1457+
inlineTargetLabel: `transcript (${path.basename(fileOptions.transcript)})`,
14531458
},
14541459
];
1455-
} else if (suite.inlineTarget && effectiveOptions.cliTargets.length === 0) {
1460+
} else if (suite.inlineTarget && fileOptions.cliTargets.length === 0) {
14561461
const targetDefinition = suite.inlineTarget;
14571462
const resolvedTarget = resolveProviderDefinition(targetDefinition, process.env, testFilePath, {
14581463
emitDeprecationWarnings: false,
@@ -1469,7 +1474,7 @@ async function prepareFileMetadata(params: {
14691474
inlineTargetLabel: resolveTargetLabel(targetDefinition.name, resolvedTarget.name),
14701475
},
14711476
];
1472-
} else if (suite.providerFactory && effectiveOptions.cliTargets.length === 0) {
1477+
} else if (suite.providerFactory && fileOptions.cliTargets.length === 0) {
14731478
const taskTarget: ResolvedProviderBackend = {
14741479
kind: 'mock',
14751480
name: 'custom-task',
@@ -1490,12 +1495,12 @@ async function prepareFileMetadata(params: {
14901495
];
14911496
} else {
14921497
// Determine provider labels: CLI --provider flags override YAML
1493-
const cliTargets = effectiveOptions.cliTargets;
1494-
const experimentTargets = effectiveOptions.experimentTargets ?? [];
1498+
const cliTargets = fileOptions.cliTargets;
1499+
const experimentTargets = fileOptions.experimentTargets ?? [];
14951500
const suiteTargetSpec = suite.targetSpec;
14961501
const suiteTargets = suiteTargetSpec ? [suiteTargetSpec.name] : suite.targets;
14971502
const suiteTargetRefs = suite.targetRefs;
1498-
const experimentTargetRefs = effectiveOptions.experimentTargetRefs;
1503+
const experimentTargetRefs = fileOptions.experimentTargetRefs;
14991504

15001505
// Resolve which target names to use (precedence: CLI/experiment > suite YAML targets > default)
15011506
let targetNames: readonly string[];
@@ -1511,6 +1516,9 @@ async function prepareFileMetadata(params: {
15111516
} else if (suiteTargets && suiteTargets.length > 0) {
15121517
targetNames = suiteTargets;
15131518
targetRefs = suiteTargetRefs;
1519+
} else if (suiteDefaults?.provider) {
1520+
targetNames = [suiteDefaults.provider];
1521+
targetRefs = undefined;
15141522
} else {
15151523
targetNames = [];
15161524
targetRefs = undefined;
@@ -1526,12 +1534,12 @@ async function prepareFileMetadata(params: {
15261534
providerDefinitions,
15271535
providerDefinitionsSource,
15281536
requireExplicitProviderCatalog: true,
1529-
allowLegacyTargetFiles: effectiveOptions.allowLegacyTargetFiles,
1537+
allowLegacyTargetFiles: fileOptions.allowLegacyTargetFiles,
15301538
env: process.env,
15311539
targetNames,
15321540
targetRefs,
15331541
targetSource,
1534-
modelOverride: effectiveOptions.targetModelOverride,
1542+
modelOverride: fileOptions.targetModelOverride,
15351543
});
15361544

15371545
selections = multiSelections.map((sel) => ({
@@ -1549,18 +1557,18 @@ async function prepareFileMetadata(params: {
15491557
providerDefinitions,
15501558
providerDefinitionsSource,
15511559
requireExplicitProviderCatalog: true,
1552-
allowLegacyTargetFiles: effectiveOptions.allowLegacyTargetFiles,
1560+
allowLegacyTargetFiles: fileOptions.allowLegacyTargetFiles,
15531561
cliTargetName:
15541562
targetSource === 'cli'
15551563
? targetNames.length === 1
15561564
? targetNames[0]
1557-
: effectiveOptions.target
1558-
: effectiveOptions.target,
1565+
: fileOptions.target
1566+
: fileOptions.target,
15591567
fileTargetName:
15601568
targetSource === 'test-file' && targetNames.length === 1 ? targetNames[0] : undefined,
15611569
fileTargetSpec:
15621570
targetSource === 'test-file' && targetNames.length === 1 ? suiteTargetSpec : undefined,
1563-
modelOverride: effectiveOptions.targetModelOverride,
1571+
modelOverride: fileOptions.targetModelOverride,
15641572
env: process.env,
15651573
});
15661574

@@ -1587,11 +1595,11 @@ async function prepareFileMetadata(params: {
15871595
}
15881596

15891597
return {
1590-
options: effectiveOptions,
1598+
options: fileOptions,
15911599
testIds,
15921600
testCases,
15931601
selections,
1594-
trialsConfig: effectiveOptions.experimentTrialsConfig,
1602+
trialsConfig: fileOptions.experimentTrialsConfig,
15951603
suiteTargets,
15961604
yamlCache: suite.cacheConfig?.enabled,
15971605
yamlCachePath: suite.cacheConfig?.cachePath,
@@ -1951,9 +1959,9 @@ export async function runEvalCommand(
19511959
process.env.AGENTV_EXPERIMENT = normalizedExperiment;
19521960
}
19531961

1954-
// Validate --grader-target / --model combinations
1962+
// Validate --grader-provider / --model combinations
19551963
if (options.graderTarget === 'agentv' && !options.model) {
1956-
throw new Error('--grader-target agentv requires --model (e.g., --model openai:gpt-5-mini)');
1964+
throw new Error('--grader-provider agentv requires --model (e.g., --model openai:gpt-5-mini)');
19571965
}
19581966

19591967
if (options.removedOut) {

apps/cli/src/commands/eval/task-bundle.ts

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -636,12 +636,81 @@ function bundledEvalFileName(evalFilePath: string): string {
636636

637637
function uniqueTargetDefinitions(
638638
selections: readonly TaskBundleTargetSelection[],
639+
tests: readonly EvalTest[] = [],
639640
): readonly ProviderDefinition[] {
640641
const selected: ProviderDefinition[] = [];
641642
const seen = new Set<string>();
642643

644+
function addDefinitions(
645+
names: readonly string[],
646+
definitions: readonly ProviderDefinition[],
647+
): void {
648+
for (const name of names) {
649+
for (const definition of selectTargetDefinitions(name, definitions)) {
650+
if (seen.has(definition.name)) {
651+
continue;
652+
}
653+
seen.add(definition.name);
654+
selected.push(definition);
655+
}
656+
}
657+
}
658+
659+
const graderTargetNames = collectGraderTargetNames(tests);
643660
for (const selection of selections) {
644-
for (const definition of selectTargetDefinitions(selection.targetName, selection.definitions)) {
661+
addDefinitions([selection.targetName, ...graderTargetNames], selection.definitions);
662+
}
663+
664+
return selected;
665+
}
666+
667+
function collectGraderTargetNames(tests: readonly EvalTest[]): readonly string[] {
668+
const names: string[] = [];
669+
const seen = new Set<string>();
670+
671+
function collect(value: unknown, key?: string): void {
672+
if (key === 'target' && typeof value === 'string') {
673+
const target = value.trim();
674+
if (target.length > 0 && !target.includes('${{') && !seen.has(target)) {
675+
seen.add(target);
676+
names.push(target);
677+
}
678+
return;
679+
}
680+
681+
if (Array.isArray(value)) {
682+
for (const item of value) {
683+
collect(item);
684+
}
685+
return;
686+
}
687+
688+
if (isRecord(value)) {
689+
for (const [childKey, childValue] of Object.entries(value)) {
690+
collect(childValue, childKey);
691+
}
692+
}
693+
}
694+
695+
for (const test of tests) {
696+
for (const grader of test.source?.graderDefinitions ?? []) {
697+
collect(grader.definition);
698+
}
699+
}
700+
701+
return names;
702+
}
703+
704+
function selectTaskBundleTargetDefinitions(
705+
targetName: string,
706+
definitions: readonly ProviderDefinition[],
707+
tests: readonly EvalTest[],
708+
): readonly ProviderDefinition[] {
709+
const selected: ProviderDefinition[] = [];
710+
const seen = new Set<string>();
711+
712+
for (const name of [targetName, ...collectGraderTargetNames(tests)]) {
713+
for (const definition of selectTargetDefinitions(name, definitions)) {
645714
if (seen.has(definition.name)) {
646715
continue;
647716
}
@@ -1133,7 +1202,11 @@ export async function materializeTaskBundle(
11331202
return undefined;
11341203
}
11351204

1136-
const targetDefinitions = selectTargetDefinitions(options.targetName, options.targetDefinitions);
1205+
const targetDefinitions = selectTaskBundleTargetDefinitions(
1206+
options.targetName,
1207+
options.targetDefinitions,
1208+
[options.test],
1209+
);
11371210
if (targetDefinitions.length === 0) {
11381211
return undefined;
11391212
}
@@ -1228,7 +1301,7 @@ export async function materializeEvalBundle(
12281301
});
12291302
await writeYamlFile(
12301303
providersPath,
1231-
serializeTargetDefinitions(uniqueTargetDefinitions(options.targetSelections)),
1304+
serializeTargetDefinitions(uniqueTargetDefinitions(options.targetSelections, options.tests)),
12321305
);
12331306
await mkdir(path.dirname(configPath), { recursive: true });
12341307
await writeYamlFile(configPath, { providers: `file://../${BUNDLE_PROVIDERS_FILENAME}` });

apps/cli/src/commands/grade/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -657,7 +657,7 @@ export const gradeCommand = command({
657657
model: option({
658658
type: optional(string),
659659
long: 'model',
660-
description: 'Override model for the grader target (e.g., "openai:gpt-5-mini")',
660+
description: 'Override model for the grader provider (e.g., "openai:gpt-5-mini")',
661661
}),
662662
threshold: option({
663663
type: optional(number),

apps/cli/test/commands/eval/task-bundle.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ describe('materializeTaskBundle', () => {
5555
name: 'quality',
5656
type: 'llm-grader',
5757
prompt: 'file://graders/prompt.md',
58+
target: 'judge',
5859
command: ['bun', scriptPath, '--token', 'literal-secret'],
5960
},
6061
},
@@ -97,6 +98,11 @@ describe('materializeTaskBundle', () => {
9798
provider: 'mock',
9899
api_key: 'literal-secret',
99100
},
101+
{
102+
name: 'judge',
103+
provider: 'mock',
104+
api_key: '${{ JUDGE_API_KEY }}',
105+
},
100106
],
101107
outputDir: path.join(tempDir, 'out'),
102108
cwd: tempDir,
@@ -132,8 +138,11 @@ describe('materializeTaskBundle', () => {
132138
'file://files/fixtures/input.txt',
133139
);
134140
expect(assertion.prompt).toBe('file://graders/graders/prompt.md');
141+
expect(assertion.target).toBe('judge');
135142
expect(assertion.command).toEqual(['bun', 'graders/graders/check.ts', '--token', '[redacted]']);
136143
expect(taskProviders).toContain('api_key: ${{ MOCK_API_KEY }}');
144+
expect(taskProviders).toContain('label: judge');
145+
expect(taskProviders).toContain('api_key: ${{ JUDGE_API_KEY }}');
137146
expect(taskProviders).toContain('api_key: "[redacted]"');
138147
expect(taskEval).not.toContain('literal-secret');
139148
expect(taskProviders).not.toContain('literal-secret');

apps/web/src/content/docs/docs/next/graders/llm-graders.mdx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,27 @@ tests:
162162

163163
## Per-Grader Provider
164164

165-
By default, an `llm-rubric` uses `defaults.grader` from the resolved config graph.
166-
Override it per assertion when you need multiple grader models in one run:
165+
By default, an `llm-rubric` uses `tests[].options.provider`, then
166+
`default_test.options.provider`, then `defaults.grader` from the resolved config
167+
graph. Each value selects from the same `providers` pool used for candidate
168+
providers; AgentV does not infer a grader from the first candidate provider.
169+
Override the provider per assertion when you need multiple grader models in one
170+
run:
167171

168172
```yaml
173+
default_test:
174+
options:
175+
provider: grader_gpt_5_mini
176+
177+
tests:
178+
- id: strict-case
179+
options:
180+
provider: grader_claude_haiku
181+
assert:
182+
- name: semantic_quality
183+
type: llm-rubric
184+
value: The answer is correct and concise.
185+
169186
assert:
170187
- name: grader-gpt
171188
type: llm-rubric

apps/web/src/content/docs/docs/next/targets/configuration.mdx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,11 @@ settings belong under `config`. Process-backed coding-agent providers use
6363

6464
A grader is not a separate kind of entity — it is a provider selected for a
6565
grading role, either through `defaults.grader` (shown above) or an
66-
assertion-level `provider` override. There is no separate `graders:` list;
67-
authoring one is a hard error telling you to move each entry into `providers`.
66+
`options.provider` / assertion-level `provider` override. `default_test.options.provider`
67+
sets a shared grader fallback for tests, `tests[].options.provider` overrides it
68+
for one test, and an assertion-level `provider` wins for that assertion. There
69+
is no separate `graders:` list; authoring one is a hard error telling you to
70+
move each entry into `providers`.
6871

6972
## Runtime Modes
7073

@@ -306,8 +309,9 @@ providers:
306309
reasoning_effort: high
307310
```
308311

309-
Use `defaults.grader` for the project default grader. A specific evaluator can
310-
still choose its own grader provider when the evaluator supports that override.
312+
Use `defaults.grader` for the project default grader. `default_test.options.provider`
313+
and `tests[].options.provider` can choose a grader provider for LLM-backed
314+
assertions before an assertion-level `provider` override takes final precedence.
311315

312316
### Environment And Lifecycle Extensions
313317

0 commit comments

Comments
 (0)