openmerit 0.1.6 → 0.1.7-preview.1
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 +4 -4
- package/README.md +61 -40
- package/dist/eval/src/adapters.mjs +73 -0
- package/dist/eval/src/cli.mjs +103 -0
- package/dist/eval/src/config.mjs +102 -0
- package/dist/eval/src/process.mjs +44 -0
- package/dist/eval/src/report.mjs +51 -0
- package/dist/eval/src/runner.mjs +106 -0
- package/dist/eval/src/scout.mjs +37 -0
- package/dist/terminal/public/app.js +36 -36
- package/dist/terminal/public/flow.css +1 -1
- package/dist/terminal/public/flow.js +26 -26
- package/dist/terminal/public/index.html +3 -2
- package/dist/terminal/public/styles.css +320 -91
- package/dist/terminal/src/cli.mjs +2 -0
- package/dist/terminal/src/demo-policy.mjs +205 -0
- package/dist/terminal/src/enterprise-sample.mjs +151 -0
- package/docs/adapter-guide.md +2 -2
- package/docs/agent-evaluations.md +101 -0
- package/docs/architecture.md +3 -3
- package/docs/automation.md +5 -5
- package/docs/budgets.md +2 -2
- package/docs/commands.md +7 -5
- package/docs/demo-backfill.md +3 -1
- package/docs/demo-fieldkit.md +3 -1
- package/docs/demo-placement.md +3 -1
- package/docs/demo-spam.md +3 -1
- package/docs/demo-support.md +3 -1
- package/docs/demo.md +22 -5
- package/docs/first-trial.md +2 -2
- package/docs/getting-started.md +23 -12
- package/docs/index.md +18 -29
- package/docs/inference-terminal.md +90 -54
- package/docs/interactive-demo.md +118 -0
- package/docs/lifecycle.md +4 -4
- package/docs/metrics-and-evidence.md +3 -3
- package/docs/operations.md +3 -5
- package/docs/pi-extension.md +6 -6
- package/docs/roadmap.md +17 -17
- package/docs/scout.md +206 -0
- package/docs/security.md +6 -4
- package/docs/site-demo.md +4 -0
- package/docs/site-design.md +7 -1
- package/docs/site-documentation.md +9 -3
- package/docs/site-domain-migration.md +57 -0
- package/docs/site-dynamic-og.md +2 -2
- package/docs/site-faq-maintenance.md +121 -6
- package/docs/site-hero-resolution.md +2 -0
- package/docs/site-illustration-sequences.md +3 -1
- package/docs/site-illustration-transparency.md +80 -0
- package/docs/site-memo.md +6 -0
- package/docs/site-og-image.md +1 -1
- package/docs/site-publication-2026-10-14.md +29 -0
- package/docs/site-scout-decisions-experiment.md +87 -0
- package/docs/site-scout.md +254 -0
- package/docs/site-terminal-preview.md +96 -3
- package/docs/site-themes.md +30 -0
- package/docs/testing.md +37 -78
- package/docs/troubleshooting.md +3 -3
- package/package.json +9 -3
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { loadConfig, snapshot, materialize, readJSON, saveJSON, statePath, digest } from './config.mjs';
|
|
6
|
+
import { validatePlan } from './scout.mjs';
|
|
7
|
+
import { preflight, runAgent, invocation } from './adapters.mjs';
|
|
8
|
+
import { execute, agentEnvironment } from './process.mjs';
|
|
9
|
+
import { summarize, isVolatile, compareReports } from './report.mjs';
|
|
10
|
+
|
|
11
|
+
async function optionalJSON(path) { try { return await readJSON(path); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } }
|
|
12
|
+
export async function acquireLock(root) {
|
|
13
|
+
const lock = join(root, statePath, 'lock'); await mkdir(join(root, statePath), { recursive: true });
|
|
14
|
+
try { await mkdir(lock); } catch (error) { if (error.code === 'EEXIST') throw new Error('An eval run/watch owns this project. Stop it first. After a crash, verify the PID in .openmerit/eval/lock/pid before removing that lock directory.'); throw error; }
|
|
15
|
+
await writeFile(join(lock, 'pid'), String(process.pid));
|
|
16
|
+
return () => rm(lock, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
export function budgetUsed(ledger) {
|
|
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
|
+
return ledger.entries.reduce((sum, e) => sum + (e.costUsd ?? e.reservationUsd), 0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function evaluate(root, { signal, onProgress = () => {}, agentRunner = runAgent, checkAgent = preflight } = {}) {
|
|
24
|
+
const config = await loadConfig(root), plan = await readJSON(join(root, statePath, 'plan.json'));
|
|
25
|
+
validatePlan(config, plan);
|
|
26
|
+
const snap = await snapshot(root, config);
|
|
27
|
+
const selected = new Set(plan.tasks.flatMap(t => t.candidates.flatMap(c => c.connections)));
|
|
28
|
+
const versions = {};
|
|
29
|
+
const ledgerPath = join(root, statePath, 'budget.json');
|
|
30
|
+
const ledger = await optionalJSON(ledgerPath) ?? { version: 1, entries: [] };
|
|
31
|
+
if (budgetUsed(ledger) + config.budget.reservationUsd > config.budget.maxUsd + 1e-10) throw new Error('Evaluation budget is exhausted or disabled. Set budget.maxUsd in .openmerit/eval.json; prior reservations still count.');
|
|
32
|
+
for (const connection of config.connections.filter(c => selected.has(c.id))) versions[connection.id] = await checkAgent(connection);
|
|
33
|
+
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, config.budget.reservationUsd) }));
|
|
35
|
+
const report = { version: 1, runnerVersion, id: randomUUID(), startedAt: new Date().toISOString(), revision: snap.revision, compatibility: digest({ snapshot: snap.compatibility, versions, runnerVersion, commands }), planHash: digest(plan.tasks), agentVersions: versions, status: 'running', rows: [], summary: [] };
|
|
36
|
+
const reportPath = join(root, statePath, 'runs', `${report.startedAt.replace(/[:.]/g, '-')}-${report.id}.json`);
|
|
37
|
+
const previous = await optionalJSON(join(root, statePath, 'latest.json'));
|
|
38
|
+
const temp = await mkdtemp(join(tmpdir(), 'openmerit-eval-'));
|
|
39
|
+
const checkpoint = async () => { report.summary = summarize(report.rows); await saveJSON(reportPath, report); };
|
|
40
|
+
try {
|
|
41
|
+
await checkpoint();
|
|
42
|
+
// Graders are copied outside all agent workspaces, before any trial runs.
|
|
43
|
+
for (const task of snap.tasks) await materialize(task.graderFiles, join(temp, 'graders', task.id));
|
|
44
|
+
outer: for (const task of snap.tasks) {
|
|
45
|
+
const taskPlan = plan.tasks.find(t => t.id === task.id);
|
|
46
|
+
const cells = taskPlan.candidates.flatMap(candidate => candidate.connections.flatMap(connection => snap.variants.map(variant => ({ connection: config.connections.find(c => c.id === connection), variant, candidate }))));
|
|
47
|
+
for (let round = 0; round < config.repeats.max; round++) {
|
|
48
|
+
// Rotate and reverse the balanced order so one candidate is not always warm/last.
|
|
49
|
+
const ordered = [...cells.slice(round % cells.length), ...cells.slice(0, round % cells.length)];
|
|
50
|
+
if (round % 2) ordered.reverse();
|
|
51
|
+
for (const { connection, variant, candidate } of ordered) {
|
|
52
|
+
if (signal?.aborted) { report.status = 'cancelled'; report.reason = 'Interrupted'; break outer; }
|
|
53
|
+
if (budgetUsed(ledger) + config.budget.reservationUsd > config.budget.maxUsd + 1e-10) { report.status = 'budget_stopped'; report.reason = 'Cumulative budget cannot admit another reserved attempt.'; break outer; }
|
|
54
|
+
const entry = { id: randomUUID(), run: report.id, reservationUsd: config.budget.reservationUsd, costUsd: null, settled: false };
|
|
55
|
+
ledger.entries.push(entry); await saveJSON(ledgerPath, ledger);
|
|
56
|
+
const workspace = join(temp, entry.id); await mkdir(workspace);
|
|
57
|
+
await materialize(task.fixtureFiles, workspace, task.fixture);
|
|
58
|
+
await materialize(variant.sourceFiles, workspace);
|
|
59
|
+
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: config.budget.reservationUsd });
|
|
61
|
+
entry.costUsd = result.costUsd; entry.reservationUsd = Math.max(entry.reservationUsd, result.partialCostUsd ?? 0); entry.settled = true; await saveJSON(ledgerPath, ledger);
|
|
62
|
+
let quality = result.status === 'ok' || result.failureReason ? null : 0, status = result.status;
|
|
63
|
+
if (result.status === 'ok') {
|
|
64
|
+
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 });
|
|
65
|
+
try {
|
|
66
|
+
const value = JSON.parse(grader.stdout);
|
|
67
|
+
if (grader.code !== 0 || grader.reason || typeof value.quality !== 'number' || !Number.isFinite(value.quality) || value.quality < 0 || value.quality > 1) throw new Error('Invalid grade');
|
|
68
|
+
quality = value.quality;
|
|
69
|
+
} catch { status = 'grader_error'; }
|
|
70
|
+
}
|
|
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 };
|
|
72
|
+
report.rows.push(row); await checkpoint(); await rm(workspace, { recursive: true, force: true });
|
|
73
|
+
onProgress(row);
|
|
74
|
+
if (result.failureReason) { report.status = 'connection_blocked'; report.reason = `${connection.id}: ${result.failureReason}. Fix the native agent connection before retrying.`; break outer; }
|
|
75
|
+
if (status === 'grader_error') { report.status = 'invalid'; report.reason = `Grader failed for ${task.id}. Fix it before collecting more trials.`; break outer; }
|
|
76
|
+
}
|
|
77
|
+
if (round + 1 >= config.repeats.min && !isVolatile(report.rows.filter(r => r.task === task.id))) break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (report.status === 'running') report.status = 'complete';
|
|
81
|
+
} catch (error) {
|
|
82
|
+
report.status = 'error'; report.reason = 'Execution failed; unsettled reservations remain charged. Check configuration and agent authentication.';
|
|
83
|
+
throw error;
|
|
84
|
+
} finally {
|
|
85
|
+
report.finishedAt = new Date().toISOString(); report.budgetUsedUsd = budgetUsed(ledger);
|
|
86
|
+
report.compatibility = digest({ base: report.compatibility, observedModels: Object.fromEntries([...selected].sort().map(id => [id, [...new Set(report.rows.filter(r => r.connection === id).flatMap(r => r.observedModels ?? []))].sort()])) });
|
|
87
|
+
report.summary = summarize(report.rows); report.comparison = compareReports(report, previous);
|
|
88
|
+
await saveJSON(reportPath, report); await saveJSON(join(root, statePath, 'latest.json'), report);
|
|
89
|
+
await rm(temp, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
return report;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function watch(root, { signal, onReport, onProgress, intervalMs = 1500, ...options } = {}) {
|
|
95
|
+
let last = null, pending = null;
|
|
96
|
+
while (!signal?.aborted) {
|
|
97
|
+
const config = await loadConfig(root), current = (await snapshot(root, config)).revision;
|
|
98
|
+
if (current !== last && current === pending) {
|
|
99
|
+
const result = await evaluate(root, { signal, onProgress, ...options });
|
|
100
|
+
onReport?.(result); last = result.revision;
|
|
101
|
+
if (result.status !== 'complete') return result;
|
|
102
|
+
}
|
|
103
|
+
pending = current;
|
|
104
|
+
await new Promise(resolve => { const done = () => { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); }; const timer = setTimeout(done, intervalMs); signal?.addEventListener('abort', done, { once: true }); });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { digest, saveJSON, statePath } from './config.mjs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const selectionHash = config => digest({ scope: config.scope, connections: config.connections, tasks: config.tasks.map(t => ({ id: t.id, description: t.description })), scoutURL: config.scoutURL });
|
|
5
|
+
export async function makePlan(config, fetcher = fetch) {
|
|
6
|
+
if (!['existing', 'all'].includes(config.scope)) throw new Error('Choose a scope with eval plan --scope existing or --scope all. Existing means the connections declared for this project.');
|
|
7
|
+
const modelIds = [...new Set(config.connections.map(c => c.scoutId))];
|
|
8
|
+
if (config.scope === 'existing' && !modelIds.length) throw new Error('Declare project connections in .openmerit/eval.json first. No credentials are sent to Scout.');
|
|
9
|
+
const tasks = [];
|
|
10
|
+
for (const task of config.tasks) {
|
|
11
|
+
const response = await fetcher(config.scoutURL ?? 'https://openmerit.ai/scout/api/recommend', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: task.description, ...(config.scope === 'existing' ? { modelIds } : {}) }), signal: AbortSignal.timeout(15_000), redirect: 'error' });
|
|
12
|
+
if (!response.ok) throw new Error(`Scout returned HTTP ${response.status}; no evaluation was started.`);
|
|
13
|
+
const body = await response.json();
|
|
14
|
+
if (!Array.isArray(body.recommendations) || body.recommendations.length > 5) throw new Error('Invalid Scout response.');
|
|
15
|
+
if (config.scope === 'existing' && (!Array.isArray(body.selection?.modelIds) || digest([...body.selection.modelIds].sort()) !== digest([...modelIds].sort()))) throw new Error('Scout did not acknowledge filtering before ranking. Update the Scout endpoint; no unscoped fallback is allowed.');
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
const candidates = body.recommendations.map(m => {
|
|
18
|
+
if (typeof m.id !== 'string' || seen.has(m.id) || config.scope === 'existing' && !modelIds.includes(m.id)) throw new Error('Invalid Scout model selection.');
|
|
19
|
+
seen.add(m.id);
|
|
20
|
+
return { scoutId: m.id, name: m.name, rank: m.rank, connections: config.connections.filter(c => c.scoutId === m.id).map(c => c.id) };
|
|
21
|
+
});
|
|
22
|
+
tasks.push({ id: task.id, candidates, ranking: body.ranking, warnings: body.warnings ?? [] });
|
|
23
|
+
}
|
|
24
|
+
return { version: 1, createdAt: new Date().toISOString(), scope: config.scope, selectionHash: selectionHash(config), tasks };
|
|
25
|
+
}
|
|
26
|
+
export async function savePlan(root, plan) { await saveJSON(join(root, statePath, 'plan.json'), plan); }
|
|
27
|
+
export function validatePlan(config, plan) {
|
|
28
|
+
if (plan.selectionHash !== selectionHash(config)) throw new Error('Task descriptions, scope or connections changed. Run eval plan again.');
|
|
29
|
+
if (!Array.isArray(plan.tasks) || plan.tasks.length !== config.tasks.length) throw new Error('Incomplete evaluation plan. Run eval plan again.');
|
|
30
|
+
for (const task of config.tasks) {
|
|
31
|
+
const entry = plan.tasks.find(t => t.id === task.id);
|
|
32
|
+
if (!entry?.candidates?.length) throw new Error(`Scout has no supported candidates for ${task.id}. Broaden scope or revise the task description.`);
|
|
33
|
+
for (const model of entry.candidates) {
|
|
34
|
+
if (!model.connections.length || model.connections.some(id => !config.connections.some(c => c.id === id && c.scoutId === model.scoutId))) throw new Error(`Configure an authenticated agent connection for ${model.scoutId}, then plan again. All-model discovery does not grant endpoint access.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|