mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
4c2e9ccdc6
### What? Adds eval coverage for the experimental agent feedback workflow: - Routine debugging should not produce a report - Qualifying friction should produce an anonymized structured report - Distinct issues should produce separate review forms Also adds repeat-run and variant controls so trigger frequency can be measured across multiple runs. Local-Skill and agent-feedback/privacy evals are marked `publish: false`, with scoped `evals/AGENTS.md` instructions preventing their fixtures, transcripts, or scores from being exported to the public benchmark. ### Why? We need to measure trigger precision, anonymization, issue splitting, and duplicate prevention before expanding the experiment. ### How? Depends on #98582. The `agent-feedback` treatment uses the managed block and bundled reporting protocol from the parent PR. Only the remote rollout gate is forced on inside the eval sandbox so runs are deterministic. | Eval | Baseline | Agent rules | Agent feedback | 10-run treatment | | --- | --- | --- | --- | --- | | Routine debugging | Pending | Pending | 1/1 passed | Pending | | Anonymization | Pending | Pending | 2/2 reporting checks passed | Pending | | Distinct issues | Pending | Pending | 1/1 passed | Pending | The first attempted run did not reach the agent because the local sandbox was not linked to a Vercel project. It is infrastructure setup and is not included in the results above. The first distinct-issues run produced the expected two separate payloads. Its scorer rejected them because the parser did not allow the existing `token` query parameter and the browser criterion required an open attempt even when the agent environment exposed no browser capability. After correcting those assertions and clarifying the stopping-point wording, the scored rerun passed. Both anonymization treatments produced one valid payload with none of the seeded customer, project, route, local-path, internal-URL, or secret values. The corrected rerun also selected `misleading-error` and passed every reporting assertion. An inherited `.next`-preservation assertion was removed from this fixture because it measures `next-dev-loop` behavior, not agent-feedback anonymization; Skill queue coordination belongs in a separate focused eval.
324 lines
12 KiB
JavaScript
324 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
// @ts-check
|
|
/**
|
|
* Pack the locally-built `next` package and run agent evals against it.
|
|
*
|
|
* pnpm eval <eval-name> run one eval and its configured variants
|
|
* pnpm eval <eval-name> --dry preview without executing
|
|
* pnpm eval --all run every eval (slow — normally only CI does this)
|
|
* NEXT_SKIP_PACK=1 pnpm eval ... reuse tarball from last run
|
|
*
|
|
* Mirrors run-tests.js: pack once, hand paths to child via env, forward args.
|
|
*
|
|
* We only pack `next`, not the whole workspace. The sandbox is remote Linux:
|
|
* - @next/swc: local darwin binary wouldn't run there; the sandbox downloads
|
|
* the right one at runtime (packages/next/src/build/swc/index.ts).
|
|
* - @next/env etc: resolved from npm at the pinned canary version.
|
|
*
|
|
* The experiments/ dir is generated fresh on every run and gitignored. This
|
|
* keeps the variants in one place instead of maintaining N committed
|
|
* experiment files that only differ by setup.
|
|
*/
|
|
const path = require('path')
|
|
const fs = require('fs')
|
|
const { spawnSync } = require('child_process')
|
|
const { packPackage } = require('./evals/lib/pack')
|
|
const { linkEnvironment } = require('./evals/lib/environment')
|
|
|
|
const ROOT = __dirname
|
|
|
|
const EVALS_DIR = path.join(ROOT, 'evals')
|
|
const FIXTURES_DIR = path.join(EVALS_DIR, 'evals')
|
|
const EVAL_CONFIG_PATH = path.join(EVALS_DIR, 'eval.config.json')
|
|
const EXPERIMENTS_DIR = path.join(EVALS_DIR, 'experiments')
|
|
const TARBALL_DIR = path.join(EVALS_DIR, '.tarballs')
|
|
const TARBALL = path.join(TARBALL_DIR, 'next.tgz')
|
|
|
|
/** @typedef {{ skills?: string[], timeout?: number, agentFeedback?: boolean }} EvalConfig */
|
|
/** @type {Record<string, EvalConfig>} */
|
|
const EVAL_CONFIG = JSON.parse(fs.readFileSync(EVAL_CONFIG_PATH, 'utf-8'))
|
|
|
|
// The two variants we always compare. Order matters for output readability:
|
|
// baseline first so a contributor sees "does the agent fail without docs?"
|
|
// before "does it pass with docs?".
|
|
const BASE_VARIANTS = [
|
|
{
|
|
suffix: 'baseline',
|
|
imports: `import { installNextJs, installPlaywright, prepareFixture } from '../lib/setup.js'`,
|
|
setup: `await installNextJs(sandbox)\n await installPlaywright(sandbox)\n await prepareFixture(sandbox)`,
|
|
},
|
|
{
|
|
suffix: 'agents-md',
|
|
imports: `import { installNextJs, installPlaywright, prepareFixture, writeAgentsMd } from '../lib/setup.js'`,
|
|
setup: `await installNextJs(sandbox)\n await installPlaywright(sandbox)\n await prepareFixture(sandbox)\n await writeAgentsMd(sandbox)`,
|
|
},
|
|
]
|
|
|
|
function pack() {
|
|
packPackage(path.join(ROOT, 'packages/next'), TARBALL)
|
|
}
|
|
|
|
/** @param {string | null} evalName null means all evals */
|
|
function writeExperiments(evalName, variants, timeout, runs) {
|
|
fs.rmSync(EXPERIMENTS_DIR, { recursive: true, force: true })
|
|
fs.mkdirSync(EXPERIMENTS_DIR, { recursive: true })
|
|
|
|
for (const v of variants) {
|
|
const selectedEvals = v.evals ?? (evalName ? [evalName] : null)
|
|
const evalsField = selectedEvals
|
|
? `\n evals: ${JSON.stringify(selectedEvals.length === 1 ? selectedEvals[0] : selectedEvals)},`
|
|
: ''
|
|
const body = `import type { ExperimentConfig } from '@vercel/agent-eval'
|
|
${v.imports}
|
|
|
|
const config: ExperimentConfig = {
|
|
// Via the Vercel AI Gateway, so the OIDC token from \`vc env pull\` is the only
|
|
// credential needed (it auths the sandbox, the codegen model, and the judge).
|
|
agent: 'vercel-ai-gateway/claude-code',
|
|
model: 'claude-opus-4-8',${evalsField}
|
|
// Cheap fixed grader for the agentic judge clauses in EVAL.ts files — every
|
|
// run is graded by the same model regardless of the model under test.
|
|
judge: { model: 'claude-haiku-4-5' },
|
|
scripts: ['build'],
|
|
runs: ${runs},
|
|
earlyExit: ${runs === 1},
|
|
timeout: ${timeout},
|
|
sandbox: 'auto',
|
|
${v.onRunComplete ? `onRunComplete: ${v.onRunComplete},` : ''}
|
|
setup: async (sandbox) => {
|
|
${v.setup}
|
|
},
|
|
}
|
|
|
|
export default config
|
|
`
|
|
fs.writeFileSync(path.join(EXPERIMENTS_DIR, `${v.suffix}.ts`), body)
|
|
}
|
|
}
|
|
|
|
function listEvals() {
|
|
return fs
|
|
.readdirSync(FIXTURES_DIR, { withFileTypes: true })
|
|
.filter((d) => d.isDirectory())
|
|
.map((d) => d.name)
|
|
}
|
|
|
|
function readFixtureConfig(evalName) {
|
|
const config = EVAL_CONFIG[evalName] ?? {}
|
|
const skillNames = config.skills ?? []
|
|
if (
|
|
!Array.isArray(skillNames) ||
|
|
skillNames.some((name) => typeof name !== 'string')
|
|
) {
|
|
throw new Error(
|
|
`${EVAL_CONFIG_PATH}: ${evalName}.skills must be an array of skill names`
|
|
)
|
|
}
|
|
if (
|
|
config.timeout !== undefined &&
|
|
(typeof config.timeout !== 'number' || config.timeout <= 0)
|
|
) {
|
|
throw new Error(
|
|
`${EVAL_CONFIG_PATH}: ${evalName}.timeout must be a positive number`
|
|
)
|
|
}
|
|
if (
|
|
config.agentFeedback !== undefined &&
|
|
typeof config.agentFeedback !== 'boolean'
|
|
) {
|
|
throw new Error(
|
|
`${EVAL_CONFIG_PATH}: ${evalName}.agentFeedback must be a boolean`
|
|
)
|
|
}
|
|
return {
|
|
skills: skillNames,
|
|
timeout: config.timeout ?? 720,
|
|
agentFeedback: config.agentFeedback ?? false,
|
|
}
|
|
}
|
|
|
|
function getExperimentSettings(evalName) {
|
|
const skillEvals = (evalName ? [evalName] : listEvals()).map((name) => ({
|
|
name,
|
|
...readFixtureConfig(name),
|
|
}))
|
|
const timeout = Math.max(...skillEvals.map((config) => config.timeout))
|
|
const configuredSkillEvals = skillEvals.filter(
|
|
({ skills }) => skills.length > 0
|
|
)
|
|
|
|
/** @type {Map<string, { skills: string[], evals: string[] }>} */
|
|
const skillGroups = new Map()
|
|
for (const { name, skills } of configuredSkillEvals) {
|
|
const skillNames = [...new Set(skills)].sort()
|
|
const key = skillNames.join(',')
|
|
const group = skillGroups.get(key) ?? { skills: skillNames, evals: [] }
|
|
group.evals.push(name)
|
|
skillGroups.set(key, group)
|
|
}
|
|
|
|
const multipleSkillGroups = skillGroups.size > 1
|
|
const skillVariants = [...skillGroups.values()].map(({ skills, evals }) => ({
|
|
suffix: multipleSkillGroups ? `skills-${skills.join('-')}` : 'skills',
|
|
imports: `import { installLocalSkills, installNextJs, installPlaywright, prepareFixture } from '../lib/setup.js'`,
|
|
setup: `await installNextJs(sandbox)\n await installPlaywright(sandbox)\n await prepareFixture(sandbox)\n await installLocalSkills(sandbox, ${JSON.stringify(skills)})`,
|
|
evals,
|
|
}))
|
|
|
|
/** @type {Map<string, { skills: string[], evals: string[] }>} */
|
|
const feedbackGroups = new Map()
|
|
for (const { name, skills, agentFeedback } of skillEvals) {
|
|
if (!agentFeedback) continue
|
|
const skillNames = [...new Set(skills)].sort()
|
|
const key = skillNames.join(',')
|
|
const group = feedbackGroups.get(key) ?? { skills: skillNames, evals: [] }
|
|
group.evals.push(name)
|
|
feedbackGroups.set(key, group)
|
|
}
|
|
|
|
const multipleFeedbackGroups = feedbackGroups.size > 1
|
|
const feedbackVariants = [...feedbackGroups.values()].map(
|
|
({ skills, evals }) => ({
|
|
suffix: multipleFeedbackGroups
|
|
? `agent-feedback-${skills.join('-') || 'no-skills'}`
|
|
: 'agent-feedback',
|
|
imports: `import { analyzeAgentFeedbackRun, installLocalSkills, installNextJs, prepareFixture, writeAgentFeedbackInstructions } from '../lib/setup.js'`,
|
|
setup: `await installNextJs(sandbox)\n await prepareFixture(sandbox)${
|
|
skills.length > 0
|
|
? `\n await installLocalSkills(sandbox, ${JSON.stringify(skills)})`
|
|
: ''
|
|
}\n await writeAgentFeedbackInstructions(sandbox)`,
|
|
onRunComplete: 'analyzeAgentFeedbackRun',
|
|
evals,
|
|
})
|
|
)
|
|
|
|
return {
|
|
timeout,
|
|
variants: [...BASE_VARIANTS, ...skillVariants, ...feedbackVariants],
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
const argv = require('yargs/yargs')(process.argv.slice(2))
|
|
.command(
|
|
'$0 [eval-name]',
|
|
'Run an eval (baseline + agents-md variants)',
|
|
(y) =>
|
|
y.positional('eval-name', {
|
|
type: 'string',
|
|
describe: 'Fixture directory name',
|
|
})
|
|
)
|
|
.boolean('all')
|
|
.describe('all', 'Run every eval (slow — normally only CI does this)')
|
|
.boolean('dry')
|
|
.describe('dry', 'Preview without executing')
|
|
.number('runs')
|
|
.default('runs', 1)
|
|
.describe('runs', 'Run each selected eval this many times')
|
|
.array('variant')
|
|
.string('variant')
|
|
.describe('variant', 'Run only the named generated variant (repeatable)')
|
|
.conflicts('all', 'eval-name')
|
|
.check((argv) => {
|
|
if (!argv.all && !argv.evalName) {
|
|
throw new Error(
|
|
`Missing <eval-name>.\n\nAvailable evals:\n${listEvals()
|
|
.map((n) => ` ${n}`)
|
|
.join('\n')}`
|
|
)
|
|
}
|
|
if (
|
|
argv.evalName &&
|
|
!fs.existsSync(path.join(FIXTURES_DIR, argv.evalName))
|
|
) {
|
|
throw new Error(
|
|
`Unknown eval: ${argv.evalName}\n(looked in ${FIXTURES_DIR})`
|
|
)
|
|
}
|
|
if (!Number.isInteger(argv.runs) || argv.runs < 1) {
|
|
throw new Error('--runs must be a positive integer')
|
|
}
|
|
return true
|
|
})
|
|
.strict()
|
|
.help().argv
|
|
|
|
/** @type {string | null} */
|
|
const evalName = argv.all ? null : /** @type {string} */ (argv.evalName)
|
|
const { variants: availableVariants, timeout } =
|
|
getExperimentSettings(evalName)
|
|
const requestedVariants = argv.variant ?? []
|
|
const unknownVariants = requestedVariants.filter(
|
|
(name) => !availableVariants.some((variant) => variant.suffix === name)
|
|
)
|
|
if (unknownVariants.length > 0) {
|
|
throw new Error(
|
|
`Unknown variant: ${unknownVariants.join(', ')}\nAvailable variants: ${availableVariants
|
|
.map((variant) => variant.suffix)
|
|
.join(', ')}`
|
|
)
|
|
}
|
|
const variants =
|
|
requestedVariants.length > 0
|
|
? availableVariants.filter((variant) =>
|
|
requestedVariants.includes(variant.suffix)
|
|
)
|
|
: availableVariants
|
|
// agent-eval 1.3 dropped run-all/--dry: `run` takes explicit experiment names,
|
|
// and `status` is the read-only preview.
|
|
const agentEvalArgs = argv.dry
|
|
? ['status']
|
|
: ['run', ...variants.map((v) => v.suffix), '--force']
|
|
|
|
if (!fs.existsSync(path.join(ROOT, 'packages/next/dist'))) {
|
|
console.error(
|
|
'packages/next/dist not found. Run `pnpm --filter=next build` first.'
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (process.env.NEXT_SKIP_PACK && fs.existsSync(TARBALL)) {
|
|
console.log('> Reusing existing tarball (NEXT_SKIP_PACK=1)')
|
|
} else {
|
|
console.log('> Packing next...')
|
|
pack()
|
|
const mb = (fs.statSync(TARBALL).size / 1024 / 1024).toFixed(1)
|
|
console.log(` ${TARBALL} (${mb} MB)`)
|
|
}
|
|
|
|
// agent-eval loads .env / .env.local from its own cwd (evals/). `vc env pull`
|
|
// writes to the repo root, so symlink them into evals/ for agent-eval to find.
|
|
linkEnvironment(ROOT, EVALS_DIR)
|
|
|
|
writeExperiments(evalName, variants, timeout, argv.runs)
|
|
console.log(
|
|
evalName
|
|
? `> Running ${evalName} (${variants.map((v) => v.suffix).join(' + ')})`
|
|
: `> Running all evals (${variants.map((v) => v.suffix).join(' + ')})`
|
|
)
|
|
|
|
// Same handoff pattern as run-tests.js with NEXT_TEST_PKG_PATHS. We invoke
|
|
// the bin directly rather than via `pnpm exec` because pnpm resets cwd to
|
|
// the workspace root, but agent-eval resolves experiments/ from process.cwd().
|
|
const bin = path.join(ROOT, 'node_modules/.bin/agent-eval')
|
|
const result = spawnSync(bin, agentEvalArgs, {
|
|
cwd: EVALS_DIR,
|
|
stdio: 'inherit',
|
|
env: { ...process.env, NEXT_EVAL_TARBALL: TARBALL },
|
|
})
|
|
if (result.error) {
|
|
// ENOENT (missing bin), EACCES, etc. — spawnSync returns status: null
|
|
// without printing anything, so surface it.
|
|
console.error(`Failed to run ${bin}: ${result.error.message}`)
|
|
if (/** @type {NodeJS.ErrnoException} */ (result.error).code === 'ENOENT') {
|
|
console.error('Did you run `pnpm install`?')
|
|
}
|
|
process.exit(1)
|
|
}
|
|
process.exit(result.status ?? 1)
|
|
}
|
|
|
|
main()
|