premanmcp 1.1.11 → 1.1.13
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/bin/account.js +48 -0
- package/bin/cli.js +73 -14
- package/bin/connect/configs.js +20 -6
- package/bin/desktop.js +557 -79
- package/bin/eval.js +328 -50
- package/bin/eval_entry.js +9 -2
- package/bin/eval_generation_contract.py +72 -0
- package/bin/eval_generation_debug.py +279 -0
- package/bin/eval_generation_status.py +284 -0
- package/bin/eval_harness.py +198 -4
- package/bin/eval_start.js +203 -0
- package/bin/eval_target.js +20 -1
- package/bin/eval_templates.js +447 -21
- package/bin/hook.js +21 -1
- package/bin/integrations.js +20 -4
- package/bin/probe.js +323 -0
- package/bin/runner.js +23 -0
- package/bin/shared.js +331 -14
- package/package.json +2 -2
package/bin/eval.js
CHANGED
|
@@ -238,7 +238,7 @@ function tidy(home) {
|
|
|
238
238
|
* assumption every path-traversal bug is built on. A frame that names
|
|
239
239
|
* `../../.ssh/authorized_keys` is refused here rather than written.
|
|
240
240
|
*/
|
|
241
|
-
export function materialize(job, root) {
|
|
241
|
+
export function materialize(job, root, { trace = false } = {}) {
|
|
242
242
|
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
243
243
|
const bundle = path.join(root, "bundle");
|
|
244
244
|
// Emptied first, because the directory outlives the run now. Every file the
|
|
@@ -259,13 +259,65 @@ export function materialize(job, root) {
|
|
|
259
259
|
writeFileSync(target, String(entry.text ?? ""), { mode: 0o600 });
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
const spec = JSON.parse(JSON.stringify(job.spec ?? {}));
|
|
263
|
+
// Traced only when this device asked for it and the target is the in-process
|
|
264
|
+
// callable: assert-ai then uses OTelTracedSession, which switches the
|
|
265
|
+
// installed plugins on before importing the agent. A spec that already says
|
|
266
|
+
// how to trace keeps its own setting.
|
|
267
|
+
const traceTarget = spec.pipeline?.inference?.target;
|
|
268
|
+
if (trace && traceTarget && typeof traceTarget === "object" && traceTarget.callable && !traceTarget.trace) {
|
|
269
|
+
traceTarget.trace = { backend: "otel", group_by: "session.id" };
|
|
270
|
+
}
|
|
271
|
+
const taxonomy = spec._preman_generation_taxonomy;
|
|
272
|
+
const hasSavedTaxonomy = taxonomy !== null && typeof taxonomy === "object" && !Array.isArray(taxonomy);
|
|
273
|
+
delete spec._preman_generation_taxonomy;
|
|
274
|
+
|
|
275
|
+
// A dispatched bank is already selected, including an explicitly empty one.
|
|
276
|
+
// Generating cases again would spend money and replace the questions being rerun.
|
|
277
|
+
if (job.case_bank != null) {
|
|
278
|
+
if (!Array.isArray(job.case_bank)) throw new EvalError("the run carried an invalid case bank");
|
|
279
|
+
const outputDir = path.resolve(runDir(root, job));
|
|
280
|
+
if (!outputDir.startsWith(path.resolve(root) + path.sep)) {
|
|
281
|
+
throw new EvalError("refusing to write outside the run directory: case_map.json");
|
|
282
|
+
}
|
|
283
|
+
const bankPath = path.resolve(bundle, "preman-selected-cases.jsonl");
|
|
284
|
+
writeFileSync(bankPath, job.case_bank.map((row) => `${JSON.stringify(row)}\n`).join(""), { mode: 0o600 });
|
|
285
|
+
const pipeline = spec.pipeline ||= {};
|
|
286
|
+
delete pipeline.test_set;
|
|
287
|
+
// Legacy subsets still need a rubric when no saved taxonomy arrived.
|
|
288
|
+
if (hasSavedTaxonomy) delete pipeline.systematize;
|
|
289
|
+
const inference = pipeline.inference ||= {};
|
|
290
|
+
inference.test_set_path = bankPath;
|
|
291
|
+
|
|
292
|
+
// ASSERT renumbers its input by position. Store the translation alongside
|
|
293
|
+
// the run's results so a subset still answers its original bank cases.
|
|
294
|
+
const mapping = Object.fromEntries(job.case_bank.flatMap((row, index) =>
|
|
295
|
+
typeof row?.test_case_id === "string" && row.test_case_id
|
|
296
|
+
? [[`test_case_${String(index + 1).padStart(6, "0")}`, row.test_case_id]]
|
|
297
|
+
: []
|
|
298
|
+
));
|
|
299
|
+
if (Object.keys(mapping).length) {
|
|
300
|
+
mkdirSync(outputDir, { recursive: true, mode: 0o700 });
|
|
301
|
+
writeFileSync(path.join(outputDir, "case_map.json"), `${JSON.stringify(mapping, null, 2)}\n`, { mode: 0o600 });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Curated jobs already reference a bundled rubric. Only replace it when
|
|
305
|
+
// dispatch supplies the rubric captured with a generated bank.
|
|
306
|
+
const judge = pipeline.judge;
|
|
307
|
+
if (hasSavedTaxonomy && judge && typeof judge === "object" && !Array.isArray(judge)) {
|
|
308
|
+
const taxonomyPath = path.resolve(bundle, "preman-generation-taxonomy.json");
|
|
309
|
+
writeFileSync(taxonomyPath, `${JSON.stringify(taxonomy, null, 2)}\n`, { mode: 0o600 });
|
|
310
|
+
judge.taxonomy_path = taxonomyPath;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
262
314
|
// The spec is written as JSON with a .yaml name, which is legal and not a
|
|
263
315
|
// shortcut: YAML is a superset of JSON, assert-ai's loader accepts it, and
|
|
264
316
|
// JSON.stringify cannot mis-quote a value the way a hand-rolled YAML emitter
|
|
265
317
|
// can. Shipping a YAML serializer to avoid a filename that looks odd would be
|
|
266
318
|
// the worse trade.
|
|
267
319
|
const configPath = path.join(bundle, "preman-eval.yaml");
|
|
268
|
-
writeFileSync(configPath, `${JSON.stringify(
|
|
320
|
+
writeFileSync(configPath, `${JSON.stringify(spec, null, 2)}\n`, { mode: 0o600 });
|
|
269
321
|
return { cwd: root, bundle, configPath };
|
|
270
322
|
}
|
|
271
323
|
|
|
@@ -298,6 +350,35 @@ export const HARNESS_SPEC =
|
|
|
298
350
|
String(process.env.PREMAN_EVAL_HARNESS_SPEC || "").trim() ||
|
|
299
351
|
"git+https://github.com/AleWang16/ASSERT@14f4e2136ab753897ce4cb90613e1663730aeb70";
|
|
300
352
|
|
|
353
|
+
/**
|
|
354
|
+
* OpenInference plugins added to the environment when a run is traced
|
|
355
|
+
* (`PREMAN_EVAL_TRACE=1`, docs/EVAL_AGENT_TRACING.md). Each one only records
|
|
356
|
+
* calls into a library the agent actually imports.
|
|
357
|
+
*
|
|
358
|
+
* Two tiers, because a framework plugin can pin its framework's version and a
|
|
359
|
+
* customer's own pin may disagree: the resolve is tried with everything, then
|
|
360
|
+
* with the provider SDKs alone, then with none. A plugin must never be the
|
|
361
|
+
* reason a run cannot start.
|
|
362
|
+
*/
|
|
363
|
+
export const TRACE_PROVIDER_PLUGINS = [
|
|
364
|
+
"openinference-instrumentation-openai",
|
|
365
|
+
"openinference-instrumentation-anthropic",
|
|
366
|
+
"openinference-instrumentation-google-genai",
|
|
367
|
+
"openinference-instrumentation-bedrock",
|
|
368
|
+
];
|
|
369
|
+
export const TRACE_FRAMEWORK_PLUGINS = [
|
|
370
|
+
"openinference-instrumentation-langchain",
|
|
371
|
+
"openinference-instrumentation-llama-index",
|
|
372
|
+
"openinference-instrumentation-openai-agents",
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
/** Shapes whose agent runs inside the harness's own Python process. */
|
|
376
|
+
const IN_PROCESS_SHAPES = new Set(["py_history", "py_plain", "py_class", "langgraph", "other", ""]);
|
|
377
|
+
|
|
378
|
+
export function tracesInProcess(target) {
|
|
379
|
+
return target?.kind === "callable" && target.language === "python" && IN_PROCESS_SHAPES.has(target.shape || "");
|
|
380
|
+
}
|
|
381
|
+
|
|
301
382
|
/** How long a first resolve may take before it counts as a failure. */
|
|
302
383
|
const COLD_RESOLVE_MS = 600_000;
|
|
303
384
|
|
|
@@ -410,15 +491,29 @@ export function resolveInterpreter({ projectPath = process.cwd(), log = () => {}
|
|
|
410
491
|
// fetches the harness's whole dependency tree, and treating a slow network
|
|
411
492
|
// as "this interpreter cannot import assert_ai" would send somebody looking
|
|
412
493
|
// for a missing package.
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
494
|
+
const tiers =
|
|
495
|
+
process.env.PREMAN_EVAL_TRACE === "1"
|
|
496
|
+
? [
|
|
497
|
+
{ plugins: [...TRACE_PROVIDER_PLUGINS, ...TRACE_FRAMEWORK_PLUGINS], note: ", tracing the agent's model and framework calls" },
|
|
498
|
+
{ plugins: TRACE_PROVIDER_PLUGINS, note: ", tracing the agent's model calls (framework plugins did not resolve against this project)" },
|
|
499
|
+
{ plugins: [], note: " (tracing requested, but no tracing plugin resolved against this project)" },
|
|
500
|
+
]
|
|
501
|
+
: [{ plugins: [], note: "" }];
|
|
502
|
+
for (const { plugins, note } of tiers) {
|
|
503
|
+
const added = [
|
|
504
|
+
"run", "--project", projectPath, "--with", HARNESS_SPEC,
|
|
505
|
+
...plugins.flatMap((plugin) => ["--with", plugin]),
|
|
506
|
+
"python",
|
|
507
|
+
];
|
|
508
|
+
const resolved = probeInterpreter("uv", added, { timeoutMs: COLD_RESOLVE_MS });
|
|
509
|
+
if (resolved) {
|
|
510
|
+
return {
|
|
511
|
+
command: "uv",
|
|
512
|
+
argv: added,
|
|
513
|
+
version: resolved,
|
|
514
|
+
how: `uv run in this project, with PreMan's harness alongside it${note}`,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
422
517
|
}
|
|
423
518
|
}
|
|
424
519
|
|
|
@@ -637,9 +732,10 @@ export const RUN_ARTIFACTS = [
|
|
|
637
732
|
* No scope is sent with an upload. The server derives it from the filename
|
|
638
733
|
* (`eval_runner_artifacts.scope_of`), files these under the suite rather than
|
|
639
734
|
* the run, and applies its own write-once rule — so a second run of the same
|
|
640
|
-
* suite offers them again and the server declines to rewrite history.
|
|
735
|
+
* suite offers them again and the server declines to rewrite history. The
|
|
736
|
+
* generation report is mutable and describes jobs that did not enter the bank.
|
|
641
737
|
*/
|
|
642
|
-
export const SUITE_ARTIFACTS = ["taxonomy.json", "test_set.jsonl"];
|
|
738
|
+
export const SUITE_ARTIFACTS = ["taxonomy.json", "test_set.jsonl", "generation.json"];
|
|
643
739
|
|
|
644
740
|
/**
|
|
645
741
|
* Upload the artifacts that changed since last time.
|
|
@@ -1168,7 +1264,7 @@ export async function executeEvalRun(
|
|
|
1168
1264
|
try {
|
|
1169
1265
|
home = suiteHome(job);
|
|
1170
1266
|
pruneRuns(home, job);
|
|
1171
|
-
laid = materialize(job, home);
|
|
1267
|
+
laid = materialize(job, home, { trace: process.env.PREMAN_EVAL_TRACE === "1" });
|
|
1172
1268
|
// Offered only when the harness is the only thing that has to import
|
|
1173
1269
|
// anything. A `callable` target imports the customer's agent in-process, so
|
|
1174
1270
|
// it must resolve against an environment that has *their* dependencies —
|
|
@@ -1553,11 +1649,24 @@ export async function summarizeRuns(args, key, runs, { say = () => {} } = {}) {
|
|
|
1553
1649
|
|
|
1554
1650
|
export const EVAL_HELP = `
|
|
1555
1651
|
Eval options:
|
|
1652
|
+
eval Measure your agent. With no flags this is a
|
|
1653
|
+
conversation: PreMan asks what the agent does
|
|
1654
|
+
and narrows to the two or three behaviours worth
|
|
1655
|
+
measuring, rather than pricing everything that
|
|
1656
|
+
happens to be switched on
|
|
1657
|
+
eval --only <id> Skip the conversation and measure one behaviour
|
|
1658
|
+
--yes Do not ask before spending. For CI
|
|
1659
|
+
--fail-on <rate> Exit 1 if the pass rate is below this. Accepts
|
|
1660
|
+
0.8 or 80. Off by default, so adding this to a
|
|
1661
|
+
pipeline cannot fail a build that passed before
|
|
1662
|
+
--detach Start it and hand the terminal back
|
|
1556
1663
|
eval run --token <pmev_...> Redeem a token from the dashboard: find the agent
|
|
1557
1664
|
on this machine and run exactly the evals the
|
|
1558
1665
|
token names
|
|
1559
1666
|
eval run Pair this project and run the eval PreMan has
|
|
1560
|
-
queued for it, then exit
|
|
1667
|
+
queued for it, then exit. Also \`runner work\`,
|
|
1668
|
+
which says what it is: this is the machine
|
|
1669
|
+
volunteering, not you asking
|
|
1561
1670
|
eval target Find the code in this repository that runs your
|
|
1562
1671
|
agent, write a wrapper the eval can call, and
|
|
1563
1672
|
remember it once you approve it
|
|
@@ -1671,42 +1780,77 @@ export function watchRunTerminal(args, runId, { token, intervalMs = 3_000, fetch
|
|
|
1671
1780
|
|
|
1672
1781
|
async function runForThisAccount(args, deps) {
|
|
1673
1782
|
const say = (line) => process.stdout.write(`${line}\n`);
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1783
|
+
const {
|
|
1784
|
+
chooseBehaviors,
|
|
1785
|
+
confirmSpend,
|
|
1786
|
+
quoteRun,
|
|
1787
|
+
requireKey,
|
|
1788
|
+
EXIT_DID_NOT_RUN,
|
|
1789
|
+
} = await import("./eval_start.js");
|
|
1790
|
+
|
|
1791
|
+
let key;
|
|
1792
|
+
let chosen;
|
|
1793
|
+
try {
|
|
1794
|
+
key = requireKey(args);
|
|
1795
|
+
chosen = await chooseBehaviors(args, key, { only: args.value("--only", "") });
|
|
1796
|
+
} catch (error) {
|
|
1797
|
+
throw new EvalError(error.message);
|
|
1681
1798
|
}
|
|
1682
1799
|
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1800
|
+
// Detaching changes who runs it, which is why it changes which check applies.
|
|
1801
|
+
//
|
|
1802
|
+
// A foreground run executes in this process: the harness, the adapter and the
|
|
1803
|
+
// agent are all here, and the terminal staying open is what keeps them alive.
|
|
1804
|
+
// There is nothing to hand it to on the way out, so `--detach` cannot mean
|
|
1805
|
+
// "the same run, without the wait" -- it has to mean "queue this to the
|
|
1806
|
+
// machine that is already connected and listening". That is a different
|
|
1807
|
+
// destination, and the account's four readiness states are exactly the
|
|
1808
|
+
// question of whether it exists.
|
|
1809
|
+
const detach = args.has("--detach");
|
|
1810
|
+
if (detach) {
|
|
1811
|
+
const { accountReadiness, reportNotReady, EXIT_DID_NOT_RUN } = await import(
|
|
1812
|
+
"./eval_start.js"
|
|
1688
1813
|
);
|
|
1814
|
+
const state = await accountReadiness(args, key);
|
|
1815
|
+
if (state.state !== "ready") {
|
|
1816
|
+
say(
|
|
1817
|
+
"--detach queues the run to a connected machine rather than running it " +
|
|
1818
|
+
"here, and there is not one yet."
|
|
1819
|
+
);
|
|
1820
|
+
say("");
|
|
1821
|
+
reportNotReady(state, { say });
|
|
1822
|
+
return { ok: false, reason: state.state, exitCode: EXIT_DID_NOT_RUN };
|
|
1823
|
+
}
|
|
1689
1824
|
}
|
|
1690
1825
|
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1826
|
+
// The agent has to be findable before anybody is quoted for measuring it.
|
|
1827
|
+
//
|
|
1828
|
+
// Deliberately not the account's four readiness states, which answer "could
|
|
1829
|
+
// the server dispatch this to a connected daemon". A foreground run *is* the
|
|
1830
|
+
// runner for its own duration, so gating it on a daemon would refuse a local
|
|
1831
|
+
// run for the absence of something it does not use. The honest local question
|
|
1832
|
+
// is whether there is an approved entry point here, and `allowSearch: false`
|
|
1833
|
+
// asks it without writing a wrapper or prompting: the search belongs to
|
|
1834
|
+
// `eval target`, on purpose, with somebody watching.
|
|
1835
|
+
if (
|
|
1836
|
+
!detach &&
|
|
1837
|
+
!String(args.value("--target", "") || args.value("--preman-agent", "")).trim()
|
|
1838
|
+
) {
|
|
1839
|
+
let found = null;
|
|
1840
|
+
try {
|
|
1841
|
+
found = await discoverTarget(args, { log: () => {}, allowSearch: false });
|
|
1842
|
+
} catch {
|
|
1843
|
+
found = null;
|
|
1844
|
+
}
|
|
1845
|
+
if (!found) {
|
|
1846
|
+
say(
|
|
1847
|
+
"No agent has been approved in this repository yet, so there is nothing " +
|
|
1848
|
+
"here to measure. Find one once and approve it:"
|
|
1849
|
+
);
|
|
1850
|
+
say("");
|
|
1851
|
+
say(` ${cliInvocation()} eval target`);
|
|
1852
|
+
return { ok: false, reason: "no_target", exitCode: EXIT_DID_NOT_RUN };
|
|
1853
|
+
}
|
|
1710
1854
|
}
|
|
1711
1855
|
|
|
1712
1856
|
// One run each, and said plainly before anything is spent: assert-ai's
|
|
@@ -1715,6 +1859,37 @@ async function runForThisAccount(args, deps) {
|
|
|
1715
1859
|
say(`${chosen.length} behaviour${chosen.length === 1 ? "" : "s"} switched on:`);
|
|
1716
1860
|
for (const entry of chosen) say(` ${entry.title || entry.id}`);
|
|
1717
1861
|
|
|
1862
|
+
const quote = await quoteRun(args, key, {
|
|
1863
|
+
behaviorIds: chosen.map((entry) => entry.id),
|
|
1864
|
+
endpoint: String(args.value("--endpoint", "") || "").trim(),
|
|
1865
|
+
});
|
|
1866
|
+
const agreed = await confirmSpend(quote, {
|
|
1867
|
+
assumeYes: args.has("--yes"),
|
|
1868
|
+
say,
|
|
1869
|
+
});
|
|
1870
|
+
if (!agreed) {
|
|
1871
|
+
say("Nothing started, nothing charged.");
|
|
1872
|
+
return { ok: false, reason: "declined", exitCode: EXIT_DID_NOT_RUN };
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
if (detach) {
|
|
1876
|
+
const queued = await callBackendJson(args, "POST", "/eval-sessions/dispatch", {
|
|
1877
|
+
token: key,
|
|
1878
|
+
json: { behavior_ids: chosen.map((entry) => entry.id) },
|
|
1879
|
+
});
|
|
1880
|
+
if (!queued.ok) {
|
|
1881
|
+
throw new EvalError(
|
|
1882
|
+
`could not queue (${queued.status_code}): ${describeFailure(queued)}`
|
|
1883
|
+
);
|
|
1884
|
+
}
|
|
1885
|
+
const runs = queued.runs || [];
|
|
1886
|
+
say(`Queued ${runs.length} run${runs.length === 1 ? "" : "s"} on ${queued.target?.label || "the connected machine"}.`);
|
|
1887
|
+
for (const row of runs) say(` ${row.id}`);
|
|
1888
|
+
say("");
|
|
1889
|
+
say("The result appears in the PreMan app when it finishes.");
|
|
1890
|
+
return { ok: true, detached: true, runs, exitCode: 0 };
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1718
1893
|
const opened = await callBackendJson(args, "POST", "/eval-sessions", {
|
|
1719
1894
|
token: key,
|
|
1720
1895
|
json: { behavior_ids: chosen.map((entry) => entry.id) },
|
|
@@ -1725,7 +1900,44 @@ async function runForThisAccount(args, deps) {
|
|
|
1725
1900
|
);
|
|
1726
1901
|
}
|
|
1727
1902
|
|
|
1728
|
-
|
|
1903
|
+
const finished = await runWithToken(opened.token, args, deps);
|
|
1904
|
+
|
|
1905
|
+
const { exitCodeFor } = await import("./eval_start.js");
|
|
1906
|
+
const totals = tallyCases(finished.summaries);
|
|
1907
|
+
return {
|
|
1908
|
+
...finished,
|
|
1909
|
+
totals,
|
|
1910
|
+
exitCode: exitCodeFor(totals, { failOn: args.value("--fail-on", "") }),
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
/**
|
|
1915
|
+
* Cases and passes across every behaviour, for a gate to compare.
|
|
1916
|
+
*
|
|
1917
|
+
* Totalled rather than averaged: a behaviour of 2 cases and one of 20 are not
|
|
1918
|
+
* equal votes, and one behaviour passing perfectly does not offset another that
|
|
1919
|
+
* failed.
|
|
1920
|
+
*
|
|
1921
|
+
* A `flagged` case is one the judge marked against the rubric, so it is not a
|
|
1922
|
+
* pass. Neither is `not_run` -- a case that never reached the agent told us
|
|
1923
|
+
* nothing, and counting it either way would be inventing a result. It is
|
|
1924
|
+
* counted in the denominator so that a run which mostly failed to execute
|
|
1925
|
+
* cannot report a perfect rate off the handful that did.
|
|
1926
|
+
*/
|
|
1927
|
+
export function tallyCases(summaries = []) {
|
|
1928
|
+
return (summaries || []).reduce(
|
|
1929
|
+
(sum, entry) => {
|
|
1930
|
+
const row = entry?.row ?? entry ?? {};
|
|
1931
|
+
const scored = Number(row.scored) || 0;
|
|
1932
|
+
const flagged = Number(row.flagged) || 0;
|
|
1933
|
+
const notRun = Number(row.not_run) || 0;
|
|
1934
|
+
return {
|
|
1935
|
+
cases: sum.cases + scored + notRun,
|
|
1936
|
+
passed: sum.passed + Math.max(0, scored - flagged),
|
|
1937
|
+
};
|
|
1938
|
+
},
|
|
1939
|
+
{ cases: 0, passed: 0 }
|
|
1940
|
+
);
|
|
1729
1941
|
}
|
|
1730
1942
|
|
|
1731
1943
|
/**
|
|
@@ -1776,6 +1988,17 @@ async function runWithToken(
|
|
|
1776
1988
|
// import path, so the harness reads this and inserts it before handing off.
|
|
1777
1989
|
process.env.PREMAN_EVAL_SYS_PATH = target.sysPath;
|
|
1778
1990
|
}
|
|
1991
|
+
// On unless PREMAN_EVAL_TRACE=0 (docs/EVAL_AGENT_TRACING.md). Kept only for an
|
|
1992
|
+
// agent that runs inside the harness's Python process; a node bridge or a
|
|
1993
|
+
// subprocess is invisible to in-process tracing, and tracing it would only
|
|
1994
|
+
// replace the adapter's reported tool calls with nothing.
|
|
1995
|
+
const traceAsked = process.env.PREMAN_EVAL_TRACE;
|
|
1996
|
+
if (traceAsked !== "0") {
|
|
1997
|
+
process.env.PREMAN_EVAL_TRACE = tracesInProcess(target) ? "1" : "0";
|
|
1998
|
+
if (traceAsked === "1" && !tracesInProcess(target)) {
|
|
1999
|
+
say(`tracing is not available for this agent (${target.language || "?"} / ${target.shape || "?"}); running untraced`);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
1779
2002
|
|
|
1780
2003
|
if (adapter.translated) {
|
|
1781
2004
|
// Set for this process so `childEnv` carries it to the harness. assert-ai
|
|
@@ -1897,12 +2120,15 @@ async function runWithToken(
|
|
|
1897
2120
|
say(`Finished ${done} of ${runs.length}.`);
|
|
1898
2121
|
// The numbers before the link, because the link is where somebody goes when
|
|
1899
2122
|
// the numbers are not enough -- and on this path they usually are.
|
|
1900
|
-
await summarizeRuns(args, resolveApiKey(args), runs, { say });
|
|
2123
|
+
const summaries = await summarizeRuns(args, resolveApiKey(args), runs, { say });
|
|
1901
2124
|
if (surface) {
|
|
1902
2125
|
say("");
|
|
1903
2126
|
say(formatSurfaceLink(surface, { stream: process.stdout }));
|
|
1904
2127
|
}
|
|
1905
|
-
|
|
2128
|
+
// Carried out so a caller can gate on the pass rate. Returning only
|
|
2129
|
+
// `ok: done > 0` says the runs executed, which is a different question from
|
|
2130
|
+
// whether the agent behaved -- and a CI step needs the second one.
|
|
2131
|
+
return { ok: done > 0, jobsRun: done, expected: runs.length, summaries };
|
|
1906
2132
|
} finally {
|
|
1907
2133
|
// Always, including on the throw paths above. The adapter holds a listening
|
|
1908
2134
|
// socket and a set of live conversations with the agent; leaving it up after
|
|
@@ -1955,8 +2181,17 @@ export async function evalCommand(
|
|
|
1955
2181
|
resolveAgent = null,
|
|
1956
2182
|
} = {}
|
|
1957
2183
|
) {
|
|
1958
|
-
|
|
2184
|
+
// Bare `preman eval` used to mean "volunteer this machine as a worker and
|
|
2185
|
+
// wait for queued work", which is the opposite of what someone typing the
|
|
2186
|
+
// name of the feature is asking for. It now means "measure my agent", and the
|
|
2187
|
+
// worker role keeps its explicit spellings: `eval run`, `eval run --token`,
|
|
2188
|
+
// and the `runner work` alias that says what it is.
|
|
2189
|
+
//
|
|
2190
|
+
// `--token` still implies the worker path with no subcommand, because a
|
|
2191
|
+
// redeemed token is by definition work somebody else chose.
|
|
1959
2192
|
const args = makeArgs(commandArgs);
|
|
2193
|
+
const named = commandArgs.find((value) => !value.startsWith("-")) || "";
|
|
2194
|
+
const sub = named || (String(args.value("--token", "") || "").trim() ? "run" : "");
|
|
1960
2195
|
|
|
1961
2196
|
if (sub === "target") {
|
|
1962
2197
|
// Its own command so the model call and the file write happen once, on
|
|
@@ -1980,6 +2215,25 @@ export async function evalCommand(
|
|
|
1980
2215
|
|
|
1981
2216
|
if (sub === "doctor") {
|
|
1982
2217
|
const projectPath = path.resolve(args.value("--path", process.cwd()));
|
|
2218
|
+
|
|
2219
|
+
// Two questions, because they have two different answers and people hit
|
|
2220
|
+
// both. "Can this box run a harness" is local and is what the rest of this
|
|
2221
|
+
// block measures. "Would a run started from the dashboard reach a machine"
|
|
2222
|
+
// is the account's, lives on the server, and is invisible from here -- a
|
|
2223
|
+
// laptop with a perfect local setup still shows `disconnected` if its
|
|
2224
|
+
// daemon is not running, and that is exactly the confusion worth printing.
|
|
2225
|
+
try {
|
|
2226
|
+
const { accountReadiness, requireKey } = await import("./eval_start.js");
|
|
2227
|
+
const state = await accountReadiness(args, requireKey(args));
|
|
2228
|
+
process.stdout.write(`Account: ${state.state}\n ${state.note}\n`);
|
|
2229
|
+
if (state.next_step) process.stdout.write(` ${state.next_step}\n`);
|
|
2230
|
+
process.stdout.write("\n");
|
|
2231
|
+
} catch (error) {
|
|
2232
|
+
// A doctor that dies on the first failed question is not a doctor. The
|
|
2233
|
+
// local half below is the half somebody ran this for.
|
|
2234
|
+
process.stdout.write(`Account: could not be read (${error.message})\n\n`);
|
|
2235
|
+
}
|
|
2236
|
+
|
|
1983
2237
|
let python;
|
|
1984
2238
|
try {
|
|
1985
2239
|
// Doctor answers "could a run happen here", so it has to resolve the same
|
|
@@ -2005,6 +2259,30 @@ export async function evalCommand(
|
|
|
2005
2259
|
return { ok: true, python };
|
|
2006
2260
|
}
|
|
2007
2261
|
|
|
2262
|
+
// The user's door. With a selection or a target this is a direct run; with
|
|
2263
|
+
// nothing it is a conversation, because "evaluate my agent" is not a complete
|
|
2264
|
+
// request and the model is much better than a flag at turning it into one --
|
|
2265
|
+
// it reads what the agent does and narrows to two or three behaviours, where
|
|
2266
|
+
// a bare command would price every behaviour that happens to be switched on.
|
|
2267
|
+
if (sub === "") {
|
|
2268
|
+
const direct =
|
|
2269
|
+
args.has("--only") ||
|
|
2270
|
+
args.has("--yes") ||
|
|
2271
|
+
args.has("--endpoint") ||
|
|
2272
|
+
args.has("--target") ||
|
|
2273
|
+
args.has("--preman-agent");
|
|
2274
|
+
if (!direct) {
|
|
2275
|
+
const { agentCommand } = await import("./agent.js");
|
|
2276
|
+
return agentCommand(["Evaluate my agent."]);
|
|
2277
|
+
}
|
|
2278
|
+
return runForThisAccount(args, {
|
|
2279
|
+
runnerLoop,
|
|
2280
|
+
saveRunnerState,
|
|
2281
|
+
readRunnerState,
|
|
2282
|
+
deviceId,
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2008
2286
|
if (sub !== "run") throw new EvalError(`unknown eval subcommand: ${sub}${EVAL_HELP}`);
|
|
2009
2287
|
|
|
2010
2288
|
// The token path is a different command wearing the same name, and the
|
package/bin/eval_entry.js
CHANGED
|
@@ -52,7 +52,10 @@ export const ENTRY_FILE = `${ENTRY_MODULE}.py`;
|
|
|
52
52
|
export const BRIDGE_FILE = "agent_bridge.mjs";
|
|
53
53
|
|
|
54
54
|
const SEARCH_TIMEOUT_MS = 120_000;
|
|
55
|
-
|
|
55
|
+
// One real turn, after importing the customer's agent. GPUScraper alone takes
|
|
56
|
+
// 37s to import from /mnt/c (2,227 modules), so 60s failed on the import plus
|
|
57
|
+
// one model call. PREMAN_PROBE_TIMEOUT_S overrides for slower repos.
|
|
58
|
+
const PROBE_TIMEOUT_MS = (Number(process.env.PREMAN_PROBE_TIMEOUT_S) || 180) * 1000;
|
|
56
59
|
const MAX_TREE_PATHS = 4_000;
|
|
57
60
|
const MAX_TREE_BYTES = 120_000;
|
|
58
61
|
const MAX_SHORTLIST = 12;
|
|
@@ -392,7 +395,11 @@ export async function validateShim(file, { projectPath, log }) {
|
|
|
392
395
|
cwd: projectPath,
|
|
393
396
|
});
|
|
394
397
|
if (ran.error?.code === "ETIMEDOUT") {
|
|
395
|
-
return {
|
|
398
|
+
return {
|
|
399
|
+
ok: false,
|
|
400
|
+
why: `it did not answer within ${PROBE_TIMEOUT_MS / 1000} seconds (importing the agent ` +
|
|
401
|
+
`counts; set PREMAN_PROBE_TIMEOUT_S to allow longer)`,
|
|
402
|
+
};
|
|
396
403
|
}
|
|
397
404
|
const line = String(ran.stdout || "")
|
|
398
405
|
.split("\n")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Stable identity of inputs that decide an eval suite's generated cases.
|
|
2
|
+
|
|
3
|
+
The API and the packaged device harness use byte-identical copies. This module
|
|
4
|
+
has no ASSERT or application imports so both environments compute the same hash.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Mapping
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def generation_spec_sha256(
|
|
15
|
+
spec: Mapping[str, Any], *, config_dir: Path | None = None
|
|
16
|
+
) -> str | None:
|
|
17
|
+
"""Hash generation inputs, excluding the run and measured target.
|
|
18
|
+
|
|
19
|
+
A curated taxonomy is shipped to the device at a different path, so its
|
|
20
|
+
contents enter the identity and its absolute path does not. Missing files
|
|
21
|
+
yield no identity: a caller should skip bank reuse in that case.
|
|
22
|
+
"""
|
|
23
|
+
if not isinstance(spec, Mapping):
|
|
24
|
+
return None
|
|
25
|
+
pipeline = spec.get("pipeline") or {}
|
|
26
|
+
if not isinstance(pipeline, dict):
|
|
27
|
+
return None
|
|
28
|
+
test_set = pipeline.get("test_set") or {}
|
|
29
|
+
if not isinstance(test_set, dict):
|
|
30
|
+
return None
|
|
31
|
+
test_set = dict(test_set)
|
|
32
|
+
taxonomy_path = test_set.pop("taxonomy_path", None)
|
|
33
|
+
taxonomy_hash = None
|
|
34
|
+
if taxonomy_path:
|
|
35
|
+
try:
|
|
36
|
+
path = Path(taxonomy_path)
|
|
37
|
+
if not path.is_absolute():
|
|
38
|
+
if config_dir is None:
|
|
39
|
+
return None
|
|
40
|
+
path = Path(config_dir) / path
|
|
41
|
+
taxonomy_hash = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
42
|
+
except (OSError, TypeError, ValueError):
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
inference = pipeline.get("inference") or {}
|
|
46
|
+
target = inference.get("target") if isinstance(inference, dict) else {}
|
|
47
|
+
if not isinstance(target, dict):
|
|
48
|
+
target = {}
|
|
49
|
+
target_inputs = {"system_prompt": target.get("system_prompt")}
|
|
50
|
+
if test_set.get("tool_source") == "per_test_case":
|
|
51
|
+
target_inputs.update({
|
|
52
|
+
"model": target.get("model"),
|
|
53
|
+
"tools": target.get("tools"),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
inputs = {
|
|
57
|
+
"format": "preman.eval-generation-input/v1",
|
|
58
|
+
"behavior": spec.get("behavior"),
|
|
59
|
+
"context": spec.get("context"),
|
|
60
|
+
"default_model": spec.get("default_model"),
|
|
61
|
+
"dimensions": spec.get("dimensions"),
|
|
62
|
+
"models": spec.get("models"),
|
|
63
|
+
"systematize": pipeline.get("systematize"),
|
|
64
|
+
"test_set": test_set,
|
|
65
|
+
"target_generation_inputs": target_inputs,
|
|
66
|
+
"taxonomy_sha256": taxonomy_hash,
|
|
67
|
+
}
|
|
68
|
+
try:
|
|
69
|
+
encoded = json.dumps(inputs, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
70
|
+
except (TypeError, ValueError):
|
|
71
|
+
return None
|
|
72
|
+
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|