@hue-run/sdk 0.9.0 → 0.11.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/CLI.md +240 -49
- package/ENVIRONMENTS.md +48 -9
- package/EVALUATIONS.md +41 -21
- package/README.md +70 -30
- package/dist/cli/eval-direct.d.ts +12 -1
- package/dist/cli/eval-direct.js +145 -33
- package/dist/cli/eval.d.ts +28 -1
- package/dist/cli/eval.js +504 -81
- package/dist/cli/listen.d.ts +76 -0
- package/dist/cli/listen.js +816 -0
- package/dist/cli/mcp.d.ts +65 -7
- package/dist/cli/mcp.js +271 -73
- package/dist/client.d.ts +7 -4
- package/dist/client.js +36 -10
- package/dist/evals/checkpoint.d.ts +15 -1
- package/dist/evals/checkpoint.js +34 -3
- package/dist/evals/client.d.ts +24 -4
- package/dist/evals/client.js +92 -38
- package/dist/evals/environment-target.d.ts +18 -3
- package/dist/evals/environment-target.js +22 -10
- package/dist/evals/exit-cleanup.d.ts +4 -0
- package/dist/evals/exit-cleanup.js +26 -0
- package/dist/evals/files.d.ts +69 -3
- package/dist/evals/files.js +255 -42
- package/dist/evals/json.js +116 -9
- package/dist/evals/local-worker.d.ts +21 -3
- package/dist/evals/local-worker.js +14 -0
- package/dist/evals/runner.d.ts +45 -0
- package/dist/evals/runner.js +199 -21
- package/dist/evals/scenarios.d.ts +7 -5
- package/dist/evals/scenarios.js +71 -14
- package/dist/evals/scorer-publication.js +58 -3
- package/dist/evals/simulation.d.ts +17 -4
- package/dist/evals/simulation.js +8 -0
- package/dist/evals/types.d.ts +48 -0
- package/dist/evals/verdicts.d.ts +26 -2
- package/dist/evals/verdicts.js +72 -9
- package/dist/evals.d.ts +4 -3
- package/dist/evals.js +2 -2
- package/dist/inline-files.d.ts +4 -0
- package/dist/inline-files.js +48 -17
- package/dist/provider-tools.d.ts +12 -1
- package/dist/provider-tools.js +192 -106
- package/dist/safety.d.ts +7 -1
- package/dist/safety.js +9 -7
- package/dist/setup/cli.js +31 -8
- package/dist/setup/peers.d.ts +13 -0
- package/dist/setup/peers.js +44 -0
- package/dist/snapshot.d.ts +2 -2
- package/dist/snapshot.js +47 -12
- package/dist/tool-definitions.d.ts +18 -0
- package/dist/tool-definitions.js +232 -0
- package/dist/transport.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/cli/eval.js
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { access, mkdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
3
5
|
import { basename, extname, join, resolve } from "node:path";
|
|
4
6
|
import { pathToFileURL } from "node:url";
|
|
5
7
|
import { parseArgs } from "node:util";
|
|
6
8
|
import { randomUUID } from "node:crypto";
|
|
7
9
|
import { createHue } from "../client.js";
|
|
8
10
|
import { createEnvironmentClient } from "../environment/client.js";
|
|
9
|
-
import { agentEnvironment, stripHueControlPlaneCredentials, writeMcpConfig, } from "../environment/world.js";
|
|
11
|
+
import { agentEnvironment, isHueControlPlaneCredential, stripHueControlPlaneCredentials, writeMcpConfig, } from "../environment/world.js";
|
|
10
12
|
import { EvaluationClient, HueApiError } from "../evals/client.js";
|
|
11
13
|
import { TargetResult } from "../evals/types.js";
|
|
12
|
-
import { CheckpointStore } from "../evals/checkpoint.js";
|
|
14
|
+
import { CheckpointIdentityError, CheckpointStore } from "../evals/checkpoint.js";
|
|
15
|
+
import { onForcedExit, runForcedExitCleanups } from "../evals/exit-cleanup.js";
|
|
16
|
+
import { safeFilename } from "../evals/files.js";
|
|
13
17
|
import { digest } from "../evals/json.js";
|
|
14
18
|
import { runLocalAgent } from "../evals/local-worker.js";
|
|
15
|
-
import { runExperiment, TargetCancelledError } from "../evals/runner.js";
|
|
19
|
+
import { describeTelemetryIssues, runExperiment, TargetCancelledError, TELEMETRY_NOT_ACCEPTED, telemetryIssueCounts, } from "../evals/runner.js";
|
|
20
|
+
import { HueExportError } from "../transport.js";
|
|
16
21
|
import { matchByName, parseScenarioSelector, resolveEvalSetPins, resolveScenarioPins, } from "../evals/scenarios.js";
|
|
17
22
|
import { collectDirectOutputs, stageDirectCase } from "./eval-direct.js";
|
|
18
23
|
import { runSimulation, } from "../evals/simulation.js";
|
|
@@ -41,12 +46,17 @@ Agent (exactly one):
|
|
|
41
46
|
HUE_ENVIRONMENT_RUN_ID, HUE_CASE_ID and HUE_CASE_KEY set;
|
|
42
47
|
{"inputs","config"} on stdin. HUE_API_KEY and other Hue
|
|
43
48
|
control-plane credentials are removed from the child.
|
|
49
|
+
HUE_CASE_DIR, HUE_CASE_INPUTS and HUE_CASE_OUTPUT_DIR name the
|
|
50
|
+
case directory as for direct cases: the case's agent-visible
|
|
51
|
+
files and output/, uploaded after the case
|
|
44
52
|
--allow-hue-credentials Keep HUE_API_KEY and other Hue control-plane credentials in
|
|
45
53
|
the --command child (off by default)
|
|
46
54
|
Direct: spawned in a private case directory with HUE_CASE_DIR,
|
|
47
55
|
HUE_CASE_INPUTS, HUE_CASE_OUTPUT_DIR, HUE_CASE_ID, HUE_CASE_KEY
|
|
48
56
|
and HUE_EXECUTION_ID set; files/<role>/ hold the pinned inputs
|
|
49
57
|
and every file written to output/ is uploaded to Hue
|
|
58
|
+
Stdout is the case's answer and is stored: print no secrets or
|
|
59
|
+
debug logs there (credentials hue eval knows are redacted)
|
|
50
60
|
|
|
51
61
|
Modes:
|
|
52
62
|
--worker Register the agent and poll for runs launched from Hue
|
|
@@ -54,6 +64,8 @@ Modes:
|
|
|
54
64
|
--agent-key <key> Agent key (default: slug of the adapter filename)
|
|
55
65
|
--agent-name <name> Agent display name (default: the key)
|
|
56
66
|
--revision <id> Agent revision (default: AGENT_REVISION, git HEAD or "dev")
|
|
67
|
+
--capability <value> Extra capability to register (repeatable), for example
|
|
68
|
+
environment-files:v1 and input:pdf for world cases with files
|
|
57
69
|
|
|
58
70
|
Connection:
|
|
59
71
|
--env-file <path> Load a dotenv file (HUE_API_KEY, HUE_BASE_URL) first
|
|
@@ -64,8 +76,10 @@ Output and limits:
|
|
|
64
76
|
--name <run name> Run name (default: <agent key> @ <revision>, hashes shortened)
|
|
65
77
|
--baseline <experiment id|url> Compare verdicts with a previous experiment
|
|
66
78
|
--json Print one JSON document on stdout; progress goes to stderr
|
|
67
|
-
--content Capture telemetry content
|
|
68
|
-
|
|
79
|
+
--content Capture telemetry content (span inputs, outputs and messages)
|
|
80
|
+
--no-output One-shot: do not store case outputs, error messages and
|
|
81
|
+
explanations (stored by default; --worker always stores); with
|
|
82
|
+
--content the case span still carries the output
|
|
69
83
|
--save-version Freeze an unsaved eval-set version before running
|
|
70
84
|
--checkpoint-dir <path> Private checkpoint directory (default: .hue/eval/<agent-key>)
|
|
71
85
|
--concurrency <n> Cases in flight, 1-64 (default: 1)
|
|
@@ -106,6 +120,7 @@ function parse(argv) {
|
|
|
106
120
|
"max-runs": { type: "string" },
|
|
107
121
|
"agent-key": { type: "string" },
|
|
108
122
|
"agent-name": { type: "string" },
|
|
123
|
+
capability: { type: "string", multiple: true },
|
|
109
124
|
revision: { type: "string" },
|
|
110
125
|
...envFileOptions,
|
|
111
126
|
origin: { type: "string" },
|
|
@@ -113,6 +128,7 @@ function parse(argv) {
|
|
|
113
128
|
baseline: { type: "string" },
|
|
114
129
|
json: { type: "boolean", default: false },
|
|
115
130
|
content: { type: "boolean", default: false },
|
|
131
|
+
"no-output": { type: "boolean", default: false },
|
|
116
132
|
"save-version": { type: "boolean", default: false },
|
|
117
133
|
"checkpoint-dir": { type: "string" },
|
|
118
134
|
concurrency: { type: "string" },
|
|
@@ -220,13 +236,32 @@ async function loadAdapter(file) {
|
|
|
220
236
|
}
|
|
221
237
|
/** Grace between the stop signal and SIGKILL for an agent command that ignores SIGTERM. */
|
|
222
238
|
const COMMAND_KILL_GRACE_MS = 5_000;
|
|
239
|
+
/** How long after SIGKILL a stopped command waits for its group to be gone before settling. A
|
|
240
|
+
* killed process that nobody reaps stays in the group as a zombie, which can no longer run. */
|
|
241
|
+
const COMMAND_REAP_MS = 2_000;
|
|
242
|
+
const runningCommands = new Set();
|
|
243
|
+
/** Stops every agent command still running and resolves once each has settled. */
|
|
244
|
+
async function settleCommands() {
|
|
245
|
+
for (const command of runningCommands)
|
|
246
|
+
command.stop();
|
|
247
|
+
while (runningCommands.size)
|
|
248
|
+
await Promise.all([...runningCommands].map((c) => c.settled));
|
|
249
|
+
}
|
|
250
|
+
function warn(message) {
|
|
251
|
+
process.stderr.write(`Warning: ${message}\n`);
|
|
252
|
+
}
|
|
223
253
|
/**
|
|
224
254
|
* Spawns the agent command once in its own process group and returns its trimmed stdout. A
|
|
225
255
|
* timeout or Ctrl+C stops the agent the shell started, not only the shell: a survivor would still
|
|
226
|
-
* hold a world token and could write after Hue recorded the case as failed.
|
|
227
|
-
*
|
|
256
|
+
* hold a world token and could write after Hue recorded the case as failed. The group is signalled
|
|
257
|
+
* even once the shell has exited, since a compound command's agent can outlive it, and a stopped
|
|
258
|
+
* command settles only when nothing in the group is left. A command that exits normally but
|
|
259
|
+
* leaves processes behind has them stopped the same way before it settles, so none can touch its
|
|
260
|
+
* outputs while they are collected. Once the group is found empty it is never signalled again,
|
|
261
|
+
* since its ID could be reused. Windows has no process group to signal, so the child alone is
|
|
262
|
+
* stopped there.
|
|
228
263
|
*/
|
|
229
|
-
function spawnAgentCommand(command, options) {
|
|
264
|
+
export function spawnAgentCommand(command, options) {
|
|
230
265
|
return new Promise((resolvePromise, reject) => {
|
|
231
266
|
const group = process.platform !== "win32";
|
|
232
267
|
const child = spawn(command, {
|
|
@@ -240,35 +275,108 @@ function spawnAgentCommand(command, options) {
|
|
|
240
275
|
let size = 0;
|
|
241
276
|
let timedOut = false;
|
|
242
277
|
let oversized = false;
|
|
243
|
-
let
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
278
|
+
let stopping = false;
|
|
279
|
+
let stopped = false;
|
|
280
|
+
let settled = false;
|
|
281
|
+
let groupGone = false;
|
|
282
|
+
let poll;
|
|
283
|
+
let watch;
|
|
284
|
+
let closed;
|
|
285
|
+
let warned = false;
|
|
286
|
+
/** Records a vanished group; any other failure to signal it is reported, once. */
|
|
287
|
+
const gone = (error, signal) => {
|
|
288
|
+
const code = error.code;
|
|
289
|
+
if (code === "ESRCH")
|
|
290
|
+
groupGone = true;
|
|
291
|
+
else if (!warned) {
|
|
292
|
+
warned = true;
|
|
293
|
+
warn(`could not signal the agent command's process group (${signal}, ${code})`);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
/** Whether anything the command started is left: its whole group, or the child alone. */
|
|
297
|
+
const running = () => {
|
|
298
|
+
if (!group || child.pid === undefined)
|
|
299
|
+
return child.exitCode === null && child.signalCode === null;
|
|
300
|
+
if (groupGone)
|
|
301
|
+
return false;
|
|
247
302
|
try {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
else
|
|
251
|
-
child.kill(signal);
|
|
303
|
+
process.kill(-child.pid, 0);
|
|
304
|
+
return true;
|
|
252
305
|
}
|
|
253
|
-
catch {
|
|
254
|
-
|
|
306
|
+
catch (error) {
|
|
307
|
+
gone(error, 0);
|
|
308
|
+
return !groupGone;
|
|
255
309
|
}
|
|
256
310
|
};
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
311
|
+
const signalTree = (signal) => {
|
|
312
|
+
if (group && child.pid !== undefined) {
|
|
313
|
+
if (groupGone)
|
|
314
|
+
return;
|
|
315
|
+
try {
|
|
316
|
+
process.kill(-child.pid, signal);
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
gone(error, signal);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
else if (running())
|
|
323
|
+
child.kill(signal);
|
|
324
|
+
};
|
|
325
|
+
/** SIGTERM, then SIGKILL for whatever is still running when the grace ends; the command
|
|
326
|
+
* settles once the group is gone. */
|
|
327
|
+
const stop = () => {
|
|
328
|
+
if (stopping || settled)
|
|
329
|
+
return;
|
|
330
|
+
stopping = true;
|
|
331
|
+
clearTimeout(timer);
|
|
332
|
+
signalTree("SIGTERM");
|
|
333
|
+
const killAt = Date.now() + COMMAND_KILL_GRACE_MS;
|
|
334
|
+
let reapBy;
|
|
335
|
+
poll = setInterval(() => {
|
|
336
|
+
const left = running();
|
|
337
|
+
if (left && reapBy === undefined && Date.now() >= killAt) {
|
|
338
|
+
signalTree("SIGKILL");
|
|
339
|
+
reapBy = Date.now() + COMMAND_REAP_MS;
|
|
340
|
+
}
|
|
341
|
+
if (left && (reapBy === undefined || Date.now() < reapBy))
|
|
342
|
+
return;
|
|
343
|
+
clearInterval(poll);
|
|
344
|
+
if (left)
|
|
345
|
+
warn("the agent command's process group still has members after SIGKILL");
|
|
346
|
+
stopped = true;
|
|
347
|
+
settle();
|
|
348
|
+
}, 50);
|
|
349
|
+
};
|
|
350
|
+
let markSettled = () => { };
|
|
351
|
+
const handle = {
|
|
352
|
+
stop,
|
|
353
|
+
kill: () => signalTree("SIGKILL"),
|
|
354
|
+
settled: new Promise((done) => (markSettled = done)),
|
|
355
|
+
};
|
|
356
|
+
runningCommands.add(handle);
|
|
357
|
+
const release = () => {
|
|
358
|
+
settled = true;
|
|
359
|
+
clearTimeout(timer);
|
|
360
|
+
clearInterval(poll);
|
|
361
|
+
clearInterval(watch);
|
|
362
|
+
options.signal?.removeEventListener("abort", cancel);
|
|
363
|
+
runningCommands.delete(handle);
|
|
364
|
+
markSettled();
|
|
260
365
|
};
|
|
261
366
|
const timer = setTimeout(() => {
|
|
262
367
|
timedOut = true;
|
|
263
|
-
stop(
|
|
368
|
+
stop();
|
|
264
369
|
}, options.timeoutSeconds * 1000);
|
|
265
|
-
const cancel = () => stop(
|
|
370
|
+
const cancel = () => stop();
|
|
266
371
|
options.signal?.addEventListener("abort", cancel, { once: true });
|
|
372
|
+
// An abort that landed before the listener existed still stops the command.
|
|
373
|
+
if (options.signal?.aborted)
|
|
374
|
+
stop();
|
|
267
375
|
child.stdout.on("data", (chunk) => {
|
|
268
376
|
size += chunk.byteLength;
|
|
269
377
|
if (size > 4 * 1024 * 1024) {
|
|
270
378
|
oversized = true;
|
|
271
|
-
stop(
|
|
379
|
+
stop();
|
|
272
380
|
return;
|
|
273
381
|
}
|
|
274
382
|
chunks.push(chunk);
|
|
@@ -279,26 +387,51 @@ function spawnAgentCommand(command, options) {
|
|
|
279
387
|
else
|
|
280
388
|
child.stdin.end();
|
|
281
389
|
child.on("error", (error) => {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
390
|
+
if (settled)
|
|
391
|
+
return;
|
|
392
|
+
release();
|
|
285
393
|
reject(new Error(`Unable to start the agent command: ${error.message}`));
|
|
286
394
|
});
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
395
|
+
// From the shell's exit, notice the moment its group empties, so a later stop never signals
|
|
396
|
+
// an ID another process may have taken.
|
|
397
|
+
child.on("exit", () => {
|
|
398
|
+
if (!group || !running())
|
|
399
|
+
return;
|
|
400
|
+
watch = setInterval(() => {
|
|
401
|
+
if (!running())
|
|
402
|
+
clearInterval(watch);
|
|
403
|
+
}, 50).unref();
|
|
404
|
+
});
|
|
405
|
+
/** Settles once the shell's output closed or, after a stop, once its group is gone. A stopped
|
|
406
|
+
* command's output no longer matters, and a process that left the group may hold the pipe. */
|
|
407
|
+
const settle = () => {
|
|
408
|
+
if (settled || (stopping ? !stopped : !closed))
|
|
409
|
+
return;
|
|
410
|
+
release();
|
|
411
|
+
if (stopping) {
|
|
412
|
+
child.stdout.destroy();
|
|
413
|
+
child.stdin.destroy();
|
|
414
|
+
}
|
|
291
415
|
if (options.signal?.aborted)
|
|
292
416
|
return reject(new TargetCancelledError());
|
|
293
417
|
if (timedOut)
|
|
294
418
|
return reject(new Error(`The agent command timed out after ${options.timeoutSeconds} seconds`));
|
|
295
419
|
if (oversized)
|
|
296
420
|
return reject(new Error("The agent command printed more than 4 MiB"));
|
|
421
|
+
const { code, signal } = closed;
|
|
297
422
|
if (code !== 0)
|
|
298
423
|
return reject(new Error(signal
|
|
299
424
|
? `The agent command was stopped by ${signal}`
|
|
300
425
|
: `The agent command exited with code ${code}`));
|
|
301
426
|
resolvePromise(Buffer.concat(chunks).toString("utf8").trim());
|
|
427
|
+
};
|
|
428
|
+
child.on("close", (code, signal) => {
|
|
429
|
+
closed = { code, signal };
|
|
430
|
+
// Whatever the command left running is stopped before its answer and files are read, so
|
|
431
|
+
// nothing it started can still write, swap or link files while they are collected.
|
|
432
|
+
if (group && !stopping && running())
|
|
433
|
+
return stop();
|
|
434
|
+
settle();
|
|
302
435
|
});
|
|
303
436
|
});
|
|
304
437
|
}
|
|
@@ -312,13 +445,142 @@ function parseAnswer(text) {
|
|
|
312
445
|
return text;
|
|
313
446
|
}
|
|
314
447
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
448
|
+
const MIN_SECRET_LENGTH = 16;
|
|
449
|
+
/** Each export of the CLI's own telemetry, retries included, may take this long. A case's trace
|
|
450
|
+
* is required evidence, and Hue can acknowledge it slowly or ask for a retry after a few seconds,
|
|
451
|
+
* which the 10-second default leaves no room for. A refusal still fails at once. */
|
|
452
|
+
const CASE_TRACE_EXPORT_MILLIS = 30_000;
|
|
453
|
+
function caseSecrets(context) {
|
|
454
|
+
const values = new Set();
|
|
455
|
+
// Hue issues long credentials; a short value would redact ordinary text around it.
|
|
456
|
+
const add = (value) => {
|
|
457
|
+
if (typeof value === "string" && value.trim().length >= MIN_SECRET_LENGTH)
|
|
458
|
+
values.add(value).add(value.trim());
|
|
459
|
+
};
|
|
460
|
+
for (const [name, value] of Object.entries(process.env))
|
|
461
|
+
if (isHueControlPlaneCredential(name, value))
|
|
462
|
+
add(value);
|
|
463
|
+
add(context.world?.token);
|
|
464
|
+
for (const server of Object.values(context.world?.mcpConfig?.mcpServers ?? {}))
|
|
465
|
+
if (server.headers && typeof server.headers === "object")
|
|
466
|
+
for (const header of Object.values(server.headers))
|
|
467
|
+
add(header);
|
|
468
|
+
add(context.mcp?.token);
|
|
469
|
+
const bearers = (value, depth) => {
|
|
470
|
+
if (depth > 16 || !value || typeof value !== "object")
|
|
471
|
+
return;
|
|
472
|
+
for (const [key, item] of Object.entries(value))
|
|
473
|
+
if (key === "bearer" || key === "token")
|
|
474
|
+
add(item);
|
|
475
|
+
else
|
|
476
|
+
bearers(item, depth + 1);
|
|
477
|
+
};
|
|
478
|
+
bearers(context.connectionBundle, 0);
|
|
479
|
+
return [...values].sort((a, b) => b.length - a.length);
|
|
480
|
+
}
|
|
481
|
+
/** Each secret's exact value, as written and as JSON escapes it, replaced with `[redacted]`. */
|
|
482
|
+
function redactSecrets(text, secrets) {
|
|
483
|
+
for (const secret of secrets) {
|
|
484
|
+
text = text.replaceAll(secret, "[redacted]");
|
|
485
|
+
const escaped = JSON.stringify(secret).slice(1, -1);
|
|
486
|
+
if (escaped !== secret)
|
|
487
|
+
text = text.replaceAll(escaped, "[redacted]");
|
|
488
|
+
}
|
|
489
|
+
return text;
|
|
490
|
+
}
|
|
491
|
+
const TEXT_CONTENT_TYPES = new Set(["text/plain", "text/csv", "application/json"]);
|
|
492
|
+
/** A generated file with its name, and a text document held in memory with its text, cleared of
|
|
493
|
+
* the case's credentials. Other documents, a file declared by path and text that is not UTF-8
|
|
494
|
+
* are uploaded as written. */
|
|
495
|
+
function redactFile(file, secrets) {
|
|
496
|
+
// A name that is not a string is left for staging to refuse.
|
|
497
|
+
const filename = typeof file.filename === "string" ? redactSecrets(file.filename, secrets) : file.filename;
|
|
498
|
+
if (filename !== file.filename)
|
|
499
|
+
file = { ...file, filename };
|
|
500
|
+
if (!file.bytes || !TEXT_CONTENT_TYPES.has(file.contentType))
|
|
501
|
+
return file;
|
|
502
|
+
let text;
|
|
503
|
+
try {
|
|
504
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(file.bytes);
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
return file;
|
|
508
|
+
}
|
|
509
|
+
const redacted = redactSecrets(text, secrets);
|
|
510
|
+
return redacted === text ? file : { ...file, bytes: new TextEncoder().encode(redacted) };
|
|
511
|
+
}
|
|
512
|
+
/** Generated files cleared of the case's credentials. A name redaction made equal to another
|
|
513
|
+
* file's, as staging compares them, gains `-2`, `-3`, … before its extension, so every file is
|
|
514
|
+
* still taken. */
|
|
515
|
+
function redactFiles(files, secrets) {
|
|
516
|
+
const redacted = files.map((file) => redactFile(file, secrets));
|
|
517
|
+
const renamed = redacted.map((file, index) => file.filename !== files[index].filename);
|
|
518
|
+
const taken = new Set(redacted.flatMap((file, index) => !renamed[index] && typeof file.filename === "string" ? [safeFilename(file.filename)] : []));
|
|
519
|
+
return redacted.map((file, index) => {
|
|
520
|
+
if (!renamed[index])
|
|
521
|
+
return file;
|
|
522
|
+
const dot = file.filename.lastIndexOf(".");
|
|
523
|
+
const stem = dot > 0 ? file.filename.slice(0, dot) : file.filename;
|
|
524
|
+
const extension = dot > 0 ? file.filename.slice(dot) : "";
|
|
525
|
+
let filename = file.filename;
|
|
526
|
+
for (let copy = 2; taken.has(safeFilename(filename)); copy++)
|
|
527
|
+
filename = `${stem}-${copy}${extension}`;
|
|
528
|
+
taken.add(safeFilename(filename));
|
|
529
|
+
return filename === file.filename ? file : { ...file, filename };
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
/** An answer with its strings redacted, keys included. Deeper than any output the runner
|
|
533
|
+
* accepts, a value is left for the runner to refuse. */
|
|
534
|
+
function redactAnswer(value, secrets, depth = 0) {
|
|
535
|
+
if (typeof value === "string")
|
|
536
|
+
return redactSecrets(value, secrets);
|
|
537
|
+
if (depth > 64 || !value || typeof value !== "object")
|
|
538
|
+
return value;
|
|
539
|
+
if (value instanceof TargetResult)
|
|
540
|
+
return new TargetResult(redactAnswer(value.output, secrets, depth + 1), redactFiles(value.files, secrets));
|
|
541
|
+
if (Array.isArray(value))
|
|
542
|
+
return value.map((item) => redactAnswer(item, secrets, depth + 1));
|
|
543
|
+
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
|
544
|
+
return value;
|
|
545
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
546
|
+
redactSecrets(key, secrets),
|
|
547
|
+
redactAnswer(item, secrets, depth + 1),
|
|
548
|
+
]));
|
|
549
|
+
}
|
|
550
|
+
/** Runs an adapter with its answer and any error it throws cleared of the case's credentials,
|
|
551
|
+
* before either reaches Hue, the case's error message or a checkpoint. */
|
|
552
|
+
function redacting(adapter) {
|
|
553
|
+
return async (inputs, context) => {
|
|
554
|
+
// A direct case's context carries no world; its answer is still cleared of the CLI's own.
|
|
555
|
+
const secrets = caseSecrets(context);
|
|
556
|
+
try {
|
|
557
|
+
return redactAnswer(await adapter(inputs, context), secrets);
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
if (!(error instanceof Error))
|
|
561
|
+
throw error;
|
|
562
|
+
// The name is exported too, as the span's error type, so it is cleared like the message.
|
|
563
|
+
const message = redactSecrets(error.message, secrets);
|
|
564
|
+
const name = redactSecrets(String(error.name), secrets);
|
|
565
|
+
if (message === error.message && name === String(error.name))
|
|
566
|
+
throw error;
|
|
567
|
+
try {
|
|
568
|
+
if (message !== error.message)
|
|
569
|
+
error.message = message;
|
|
570
|
+
if (name !== String(error.name))
|
|
571
|
+
error.name = name;
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
// A frozen error, or one whose message or name is a getter, keeps its text; replace it.
|
|
575
|
+
}
|
|
576
|
+
if (error.message === message && String(error.name) === name)
|
|
577
|
+
throw error;
|
|
578
|
+
const replacement = new Error(message);
|
|
579
|
+
replacement.name = name;
|
|
580
|
+
throw replacement;
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
}
|
|
322
584
|
/** The parent environment an agent child starts from: without Hue control-plane credentials
|
|
323
585
|
* unless `--allow-hue-credentials` was passed. */
|
|
324
586
|
function parentEnvironment(allowHueCredentials) {
|
|
@@ -329,14 +591,36 @@ function parentEnvironment(allowHueCredentials) {
|
|
|
329
591
|
function commandAdapter(command, timeoutSeconds, options) {
|
|
330
592
|
return async (inputs, context) => {
|
|
331
593
|
const parent = parentEnvironment(options.allowHueCredentials);
|
|
594
|
+
// The case directory direct cases get: inputs, the verified agent-visible files and
|
|
595
|
+
// output/. It holds no world credential and the runner removes it after the case.
|
|
596
|
+
const layout = await stageDirectCase(context.outputDirectory, {
|
|
597
|
+
inputs,
|
|
598
|
+
config: context.config,
|
|
599
|
+
item: context.item,
|
|
600
|
+
executionId: context.executionId,
|
|
601
|
+
files: context.files,
|
|
602
|
+
});
|
|
332
603
|
const identity = {
|
|
333
604
|
HUE_EXECUTION_ID: context.executionId,
|
|
334
605
|
HUE_ENVIRONMENT_RUN_ID: context.environmentRunId,
|
|
335
606
|
HUE_CASE_ID: context.item.id,
|
|
336
607
|
HUE_CASE_KEY: context.item.externalKey,
|
|
608
|
+
HUE_CASE_DIR: layout.caseDirectory,
|
|
609
|
+
HUE_CASE_INPUTS: layout.inputsPath,
|
|
610
|
+
HUE_CASE_OUTPUT_DIR: layout.outputDirectory,
|
|
337
611
|
};
|
|
338
|
-
|
|
612
|
+
// The token-bearing file is written inside a private directory registered before any of it
|
|
613
|
+
// exists, so a forced exit at any point removes it.
|
|
614
|
+
const configDirectory = context.world
|
|
615
|
+
? mkdtempSync(join(tmpdir(), "hue-mcp-config-"))
|
|
616
|
+
: undefined;
|
|
617
|
+
const untrack = configDirectory
|
|
618
|
+
? onForcedExit(() => rmSync(configDirectory, { recursive: true, force: true }))
|
|
619
|
+
: undefined;
|
|
339
620
|
try {
|
|
621
|
+
const configFile = context.world
|
|
622
|
+
? await writeMcpConfig(context.world, { directory: configDirectory })
|
|
623
|
+
: undefined;
|
|
340
624
|
const env = context.world
|
|
341
625
|
? {
|
|
342
626
|
...agentEnvironment(context.world, { parent, includeHueCredentials: true }),
|
|
@@ -354,15 +638,18 @@ function commandAdapter(command, timeoutSeconds, options) {
|
|
|
354
638
|
: {}),
|
|
355
639
|
...identity,
|
|
356
640
|
};
|
|
357
|
-
|
|
641
|
+
const stdout = await spawnAgentCommand(command, {
|
|
358
642
|
env,
|
|
359
643
|
stdin: JSON.stringify({ inputs, config: context.config }),
|
|
360
644
|
timeoutSeconds,
|
|
361
645
|
...(context.signal ? { signal: context.signal } : {}),
|
|
362
|
-
})
|
|
646
|
+
});
|
|
647
|
+
return collectDirectOutputs(layout.outputDirectory, parseAnswer(stdout));
|
|
363
648
|
}
|
|
364
649
|
finally {
|
|
365
|
-
|
|
650
|
+
if (configDirectory)
|
|
651
|
+
await rm(configDirectory, { recursive: true, force: true });
|
|
652
|
+
untrack?.();
|
|
366
653
|
}
|
|
367
654
|
};
|
|
368
655
|
}
|
|
@@ -412,18 +699,39 @@ const STATE_LABEL = {
|
|
|
412
699
|
skipped: "SKIPPED",
|
|
413
700
|
pending: "PENDING",
|
|
414
701
|
};
|
|
415
|
-
function renderTable(verdicts, output) {
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
702
|
+
export function renderTable(verdicts, output) {
|
|
703
|
+
const cases = verdicts.summary.cases;
|
|
704
|
+
// A column per metric name; a name several evaluators report, such as two judges' `verdict`,
|
|
705
|
+
// gets a column per evaluator so each one's value shows.
|
|
706
|
+
const reporters = new Map();
|
|
707
|
+
for (const item of cases)
|
|
708
|
+
for (const metric of item.metrics) {
|
|
709
|
+
const versions = reporters.get(metric.name) ?? [];
|
|
710
|
+
if (!versions.includes(metric.scorerVersionId))
|
|
711
|
+
versions.push(metric.scorerVersionId);
|
|
712
|
+
reporters.set(metric.name, versions);
|
|
713
|
+
}
|
|
714
|
+
const columns = [...reporters].flatMap(([name, versions]) => versions.map((version) => ({
|
|
715
|
+
name,
|
|
716
|
+
version,
|
|
717
|
+
label: versions.length === 1 ? name : `${name} ${version.slice(0, 8)}`,
|
|
718
|
+
})));
|
|
719
|
+
// An advisory evaluator's values are shown, marked, and never decide a case.
|
|
720
|
+
const advisory = new Set(cases.flatMap((item) => item.advisory ?? []));
|
|
721
|
+
const header = [
|
|
722
|
+
"Case",
|
|
723
|
+
...columns.map((column) => advisory.has(column.version) ? `${column.label} (advisory)` : column.label),
|
|
724
|
+
"Result",
|
|
725
|
+
];
|
|
726
|
+
const rows = cases.map((item) => [
|
|
423
727
|
item.externalKey,
|
|
424
|
-
...
|
|
425
|
-
const metric = item.metrics.find((candidate) => candidate.name === name);
|
|
426
|
-
|
|
728
|
+
...columns.map((column) => {
|
|
729
|
+
const metric = item.metrics.find((candidate) => candidate.name === column.name && candidate.scorerVersionId === column.version);
|
|
730
|
+
if (metric)
|
|
731
|
+
return metricText(metric);
|
|
732
|
+
// A case the column's evaluator does not apply to shows n/a; one it applies to without
|
|
733
|
+
// reporting the metric shows "-".
|
|
734
|
+
return item.notApplicable?.includes(column.version) ? "n/a" : "-";
|
|
427
735
|
}),
|
|
428
736
|
STATE_LABEL[item.state],
|
|
429
737
|
]);
|
|
@@ -444,10 +752,18 @@ function renderTable(verdicts, output) {
|
|
|
444
752
|
output.log(` ${item.externalKey}: ${detail}`);
|
|
445
753
|
}
|
|
446
754
|
const totals = verdicts.summary.totals;
|
|
755
|
+
const advisoryFailures = cases.reduce((count, item) => count +
|
|
756
|
+
item.metrics.filter((metric) => item.advisory?.includes(metric.scorerVersionId) && !metricPassed(metric)).length, 0);
|
|
447
757
|
const extra = [
|
|
448
758
|
totals.error ? `${totals.error} error` : "",
|
|
449
759
|
totals.skipped ? `${totals.skipped} skipped` : "",
|
|
450
760
|
totals.pending ? `${totals.pending} pending` : "",
|
|
761
|
+
totals.notApplicable
|
|
762
|
+
? `${totals.notApplicable} evaluator result${totals.notApplicable === 1 ? "" : "s"} not applicable`
|
|
763
|
+
: "",
|
|
764
|
+
advisoryFailures
|
|
765
|
+
? `${advisoryFailures} advisory failure${advisoryFailures === 1 ? "" : "s"} not counted`
|
|
766
|
+
: "",
|
|
451
767
|
].filter(Boolean);
|
|
452
768
|
output.log(`${totals.passed} of ${totals.cases} case${totals.cases === 1 ? "" : "s"} passed${extra.length ? ` (${extra.join(", ")})` : ""}`);
|
|
453
769
|
if (!verdicts.results.complete)
|
|
@@ -466,9 +782,20 @@ function redact(message, secrets) {
|
|
|
466
782
|
text = text.replaceAll(secret, "[redacted]");
|
|
467
783
|
return text;
|
|
468
784
|
}
|
|
469
|
-
function explain(error) {
|
|
785
|
+
export function explain(error) {
|
|
470
786
|
if (error instanceof HueApiError && (error.status === 401 || error.status === 403))
|
|
471
787
|
return `${error.message}. Check that HUE_API_KEY is a "Read and write" project key for this origin.`;
|
|
788
|
+
// An unfinished run keeps the content policy it started with; name the flags that resume it.
|
|
789
|
+
if (error instanceof CheckpointIdentityError && error.startedWith) {
|
|
790
|
+
const flags = [
|
|
791
|
+
error.startedWith.persistResultContent === false ? "--no-output" : "",
|
|
792
|
+
error.startedWith.captureContent === true ? "--content" : "",
|
|
793
|
+
].filter(Boolean);
|
|
794
|
+
return `This unfinished run was started ${flags.length ? `with ${flags.join(" and ")}` : "without --no-output or --content"}; rerun with the same flags to resume it, or remove its checkpoint directory to start over`;
|
|
795
|
+
}
|
|
796
|
+
// The error's own message points at an in-memory report the user cannot reach.
|
|
797
|
+
if (error instanceof HueExportError)
|
|
798
|
+
return `Hue could not accept all telemetry (${describeTelemetryIssues(telemetryIssueCounts(error))})`;
|
|
472
799
|
if (error instanceof Error) {
|
|
473
800
|
const causes = [];
|
|
474
801
|
let cause = error.cause;
|
|
@@ -625,13 +952,19 @@ async function detectDirect(client, pins, mode) {
|
|
|
625
952
|
} while (after);
|
|
626
953
|
return true;
|
|
627
954
|
}
|
|
628
|
-
function toJson(verdicts, runUrl, baseline, extra = {}) {
|
|
955
|
+
function toJson(verdicts, runUrl, baseline, extra = {}, telemetry = []) {
|
|
956
|
+
const notAccepted = new Map(telemetry.map((entry) => [entry.caseId, entry.issues]));
|
|
629
957
|
return {
|
|
630
958
|
experimentId: verdicts.experimentId,
|
|
631
959
|
runId: verdicts.runId,
|
|
632
960
|
runUrl,
|
|
633
961
|
complete: verdicts.results.complete,
|
|
634
|
-
cases: verdicts.summary.cases
|
|
962
|
+
cases: verdicts.summary.cases.map((item) => notAccepted.has(item.caseId)
|
|
963
|
+
? {
|
|
964
|
+
...item,
|
|
965
|
+
telemetry: { code: TELEMETRY_NOT_ACCEPTED, issues: notAccepted.get(item.caseId) },
|
|
966
|
+
}
|
|
967
|
+
: item),
|
|
635
968
|
totals: verdicts.summary.totals,
|
|
636
969
|
...extra,
|
|
637
970
|
...(baseline
|
|
@@ -639,16 +972,52 @@ function toJson(verdicts, runUrl, baseline, extra = {}) {
|
|
|
639
972
|
: {}),
|
|
640
973
|
};
|
|
641
974
|
}
|
|
975
|
+
/** Says, once per execution, that a case failed because Hue did not accept its telemetry, with
|
|
976
|
+
* sanitized counts: as the case completes, or from the report for a case an earlier, interrupted
|
|
977
|
+
* run completed. */
|
|
978
|
+
function telemetryReporter(output) {
|
|
979
|
+
const reported = new Set();
|
|
980
|
+
const report = (entry) => {
|
|
981
|
+
if (reported.has(entry.executionId))
|
|
982
|
+
return;
|
|
983
|
+
reported.add(entry.executionId);
|
|
984
|
+
output.error(`[${entry.caseKey}] telemetry not accepted, case failed: ${describeTelemetryIssues(entry.issues)}`);
|
|
985
|
+
};
|
|
986
|
+
return {
|
|
987
|
+
report,
|
|
988
|
+
rest: (entries) => entries?.forEach(report),
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
/** A case without trace evidence fails whatever its scores say: a scorer that grades only the
|
|
992
|
+
* world or ignores the execution state could still pass it. */
|
|
993
|
+
function withTelemetryFailures(verdicts, telemetry = []) {
|
|
994
|
+
if (!telemetry.length)
|
|
995
|
+
return verdicts;
|
|
996
|
+
const failed = new Set(telemetry.map((entry) => entry.caseId));
|
|
997
|
+
const cases = verdicts.summary.cases.map((item) => failed.has(item.caseId) ? { ...item, state: "error", passed: false } : item);
|
|
998
|
+
const totals = {
|
|
999
|
+
cases: cases.length,
|
|
1000
|
+
passed: 0,
|
|
1001
|
+
failed: 0,
|
|
1002
|
+
error: 0,
|
|
1003
|
+
skipped: 0,
|
|
1004
|
+
pending: 0,
|
|
1005
|
+
notApplicable: verdicts.summary.totals.notApplicable,
|
|
1006
|
+
};
|
|
1007
|
+
for (const item of cases)
|
|
1008
|
+
totals[item.state]++;
|
|
1009
|
+
return { ...verdicts, summary: { cases, totals } };
|
|
1010
|
+
}
|
|
642
1011
|
/** Waits for Hue's verdicts, prints them (table or JSON, with the optional baseline) and returns the exit code. */
|
|
643
1012
|
async function reportVerdicts(client, values, run, baselineId, output, signal) {
|
|
644
1013
|
const wait = integer("wait", values.wait, 300, 0, 86_400);
|
|
645
1014
|
output.log("Waiting for Hue checks...");
|
|
646
|
-
const verdicts = await collectExperimentVerdicts(client, {
|
|
1015
|
+
const verdicts = withTelemetryFailures(await collectExperimentVerdicts(client, {
|
|
647
1016
|
experimentId: run.experimentId,
|
|
648
1017
|
subjectIds: run.subjectIds,
|
|
649
1018
|
timeoutMillis: wait * 1000,
|
|
650
1019
|
signal,
|
|
651
|
-
});
|
|
1020
|
+
}), run.telemetryNotAccepted);
|
|
652
1021
|
// The wait returns its partial state on abort rather than throwing, so Ctrl+C here must not
|
|
653
1022
|
// fall through to a baseline read and a verdict table that nobody asked to finish.
|
|
654
1023
|
if (signal.aborted) {
|
|
@@ -667,7 +1036,7 @@ async function reportVerdicts(client, values, run, baselineId, output, signal) {
|
|
|
667
1036
|
};
|
|
668
1037
|
}
|
|
669
1038
|
if (values.json) {
|
|
670
|
-
process.stdout.write(`${JSON.stringify(toJson(verdicts, run.runUrl, baseline, run.extra))}\n`);
|
|
1039
|
+
process.stdout.write(`${JSON.stringify(toJson(verdicts, run.runUrl, baseline, run.extra, run.telemetryNotAccepted))}\n`);
|
|
671
1040
|
}
|
|
672
1041
|
else {
|
|
673
1042
|
renderTable(verdicts, output);
|
|
@@ -676,6 +1045,8 @@ async function reportVerdicts(client, values, run, baselineId, output, signal) {
|
|
|
676
1045
|
output.log(`Run: ${run.runUrl}`);
|
|
677
1046
|
}
|
|
678
1047
|
const totals = verdicts.summary.totals;
|
|
1048
|
+
if (run.telemetryNotAccepted?.length)
|
|
1049
|
+
return 1;
|
|
679
1050
|
return verdicts.results.complete && totals.cases > 0 && totals.passed === totals.cases ? 0 : 1;
|
|
680
1051
|
}
|
|
681
1052
|
function parseBaseline(value) {
|
|
@@ -721,6 +1092,7 @@ async function runOnce(values, connection, agents, agent, hue, output, signal) {
|
|
|
721
1092
|
const checkpointDirectory = await prepareCheckpointDirectory(values["checkpoint-dir"], agent.key, project.id, "simulation");
|
|
722
1093
|
const caseKeys = new Map();
|
|
723
1094
|
let runUrl = "";
|
|
1095
|
+
const telemetry = telemetryReporter(output);
|
|
724
1096
|
const report = await runSimulation({
|
|
725
1097
|
client,
|
|
726
1098
|
environmentClient,
|
|
@@ -732,8 +1104,13 @@ async function runOnce(values, connection, agents, agent, hue, output, signal) {
|
|
|
732
1104
|
scorerVersionIds: pins.scorerVersionIds,
|
|
733
1105
|
},
|
|
734
1106
|
runName,
|
|
735
|
-
|
|
1107
|
+
// Outputs, error messages and explanations are stored unless opted out; --content governs
|
|
1108
|
+
// only the telemetry.
|
|
1109
|
+
persistResultContent: !values["no-output"],
|
|
736
1110
|
traceEvidence: { mode: "required" },
|
|
1111
|
+
// A case whose telemetry Hue did not accept fails instead of staying started.
|
|
1112
|
+
traceNotAccepted: "fail_case",
|
|
1113
|
+
onTelemetryNotAccepted: telemetry.report,
|
|
737
1114
|
concurrency,
|
|
738
1115
|
agentRevision: agent.revision,
|
|
739
1116
|
// The CLI adapts to whatever the deployment serves; the library warning is for code that
|
|
@@ -771,10 +1148,12 @@ async function runOnce(values, connection, agents, agent, hue, output, signal) {
|
|
|
771
1148
|
output.log(`[${label}] attempt prepared: ${event.status}`);
|
|
772
1149
|
},
|
|
773
1150
|
});
|
|
1151
|
+
telemetry.rest(report.telemetryNotAccepted);
|
|
774
1152
|
return reportVerdicts(client, values, {
|
|
775
1153
|
experimentId: report.experimentId,
|
|
776
1154
|
subjectIds: report.subjectIds,
|
|
777
1155
|
runUrl: runUrl || report.runUrl,
|
|
1156
|
+
telemetryNotAccepted: report.telemetryNotAccepted,
|
|
778
1157
|
}, baselineId, output, signal);
|
|
779
1158
|
}
|
|
780
1159
|
/**
|
|
@@ -796,6 +1175,7 @@ async function runDirect(run, connection) {
|
|
|
796
1175
|
let experimentId = "";
|
|
797
1176
|
let runUrl = "";
|
|
798
1177
|
let report;
|
|
1178
|
+
const telemetry = telemetryReporter(output);
|
|
799
1179
|
try {
|
|
800
1180
|
const selectionDigest = digest({
|
|
801
1181
|
datasetVersionId: pins.datasetVersionId,
|
|
@@ -830,8 +1210,12 @@ async function runDirect(run, connection) {
|
|
|
830
1210
|
hue: run.hue,
|
|
831
1211
|
experimentId,
|
|
832
1212
|
checkpointDirectory: join(store.directory, experimentId),
|
|
833
|
-
|
|
1213
|
+
// Outputs, error messages and explanations are stored unless opted out; --content governs
|
|
1214
|
+
// only the telemetry.
|
|
1215
|
+
persistResultContent: !values["no-output"],
|
|
834
1216
|
traceEvidence: { mode: "required" },
|
|
1217
|
+
traceNotAccepted: "fail_case",
|
|
1218
|
+
onTelemetryNotAccepted: telemetry.report,
|
|
835
1219
|
concurrency: run.concurrency,
|
|
836
1220
|
scorers: [],
|
|
837
1221
|
deferUnboundLocalScorers: true,
|
|
@@ -865,11 +1249,13 @@ async function runDirect(run, connection) {
|
|
|
865
1249
|
}
|
|
866
1250
|
if (report.deferredScorerVersionIds.length)
|
|
867
1251
|
output.log(`${report.deferredScorerVersionIds.length} evaluator version${report.deferredScorerVersionIds.length === 1 ? "" : "s"} left to Hue's executor`);
|
|
1252
|
+
telemetry.rest(report.telemetryNotAccepted);
|
|
868
1253
|
return reportVerdicts(client, values, {
|
|
869
1254
|
experimentId,
|
|
870
1255
|
subjectIds: report.subjectIds,
|
|
871
1256
|
runUrl,
|
|
872
1257
|
extra: { mode: "direct", deferredScorerVersionIds: report.deferredScorerVersionIds },
|
|
1258
|
+
telemetryNotAccepted: report.telemetryNotAccepted,
|
|
873
1259
|
}, run.baselineId, output, signal);
|
|
874
1260
|
}
|
|
875
1261
|
async function runWorker(values, connection, adapter, agent, hue, output, signal) {
|
|
@@ -890,6 +1276,7 @@ async function runWorker(values, connection, adapter, agent, hue, output, signal
|
|
|
890
1276
|
current = claim;
|
|
891
1277
|
output.log(`Claimed run ${claim.runId}: ${new URL(`/experiments/${claim.experimentId}`, connection.baseUrl).toString()}`);
|
|
892
1278
|
};
|
|
1279
|
+
const telemetry = telemetryReporter(output);
|
|
893
1280
|
await runLocalAgent({
|
|
894
1281
|
client,
|
|
895
1282
|
environmentClient,
|
|
@@ -899,10 +1286,12 @@ async function runWorker(values, connection, adapter, agent, hue, output, signal
|
|
|
899
1286
|
key: agent.key,
|
|
900
1287
|
name: agent.name,
|
|
901
1288
|
revision: agent.revision,
|
|
902
|
-
capabilities: ["environment:v1"],
|
|
1289
|
+
capabilities: ["environment:v1", ...(values.capability ?? [])],
|
|
903
1290
|
},
|
|
904
1291
|
scorers: [],
|
|
905
1292
|
concurrency,
|
|
1293
|
+
traceNotAccepted: "fail_case",
|
|
1294
|
+
onTelemetryNotAccepted: telemetry.report,
|
|
906
1295
|
deprecationWarnings: false,
|
|
907
1296
|
signal,
|
|
908
1297
|
...(maxRuns === undefined ? {} : { maxRuns }),
|
|
@@ -917,25 +1306,28 @@ async function runWorker(values, connection, adapter, agent, hue, output, signal
|
|
|
917
1306
|
...(context.world ? { world: context.world } : {}),
|
|
918
1307
|
...(context.mcp ? { mcp: context.mcp } : {}),
|
|
919
1308
|
...(context.connectionBundle ? { connectionBundle: context.connectionBundle } : {}),
|
|
1309
|
+
files: context.files,
|
|
1310
|
+
outputDirectory: context.outputDirectory,
|
|
920
1311
|
signal,
|
|
921
1312
|
});
|
|
922
1313
|
},
|
|
923
1314
|
async onCompleted(report) {
|
|
924
1315
|
output.log(`Run ${report.runId} completed: ${report.subjectIds.length} case${report.subjectIds.length === 1 ? "" : "s"}`);
|
|
1316
|
+
telemetry.rest(report.telemetryNotAccepted);
|
|
925
1317
|
if (!current)
|
|
926
1318
|
return;
|
|
927
1319
|
output.log("Waiting for Hue checks...");
|
|
928
1320
|
try {
|
|
929
|
-
const verdicts = await collectExperimentVerdicts(client, {
|
|
1321
|
+
const verdicts = withTelemetryFailures(await collectExperimentVerdicts(client, {
|
|
930
1322
|
experimentId: current.experimentId,
|
|
931
1323
|
subjectIds: report.subjectIds,
|
|
932
1324
|
timeoutMillis: wait * 1000,
|
|
933
1325
|
signal,
|
|
934
|
-
});
|
|
1326
|
+
}), report.telemetryNotAccepted);
|
|
935
1327
|
if (signal.aborted)
|
|
936
1328
|
return;
|
|
937
1329
|
if (values.json)
|
|
938
|
-
process.stdout.write(`${JSON.stringify(toJson(verdicts, new URL(`/experiments/${current.experimentId}`, connection.baseUrl).toString()))}\n`);
|
|
1330
|
+
process.stdout.write(`${JSON.stringify(toJson(verdicts, new URL(`/experiments/${current.experimentId}`, connection.baseUrl).toString(), undefined, {}, report.telemetryNotAccepted))}\n`);
|
|
939
1331
|
else
|
|
940
1332
|
renderTable(verdicts, output);
|
|
941
1333
|
}
|
|
@@ -958,9 +1350,31 @@ async function runWorker(values, connection, adapter, agent, hue, output, signal
|
|
|
958
1350
|
export async function runEvalCommand(argv) {
|
|
959
1351
|
const secrets = [];
|
|
960
1352
|
const controller = new AbortController();
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1353
|
+
let firstInterrupt = 0;
|
|
1354
|
+
// The first interrupt stops the agents within their grace; a repeated one kills their process
|
|
1355
|
+
// groups at once and exits, so no agent is left running with its world token. `npm run` and
|
|
1356
|
+
// `npx` forward the terminal's SIGINT a moment after the terminal delivers it, so a repeat
|
|
1357
|
+
// within 50 ms is the same Ctrl+C.
|
|
1358
|
+
const interrupt = () => {
|
|
1359
|
+
if (!controller.signal.aborted) {
|
|
1360
|
+
firstInterrupt = performance.now();
|
|
1361
|
+
if (runningCommands.size)
|
|
1362
|
+
process.stderr.write("Stopping the agent; press Ctrl+C again to force.\n");
|
|
1363
|
+
controller.abort(new Error("Interrupted"));
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
if (performance.now() - firstInterrupt < 50)
|
|
1367
|
+
return;
|
|
1368
|
+
for (const command of runningCommands)
|
|
1369
|
+
command.kill();
|
|
1370
|
+
// The exit cannot wait for the normal cleanup: remove the world cases' files, the MCP
|
|
1371
|
+
// configurations holding world tokens and the checkpoint locks now.
|
|
1372
|
+
runForcedExitCleanups();
|
|
1373
|
+
process.stderr.write("Interrupted.\n");
|
|
1374
|
+
process.exit(130);
|
|
1375
|
+
};
|
|
1376
|
+
process.on("SIGINT", interrupt);
|
|
1377
|
+
process.on("SIGTERM", interrupt);
|
|
964
1378
|
let hue;
|
|
965
1379
|
let json = false;
|
|
966
1380
|
try {
|
|
@@ -1014,25 +1428,31 @@ export async function runEvalCommand(argv) {
|
|
|
1014
1428
|
};
|
|
1015
1429
|
if (values.worker && (values.mode || values["set-version"] || values.scorer?.length))
|
|
1016
1430
|
throw new UsageError("--worker takes no selection; Hue chooses the run to execute");
|
|
1431
|
+
if (values.worker && values["no-output"])
|
|
1432
|
+
throw new UsageError("--no-output applies to one-shot runs; a run launched from Hue always stores its outputs");
|
|
1433
|
+
if (!values.worker && values.capability?.length)
|
|
1434
|
+
throw new UsageError("--capability applies to --worker only");
|
|
1017
1435
|
const loaded = adapterFile ? await loadAdapter(adapterFile) : undefined;
|
|
1018
1436
|
// One adapter module serves both case kinds; the direct context announces itself with `mode`.
|
|
1437
|
+
// Outputs are stored by default, so every answer and error message is cleared of the
|
|
1438
|
+
// credentials the case handed the agent before it leaves the CLI.
|
|
1019
1439
|
const agents = {
|
|
1020
|
-
simulation: loaded
|
|
1021
|
-
|
|
1022
|
-
const answer = await loaded(inputs, context);
|
|
1023
|
-
if (answer instanceof TargetResult)
|
|
1024
|
-
throw new Error("The adapter returned generated files for a simulated-world case");
|
|
1025
|
-
return answer;
|
|
1026
|
-
}
|
|
1027
|
-
: commandAdapter(values.command, timeout, {
|
|
1440
|
+
simulation: redacting(loaded ??
|
|
1441
|
+
commandAdapter(values.command, timeout, {
|
|
1028
1442
|
allowHueCredentials: values["allow-hue-credentials"],
|
|
1029
|
-
}),
|
|
1030
|
-
direct: loaded ??
|
|
1443
|
+
})),
|
|
1444
|
+
direct: redacting(loaded ??
|
|
1031
1445
|
directCommandAdapter(values.command, timeout, {
|
|
1032
1446
|
allowHueCredentials: values["allow-hue-credentials"],
|
|
1033
|
-
}),
|
|
1447
|
+
})),
|
|
1034
1448
|
};
|
|
1035
|
-
hue = createHue({
|
|
1449
|
+
hue = createHue({
|
|
1450
|
+
apiKey,
|
|
1451
|
+
baseUrl,
|
|
1452
|
+
serviceName: key,
|
|
1453
|
+
captureContent: values.content,
|
|
1454
|
+
timeoutMillis: CASE_TRACE_EXPORT_MILLIS,
|
|
1455
|
+
});
|
|
1036
1456
|
return values.worker
|
|
1037
1457
|
? await runWorker(values, connection, agents.simulation, agent, hue, output, controller.signal)
|
|
1038
1458
|
: await runOnce(values, connection, agents, agent, hue, output, controller.signal);
|
|
@@ -1050,6 +1470,9 @@ export async function runEvalCommand(argv) {
|
|
|
1050
1470
|
return 1;
|
|
1051
1471
|
}
|
|
1052
1472
|
finally {
|
|
1473
|
+
// The handlers stay until every agent command has settled, so an interrupt during a stop's
|
|
1474
|
+
// grace still reaches it.
|
|
1475
|
+
await settleCommands();
|
|
1053
1476
|
process.removeListener("SIGINT", interrupt);
|
|
1054
1477
|
process.removeListener("SIGTERM", interrupt);
|
|
1055
1478
|
if (hue)
|