openmerit 0.1.7-preview.1 → 0.1.7-preview.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/dist/eval/src/adapters.mjs +52 -13
- package/dist/eval/src/cli.mjs +59 -11
- package/dist/eval/src/config.mjs +8 -2
- package/dist/eval/src/report.mjs +12 -4
- package/dist/eval/src/runner.mjs +32 -15
- package/docs/agent-evaluations.md +51 -7
- package/docs/commands.md +3 -1
- package/docs/security.md +2 -0
- package/docs/site-faq-maintenance.md +12 -0
- package/docs/testing.md +19 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.7-preview.2 — 2026-10-15
|
|
4
|
+
|
|
5
|
+
- Ask for a $10, custom or no-fixed-cap evaluation budget after model scope; save the explicit choice, handle reservations internally, and preserve cumulative usage across reruns and budget changes. Add `eval plan --budget <USD|unlimited>` for coding agents and noninteractive setup.
|
|
6
|
+
- Separate native budget and environment blocks from task scores in agent evaluations; probe the Codex Linux sandbox before making model requests.
|
|
7
|
+
- Recover partial Claude usage, retain partial cost estimates, keep missing native pricing unknown, and preserve budget reservations independently of estimated charges. Report version 2 separates these accounting rules from earlier comparisons.
|
|
8
|
+
|
|
3
9
|
## 0.1.7-preview.1 — 2026-10-15
|
|
4
10
|
|
|
5
11
|
- Add `openmerit eval init/doctor/plan/run/watch/report`: Scout shortlists, explicit model scope, Claude Code/Codex/OpenCode adapters, fresh task attempts, local graders, balanced adaptive 3–10 repetitions, revision comparisons and cumulative budget accounting.
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ Explore the [hosted sample](https://dash.openmerit.ai/) or [before/after dashboa
|
|
|
22
22
|
|
|
23
23
|
## Compare models with Pi
|
|
24
24
|
|
|
25
|
-
The **npm preview `0.1.7-preview.
|
|
25
|
+
The **npm preview `0.1.7-preview.2`** provides `openmerit eval` for evaluating Claude Code, Codex and OpenCode with your CLI. Choose model scope and a $10, custom or no-fixed-cap budget, freeze Scout's shortlist, and compare 3–10 fresh attempts after source changes. Budget and sandbox blocks stay separate from task scores; unknown costs stay unknown. Install with `npm install -g openmerit@preview`. See [agent evaluations](https://unpkg.com/openmerit@0.1.7-preview.2/docs/agent-evaluations.md) for setup, graders, budgets and availability. This is separate from the Pi workflow below.
|
|
26
26
|
|
|
27
27
|
With Pi 0.87 and a model provider configured:
|
|
28
28
|
|
|
@@ -2,18 +2,28 @@ import { execute, agentEnvironment } from './process.mjs';
|
|
|
2
2
|
|
|
3
3
|
const number = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
|
4
4
|
const sum = values => values.length && values.every(v => v !== null) ? values.reduce((a, b) => a + b, 0) : null;
|
|
5
|
+
const maximum = values => values.some(v => v !== null) ? Math.max(...values.filter(v => v !== null)) : null;
|
|
5
6
|
export const agents = ['claude', 'codex', 'opencode'];
|
|
6
7
|
|
|
7
8
|
export function connectionFailure(text) {
|
|
8
|
-
if (/
|
|
9
|
+
if (/budget_exhausted|error_max_budget_usd|(?:maximum|exceeded|exhausted|insufficient).{0,25}(?:budget|credits)|(?:budget|credits).{0,25}(?:exceeded|exhausted)/i.test(text)) return 'budget_exhausted';
|
|
10
|
+
if (/(?:bwrap|bubblewrap|sandbox|landlock|seccomp|unshare|user namespace|uid_map).{0,160}(?:failed|not permitted|unavailable|denied)|failed to (?:start|initialize|create).{0,40}sandbox/i.test(text)) return 'sandbox_unavailable';
|
|
11
|
+
if (/\b(?:HTTP\s*)?401\b.{0,30}(?:unauthorized|authentication)|unauthorized|authentication[_ -]?(?:error|required)|invalid api key|not logged in|please log in/i.test(text)) return 'authentication';
|
|
9
12
|
if (/model.{0,100}(?:not supported|not found|not available|does not exist)|(?:unknown|invalid|unsupported) model|ProviderModelNotFoundError/i.test(text)) return 'model_unavailable';
|
|
10
13
|
if (/rejected permission|permission.{0,30}(?:denied|rejected)|not allowed to use.{0,30}tool/i.test(text)) return 'permission_denied';
|
|
11
14
|
return null;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
function toolFailure(text) {
|
|
18
|
+
const reason = connectionFailure(text);
|
|
19
|
+
// A task's own HTTP/auth/budget errors are task outcomes, not evidence that
|
|
20
|
+
// the coding agent's provider connection failed.
|
|
21
|
+
return ['sandbox_unavailable', 'permission_denied'].includes(reason) ? reason : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function invocation(connection, reservationUsd, unlimitedBudget = false) {
|
|
15
25
|
const model = connection.model;
|
|
16
|
-
if (connection.agent === 'claude') return ['-p', '--output-format', 'json', '--model', model, '--no-session-persistence', '--permission-mode', 'dontAsk', '--allowedTools', 'Read,Glob,Grep,Bash,Edit,Write', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--setting-sources', '', '--max-budget-usd', String(reservationUsd)];
|
|
26
|
+
if (connection.agent === 'claude') return ['-p', '--output-format', 'json', '--model', model, '--no-session-persistence', '--permission-mode', 'dontAsk', '--allowedTools', 'Read,Glob,Grep,Bash,Edit,Write', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--setting-sources', '', ...(unlimitedBudget ? [] : ['--max-budget-usd', String(reservationUsd)])];
|
|
17
27
|
if (connection.agent === 'codex') return ['exec', '--json', '--model', model, '--sandbox', 'workspace-write', '--skip-git-repo-check', '--ephemeral', '-'];
|
|
18
28
|
if (connection.agent === 'opencode') return ['run', '--format', 'json', '--model', model, '--auto'];
|
|
19
29
|
throw new Error('Unknown agent. Use claude, codex or opencode.');
|
|
@@ -23,7 +33,11 @@ export function parseOutput(agent, stdout) {
|
|
|
23
33
|
let events;
|
|
24
34
|
try { const parsed = JSON.parse(stdout); events = Array.isArray(parsed) ? parsed : [parsed]; }
|
|
25
35
|
catch { events = stdout.split('\n').flatMap(line => { try { return [JSON.parse(line)]; } catch { return []; } }); }
|
|
26
|
-
let output = '', usage = null, costUsd = null, completed = false, failed = false;
|
|
36
|
+
let output = '', usage = null, costUsd = null, partialCostUsd = null, completed = false, failed = false, failureReason = null;
|
|
37
|
+
// Only native failure fields are evidence of a blocked execution. Task text,
|
|
38
|
+
// successful tool output and model commentary can legitimately discuss errors.
|
|
39
|
+
const errors = events.filter(e => ['error', 'turn.failed'].includes(e.type));
|
|
40
|
+
for (const event of errors) failureReason ??= connectionFailure(JSON.stringify(event));
|
|
27
41
|
const observedModels = new Set();
|
|
28
42
|
if (agent === 'claude') {
|
|
29
43
|
const result = events.findLast(e => e.type === 'result');
|
|
@@ -32,8 +46,16 @@ export function parseOutput(agent, stdout) {
|
|
|
32
46
|
failed = !completed; output = result.result ?? '';
|
|
33
47
|
const u = result.usage;
|
|
34
48
|
if (u) usage = { input: sum([number(u.input_tokens), number(u.cache_read_input_tokens ?? 0), number(u.cache_creation_input_tokens ?? 0)]), output: number(u.output_tokens), cached: number(u.cache_read_input_tokens ?? 0) };
|
|
35
|
-
|
|
49
|
+
const models = Object.values(result.modelUsage ?? {});
|
|
50
|
+
if (models.length) {
|
|
51
|
+
const modelUsage = { input: sum(models.map(m => sum([number(m.inputTokens), number(m.cacheReadInputTokens ?? 0), number(m.cacheCreationInputTokens ?? 0)]))), output: sum(models.map(m => number(m.outputTokens))), cached: sum(models.map(m => number(m.cacheReadInputTokens ?? 0))) };
|
|
52
|
+
// A budget stop can emit zero aggregate usage but nonzero per-model usage.
|
|
53
|
+
usage = Object.fromEntries(Object.keys(modelUsage).map(k => [k, maximum([usage?.[k] ?? null, modelUsage[k]])]));
|
|
54
|
+
}
|
|
55
|
+
costUsd = maximum([number(result.total_cost_usd), sum(models.map(m => number(m.costUSD)))]);
|
|
36
56
|
for (const model of Object.keys(result.modelUsage ?? {})) observedModels.add(model);
|
|
57
|
+
if (failed) failureReason ??= connectionFailure(JSON.stringify({ subtype: result.subtype, terminal_reason: result.terminal_reason, errors: result.errors }));
|
|
58
|
+
if (result.permission_denials?.length) failureReason ??= 'permission_denied';
|
|
37
59
|
}
|
|
38
60
|
} else if (agent === 'codex') {
|
|
39
61
|
const turns = events.filter(e => e.type === 'turn.completed');
|
|
@@ -41,6 +63,9 @@ export function parseOutput(agent, stdout) {
|
|
|
41
63
|
failed = events.some(e => ['turn.failed', 'error'].includes(e.type));
|
|
42
64
|
output = events.findLast(e => e.type === 'item.completed' && e.item?.type === 'agent_message')?.item.text ?? '';
|
|
43
65
|
if (turns.length) usage = { input: sum(turns.map(e => number(e.usage?.input_tokens))), output: sum(turns.map(e => number(e.usage?.output_tokens))), cached: sum(turns.map(e => number(e.usage?.cached_input_tokens))) };
|
|
66
|
+
for (const event of events.filter(e => e.type === 'item.completed' && e.item?.type === 'command_execution' && (e.item.exit_code > 0 || e.item.status === 'failed'))) {
|
|
67
|
+
failureReason ??= toolFailure(event.item.aggregated_output ?? '');
|
|
68
|
+
}
|
|
44
69
|
} else if (agent === 'opencode') {
|
|
45
70
|
const steps = events.filter(e => e.type === 'step_finish');
|
|
46
71
|
const finalStep = steps.findLast(e => ['stop', 'end_turn'].includes(e.part?.reason));
|
|
@@ -50,24 +75,38 @@ export function parseOutput(agent, stdout) {
|
|
|
50
75
|
output = finalStep?.part?.messageID ? texts.filter(e => e.part?.messageID === finalStep.part.messageID).map(e => e.part?.text ?? '').join('\n') : texts.at(-1)?.part?.text ?? '';
|
|
51
76
|
if (steps.length) {
|
|
52
77
|
usage = { input: sum(steps.map(e => sum([number(e.part?.tokens?.input), number(e.part?.tokens?.cache?.read ?? 0), number(e.part?.tokens?.cache?.write ?? 0)]))), output: sum(steps.map(e => sum([number(e.part?.tokens?.output), number(e.part?.tokens?.reasoning ?? 0)]))), cached: sum(steps.map(e => number(e.part?.tokens?.cache?.read ?? 0))) };
|
|
53
|
-
|
|
78
|
+
const costs = steps.map(e => number(e.part?.cost));
|
|
79
|
+
costUsd = costs.every(c => c !== null && c > 0) ? sum(costs) : null;
|
|
80
|
+
if (costUsd === null) partialCostUsd = sum(costs.filter(c => c !== null && c > 0));
|
|
54
81
|
}
|
|
82
|
+
for (const event of events.filter(e => e.type === 'tool_use' && e.part?.state?.status === 'error')) failureReason ??= toolFailure(event.part.state.error ?? '');
|
|
55
83
|
}
|
|
56
|
-
|
|
84
|
+
// Native zero prices can mean missing pricing metadata, not free inference.
|
|
85
|
+
return { output, usage, costUsd: costUsd > 0 ? costUsd : null, partialCostUsd, completed: completed && !failed, failureReason, observedModels: [...observedModels] };
|
|
57
86
|
}
|
|
58
87
|
|
|
59
|
-
export async function preflight(connection) {
|
|
88
|
+
export async function preflight(connection, { cwd, executeCommand = execute, platform = process.platform } = {}) {
|
|
60
89
|
const command = connection.command ?? connection.agent;
|
|
61
|
-
const
|
|
62
|
-
const
|
|
90
|
+
const options = { cwd, timeoutMs: 10_000, env: agentEnvironment(connection.env) };
|
|
91
|
+
const version = await executeCommand(command, ['--version'], options);
|
|
92
|
+
const help = await executeCommand(command, connection.agent === 'codex' ? ['exec', '--help'] : connection.agent === 'opencode' ? ['run', '--help'] : ['--help'], options);
|
|
63
93
|
const flags = connection.agent === 'codex' ? ['--json', '--ephemeral', '--sandbox'] : connection.agent === 'claude' ? ['--output-format', '--no-session-persistence', '--max-budget-usd'] : ['--format', '--model', '--auto'];
|
|
64
94
|
if (version.code !== 0 || help.code !== 0 || !flags.every(f => (help.stdout + help.stderr).includes(f))) throw new Error(`${connection.agent} lacks the required non-interactive JSON interface. Update it or set this connection's command to a compatible executable.`);
|
|
95
|
+
if (connection.agent === 'codex' && platform === 'linux') {
|
|
96
|
+
const sandboxHelp = await executeCommand(command, ['sandbox', '--help'], options);
|
|
97
|
+
const legacy = /^\s+linux\s/m.test(sandboxHelp.stdout);
|
|
98
|
+
const probe = await executeCommand(command, ['-c', 'sandbox_mode="workspace-write"', 'sandbox', ...(legacy ? ['linux'] : []), '--', process.execPath, '-e', 'process.stdout.write("openmerit-sandbox-ok")'], options);
|
|
99
|
+
if (probe.code !== 0 || probe.reason || probe.stdout !== 'openmerit-sandbox-ok') {
|
|
100
|
+
throw Object.assign(new Error('Codex sandbox could not run a local probe. Fix host sandbox support before evaluating; no model request was made.'), { failureReason: 'sandbox_unavailable' });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
65
103
|
return version.stdout.trim().slice(0, 160);
|
|
66
104
|
}
|
|
67
105
|
|
|
68
106
|
export async function runAgent(connection, prompt, options) {
|
|
69
|
-
const result = await execute(connection.command ?? connection.agent, invocation(connection, options.reservationUsd), { ...options, input: prompt, env: agentEnvironment(connection.env) });
|
|
107
|
+
const result = await execute(connection.command ?? connection.agent, invocation(connection, options.reservationUsd, options.unlimitedBudget), { ...options, input: prompt, env: agentEnvironment(connection.env) });
|
|
70
108
|
const parsed = parseOutput(connection.agent, result.stdout);
|
|
71
|
-
const
|
|
72
|
-
|
|
109
|
+
const failureReason = parsed.failureReason ?? (result.code !== 0 ? connectionFailure(result.stderr) : null) ?? (['spawn_failed', 'cancelled', 'output_limit'].includes(result.reason) ? result.reason : null);
|
|
110
|
+
const status = failureReason === 'budget_exhausted' ? 'budget_exhausted' : ['sandbox_unavailable', 'permission_denied', 'spawn_failed'].includes(failureReason) ? 'environment_error' : result.reason ?? (failureReason || result.code !== 0 || !parsed.completed ? 'agent_error' : 'ok');
|
|
111
|
+
return { ...parsed, costUsd: status === 'ok' ? parsed.costUsd : null, partialCostUsd: status === 'ok' ? parsed.partialCostUsd : maximum([parsed.costUsd, parsed.partialCostUsd]), costBasis: parsed.costUsd === null && parsed.partialCostUsd === null ? 'unknown' : 'agent_estimate', seconds: result.seconds, status, failureReason };
|
|
73
112
|
}
|
package/dist/eval/src/cli.mjs
CHANGED
|
@@ -11,8 +11,8 @@ export const help = `OpenMerit · Agent evaluations (source preview)
|
|
|
11
11
|
|
|
12
12
|
openmerit eval init Add a starter suite and project agent instructions
|
|
13
13
|
openmerit eval doctor Check installed Claude Code, Codex and OpenCode CLIs
|
|
14
|
-
openmerit eval plan [--scope existing|all]
|
|
15
|
-
|
|
14
|
+
openmerit eval plan [--scope existing|all] [--budget <USD|unlimited>]
|
|
15
|
+
Choose scope and budget, then freeze Scout's shortlist
|
|
16
16
|
openmerit eval run [--json] Evaluate the frozen shortlist in fresh workspaces
|
|
17
17
|
openmerit eval watch [--json] Repeat after selected CLI/docs/fixture files change
|
|
18
18
|
openmerit eval report [--json] Read the latest result without running models
|
|
@@ -21,6 +21,9 @@ export const help = `OpenMerit · Agent evaluations (source preview)
|
|
|
21
21
|
|
|
22
22
|
Configure .openmerit/eval.json: explicit agent connections, task fixtures, local
|
|
23
23
|
graders, CLI variants, 3–10 repeats, and a cumulative budget. Init starts at $0.
|
|
24
|
+
Plan asks for $10, a custom amount, or no fixed cap; nothing is preselected.
|
|
25
|
+
Saved choices and prior budget usage persist across reruns. --budget changes
|
|
26
|
+
the total allowance, not the amount remaining. Reservations are automatic.
|
|
24
27
|
Scout receives task descriptions and, for existing scope, model IDs only.
|
|
25
28
|
Run/watch execute local agents and may incur charges. No background daemon.
|
|
26
29
|
`;
|
|
@@ -34,9 +37,10 @@ When building or changing this project's CLI, tools or harness documentation, us
|
|
|
34
37
|
|
|
35
38
|
- Read .openmerit/eval.json and run \`openmerit eval doctor\`.
|
|
36
39
|
- Ask once: "Models available through this project's existing connections, or all models Scout indexes?" Use \`eval plan --scope existing\` or \`--scope all\`. Reuse the saved choice. Never infer endpoint access from a model name or expose credentials.
|
|
40
|
+
- Then ask once: "What budget should OpenMerit use for this project's evaluations? 1. $10; 2. Custom amount (how much?); 3. No fixed cap." Pass the explicit answer as \`eval plan --budget 10\`, \`--budget <amount>\` or \`--budget unlimited\`, together with scope on first setup. Never choose a default or infer unlimited spending. Reuse an existing positive or unlimited budget unless the user changes it. The allowance is cumulative across reruns, not reset by edits or replanning. Reservations are internal; do not ask the user to calculate them. No fixed cap still has bounded tasks, 3–10 repetitions, timeouts and user-controlled watch; subsequent watched edits can incur further charges until stopped.
|
|
37
41
|
- Declare real connections with Scout model IDs, agent-native model IDs, and environment variable NAMES. Existing scope uses this project-declared inventory. All scope may require new authenticated connections before execution.
|
|
38
42
|
- Replace starter cases with representative tasks, fresh fixtures and deterministic standalone Node graders. A grader reads {workspace, output} JSON on stdin and returns {quality: 0..1}. Keep answer keys outside agent workspaces. Include a bare variant and a CLI/docs variant where useful.
|
|
39
|
-
- Confirm the suite
|
|
43
|
+
- Confirm the suite and variants before enabling paid runs, using the budget already selected above. Do not ask again for an unchanged authorized budget. Then plan once, run a baseline, make the requested CLI changes, and run again. Use \`eval watch\` only when the user wants continuous local reruns; it exits with the terminal and retains budget usage across restarts.
|
|
40
44
|
- Report quality, failures, tokens, reported costs and elapsed time per task/model/variant. A shortlist is a prior, not measured success. Do not claim missing costs are zero or that 3–10 repeats prove an exact ordering. Keep fresh holdouts for final validation.
|
|
41
45
|
- If a compatible watcher is already active, let it run; never start overlapping loops. Do not overwrite the frozen plan merely because the CLI changed.
|
|
42
46
|
<!-- openmerit-eval:end -->`;
|
|
@@ -61,16 +65,57 @@ export async function initialize(root) {
|
|
|
61
65
|
if (!ignore.split('\n').includes('/.openmerit/eval/')) await writeFile(join(root, '.gitignore'), `${ignore}${ignore.endsWith('\n') || !ignore ? '' : '\n'}/.openmerit/eval/\n`);
|
|
62
66
|
}
|
|
63
67
|
|
|
68
|
+
export function parseBudget(value) {
|
|
69
|
+
const input = value.trim();
|
|
70
|
+
if (input === 'unlimited') return input;
|
|
71
|
+
const amount = input.replace(/^\$/, '');
|
|
72
|
+
if (!/^(?:\d+(?:\.\d{1,2})?|\.\d{1,2})$/.test(amount) || Number(amount) <= 0 || Number(amount) > Number.MAX_SAFE_INTEGER / 100) throw new Error('Enter a positive USD amount with at most two decimal places, or "unlimited" for no fixed cap.');
|
|
73
|
+
return Number(amount);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ask is supplied only for an interactive terminal. Coding agents pass explicit
|
|
77
|
+
// flags after asking the user; EOF, blank input and missing flags never consent.
|
|
78
|
+
export async function choosePlanOptions(config, { scope, budget, ask, tell = () => {} } = {}) {
|
|
79
|
+
const next = structuredClone(config);
|
|
80
|
+
if (scope) next.scope = scope;
|
|
81
|
+
while (!['existing', 'all'].includes(next.scope)) {
|
|
82
|
+
if (!ask) throw new Error('Choose a scope with eval plan --scope existing or --scope all, then choose --budget <USD|unlimited>.');
|
|
83
|
+
const answer = (await ask('Use existing project connections or all models Scout indexes? [existing/all] ')).trim();
|
|
84
|
+
if (['existing', 'all'].includes(answer)) next.scope = answer;
|
|
85
|
+
else tell('Choose existing or all.');
|
|
86
|
+
}
|
|
87
|
+
if (budget !== undefined) next.budget = { maxUsd: parseBudget(budget), reservationUsd: 'auto' };
|
|
88
|
+
while (next.budget.maxUsd === 0) {
|
|
89
|
+
if (!ask) throw new Error('Choose an evaluation budget after model scope: --budget 10, --budget <USD>, or --budget unlimited. No amount is selected automatically.');
|
|
90
|
+
const choice = (await ask('What budget should OpenMerit use for this project’s evaluations?\n 1. $10\n 2. Custom amount\n 3. No fixed cap (watched changes can incur further charges until stopped)\nChoose [1/2/3]: ')).trim();
|
|
91
|
+
if (choice === '1') next.budget = { maxUsd: 10, reservationUsd: 'auto' };
|
|
92
|
+
else if (choice === '3') next.budget = { maxUsd: 'unlimited', reservationUsd: 'auto' };
|
|
93
|
+
else if (choice === '2') {
|
|
94
|
+
while (next.budget.maxUsd === 0) {
|
|
95
|
+
const amount = await ask('How much in USD? ');
|
|
96
|
+
try {
|
|
97
|
+
const parsed = parseBudget(amount);
|
|
98
|
+
if (parsed === 'unlimited') throw new Error('Enter a custom USD amount.');
|
|
99
|
+
next.budget = { maxUsd: parsed, reservationUsd: 'auto' };
|
|
100
|
+
} catch { tell('Enter a positive USD amount with at most two decimal places.'); }
|
|
101
|
+
}
|
|
102
|
+
} else tell('Choose 1, 2 or 3. Nothing is selected automatically.');
|
|
103
|
+
}
|
|
104
|
+
return next;
|
|
105
|
+
}
|
|
106
|
+
|
|
64
107
|
export async function main(args) {
|
|
65
108
|
if (!args.length || args.includes('--help') || args.includes('-h')) { console.log(help); return; }
|
|
66
|
-
const action = args.shift(); let root = process.cwd(), scope, json = false;
|
|
109
|
+
const action = args.shift(); let root = process.cwd(), scope, budget, json = false;
|
|
67
110
|
for (let i = 0; i < args.length; i++) {
|
|
68
111
|
if (args[i] === '--path' && args[i + 1]) root = resolve(args[++i]);
|
|
69
112
|
else if (args[i] === '--scope' && ['existing', 'all'].includes(args[i + 1])) scope = args[++i];
|
|
113
|
+
else if (args[i] === '--budget' && args[i + 1] && budget === undefined) { budget = args[++i]; parseBudget(budget); }
|
|
70
114
|
else if (args[i] === '--json') json = true;
|
|
71
115
|
else throw new Error(`Unknown eval argument: ${args[i]}`);
|
|
72
116
|
}
|
|
73
117
|
if (scope && action !== 'plan') throw new Error('--scope belongs to eval plan.');
|
|
118
|
+
if (budget !== undefined && action !== 'plan') throw new Error('--budget belongs to eval plan.');
|
|
74
119
|
if (action === 'init') { await initialize(root); console.log('Created .openmerit/eval.json, starter fixtures, and AGENTS.md / CLAUDE.md instructions. Declare connections, replace the starter task, and set an approved budget before running.'); return; }
|
|
75
120
|
if (action === 'doctor') {
|
|
76
121
|
const results = [];
|
|
@@ -84,14 +129,17 @@ export async function main(args) {
|
|
|
84
129
|
process.once('SIGINT', stop); process.once('SIGTERM', stop);
|
|
85
130
|
try {
|
|
86
131
|
if (action === 'plan') {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
132
|
+
const saved = await loadConfig(root);
|
|
133
|
+
const rl = process.stdin.isTTY && !json ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
134
|
+
const inputClosed = new AbortController();
|
|
135
|
+
rl?.once('close', () => inputClosed.abort());
|
|
136
|
+
let config;
|
|
137
|
+
try { config = await choosePlanOptions(saved, { scope, budget, ask: rl ? question => rl.question(question, { signal: AbortSignal.any([controller.signal, inputClosed.signal]) }) : undefined, tell: message => console.log(message) }); }
|
|
138
|
+
finally { rl?.close(); }
|
|
139
|
+
// Preserve explicit choices even if Scout is temporarily unavailable.
|
|
140
|
+
await saveJSON(join(root, configPath), config);
|
|
93
141
|
const plan = await makePlan(config);
|
|
94
|
-
await
|
|
142
|
+
await savePlan(root, plan);
|
|
95
143
|
console.log(JSON.stringify(plan, null, 2));
|
|
96
144
|
return;
|
|
97
145
|
}
|
package/dist/eval/src/config.mjs
CHANGED
|
@@ -43,7 +43,7 @@ export function validateConfig(config) {
|
|
|
43
43
|
v.files.forEach(safePath);
|
|
44
44
|
}
|
|
45
45
|
requireThat(Number.isInteger(config.repeats?.min) && Number.isInteger(config.repeats?.max) && config.repeats.min >= 3 && config.repeats.max <= 10 && config.repeats.min <= config.repeats.max, 'repeats must be between 3 and 10.');
|
|
46
|
-
requireThat(Number.isFinite(config.budget?.maxUsd) && config.budget.maxUsd >= 0 && Number.isFinite(config.budget.reservationUsd) && config.budget.reservationUsd > 0, 'set nonnegative maxUsd and positive
|
|
46
|
+
requireThat((config.budget?.maxUsd === 'unlimited' || Number.isFinite(config.budget?.maxUsd) && config.budget.maxUsd >= 0) && (config.budget.reservationUsd === 'auto' || Number.isFinite(config.budget.reservationUsd) && config.budget.reservationUsd > 0), 'set nonnegative maxUsd or explicit "unlimited", and reservationUsd to "auto" or a positive amount.');
|
|
47
47
|
requireThat(Number.isInteger(config.timeoutSeconds) && config.timeoutSeconds >= 1 && config.timeoutSeconds <= 1800, 'timeoutSeconds must be 1–1800.');
|
|
48
48
|
if (config.scoutURL !== undefined) {
|
|
49
49
|
const url = new URL(config.scoutURL);
|
|
@@ -53,6 +53,12 @@ export function validateConfig(config) {
|
|
|
53
53
|
}
|
|
54
54
|
export async function loadConfig(root) { return validateConfig(await readJSON(join(root, configPath))); }
|
|
55
55
|
|
|
56
|
+
export function reservationFor(config, plan) {
|
|
57
|
+
if (config.budget.reservationUsd !== 'auto') return config.budget.reservationUsd;
|
|
58
|
+
const attempts = plan.tasks.reduce((total, task) => total + task.candidates.reduce((n, model) => n + model.connections.length, 0), 0) * config.variants.length * config.repeats.min;
|
|
59
|
+
return config.budget.maxUsd === 'unlimited' || config.budget.maxUsd === 0 ? .25 : Math.min(.25, config.budget.maxUsd / Math.max(1, attempts));
|
|
60
|
+
}
|
|
61
|
+
|
|
56
62
|
// Snapshot exact bytes before executing; edits during a run belong to the next revision.
|
|
57
63
|
export async function filesAt(root, paths) {
|
|
58
64
|
const files = Object.create(null); let bytes = 0; const canonicalRoot = await realpath(root);
|
|
@@ -98,5 +104,5 @@ export const starter = {
|
|
|
98
104
|
version: 1, scope: null, connections: [],
|
|
99
105
|
tasks: [{ id: 'json', description: 'Extract a customer name and item count from text into a strict JSON object.', prompt: 'Read request.txt. Return only JSON with keys name (string) and count (number).', fixture: 'eval/fixtures/json', grader: 'eval/grade.mjs' }],
|
|
100
106
|
variants: [{ id: 'bare', files: [], instructions: '' }, { id: 'cli', files: ['eval/tool.mjs'], instructions: 'You can use node eval/tool.mjs request.txt to extract the fields.' }],
|
|
101
|
-
repeats: { min: 3, max: 10 }, budget: { maxUsd: 0, reservationUsd:
|
|
107
|
+
repeats: { min: 3, max: 10 }, budget: { maxUsd: 0, reservationUsd: 'auto' }, timeoutSeconds: 180,
|
|
102
108
|
};
|
package/dist/eval/src/report.mjs
CHANGED
|
@@ -9,10 +9,13 @@ export function summarize(rows) {
|
|
|
9
9
|
}
|
|
10
10
|
return [...groups].map(([key, runs]) => ({
|
|
11
11
|
key, task: runs[0].task, connection: runs[0].connection, variant: runs[0].variant, runs: runs.length,
|
|
12
|
-
quality: completeMean(runs.map(r => r.quality)), failures: runs.filter(r => r.status !== 'ok').length,
|
|
12
|
+
quality: completeMean(runs.map(r => r.quality)), failures: runs.filter(r => r.status !== 'ok' && r.quality !== null).length,
|
|
13
|
+
blockedAttempts: runs.filter(r => r.failureReason).length, scoredRuns: runs.filter(r => r.quality !== null).length,
|
|
14
|
+
failedTaskChecks: runs.filter(r => r.status === 'ok' && r.quality === 0).length,
|
|
13
15
|
qualityRange: runs.every(r => r.quality !== null) ? [Math.min(...runs.map(r => r.quality)), Math.max(...runs.map(r => r.quality))] : null,
|
|
14
16
|
inputTokens: completeMean(runs.map(r => r.usage?.input ?? null)), outputTokens: completeMean(runs.map(r => r.usage?.output ?? null)),
|
|
15
|
-
reportedCostUsd: runs.filter(r => r.costUsd !== null).reduce((s, r) => s + r.costUsd, 0), unknownCosts: runs.filter(r => r.costUsd === null).length,
|
|
17
|
+
reportedCostUsd: runs.some(r => r.costUsd !== null) ? runs.filter(r => r.costUsd !== null).reduce((s, r) => s + r.costUsd, 0) : null, unknownCosts: runs.filter(r => r.costUsd === null).length,
|
|
18
|
+
partialReportedCostUsd: runs.reduce((s, r) => s + (r.partialCostUsd ?? 0), 0),
|
|
16
19
|
meanCostUsd: completeMean(runs.map(r => r.costUsd)), medianSeconds: median(runs.map(r => r.seconds)),
|
|
17
20
|
}));
|
|
18
21
|
}
|
|
@@ -39,13 +42,18 @@ export function compareReports(current, previous) {
|
|
|
39
42
|
}
|
|
40
43
|
const fmt = (v, digits = 2) => v === null || v === undefined ? '?' : v.toFixed(digits);
|
|
41
44
|
export function formatReport(report) {
|
|
42
|
-
const lines = [`OpenMerit eval · ${report.status} · ${report.revision.slice(0, 12)}`, '', 'Task / connection / variant |
|
|
43
|
-
for (const row of report.summary)
|
|
45
|
+
const lines = [`OpenMerit eval · ${report.status} · ${report.revision.slice(0, 12)}`, '', 'Task / connection / variant | scored / attempts | blocked | quality | input / output tokens | estimated $/run | seconds (median)'];
|
|
46
|
+
for (const row of report.summary) {
|
|
47
|
+
lines.push(`${row.key} | ${row.scoredRuns ?? row.runs} / ${row.runs} | ${row.blockedAttempts ?? 0} | ${row.quality === null ? '?' : fmt(row.quality * 100, 1) + '%'} | ${fmt(row.inputTokens, 0)} / ${fmt(row.outputTokens, 0)} | ${fmt(row.meanCostUsd, 5)} | ${fmt(row.medianSeconds)}`);
|
|
48
|
+
if (row.partialReportedCostUsd > 0) lines.push(` Partial cost estimate: $${fmt(row.partialReportedCostUsd, 5)}; full attempt cost unknown.`);
|
|
49
|
+
}
|
|
44
50
|
if (report.comparison) {
|
|
45
51
|
lines.push('', 'Change from the previous compatible revision (negative tokens/cost/time is lower):');
|
|
46
52
|
for (const row of report.comparison) lines.push(`${row.key}: quality ${fmt(row.qualityDelta === null ? null : row.qualityDelta * 100, 1)} pp; tokens ${fmt(row.inputTokensDelta, 0)} / ${fmt(row.outputTokensDelta, 0)}; $ ${fmt(row.meanCostUsdDelta, 5)}; seconds ${fmt(row.medianSecondsDelta)}`);
|
|
47
53
|
} else lines.push('', 'No complete, compatible previous revision for comparison.');
|
|
48
54
|
lines.push('', '3–10 repetitions measure variability, not statistical certainty. ? means unavailable.', 'Costs are agent-reported estimates, not reconciled bills. Compare agents as different harnesses.');
|
|
55
|
+
if (report.budgetLimitUsd !== undefined) lines.push(report.budgetLimitUsd === 'unlimited' ? 'Budget: no fixed cap. Task/repetition limits and timeouts still apply; watched changes can incur further charges until stopped.' : `Budget: $${fmt(report.budgetLimitUsd)} cumulative across reruns.`);
|
|
56
|
+
lines.push(`Reserved allowance used: $${fmt(report.budgetUsedUsd, 5)} (not billed spend). Blocked attempts are unscored.`);
|
|
49
57
|
if (report.reason) lines.push(`Stopped: ${report.reason}`);
|
|
50
58
|
return lines.join('\n');
|
|
51
59
|
}
|
package/dist/eval/src/runner.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, readFile, writeFile } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { loadConfig, snapshot, materialize, readJSON, saveJSON, statePath, digest } from './config.mjs';
|
|
5
|
+
import { loadConfig, snapshot, materialize, readJSON, saveJSON, statePath, digest, reservationFor } from './config.mjs';
|
|
6
6
|
import { validatePlan } from './scout.mjs';
|
|
7
7
|
import { preflight, runAgent, invocation } from './adapters.mjs';
|
|
8
8
|
import { execute, agentEnvironment } from './process.mjs';
|
|
@@ -17,7 +17,9 @@ export async function acquireLock(root) {
|
|
|
17
17
|
}
|
|
18
18
|
export function budgetUsed(ledger) {
|
|
19
19
|
if (ledger?.version !== 1 || !Array.isArray(ledger.entries) || ledger.entries.some(e => !Number.isFinite(e.reservationUsd) || e.reservationUsd <= 0 || e.costUsd !== null && (!Number.isFinite(e.costUsd) || e.costUsd < 0))) throw new Error('Invalid budget ledger. Restore it before running; never discard unknown charges.');
|
|
20
|
-
|
|
20
|
+
// Agent prices are estimates, not provider settlement. Never release a
|
|
21
|
+
// reservation on that evidence, including entries written by older previews.
|
|
22
|
+
return ledger.entries.reduce((sum, e) => sum + Math.max(e.costUsd ?? 0, e.reservationUsd), 0);
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export async function evaluate(root, { signal, onProgress = () => {}, agentRunner = runAgent, checkAgent = preflight } = {}) {
|
|
@@ -28,17 +30,22 @@ export async function evaluate(root, { signal, onProgress = () => {}, agentRunne
|
|
|
28
30
|
const versions = {};
|
|
29
31
|
const ledgerPath = join(root, statePath, 'budget.json');
|
|
30
32
|
const ledger = await optionalJSON(ledgerPath) ?? { version: 1, entries: [] };
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
const reservationUsd = reservationFor(config, plan), unlimitedBudget = config.budget.maxUsd === 'unlimited';
|
|
34
|
+
const exhausted = () => { const used = budgetUsed(ledger); return !unlimitedBudget && used + reservationUsd > config.budget.maxUsd + 1e-10; };
|
|
35
|
+
if (exhausted()) throw new Error('Evaluation budget is exhausted or disabled. Choose a budget with eval plan --budget <USD|unlimited>; prior reservations still count.');
|
|
33
36
|
const runnerVersion = (await readJSON(new URL('../../../package.json', import.meta.url))).version;
|
|
34
|
-
const commands = config.connections.filter(c => selected.has(c.id)).map(c => ({ id: c.id, args: invocation(c,
|
|
35
|
-
const report = { version:
|
|
37
|
+
const commands = config.connections.filter(c => selected.has(c.id)).map(c => ({ id: c.id, args: invocation(c, reservationUsd, unlimitedBudget) }));
|
|
38
|
+
const report = { version: 2, runnerVersion, budgetLimitUsd: config.budget.maxUsd, reservationUsd, id: randomUUID(), startedAt: new Date().toISOString(), revision: snap.revision, compatibility: null, planHash: digest(plan.tasks), agentVersions: versions, status: 'running', rows: [], summary: [] };
|
|
36
39
|
const reportPath = join(root, statePath, 'runs', `${report.startedAt.replace(/[:.]/g, '-')}-${report.id}.json`);
|
|
37
40
|
const previous = await optionalJSON(join(root, statePath, 'latest.json'));
|
|
38
41
|
const temp = await mkdtemp(join(tmpdir(), 'openmerit-eval-'));
|
|
39
42
|
const checkpoint = async () => { report.summary = summarize(report.rows); await saveJSON(reportPath, report); };
|
|
40
43
|
try {
|
|
41
44
|
await checkpoint();
|
|
45
|
+
for (const connection of config.connections.filter(c => selected.has(c.id))) {
|
|
46
|
+
try { versions[connection.id] = await checkAgent(connection, { cwd: temp }); }
|
|
47
|
+
catch (error) { error.connection = connection.id; throw error; }
|
|
48
|
+
}
|
|
42
49
|
// Graders are copied outside all agent workspaces, before any trial runs.
|
|
43
50
|
for (const task of snap.tasks) await materialize(task.graderFiles, join(temp, 'graders', task.id));
|
|
44
51
|
outer: for (const task of snap.tasks) {
|
|
@@ -50,15 +57,16 @@ export async function evaluate(root, { signal, onProgress = () => {}, agentRunne
|
|
|
50
57
|
if (round % 2) ordered.reverse();
|
|
51
58
|
for (const { connection, variant, candidate } of ordered) {
|
|
52
59
|
if (signal?.aborted) { report.status = 'cancelled'; report.reason = 'Interrupted'; break outer; }
|
|
53
|
-
if (
|
|
54
|
-
const entry = { id: randomUUID(), run: report.id, reservationUsd
|
|
60
|
+
if (exhausted()) { report.status = 'budget_stopped'; report.reason = 'Cumulative budget cannot admit another reserved attempt.'; break outer; }
|
|
61
|
+
const entry = { id: randomUUID(), run: report.id, reservationUsd, costUsd: null, settled: false };
|
|
55
62
|
ledger.entries.push(entry); await saveJSON(ledgerPath, ledger);
|
|
56
63
|
const workspace = join(temp, entry.id); await mkdir(workspace);
|
|
57
64
|
await materialize(task.fixtureFiles, workspace, task.fixture);
|
|
58
65
|
await materialize(variant.sourceFiles, workspace);
|
|
59
66
|
const prompt = `${task.prompt}\n\n${variant.instructions}\n\nThis is an isolated evaluation attempt. Do not run OpenMerit evaluation commands or inspect paths outside this workspace.`;
|
|
60
|
-
const result = await agentRunner(connection, prompt, { cwd: workspace, timeoutMs: config.timeoutSeconds * 1000, signal, reservationUsd
|
|
61
|
-
entry.costUsd = result.costUsd; entry.
|
|
67
|
+
const result = await agentRunner(connection, prompt, { cwd: workspace, timeoutMs: config.timeoutSeconds * 1000, signal, reservationUsd, unlimitedBudget });
|
|
68
|
+
entry.costUsd = result.costUsd; entry.costBasis = result.costBasis ?? (result.costUsd === null ? 'unknown' : 'agent_estimate');
|
|
69
|
+
entry.partialCostUsd = result.partialCostUsd ?? null; entry.reservationUsd = Math.max(entry.reservationUsd, result.partialCostUsd ?? 0); entry.settled = true; await saveJSON(ledgerPath, ledger);
|
|
62
70
|
let quality = result.status === 'ok' || result.failureReason ? null : 0, status = result.status;
|
|
63
71
|
if (result.status === 'ok') {
|
|
64
72
|
const grader = await execute(process.execPath, [join(temp, 'graders', task.id, task.grader)], { cwd: temp, env: agentEnvironment(), input: JSON.stringify({ workspace, output: result.output }), timeoutMs: 30_000, signal });
|
|
@@ -68,10 +76,14 @@ export async function evaluate(root, { signal, onProgress = () => {}, agentRunne
|
|
|
68
76
|
quality = value.quality;
|
|
69
77
|
} catch { status = 'grader_error'; }
|
|
70
78
|
}
|
|
71
|
-
const row = { task: task.id, connection: connection.id, agent: connection.agent, requestedModel: connection.model, observedModels: result.observedModels, scoutId: candidate.scoutId, scoutRank: candidate.rank, variant: variant.id, round: round + 1, quality, status, failureReason: result.failureReason ?? null, usage: result.usage, costUsd: result.costUsd, seconds: result.seconds };
|
|
79
|
+
const row = { task: task.id, connection: connection.id, agent: connection.agent, requestedModel: connection.model, observedModels: result.observedModels, scoutId: candidate.scoutId, scoutRank: candidate.rank, variant: variant.id, round: round + 1, quality, status, failureReason: result.failureReason ?? null, usage: result.usage, costUsd: result.costUsd, partialCostUsd: result.partialCostUsd ?? null, costBasis: entry.costBasis, seconds: result.seconds };
|
|
72
80
|
report.rows.push(row); await checkpoint(); await rm(workspace, { recursive: true, force: true });
|
|
73
81
|
onProgress(row);
|
|
74
|
-
if (result.failureReason) {
|
|
82
|
+
if (result.failureReason) {
|
|
83
|
+
report.status = result.failureReason === 'budget_exhausted' ? 'budget_stopped' : result.failureReason === 'cancelled' ? 'cancelled' : ['sandbox_unavailable', 'permission_denied', 'spawn_failed'].includes(result.failureReason) ? 'environment_blocked' : 'connection_blocked';
|
|
84
|
+
report.reason = `${connection.id}: ${result.failureReason}. This attempt is unscored; resolve the execution block before retrying.`;
|
|
85
|
+
break outer;
|
|
86
|
+
}
|
|
75
87
|
if (status === 'grader_error') { report.status = 'invalid'; report.reason = `Grader failed for ${task.id}. Fix it before collecting more trials.`; break outer; }
|
|
76
88
|
}
|
|
77
89
|
if (round + 1 >= config.repeats.min && !isVolatile(report.rows.filter(r => r.task === task.id))) break;
|
|
@@ -79,11 +91,16 @@ export async function evaluate(root, { signal, onProgress = () => {}, agentRunne
|
|
|
79
91
|
}
|
|
80
92
|
if (report.status === 'running') report.status = 'complete';
|
|
81
93
|
} catch (error) {
|
|
82
|
-
|
|
83
|
-
|
|
94
|
+
if (error.failureReason) {
|
|
95
|
+
report.status = 'environment_blocked'; report.reason = `${error.connection}: ${error.failureReason}. ${error.message}`;
|
|
96
|
+
report.blockers = [{ connection: error.connection, failureReason: error.failureReason }];
|
|
97
|
+
} else {
|
|
98
|
+
report.status = 'error'; report.reason = 'Execution failed; unsettled reservations remain charged. Check configuration and agent authentication.';
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
84
101
|
} finally {
|
|
85
102
|
report.finishedAt = new Date().toISOString(); report.budgetUsedUsd = budgetUsed(ledger);
|
|
86
|
-
report.compatibility = digest({
|
|
103
|
+
report.compatibility = digest({ accountingVersion: report.version, snapshot: snap.compatibility, versions, runnerVersion, commands, observedModels: Object.fromEntries([...selected].sort().map(id => [id, [...new Set(report.rows.filter(r => r.connection === id).flatMap(r => r.observedModels ?? []))].sort()])) });
|
|
87
104
|
report.summary = summarize(report.rows); report.comparison = compareReports(report, previous);
|
|
88
105
|
await saveJSON(reportPath, report); await saveJSON(join(root, statePath, 'latest.json'), report);
|
|
89
106
|
await rm(temp, { recursive: true, force: true });
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Evaluate CLI changes across coding agents
|
|
2
2
|
|
|
3
|
-
**npm preview `0.1.7-preview.
|
|
3
|
+
**npm preview `0.1.7-preview.2`.** Install from the `preview` tag; stable `openmerit@0.1.6` does not include these commands. This evaluates the coding agent with your CLI and documentation. The [Pi workflow](getting-started.md) still evaluates application inference; the [Inference Terminal](inference-terminal.md) remains read-only.
|
|
4
|
+
|
|
5
|
+
Compared with `0.1.7-preview.1`, this version adds [corrected cost accounting and blocked-execution handling](#accounting-and-blocked-executions), plus [budget selection after model scope](#budget-selection). It does not claim reconciled provider billing or a guaranteed exact model order.
|
|
4
6
|
|
|
5
7
|
One local runner supports Claude Code, Codex and OpenCode. Scout supplies candidates; local task checks measure each model/agent/CLI combination. Installing npm alone does not install or authenticate agents, modify project instructions, or start paid evaluations.
|
|
6
8
|
|
|
@@ -24,7 +26,7 @@ Replace the starter before drawing product conclusions. Have your agent author r
|
|
|
24
26
|
openmerit eval plan
|
|
25
27
|
```
|
|
26
28
|
|
|
27
|
-
The first interactive plan asks: **existing project connections or all models Scout indexes?**
|
|
29
|
+
The first interactive plan asks: **existing project connections or all models Scout indexes?** It then asks for a **$10, custom or no-fixed-cap budget**. Both choices are saved. Noninteractive agents must ask the user and pass `--scope existing` or `--scope all` together with `--budget <USD|unlimited>` on first setup; missing input never chooses scope or spending automatically.
|
|
28
30
|
|
|
29
31
|
Add explicit routes to `connections` in the config, for example:
|
|
30
32
|
|
|
@@ -54,14 +56,14 @@ Each variant needs `id`, `files` and `instructions`. The starter compares `bare`
|
|
|
54
56
|
|
|
55
57
|
Selected files, including uncommitted changes, are snapshotted before execution. Variant paths must not overwrite fixture inputs and retain their project-relative locations; fixture contents go at the workspace root. Include self-contained CLI artifacts and lean docs explicitly. Dependencies are not installed automatically; tools must be bundled or have their runtime requirements already available. Symlinks, parent-directory paths, `.env*`, `.git`, `.openmerit` and `node_modules` are rejected. Each selected snapshot is limited to 25 MB. Do not select credentials stored under other filenames.
|
|
56
58
|
|
|
57
|
-
The grader reads `{ "workspace": "/temporary/path", "output": "final response" }` from stdin and prints exactly `{ "quality": 0.0 }`, with a finite score from 0 to 1. It can check generated files using executable assertions. Keep it self-contained: imports beside it are not copied automatically. Graders are copied outside agent workspaces; never also include them or answer keys in fixtures/variant files. Test graders against correct and incorrect answers. A broken grader stops the experiment with unknown quality
|
|
59
|
+
The grader reads `{ "workspace": "/temporary/path", "output": "final response" }` from stdin and prints exactly `{ "quality": 0.0 }`, with a finite score from 0 to 1. It can check generated files using executable assertions. Keep it self-contained: imports beside it are not copied automatically. Graders are copied outside agent workspaces; never also include them or answer keys in fixtures/variant files. Test graders against correct and incorrect answers. A broken grader stops the experiment with unknown quality. Ordinary agent failures/timeouts count as quality zero; recognized budget, environment and connection blocks instead stop with unknown quality. See [failure classification](#accounting-and-blocked-executions).
|
|
58
60
|
|
|
59
61
|
## Run after changes
|
|
60
62
|
|
|
61
|
-
The starter's `budget.maxUsd` is zero.
|
|
63
|
+
The starter's `budget.maxUsd` is zero. After the user's scope and budget choices, for example:
|
|
62
64
|
|
|
63
65
|
```sh
|
|
64
|
-
openmerit eval plan --scope existing
|
|
66
|
+
openmerit eval plan --scope existing --budget 10
|
|
65
67
|
openmerit eval run
|
|
66
68
|
# Edit your CLI or its documentation.
|
|
67
69
|
openmerit eval run
|
|
@@ -88,14 +90,56 @@ openmerit eval report --json
|
|
|
88
90
|
|
|
89
91
|
Reports show quality mean/range, failures, mean input/output tokens, mean reported dollars and median full-process seconds per task/connection/variant. They retain Scout ranks but do not force a weighted winner. Lower cost with lower quality is a tradeoff. Matching complete revisions show deltas; partial/invalid experiments do not.
|
|
90
92
|
|
|
91
|
-
Claude provides aggregate usage and
|
|
93
|
+
Claude provides aggregate and per-model usage and cost estimates, reconciled without counting the same usage twice. Codex JSON events provide tokens but not billed dollars, so cost stays unknown. OpenCode per-step usage/cost is summed across the episode, including tools; missing or zero step pricing leaves full cost unknown and preserves partial estimates. Input totals include separately reported cache reads/writes; cached tokens are retained separately without counting them twice. OpenCode output totals include separately reported reasoning tokens. Agent accounting and subscriptions differ. Reported costs are not reconciled provider bills; zero pricing is treated as unknown. Catalogue prices never replace unknown measured costs.
|
|
92
94
|
|
|
93
95
|
Preflight requires supported noninteractive JSON interfaces: Claude print JSON, Codex `exec --json --ephemeral --sandbox workspace-write`, and OpenCode `run --format json --auto`. OpenCode's auto option approves tool requests that are not explicitly denied, avoiding an interactive approval stall. Claude uses an explicit local tool allowlist without inherited MCP configuration; Codex and OpenCode retain native global settings. Recognized permission failures stop as connection problems, not model-quality evidence. OpenMerit does not add a sandbox around Claude/OpenCode. The old 2025 Codex CLI is unsupported. Native references: [Claude Code](https://code.claude.com/docs/en/headless), [Codex](https://developers.openai.com/codex/noninteractive), [OpenCode](https://opencode.ai/docs/cli/).
|
|
94
96
|
|
|
95
97
|
## Budget and data
|
|
96
98
|
|
|
97
|
-
`budget.maxUsd` applies cumulatively across runs and restarts. Before each attempt the runner persists
|
|
99
|
+
`budget.maxUsd` applies cumulatively across runs and restarts. Before each attempt the runner persists a reservation. It retains the greater of that reservation and the available full or partial cost estimate; estimates never release reserved allowance. Partial events never count as a complete bill. Insufficient remaining allowance stops a partial run. Raising maxUsd extends the allowance, not resets it. Do not delete unknown spend from the ledger. This is admission control, not a billing cutoff: one attempt may exceed its reservation. Finite budgets also use Claude's native maximum-budget flag. Use provider-side limits for hard monetary caps. Each attempt also has a timeout and 4 MB process-output limit. See [budget selection](#budget-selection) for the three choices and automatic reservations.
|
|
98
100
|
|
|
99
101
|
`.openmerit/eval/` holds `plan.json`, `budget.json`, `latest.json`, `runs/*.json` and `lock/pid`. Init ignores it in Git. Reports contain identities, metrics, hashes and status, not full transcripts or final outputs. Temporary files are removed afterward. Native agents/providers may retain their own data; Claude/Codex receive ephemeral flags, while OpenCode uses native storage. `.openmerit/eval.json` can be versioned without credentials.
|
|
100
102
|
|
|
101
103
|
Fresh workspaces are not security sandboxes. Trusted agents and graders execute with native permissions and user-level resources. Hard kills may leave temporary directories. After a crash, inspect the lock's PID and remove it only if that process is gone. This preview does not change Pi approvals, swap production models or upload results into Scout rankings.
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
## VM acceptance limits
|
|
107
|
+
|
|
108
|
+
The published `0.1.7-preview.1` package was tested on an Ubuntu ARM64 VM with Node 22.23.1 on 15 October 2026. Claude Code 2.1.300 and OpenCode 1.19.0 executed a small CSV CLI task before and after a parser change. A separate OpenCode watcher completed three attempts per revision and emitted comparison deltas. Codex 0.162.1 passed interface preflight but could not execute filesystem tools because that VM denied the required Linux user namespaces. CLI flag checks do not establish that the native sandbox works on a host.
|
|
109
|
+
|
|
110
|
+
In that earlier `0.1.7-preview.1` test, a native budget stop could appear as a generic agent failure, and a successful final response explaining a host permission problem could receive zero from the task grader. These did not establish poor model quality; the corrections below address recognized native failures. The combined VM run also stopped before its final repetition because conservative reservations and agent cost estimates exhausted its local allowance; partial reports correctly omitted automatic comparison. Agent-reported cost differed substantially from OpenRouter account usage. This was one integration fixture, not validation of general task quality, exact model order, or billing accuracy.
|
|
111
|
+
|
|
112
|
+
## Accounting and blocked executions
|
|
113
|
+
|
|
114
|
+
Since `0.1.7-preview.2`, recognized budget, sandbox, permission, authentication and model-access blocks are separate from task failures. A blocked attempt has `quality: null`, stops the run and cannot produce a revision comparison. Reports distinguish scored attempts, blocked attempts and task-check failures. Ordinary wrong outputs still receive their grader score; ordinary agent failures and configured timeouts still count as failures. Cancellation, a missing executable and output-limit interruption are unscored. Error classification uses native failure fields and failed-tool diagnostics, not arbitrary task text or model commentary. Unrecognized host failures can still require manual review.
|
|
115
|
+
|
|
116
|
+
On Linux, Codex preflight also runs a local sandbox probe before any model request or budget reservation. It uses the configured executable and the workspace-write policy; it does not weaken sandbox permissions. A failed probe produces `environment_blocked` with no task score. Doctor performs this same probe. Other operating systems still have interface checks only.
|
|
117
|
+
|
|
118
|
+
Claude's per-model totals recover usage omitted from aggregate budget-stop responses, without adding the same aggregate and model counters twice. Costs carry `costBasis` and remain native estimates. Zero or missing pricing is unknown; incomplete OpenCode step pricing stays partial. Partial costs survive into the row, summary and ledger, including failed attempts. No provider bill is reconstructed from these estimates, and no account-wide spend delta is assigned to individual attempts.
|
|
119
|
+
|
|
120
|
+
Budget admission now retains the greater of each reservation and its available full or partial estimate, including old ledger entries. Estimates never release a reservation. This may exhaust an existing allowance earlier; increasing it requires an explicit budget decision. The displayed reserved allowance is not billed spend, and a provider-side limit is still needed for a hard cap. Report version 2 prevents comparisons with the earlier accounting rules. There is no new provider integration, proxy, background service or model-specific rule.
|
|
121
|
+
|
|
122
|
+
## Budget selection
|
|
123
|
+
|
|
124
|
+
After choosing existing connections or all indexed models, the first interactive `eval plan` asks:
|
|
125
|
+
|
|
126
|
+
1. **$10** — a cumulative evaluation allowance for this project.
|
|
127
|
+
2. **Custom amount** — asks how much in USD.
|
|
128
|
+
3. **No fixed cap** — run the configured suite without a monetary admission limit.
|
|
129
|
+
|
|
130
|
+
Nothing is preselected. Blank, invalid or cancelled input cannot enable spending. Init still starts at zero. Newly generated project instructions tell coding agents to ask the same follow-up and pass the answer explicitly, for example:
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
openmerit eval plan --scope existing --budget 10
|
|
134
|
+
# Alternatives after the user's choice:
|
|
135
|
+
openmerit eval plan --budget 25.50
|
|
136
|
+
openmerit eval plan --budget unlimited
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`--budget` belongs to `plan`, accepts positive USD amounts with at most two decimal places or the exact word `unlimited`, and never starts evaluation by itself. Noninteractive planning requires explicit flags when scope or budget has not been chosen. Existing positive budgets are reused. The choices are saved even if Scout subsequently fails, so a retry does not ask again. Init does not rewrite instructions in already-initialized projects; an agent in such a project can use these flags directly.
|
|
140
|
+
|
|
141
|
+
The allowance is cumulative across runs, watched edits, restarts and replanning. Changing from $10 to $20 raises the total allowance to $20; it does not add $20 or erase earlier reservations. Changing to or from no fixed cap also preserves the ledger. A normal `eval plan` reuses the saved choice. To change it, pass a new `--budget` value; setting `budget.maxUsd` back to zero in the config disables paid execution and prompts again on the next interactive plan.
|
|
142
|
+
|
|
143
|
+
Reservations are internal. New suites and budget choices use `reservationUsd: "auto"`: the smaller of $0.25 and the total allowance divided across the minimum planned attempts (tasks × selected connections × variants × minimum repetitions). No fixed cap uses $0.25 for ledger bookkeeping. This is a conservative starting allowance, not a prediction of an attempt's price or a guarantee the suite will finish. Higher reported charges still consume more allowance. Existing explicitly configured numeric reservations remain supported. Finite budgets retain Claude's native per-attempt budget flag; as before, an attempt can exceed its reservation, and provider limits are needed for a hard billing cap.
|
|
144
|
+
|
|
145
|
+
No fixed cap is stored explicitly as `budget.maxUsd: "unlimited"`; missing, null or invalid values never mean unlimited. It also removes Claude's per-attempt dollar limit, while keeping the same task count, 3–10 repetition bounds, timeouts and native permissions. Provider/account limits still apply. Usage and cost estimates remain recorded. `eval watch` can incur further charges after each selected change until stopped; Ctrl+C stops it and the active attempt. No background service or automatic production model change is added.
|
package/docs/commands.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
The standalone `openmerit dash` command runs in your shell. The `/openmerit` slash commands run inside Pi with the extension loaded.
|
|
4
4
|
|
|
5
|
-
The npm `0.1.7-preview.
|
|
5
|
+
The npm `0.1.7-preview.2` preview (`openmerit@preview`) provides `openmerit eval init`, `doctor`, `plan`, `run`, `watch` and `report`. See [agent evaluations](agent-evaluations.md) for scope selection, repeated task checks and budgets. These are shell commands, separate from Pi commands.
|
|
6
|
+
|
|
7
|
+
Since `0.1.7-preview.2`, `openmerit eval plan [--scope existing|all] [--budget <USD|unlimited>]` asks for a budget after scope. Choose $10, a custom amount, or no fixed cap; saved choices and cumulative usage persist. See [budget selection](agent-evaluations.md#budget-selection).
|
|
6
8
|
|
|
7
9
|
## Inference Terminal
|
|
8
10
|
|
package/docs/security.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
The [preview agent-evaluation runner](agent-evaluations.md) is a separate execution path: it runs trusted local agents/graders, passes only basic OS variables and explicitly named environment variables, and preserves agent-owned authentication. It sends task descriptions and optional model IDs to Scout. Local metric reports and budget reservations persist; temporary task outputs are removed. Fresh directories are not security sandboxes. Its budget admission controls do not enforce provider billing limits.
|
|
4
4
|
|
|
5
|
+
The [budget selector](agent-evaluations.md#budget-selection) in `0.1.7-preview.2` requires an explicit $10, custom or no-fixed-cap choice after model scope. No fixed cap removes monetary admission limits, including Claude's per-attempt dollar flag, while retaining tasks, repetition bounds, timeouts, native permissions and accounting. Saved choices apply across reruns; a foreground watcher can incur further charges on each change until stopped. Provider limits remain independent.
|
|
6
|
+
|
|
5
7
|
## Credential ownership
|
|
6
8
|
|
|
7
9
|
OpenMerit accepts no model-provider or Jev credential. Credentials belong to the coding harness, its environment, or an external secret store.
|
|
@@ -303,3 +303,15 @@ Reviewed evidence and comparison claims after removing the prose summary from ev
|
|
|
303
303
|
## Agent evaluation npm preview — 15 October 2026
|
|
304
304
|
|
|
305
305
|
Prepared the `0.1.7-preview.1` npm preview with explicit installation and setup instructions. The stable `latest` tag remains `0.1.6`. Reviewed homepage readiness, evaluation, budget and data claims: they describe stable terminal/Pi behavior and require no copy changes. The preview retains explicit initialization, connection scope, local execution and budget limitations; no install-time automation is claimed.
|
|
306
|
+
|
|
307
|
+
## Agent evaluation accounting — unreleased source, 15 October 2026
|
|
308
|
+
|
|
309
|
+
Reviewed evidence, comparisons, budgets, data and readiness against the source corrections. Native cost estimates and partial charges remain distinct from provider billing; reservations are not released by estimates. Recognized budget/environment blocks are unscored and stop comparisons. The Linux Codex sandbox probe is a local preflight action with no inference call or permission bypass. There is no provider-account ingestion in the product, production model swap or new background process. The guide distinguishes these changes from npm `0.1.7-preview.1`; stable homepage FAQ wording remains unchanged.
|
|
310
|
+
|
|
311
|
+
## Agent evaluation budget selection — unreleased source, 15 October 2026
|
|
312
|
+
|
|
313
|
+
Reviewed budget, approval, scheduling and readiness claims. Source planning now asks for an explicit $10, custom or no-fixed-cap allowance after model scope; no option is selected automatically. New project instructions reuse the saved choice, and reservations stay internal. Cumulative accounting survives replanning and limit changes. No fixed cap retains task/repetition/time limits and native permissions, but a foreground watcher can incur further charges until stopped. Provider billing limits remain independent. These changes are documented separately from published `0.1.7-preview.1`; the stable homepage FAQ still describes terminal/Pi behavior and needs no wording change.
|
|
314
|
+
|
|
315
|
+
## Agent evaluation preview.2 release preparation — 15 October 2026
|
|
316
|
+
|
|
317
|
+
Prepared `0.1.7-preview.2` for the existing npm preview channel, combining corrected accounting and execution-block handling with explicit budget selection. The public guide now describes these versioned behaviors together and keeps earlier VM findings historical. Stable `latest` remains `0.1.6`; homepage FAQ claims still describe terminal/Pi behavior and require no copy changes. Registry publication and fresh-install VM acceptance are recorded separately; a successful local build alone does not establish either.
|
package/docs/testing.md
CHANGED
|
@@ -75,3 +75,22 @@ An isolated installation of the actual npm tarball completed init, real hosted S
|
|
|
75
75
|
Real macOS smoke runs used Claude Code 2.1.7, Codex CLI 0.162.0-alpha.17.2 and OpenCode 1.18.30. On one tiny JavaScript normalization fixture, with three fresh attempts per route, Claude/Haiku 4.5 and Codex/GPT-6 Luna passed all executable checks. OpenCode/Gemini 2.5 Flash Lite passed one of three. Costs remained unknown for Codex. These different model/harness combinations validate execution and grading paths; they are not an unbiased comparison of agents or a general model ranking. Earlier smoke runs deliberately exposed unsupported authentication/model access, native permission failures, a timeout and invalid JSON. Those cases informed connection-error handling and conservative partial-cost accounting. No model-specific ranking rule was added.
|
|
76
76
|
|
|
77
77
|
Scout Worker `7ddeacd3-1310-48bd-b546-60fcab4fa4ef` passed live HTTP/MCP checks for pre-ranking model eligibility, empty inventory and duplicate-ID rejection. npm publication is separate from the successful tarball installation; the registry credential returned HTTP 401 during release preparation.
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
### Published preview on Linux — 15 October 2026
|
|
81
|
+
|
|
82
|
+
Installed `openmerit@0.1.7-preview.1` from npm into a separate prefix in the existing Ubuntu ARM64 VM. The live Scout API returned a frozen shortlist within the declared OpenRouter connections. A deterministic CSV grader scored quoted commas, escaped quotes, leading-zero identifiers and field types; the uncorrected and corrected CLI passed 8/12 and 12/12 checks respectively in grader sanity checks.
|
|
83
|
+
|
|
84
|
+
Claude Code/Haiku 4.5 completed three attempts per revision at 66.7% then 100% mean field accuracy. The combined watcher stopped on budget admission before OpenCode’s last post-change attempt, so its report has no automatic comparison. A separate OpenCode/Gemini 2.5 Flash Lite watcher completed three attempts per revision at 66.7% then 100%, detected the source edit, emitted token/cost/time deltas, and exited cleanly. Earlier partial trials remain separate. Codex/GPT-4.1 Mini could not execute filesystem tools under this VM’s Linux sandbox; no passing Codex task result is claimed. Total account usage was approximately $0.26 including diagnostics, while native reported estimates differed. One authored fixture and three repetitions do not establish general performance or a cost improvement. Source and diagnostic artifacts are retained in the task’s `openmerit-vm-eval` directory; a scan found no temporary key in the 66 test-project files.
|
|
85
|
+
|
|
86
|
+
### Accounting corrections on Linux — unreleased source, 15 October 2026
|
|
87
|
+
|
|
88
|
+
The source package was packed and installed into a separate VM prefix; the registry preview and existing VM installations were left unchanged. All 400 repository tests pass, including 22 eval-runner tests. The added cases cover partial usage/cost recovery, missing prices, conservative old/new ledgers, scored wrong answers, native budget and permission blocks, zero-exit sandbox failures, and the free Linux sandbox probe. Replaying the previously captured Claude budget response recovers 30,820 input tokens and 196 output tokens instead of zero.
|
|
89
|
+
|
|
90
|
+
The real Codex connection now stops during sandbox preflight with `environment_blocked`, zero attempts, no reserved allowance and no inference spend. A deliberately small Claude budget stops after one attempt with unknown quality, 21,532 input tokens, 190 output tokens and a retained $0.110705 partial native estimate. A second normal-run attempt also hit its native cap; its unscored record and $0.1505768 reservation were preserved. With sufficient per-attempt allowance, Claude completed three scored CSV attempts at 100%, a 12.82-second median, and a $0.06029 mean native estimate. This is not a billed-dollar measurement or a before/after Claude comparison.
|
|
91
|
+
|
|
92
|
+
OpenCode's real watcher completed three attempts per revision. The earlier CLI averaged 44.4% field accuracy (one actual zero-score task output remained included); the corrected CLI averaged 100%. Median times were 5.05 and 5.79 seconds. One earlier and two later attempts had incomplete native step pricing, so the cost means and cost delta correctly remained unknown while partial estimates were retained. The cumulative reservation stayed at $0.03. These checks validate accounting and failure separation on the same integration fixture, not general task quality, model ordering or savings. Sanitized reports, package and verification output are retained in the task's `openmerit-eval-accounting` directory.
|
|
93
|
+
|
|
94
|
+
### Budget setup — unreleased source, 15 October 2026
|
|
95
|
+
|
|
96
|
+
The eval suite now has 29 tests. New coverage checks the three budget choices after scope, custom amount validation, explicit unlimited configuration, reuse of saved choices, automatic reservations, retained cumulative usage, bounded repetitions and cancellation. A real CLI subprocess uses a loopback Scout fixture to verify persistence across replans and failed requests, rejection of missing or invalid noninteractive choices, and lock cleanup. An interactive terminal smoke test also verifies scope-before-budget, custom input, no-repeat questions, no fixed cap and EOF cancellation. These are offline workflow tests; no model calls, provider charges or npm publication were required.
|