Skip to content

Commit 559dd06

Browse files
Add opt-in Intent discovery eval suite (#173)
* Add intent discovery evaluation suite and related configurations - Introduced new README.md for intent discovery eval with usage instructions. - Added conditions and tasks for intent discovery evaluation. - Implemented grading functions for skill loading and failure classification. - Created harness for running saved transcripts in evaluations. - Configured Vitest for running intent discovery tests. - Updated package.json and pnpm-lock.yaml to include vitest-evals dependency. - Added .gitignore entries for evaluation runs and vitest artifacts. * Add intent discovery evaluation framework and related fixtures * Refactor intent discovery evaluation: streamline skill loading and command parsing * Implement live Copilot harness and error handling for intent discovery evaluation * Enhance live Copilot harness: support opt-in command backend and capture command, skill, transcript, and diff evidence * Add live Copilot harness and evaluation metadata for intent discovery * Add live Copilot condition setup and enhance evaluation framework for intent discovery * Enhance intent discovery evaluation: update file writing logic and add skill package handling * Enhance intent discovery evaluation: add LLM judge and summary report generation, update command parsing for various package managers * ci: apply automated fixes * Enhance intent discovery evaluation: add request timeout for LLM judge, improve error handling, and update fixture dependencies * ci: apply automated fixes * Refactor intent discovery types and functions: remove exports for internal types, update fixture handling, and enhance reference-only logic --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 6826777 commit 559dd06

42 files changed

Lines changed: 3055 additions & 26 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,10 @@ vite.config.ts.timestamp-*
6363
.angular
6464

6565
docs/superpowers
66+
67+
evals/intent-discovery/.vitest-evals/
68+
evals/intent-discovery/runs/*
69+
!evals/intent-discovery/runs/.gitkeep
70+
!evals/intent-discovery/runs/latest/
71+
evals/intent-discovery/runs/latest/*
72+
!evals/intent-discovery/runs/latest/.gitkeep

eslint.config.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import unusedImports from 'eslint-plugin-unused-imports'
66
/** @type {import('eslint').Linter.Config[]} */
77
const config = [
88
...tanstackConfig,
9+
{
10+
name: 'intent/eval-fixture-ignores',
11+
ignores: ['evals/intent-discovery/fixtures/**/*.tsx'],
12+
},
913
{
1014
name: 'tanstack/temp',
1115
plugins: {
@@ -73,6 +77,15 @@ const config = [
7377
],
7478
},
7579
},
80+
{
81+
name: 'intent/evals',
82+
files: ['evals/intent-discovery/**/*.ts'],
83+
languageOptions: {
84+
parserOptions: {
85+
project: './evals/intent-discovery/tsconfig.json',
86+
},
87+
},
88+
},
7689
]
7790

7891
export default config

evals/intent-discovery/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Intent discovery eval
2+
3+
Opt-in eval suite for measuring whether Copilot discovers and invokes Intent surfaces without direct user instruction.
4+
5+
## Commands
6+
7+
- `pnpm eval:intent-discovery` runs the saved-transcript eval suite.
8+
- `pnpm eval:intent-discovery:json` writes `evals/intent-discovery/runs/latest/vitest-results.json`.
9+
- `pnpm eval:intent-discovery:live` runs the eval suite with the local Copilot CLI adapter enabled.
10+
- `pnpm eval:intent-discovery:live:json` writes a JSON report that includes live Copilot condition cases.
11+
- `pnpm eval:intent-discovery:judge` optionally annotates the latest JSON report with an OpenAI-backed output-quality judge when `OPENAI_API_KEY` is set.
12+
- `pnpm eval:intent-discovery:report` serves the saved JSON report.
13+
- `pnpm eval:intent-discovery:summary` writes `summary.json` and `summary.md` from the latest JSON report.
14+
15+
The default JSON/report commands show saved-transcript efficacy cases only. To include the live Copilot condition matrix in the report artifact, run:
16+
17+
```sh
18+
pnpm eval:intent-discovery:live:json
19+
pnpm eval:intent-discovery:summary
20+
pnpm eval:intent-discovery:report
21+
```
22+
23+
Set `INTENT_DISCOVERY_RUN_COUNT=3` with the live commands to run each live condition three times and include `pass@k` / `pass^k` in the generated summary.
24+
25+
The optional LLM judge is secondary. It can annotate whether final answers appear to apply loaded guidance, but it never changes deterministic scores such as `StrictIntentInvocation`, `CorrectSkillLoaded`, or `AutonomousDiscoverySuccess`.
26+
27+
## Current scope
28+
29+
This executable slice grades synthetic saved transcripts with Vitest plus `vitest-evals` harness normalization helpers. It attaches `vitest-evals`-compatible metadata to the Vitest JSON artifact for the local report UI because this repo's current Vitest runtime does not expose the APIs used by `vitest-evals/reporter` and `describeEval()`.
30+
31+
The controlled fixture corpus is limited to current skill-backed surfaces. For this slice, that means TanStack Router, TanStack Start, and TanStack Table v9.
32+
33+
Live Router runs compare four setup conditions:
34+
35+
- `no-intent`: no Intent guidance or allowlist is added.
36+
- `current-intent`: `package.json#intent.skills` plus the current install-style `AGENTS.md` skill-loading guidance.
37+
- `mapped-intent`: `package.json#intent.skills` plus `AGENTS.md` task-to-skill mappings like `install --map`.
38+
- `explicit-intent-control`: current install-style setup plus a prompt that explicitly asks the agent to run Intent. This condition is diagnostic and excluded from autonomous scoring.
39+
40+
The live Copilot harness can run an opt-in command backend through `INTENT_DISCOVERY_COPILOT_COMMAND`. When that environment variable is unset, it returns a normalized `unsupported` run with no tool calls and an explicit `LiveCopilotRunnerUnavailableError`. The command runs inside a prepared fixture workspace with task metadata in `INTENT_DISCOVERY_TASK_ID`, `INTENT_DISCOVERY_FIXTURE`, `INTENT_DISCOVERY_PROMPT`, `INTENT_DISCOVERY_RUN_ID`, and `INTENT_DISCOVERY_WORKSPACE`.
41+
42+
`pnpm eval:intent-discovery:live` sets `INTENT_DISCOVERY_RUN_LIVE=1` and `INTENT_DISCOVERY_COPILOT_COMMAND` to the repo-local Copilot CLI adapter. The adapter calls `copilot -p` in the prepared fixture workspace, writes a Copilot share transcript under the generated run directory, and prints the transcript for command capture. Live runs attach the same strict efficacy scores as saved transcripts, so a passing harness run can still report `AutonomousDiscoverySuccess: 0` when Copilot did not invoke Intent or loaded the wrong skill. Do not put API keys or tokens in the command or prompt; provide credentials through the normal Copilot CLI login or secret environment configuration.
43+
44+
Harness integrity failures fail the eval. Product findings such as reference-only behavior, no discovery attempt, or wrong skill selection are recorded as diagnostic failures, not passing scores. The headline success signal is strict Intent invocation plus the expected skill loaded for autonomous cases.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!/usr/bin/env node
2+
3+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
4+
import { dirname, join } from 'node:path'
5+
import { spawnSync } from 'node:child_process'
6+
7+
const workspace = requiredEnv('INTENT_DISCOVERY_WORKSPACE')
8+
const taskId = requiredEnv('INTENT_DISCOVERY_TASK_ID')
9+
const fixture = requiredEnv('INTENT_DISCOVERY_FIXTURE')
10+
const prompt = requiredEnv('INTENT_DISCOVERY_PROMPT')
11+
const runId = requiredEnv('INTENT_DISCOVERY_RUN_ID')
12+
const sharePath = join(
13+
workspace,
14+
'.intent-eval',
15+
`${sanitizeFileName(runId)}.md`,
16+
)
17+
18+
mkdirSync(dirname(sharePath), { recursive: true })
19+
20+
const copilotPrompt = [
21+
`Task id: ${taskId}`,
22+
`Fixture: ${fixture}`,
23+
'',
24+
prompt,
25+
'',
26+
'Work in the current repository. Use the available project context and tools as you normally would. Do not summarize this prompt; complete the task and report what you changed.',
27+
].join('\n')
28+
29+
const args = [
30+
'-p',
31+
copilotPrompt,
32+
'-C',
33+
workspace,
34+
'--allow-all-tools',
35+
'--add-dir',
36+
workspace,
37+
'--no-ask-user',
38+
'--no-color',
39+
'--plain-diff',
40+
'--share',
41+
sharePath,
42+
]
43+
44+
const result = spawnSync('copilot', args, {
45+
cwd: workspace,
46+
encoding: 'utf8',
47+
env: {
48+
...process.env,
49+
NO_COLOR: '1',
50+
},
51+
stdio: ['ignore', 'pipe', 'pipe'],
52+
})
53+
54+
if (result.error) {
55+
console.error(result.error.message)
56+
process.exit(1)
57+
}
58+
59+
if (result.stdout.trim()) {
60+
console.log(result.stdout.trim())
61+
}
62+
63+
if (existsSync(sharePath)) {
64+
console.log(`\nTRANSCRIPT_PATH: ${sharePath}`)
65+
console.log(readFileSync(sharePath, 'utf8'))
66+
}
67+
68+
if (result.stderr.trim()) {
69+
console.error(result.stderr.trim())
70+
}
71+
72+
process.exit(result.status ?? 1)
73+
74+
function requiredEnv(name) {
75+
const value = process.env[name]
76+
77+
if (!value) {
78+
console.error(`Missing required environment variable: ${name}`)
79+
process.exit(1)
80+
}
81+
82+
return value
83+
}
84+
85+
function sanitizeFileName(value) {
86+
return value.replace(/[^a-z0-9.-]+/gi, '-')
87+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/usr/bin/env node
2+
3+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
4+
import { dirname, join } from 'node:path'
5+
6+
const reportPath =
7+
process.argv[2] ?? 'evals/intent-discovery/runs/latest/vitest-results.json'
8+
const apiKey = process.env.OPENAI_API_KEY
9+
const model = process.env.INTENT_DISCOVERY_LLM_JUDGE_MODEL ?? 'gpt-4o-mini'
10+
const requestTimeoutMs = Number(
11+
process.env.INTENT_DISCOVERY_LLM_JUDGE_TIMEOUT_MS ?? '30000',
12+
)
13+
14+
if (!apiKey) {
15+
console.log('Skipped LLM judge: OPENAI_API_KEY is not set.')
16+
process.exit(0)
17+
}
18+
19+
const report = JSON.parse(readFileSync(reportPath, 'utf8'))
20+
const cases = reportCases(report)
21+
const judgments = []
22+
23+
for (const item of cases) {
24+
judgments.push(await judgeCase({ apiKey, item, model }))
25+
}
26+
27+
const output = {
28+
generatedAt: new Date().toISOString(),
29+
judgments,
30+
model,
31+
}
32+
const outDir = dirname(reportPath)
33+
mkdirSync(outDir, { recursive: true })
34+
writeFileSync(
35+
join(outDir, 'llm-judge.json'),
36+
`${JSON.stringify(output, null, 2)}\n`,
37+
)
38+
console.log(JSON.stringify(output, null, 2))
39+
40+
function reportCases(report) {
41+
return (report.testResults ?? []).flatMap((suite) =>
42+
(suite.assertionResults ?? [])
43+
.filter((test) => test.meta?.eval)
44+
.map((test) => {
45+
const run = test.meta.harness?.run ?? {}
46+
const artifacts = run.artifacts ?? {}
47+
const scores = Object.fromEntries(
48+
(test.meta.eval.scores ?? []).map((score) => [
49+
score.name,
50+
score.score ?? 0,
51+
]),
52+
)
53+
54+
return {
55+
artifacts: pick(artifacts, [
56+
'condition',
57+
'expectedSkillAreas',
58+
'intentCommandsInvoked',
59+
'loadedSkills',
60+
'runnerStatus',
61+
'taskId',
62+
]),
63+
finalAnswer: test.meta.eval.output?.finalAnswer ?? '',
64+
scores,
65+
title: test.title,
66+
}
67+
}),
68+
)
69+
}
70+
71+
async function judgeCase({ apiKey, item, model }) {
72+
const controller = new AbortController()
73+
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs)
74+
let response
75+
76+
try {
77+
response = await fetch('https://api.openai.com/v1/chat/completions', {
78+
body: JSON.stringify({
79+
messages: [
80+
{
81+
role: 'system',
82+
content:
83+
'You judge whether a coding agent output appears to apply loaded library skill guidance. You must not decide whether Intent was invoked; that is provided by deterministic scores. Return strict JSON only.',
84+
},
85+
{
86+
role: 'user',
87+
content: JSON.stringify({
88+
instruction:
89+
'Assess final output quality only. Return {"appliedGuidance":"yes"|"no"|"unknown","rationale":"..."}. Use unknown when evidence is insufficient.',
90+
item,
91+
}),
92+
},
93+
],
94+
model,
95+
response_format: { type: 'json_object' },
96+
temperature: 0,
97+
}),
98+
headers: {
99+
authorization: `Bearer ${apiKey}`,
100+
'content-type': 'application/json',
101+
},
102+
method: 'POST',
103+
signal: controller.signal,
104+
})
105+
} catch (error) {
106+
return {
107+
deterministicScores: item.scores,
108+
error: `LLM judge request failed: ${String(error)}`,
109+
title: item.title,
110+
}
111+
} finally {
112+
clearTimeout(timeout)
113+
}
114+
115+
if (!response.ok) {
116+
return {
117+
error: await response.text(),
118+
title: item.title,
119+
}
120+
}
121+
122+
const body = await response.json()
123+
const content = body.choices?.[0]?.message?.content ?? '{}'
124+
let judgment
125+
try {
126+
judgment = JSON.parse(content)
127+
} catch (error) {
128+
return {
129+
deterministicScores: item.scores,
130+
error: `Invalid JSON from model: ${String(error)}`,
131+
rawContent: content,
132+
title: item.title,
133+
}
134+
}
135+
136+
return {
137+
deterministicScores: item.scores,
138+
judgment,
139+
title: item.title,
140+
}
141+
}
142+
143+
function pick(value, keys) {
144+
return Object.fromEntries(
145+
keys
146+
.filter((key) => Object.prototype.hasOwnProperty.call(value, key))
147+
.map((key) => [key, value[key]]),
148+
)
149+
}

0 commit comments

Comments
 (0)