openmerit 0.1.7-preview.1 → 0.1.7-preview.3
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 +57 -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/scout.md +142 -30
- package/docs/security.md +13 -0
- package/docs/testing.md +27 -0
- package/package.json +1 -1
- package/docs/site-artwork-linocut.md +0 -23
- package/docs/site-artwork-miniature-diverse.md +0 -28
- package/docs/site-artwork-miniature.md +0 -26
- package/docs/site-demo.md +0 -181
- package/docs/site-design.md +0 -100
- package/docs/site-documentation.md +0 -89
- package/docs/site-domain-migration.md +0 -57
- package/docs/site-dynamic-og.md +0 -35
- package/docs/site-faq-maintenance.md +0 -305
- package/docs/site-hero-resolution.md +0 -62
- package/docs/site-illustration-sequences.md +0 -229
- package/docs/site-illustration-transparency.md +0 -80
- package/docs/site-inference-terminal.md +0 -203
- package/docs/site-memo.md +0 -45
- package/docs/site-og-image.md +0 -38
- package/docs/site-og-workshop.md +0 -21
- package/docs/site-publication-2026-10-14.md +0 -29
- package/docs/site-scout-decisions-experiment.md +0 -87
- package/docs/site-scout.md +0 -254
- package/docs/site-section-artwork.md +0 -56
- package/docs/site-skill-review.md +0 -57
- package/docs/site-terminal-preview.md +0 -242
- package/docs/site-themes.md +0 -30
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.7-preview.3 — 2026-10-16
|
|
4
|
+
|
|
5
|
+
- Update packaged Scout API/MCP and agent-evaluation guides for the sole `/scout/` service and current request, inventory, data-handling and reliability contracts.
|
|
6
|
+
- Keep the hosted hourly research and matching service separate from npm installation. Hosted publication recovery, per-source freshness, bounded Decisions retries and reduced overlapping pool reads were deployed independently.
|
|
7
|
+
- Exclude internal website and deployment maintenance notes from the npm artifact. Keep existing commands, exports, legal metadata and stable `latest` behavior unchanged.
|
|
8
|
+
|
|
9
|
+
## Scout semantic benchmark interpretation — hosted beta, 2026-10-15
|
|
10
|
+
|
|
11
|
+
- Replace benchmark capability/name rules and family-name regexes with hourly Decisions interpretation of source definitions and methodology.
|
|
12
|
+
- Cache unchanged interpretations; leave unsupported or failed scopes unclassified and preserve evidence provenance.
|
|
13
|
+
- Keep prompt matching local to prepared benchmark metadata, with unchanged UI, prompt/specialist calls and hourly schedule. All 24 semantic benchmark cases, 107 local/live HTTP/MCP matching cases and 546 automated checks passed. npm publication remains deferred.
|
|
14
|
+
- Store compressed analytics payloads as gzip bytes to avoid base64 overhead on larger evidence records; retain legacy decoding and complete-record size limits.
|
|
15
|
+
|
|
16
|
+
## Scout semantic specialist matching — hosted beta, 2026-10-15
|
|
17
|
+
|
|
18
|
+
- Replace Hugging Face domain/language keyword dictionaries and partial label overlap with Decisions compatibility checks.
|
|
19
|
+
- Prepare and cache specialist profiles during the hourly refresh; batch only relevant compatibility checks when a prompt arrives.
|
|
20
|
+
- Require all applicable domain, language and fixed-label checks to pass. Preserve unknown metrics and ordinary results if specialist checking fails.
|
|
21
|
+
- Keep the UI unchanged. All 107 cases passed locally and through live HTTP/MCP; 526 automated tests passed. npm remains deferred.
|
|
22
|
+
|
|
23
|
+
## Scout semantic formats and SQL interpretation — hosted beta, 2026-10-15
|
|
24
|
+
|
|
25
|
+
- Remove prompt keyword scans from strict-format and structured-data interpretation; use the existing Decisions answers, including described record layouts and non-English requests.
|
|
26
|
+
- Recognize read-only SQL query writing and repair without requiring schema details in the task description. Keep query source distinct from executing it, its result format, prose explanation and data modification.
|
|
27
|
+
- Keep uncertain format answers visible and exclude them from narrow calibration matching. Preserve response validation, general consistency checks, confidence thresholds, one interpretation request, hourly collection and the UI.
|
|
28
|
+
- All 91 semantic cases passed locally and through live HTTP/MCP, including the previously failing SQL request and four fresh SQL holdouts. All 511 automated tests passed. npm publication remains deferred.
|
|
29
|
+
|
|
30
|
+
## Scout category and content interpretation — hosted beta, 2026-10-15
|
|
31
|
+
|
|
32
|
+
- Recognize conversationally supplied categories without requiring a formal label list; keep subtype and individual-identity discovery open-ended.
|
|
33
|
+
- Require document understanding for requested written/table content, including mixed outputs, without inferring it merely from a photographed object.
|
|
34
|
+
- Update two existing semantic questions with unchanged confidence thresholds, UI, hourly refresh and request count. Local and live HTTP/MCP acceptance passed 43 cases; an opt-in semantic audit preserves those cases for future interpretation changes. npm publication remains deferred.
|
|
35
|
+
|
|
36
|
+
## Scout task interpretation and ambiguous matching — hosted beta, 2026-10-15
|
|
37
|
+
|
|
38
|
+
- Frame every task description as quoted data for requirement analysis, preserving supplied-label and negation semantics without prompt-specific exceptions or lower confidence thresholds.
|
|
39
|
+
- Keep decision-plus-prose or extraction requests on generative endpoints, reject narrow calibration for uncertain operations/outputs, and ignore weak document-layout guesses for ordinary photos.
|
|
40
|
+
- All 25 routing cases passed locally and through live HTTP/MCP, including the prior binary-label failure. UI, hourly refresh and the single interpretation request are unchanged. npm publication remains deferred.
|
|
41
|
+
|
|
42
|
+
## Scout identification matching — hosted beta, 2026-10-15
|
|
43
|
+
|
|
44
|
+
- Distinguish open identification from supplied-label classification within the existing interpretation call. Open or uncertain identification uses evidence-ranked generative endpoints; fixed-choice APIs and label-dependent classifiers require supplied choices.
|
|
45
|
+
- Require relevant visual benchmark evidence for visual requests, preserve classification label scope in calibration matching, and use ordinary output-length estimates for generated names. UI and hourly refresh are unchanged; no npm release is included.
|
|
46
|
+
|
|
47
|
+
## Scout hosted beta — 2026-10-15
|
|
48
|
+
|
|
49
|
+
- Apply shared modality, output-format, tool and operation requirements across Scout recommendation paths. Catalogue refresh now retains explicit text-output contracts and excludes mixed media output from general text candidates.
|
|
50
|
+
- Preserve unknown and partial token prices as `null`; keep genuine advertised zero rates distinct.
|
|
51
|
+
- Suppress separate specialist discovery for endpoint-scoped requests, including empty inventories.
|
|
52
|
+
- Honor latency priority when choosing a model's endpoint, using only timing tied to that route. UI, hourly scheduling and interpretation calls are unchanged. These four service corrections were deployed separately from npm.
|
|
53
|
+
|
|
54
|
+
## 0.1.7-preview.2 — 2026-10-15
|
|
55
|
+
|
|
56
|
+
- 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.
|
|
57
|
+
- Separate native budget and environment blocks from task scores in agent evaluations; probe the Codex Linux sandbox before making model requests.
|
|
58
|
+
- 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.
|
|
59
|
+
|
|
3
60
|
## 0.1.7-preview.1 — 2026-10-15
|
|
4
61
|
|
|
5
62
|
- 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.3`** 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.3/docs/agent-evaluations.md) for setup, graders, budgets and availability. Scout's hosted API and MCP service update independently of the npm package. 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 });
|