premanmcp 1.1.4 → 1.1.6

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
@@ -45,7 +45,7 @@ import path from "node:path";
45
45
  import { fileURLToPath } from "node:url";
46
46
 
47
47
  import { adapterStats, discoverTarget, startAdapter, stepFeed } from "./eval_target.js";
48
- import { resolveEvalSurface } from "./link.js";
48
+ import { formatSurfaceLink, resolveEvalSurface } from "./link.js";
49
49
  import {
50
50
  backendUrl,
51
51
  callBackendJson,
@@ -85,6 +85,42 @@ const MAX_STDERR_BYTES = 256 * 1024;
85
85
 
86
86
  export class EvalError extends Error {}
87
87
 
88
+ /**
89
+ * What a run has open that a signal would otherwise orphan.
90
+ *
91
+ * A run holds two things the process does not get back by dying: the Python
92
+ * harness, a child that goes on measuring somebody's agent with nobody reading
93
+ * the result, and the adapter's listening socket -- which the code that opens it
94
+ * calls "an unauthenticated door to somebody's agent open for as long as the
95
+ * shell stays alive". Both were released only by a `finally`, so a SIGTERM or a
96
+ * closed tab left them standing.
97
+ *
98
+ * A set rather than a single handle because a session can be told to stop
99
+ * between one run and the next, and a teardown that has already run is not an
100
+ * error -- it is simply no longer registered.
101
+ */
102
+ const OPEN_RUNS = new Set();
103
+
104
+ export function registerEvalTeardown(fn) {
105
+ OPEN_RUNS.add(fn);
106
+ return () => OPEN_RUNS.delete(fn);
107
+ }
108
+
109
+ /** Close everything a run has open. Safe to call when nothing is running. */
110
+ export async function cancelEvalRuns() {
111
+ const pending = [...OPEN_RUNS];
112
+ OPEN_RUNS.clear();
113
+ for (const fn of pending) {
114
+ try {
115
+ await fn();
116
+ } catch {
117
+ // Best effort by construction: this runs on the way out, and a socket
118
+ // that is already closed is the outcome being asked for.
119
+ }
120
+ }
121
+ return pending.length;
122
+ }
123
+
88
124
  // ── Where a suite lives between runs ────────────────────────────────────
89
125
 
90
126
  /** Everything this machine keeps about evals it has run. */
@@ -575,6 +611,58 @@ export function readProgress(cwd, job) {
575
611
  };
576
612
  }
577
613
 
614
+ /**
615
+ * Whether the tool calls the agent made are in the transcript the judge read.
616
+ *
617
+ * A behaviour like `unchecked_account_claim` is defined entirely in terms of
618
+ * tool calls: its taxonomy tells the judge to read the events and not to infer
619
+ * a lookup from the wording of a reply. So a transcript that lost them is not a
620
+ * degraded measurement, it is the opposite measurement -- an agent that checked
621
+ * before answering is indistinguishable from one that made the figure up, and
622
+ * the run reports the second. It happened for every run of that suite until the
623
+ * adapter's event field names were corrected, and it exited 0 each time.
624
+ *
625
+ * Compared against the adapter's own count rather than checked for zero on its
626
+ * own, because zero tool calls is a legitimate result: an agent asked a general
627
+ * question should not call anything. The failure is the disagreement.
628
+ *
629
+ * Returns "" when there is nothing wrong, so the caller can tell "fine" from
630
+ * "unreadable".
631
+ */
632
+ export function missingToolEvidence(dir, stats) {
633
+ const forwarded = Number(stats?.toolCalls);
634
+ if (!Number.isFinite(forwarded) || forwarded <= 0) return "";
635
+
636
+ let recorded = 0;
637
+ try {
638
+ const text = readFileSync(path.join(dir, "inference_set.jsonl"), "utf8");
639
+ for (const line of text.split("\n")) {
640
+ if (!line.trim()) continue;
641
+ let row;
642
+ try {
643
+ row = JSON.parse(line);
644
+ } catch {
645
+ continue; // A row we cannot read is not evidence that evidence is missing.
646
+ }
647
+ for (const event of row.events || []) {
648
+ if (event?.edit?.type === "tool_call") recorded += 1;
649
+ }
650
+ }
651
+ } catch {
652
+ // No transcript to read is a different failure, and the harness's own exit
653
+ // code reports it. Claiming this one on top would be guessing.
654
+ return "";
655
+ }
656
+ if (recorded > 0) return "";
657
+
658
+ return (
659
+ `the agent made ${forwarded} tool call(s) and none of them reached the transcript, so ` +
660
+ `the judge scored it as an agent that called nothing. Every case that answered from a ` +
661
+ `lookup would be marked unsupported. This is the adapter's event channel, not the ` +
662
+ `agent: check that the events it sends still match the fields assert-ai reads.`
663
+ );
664
+ }
665
+
578
666
  // ── Handing the run over to a surface ───────────────────────────────────
