@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.
Files changed (56) hide show
  1. package/CLI.md +240 -49
  2. package/ENVIRONMENTS.md +48 -9
  3. package/EVALUATIONS.md +41 -21
  4. package/README.md +70 -30
  5. package/dist/cli/eval-direct.d.ts +12 -1
  6. package/dist/cli/eval-direct.js +145 -33
  7. package/dist/cli/eval.d.ts +28 -1
  8. package/dist/cli/eval.js +504 -81
  9. package/dist/cli/listen.d.ts +76 -0
  10. package/dist/cli/listen.js +816 -0
  11. package/dist/cli/mcp.d.ts +65 -7
  12. package/dist/cli/mcp.js +271 -73
  13. package/dist/client.d.ts +7 -4
  14. package/dist/client.js +36 -10
  15. package/dist/evals/checkpoint.d.ts +15 -1
  16. package/dist/evals/checkpoint.js +34 -3
  17. package/dist/evals/client.d.ts +24 -4
  18. package/dist/evals/client.js +92 -38
  19. package/dist/evals/environment-target.d.ts +18 -3
  20. package/dist/evals/environment-target.js +22 -10
  21. package/dist/evals/exit-cleanup.d.ts +4 -0
  22. package/dist/evals/exit-cleanup.js +26 -0
  23. package/dist/evals/files.d.ts +69 -3
  24. package/dist/evals/files.js +255 -42
  25. package/dist/evals/json.js +116 -9
  26. package/dist/evals/local-worker.d.ts +21 -3
  27. package/dist/evals/local-worker.js +14 -0
  28. package/dist/evals/runner.d.ts +45 -0
  29. package/dist/evals/runner.js +199 -21
  30. package/dist/evals/scenarios.d.ts +7 -5
  31. package/dist/evals/scenarios.js +71 -14
  32. package/dist/evals/scorer-publication.js +58 -3
  33. package/dist/evals/simulation.d.ts +17 -4
  34. package/dist/evals/simulation.js +8 -0
  35. package/dist/evals/types.d.ts +48 -0
  36. package/dist/evals/verdicts.d.ts +26 -2
  37. package/dist/evals/verdicts.js +72 -9
  38. package/dist/evals.d.ts +4 -3
  39. package/dist/evals.js +2 -2
  40. package/dist/inline-files.d.ts +4 -0
  41. package/dist/inline-files.js +48 -17
  42. package/dist/provider-tools.d.ts +12 -1
  43. package/dist/provider-tools.js +192 -106
  44. package/dist/safety.d.ts +7 -1
  45. package/dist/safety.js +9 -7
  46. package/dist/setup/cli.js +31 -8
  47. package/dist/setup/peers.d.ts +13 -0
  48. package/dist/setup/peers.js +44 -0
  49. package/dist/snapshot.d.ts +2 -2
  50. package/dist/snapshot.js +47 -12
  51. package/dist/tool-definitions.d.ts +18 -0
  52. package/dist/tool-definitions.js +232 -0
  53. package/dist/transport.js +2 -2
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. 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 { access, mkdir, writeFile } from "node:fs/promises";
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; one-shot also persists
68
- outputs/explanations (--worker always persists them)
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. Windows has no
227
- * process group to signal, so the child alone is stopped there.
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 escalation;
244
- const signalTree = (signal) => {
245
- if (child.exitCode !== null || child.signalCode !== null)
246
- return;
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
- if (group && child.pid !== undefined)
249
- process.kill(-child.pid, signal);
250
- else
251
- child.kill(signal);
303
+ process.kill(-child.pid, 0);
304
+ return true;
252
305
  }
253
- catch {
254
- // The group is already gone; nothing is left to stop.
306
+ catch (error) {
307
+ gone(error, 0);
308
+ return !groupGone;
255
309
  }
256
310
  };
257
- const stop = (signal) => {
258
- signalTree(signal);
259
- escalation ??= setTimeout(() => signalTree("SIGKILL"), COMMAND_KILL_GRACE_MS).unref();
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("SIGTERM");
368
+ stop();
264
369
  }, options.timeoutSeconds * 1000);
265
- const cancel = () => stop("SIGTERM");
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("SIGTERM");
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
- clearTimeout(timer);
283
- clearTimeout(escalation);
284
- options.signal?.removeEventListener("abort", cancel);
390
+ if (settled)
391
+ return;
392
+ release();
285
393
  reject(new Error(`Unable to start the agent command: ${error.message}`));
286
394
  });
287
- child.on("close", (code, signal) => {
288
- clearTimeout(timer);
289
- clearTimeout(escalation);
290
- options.signal?.removeEventListener("abort", cancel);
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
- * Runs the shell command once per case. The world token and mirror URLs travel only through the
317
- * child's environment and an owner-only MCP configuration file that is removed after the run;
318
- * Hue control-plane credentials (`HUE_API_KEY`, `HUE_MCP_KEY`, any `hue_sk_` value) stay with
319
- * the CLI unless `--allow-hue-credentials` is passed. A world created while the gateway is off
320
- * still gets the `hue_sim_` capability under the same `HUE_MCP_*` names.
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
- const configFile = context.world ? await writeMcpConfig(context.world) : undefined;
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
- return parseAnswer(await spawnAgentCommand(command, {
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
- await configFile?.dispose();
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 names = [];
417
- for (const item of verdicts.summary.cases)
418
- for (const metric of item.metrics)
419
- if (!names.includes(metric.name))
420
- names.push(metric.name);
421
- const header = ["Case", ...names, "Result"];
422
- const rows = verdicts.summary.cases.map((item) => [
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
- ...names.map((name) => {
425
- const metric = item.metrics.find((candidate) => candidate.name === name);
426
- return metric ? metricText(metric) : "-";
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
- persistResultContent: values.content,
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
- persistResultContent: values.content,
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
- const interrupt = () => controller.abort(new Error("Interrupted"));
962
- process.once("SIGINT", interrupt);
963
- process.once("SIGTERM", interrupt);
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
- ? async (inputs, context) => {
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({ apiKey, baseUrl, serviceName: key, captureContent: values.content });
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)