openmerit 0.2.0 → 0.3.0
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 +12 -25
- package/dist/terminal/scout-update.json +1 -1
- package/dist/terminal/src/browser.mjs +2 -4
- package/dist/terminal/src/cli.mjs +4 -56
- package/dist/terminal/src/install.mjs +2 -2
- package/dist/terminal/src/scout-pi.js +1 -1
- package/dist/terminal/src/scout.js +3 -3
- package/dist/terminal/src/telemetry.mjs +8 -60
- package/docs/agent-evaluations.md +8 -148
- package/docs/commands.md +3 -9
- package/docs/getting-started.md +2 -16
- package/docs/index.md +4 -18
- package/docs/inference-terminal.md +13 -594
- package/docs/roadmap.md +2 -2
- package/docs/scout.md +6 -2
- package/docs/security.md +5 -18
- package/docs/testing.md +4 -16
- package/docs/troubleshooting.md +2 -2
- package/package.json +10 -23
- package/dist/eval/src/adapters.mjs +0 -112
- package/dist/eval/src/cli.mjs +0 -151
- package/dist/eval/src/config.mjs +0 -108
- package/dist/eval/src/process.mjs +0 -44
- package/dist/eval/src/report.mjs +0 -59
- package/dist/eval/src/runner.mjs +0 -123
- package/dist/eval/src/scout.mjs +0 -48
- package/dist/terminal/public/app.js +0 -298
- package/dist/terminal/public/brands/anthropic.png +0 -0
- package/dist/terminal/public/brands/baai.png +0 -0
- package/dist/terminal/public/brands/baseten.png +0 -0
- package/dist/terminal/public/brands/cerebras.png +0 -0
- package/dist/terminal/public/brands/cohere.png +0 -0
- package/dist/terminal/public/brands/deepseek.ico +0 -0
- package/dist/terminal/public/brands/google.png +0 -0
- package/dist/terminal/public/brands/groq.ico +0 -0
- package/dist/terminal/public/brands/huggingface.svg +0 -8
- package/dist/terminal/public/brands/lm-studio.png +0 -0
- package/dist/terminal/public/brands/meta.ico +0 -0
- package/dist/terminal/public/brands/mistral.png +0 -0
- package/dist/terminal/public/brands/nomic.png +0 -0
- package/dist/terminal/public/brands/ollama.png +0 -0
- package/dist/terminal/public/brands/openai.png +0 -0
- package/dist/terminal/public/brands/openrouter.png +0 -0
- package/dist/terminal/public/brands/qwen.png +0 -0
- package/dist/terminal/public/brands/vllm.png +0 -0
- package/dist/terminal/public/favicon.svg +0 -1
- package/dist/terminal/public/index.html +0 -22
- package/dist/terminal/public/styles.css +0 -921
- package/dist/terminal/src/activity-merge.mjs +0 -66
- package/dist/terminal/src/collect.mjs +0 -370
- package/dist/terminal/src/coverage.mjs +0 -33
- package/dist/terminal/src/discovery.mjs +0 -99
- package/dist/terminal/src/hardware.mjs +0 -57
- package/dist/terminal/src/history.mjs +0 -32
- package/dist/terminal/src/log-continuity.mjs +0 -26
- package/dist/terminal/src/project-activity.mjs +0 -157
- package/dist/terminal/src/repository-background.mjs +0 -37
- package/dist/terminal/src/repository-index.mjs +0 -267
- package/dist/terminal/src/repository-policy.mjs +0 -3
- package/dist/terminal/src/repository-syntax.mjs +0 -236
- package/dist/terminal/src/repository-worker.mjs +0 -15
- package/dist/terminal/src/sample.mjs +0 -171
- package/dist/terminal/src/scout-credentials.d.ts +0 -12
- package/dist/terminal/src/scout-credentials.ts +0 -44
- package/dist/terminal/src/scout-instructions.d.ts +0 -5
- package/dist/terminal/src/scout-instructions.ts +0 -119
- package/dist/terminal/src/scout-pi.d.ts +0 -44
- package/dist/terminal/src/scout-pi.ts +0 -76
- package/dist/terminal/src/scout.d.ts +0 -77
- package/dist/terminal/src/scout.ts +0 -164
- package/dist/terminal/src/server.mjs +0 -95
- package/dist/terminal/src/service-registry.mjs +0 -78
- package/dist/terminal/src/services.mjs +0 -44
- package/dist/terminal/src/topology.mjs +0 -30
- package/dist/terminal/src/updates.d.ts +0 -62
- package/dist/terminal/src/updates.ts +0 -313
package/dist/eval/src/config.mjs
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir, rename, readdir, lstat, realpath } from 'node:fs/promises';
|
|
2
|
-
import { resolve, relative, join, dirname, isAbsolute } from 'node:path';
|
|
3
|
-
import { createHash } from 'node:crypto';
|
|
4
|
-
import { agents } from './adapters.mjs';
|
|
5
|
-
|
|
6
|
-
export const configPath = '.openmerit/eval.json';
|
|
7
|
-
export const statePath = '.openmerit/eval';
|
|
8
|
-
export const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
9
|
-
export async function readJSON(path) { return JSON.parse(await readFile(path, 'utf8')); }
|
|
10
|
-
export async function saveJSON(path, data) {
|
|
11
|
-
await mkdir(dirname(path), { recursive: true });
|
|
12
|
-
const temp = `${path}.${process.pid}.tmp`;
|
|
13
|
-
await writeFile(temp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); await rename(temp, path);
|
|
14
|
-
}
|
|
15
|
-
const text = v => typeof v === 'string' && v.trim().length > 0;
|
|
16
|
-
const id = v => text(v) && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,79}$/.test(v);
|
|
17
|
-
function requireThat(ok, message) { if (!ok) throw new Error(`Eval config: ${message}`); }
|
|
18
|
-
export function safePath(path) {
|
|
19
|
-
requireThat(text(path) && !isAbsolute(path) && !path.split(/[\\/]/).some(p => ['..', '.', '.git', '.openmerit', 'node_modules'].includes(p) || p.startsWith('.env')), `unsafe file path ${path}. Use explicit project-relative source/fixture paths, without secrets or symlinks.`);
|
|
20
|
-
return path;
|
|
21
|
-
}
|
|
22
|
-
export function validateConfig(config) {
|
|
23
|
-
requireThat(config.version === 1, 'version must be 1.');
|
|
24
|
-
requireThat([null, 'existing', 'all'].includes(config.scope), 'scope must be null, existing or all.');
|
|
25
|
-
requireThat(Array.isArray(config.connections) && config.connections.length <= 100, 'connections must be a list of up to 100 routes.');
|
|
26
|
-
requireThat(new Set(config.connections.map(c => c.id)).size === config.connections.length, 'connection IDs must be unique.');
|
|
27
|
-
for (const c of config.connections) {
|
|
28
|
-
requireThat(id(c.id) && agents.includes(c.agent) && text(c.scoutId) && text(c.model), 'each connection needs id, scoutId, agent and native model.');
|
|
29
|
-
requireThat(!c.model.startsWith('-') && c.model.length <= 200, 'invalid native model.');
|
|
30
|
-
requireThat(c.env === undefined || Array.isArray(c.env) && c.env.every(k => /^[A-Z][A-Z0-9_]*$/.test(k)), 'env contains variable names, never credential values.');
|
|
31
|
-
requireThat(c.command === undefined || text(c.command), 'command must be an executable path.');
|
|
32
|
-
}
|
|
33
|
-
requireThat(Array.isArray(config.tasks) && config.tasks.length > 0 && config.tasks.length <= 30, 'provide 1–30 tasks.');
|
|
34
|
-
requireThat(new Set(config.tasks.map(t => t.id)).size === config.tasks.length, 'task IDs must be unique.');
|
|
35
|
-
for (const t of config.tasks) {
|
|
36
|
-
requireThat(id(t.id) && text(t.description) && t.description.length <= 4000 && text(t.prompt), 'each task needs id, description for Scout and a local execution prompt.');
|
|
37
|
-
safePath(t.fixture); safePath(t.grader);
|
|
38
|
-
}
|
|
39
|
-
requireThat(Array.isArray(config.variants) && config.variants.length > 0 && config.variants.length <= 5, 'provide 1–5 variants.');
|
|
40
|
-
requireThat(new Set(config.variants.map(v => v.id)).size === config.variants.length, 'variant IDs must be unique.');
|
|
41
|
-
for (const v of config.variants) {
|
|
42
|
-
requireThat(id(v.id) && Array.isArray(v.files) && typeof v.instructions === 'string', 'each variant needs id, files and instructions.');
|
|
43
|
-
v.files.forEach(safePath);
|
|
44
|
-
}
|
|
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((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
|
-
requireThat(Number.isInteger(config.timeoutSeconds) && config.timeoutSeconds >= 1 && config.timeoutSeconds <= 1800, 'timeoutSeconds must be 1–1800.');
|
|
48
|
-
if (config.scoutURL !== undefined) {
|
|
49
|
-
const url = new URL(config.scoutURL);
|
|
50
|
-
requireThat(!url.username && !url.password && (url.protocol === 'https:' || url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)), 'scoutURL requires HTTPS, or HTTP on loopback for development.');
|
|
51
|
-
}
|
|
52
|
-
return config;
|
|
53
|
-
}
|
|
54
|
-
export async function loadConfig(root) { return validateConfig(await readJSON(join(root, configPath))); }
|
|
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
|
-
|
|
62
|
-
// Snapshot exact bytes before executing; edits during a run belong to the next revision.
|
|
63
|
-
export async function filesAt(root, paths) {
|
|
64
|
-
const files = Object.create(null); let bytes = 0; const canonicalRoot = await realpath(root);
|
|
65
|
-
async function visit(path) {
|
|
66
|
-
safePath(path);
|
|
67
|
-
const full = resolve(root, path), info = await lstat(full);
|
|
68
|
-
if (info.isSymbolicLink() || relative(canonicalRoot, await realpath(full)).startsWith('..')) throw new Error(`Eval refuses symlink/outside path: ${path}`);
|
|
69
|
-
if (info.isDirectory()) {
|
|
70
|
-
for (const name of (await readdir(full)).sort()) await visit(join(path, name));
|
|
71
|
-
} else if (info.isFile()) {
|
|
72
|
-
bytes += info.size;
|
|
73
|
-
if (bytes > 25_000_000) throw new Error('Eval fixture/source snapshot exceeds 25 MB. Select smaller paths.');
|
|
74
|
-
files[path] = { data: (await readFile(full)).toString('base64'), executable: !!(info.mode & 0o111) };
|
|
75
|
-
} else throw new Error(`Eval requires regular files: ${path}`);
|
|
76
|
-
}
|
|
77
|
-
for (const path of paths) await visit(path);
|
|
78
|
-
return files;
|
|
79
|
-
}
|
|
80
|
-
export async function snapshot(root, config) {
|
|
81
|
-
const tasks = [];
|
|
82
|
-
for (const task of config.tasks) {
|
|
83
|
-
if (!(await lstat(join(root, task.fixture))).isDirectory() || !(await lstat(join(root, task.grader))).isFile()) throw new Error(`${task.id}: fixture must be a directory and grader a standalone file.`);
|
|
84
|
-
tasks.push({ ...task, fixtureFiles: await filesAt(root, [task.fixture]), graderFiles: await filesAt(root, [task.grader]) });
|
|
85
|
-
}
|
|
86
|
-
const variants = [];
|
|
87
|
-
for (const variant of config.variants) variants.push({ ...variant, sourceFiles: await filesAt(root, variant.files) });
|
|
88
|
-
for (const task of tasks) for (const variant of variants) {
|
|
89
|
-
const fixtureNames = new Set(Object.keys(task.fixtureFiles).map(path => relative(task.fixture, path)));
|
|
90
|
-
if (Object.keys(variant.sourceFiles).some(path => fixtureNames.has(path))) throw new Error(`${variant.id} overwrites a fixture file for ${task.id}. Keep CLI files and task inputs separate.`);
|
|
91
|
-
}
|
|
92
|
-
const compatibility = digest({ tasks, connections: config.connections, repeats: config.repeats, variantIds: variants.map(v => v.id), timeoutSeconds: config.timeoutSeconds });
|
|
93
|
-
return { tasks, variants, compatibility, revision: digest({ tasks, variants, config }) };
|
|
94
|
-
}
|
|
95
|
-
export async function materialize(files, dest, strip = '') {
|
|
96
|
-
for (const [path, file] of Object.entries(files)) {
|
|
97
|
-
const target = join(dest, strip ? relative(strip, path) : path);
|
|
98
|
-
await mkdir(dirname(target), { recursive: true });
|
|
99
|
-
await writeFile(target, Buffer.from(file.data, 'base64'), { mode: file.executable ? 0o700 : 0o600 });
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export const starter = {
|
|
104
|
-
version: 1, scope: null, connections: [],
|
|
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' }],
|
|
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.' }],
|
|
107
|
-
repeats: { min: 3, max: 10 }, budget: { maxUsd: 0, reservationUsd: 'auto' }, timeoutSeconds: 180,
|
|
108
|
-
};
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
// No shell interpolation. Bound both time and output, and stop descendants too.
|
|
4
|
-
export function execute(command, args, { cwd, env = process.env, input = '', timeoutMs = 180_000, signal } = {}) {
|
|
5
|
-
return new Promise(resolve => {
|
|
6
|
-
const start = performance.now();
|
|
7
|
-
let stdout = '', stderr = '', bytes = 0, reason = null, escalation;
|
|
8
|
-
const child = spawn(command, args, { cwd, env, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
9
|
-
const kill = sig => {
|
|
10
|
-
try {
|
|
11
|
-
if (process.platform === 'win32') { const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }); killer.on('error', () => child.kill(sig)); }
|
|
12
|
-
else process.kill(-child.pid, sig);
|
|
13
|
-
} catch {}
|
|
14
|
-
};
|
|
15
|
-
const stop = why => { if (reason) return; reason = why; kill('SIGTERM'); escalation = setTimeout(() => kill('SIGKILL'), 500); };
|
|
16
|
-
const abort = () => stop('cancelled');
|
|
17
|
-
signal?.addEventListener('abort', abort, { once: true });
|
|
18
|
-
if (signal?.aborted) abort();
|
|
19
|
-
const timer = setTimeout(() => stop('timeout'), timeoutMs);
|
|
20
|
-
const collect = (chunk, which) => {
|
|
21
|
-
bytes += chunk.length;
|
|
22
|
-
if (bytes > 4_000_000) return stop('output_limit');
|
|
23
|
-
if (which === 'out') stdout += chunk; else stderr += chunk;
|
|
24
|
-
};
|
|
25
|
-
child.stdout.on('data', chunk => collect(chunk, 'out'));
|
|
26
|
-
child.stderr.on('data', chunk => collect(chunk, 'err'));
|
|
27
|
-
child.on('error', () => { reason = 'spawn_failed'; });
|
|
28
|
-
child.stdin.on('error', () => {});
|
|
29
|
-
child.stdin.end(input);
|
|
30
|
-
child.on('close', code => {
|
|
31
|
-
clearTimeout(timer); clearTimeout(escalation); signal?.removeEventListener('abort', abort);
|
|
32
|
-
resolve({ code, reason, stdout, stderr, seconds: (performance.now() - start) / 1000 });
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function agentEnvironment(names = []) {
|
|
38
|
-
const result = {};
|
|
39
|
-
for (const key of ['PATH', 'HOME', 'USERPROFILE', 'SystemRoot', 'TMPDIR', 'TMP', 'TEMP', 'LANG', 'TERM', ...names]) {
|
|
40
|
-
if (process.env[key] !== undefined) result[key] = process.env[key];
|
|
41
|
-
}
|
|
42
|
-
result.OPENMERIT_EVAL = '1';
|
|
43
|
-
return result;
|
|
44
|
-
}
|
package/dist/eval/src/report.mjs
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
const mean = values => values.length ? values.reduce((a, b) => a + b, 0) / values.length : null;
|
|
2
|
-
const median = values => { const sorted = [...values].sort((a, b) => a - b); return sorted.length ? (sorted[Math.floor((sorted.length - 1) / 2)] + sorted[Math.floor(sorted.length / 2)]) / 2 : null; };
|
|
3
|
-
const completeMean = values => values.every(v => v !== null) ? mean(values) : null;
|
|
4
|
-
export function summarize(rows) {
|
|
5
|
-
const groups = new Map();
|
|
6
|
-
for (const row of rows) {
|
|
7
|
-
const key = [row.task, row.connection, row.variant].join('/');
|
|
8
|
-
if (!groups.has(key)) groups.set(key, []); groups.get(key).push(row);
|
|
9
|
-
}
|
|
10
|
-
return [...groups].map(([key, runs]) => ({
|
|
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' && 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,
|
|
15
|
-
qualityRange: runs.every(r => r.quality !== null) ? [Math.min(...runs.map(r => r.quality)), Math.max(...runs.map(r => r.quality))] : null,
|
|
16
|
-
inputTokens: completeMean(runs.map(r => r.usage?.input ?? null)), outputTokens: completeMean(runs.map(r => r.usage?.output ?? null)),
|
|
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),
|
|
19
|
-
meanCostUsd: completeMean(runs.map(r => r.costUsd)), medianSeconds: median(runs.map(r => r.seconds)),
|
|
20
|
-
}));
|
|
21
|
-
}
|
|
22
|
-
export function isVolatile(rows) {
|
|
23
|
-
const spread = values => {
|
|
24
|
-
if (values.length < 3 || values.some(v => v === null)) return false;
|
|
25
|
-
const avg = mean(values);
|
|
26
|
-
return avg > 0 && Math.sqrt(mean(values.map(v => (v - avg) ** 2))) / avg > .25;
|
|
27
|
-
};
|
|
28
|
-
for (const group of summarize(rows)) {
|
|
29
|
-
const runs = rows.filter(r => `${r.task}/${r.connection}/${r.variant}` === group.key);
|
|
30
|
-
if (group.qualityRange && group.qualityRange[1] - group.qualityRange[0] > .05 || runs.some(r => r.status !== 'ok') || spread(runs.map(r => r.seconds)) || spread(runs.map(r => r.costUsd)) || spread(runs.map(r => r.usage?.input ?? null))) return true;
|
|
31
|
-
}
|
|
32
|
-
return false;
|
|
33
|
-
}
|
|
34
|
-
export function compareReports(current, previous) {
|
|
35
|
-
if (!previous || previous.compatibility !== current.compatibility || previous.planHash !== current.planHash || previous.status !== 'complete' || current.status !== 'complete') return null;
|
|
36
|
-
return current.summary.map(now => {
|
|
37
|
-
const before = previous.summary.find(row => row.key === now.key);
|
|
38
|
-
if (!before) return null;
|
|
39
|
-
const delta = key => now[key] === null || before[key] === null ? null : now[key] - before[key];
|
|
40
|
-
return { key: now.key, previousRuns: before.runs, runs: now.runs, qualityDelta: delta('quality'), inputTokensDelta: delta('inputTokens'), outputTokensDelta: delta('outputTokens'), meanCostUsdDelta: delta('meanCostUsd'), medianSecondsDelta: delta('medianSeconds') };
|
|
41
|
-
}).filter(Boolean);
|
|
42
|
-
}
|
|
43
|
-
const fmt = (v, digits = 2) => v === null || v === undefined ? '?' : v.toFixed(digits);
|
|
44
|
-
export function formatReport(report) {
|
|
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
|
-
}
|
|
50
|
-
if (report.comparison) {
|
|
51
|
-
lines.push('', 'Change from the previous compatible revision (negative tokens/cost/time is lower):');
|
|
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)}`);
|
|
53
|
-
} else lines.push('', 'No complete, compatible previous revision for comparison.');
|
|
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.`);
|
|
57
|
-
if (report.reason) lines.push(`Stopped: ${report.reason}`);
|
|
58
|
-
return lines.join('\n');
|
|
59
|
-
}
|
package/dist/eval/src/runner.mjs
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
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, reservationFor } 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
|
-
// 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);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export async function evaluate(root, { signal, onProgress = () => {}, agentRunner = runAgent, checkAgent = preflight } = {}) {
|
|
26
|
-
const config = await loadConfig(root), plan = await readJSON(join(root, statePath, 'plan.json'));
|
|
27
|
-
validatePlan(config, plan);
|
|
28
|
-
const snap = await snapshot(root, config);
|
|
29
|
-
const selected = new Set(plan.tasks.flatMap(t => t.candidates.flatMap(c => c.connections)));
|
|
30
|
-
const versions = {};
|
|
31
|
-
const ledgerPath = join(root, statePath, 'budget.json');
|
|
32
|
-
const ledger = await optionalJSON(ledgerPath) ?? { version: 1, entries: [] };
|
|
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.');
|
|
36
|
-
const runnerVersion = (await readJSON(new URL('../../../package.json', import.meta.url))).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: [] };
|
|
39
|
-
const reportPath = join(root, statePath, 'runs', `${report.startedAt.replace(/[:.]/g, '-')}-${report.id}.json`);
|
|
40
|
-
const previous = await optionalJSON(join(root, statePath, 'latest.json'));
|
|
41
|
-
const temp = await mkdtemp(join(tmpdir(), 'openmerit-eval-'));
|
|
42
|
-
const checkpoint = async () => { report.summary = summarize(report.rows); await saveJSON(reportPath, report); };
|
|
43
|
-
try {
|
|
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
|
-
}
|
|
49
|
-
// Graders are copied outside all agent workspaces, before any trial runs.
|
|
50
|
-
for (const task of snap.tasks) await materialize(task.graderFiles, join(temp, 'graders', task.id));
|
|
51
|
-
outer: for (const task of snap.tasks) {
|
|
52
|
-
const taskPlan = plan.tasks.find(t => t.id === task.id);
|
|
53
|
-
const cells = taskPlan.candidates.flatMap(candidate => candidate.connections.flatMap(connection => snap.variants.map(variant => ({ connection: config.connections.find(c => c.id === connection), variant, candidate }))));
|
|
54
|
-
for (let round = 0; round < config.repeats.max; round++) {
|
|
55
|
-
// Rotate and reverse the balanced order so one candidate is not always warm/last.
|
|
56
|
-
const ordered = [...cells.slice(round % cells.length), ...cells.slice(0, round % cells.length)];
|
|
57
|
-
if (round % 2) ordered.reverse();
|
|
58
|
-
for (const { connection, variant, candidate } of ordered) {
|
|
59
|
-
if (signal?.aborted) { report.status = 'cancelled'; report.reason = 'Interrupted'; break outer; }
|
|
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 };
|
|
62
|
-
ledger.entries.push(entry); await saveJSON(ledgerPath, ledger);
|
|
63
|
-
const workspace = join(temp, entry.id); await mkdir(workspace);
|
|
64
|
-
await materialize(task.fixtureFiles, workspace, task.fixture);
|
|
65
|
-
await materialize(variant.sourceFiles, workspace);
|
|
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.`;
|
|
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);
|
|
70
|
-
let quality = result.status === 'ok' || result.failureReason ? null : 0, status = result.status;
|
|
71
|
-
if (result.status === 'ok') {
|
|
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 });
|
|
73
|
-
try {
|
|
74
|
-
const value = JSON.parse(grader.stdout);
|
|
75
|
-
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');
|
|
76
|
-
quality = value.quality;
|
|
77
|
-
} catch { status = 'grader_error'; }
|
|
78
|
-
}
|
|
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 };
|
|
80
|
-
report.rows.push(row); await checkpoint(); await rm(workspace, { recursive: true, force: true });
|
|
81
|
-
onProgress(row);
|
|
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
|
-
}
|
|
87
|
-
if (status === 'grader_error') { report.status = 'invalid'; report.reason = `Grader failed for ${task.id}. Fix it before collecting more trials.`; break outer; }
|
|
88
|
-
}
|
|
89
|
-
if (round + 1 >= config.repeats.min && !isVolatile(report.rows.filter(r => r.task === task.id))) break;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
if (report.status === 'running') report.status = 'complete';
|
|
93
|
-
} catch (error) {
|
|
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
|
-
}
|
|
101
|
-
} finally {
|
|
102
|
-
report.finishedAt = new Date().toISOString(); report.budgetUsedUsd = budgetUsed(ledger);
|
|
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()])) });
|
|
104
|
-
report.summary = summarize(report.rows); report.comparison = compareReports(report, previous);
|
|
105
|
-
await saveJSON(reportPath, report); await saveJSON(join(root, statePath, 'latest.json'), report);
|
|
106
|
-
await rm(temp, { recursive: true, force: true });
|
|
107
|
-
}
|
|
108
|
-
return report;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export async function watch(root, { signal, onReport, onProgress, intervalMs = 1500, ...options } = {}) {
|
|
112
|
-
let last = null, pending = null;
|
|
113
|
-
while (!signal?.aborted) {
|
|
114
|
-
const config = await loadConfig(root), current = (await snapshot(root, config)).revision;
|
|
115
|
-
if (current !== last && current === pending) {
|
|
116
|
-
const result = await evaluate(root, { signal, onProgress, ...options });
|
|
117
|
-
onReport?.(result); last = result.revision;
|
|
118
|
-
if (result.status !== 'complete') return result;
|
|
119
|
-
}
|
|
120
|
-
pending = current;
|
|
121
|
-
await new Promise(resolve => { const done = () => { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); }; const timer = setTimeout(done, intervalMs); signal?.addEventListener('abort', done, { once: true }); });
|
|
122
|
-
}
|
|
123
|
-
}
|
package/dist/eval/src/scout.mjs
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { digest, saveJSON, statePath } from './config.mjs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { scoutDiagnostic } from '../../terminal/src/telemetry.mjs';
|
|
4
|
-
|
|
5
|
-
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 });
|
|
6
|
-
export async function makePlan(config, fetcher = fetch) {
|
|
7
|
-
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.');
|
|
8
|
-
const modelIds = [...new Set(config.connections.map(c => c.scoutId))];
|
|
9
|
-
if (config.scope === 'existing' && !modelIds.length) throw new Error('Declare project connections in .openmerit/eval.json first. No credentials are sent to Scout.');
|
|
10
|
-
const tasks = [];
|
|
11
|
-
for (const task of config.tasks) {
|
|
12
|
-
let trace, code = 'scout_network_error';
|
|
13
|
-
try {
|
|
14
|
-
const response = await fetcher(config.scoutURL ?? 'https://openmerit.ai/scout/api/recommend', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Scout-Client': 'cli', 'X-Scout-Analytics-Content': 'off', 'X-Scout-Analytics': process.env.OPENMERIT_TELEMETRY === '1' && process.env.DO_NOT_TRACK !== '1' ? 'on' : 'off' }, body: JSON.stringify({ prompt: task.description, ...(config.scope === 'existing' ? { modelIds } : {}) }), signal: AbortSignal.timeout(15_000), redirect: 'error' });
|
|
15
|
-
trace = response.headers?.get('X-Scout-Trace-Id');
|
|
16
|
-
code = 'scout_http_error';
|
|
17
|
-
if (!response.ok) throw new Error(`Scout returned HTTP ${response.status}; no evaluation was started.`);
|
|
18
|
-
code = 'scout_response_error';
|
|
19
|
-
const body = await response.json();
|
|
20
|
-
if (!Array.isArray(body.recommendations) || body.recommendations.length > 5) throw new Error('Invalid Scout response.');
|
|
21
|
-
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.');
|
|
22
|
-
const seen = new Set();
|
|
23
|
-
const candidates = body.recommendations.map(m => {
|
|
24
|
-
if (typeof m.id !== 'string' || seen.has(m.id) || config.scope === 'existing' && !modelIds.includes(m.id)) throw new Error('Invalid Scout model selection.');
|
|
25
|
-
seen.add(m.id);
|
|
26
|
-
return { scoutId: m.id, name: m.name, rank: m.rank, connections: config.connections.filter(c => c.scoutId === m.id).map(c => c.id) };
|
|
27
|
-
});
|
|
28
|
-
tasks.push({ id: task.id, candidates, ranking: body.ranking, warnings: body.warnings ?? [] });
|
|
29
|
-
} catch (error) {
|
|
30
|
-
try { scoutDiagnostic(error, trace, error?.name === 'TimeoutError' || error?.name === 'AbortError' ? 'scout_timeout' : code); }
|
|
31
|
-
catch { /* Diagnostics must not replace the original Scout failure. */ }
|
|
32
|
-
throw error;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return { version: 1, createdAt: new Date().toISOString(), scope: config.scope, selectionHash: selectionHash(config), tasks };
|
|
36
|
-
}
|
|
37
|
-
export async function savePlan(root, plan) { await saveJSON(join(root, statePath, 'plan.json'), plan); }
|
|
38
|
-
export function validatePlan(config, plan) {
|
|
39
|
-
if (plan.selectionHash !== selectionHash(config)) throw new Error('Task descriptions, scope or connections changed. Run eval plan again.');
|
|
40
|
-
if (!Array.isArray(plan.tasks) || plan.tasks.length !== config.tasks.length) throw new Error('Incomplete evaluation plan. Run eval plan again.');
|
|
41
|
-
for (const task of config.tasks) {
|
|
42
|
-
const entry = plan.tasks.find(t => t.id === task.id);
|
|
43
|
-
if (!entry?.candidates?.length) throw new Error(`Scout has no supported candidates for ${task.id}. Broaden scope or revise the task description.`);
|
|
44
|
-
for (const model of entry.candidates) {
|
|
45
|
-
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.`);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
}
|