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/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(job.spec ?? {}, null, 2)}\n`, { mode: 0o600 });
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 added = ["run", "--project", projectPath, "--with", HARNESS_SPEC, "python"];
414
- const resolved = probeInterpreter("uv", added, { timeoutMs: COLD_RESOLVE_MS });
415
- if (resolved) {
416
- return {
417
- command: "uv",
418
- argv: added,
419
- version: resolved,
420
- how: "uv run in this project, with PreMan's harness alongside it",
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
- const key = resolveApiKey(args);
1676
- if (!key) {
1677
- throw new EvalError(
1678
- `no PreMan API key on this machine, so there is no account to read the ` +
1679
- `switched-on behaviours from. Run \`${cliInvocation()} login\`.`
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
- const catalog = await callBackendJson(args, "GET", "/eval-behaviors", { token: key });
1684
- if (!catalog.ok) {
1685
- throw new EvalError(
1686
- `could not read what this account measures (${catalog.status_code}): ` +
1687
- `${describeFailure(catalog)}`
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
- const enabled = (catalog.behaviors || []).filter((entry) => entry.enabled);
1692
- const only = args.value("--only", "").trim();
1693
- const chosen = only
1694
- ? enabled.filter((entry) => entry.id === only || entry.name === only)
1695
- : enabled;
1696
-
1697
- if (only && chosen.length === 0) {
1698
- // Naming the ones that are on beats "not found": the usual cause is a
1699
- // behaviour that exists but is switched off, and that is a different fix
1700
- // from a typo.
1701
- const names = enabled.map((entry) => entry.id).join(", ") || "nothing";
1702
- throw new EvalError(`${only} is not switched on. Currently on: ${names}`);
1703
- }
1704
- if (chosen.length === 0) {
1705
- throw new EvalError(
1706
- "nothing is switched on for this account, so there is nothing to measure. " +
1707
- "Turn a behaviour on under Agent eval → Settings, or describe one in your " +
1708
- "own words there."
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
- return runWithToken(opened.token, args, deps);
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
- return { ok: done > 0, jobsRun: done, expected: runs.length };
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
- const sub = commandArgs.find((value) => !value.startsWith("-")) || "run";
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
- const PROBE_TIMEOUT_MS = 60_000;
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 { ok: false, why: "it did not answer within 60 seconds" };
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()