@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/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,138 @@ 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
+ 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
- const configFile = context.world ? await writeMcpConfig(context.world) : undefined;
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
- return parseAnswer(await spawnAgentCommand(command, {
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
- await configFile?.dispose();
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
- return metric ? metricText(metric) : "-";
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
- persistResultContent: values.content,
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
- persistResultContent: values.content,
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
- const interrupt = () => controller.abort(new Error("Interrupted"));
962
- process.once("SIGINT", interrupt);
963
- process.once("SIGTERM", interrupt);
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
- ? 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, {
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)