579
667
 
580
668
  /** How long to hold a leased run waiting for somebody to be watching it. */
@@ -673,7 +761,7 @@ export async function awaitAudience(job, { call, log, lease, headless = true, su
673
761
  // minutes of waiting and the same five lines printed seven times, which reads
674
762
  // as the thing running in circles.
675
763
  if (audienceDeclined) {
676
- if (surface?.webUrl) log(`Watch it at ${surface.webUrl}`);
764
+ if (surface?.webUrl) log(surface.webUrl);
677
765
  return { watching: false, waited: 0, skipped: "declined-earlier" };
678
766
  }
679
767
 
@@ -683,15 +771,14 @@ export async function awaitAudience(job, { call, log, lease, headless = true, su
683
771
  // even the desktop branch cannot confirm the window came forward — an older
684
772
  // build ignores the route and lands wherever it was. So the URL is always
685
773
  // printed, and "opened" is never claimed.
686
- if (surface.webUrl) log(`Watch it at ${surface.webUrl}`);
774
+ for (const line of formatSurfaceLink(surface, { stream: process.stdout }).split("\n")) {
775
+ if (line) log(line);
776
+ }
687
777
  try {
778
+ // The caveat a raise carries is in the label above, so nothing is added
779
+ // here: this build comes forward on whatever it was showing, and a reader
780
+ // who expected the run would otherwise read the wrong view as a failure.
688
781
  surface.open?.();
689
- if (surface.surface === "desktop-raise") {
690
- // Said plainly because the window will come forward showing whatever it
691
- // was showing, and somebody who expected it to land on the run would
692
- // otherwise read "not this" as "it did not work".
693
- log("Brought the PreMan app forward. This build cannot jump to a view, so open Agent eval yourself.");
694
- }
695
782
  } catch (error) {
696
783
  log(`could not open a window here (${error.message}); the link above still works`);
697
784
  }
@@ -769,7 +856,10 @@ export async function executeEvalRun(
769
856
  { lease_token: lease, ok, summary, error: error ? error.slice(0, ERROR_CAP) : undefined, exit_code: exitCode },
770
857
  log
771
858
  );
772
- return { ok, reason: result.ok ? "reported" : "report_failed" };
859
+ // `error` travels back with the outcome as well as to the backend. It used
860
+ // to go only to the backend, so the terminal that started the run watched
861
+ // it end at the word "failed" with the reason readable in a browser.
862
+ return { ok, reason: result.ok ? "reported" : "report_failed", error };
773
863
  };
774
864
 
775
865
  let home = "";
@@ -785,9 +875,8 @@ export async function executeEvalRun(
785
875
  // Every one of these is a fact about this machine or this frame, and no
786
876
  // amount of retrying changes it. Failing with the reason is more use than
787
877
  // letting the lease lapse and having the server call it a lost device.
788
- log(`eval ${job.id} cannot run here: ${error.message}`);
789
878
  tidy(home);
790
- return finish(false, { error: error.message });
879
+ return finish(false, { error: `cannot run here: ${error.message}` });
791
880
  }
792
881
 
793
882
  if (!existsSync(HARNESS_PATH)) {
@@ -822,6 +911,8 @@ export async function executeEvalRun(
822
911
  stdio: ["ignore", "ignore", "pipe"],
823
912
  });
824
913
 
914
+ const forgetChild = registerEvalTeardown(() => child.kill("SIGTERM"));
915
+
825
916
  let stderr = "";
826
917
  child.stderr.on("data", (chunk) => {
827
918
  if (stderr.length < MAX_STDERR_BYTES) stderr += chunk.toString("utf8");
@@ -894,6 +985,7 @@ export async function executeEvalRun(
894
985
  });
895
986
  clearInterval(heartbeat);
896
987
  clearTimeout(killer);
988
+ forgetChild();
897
989
 
898
990
  // Deliberately before the bundle is removed: the summary is read off the
899
991
  // disk the run just wrote to.
@@ -931,7 +1023,9 @@ export async function executeEvalRun(
931
1023
  // answered some of the time is a real measurement of a flaky agent and
932
1024
  // deciding otherwise here would throw away the finding.
933
1025
  const stats = adapterStats();
934
- if (stats && stats.turns === 0 && stats.failures > 0) {
1026
+ // `reached` rather than `turns`: the adapter counts a turn when it starts
1027
+ // one, so a run where every turn failed has as many turns as cases.
1028
+ if (stats && (stats.reached ?? stats.turns) === 0 && stats.failures > 0) {
935
1029
  const outcome = await finish(false, {
936
1030
  summary,
937
1031
  error:
@@ -943,6 +1037,15 @@ export async function executeEvalRun(
943
1037
  return { ...outcome, summary, artifacts };
944
1038
  }
945
1039
 
1040
+ // The same shape of failure one layer in: the agent was reached, it acted,
1041
+ // and the record of it acting did not survive the trip to the judge.
1042
+ const blind = missingToolEvidence(artifacts, stats);
1043
+ if (blind) {
1044
+ const outcome = await finish(false, { summary, error: blind, exitCode });
1045
+ tidy(home);
1046
+ return { ...outcome, summary, artifacts };
1047
+ }
1048
+
946
1049
  const outcome = await finish(true, { summary, exitCode });
947
1050
  tidy(home);
948
1051
  return { ...outcome, summary, artifacts };
@@ -958,6 +1061,124 @@ export async function executeEvalRun(
958
1061
  return { ...outcome, summary, artifacts };
959
1062
  }
960
1063
 
1064
+ /**
1065
+ * How long a run took, in the units somebody reads without converting.
1066
+ */
1067
+ function elapsed(seconds) {
1068
+ const total = Math.round(Number(seconds) || 0);
1069
+ if (!total) return "";
1070
+ if (total < 60) return `${total}s`;
1071
+ const minutes = Math.floor(total / 60);
1072
+ return `${minutes}m${String(total % 60).padStart(2, "0")}s`;
1073
+ }
1074
+
1075
+ /**
1076
+ * One run's numbers as a line, the way the page states them.
1077
+ *
1078
+ * `scored` is the denominator and `not_run` is outside it, which is the part a
1079
+ * terminal most easily gets wrong: a run where six of eight cases never reached
1080
+ * the agent has two results, not eight, and "6 passed" would be a fabrication.
1081
+ * A split decision is called out rather than folded into the flagged count,
1082
+ * because a judge panel that disagreed with itself is a weaker finding than one
1083
+ * that did not, and the two should not read identically.
1084
+ */
1085
+ export function describeRunSummary(row) {
1086
+ const took = elapsed(row.elapsed_s);
1087
+ const suffix = took ? ` · ${took}` : "";
1088
+
1089
+ // Read whatever the run's status, because a case that never reached the agent
1090
+ // is not a failed run to the backend: `_build_run_summary` only ever says
1091
+ // `done` or `running`, and hangs `failure` off a finished run whenever
1092
+ // `not_run` is non-zero. Reading it under a "failed" status alone dropped the
1093
+ // detail in exactly the case it was wanted -- six cases that never reached
1094
+ // the agent, and no word here about why.
1095
+ const failure = row.failure || {};
1096
+ const named = [failure.reason, failure.detail].filter(Boolean).join(": ");
1097
+ const affected = failure.cases_affected ? ` (${failure.cases_affected} cases)` : "";
1098
+ const why = named ? `${named}${affected}` : "";
1099
+
1100
+ if (row.status === "running") return { mark: "·", text: `still finishing${suffix}`, why };
1101
+ if (row.status === "failed") return { mark: "✗", text: `failed${suffix}`, why };
1102
+
1103
+ const parts = [];
1104
+ if (row.flagged) {
1105
+ const split = row.split ? `, ${row.split} of them split` : "";
1106
+ parts.push(`${row.flagged} of ${row.scored} flagged${split}`);
1107
+ } else if (row.scored) {
1108
+ parts.push(`${row.scored} scored, nothing flagged`);
1109
+ } else {
1110
+ parts.push("nothing was scored");
1111
+ }
1112
+ if (row.not_run) parts.push(`${row.not_run} never ran`);
1113
+
1114
+ // A tick is reserved for a run that measured everything it set out to and
1115
+ // found nothing. Cases that never reached the agent are not passes, so a run
1116
+ // carrying any of them is marked as the partial result it is rather than
1117
+ // being waved through on the strength of the cases that did run.
1118
+ const mark = row.flagged || !row.scored ? "✗" : row.not_run ? "·" : "✓";
1119
+ return { mark, text: `${parts.join(" · ")}${suffix}`, why };
1120
+ }
1121
+
1122
+ /**
1123
+ * Read back what the runs measured, and say it here.
1124
+ *
1125
+ * The terminal used to end at "finished: failed" and a URL, which is the one
1126
+ * moment somebody is definitely looking at the terminal and not at a dashboard.
1127
+ * The numbers come from `/eval-traces` rather than from the artifacts this
1128
+ * machine just wrote, deliberately: `case_state` on the server is the single
1129
+ * definition of what counts as a pass, a split or a case that never ran, and a
1130
+ * second copy of that rule in JavaScript would agree with the app only by
1131
+ * coincidence -- which is the exact failure that rule was consolidated to end.
1132
+ *
1133
+ * Best-effort throughout. A missing key, an unreachable backend, a run whose
1134
+ * artifacts have not landed yet: none of them is a reason to fail a run that
1135
+ * has already finished, so every one of them prints less rather than erroring.
1136
+ */
1137
+ export async function summarizeRuns(args, key, runs, { say = () => {} } = {}) {
1138
+ if (!key || !runs.length) return [];
1139
+
1140
+ const wanted = new Map(runs.map((run) => [`${run.suite}/${run.run}`, run]));
1141
+ const found = new Map();
1142
+ for (const suite of new Set(runs.map((run) => run.suite))) {
1143
+ let result;
1144
+ try {
1145
+ result = await callBackendJson(
1146
+ args,
1147
+ "GET",
1148
+ `/eval-traces/suites/${encodeURIComponent(suite)}/runs`,
1149
+ { token: key }
1150
+ );
1151
+ } catch {
1152
+ continue;
1153
+ }
1154
+ if (!result.ok) continue;
1155
+ for (const row of result.list || []) {
1156
+ const id = `${row.suite}/${row.run}`;
1157
+ if (wanted.has(id)) found.set(id, row);
1158
+ }
1159
+ }
1160
+
1161
+ if (!found.size) return [];
1162
+
1163
+ say("");
1164
+ say("Results:");
1165
+ const lines = [];
1166
+ for (const [id, run] of wanted) {
1167
+ const row = found.get(id);
1168
+ // A queued behaviour whose artifacts never arrived is named and left at
1169
+ // that. Saying nothing about it would read as a run that did not happen.
1170
+ if (!row) {
1171
+ say(` · ${id} — nothing to read back yet`);
1172
+ continue;
1173
+ }
1174
+ const { mark, text, why } = describeRunSummary(row);
1175
+ say(` ${mark} ${id} — ${text}`);
1176
+ if (why) say(` ${why}`);
1177
+ lines.push({ id, row });
1178
+ }
1179
+ return lines;
1180
+ }
1181
+
961
1182
  // ── Command ─────────────────────────────────────────────────────────────
962
1183
 
963
1184
  export const EVAL_HELP = `
@@ -1109,6 +1330,10 @@ async function runWithToken(
1109
1330
  // audience for these is the dashboard.
1110
1331
  onStep: (step) => stepFeed.push(step),
1111
1332
  });
1333
+ // The `finally` below still closes it on every path this function can see.
1334
+ // This is for the ones it cannot: a signal, a closed tab, a crash in a
1335
+ // callback -- after which the socket would otherwise outlive the run.
1336
+ const forgetAdapter = registerEvalTeardown(() => adapter.close());
1112
1337
  if (adapter.translated) {
1113
1338
  // Set for this process so `childEnv` carries it to the harness. assert-ai
1114
1339
  // refuses loopback endpoints by default and that default is right: it stops
@@ -1181,7 +1406,7 @@ async function runWithToken(
1181
1406
  const surface = resolved
1182
1407
  ? { ...resolved, open: () => openUrl(resolved.href, { app: resolved.app }) }
1183
1408
  : null;
1184
- if (surface?.webUrl) say(`Watch it at ${surface.webUrl}`);
1409
+ if (surface) say(formatSurfaceLink(surface, { stream: process.stdout }));
1185
1410
  say("");
1186
1411
 
1187
1412
  const headless = args.has("--headless") || args.has("--no-wait") || !process.stdout.isTTY;
@@ -1202,13 +1427,20 @@ async function runWithToken(
1202
1427
 
1203
1428
  say("");
1204
1429
  say(`Finished ${done} of ${runs.length}.`);
1205
- if (surface) say(`Results: ${surface.webUrl}`);
1430
+ // The numbers before the link, because the link is where somebody goes when
1431
+ // the numbers are not enough -- and on this path they usually are.
1432
+ await summarizeRuns(args, resolveApiKey(args), runs, { say });
1433
+ if (surface) {
1434
+ say("");
1435
+ say(formatSurfaceLink(surface, { stream: process.stdout }));
1436
+ }
1206
1437
  return { ok: done > 0, jobsRun: done, expected: runs.length };
1207
1438
  } finally {
1208
1439
  // Always, including on the throw paths above. The adapter holds a listening
1209
1440
  // socket and a set of live conversations with the agent; leaving it up after
1210
1441
  // a failed redemption would leave an unauthenticated door to somebody's
1211
1442
  // agent open for as long as the shell stays alive.
1443
+ forgetAdapter();
1212
1444
  await adapter.close();
1213
1445
  }
1214
1446
  }
@@ -85,11 +85,15 @@ def _config_mentions_anthropic() -> bool:
85
85
 
86
86
 
87
87
  WORKSPACE_ID = os.environ.get("ANTHROPIC_WORKSPACE_ID")
88
- if _config_mentions_anthropic() and not WORKSPACE_ID:
89
- sys.exit("ANTHROPIC_WORKSPACE_ID is not set (expected a wrkspc_... value)")
90
88
 
91
89
  import litellm
92
90
 
91
+ # Workaround 1 applies to an identity-linked key, which carries no workspace
92
+ # binding of its own. A workspace-scoped key already has one and the Messages
93
+ # API accepts it bare, so an absent id is not by itself an error — it only means
94
+ # there is no header to attach. Refusing the run here failed keys that work; a
95
+ # key that genuinely needs the header now hears it from Anthropic, in its own
96
+ # words, at the first call.
93
97
  if WORKSPACE_ID and _config_mentions_anthropic():
94
98
  litellm.headers = {"anthropic-workspace-id": WORKSPACE_ID}
95
99
 
@@ -33,6 +33,9 @@
33
33
  */
34
34
 
35
35
  import { createServer } from "node:http";
36
+
37
+ import { askAgent } from "./agent_dialects.js";
38
+ import { REPO_CONFIG, savedAgent } from "./target.js";
36
39
  import { createHash } from "node:crypto";
37
40
 
38
41
  import { backendUrl, resolveApiKey, storedKeyOrigin } from "./shared.js";
@@ -157,6 +160,23 @@ export async function discoverTarget(args, { log = () => {} } = {}) {
157
160
  };
158
161
  }
159
162
 
163
+ // What the repository says its agent is, saved by `preman target detect`. It
164
+ // ranks above discovery for the same reason `--target` does: somebody already
165
+ // answered this question, and probing past their answer would measure a
166
+ // different process than the one they pointed at. Below `--target` because a
167
+ // flag is about this invocation and the file is about the project.
168
+ const saved = savedAgent();
169
+ if (saved) {
170
+ return {
171
+ kind: "external",
172
+ agent: saved,
173
+ url: `${saved.url}${saved.path}`,
174
+ label: `your agent (${saved.url}${saved.path})`,
175
+ how: `${REPO_CONFIG} names it, and it speaks ${saved.dialect}`,
176
+ probed: [],
177
+ };
178
+ }
179
+
160
180
  const probed = [];
161
181
  for (const port of LOCAL_PORTS) {
162
182
  const result = await probeAgent(`http://127.0.0.1:${port}`);
@@ -293,7 +313,7 @@ function readBody(request) {
293
313
  /**
294
314
  * One turn against PreMan's own agent.
295
315
  *
296
- * Tool calls come back in `turn.artifacts` and are forwarded as adapter-shaped
316
+ * Tool calls come back in `turn.tool_calls` and are forwarded as adapter-shaped
297
317
  * `events`, which is the difference between the judge seeing a transcript and
298
318
  * the judge seeing what the agent *did*. `HTTPEndpointSession` promotes those to
299
319
  * first-class interaction messages; without them a tool-using agent is scored on
@@ -443,7 +463,18 @@ async function premanTurn(target, args, { message, history }, state) {
443
463
  .digest("hex");
444
464
  state.conversations.set(nextKey, conversationId);
445
465
 
446
- return { response: reply, events: eventsFrom(turn) };
466
+ // Counted here rather than derived from the artifacts later, because these
467
+ // two numbers answer different questions: this is what the agent did, and
468
+ // what the transcript holds is what the judge got to see. A run where they
469
+ // disagree is the failure this counter exists to make visible.
470
+ //
471
+ // Counting the events rather than `turn.tool_calls` keeps it a count of what
472
+ // was forwarded: a call this adapter dropped is not one the transcript is
473
+ // missing. `tool_result` is one call, per `eventsFrom`.
474
+ const events = eventsFrom(turn);
475
+ state.toolCalls += events.filter((event) => event.role === "tool_result").length;
476
+
477
+ return { response: reply, events };
447
478
  }
448
479
 
449
480
  /**
@@ -457,33 +488,75 @@ async function premanTurn(target, args, { message, history }, state) {
457
488
  * agent checked before answering was reading a transcript in which checking
458
489
  * was invisible.
459
490
  *
460
- * `executed: false` is reported as a distinct event rather than dropped or
461
- * flattened into a call. A risky tool is not run — the broker records a
462
- * proposal and waits for a person — so treating the request as the deed would
463
- * have the judge score a deletion that never occurred.
491
+ * The field names are assert-ai's `AdapterEvent`, and they are the whole
492
+ * difference between an event and nothing. Its normalizer keys on `role` and
493
+ * skips any event whose role is not one of its three, without logging -- so a
494
+ * plausible-looking `{type, name, arguments}` is dropped silently and produces
495
+ * exactly the same transcript as an agent that called nothing. Every run of
496
+ * `unchecked_account_claim` before this was scored against one: 24 cases, zero
497
+ * tool calls recorded, while the agent was answering with live figures that
498
+ * matched the workspace.
499
+ *
500
+ * One `tool_result` per call, and deliberately no `tool_call` event alongside
501
+ * it. `tool_result` is the one that becomes a tool call in the artifacts: a
502
+ * `tool_call` event is an assistant message carrying `tool_calls`, which the
503
+ * inference stage only remembers, while the `ToolCallEdit` that
504
+ * `edit.type == "tool_call"` refers to -- what the judge and the optimizer both
505
+ * count -- is written when the tool *message* arrives. Sending both records the
506
+ * same single call and adds an assistant message with empty content for each
507
+ * one, because the endpoint session attaches a `raw` to every event and the
508
+ * stage keeps any message that has one. Blank assistant turns are the last
509
+ * thing to show a judge deciding whether the agent answered from a lookup.
510
+ *
511
+ * Whether the tool ran travels in the result's text, in the taxonomy's own
512
+ * words, because text is what survives: of everything an event can carry, only
513
+ * `content`, `tool_name`, `tool_args` and `tool_call_id` reach the artifacts. A
514
+ * risky tool is not run — the broker records a proposal and waits for a person —
515
+ * and a judge that read the request as the deed would score a deletion that
516
+ * never occurred. What the tool returned is not carried at all: the backend's
517
+ * record is name, arguments and executed, which is what "did it look this up
518
+ * before answering" needs and no more.
464
519
  */
465
- function eventsFrom(turn) {
520
+ export function eventsFrom(turn) {
466
521
  const calls = Array.isArray(turn.tool_calls) ? turn.tool_calls : [];
467
522
  const events = [];
468
523
  for (const call of calls) {
469
524
  const name = String(call?.name || "");
470
525
  if (!name) continue;
526
+ const args = call.arguments;
471
527
  events.push({
472
- type: "tool_call",
473
- name,
474
- arguments: call.arguments ?? {},
528
+ role: "tool_result",
529
+ content:
530
+ call.executed === false
531
+ ? `executed: false — ${name} is a risky action held for the user's approval. It ` +
532
+ `did not run and returned no data.`
533
+ : `executed: true — ${name} ran and returned its result to the agent. The result ` +
534
+ `itself is not carried into this transcript.`,
535
+ tool_name: name,
536
+ tool_args: args && typeof args === "object" && !Array.isArray(args) ? args : {},
475
537
  });
476
- if (call.executed === false) {
477
- events.push({
478
- type: "tool_result",
479
- name,
480
- result: { executed: false, reason: "awaiting the user's approval" },
481
- });
482
- }
483
538
  }
484
539
  return events;
485
540
  }
486
541
 
542
+ /**
543
+ * One turn against an agent PreMan did not write.
544
+ *
545
+ * The whole translation is `askAgent`: the dialect decides the request and
546
+ * reads the reply, and what comes back here is text plus whatever tool calls
547
+ * the answer carried. Counted the same way the PreMan path counts, because
548
+ * `executeEvalRun` refuses a run where no turn ever reached the agent, and that
549
+ * guard is worth more against a customer's agent than against our own -- it is
550
+ * the one that catches an adapter pointed at the wrong port answering 404 to
551
+ * every case while the run reports a clean sheet.
552
+ */
553
+ async function externalTurn(target, { message, history }, state) {
554
+ const { text, events } = await askAgent(target.agent, String(message || ""), history || []);
555
+ state.toolCalls += events.length;
556
+ for (const event of events) state.step(`${event.tool_name}`);
557
+ return { response: text, events };
558
+ }
559
+
487
560
  /**
488
561
  * Serve assert-ai's contract on loopback until the eval is done.
489
562
  *
@@ -512,9 +585,16 @@ export async function startAdapter(target, args, { log = () => {}, onStep = null
512
585
  const state = {
513
586
  conversations: new Map(),
514
587
  turns: 0,
588
+ // Attempted and arrived are different numbers, and only one of them answers
589
+ // "did this run ever reach the agent". `turns` is incremented before the
590
+ // call, so an adapter pointed at the wrong port answers every case with an
591
+ // apology and still reports a full set of turns -- which is the exact
592
+ // failure `executeEvalRun`'s guard exists to catch, and it could not.
593
+ reached: 0,
515
594
  failures: 0,
516
595
  lastFailure: "",
517
596
  streamFailures: 0,
597
+ toolCalls: 0,
518
598
  log,
519
599
  /**
520
600
  * One thing the agent said it was doing, on its way to the dashboard.
@@ -547,7 +627,11 @@ export async function startAdapter(target, args, { log = () => {}, onStep = null
547
627
  try {
548
628
  const body = await readBody(request);
549
629
  state.turns += 1;
550
- const result = await premanTurn(target, args, body, state);
630
+ const result =
631
+ target.kind === "external"
632
+ ? await externalTurn(target, body, state)
633
+ : await premanTurn(target, args, body, state);
634
+ state.reached += 1;
551
635
  reply(200, result);
552
636
  } catch (error) {
553
637
  state.failures += 1;
package/bin/link.js CHANGED
@@ -182,25 +182,52 @@ function resolveSurface(
182
182
  };
183
183
  }
184
184
 
185
- // A raise is now the last resort rather than the second choice, and only when
186
- // there is no page to send anyone to instead.
185
+ // A raise outranks the browser again, and only where the app is actually on
186
+ // this machine.
187
187
  //
188
- // It was ahead of the web branch on the reasoning that somebody watching an
189
- // eval is already sitting in front of the app. True, but it trades a link
190
- // that lands on the run for a window that lands wherever it was left --
191
- // which, for an app too old to route, is the one thing it can never be. What
192
- // that bought in practice was a stale view and the question "where is my
193
- // run", while the browser would have shown it. Raising still beats nothing,
194
- // so it survives for the case where no dashboard is known at all.
195
- if (!base) {
196
- return preferApp && installed
197
- ? { href: `preman://open?source=${source}`, webUrl, app, surface: "desktop-raise" }
198
- : null;
188
+ // It was demoted on the reasoning that a raise cannot land you on the run
189
+ // while a browser link can, so the browser was the more useful click. That
190
+ // remains true and is the cost being paid here: the decision was reversed
191
+ // deliberately, because somebody who has installed the desktop app has said
192
+ // where they want PreMan to open, and a CLI that keeps sending them to a web
193
+ // tab instead is overruling them on their own machine. The caller prints the
194
+ // run's URL underneath every branch, and says plainly that an app too old to
195
+ // route will surface wherever it was left -- so what a raise costs is a
196
+ // couple of clicks inside the app, not the run.
197
+ if (preferApp && installed) {
198
+ return { href: `preman://open?source=${source}`, webUrl, app, surface: "desktop-raise" };
199
199
  }
200
+ if (!base) return null;
200
201
  if (base !== DEFAULT_FRONTEND) return { href: webUrl, webUrl, surface: "web-local" };
201
202
  return { href: webUrl, webUrl, surface: "web" };
202
203
  }
203
204
 
205
+ /**
206
+ * A two-line block for a surface somebody already resolved: clickable headline,
207
+ * plain URL underneath.
208
+ *
209
+ * Takes the surface rather than resolving one, because the eval flow needs the
210
+ * same object it opens windows with -- resolving twice would let the line a
211
+ * reader clicks and the window the CLI opens disagree about where the run is.
212
+ *
213
+ * The label says which of the three it is, since "the app" and "the dashboard"
214
+ * are different places to go and look. A `desktop-raise` says what it cannot
215
+ * do, in the line itself: that build brings its window forward on whatever it
216
+ * was showing, and a reader who expected the run would otherwise read the
217
+ * wrong view as a failure.
218
+ */
219
+ export function formatSurfaceLink(surface, { label = "", stream = process.stdout } = {}) {
220
+ if (!surface) return "";
221
+ const named =
222
+ label ||
223
+ (surface.surface === "desktop"
224
+ ? "\u25c6 Open in the PreMan app"
225
+ : surface.surface === "desktop-raise"
226
+ ? "\u25c6 Open the PreMan app \u2014 this build lands where you left it, so pick Agent eval"
227
+ : "\u25c6 Open in the PreMan dashboard");
228
+ return renderLinkBlock({ href: surface.href, webUrl: surface.webUrl, label: named, stream });
229
+ }
230
+
204
231
  /** Wrap `label` so supporting terminals make it clickable. */
205
232
  export function terminalHyperlink(url, label) {
206
233
  return `\u001b]8;;${url}\u001b\\${label}\u001b]8;;\u001b\\`;