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/README.md +59 -0
- package/bin/agent.js +1925 -0
- package/bin/agent_dialects.js +385 -0
- package/bin/cli.js +136 -17
- package/bin/eval.js +247 -15
- package/bin/eval_harness.py +6 -2
- package/bin/eval_target.js +102 -18
- package/bin/link.js +40 -13
- package/bin/predict.js +857 -0
- package/bin/runner.js +4 -1
- package/bin/target.js +505 -0
- package/dist/server.js +6 -0
- package/package.json +2 -2
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/bin/eval_harness.py
CHANGED
|
@@ -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
|
|
package/bin/eval_target.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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 =
|
|
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
|
|
186
|
-
//
|
|
185
|
+
// A raise outranks the browser again, and only where the app is actually on
|
|
186
|
+
// this machine.
|
|
187
187
|
//
|
|
188
|
-
// It was
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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\\`;
|