premanmcp 1.0.5 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +36 -1
- package/bin/eval.js +1200 -0
- package/bin/eval_harness.py +231 -0
- package/bin/eval_target.js +530 -0
- package/bin/link.js +81 -6
- package/bin/runner.js +193 -7
- package/bin/shared.js +37 -2
- package/package.json +2 -2
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding the agent to evaluate, and making it answerable.
|
|
3
|
+
*
|
|
4
|
+
* Two jobs that look like one, and separating them is the whole design.
|
|
5
|
+
*
|
|
6
|
+
* **Finding it.** Nobody should have to tell PreMan where their agent is. The
|
|
7
|
+
* answer is a port on this machine, and the machine already knows: the project
|
|
8
|
+
* is right here, the process is running, and the URL is in a config file or a
|
|
9
|
+
* listening socket. Asking the customer to paste it is asking them to look up
|
|
10
|
+
* something we could have read.
|
|
11
|
+
*
|
|
12
|
+
* **Making it answerable.** assert-ai drives a target by POSTing
|
|
13
|
+
* `{message, history}` and expecting `{response}` back. Almost no real agent
|
|
14
|
+
* speaks that, and the ones that do are the ones somebody already wrote an
|
|
15
|
+
* adapter for. So this module stands up the adapter: a local server that speaks
|
|
16
|
+
* assert-ai's contract on one side and the agent's own API on the other,
|
|
17
|
+
* carrying whatever credentials the agent needs.
|
|
18
|
+
*
|
|
19
|
+
* The adapter is the reason the credentials story works. It runs here, on the
|
|
20
|
+
* developer's machine, holding the developer's key. PreMan's servers render the
|
|
21
|
+
* spec and store the results, and at no point does a key for the agent under
|
|
22
|
+
* test leave this process.
|
|
23
|
+
*
|
|
24
|
+
* ## Why the adapter is not optional even for an agent that "already has an HTTP API"
|
|
25
|
+
*
|
|
26
|
+
* A chat API is not a turn API. PreMan's own is the worked example: a
|
|
27
|
+
* conversation is a server-side object you create once and then append to, so
|
|
28
|
+
* `history` is not a field you send — it is state the server already holds. An
|
|
29
|
+
* adapter that ignored that and replayed the whole history every turn would be
|
|
30
|
+
* measuring a different agent than the one customers talk to. So the mapping
|
|
31
|
+
* from "assert-ai's stateless turn" to "this agent's idea of a conversation"
|
|
32
|
+
* is per-agent work, and this is where it lives.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { createServer } from "node:http";
|
|
36
|
+
import { createHash } from "node:crypto";
|
|
37
|
+
|
|
38
|
+
import { backendUrl, resolveApiKey } from "./shared.js";
|
|
39
|
+
|
|
40
|
+
export class TargetError extends Error {}
|
|
41
|
+
|
|
42
|
+
/** Ports a locally-running PreMan backend is plausibly on, in order of likelihood. */
|
|
43
|
+
const LOCAL_PORTS = [8000, 8001, 8010, 8011, 8002, 8080];
|
|
44
|
+
|
|
45
|
+
const PROBE_TIMEOUT_MS = 1_500;
|
|
46
|
+
|
|
47
|
+
/** The route that makes a service *the agent* rather than merely a PreMan service. */
|
|
48
|
+
const AGENT_ROUTE = "/workbench/conversations/{conversation_id}/messages";
|
|
49
|
+
|
|
50
|
+
async function get(url, { timeout = PROBE_TIMEOUT_MS } = {}) {
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
53
|
+
try {
|
|
54
|
+
return await fetch(url, { signal: controller.signal });
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Is the agent we know how to drive answering here?
|
|
62
|
+
*
|
|
63
|
+
* Two questions, and asking only the first is what makes discovery pick the
|
|
64
|
+
* wrong thing. `/health` says a PreMan-shaped service is on this port; it does
|
|
65
|
+
* not say the service has a chat agent. A developer's machine can easily have an
|
|
66
|
+
* old build, a cut-down demo API, or a differently-scoped deployment answering
|
|
67
|
+
* `/health` with the right name — and pointing an eval at one produces a run
|
|
68
|
+
* where every case fails with a 404 that looks like the agent misbehaving.
|
|
69
|
+
*
|
|
70
|
+
* So the second question is asked of the service's own OpenAPI document: does it
|
|
71
|
+
* publish the route the adapter is about to POST to. That is a capability check
|
|
72
|
+
* rather than a version check, which means it keeps working when the route moves
|
|
73
|
+
* behind a different service and stops working the moment it is renamed — both
|
|
74
|
+
* of which are the answers you want.
|
|
75
|
+
*/
|
|
76
|
+
async function probeAgent(url) {
|
|
77
|
+
try {
|
|
78
|
+
const health = await get(`${url}/health`);
|
|
79
|
+
if (!health.ok) return { url, ok: false, why: `health answered ${health.status}` };
|
|
80
|
+
const body = await health.json().catch(() => ({}));
|
|
81
|
+
const service = String(body?.service || "");
|
|
82
|
+
if (!/preman/i.test(service)) {
|
|
83
|
+
return { url, ok: false, why: `something else is on this port (${service || "no service name"})` };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const spec = await get(`${url}/openapi.json`, { timeout: 6_000 });
|
|
87
|
+
if (!spec.ok) return { url, ok: false, why: `a PreMan service, but it publishes no API document` };
|
|
88
|
+
const document = await spec.json().catch(() => ({}));
|
|
89
|
+
if (!document?.paths?.[AGENT_ROUTE]) {
|
|
90
|
+
return { url, ok: false, why: `a PreMan service, but it has no chat agent on it` };
|
|
91
|
+
}
|
|
92
|
+
return { url, ok: true, service };
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return { url, ok: false, why: error.name === "AbortError" ? "no answer" : error.message };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Work out which agent this eval is about.
|
|
100
|
+
*
|
|
101
|
+
* The order is deliberate and it is "most explicit first". A `--target` the
|
|
102
|
+
* customer typed beats anything discovered, because they have already answered
|
|
103
|
+
* the question and a tool that overrode them would be unusable in the one
|
|
104
|
+
* environment they configured it for. Then a backend on this machine, because
|
|
105
|
+
* somebody running an eval from a project directory with a dev server up almost
|
|
106
|
+
* certainly means that one. Then the configured backend, which is the honest
|
|
107
|
+
* fallback and is also the answer for anyone who is not a PreMan developer.
|
|
108
|
+
*/
|
|
109
|
+
export async function discoverTarget(args, { log = () => {} } = {}) {
|
|
110
|
+
const explicit = String(args.value("--target", "") || "").trim();
|
|
111
|
+
if (explicit) {
|
|
112
|
+
return {
|
|
113
|
+
kind: "raw",
|
|
114
|
+
url: explicit,
|
|
115
|
+
label: explicit,
|
|
116
|
+
how: "you named it with --target, so nothing was probed",
|
|
117
|
+
probed: [],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const probed = [];
|
|
122
|
+
for (const port of LOCAL_PORTS) {
|
|
123
|
+
const result = await probeAgent(`http://127.0.0.1:${port}`);
|
|
124
|
+
probed.push(result);
|
|
125
|
+
if (result.ok) {
|
|
126
|
+
log(`Found a PreMan backend on port ${port}.`);
|
|
127
|
+
return {
|
|
128
|
+
kind: "preman-chat",
|
|
129
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
130
|
+
// The agent's own credential, held here and never sent to PreMan's
|
|
131
|
+
// control plane. The adapter is the only thing that ever presents it,
|
|
132
|
+
// which is the whole reason the adapter runs on this machine.
|
|
133
|
+
token: resolveApiKey(args),
|
|
134
|
+
label: `PreMan agent (local, port ${port})`,
|
|
135
|
+
how: `probed localhost and got a PreMan /health on port ${port}`,
|
|
136
|
+
probed,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const configured = backendUrl(args);
|
|
142
|
+
const remote = await probeAgent(configured);
|
|
143
|
+
probed.push(remote);
|
|
144
|
+
if (remote.ok) {
|
|
145
|
+
log(`No local backend; using the one this CLI is configured against.`);
|
|
146
|
+
return {
|
|
147
|
+
kind: "preman-chat",
|
|
148
|
+
baseUrl: configured,
|
|
149
|
+
token: resolveApiKey(args),
|
|
150
|
+
label: `PreMan agent (${configured})`,
|
|
151
|
+
how: `nothing local answered, so this is the backend the CLI is configured against`,
|
|
152
|
+
probed,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
throw new TargetError(
|
|
157
|
+
`Could not find an agent to evaluate. Tried ${probed.length} address(es): ` +
|
|
158
|
+
probed.map((p) => `${p.url} (${p.why})`).join(", ") +
|
|
159
|
+
`. Point at one directly with --target <url>, where that URL accepts ` +
|
|
160
|
+
`POST {"message","history"} and answers {"response"}.`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── What the agent said it was doing ────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* How many recent steps travel with a progress tick.
|
|
168
|
+
*
|
|
169
|
+
* The wire allows twenty and four of those are the pipeline stages, so this is
|
|
170
|
+
* what is left. It is a window on purpose: somebody watching wants the last few
|
|
171
|
+
* seconds, and a longer tail would push the stage summary off a small panel to
|
|
172
|
+
* show steps that have already been superseded.
|
|
173
|
+
*/
|
|
174
|
+
const STEP_WINDOW = 16;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The steps the adapter has seen, waiting to be reported.
|
|
178
|
+
*
|
|
179
|
+
* A module-level singleton, which is worth defending. Two things need it and
|
|
180
|
+
* they sit on opposite sides of the CLI: the adapter fills it from inside a
|
|
181
|
+
* turn, and the heartbeat drains it from inside the job loop. Threading a
|
|
182
|
+
* buffer between them would mean a parameter on `runnerLoop` and on everything
|
|
183
|
+
* it calls, all to carry something that is per-process anyway — this CLI runs
|
|
184
|
+
* one adapter and one eval at a time, by construction.
|
|
185
|
+
*
|
|
186
|
+
* Bounded, and dropped rather than queued when it overflows. This is a view of
|
|
187
|
+
* what is happening now; a backlog of steps from a minute ago is not a thing
|
|
188
|
+
* anybody is waiting to see, and keeping it would grow with the run.
|
|
189
|
+
*/
|
|
190
|
+
function createStepFeed() {
|
|
191
|
+
let entries = [];
|
|
192
|
+
return {
|
|
193
|
+
push(entry) {
|
|
194
|
+
entries.push(entry);
|
|
195
|
+
if (entries.length > STEP_WINDOW) entries = entries.slice(-STEP_WINDOW);
|
|
196
|
+
},
|
|
197
|
+
recent() {
|
|
198
|
+
return entries.slice();
|
|
199
|
+
},
|
|
200
|
+
/** Between runs, so one eval's feed cannot appear under the next one's. */
|
|
201
|
+
reset() {
|
|
202
|
+
entries = [];
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const stepFeed = createStepFeed();
|
|
208
|
+
|
|
209
|
+
// ── The adapter ─────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Which PreMan conversation continues this exchange.
|
|
213
|
+
*
|
|
214
|
+
* assert-ai sends the whole history every turn and holds no server-side handle;
|
|
215
|
+
* PreMan holds the conversation and expects only the new message. So the history
|
|
216
|
+
* has to be turned back into an identity, and the identity is a hash of it.
|
|
217
|
+
*
|
|
218
|
+
* Hashing the *prior* turns rather than all of them is what makes the lookup
|
|
219
|
+
* work: on turn three assert-ai sends [u1, a1, u2, a2, u3], and what we stored
|
|
220
|
+
* after turn two was keyed on [u1, a1, u2, a2]. Dropping the trailing user
|
|
221
|
+
* message is what turns "here is everything so far" into "here is the
|
|
222
|
+
* conversation you already have".
|
|
223
|
+
*/
|
|
224
|
+
function conversationKey(history) {
|
|
225
|
+
const prior = history.filter((m) => m && m.role && typeof m.content === "string");
|
|
226
|
+
while (prior.length && prior[prior.length - 1].role === "user") prior.pop();
|
|
227
|
+
if (!prior.length) return "";
|
|
228
|
+
return createHash("sha256")
|
|
229
|
+
.update(JSON.stringify(prior.map((m) => [m.role, m.content])))
|
|
230
|
+
.digest("hex");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function readBody(request) {
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
let raw = "";
|
|
236
|
+
request.on("data", (chunk) => {
|
|
237
|
+
raw += chunk;
|
|
238
|
+
// A turn is a sentence. Anything past this is a client that has lost
|
|
239
|
+
// track of what it is sending, and buffering it would be this process
|
|
240
|
+
// absorbing somebody else's bug as memory pressure.
|
|
241
|
+
if (raw.length > 2 * 1024 * 1024) reject(new Error("request body too large"));
|
|
242
|
+
});
|
|
243
|
+
request.on("end", () => {
|
|
244
|
+
try {
|
|
245
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
246
|
+
} catch (error) {
|
|
247
|
+
reject(error);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
request.on("error", reject);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* One turn against PreMan's own agent.
|
|
256
|
+
*
|
|
257
|
+
* Tool calls come back in `turn.artifacts` and are forwarded as adapter-shaped
|
|
258
|
+
* `events`, which is the difference between the judge seeing a transcript and
|
|
259
|
+
* the judge seeing what the agent *did*. `HTTPEndpointSession` promotes those to
|
|
260
|
+
* first-class interaction messages; without them a tool-using agent is scored on
|
|
261
|
+
* its prose alone, which is exactly how a confidently-worded wrong action passes.
|
|
262
|
+
*/
|
|
263
|
+
async function post(target, route, body) {
|
|
264
|
+
const response = await fetch(new URL(route, `${target.baseUrl}/`), {
|
|
265
|
+
method: "POST",
|
|
266
|
+
headers: {
|
|
267
|
+
"content-type": "application/json",
|
|
268
|
+
accept: "application/json",
|
|
269
|
+
authorization: `Bearer ${target.token}`,
|
|
270
|
+
},
|
|
271
|
+
body: JSON.stringify(body),
|
|
272
|
+
});
|
|
273
|
+
const text = await response.text();
|
|
274
|
+
let parsed = {};
|
|
275
|
+
try {
|
|
276
|
+
parsed = text ? JSON.parse(text) : {};
|
|
277
|
+
} catch {
|
|
278
|
+
parsed = { raw: text };
|
|
279
|
+
}
|
|
280
|
+
if (!response.ok) {
|
|
281
|
+
const detail = typeof parsed.detail === "string" ? parsed.detail : text.slice(0, 200);
|
|
282
|
+
throw new Error(`${route} answered ${response.status}: ${detail}`);
|
|
283
|
+
}
|
|
284
|
+
return parsed;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Read one SSE turn, announcing each step as it arrives.
|
|
289
|
+
*
|
|
290
|
+
* The streaming endpoint is used for the *live* half of the eval, not the
|
|
291
|
+
* scored half. Both arrive here: `status` frames are the agent narrating its own
|
|
292
|
+
* tool activity while the turn is still running, and the `done` frame carries
|
|
293
|
+
* the identical persisted turn the JSON endpoint would have returned. So
|
|
294
|
+
* streaming costs nothing in fidelity and buys the only view of a run that
|
|
295
|
+
* updates faster than a case row.
|
|
296
|
+
*
|
|
297
|
+
* What a `status` label is, and what it is not: it is prose the agent wrote for
|
|
298
|
+
* a human — "Running smoke tests…" — emitted immediately before it invokes a
|
|
299
|
+
* tool. It is a truthful signal that a tool call is happening now, and it is
|
|
300
|
+
* *not* the tool's name or its arguments. Nothing scored is derived from it;
|
|
301
|
+
* the judge reads `turn.artifacts`, same as before. Treating these as evidence
|
|
302
|
+
* would be scoring an agent on its own commentary.
|
|
303
|
+
*/
|
|
304
|
+
async function streamTurn(target, conversationId, message, state) {
|
|
305
|
+
const response = await fetch(
|
|
306
|
+
new URL(
|
|
307
|
+
`workbench/conversations/${encodeURIComponent(conversationId)}/messages/stream`,
|
|
308
|
+
`${target.baseUrl}/`
|
|
309
|
+
),
|
|
310
|
+
{
|
|
311
|
+
method: "POST",
|
|
312
|
+
headers: {
|
|
313
|
+
"content-type": "application/json",
|
|
314
|
+
accept: "text/event-stream",
|
|
315
|
+
authorization: `Bearer ${target.token}`,
|
|
316
|
+
},
|
|
317
|
+
body: JSON.stringify({ content: message }),
|
|
318
|
+
}
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
if (!response.ok || !response.body) {
|
|
322
|
+
throw new Error(`the stream answered ${response.status}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let buffered = "";
|
|
326
|
+
let turn = null;
|
|
327
|
+
let failure = "";
|
|
328
|
+
|
|
329
|
+
for await (const chunk of response.body) {
|
|
330
|
+
buffered += Buffer.from(chunk).toString("utf8");
|
|
331
|
+
// Frames are `data: {json}\n\n`. Splitting on the blank line rather than on
|
|
332
|
+
// every newline is what keeps a pretty-printed payload in one piece.
|
|
333
|
+
const frames = buffered.split("\n\n");
|
|
334
|
+
buffered = frames.pop() ?? "";
|
|
335
|
+
|
|
336
|
+
for (const frame of frames) {
|
|
337
|
+
const line = frame.split("\n").find((l) => l.startsWith("data:"));
|
|
338
|
+
if (!line) continue;
|
|
339
|
+
let event;
|
|
340
|
+
try {
|
|
341
|
+
event = JSON.parse(line.slice(5).trim());
|
|
342
|
+
} catch {
|
|
343
|
+
continue; // A frame we cannot read is not a reason to fail the turn.
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (event.type === "status" && event.label) {
|
|
347
|
+
state.step(String(event.label));
|
|
348
|
+
} else if (event.type === "done") {
|
|
349
|
+
turn = event.turn || {};
|
|
350
|
+
} else if (event.type === "error") {
|
|
351
|
+
failure = String(event.error || event.detail || "the turn failed");
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (turn) return turn;
|
|
357
|
+
throw new Error(failure || "the stream ended without finishing the turn");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function premanTurn(target, args, { message, history }, state) {
|
|
361
|
+
const key = conversationKey(history || []);
|
|
362
|
+
let conversationId = key ? state.conversations.get(key) : "";
|
|
363
|
+
|
|
364
|
+
if (!conversationId) {
|
|
365
|
+
const created = await post(target, "workbench/conversations", {
|
|
366
|
+
title: "PreMan agent eval",
|
|
367
|
+
});
|
|
368
|
+
conversationId = String(created.id || created.conversation_id || "");
|
|
369
|
+
if (!conversationId) throw new Error("the agent created a conversation with no id");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let turn;
|
|
373
|
+
try {
|
|
374
|
+
turn = await streamTurn(target, conversationId, message, state);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
// The live view is a nicety; the measurement is not. An agent whose build
|
|
377
|
+
// predates the streaming route, or a proxy that buffers SSE into
|
|
378
|
+
// uselessness, should produce a run with no step feed rather than no run.
|
|
379
|
+
state.streamFailures += 1;
|
|
380
|
+
if (state.streamFailures === 1) {
|
|
381
|
+
state.log(`no live steps from this agent (${error.message}); measuring it anyway`);
|
|
382
|
+
}
|
|
383
|
+
const sent = await post(
|
|
384
|
+
target,
|
|
385
|
+
`workbench/conversations/${encodeURIComponent(conversationId)}/messages`,
|
|
386
|
+
{ content: message }
|
|
387
|
+
);
|
|
388
|
+
turn = sent.turn || {};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const reply = String(turn.content || "");
|
|
392
|
+
|
|
393
|
+
// Keyed on the exchange that just happened, so the next turn's history hashes
|
|
394
|
+
// to this same conversation.
|
|
395
|
+
const nextKey = createHash("sha256")
|
|
396
|
+
.update(
|
|
397
|
+
JSON.stringify([
|
|
398
|
+
...(history || [])
|
|
399
|
+
.filter((m) => m && m.role && typeof m.content === "string")
|
|
400
|
+
.map((m) => [m.role, m.content]),
|
|
401
|
+
["assistant", reply],
|
|
402
|
+
])
|
|
403
|
+
)
|
|
404
|
+
.digest("hex");
|
|
405
|
+
state.conversations.set(nextKey, conversationId);
|
|
406
|
+
|
|
407
|
+
return { response: reply, events: eventsFrom(turn) };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* PreMan's `turn.artifacts` as the events assert-ai understands.
|
|
412
|
+
*
|
|
413
|
+
* Best-effort and deliberately lossy: only artifacts that clearly describe a
|
|
414
|
+
* tool invocation become events. Forwarding everything would put rendered
|
|
415
|
+
* markdown and UI intents in front of the judge as though they were actions the
|
|
416
|
+
* agent took, and a judge scoring "did it do something it should not have" would
|
|
417
|
+
* be reading the wrong evidence.
|
|
418
|
+
*/
|
|
419
|
+
function eventsFrom(turn) {
|
|
420
|
+
const artifacts = Array.isArray(turn.artifacts) ? turn.artifacts : [];
|
|
421
|
+
const events = [];
|
|
422
|
+
for (const artifact of artifacts) {
|
|
423
|
+
const name = artifact?.tool || artifact?.name || artifact?.tool_name;
|
|
424
|
+
if (!name) continue;
|
|
425
|
+
events.push({ type: "tool_call", name: String(name), arguments: artifact.arguments ?? artifact.input ?? {} });
|
|
426
|
+
if (artifact.result !== undefined || artifact.output !== undefined) {
|
|
427
|
+
events.push({
|
|
428
|
+
type: "tool_result",
|
|
429
|
+
name: String(name),
|
|
430
|
+
result: artifact.result ?? artifact.output,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return events;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Serve assert-ai's contract on loopback until the eval is done.
|
|
439
|
+
*
|
|
440
|
+
* Bound to 127.0.0.1 explicitly rather than to every interface. The only client
|
|
441
|
+
* is a subprocess on this machine, and a target endpoint reachable from the
|
|
442
|
+
* network is a small unauthenticated proxy to the customer's own agent, held
|
|
443
|
+
* open for the length of a run.
|
|
444
|
+
*
|
|
445
|
+
* Port zero, so the OS picks. A fixed port would collide with the agent, with a
|
|
446
|
+
* previous run that has not finished tearing down, or with whatever else the
|
|
447
|
+
* developer has open — and every one of those failures reads as "the eval
|
|
448
|
+
* couldn't reach your agent".
|
|
449
|
+
*/
|
|
450
|
+
export async function startAdapter(target, args, { log = () => {}, onStep = null } = {}) {
|
|
451
|
+
if (target.kind === "raw") {
|
|
452
|
+
// Nothing to translate: the customer named something that already speaks
|
|
453
|
+
// this. Standing a proxy in front of it would add a hop that can fail and
|
|
454
|
+
// buy nothing.
|
|
455
|
+
//
|
|
456
|
+
// No step feed either, and that is honest rather than a gap: a URL the
|
|
457
|
+
// customer named speaks assert-ai's turn contract and nothing else, so
|
|
458
|
+
// there is no channel on which it could narrate itself.
|
|
459
|
+
return { url: target.url, close: async () => {}, translated: false };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const state = {
|
|
463
|
+
conversations: new Map(),
|
|
464
|
+
turns: 0,
|
|
465
|
+
failures: 0,
|
|
466
|
+
streamFailures: 0,
|
|
467
|
+
log,
|
|
468
|
+
/**
|
|
469
|
+
* One thing the agent said it was doing, on its way to the dashboard.
|
|
470
|
+
*
|
|
471
|
+
* Ids are positional rather than derived from the label, because the same
|
|
472
|
+
* label recurs legitimately — an agent that runs smoke tests three times
|
|
473
|
+
* says so three times — and collapsing those would under-report the work.
|
|
474
|
+
*/
|
|
475
|
+
step: (label) => {
|
|
476
|
+
if (!onStep) return;
|
|
477
|
+
state.steps += 1;
|
|
478
|
+
onStep({ id: `step.${state.turns}.${state.steps}`, label, state: "complete" });
|
|
479
|
+
},
|
|
480
|
+
steps: 0,
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const server = createServer((request, response) => {
|
|
484
|
+
const reply = (status, body) => {
|
|
485
|
+
const text = JSON.stringify(body);
|
|
486
|
+
response.writeHead(status, {
|
|
487
|
+
"content-type": "application/json",
|
|
488
|
+
"content-length": Buffer.byteLength(text),
|
|
489
|
+
});
|
|
490
|
+
response.end(text);
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
if (request.method !== "POST") return reply(405, { error: "post a turn here" });
|
|
494
|
+
|
|
495
|
+
void (async () => {
|
|
496
|
+
try {
|
|
497
|
+
const body = await readBody(request);
|
|
498
|
+
state.turns += 1;
|
|
499
|
+
const result = await premanTurn(target, args, body, state);
|
|
500
|
+
reply(200, result);
|
|
501
|
+
} catch (error) {
|
|
502
|
+
state.failures += 1;
|
|
503
|
+
// 200 with an error string in `response`, not a 5xx. A transport failure
|
|
504
|
+
// aborts the case and loses it; an agent that said something unhelpful is
|
|
505
|
+
// a transcript the judge can score. Which of those this is depends on
|
|
506
|
+
// whose fault it was, and from in here it usually is not assert-ai's.
|
|
507
|
+
log(`adapter turn failed: ${error.message}`);
|
|
508
|
+
reply(200, { response: `[the agent could not answer: ${error.message}]` });
|
|
509
|
+
}
|
|
510
|
+
})();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
await new Promise((resolve, reject) => {
|
|
514
|
+
server.once("error", reject);
|
|
515
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const { port } = server.address();
|
|
519
|
+
const url = `http://127.0.0.1:${port}/chat`;
|
|
520
|
+
log(`Adapter listening on ${url} -> ${target.label}`);
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
url,
|
|
524
|
+
translated: true,
|
|
525
|
+
stats: state,
|
|
526
|
+
close: async () => {
|
|
527
|
+
await new Promise((resolve) => server.close(resolve));
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
}
|
package/bin/link.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { desktopAppInstalled, installedDesktopVersion } from "./desktop.js";
|
|
18
|
-
import { DEFAULT_FRONTEND, frontendUrl } from "./shared.js";
|
|
18
|
+
import { DEFAULT_FRONTEND, frontendUrl, pairedFrontendUrl } from "./shared.js";
|
|
19
19
|
|
|
20
20
|
export { installedDesktopVersion };
|
|
21
21
|
|
|
@@ -98,18 +98,93 @@ export function desktopSupportsRouting(destination = "/Applications") {
|
|
|
98
98
|
* handing it a localhost route would open the wrong build.
|
|
99
99
|
*/
|
|
100
100
|
export function resolveRunSurface(args, { batchId = "", destination = "/Applications" } = {}) {
|
|
101
|
-
const base = frontendUrl(args);
|
|
102
101
|
const route = batchId ? `${RUN_ROUTE}?batch=${encodeURIComponent(batchId)}` : RUN_ROUTE;
|
|
103
|
-
|
|
102
|
+
return resolveSurface(args, route, { destination, source: "push" });
|
|
103
|
+
}
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
/** The dashboard view an eval link points at. */
|
|
106
|
+
export const EVAL_ROUTE = "/workbench/agent-eval";
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Where to send someone to watch the eval this CLI is about to run.
|
|
110
|
+
*
|
|
111
|
+
* Same three-branch resolution as a push link, and worth being explicit about
|
|
112
|
+
* what it does and does not promise, because the eval flow leans on it harder:
|
|
113
|
+
* a push link is a nicety after the work is done, whereas an eval is only worth
|
|
114
|
+
* watching *while* it runs.
|
|
115
|
+
*
|
|
116
|
+
* The desktop branch needs macOS, an installed app, and a build new enough to
|
|
117
|
+
* read `route`; older builds bring the window forward and land on whatever view
|
|
118
|
+
* was last open. So `webUrl` is printed in every branch and the caller must
|
|
119
|
+
* treat "opened" as best-effort. See docs/EVAL_REVEAL_CONTRACT.md.
|
|
120
|
+
*/
|
|
121
|
+
export function resolveEvalSurface(args, { runId = "", destination = "/Applications" } = {}) {
|
|
122
|
+
const route = runId ? `${EVAL_ROUTE}?run=${encodeURIComponent(runId)}` : EVAL_ROUTE;
|
|
123
|
+
return resolveSurface(args, route, {
|
|
124
|
+
destination,
|
|
125
|
+
source: "eval",
|
|
126
|
+
requirePairing: true,
|
|
127
|
+
// An eval is watched while it happens, and the app is where somebody
|
|
128
|
+
// watching it is already sitting. A browser tab is the fallback, not the
|
|
129
|
+
// default, for the one link in this CLI that is time-sensitive.
|
|
130
|
+
preferApp: true,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `requirePairing` is the difference between a pointer and an appointment.
|
|
136
|
+
*
|
|
137
|
+
* A push link is read after the fact: it says "your results are in the
|
|
138
|
+
* dashboard", and naming the usual dashboard is a fair answer even when this
|
|
139
|
+
* particular invocation went somewhere unusual. Guessing costs a click.
|
|
140
|
+
*
|
|
141
|
+
* An eval link is an appointment to watch something happen in the next few
|
|
142
|
+
* seconds. Sending someone to a deployment the run does not exist on gives them
|
|
143
|
+
* a page that loads, renders, and shows nothing — which reads as a lost run
|
|
144
|
+
* rather than a wrong address, and the run is over by the time that is worked
|
|
145
|
+
* out. So the eval side refuses to guess and the caller drops the link.
|
|
146
|
+
*/
|
|
147
|
+
/**
|
|
148
|
+
* `preferApp` is for the case where the app is the point.
|
|
149
|
+
*
|
|
150
|
+
* Routing into the app needs a build that reads `route`, and only the hosted
|
|
151
|
+
* dashboard is worth routing to — the app ships its own bundled UI, so handing
|
|
152
|
+
* it a localhost path opens the wrong build. Both conditions fail often, and
|
|
153
|
+
* until now failing either meant a browser.
|
|
154
|
+
*
|
|
155
|
+
* That is the wrong answer for somebody sitting in front of the app watching an
|
|
156
|
+
* eval. Raising is a strictly weaker action than routing — the URL carries no
|
|
157
|
+
* route, the window simply comes forward on whatever it was showing — so it is
|
|
158
|
+
* safe when routing is not: no build can misread it, and no dashboard can be
|
|
159
|
+
* the wrong one, because none is named. It cannot land you on the run, and the
|
|
160
|
+
* caller says so rather than implying otherwise.
|
|
161
|
+
*/
|
|
162
|
+
function resolveSurface(
|
|
163
|
+
args,
|
|
164
|
+
route,
|
|
165
|
+
{ destination, source, requirePairing = false, preferApp = false }
|
|
166
|
+
) {
|
|
167
|
+
const base = requirePairing ? pairedFrontendUrl(args) : frontendUrl(args);
|
|
168
|
+
const webUrl = base ? `${base}${route}` : "";
|
|
169
|
+
const installed = desktopAppInstalled(destination);
|
|
170
|
+
|
|
171
|
+
if (base === DEFAULT_FRONTEND && installed && desktopSupportsRouting(destination)) {
|
|
107
172
|
return {
|
|
108
|
-
href: `preman://open?source
|
|
173
|
+
href: `preman://open?source=${source}&route=${encodeURIComponent(route)}`,
|
|
109
174
|
webUrl,
|
|
110
175
|
surface: "desktop",
|
|
111
176
|
};
|
|
112
177
|
}
|
|
178
|
+
|
|
179
|
+
// Deliberately ahead of the "no dashboard is known" return below: not knowing
|
|
180
|
+
// which browser page holds the run says nothing about whether the app is
|
|
181
|
+
// installed, and raising it is still the most useful thing available.
|
|
182
|
+
if (preferApp && installed) {
|
|
183
|
+
return { href: `preman://open?source=${source}`, webUrl, surface: "desktop-raise" };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!base) return null;
|
|
187
|
+
if (base !== DEFAULT_FRONTEND) return { href: webUrl, webUrl, surface: "web-local" };
|
|
113
188
|
return { href: webUrl, webUrl, surface: "web" };
|
|
114
189
|
}
|
|
115
190
|
|