Skip to content

Commit 5dc2ed6

Browse files
authored
feat: Support inline eval definitions (#43)
* feat: Support inline eval configs * fix: Repair CI validation issues * fix: Align inline evals with eval directory structure * fix: Repair merged pnpm lockfile * fix: Default inline eval name to directory --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 7b2a76e commit 5dc2ed6

10 files changed

Lines changed: 251 additions & 16 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,21 @@ The experiment config will specify:
5555
- Evaluations (evals) that are used to grade the output of the model
5656
- Treatments that specify the different conditions you would like to test (for
5757
example, testing with an MCP server versus without)
58+
59+
Evals can also be defined inline in an experiment. Inline eval paths resolve
60+
from the directory where the CLI is run, and use the same `eval.config.ts` and
61+
`eval.test.ts` files as repository evals:
62+
63+
```ts
64+
export const experiment: ExperimentConfig = {
65+
name: 'Local project experiment',
66+
description: 'Run an eval from the current project',
67+
models: ['gpt-5.5'],
68+
evals: [
69+
{
70+
path: './evals/local-button-eval',
71+
},
72+
],
73+
treatments: [],
74+
}
75+
```

packages/agent-eval/src/cli.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {get as getEval} from '@primer/agent-evals'
77
import {list, find} from '@primer/agent-experiments'
88
import {ControlTreatment, type ExperimentConfig, type Model} from '@primer/agent-experiment'
99
import type {Treatment, TreatmentResult} from './treatment'
10+
import {resolveExperimentEval} from './eval'
1011
import {run} from './run'
1112

1213
const COPILOT_GITHUB_TOKEN = process.env.COPILOT_GITHUB_TOKEN
@@ -371,20 +372,28 @@ const results: Array<TreatmentResult> = []
371372
for (const config of experimentConfigs) {
372373
console.log('Running experiment:', config.name)
373374

375+
const evals = await Promise.all(
376+
config.evals.map(evalConfig => {
377+
return resolveExperimentEval(evalConfig, {
378+
builtInEvalResolver: getEval,
379+
})
380+
}),
381+
)
382+
374383
const treatments: Array<Treatment> = config.models.flatMap(model => {
375-
return config.evals.flatMap(evalId => {
384+
return evals.flatMap(evalConfig => {
376385
return [
377386
{
378387
config: ControlTreatment,
379-
eval: getEval(evalId),
388+
eval: evalConfig,
380389
experiment: config,
381390
id: randomUUID(),
382391
model,
383392
},
384393
...config.treatments.map(treatment => {
385394
return {
386395
config: treatment,
387-
eval: getEval(evalId),
396+
eval: evalConfig,
388397
experiment: config,
389398
id: randomUUID(),
390399
model,

packages/agent-eval/src/config.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
type EvalConfig = {
2-
prompt: string
3-
}
1+
import type {EvalConfig} from '@primer/agent-experiment'
42

53
function defineConfig(config: EvalConfig) {
64
return config
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import fs from 'node:fs/promises'
2+
import os from 'node:os'
3+
import path from 'node:path'
4+
import {afterEach, describe, expect, test} from 'vitest'
5+
import type {ExperimentEvalConfig} from '@primer/agent-experiment'
6+
import {resolveExperimentEval, type ResolvedEval} from './eval'
7+
8+
const temporaryDirectories: Array<string> = []
9+
10+
async function createTemporaryDirectory() {
11+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-eval-'))
12+
temporaryDirectories.push(directory)
13+
return directory
14+
}
15+
16+
afterEach(async () => {
17+
await Promise.all(
18+
temporaryDirectories.splice(0).map(directory => {
19+
return fs.rm(directory, {recursive: true, force: true})
20+
}),
21+
)
22+
})
23+
24+
describe(resolveExperimentEval, () => {
25+
test('resolves built-in evals through the provided resolver', async () => {
26+
const builtinEval: ResolvedEval = {
27+
id: '001-agent-uses-button-from-primer',
28+
directory: '/path/to/eval',
29+
config: {
30+
prompt: 'Use a Primer button',
31+
},
32+
testPath: '/path/to/eval/eval.test.ts',
33+
}
34+
35+
await expect(
36+
resolveExperimentEval('001-agent-uses-button-from-primer' as ExperimentEvalConfig, {
37+
builtInEvalResolver(id) {
38+
expect(id).toBe('001-agent-uses-button-from-primer')
39+
return builtinEval
40+
},
41+
}),
42+
).resolves.toBe(builtinEval)
43+
})
44+
45+
test('resolves inline eval directories relative to the provided cwd', async () => {
46+
const cwd = await createTemporaryDirectory()
47+
const directory = path.join(cwd, 'evals', 'local-eval')
48+
await fs.mkdir(directory, {recursive: true})
49+
await fs.writeFile(path.join(directory, 'eval.config.mjs'), `export default {prompt: 'Use ignored config'}`)
50+
await fs.writeFile(path.join(directory, 'eval.config.ts'), `export default {prompt: 'Update the local project'}`)
51+
await fs.writeFile(path.join(directory, 'eval.test.ts'), '')
52+
53+
await expect(
54+
resolveExperimentEval(
55+
{
56+
name: 'local-eval',
57+
path: 'evals/local-eval',
58+
},
59+
{
60+
builtInEvalResolver() {
61+
throw new Error('Unexpected built-in eval lookup')
62+
},
63+
cwd,
64+
},
65+
),
66+
).resolves.toEqual({
67+
id: 'local-eval',
68+
directory,
69+
config: {
70+
prompt: 'Update the local project',
71+
},
72+
testPath: path.join(directory, 'eval.test.ts'),
73+
})
74+
})
75+
76+
test('defaults inline eval names to the directory name', async () => {
77+
const cwd = await createTemporaryDirectory()
78+
const directory = path.join(cwd, 'evals', 'local-button-eval')
79+
await fs.mkdir(directory, {recursive: true})
80+
await fs.writeFile(path.join(directory, 'eval.config.ts'), `export default {prompt: 'Update the local project'}`)
81+
await fs.writeFile(path.join(directory, 'eval.test.ts'), '')
82+
83+
await expect(
84+
resolveExperimentEval(
85+
{
86+
path: './evals/local-button-eval',
87+
},
88+
{
89+
builtInEvalResolver() {
90+
throw new Error('Unexpected built-in eval lookup')
91+
},
92+
cwd,
93+
},
94+
),
95+
).resolves.toMatchObject({
96+
id: 'local-button-eval',
97+
directory,
98+
})
99+
})
100+
101+
test('requires inline evals to use the default eval file structure', async () => {
102+
const cwd = await createTemporaryDirectory()
103+
const directory = path.join(cwd, 'fixtures', 'local-eval')
104+
await fs.mkdir(directory, {recursive: true})
105+
await fs.writeFile(path.join(directory, 'eval.config.ts'), `export default {prompt: 'Use default config'}`)
106+
await fs.writeFile(path.join(directory, 'custom.test.ts'), '')
107+
108+
await expect(
109+
resolveExperimentEval(
110+
{
111+
name: 'local-eval',
112+
path: 'fixtures/local-eval',
113+
},
114+
{
115+
builtInEvalResolver() {
116+
throw new Error('Unexpected built-in eval lookup')
117+
},
118+
cwd,
119+
},
120+
),
121+
).rejects.toThrow(`Eval "local-eval" test file was not found: ${path.join(directory, 'eval.test.ts')}`)
122+
})
123+
})

packages/agent-eval/src/eval.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import fs from 'node:fs/promises'
2+
import path from 'node:path'
3+
import type {EvalConfig, ExperimentEvalConfig} from '@primer/agent-experiment'
4+
5+
type BuiltInEvalId = Extract<ExperimentEvalConfig, string>
6+
7+
type ResolvedEval = {
8+
readonly id: string
9+
readonly directory: string
10+
readonly config: EvalConfig
11+
readonly testPath: string
12+
}
13+
14+
type ResolveEvalOptions = {
15+
builtInEvalResolver: (id: BuiltInEvalId) => ResolvedEval
16+
cwd?: string
17+
}
18+
19+
function isEvalConfig(value: unknown): value is EvalConfig {
20+
return (
21+
value !== null &&
22+
typeof value === 'object' &&
23+
'prompt' in value &&
24+
typeof (value as Record<string, unknown>).prompt === 'string'
25+
)
26+
}
27+
28+
async function assertDirectory(directory: string, name: string) {
29+
const stats = await fs.stat(directory).catch(() => undefined)
30+
if (!stats?.isDirectory()) {
31+
throw new Error(`Eval "${name}" directory was not found: ${directory}`)
32+
}
33+
}
34+
35+
async function assertFile(filepath: string, name: string) {
36+
const stats = await fs.stat(filepath).catch(() => undefined)
37+
if (!stats?.isFile()) {
38+
throw new Error(`Eval "${name}" test file was not found: ${filepath}`)
39+
}
40+
}
41+
42+
async function loadEvalConfig(configPath: string, name: string): Promise<EvalConfig> {
43+
const configModule = (await import(configPath)) as {default?: unknown}
44+
if (!isEvalConfig(configModule.default)) {
45+
throw new Error(`Eval "${name}" config must export a default config with a prompt`)
46+
}
47+
return configModule.default
48+
}
49+
50+
async function resolveExperimentEval(
51+
evalConfig: ExperimentEvalConfig,
52+
options: ResolveEvalOptions,
53+
): Promise<ResolvedEval> {
54+
if (typeof evalConfig === 'string') {
55+
return options.builtInEvalResolver(evalConfig)
56+
}
57+
58+
const cwd = options.cwd ?? process.cwd()
59+
const directory = path.resolve(cwd, evalConfig.path)
60+
const name = evalConfig.name ?? path.basename(directory)
61+
await assertDirectory(directory, name)
62+
63+
const config = await loadEvalConfig(path.resolve(directory, 'eval.config.ts'), name)
64+
const testPath = path.resolve(directory, 'eval.test.ts')
65+
await assertFile(testPath, name)
66+
67+
return {
68+
id: name,
69+
directory,
70+
config,
71+
testPath,
72+
}
73+
}
74+
75+
export {resolveExperimentEval}
76+
export type {ResolvedEval}

packages/agent-eval/src/treatment.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import type {Eval} from '@primer/agent-evals'
21
import type {ExperimentConfig, Model, TreatmentConfig} from '@primer/agent-experiment'
2+
import type {ResolvedEval} from './eval'
33

44
type Treatment = {
55
config: TreatmentConfig
6-
eval: Eval
6+
eval: ResolvedEval
77
experiment: ExperimentConfig
88
id: string
99
model: Model

packages/evals/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import {data} from './generated/evals.ts'
2-
import type {EvalId, Eval} from './generated/evals.ts'
1+
import {data} from './generated/evals'
2+
import type {EvalId, Eval} from './generated/evals'
33

44
function list(): ReadonlyArray<Eval> {
55
return data

packages/experiment/src/config.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,22 @@ import type {EvalId} from '@primer/agent-evals'
22
import type {Sandbox} from '@primer/agent-sandbox'
33
import type {Model} from './model'
44

5+
type EvalConfig = {
6+
prompt: string
7+
}
8+
9+
type InlineEvalConfig = {
10+
name?: string
11+
path: string
12+
}
13+
14+
type ExperimentEvalConfig = EvalId | InlineEvalConfig
15+
516
type ExperimentConfig = {
617
name: string
718
description: string
819
models: Array<Model>
9-
evals: Array<EvalId>
20+
evals: Array<ExperimentEvalConfig>
1021
setup?: Setup
1122
treatments: Array<TreatmentConfig>
1223
}
@@ -18,4 +29,4 @@ type TreatmentConfig = {
1829

1930
type Setup = ({sandbox}: {sandbox: Sandbox}) => Promise<void>
2031

21-
export type {ExperimentConfig, TreatmentConfig}
32+
export type {EvalConfig, ExperimentConfig, ExperimentEvalConfig, InlineEvalConfig, TreatmentConfig}

packages/experiment/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import type {ExperimentConfig, TreatmentConfig} from './config.ts'
1+
import type {EvalConfig, ExperimentConfig, ExperimentEvalConfig, InlineEvalConfig, TreatmentConfig} from './config'
22

33
const ControlTreatment: TreatmentConfig = {
44
name: 'Control',
55
}
66

77
export {ControlTreatment}
8-
export type {ExperimentConfig, TreatmentConfig}
8+
export type {EvalConfig, ExperimentConfig, ExperimentEvalConfig, InlineEvalConfig, TreatmentConfig}
99
export type {Model} from './model'

packages/sandbox/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import Docker from 'dockerode'
66
import tarFs from 'tar-fs'
77
import type {Headers} from 'tar-fs'
88
import tarStream from 'tar-stream'
9-
import {McpConfigFileSchema} from './mcp-config.ts'
10-
import type {McpConfigFile, McpServerConfig} from './mcp-config.ts'
9+
import {McpConfigFileSchema} from './mcp-config'
10+
import type {McpConfigFile, McpServerConfig} from './mcp-config'
1111

1212
/**
1313
* Working directory inside the container.

0 commit comments

Comments
 (0)