Skip to content

Commit 79d156e

Browse files
authored
fix(sdk): align eval config providers surface (#1722)
* fix(sdk): align eval config providers surface * fix(sdk): use inline TS providers during evaluate
1 parent a48e426 commit 79d156e

20 files changed

Lines changed: 384 additions & 51 deletions

apps/web/src/content/docs/docs/next/evaluation/sdk.mdx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,28 @@ import { graders, type EvalConfig } from '@agentv/sdk';
9797

9898
const config: EvalConfig = {
9999
name: 'hello-suite',
100-
providers: ['mock-sdk'],
100+
providers: [
101+
{ id: 'mock', label: 'mock-sdk', config: { response: 'Hello from the mock provider' } },
102+
{ id: 'openai:gpt-5-mini', label: 'grader-provider' },
103+
],
104+
defaults: {
105+
provider: 'mock-sdk',
106+
grader: 'grader-provider',
107+
},
108+
defaultTest: {
109+
options: {
110+
provider: 'grader-provider',
111+
},
112+
},
101113
prompts: ['{{ task }}'],
102114
tests: [
103115
{
104116
id: 'hello',
105117
vars: { task: 'Say hello' },
106118
inputFiles: ['../fixtures/per-test-note.md'],
119+
options: {
120+
provider: 'grader-provider',
121+
},
107122
assert: [graders.contains('Hello')],
108123
},
109124
],
@@ -120,6 +135,8 @@ Useful companion helpers:
120135

121136
The durable authored field remains `assert`. TypeScript eval config authoring does not introduce a second YAML vocabulary.
122137

138+
TypeScript eval configs use the same provider surface as YAML: top-level `providers` defines both systems under test and reusable grader providers, `providers[].id` names the backend/spec, `providers[].label` is the stable AgentV identity, and `defaults.provider` / `defaults.grader` select the default candidate and grader. Per-test grader provider selection belongs in `defaultTest.options.provider`, `tests[].options.provider`, or assertion-level `provider`.
139+
123140
## Built-In Grader Helpers
124141

125142
`@agentv/sdk` includes a small `graders` catalog for common deterministic and LLM-backed grader configs. These helpers return ordinary `assert` entries and serialize to the same canonical YAML you could write by hand.

packages/core/src/evaluation/evaluate.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ export interface MaterializedEvalConfig {
222222
readonly threshold?: number;
223223
readonly metadata?: EvalMetadata;
224224
readonly target?: ProviderDefinition;
225+
readonly targets?: readonly ProviderDefinition[];
225226
readonly task?: (input: string) => string | Promise<string>;
226227
readonly providerFactory?: ProviderFactoryFn;
227228
}
@@ -362,6 +363,7 @@ export async function evaluate(config: EvalConfig): Promise<EvalRunResult> {
362363
testFilePath,
363364
repoRoot,
364365
target: resolvedTarget,
366+
...(materialized.targets ? { targets: materialized.targets } : {}),
365367
...(providerFactory ? { providerFactory } : {}),
366368
maxRetries: config.maxRetries ?? 2,
367369
agentTimeoutMs: config.agentTimeoutMs,
@@ -426,6 +428,9 @@ export async function materializeEvalConfig(
426428
category: options?.category,
427429
});
428430
const tests = applyProgrammaticSuiteOverrides(suite.tests, config);
431+
const suiteTargetDefinitions = suite.targetRefs
432+
?.map((targetRef) => targetRef.definition)
433+
.filter((definition): definition is ProviderDefinition => definition !== undefined);
429434
return {
430435
testFilePath,
431436
tests,
@@ -435,7 +440,10 @@ export async function materializeEvalConfig(
435440
budgetUsd: config.budgetUsd ?? suite.budgetUsd,
436441
threshold: config.threshold ?? suite.threshold,
437442
metadata: config.metadata ?? suite.metadata,
438-
target: config.target ?? suite.inlineTarget,
443+
target: config.target ?? suite.inlineTarget ?? suiteTargetDefinitions?.[0],
444+
...(suiteTargetDefinitions && suiteTargetDefinitions.length > 0
445+
? { targets: suiteTargetDefinitions }
446+
: {}),
439447
task: config.task,
440448
providerFactory: suite.providerFactory,
441449
};

packages/core/src/evaluation/loaders/grader-parser.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ export async function parseGraders(
238238
): Promise<readonly GraderConfig[] | undefined> {
239239
const execution = rawEvalCase.execution;
240240
const executionObject = isJsonObject(execution) ? execution : undefined;
241+
const inheritedAssertionConfig = inheritedAssertionConfigFromOptions(rawEvalCase.options);
241242

242243
// Case-level graders priority: assert > execution assert.
243244
const caseEvaluators =
@@ -255,7 +256,7 @@ export async function parseGraders(
255256
evalId,
256257
defaultPreprocessors,
257258
defaultRubricPrompt,
258-
undefined,
259+
inheritedAssertionConfig,
259260
inheritedGraderTarget,
260261
);
261262
// Parse root-level evaluators (appended after case-level)
@@ -265,7 +266,7 @@ export async function parseGraders(
265266
evalId,
266267
defaultPreprocessors,
267268
defaultRubricPrompt,
268-
undefined,
269+
inheritedAssertionConfig,
269270
inheritedGraderTarget,
270271
);
271272

@@ -1872,21 +1873,45 @@ function withInheritedAssertionConfig(
18721873
inheritedConfig?: JsonObject,
18731874
): JsonObject {
18741875
const ownConfig = isJsonObject(rawEvaluator.config) ? rawEvaluator.config : undefined;
1875-
if (!inheritedConfig && !ownConfig) {
1876+
const inheritedProvider =
1877+
typeof inheritedConfig?.provider === 'string' && inheritedConfig.provider.trim().length > 0
1878+
? inheritedConfig.provider.trim()
1879+
: undefined;
1880+
const inheritedConfigWithoutProvider = inheritedConfig
1881+
? Object.fromEntries(Object.entries(inheritedConfig).filter(([key]) => key !== 'provider'))
1882+
: undefined;
1883+
const inheritedConfigForConfig =
1884+
inheritedConfigWithoutProvider && Object.keys(inheritedConfigWithoutProvider).length > 0
1885+
? inheritedConfigWithoutProvider
1886+
: undefined;
1887+
if (!inheritedConfigForConfig && !ownConfig && inheritedProvider === undefined) {
18761888
return rawEvaluator;
18771889
}
18781890

18791891
const mergedConfig = {
1880-
...(inheritedConfig ?? {}),
1892+
...(inheritedConfigForConfig ?? {}),
18811893
...(ownConfig ?? {}),
18821894
};
18831895

18841896
return {
18851897
...rawEvaluator,
1886-
config: mergedConfig,
1898+
...(rawEvaluator.provider === undefined && inheritedProvider !== undefined
1899+
? { provider: inheritedProvider }
1900+
: {}),
1901+
...(Object.keys(mergedConfig).length > 0 ? { config: mergedConfig } : {}),
18871902
};
18881903
}
18891904

1905+
function inheritedAssertionConfigFromOptions(
1906+
options: JsonValue | undefined,
1907+
): JsonObject | undefined {
1908+
if (!isJsonObject(options)) {
1909+
return undefined;
1910+
}
1911+
const provider = typeof options.provider === 'string' ? options.provider.trim() : '';
1912+
return provider.length > 0 ? { provider } : undefined;
1913+
}
1914+
18901915
interface ParsedPromptField {
18911916
readonly prompt?: string;
18921917
readonly promptPath?: string;

packages/core/src/evaluation/loaders/ts-eval-loader.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const KNOWN_SNAKE_CASE_KEYS = {
3030
budgetUsd: 'budget_usd',
3131
conversationId: 'conversation_id',
3232
costLimitUsd: 'cost_limit_usd',
33+
defaultTest: 'default_test',
3334
dependsOn: 'depends_on',
3435
earlyExit: 'early_exit',
3536
expectedOutput: 'expected_output',
@@ -45,6 +46,7 @@ const KNOWN_SNAKE_CASE_KEYS = {
4546
outputPath: 'output_path',
4647
readOnly: 'read_only',
4748
reasoningEffort: 'reasoning_effort',
49+
rubricPrompt: 'rubric_prompt',
4850
skipDefaults: 'skip_defaults',
4951
timeoutMs: 'timeout_ms',
5052
timeoutSeconds: 'timeout_seconds',
@@ -193,10 +195,7 @@ function isProgrammaticEvalConfig(value: unknown): value is ProgrammaticEvalConf
193195

194196
function lowerTypeScriptEvalConfig(config: Record<string, unknown>): Record<string, unknown> {
195197
const lowered = lowerEvalYamlValue(config) as Record<string, unknown>;
196-
const { budget_usd: budgetUsd, repeat, target, ...withoutRuntimeAliases } = lowered;
197-
if (target !== undefined && withoutRuntimeAliases.providers === undefined) {
198-
withoutRuntimeAliases.providers = [target];
199-
}
198+
const { budget_usd: budgetUsd, repeat, ...withoutRuntimeAliases } = lowered;
200199
if (budgetUsd === undefined && repeat === undefined) {
201200
return withoutRuntimeAliases;
202201
}

packages/core/src/evaluation/yaml-parser.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2653,7 +2653,12 @@ function readSuiteRuntimeBlock(suite: RawTestSuite, evalFilePath: string): JsonO
26532653
}
26542654
if (suite.model !== undefined) {
26552655
throw new Error(
2656-
`Invalid eval runtime config in ${evalFilePath}: top-level 'model' is not part of eval YAML. Put model inside the target object.`,
2656+
`Invalid eval runtime config in ${evalFilePath}: top-level 'model' is not part of eval YAML. Put model inside the relevant providers[].config object.`,
2657+
);
2658+
}
2659+
if ((suite as Record<string, unknown>).graders !== undefined) {
2660+
throw new Error(
2661+
`Invalid eval runtime config in ${evalFilePath}: top-level 'graders' has been removed. Put grader providers in 'providers' and select them with defaults.grader, default_test.options.provider, tests[].options.provider, or assertion provider.`,
26572662
);
26582663
}
26592664
if (suite.runs !== undefined) {
Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1-
import type { EvalConfig } from '../../../../src/evaluation/evaluate.js';
2-
3-
const config: EvalConfig = {
4-
metadata: {
5-
name: 'default-export-suite',
6-
tags: ['sdk', 'typescript'],
7-
},
1+
const config = {
2+
name: 'default-export-suite',
3+
tags: ['sdk', 'typescript'],
84
prompts: ['{{ input }}'],
5+
providers: [
6+
{
7+
id: 'mock',
8+
label: 'inline-provider',
9+
config: { response: 'hello there' },
10+
},
11+
],
912
tests: [
1013
{
1114
id: 'greeting',
1215
vars: { input: 'Say hello' },
1316
assert: [{ type: 'contains', value: 'hello' }],
1417
},
1518
],
16-
cache: false,
17-
cachePath: '.agentv/ts-eval-cache',
1819
budgetUsd: 1.5,
1920
threshold: 0.9,
20-
target: { name: 'inline-target', provider: 'mock', response: 'hello there' },
2121
};
2222

2323
export default config;
Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
1-
import type { EvalConfig } from '../../../../src/evaluation/evaluate.js';
2-
3-
export const evalConfig: EvalConfig = {
1+
export const evalConfig = {
42
prompts: ['{{ input }}'],
3+
providers: ['mock-provider'],
54
tests: [
65
{
76
id: 'eval-config-named',
87
vars: { input: 'Say hello' },
98
assert: [{ type: 'contains', value: 'hello' }],
109
},
1110
],
12-
target: { provider: 'mock_agent' },
1311
};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export default {
2+
name: 'legacy-graders',
3+
providers: ['mock-provider'],
4+
graders: [{ id: 'mock', label: 'grader-provider' }],
5+
prompts: ['{{ input }}'],
6+
tests: [
7+
{
8+
id: 'legacy-graders',
9+
vars: { input: 'Say hello' },
10+
assert: [{ type: 'contains', value: 'hello' }],
11+
},
12+
],
13+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export default {
2+
name: 'legacy-target',
3+
target: 'mock-target',
4+
prompts: ['{{ input }}'],
5+
tests: [
6+
{
7+
id: 'legacy-target',
8+
vars: { input: 'Say hello' },
9+
assert: [{ type: 'contains', value: 'hello' }],
10+
},
11+
],
12+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export default {
2+
name: 'legacy-targets',
3+
targets: ['mock-target'],
4+
prompts: ['{{ input }}'],
5+
tests: [
6+
{
7+
id: 'legacy-targets',
8+
vars: { input: 'Say hello' },
9+
assert: [{ type: 'contains', value: 'hello' }],
10+
},
11+
],
12+
};

0 commit comments

Comments
 (0)