@hue-run/sdk 0.9.0 → 0.10.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 +100 -29
- package/ENVIRONMENTS.md +44 -9
- package/EVALUATIONS.md +39 -19
- package/README.md +51 -22
- 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 +469 -72
- package/dist/evals/checkpoint.d.ts +15 -1
- package/dist/evals/checkpoint.js +34 -3
- package/dist/evals/client.d.ts +19 -3
- package/dist/evals/client.js +82 -34
- 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 +59 -1
- package/dist/evals/files.js +187 -27
- 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 +164 -13
- package/dist/evals/scenarios.d.ts +7 -5
- package/dist/evals/scenarios.js +71 -14
- package/dist/evals/simulation.d.ts +17 -4
- package/dist/evals/simulation.js +8 -0
- package/dist/evals/types.d.ts +4 -0
- package/dist/evals/verdicts.d.ts +15 -2
- package/dist/evals/verdicts.js +43 -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 +40 -17
- package/dist/snapshot.d.ts +2 -2
- package/dist/snapshot.js +47 -12
- 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,138 @@ 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
|
+
function caseSecrets(context) {
|
|
450
|
+
const values = new Set();
|
|
451
|
+
// Hue issues long credentials; a short value would redact ordinary text around it.
|
|
452
|
+
const add = (value) => {
|
|
453
|
+
if (typeof value === "string" && value.trim().length >= MIN_SECRET_LENGTH)
|
|
454
|
+
values.add(value).add(value.trim());
|
|
455
|
+
};
|
|
456
|
+
for (const [name, value] of Object.entries(process.env))
|
|
457
|
+
if (isHueControlPlaneCredential(name, value))
|
|
458
|
+
add(value);
|
|
459
|
+
add(context.world?.token);
|
|
460
|
+
for (const server of Object.values(context.world?.mcpConfig?.mcpServers ?? {}))
|
|
461
|
+
if (server.headers && typeof server.headers === "object")
|
|
462
|
+
for (const header of Object.values(server.headers))
|
|
463
|
+
add(header);
|
|
464
|
+
add(context.mcp?.token);
|
|
465
|
+
const bearers = (value, depth) => {
|
|
466
|
+
if (depth > 16 || !value || typeof value !== "object")
|
|
467
|
+
return;
|
|
468
|
+
for (const [key, item] of Object.entries(value))
|
|
469
|
+
if (key === "bearer" || key === "token")
|
|
470
|
+
add(item);
|
|
471
|
+
else
|
|
472
|
+
bearers(item, depth + 1);
|
|
473
|
+
};
|
|
474
|
+
bearers(context.connectionBundle, 0);
|
|
475
|
+
return [...values].sort((a, b) => b.length - a.length);
|
|
476
|
+
}
|
|
477
|
+
/** Each secret's exact value, as written and as JSON escapes it, replaced with `[redacted]`. */
|
|
478
|
+
function redactSecrets(text, secrets) {
|
|
479
|
+
for (const secret of secrets) {
|
|
480
|
+
text = text.replaceAll(secret, "[redacted]");
|
|
481
|
+
const escaped = JSON.stringify(secret).slice(1, -1);
|
|
482
|
+
if (escaped !== secret)
|
|
483
|
+
text = text.replaceAll(escaped, "[redacted]");
|
|
484
|
+
}
|
|
485
|
+
return text;
|
|
486
|
+
}
|
|
487
|
+
const TEXT_CONTENT_TYPES = new Set(["text/plain", "text/csv", "application/json"]);
|
|
488
|
+
/** A generated file with its name, and a text document held in memory with its text, cleared of
|
|
489
|
+
* the case's credentials. Other documents, a file declared by path and text that is not UTF-8
|
|
490
|
+
* are uploaded as written. */
|
|
491
|
+
function redactFile(file, secrets) {
|
|
492
|
+
// A name that is not a string is left for staging to refuse.
|
|
493
|
+
const filename = typeof file.filename === "string" ? redactSecrets(file.filename, secrets) : file.filename;
|
|
494
|
+
if (filename !== file.filename)
|
|
495
|
+
file = { ...file, filename };
|
|
496
|
+
if (!file.bytes || !TEXT_CONTENT_TYPES.has(file.contentType))
|
|
497
|
+
return file;
|
|
498
|
+
let text;
|
|
499
|
+
try {
|
|
500
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(file.bytes);
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
return file;
|
|
504
|
+
}
|
|
505
|
+
const redacted = redactSecrets(text, secrets);
|
|
506
|
+
return redacted === text ? file : { ...file, bytes: new TextEncoder().encode(redacted) };
|
|
507
|
+
}
|
|
508
|
+
/** Generated files cleared of the case's credentials. A name redaction made equal to another
|
|
509
|
+
* file's, as staging compares them, gains `-2`, `-3`, … before its extension, so every file is
|
|
510
|
+
* still taken. */
|
|
511
|
+
function redactFiles(files, secrets) {
|
|
512
|
+
const redacted = files.map((file) => redactFile(file, secrets));
|
|
513
|
+
const renamed = redacted.map((file, index) => file.filename !== files[index].filename);
|
|
514
|
+
const taken = new Set(redacted.flatMap((file, index) => !renamed[index] && typeof file.filename === "string" ? [safeFilename(file.filename)] : []));
|
|
515
|
+
return redacted.map((file, index) => {
|
|
516
|
+
if (!renamed[index])
|
|
517
|
+
return file;
|
|
518
|
+
const dot = file.filename.lastIndexOf(".");
|
|
519
|
+
const stem = dot > 0 ? file.filename.slice(0, dot) : file.filename;
|
|
520
|
+
const extension = dot > 0 ? file.filename.slice(dot) : "";
|
|
521
|
+
let filename = file.filename;
|
|
522
|
+
for (let copy = 2; taken.has(safeFilename(filename)); copy++)
|
|
523
|
+
filename = `${stem}-${copy}${extension}`;
|
|
524
|
+
taken.add(safeFilename(filename));
|
|
525
|
+
return filename === file.filename ? file : { ...file, filename };
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
/** An answer with its strings redacted, keys included. Deeper than any output the runner
|
|
529
|
+
* accepts, a value is left for the runner to refuse. */
|
|
530
|
+
function redactAnswer(value, secrets, depth = 0) {
|
|
531
|
+
if (typeof value === "string")
|
|
532
|
+
return redactSecrets(value, secrets);
|
|
533
|
+
if (depth > 64 || !value || typeof value !== "object")
|
|
534
|
+
return value;
|
|
535
|
+
if (value instanceof TargetResult)
|
|
536
|
+
return new TargetResult(redactAnswer(value.output, secrets, depth + 1), redactFiles(value.files, secrets));
|
|
537
|
+
if (Array.isArray(value))
|
|
538
|
+
return value.map((item) => redactAnswer(item, secrets, depth + 1));
|
|
539
|
+
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
|
540
|
+
return value;
|
|
541
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
542
|
+
redactSecrets(key, secrets),
|
|
543
|
+
redactAnswer(item, secrets, depth + 1),
|
|
544
|
+
]));
|
|
545
|
+
}
|
|
546
|
+
/** Runs an adapter with its answer and any error it throws cleared of the case's credentials,
|
|
547
|
+
* before either reaches Hue, the case's error message or a checkpoint. */
|
|
548
|
+
function redacting(adapter) {
|
|
549
|
+
return async (inputs, context) => {
|
|
550
|
+
// A direct case's context carries no world; its answer is still cleared of the CLI's own.
|
|
551
|
+
const secrets = caseSecrets(context);
|
|
552
|
+
try {
|
|
553
|
+
return redactAnswer(await adapter(inputs, context), secrets);
|
|
554
|
+
}
|
|
555
|
+
catch (error) {
|
|
556
|
+
if (!(error instanceof Error))
|
|
557
|
+
throw error;
|
|
558
|
+
// The name is exported too, as the span's error type, so it is cleared like the message.
|
|
559
|
+
const message = redactSecrets(error.message, secrets);
|
|
560
|
+
const name = redactSecrets(String(error.name), secrets);
|
|
561
|
+
if (message === error.message && name === String(error.name))
|
|
562
|
+
throw error;
|
|
563
|
+
try {
|
|
564
|
+
if (message !== error.message)
|
|
565
|
+
error.message = message;
|
|
566
|
+
if (name !== String(error.name))
|
|
567
|
+
error.name = name;
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
// A frozen error, or one whose message or name is a getter, keeps its text; replace it.
|
|
571
|
+
}
|
|
572
|
+
if (error.message === message && String(error.name) === name)
|
|
573
|
+
throw error;
|
|
574
|
+
const replacement = new Error(message);
|
|
575
|
+
replacement.name = name;
|
|
576
|
+
throw replacement;
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
}
|
|
322
580
|
/** The parent environment an agent child starts from: without Hue control-plane credentials
|
|
323
581
|
* unless `--allow-hue-credentials` was passed. */
|
|
324
582
|
function parentEnvironment(allowHueCredentials) {
|
|
@@ -329,14 +587,36 @@ function parentEnvironment(allowHueCredentials) {
|
|
|
329
587
|
function commandAdapter(command, timeoutSeconds, options) {
|
|
330
588
|
return async (inputs, context) => {
|
|
331
589
|
const parent = parentEnvironment(options.allowHueCredentials);
|
|
590
|
+
// The case directory direct cases get: inputs, the verified agent-visible files and
|
|
591
|
+
// output/. It holds no world credential and the runner removes it after the case.
|
|
592
|
+
const layout = await stageDirectCase(context.outputDirectory, {
|
|
593
|
+
inputs,
|
|
594
|
+
config: context.config,
|
|
595
|
+
item: context.item,
|
|
596
|
+
executionId: context.executionId,
|
|
597
|
+
files: context.files,
|
|
598
|
+
});
|
|
332
599
|
const identity = {
|
|
333
600
|
HUE_EXECUTION_ID: context.executionId,
|
|
334
601
|
HUE_ENVIRONMENT_RUN_ID: context.environmentRunId,
|
|
335
602
|
HUE_CASE_ID: context.item.id,
|
|
336
603
|
HUE_CASE_KEY: context.item.externalKey,
|
|
604
|
+
HUE_CASE_DIR: layout.caseDirectory,
|
|
605
|
+
HUE_CASE_INPUTS: layout.inputsPath,
|
|
606
|
+
HUE_CASE_OUTPUT_DIR: layout.outputDirectory,
|
|
337
607
|
};
|
|
338
|
-
|
|
608
|
+
// The token-bearing file is written inside a private directory registered before any of it
|
|
609
|
+
// exists, so a forced exit at any point removes it.
|
|
610
|
+
const configDirectory = context.world
|
|
611
|
+
? mkdtempSync(join(tmpdir(), "hue-mcp-config-"))
|
|
612
|
+
: undefined;
|
|
613
|
+
const untrack = configDirectory
|
|
614
|
+
? onForcedExit(() => rmSync(configDirectory, { recursive: true, force: true }))
|
|
615
|
+
: undefined;
|
|
339
616
|
try {
|
|
617
|
+
const configFile = context.world
|
|
618
|
+
? await writeMcpConfig(context.world, { directory: configDirectory })
|
|
619
|
+
: undefined;
|
|
340
620
|
const env = context.world
|
|
341
621
|
? {
|
|
342
622
|
...agentEnvironment(context.world, { parent, includeHueCredentials: true }),
|
|
@@ -354,15 +634,18 @@ function commandAdapter(command, timeoutSeconds, options) {
|
|
|
354
634
|
: {}),
|
|
355
635
|
...identity,
|
|
356
636
|
};
|
|
357
|
-
|
|
637
|
+
const stdout = await spawnAgentCommand(command, {
|
|
358
638
|
env,
|
|
359
639
|
stdin: JSON.stringify({ inputs, config: context.config }),
|
|
360
640
|
timeoutSeconds,
|
|
361
641
|
...(context.signal ? { signal: context.signal } : {}),
|
|
362
|
-
})
|
|
642
|
+
});
|
|
643
|
+
return collectDirectOutputs(layout.outputDirectory, parseAnswer(stdout));
|
|
363
644
|
}
|
|
364
645
|
finally {
|
|
365
|
-
|
|
646
|
+
if (configDirectory)
|
|
647
|
+
await rm(configDirectory, { recursive: true, force: true });
|
|
648
|
+
untrack?.();
|
|
366
649
|
}
|
|
367
650
|
};
|
|
368
651
|
}
|
|
@@ -412,18 +695,28 @@ const STATE_LABEL = {
|
|
|
412
695
|
skipped: "SKIPPED",
|
|
413
696
|
pending: "PENDING",
|
|
414
697
|
};
|
|
415
|
-
function renderTable(verdicts, output) {
|
|
698
|
+
export function renderTable(verdicts, output) {
|
|
416
699
|
const names = [];
|
|
700
|
+
// The evaluators that report each metric: a case none of them applies to shows n/a there, and
|
|
701
|
+
// one an evaluator applies to without reporting the metric shows "-".
|
|
702
|
+
const reportedBy = new Map();
|
|
417
703
|
for (const item of verdicts.summary.cases)
|
|
418
|
-
for (const metric of item.metrics)
|
|
704
|
+
for (const metric of item.metrics) {
|
|
419
705
|
if (!names.includes(metric.name))
|
|
420
706
|
names.push(metric.name);
|
|
707
|
+
reportedBy.set(metric.name, (reportedBy.get(metric.name) ?? new Set()).add(metric.scorerVersionId));
|
|
708
|
+
}
|
|
421
709
|
const header = ["Case", ...names, "Result"];
|
|
422
710
|
const rows = verdicts.summary.cases.map((item) => [
|
|
423
711
|
item.externalKey,
|
|
424
712
|
...names.map((name) => {
|
|
425
713
|
const metric = item.metrics.find((candidate) => candidate.name === name);
|
|
426
|
-
|
|
714
|
+
if (metric)
|
|
715
|
+
return metricText(metric);
|
|
716
|
+
const notApplicable = new Set(item.notApplicable);
|
|
717
|
+
return [...reportedBy.get(name)].every((version) => notApplicable.has(version))
|
|
718
|
+
? "n/a"
|
|
719
|
+
: "-";
|
|
427
720
|
}),
|
|
428
721
|
STATE_LABEL[item.state],
|
|
429
722
|
]);
|
|
@@ -448,6 +741,9 @@ function renderTable(verdicts, output) {
|
|
|
448
741
|
totals.error ? `${totals.error} error` : "",
|
|
449
742
|
totals.skipped ? `${totals.skipped} skipped` : "",
|
|
450
743
|
totals.pending ? `${totals.pending} pending` : "",
|
|
744
|
+
totals.notApplicable
|
|
745
|
+
? `${totals.notApplicable} evaluator result${totals.notApplicable === 1 ? "" : "s"} not applicable`
|
|
746
|
+
: "",
|
|
451
747
|
].filter(Boolean);
|
|
452
748
|
output.log(`${totals.passed} of ${totals.cases} case${totals.cases === 1 ? "" : "s"} passed${extra.length ? ` (${extra.join(", ")})` : ""}`);
|
|
453
749
|
if (!verdicts.results.complete)
|
|
@@ -466,9 +762,20 @@ function redact(message, secrets) {
|
|
|
466
762
|
text = text.replaceAll(secret, "[redacted]");
|
|
467
763
|
return text;
|
|
468
764
|
}
|
|
469
|
-
function explain(error) {
|
|
765
|
+
export function explain(error) {
|
|
470
766
|
if (error instanceof HueApiError && (error.status === 401 || error.status === 403))
|
|
471
767
|
return `${error.message}. Check that HUE_API_KEY is a "Read and write" project key for this origin.`;
|
|
768
|
+
// An unfinished run keeps the content policy it started with; name the flags that resume it.
|
|
769
|
+
if (error instanceof CheckpointIdentityError && error.startedWith) {
|
|
770
|
+
const flags = [
|
|
771
|
+
error.startedWith.persistResultContent === false ? "--no-output" : "",
|
|
772
|
+
error.startedWith.captureContent === true ? "--content" : "",
|
|
773
|
+
].filter(Boolean);
|
|
774
|
+
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`;
|
|
775
|
+
}
|
|
776
|
+
// The error's own message points at an in-memory report the user cannot reach.
|
|
777
|
+
if (error instanceof HueExportError)
|
|
778
|
+
return `Hue could not accept all telemetry (${describeTelemetryIssues(telemetryIssueCounts(error))})`;
|
|
472
779
|
if (error instanceof Error) {
|
|
473
780
|
const causes = [];
|
|
474
781
|
let cause = error.cause;
|
|
@@ -625,13 +932,19 @@ async function detectDirect(client, pins, mode) {
|
|
|
625
932
|
} while (after);
|
|
626
933
|
return true;
|
|
627
934
|
}
|
|
628
|
-
function toJson(verdicts, runUrl, baseline, extra = {}) {
|
|
935
|
+
function toJson(verdicts, runUrl, baseline, extra = {}, telemetry = []) {
|
|
936
|
+
const notAccepted = new Map(telemetry.map((entry) => [entry.caseId, entry.issues]));
|
|
629
937
|
return {
|
|
630
938
|
experimentId: verdicts.experimentId,
|
|
631
939
|
runId: verdicts.runId,
|
|
632
940
|
runUrl,
|
|
633
941
|
complete: verdicts.results.complete,
|
|
634
|
-
cases: verdicts.summary.cases
|
|
942
|
+
cases: verdicts.summary.cases.map((item) => notAccepted.has(item.caseId)
|
|
943
|
+
? {
|
|
944
|
+
...item,
|
|
945
|
+
telemetry: { code: TELEMETRY_NOT_ACCEPTED, issues: notAccepted.get(item.caseId) },
|
|
946
|
+
}
|
|
947
|
+
: item),
|
|
635
948
|
totals: verdicts.summary.totals,
|
|
636
949
|
...extra,
|
|
637
950
|
...(baseline
|
|
@@ -639,16 +952,52 @@ function toJson(verdicts, runUrl, baseline, extra = {}) {
|
|
|
639
952
|
: {}),
|
|
640
953
|
};
|
|
641
954
|
}
|
|
955
|
+
/** Says, once per execution, that a case failed because Hue did not accept its telemetry, with
|
|
956
|
+
* sanitized counts: as the case completes, or from the report for a case an earlier, interrupted
|
|
957
|
+
* run completed. */
|
|
958
|
+
function telemetryReporter(output) {
|
|
959
|
+
const reported = new Set();
|
|
960
|
+
const report = (entry) => {
|
|
961
|
+
if (reported.has(entry.executionId))
|
|
962
|
+
return;
|
|
963
|
+
reported.add(entry.executionId);
|
|
964
|
+
output.error(`[${entry.caseKey}] telemetry not accepted, case failed: ${describeTelemetryIssues(entry.issues)}`);
|
|
965
|
+
};
|
|
966
|
+
return {
|
|
967
|
+
report,
|
|
968
|
+
rest: (entries) => entries?.forEach(report),
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
/** A case without trace evidence fails whatever its scores say: a scorer that grades only the
|
|
972
|
+
* world or ignores the execution state could still pass it. */
|
|
973
|
+
function withTelemetryFailures(verdicts, telemetry = []) {
|
|
974
|
+
if (!telemetry.length)
|
|
975
|
+
return verdicts;
|
|
976
|
+
const failed = new Set(telemetry.map((entry) => entry.caseId));
|
|
977
|
+
const cases = verdicts.summary.cases.map((item) => failed.has(item.caseId) ? { ...item, state: "error", passed: false } : item);
|
|
978
|
+
const totals = {
|
|
979
|
+
cases: cases.length,
|
|
980
|
+
passed: 0,
|
|
981
|
+
failed: 0,
|
|
982
|
+
error: 0,
|
|
983
|
+
skipped: 0,
|
|
984
|
+
pending: 0,
|
|
985
|
+
notApplicable: verdicts.summary.totals.notApplicable,
|
|
986
|
+
};
|
|
987
|
+
for (const item of cases)
|
|
988
|
+
totals[item.state]++;
|
|
989
|
+
return { ...verdicts, summary: { cases, totals } };
|
|
990
|
+
}
|
|
642
991
|
/** Waits for Hue's verdicts, prints them (table or JSON, with the optional baseline) and returns the exit code. */
|
|
643
992
|
async function reportVerdicts(client, values, run, baselineId, output, signal) {
|
|
644
993
|
const wait = integer("wait", values.wait, 300, 0, 86_400);
|
|
645
994
|
output.log("Waiting for Hue checks...");
|
|
646
|
-
const verdicts = await collectExperimentVerdicts(client, {
|
|
995
|
+
const verdicts = withTelemetryFailures(await collectExperimentVerdicts(client, {
|
|
647
996
|
experimentId: run.experimentId,
|
|
648
997
|
subjectIds: run.subjectIds,
|
|
649
998
|
timeoutMillis: wait * 1000,
|
|
650
999
|
signal,
|
|
651
|
-
});
|
|
1000
|
+
}), run.telemetryNotAccepted);
|
|
652
1001
|
// The wait returns its partial state on abort rather than throwing, so Ctrl+C here must not
|
|
653
1002
|
// fall through to a baseline read and a verdict table that nobody asked to finish.
|
|
654
1003
|
if (signal.aborted) {
|
|
@@ -667,7 +1016,7 @@ async function reportVerdicts(client, values, run, baselineId, output, signal) {
|
|
|
667
1016
|
};
|
|
668
1017
|
}
|
|
669
1018
|
if (values.json) {
|
|
670
|
-
process.stdout.write(`${JSON.stringify(toJson(verdicts, run.runUrl, baseline, run.extra))}\n`);
|
|
1019
|
+
process.stdout.write(`${JSON.stringify(toJson(verdicts, run.runUrl, baseline, run.extra, run.telemetryNotAccepted))}\n`);
|
|
671
1020
|
}
|
|
672
1021
|
else {
|
|
673
1022
|
renderTable(verdicts, output);
|
|
@@ -676,6 +1025,8 @@ async function reportVerdicts(client, values, run, baselineId, output, signal) {
|
|
|
676
1025
|
output.log(`Run: ${run.runUrl}`);
|
|
677
1026
|
}
|
|
678
1027
|
const totals = verdicts.summary.totals;
|
|
1028
|
+
if (run.telemetryNotAccepted?.length)
|
|
1029
|
+
return 1;
|
|
679
1030
|
return verdicts.results.complete && totals.cases > 0 && totals.passed === totals.cases ? 0 : 1;
|
|
680
1031
|
}
|
|
681
1032
|
function parseBaseline(value) {
|
|
@@ -721,6 +1072,7 @@ async function runOnce(values, connection, agents, agent, hue, output, signal) {
|
|
|
721
1072
|
const checkpointDirectory = await prepareCheckpointDirectory(values["checkpoint-dir"], agent.key, project.id, "simulation");
|
|
722
1073
|
const caseKeys = new Map();
|
|
723
1074
|
let runUrl = "";
|
|
1075
|
+
const telemetry = telemetryReporter(output);
|
|
724
1076
|
const report = await runSimulation({
|
|
725
1077
|
client,
|
|
726
1078
|
environmentClient,
|
|
@@ -732,8 +1084,13 @@ async function runOnce(values, connection, agents, agent, hue, output, signal) {
|
|
|
732
1084
|
scorerVersionIds: pins.scorerVersionIds,
|
|
733
1085
|
},
|
|
734
1086
|
runName,
|
|
735
|
-
|
|
1087
|
+
// Outputs, error messages and explanations are stored unless opted out; --content governs
|
|
1088
|
+
// only the telemetry.
|
|
1089
|
+
persistResultContent: !values["no-output"],
|
|
736
1090
|
traceEvidence: { mode: "required" },
|
|
1091
|
+
// A case whose telemetry Hue did not accept fails instead of staying started.
|
|
1092
|
+
traceNotAccepted: "fail_case",
|
|
1093
|
+
onTelemetryNotAccepted: telemetry.report,
|
|
737
1094
|
concurrency,
|
|
738
1095
|
agentRevision: agent.revision,
|
|
739
1096
|
// The CLI adapts to whatever the deployment serves; the library warning is for code that
|
|
@@ -771,10 +1128,12 @@ async function runOnce(values, connection, agents, agent, hue, output, signal) {
|
|
|
771
1128
|
output.log(`[${label}] attempt prepared: ${event.status}`);
|
|
772
1129
|
},
|
|
773
1130
|
});
|
|
1131
|
+
telemetry.rest(report.telemetryNotAccepted);
|
|
774
1132
|
return reportVerdicts(client, values, {
|
|
775
1133
|
experimentId: report.experimentId,
|
|
776
1134
|
subjectIds: report.subjectIds,
|
|
777
1135
|
runUrl: runUrl || report.runUrl,
|
|
1136
|
+
telemetryNotAccepted: report.telemetryNotAccepted,
|
|
778
1137
|
}, baselineId, output, signal);
|
|
779
1138
|
}
|
|
780
1139
|
/**
|
|
@@ -796,6 +1155,7 @@ async function runDirect(run, connection) {
|
|
|
796
1155
|
let experimentId = "";
|
|
797
1156
|
let runUrl = "";
|
|
798
1157
|
let report;
|
|
1158
|
+
const telemetry = telemetryReporter(output);
|
|
799
1159
|
try {
|
|
800
1160
|
const selectionDigest = digest({
|
|
801
1161
|
datasetVersionId: pins.datasetVersionId,
|
|
@@ -830,8 +1190,12 @@ async function runDirect(run, connection) {
|
|
|
830
1190
|
hue: run.hue,
|
|
831
1191
|
experimentId,
|
|
832
1192
|
checkpointDirectory: join(store.directory, experimentId),
|
|
833
|
-
|
|
1193
|
+
// Outputs, error messages and explanations are stored unless opted out; --content governs
|
|
1194
|
+
// only the telemetry.
|
|
1195
|
+
persistResultContent: !values["no-output"],
|
|
834
1196
|
traceEvidence: { mode: "required" },
|
|
1197
|
+
traceNotAccepted: "fail_case",
|
|
1198
|
+
onTelemetryNotAccepted: telemetry.report,
|
|
835
1199
|
concurrency: run.concurrency,
|
|
836
1200
|
scorers: [],
|
|
837
1201
|
deferUnboundLocalScorers: true,
|
|
@@ -865,11 +1229,13 @@ async function runDirect(run, connection) {
|
|
|
865
1229
|
}
|
|
866
1230
|
if (report.deferredScorerVersionIds.length)
|
|
867
1231
|
output.log(`${report.deferredScorerVersionIds.length} evaluator version${report.deferredScorerVersionIds.length === 1 ? "" : "s"} left to Hue's executor`);
|
|
1232
|
+
telemetry.rest(report.telemetryNotAccepted);
|
|
868
1233
|
return reportVerdicts(client, values, {
|
|
869
1234
|
experimentId,
|
|
870
1235
|
subjectIds: report.subjectIds,
|
|
871
1236
|
runUrl,
|
|
872
1237
|
extra: { mode: "direct", deferredScorerVersionIds: report.deferredScorerVersionIds },
|
|
1238
|
+
telemetryNotAccepted: report.telemetryNotAccepted,
|
|
873
1239
|
}, run.baselineId, output, signal);
|
|
874
1240
|
}
|
|
875
1241
|
async function runWorker(values, connection, adapter, agent, hue, output, signal) {
|
|
@@ -890,6 +1256,7 @@ async function runWorker(values, connection, adapter, agent, hue, output, signal
|
|
|
890
1256
|
current = claim;
|
|
891
1257
|
output.log(`Claimed run ${claim.runId}: ${new URL(`/experiments/${claim.experimentId}`, connection.baseUrl).toString()}`);
|
|
892
1258
|
};
|
|
1259
|
+
const telemetry = telemetryReporter(output);
|
|
893
1260
|
await runLocalAgent({
|
|
894
1261
|
client,
|
|
895
1262
|
environmentClient,
|
|
@@ -899,10 +1266,12 @@ async function runWorker(values, connection, adapter, agent, hue, output, signal
|
|
|
899
1266
|
key: agent.key,
|
|
900
1267
|
name: agent.name,
|
|
901
1268
|
revision: agent.revision,
|
|
902
|
-
capabilities: ["environment:v1"],
|
|
1269
|
+
capabilities: ["environment:v1", ...(values.capability ?? [])],
|
|
903
1270
|
},
|
|
904
1271
|
scorers: [],
|
|
905
1272
|
concurrency,
|
|
1273
|
+
traceNotAccepted: "fail_case",
|
|
1274
|
+
onTelemetryNotAccepted: telemetry.report,
|
|
906
1275
|
deprecationWarnings: false,
|
|
907
1276
|
signal,
|
|
908
1277
|
...(maxRuns === undefined ? {} : { maxRuns }),
|
|
@@ -917,25 +1286,28 @@ async function runWorker(values, connection, adapter, agent, hue, output, signal
|
|
|
917
1286
|
...(context.world ? { world: context.world } : {}),
|
|
918
1287
|
...(context.mcp ? { mcp: context.mcp } : {}),
|
|
919
1288
|
...(context.connectionBundle ? { connectionBundle: context.connectionBundle } : {}),
|
|
1289
|
+
files: context.files,
|
|
1290
|
+
outputDirectory: context.outputDirectory,
|
|
920
1291
|
signal,
|
|
921
1292
|
});
|
|
922
1293
|
},
|
|
923
1294
|
async onCompleted(report) {
|
|
924
1295
|
output.log(`Run ${report.runId} completed: ${report.subjectIds.length} case${report.subjectIds.length === 1 ? "" : "s"}`);
|
|
1296
|
+
telemetry.rest(report.telemetryNotAccepted);
|
|
925
1297
|
if (!current)
|
|
926
1298
|
return;
|
|
927
1299
|
output.log("Waiting for Hue checks...");
|
|
928
1300
|
try {
|
|
929
|
-
const verdicts = await collectExperimentVerdicts(client, {
|
|
1301
|
+
const verdicts = withTelemetryFailures(await collectExperimentVerdicts(client, {
|
|
930
1302
|
experimentId: current.experimentId,
|
|
931
1303
|
subjectIds: report.subjectIds,
|
|
932
1304
|
timeoutMillis: wait * 1000,
|
|
933
1305
|
signal,
|
|
934
|
-
});
|
|
1306
|
+
}), report.telemetryNotAccepted);
|
|
935
1307
|
if (signal.aborted)
|
|
936
1308
|
return;
|
|
937
1309
|
if (values.json)
|
|
938
|
-
process.stdout.write(`${JSON.stringify(toJson(verdicts, new URL(`/experiments/${current.experimentId}`, connection.baseUrl).toString()))}\n`);
|
|
1310
|
+
process.stdout.write(`${JSON.stringify(toJson(verdicts, new URL(`/experiments/${current.experimentId}`, connection.baseUrl).toString(), undefined, {}, report.telemetryNotAccepted))}\n`);
|
|
939
1311
|
else
|
|
940
1312
|
renderTable(verdicts, output);
|
|
941
1313
|
}
|
|
@@ -958,9 +1330,31 @@ async function runWorker(values, connection, adapter, agent, hue, output, signal
|
|
|
958
1330
|
export async function runEvalCommand(argv) {
|
|
959
1331
|
const secrets = [];
|
|
960
1332
|
const controller = new AbortController();
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1333
|
+
let firstInterrupt = 0;
|
|
1334
|
+
// The first interrupt stops the agents within their grace; a repeated one kills their process
|
|
1335
|
+
// groups at once and exits, so no agent is left running with its world token. `npm run` and
|
|
1336
|
+
// `npx` forward the terminal's SIGINT a moment after the terminal delivers it, so a repeat
|
|
1337
|
+
// within 50 ms is the same Ctrl+C.
|
|
1338
|
+
const interrupt = () => {
|
|
1339
|
+
if (!controller.signal.aborted) {
|
|
1340
|
+
firstInterrupt = performance.now();
|
|
1341
|
+
if (runningCommands.size)
|
|
1342
|
+
process.stderr.write("Stopping the agent; press Ctrl+C again to force.\n");
|
|
1343
|
+
controller.abort(new Error("Interrupted"));
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
if (performance.now() - firstInterrupt < 50)
|
|
1347
|
+
return;
|
|
1348
|
+
for (const command of runningCommands)
|
|
1349
|
+
command.kill();
|
|
1350
|
+
// The exit cannot wait for the normal cleanup: remove the world cases' files, the MCP
|
|
1351
|
+
// configurations holding world tokens and the checkpoint locks now.
|
|
1352
|
+
runForcedExitCleanups();
|
|
1353
|
+
process.stderr.write("Interrupted.\n");
|
|
1354
|
+
process.exit(130);
|
|
1355
|
+
};
|
|
1356
|
+
process.on("SIGINT", interrupt);
|
|
1357
|
+
process.on("SIGTERM", interrupt);
|
|
964
1358
|
let hue;
|
|
965
1359
|
let json = false;
|
|
966
1360
|
try {
|
|
@@ -1014,23 +1408,23 @@ export async function runEvalCommand(argv) {
|
|
|
1014
1408
|
};
|
|
1015
1409
|
if (values.worker && (values.mode || values["set-version"] || values.scorer?.length))
|
|
1016
1410
|
throw new UsageError("--worker takes no selection; Hue chooses the run to execute");
|
|
1411
|
+
if (values.worker && values["no-output"])
|
|
1412
|
+
throw new UsageError("--no-output applies to one-shot runs; a run launched from Hue always stores its outputs");
|
|
1413
|
+
if (!values.worker && values.capability?.length)
|
|
1414
|
+
throw new UsageError("--capability applies to --worker only");
|
|
1017
1415
|
const loaded = adapterFile ? await loadAdapter(adapterFile) : undefined;
|
|
1018
1416
|
// One adapter module serves both case kinds; the direct context announces itself with `mode`.
|
|
1417
|
+
// Outputs are stored by default, so every answer and error message is cleared of the
|
|
1418
|
+
// credentials the case handed the agent before it leaves the CLI.
|
|
1019
1419
|
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, {
|
|
1420
|
+
simulation: redacting(loaded ??
|
|
1421
|
+
commandAdapter(values.command, timeout, {
|
|
1028
1422
|
allowHueCredentials: values["allow-hue-credentials"],
|
|
1029
|
-
}),
|
|
1030
|
-
direct: loaded ??
|
|
1423
|
+
})),
|
|
1424
|
+
direct: redacting(loaded ??
|
|
1031
1425
|
directCommandAdapter(values.command, timeout, {
|
|
1032
1426
|
allowHueCredentials: values["allow-hue-credentials"],
|
|
1033
|
-
}),
|
|
1427
|
+
})),
|
|
1034
1428
|
};
|
|
1035
1429
|
hue = createHue({ apiKey, baseUrl, serviceName: key, captureContent: values.content });
|
|
1036
1430
|
return values.worker
|
|
@@ -1050,6 +1444,9 @@ export async function runEvalCommand(argv) {
|
|
|
1050
1444
|
return 1;
|
|
1051
1445
|
}
|
|
1052
1446
|
finally {
|
|
1447
|
+
// The handlers stay until every agent command has settled, so an interrupt during a stop's
|
|
1448
|
+
// grace still reaches it.
|
|
1449
|
+
await settleCommands();
|
|
1053
1450
|
process.removeListener("SIGINT", interrupt);
|
|
1054
1451
|
process.removeListener("SIGTERM", interrupt);
|
|
1055
1452
|
if (hue)
|