Skip to content

Commit c5f9d41

Browse files
authored
feat(eval): load explicit TypeScript eval configs
Load explicit *.eval.ts/*.eval.mts config files through the core eval loader and CLI validate/eval paths while rejecting promptfooconfig.ts, agentvconfig.ts, generic config.ts, and assertion modules as runnable eval suites.
1 parent d4b5374 commit c5f9d41

34 files changed

Lines changed: 643 additions & 161 deletions

File tree

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -240,12 +240,12 @@ const { results, summary } = await evaluate({
240240
console.log(`${summary.passed}/${summary.total} passed`);
241241
```
242242

243-
Use `defineEval()` when you want AgentV to run the TypeScript eval file:
243+
Use `*.eval.ts` when you want AgentV to run a TypeScript eval config:
244244

245245
```typescript
246-
import { defineEval } from '@agentv/sdk';
246+
import type { EvalConfig } from '@agentv/sdk';
247247

248-
export default defineEval({
248+
const config: EvalConfig = {
249249
description: 'Code generation quality',
250250
tags: { experiment: 'with-skills' },
251251
target: {
@@ -285,7 +285,9 @@ export default defineEval({
285285
],
286286
},
287287
],
288-
});
288+
};
289+
290+
export default config;
289291
```
290292

291293
## Documentation

apps/cli/src/commands/eval/shared.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { constants } from 'node:fs';
22
import { access, stat } from 'node:fs/promises';
33
import path from 'node:path';
4+
import { isTypeScriptEvalConfigFileName, typeScriptEvalConfigGlob } from '@agentv/core';
45
import fg from 'fast-glob';
56

67
import { isAgentSkillsEvalsJsonFile } from '../read-adapters/agent-skills-evals.js';
@@ -10,7 +11,7 @@ export interface ResolveEvalPathOptions {
1011
}
1112

1213
function isNativeEvalFile(filePath: string): boolean {
13-
return /\.(ya?ml|jsonl|[cm]?ts)$/i.test(filePath);
14+
return /\.(ya?ml|jsonl)$/i.test(filePath) || isTypeScriptEvalConfigFileName(filePath);
1415
}
1516

1617
function shouldInspectJsonPath(filePath: string): boolean {
@@ -79,8 +80,8 @@ export async function resolveEvalPaths(
7980
if (candidateStats.isDirectory()) {
8081
// Auto-expand directory to recursive eval file glob
8182
const filePattern = options.allowReadAdapters
82-
? '{suite.yaml,suite.yml,*.eval.yaml,*.eval.yml,eval.yaml,eval.yml,*.eval.ts,*.eval.mts,evals.json,*.evals.json}'
83-
: '{suite.yaml,suite.yml,*.eval.yaml,*.eval.yml,eval.yaml,eval.yml,*.eval.ts,*.eval.mts}';
83+
? `{suite.yaml,suite.yml,*.eval.yaml,*.eval.yml,eval.yaml,eval.yml,${typeScriptEvalConfigGlob()},evals.json,*.evals.json}`
84+
: `{suite.yaml,suite.yml,*.eval.yaml,*.eval.yml,eval.yaml,eval.yml,${typeScriptEvalConfigGlob()}}`;
8485
const dirGlob = path.posix.join(candidatePath.replace(/\\/g, '/'), `**/${filePattern}`);
8586
const dirMatches = await fg(dirGlob, {
8687
absolute: true,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ async function runValidateCommand(
4040

4141
export const validateCommand = command({
4242
name: 'validate',
43-
description: 'Validate AgentV eval and targets YAML files',
43+
description: 'Validate AgentV eval, TypeScript config, and targets files',
4444
args: {
4545
paths: restPositionals({
4646
type: string,

apps/cli/src/commands/validate/validate-files.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import {
55
type ValidationResult,
66
type ValidationSummary,
77
detectFileType,
8+
isTypeScriptEvalConfigFileName,
89
validateCasesFile,
910
validateConfigFile,
1011
validateEvalFile,
1112
validateFileReferences,
1213
validateTargetsFile,
14+
validateTypeScriptEvalConfigFile,
1315
validateWorkspacePaths,
1416
} from '@agentv/core/evaluation/validation';
1517
import fg from 'fast-glob';
@@ -35,6 +37,10 @@ export async function validateFiles(paths: readonly string[]): Promise<Validatio
3537
async function validateSingleFile(filePath: string): Promise<ValidationResult> {
3638
const absolutePath = path.resolve(filePath);
3739

40+
if (isTypeScriptEvalConfigFileName(absolutePath)) {
41+
return validateTypeScriptEvalConfigFile(absolutePath);
42+
}
43+
3844
// Detect file type (now infers from path if $schema is missing)
3945
const fileType = await detectFileType(absolutePath);
4046

@@ -97,12 +103,14 @@ async function expandPaths(paths: readonly string[]): Promise<readonly string[]>
97103
const stats = await stat(absolutePath);
98104

99105
if (stats.isFile()) {
100-
if (isYamlFile(absolutePath)) expanded.add(absolutePath);
106+
if (isYamlFile(absolutePath) || isTypeScriptEvalConfigFileName(absolutePath)) {
107+
expanded.add(absolutePath);
108+
}
101109
continue;
102110
}
103111
if (stats.isDirectory()) {
104-
const yamlFiles = await findYamlFiles(absolutePath);
105-
for (const f of yamlFiles) expanded.add(f);
112+
const evalFiles = await findEvalFiles(absolutePath);
113+
for (const f of evalFiles) expanded.add(f);
106114
continue;
107115
}
108116
} catch {
@@ -120,19 +128,21 @@ async function expandPaths(paths: readonly string[]): Promise<readonly string[]>
120128
followSymbolicLinks: true,
121129
});
122130

123-
const yamlMatches = matches.filter((f) => isYamlFile(f));
124-
if (yamlMatches.length === 0) {
125-
console.warn(`Warning: No YAML files matched pattern: ${inputPath}`);
131+
const evalMatches = matches.filter((f) => isYamlFile(f) || isTypeScriptEvalConfigFileName(f));
132+
if (evalMatches.length === 0) {
133+
console.warn(
134+
`Warning: No eval YAML or TypeScript config files matched pattern: ${inputPath}`,
135+
);
126136
}
127-
for (const f of yamlMatches) expanded.add(path.normalize(f));
137+
for (const f of evalMatches) expanded.add(path.normalize(f));
128138
}
129139

130140
const sorted = Array.from(expanded);
131141
sorted.sort();
132142
return sorted;
133143
}
134144

135-
async function findYamlFiles(dirPath: string): Promise<readonly string[]> {
145+
async function findEvalFiles(dirPath: string): Promise<readonly string[]> {
136146
const results: string[] = [];
137147

138148
try {
@@ -146,9 +156,12 @@ async function findYamlFiles(dirPath: string): Promise<readonly string[]> {
146156
if (entry.name === 'node_modules' || entry.name.startsWith('.')) {
147157
continue;
148158
}
149-
const subFiles = await findYamlFiles(fullPath);
159+
const subFiles = await findEvalFiles(fullPath);
150160
results.push(...subFiles);
151-
} else if (entry.isFile() && isEvalYamlFile(entry.name)) {
161+
} else if (
162+
entry.isFile() &&
163+
(isEvalYamlFile(entry.name) || isTypeScriptEvalConfigFileName(entry.name))
164+
) {
152165
results.push(fullPath);
153166
}
154167
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import path from 'node:path';
5+
6+
import { discoverEvalFiles } from '../../../src/commands/eval/discover.js';
7+
8+
describe('discoverEvalFiles', () => {
9+
let tempDir: string;
10+
11+
beforeEach(() => {
12+
tempDir = mkdtempSync(path.join(tmpdir(), 'agentv-discover-eval-files-'));
13+
});
14+
15+
afterEach(() => {
16+
rmSync(tempDir, { recursive: true, force: true });
17+
});
18+
19+
it('discovers explicit TypeScript eval config files without arbitrary TypeScript configs', async () => {
20+
const evalDir = path.join(tempDir, 'evals');
21+
mkdirSync(evalDir, { recursive: true });
22+
23+
const tsFile = path.join(evalDir, 'greeting.eval.ts');
24+
const mtsFile = path.join(evalDir, 'module.eval.mts');
25+
writeFileSync(tsFile, 'export default { prompts: ["{{ input }}"], tests: [] };\n');
26+
writeFileSync(mtsFile, 'export default { prompts: ["{{ input }}"], tests: [] };\n');
27+
writeFileSync(path.join(evalDir, 'helper.ts'), 'export const helper = true;\n');
28+
writeFileSync(path.join(evalDir, 'agentvconfig.ts'), 'export default { tests: [] };\n');
29+
writeFileSync(path.join(evalDir, 'promptfooconfig.ts'), 'export default { tests: [] };\n');
30+
31+
const discovered = await discoverEvalFiles(tempDir);
32+
const relativePaths = discovered.map((file) => file.relativePath);
33+
34+
expect(relativePaths).toEqual(['evals/greeting.eval.ts', 'evals/module.eval.mts']);
35+
});
36+
});

apps/cli/test/commands/eval/shared.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,32 @@ describe('resolveEvalPaths', () => {
108108
expect(resolved).toEqual([path.normalize(tsFile)]);
109109
});
110110

111+
it('does not discover agentvconfig.ts files from directory auto-expansion', async () => {
112+
const evalDir = path.join(tempDir, 'evals');
113+
mkdirSync(evalDir, { recursive: true });
114+
115+
const tsFile = path.join(evalDir, 'agentvconfig.ts');
116+
const helperFile = path.join(evalDir, 'helper.ts');
117+
writeFileSync(tsFile, 'export default { tests: [] }');
118+
writeFileSync(helperFile, 'export const helper = true');
119+
120+
await expect(resolveEvalPaths([tempDir], tempDir)).rejects.toThrow(
121+
'No eval files matched any provided paths or globs',
122+
);
123+
});
124+
125+
it('does not discover promptfooconfig.ts files from directory auto-expansion', async () => {
126+
const evalDir = path.join(tempDir, 'evals');
127+
mkdirSync(evalDir, { recursive: true });
128+
129+
const tsFile = path.join(evalDir, 'promptfooconfig.ts');
130+
writeFileSync(tsFile, 'export default { tests: [] }');
131+
132+
await expect(resolveEvalPaths([tempDir], tempDir)).rejects.toThrow(
133+
'No eval files matched any provided paths or globs',
134+
);
135+
});
136+
111137
it('discovers Agent Skills evals.json from directory auto-expansion when read adapters are enabled', async () => {
112138
const evalDir = path.join(tempDir, 'skills', 'demo', 'evals');
113139
mkdirSync(evalDir, { recursive: true });
@@ -144,6 +170,29 @@ describe('resolveEvalPaths', () => {
144170
expect(resolved).toEqual([path.normalize(tsFile)]);
145171
});
146172

173+
it('does not accept arbitrary direct .ts file paths as eval configs', async () => {
174+
const tsFile = path.join(tempDir, 'helper.ts');
175+
writeFileSync(tsFile, 'export const helper = true');
176+
177+
await expect(resolveEvalPaths([tsFile], tempDir)).rejects.toThrow(
178+
'No eval files matched any provided paths or globs',
179+
);
180+
});
181+
182+
it('filters arbitrary TypeScript files from broad globs', async () => {
183+
const evalDir = path.join(tempDir, 'evals');
184+
mkdirSync(evalDir, { recursive: true });
185+
186+
const tsFile = path.join(evalDir, 'custom.eval.mts');
187+
const helperFile = path.join(evalDir, 'helper.ts');
188+
writeFileSync(tsFile, 'export default { tests: [] }');
189+
writeFileSync(helperFile, 'export const helper = true');
190+
191+
const resolved = await resolveEvalPaths(['evals/**/*.ts', 'evals/**/*.mts'], tempDir);
192+
193+
expect(resolved).toEqual([path.normalize(tsFile)]);
194+
});
195+
147196
it('discovers both .yaml and .ts files from directory', async () => {
148197
const evalDir = path.join(tempDir, 'evals');
149198
mkdirSync(evalDir, { recursive: true });
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import path from 'node:path';
5+
6+
import { validateFiles } from '../../../src/commands/validate/validate-files.js';
7+
8+
describe('validateFiles TypeScript eval configs', () => {
9+
let tempDir: string;
10+
11+
beforeEach(() => {
12+
tempDir = mkdtempSync(path.join(tmpdir(), 'agentv-validate-ts-config-'));
13+
});
14+
15+
afterEach(() => {
16+
rmSync(tempDir, { recursive: true, force: true });
17+
});
18+
19+
it('validates *.eval.ts through the core loader', async () => {
20+
const configFile = path.join(tempDir, 'suite.eval.ts');
21+
writeFileSync(
22+
configFile,
23+
`export default {
24+
prompts: ['{{ input }}'],
25+
tests: [
26+
{
27+
id: 'hello',
28+
vars: { input: 'Say hello' },
29+
assert: [{ type: 'contains', value: 'hello' }],
30+
},
31+
],
32+
};
33+
`,
34+
);
35+
36+
const summary = await validateFiles([configFile]);
37+
38+
expect(summary.totalFiles).toBe(1);
39+
expect(summary.validFiles).toBe(1);
40+
expect(summary.results[0].filePath).toBe(path.normalize(configFile));
41+
});
42+
43+
it('reports missing default export errors for TypeScript configs', async () => {
44+
const configFile = path.join(tempDir, 'suite.eval.ts');
45+
writeFileSync(configFile, 'export const config = { tests: [] };\n');
46+
47+
const summary = await validateFiles([configFile]);
48+
49+
expect(summary.invalidFiles).toBe(1);
50+
expect(summary.results[0].errors[0].message).toContain('default export');
51+
});
52+
53+
it('reports invalid TypeScript eval contract errors', async () => {
54+
const configFile = path.join(tempDir, 'suite.eval.mts');
55+
writeFileSync(
56+
configFile,
57+
`export default {
58+
prompts: ['{{ input }}'],
59+
providers: ['openai:gpt-5'],
60+
tests: [{ id: 'hello', vars: { input: 'Say hello' } }],
61+
};
62+
`,
63+
);
64+
65+
const summary = await validateFiles([configFile]);
66+
67+
expect(summary.invalidFiles).toBe(1);
68+
expect(summary.results[0].errors[0].message).toContain("top-level 'providers'");
69+
});
70+
71+
it('expands directories without treating custom assertion files as eval configs', async () => {
72+
const assertionsDir = path.join(tempDir, '.agentv', 'assertions');
73+
mkdirSync(assertionsDir, { recursive: true });
74+
writeFileSync(path.join(assertionsDir, 'custom-check.ts'), 'export default () => true;\n');
75+
const configFile = path.join(tempDir, 'suite.eval.ts');
76+
writeFileSync(
77+
configFile,
78+
`export default {
79+
prompts: ['{{ input }}'],
80+
tests: [
81+
{
82+
id: 'hello',
83+
vars: { input: 'Say hello' },
84+
assert: [{ type: 'contains', value: 'hello' }],
85+
},
86+
],
87+
};
88+
`,
89+
);
90+
91+
const summary = await validateFiles([tempDir]);
92+
93+
expect(summary.totalFiles).toBe(1);
94+
expect(summary.results[0].filePath).toBe(path.normalize(configFile));
95+
});
96+
97+
it('does not validate promptfooconfig.ts as an eval config', async () => {
98+
const configFile = path.join(tempDir, 'promptfooconfig.ts');
99+
writeFileSync(configFile, 'export default { prompts: ["{{ input }}"], tests: [] };\n');
100+
101+
const summary = await validateFiles([tempDir]);
102+
103+
expect(summary.totalFiles).toBe(0);
104+
});
105+
106+
it('does not validate agentvconfig.ts as an eval config', async () => {
107+
const configFile = path.join(tempDir, 'agentvconfig.ts');
108+
writeFileSync(configFile, 'export default { prompts: ["{{ input }}"], tests: [] };\n');
109+
110+
const summary = await validateFiles([tempDir]);
111+
112+
expect(summary.totalFiles).toBe(0);
113+
});
114+
});

0 commit comments

Comments
 (0)