@hue-run/sdk 0.4.2 → 0.5.1

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.
@@ -0,0 +1,985 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { access, mkdir, writeFile } from "node:fs/promises";
3
+ import { basename, extname, join, resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { parseArgs } from "node:util";
6
+ import { randomUUID } from "node:crypto";
7
+ import { createHue } from "../client.js";
8
+ import { createEnvironmentClient } from "../environment/client.js";
9
+ import { EvaluationClient, HueApiError } from "../evals/client.js";
10
+ import { TargetResult } from "../evals/types.js";
11
+ import { CheckpointStore } from "../evals/checkpoint.js";
12
+ import { digest } from "../evals/json.js";
13
+ import { runLocalAgent } from "../evals/local-worker.js";
14
+ import { runExperiment, TargetCancelledError } from "../evals/runner.js";
15
+ import { matchByName, parseScenarioSelector, resolveEvalSetPins, resolveScenarioPins, } from "../evals/scenarios.js";
16
+ import { collectDirectOutputs, stageDirectCase } from "./eval-direct.js";
17
+ import { runSimulation, } from "../evals/simulation.js";
18
+ import { collectExperimentVerdicts, compareVerdicts, metricPassed, } from "../evals/verdicts.js";
19
+ const USAGE = `Usage: hue eval [adapter-file] [options]
20
+
21
+ Run a local agent against a Hue Scenario or eval set, then print Hue's verdicts.
22
+
23
+ Selection (exactly one, not used with --worker):
24
+ --scenario <name|id|url> Published Scenario to run
25
+ --set <name|id|url> Saved eval set; requires --scorer or --scorer-version
26
+ --set-version <n> Saved version number of the eval set (default: latest saved)
27
+ --dataset-version <id> Frozen dataset version; requires --scorer or --scorer-version
28
+ --scorer <slug|name|id> Evaluator to pin at its latest published version (repeatable)
29
+ --scorer-version <id> Scorer version to pin (repeatable)
30
+ --mode <auto|direct|simulation> Case kind; auto picks direct for sets whose cases pin no world
31
+
32
+ Agent (exactly one):
33
+ <adapter-file> Module exporting default or runMyAgent(inputs, context)
34
+ --command "<shell command>" Simulation: spawned per case with HUE_MCP_URL, HUE_MCP_TOKEN,
35
+ HUE_MCP_EXPIRES_AT, HUE_EXECUTION_ID, HUE_ENVIRONMENT_RUN_ID,
36
+ HUE_CASE_ID and HUE_CASE_KEY set; {"inputs","config"} on stdin.
37
+ Direct: spawned in a private case directory with HUE_CASE_DIR,
38
+ HUE_CASE_INPUTS, HUE_CASE_OUTPUT_DIR, HUE_CASE_ID, HUE_CASE_KEY
39
+ and HUE_EXECUTION_ID set; files/<role>/ hold the pinned inputs
40
+ and every file written to output/ is uploaded to Hue
41
+
42
+ Modes:
43
+ --worker Register the agent and poll for runs launched from Hue
44
+ --max-runs <n> Stop the worker after n completed runs
45
+ --agent-key <key> Agent key (default: slug of the adapter filename)
46
+ --agent-name <name> Agent display name (default: the key)
47
+ --revision <id> Agent revision (default: AGENT_REVISION, git HEAD or "dev")
48
+
49
+ Connection:
50
+ --env-file <path> Load a dotenv file (HUE_API_KEY, HUE_BASE_URL) first
51
+ --origin <url> Hue origin (default: HUE_BASE_URL or https://app.hue.run)
52
+
53
+ Output and limits:
54
+ --name <run name> Experiment name (default: <scenario> · <agent key> · <revision>)
55
+ --baseline <experiment id|url> Compare verdicts with a previous experiment
56
+ --json Print one JSON document on stdout; progress goes to stderr
57
+ --content Capture telemetry content; one-shot also persists
58
+ outputs/explanations (--worker always persists them)
59
+ --save-version Freeze an unsaved eval-set version before running
60
+ --checkpoint-dir <path> Private checkpoint directory (default: .hue/eval/<agent-key>)
61
+ --concurrency <n> Cases in flight, 1-16 (default: 1)
62
+ --timeout <seconds> Per-case --command timeout (default: 600)
63
+ --wait <seconds> Verdict wait after the run finishes (default: 300)
64
+ -h, --help Show this help
65
+
66
+ HUE_API_KEY must be a "Read and write" project key; it is never printed.
67
+ Code evaluators pinned to a direct run are graded by Hue's executor after the upload; the wait
68
+ covers them. Exit codes: 0 every case passed, 1 a case failed, errored or is incomplete,
69
+ 2 usage error, 130 interrupted.
70
+ `;
71
+ /** Thrown for invalid arguments or configuration; exits with status 2. */
72
+ class UsageError extends Error {
73
+ constructor(message) {
74
+ super(message);
75
+ this.name = "UsageError";
76
+ }
77
+ }
78
+ function parse(argv) {
79
+ try {
80
+ return parseArgs({
81
+ args: argv,
82
+ allowPositionals: true,
83
+ strict: true,
84
+ options: {
85
+ scenario: { type: "string" },
86
+ set: { type: "string" },
87
+ "set-version": { type: "string" },
88
+ "dataset-version": { type: "string" },
89
+ scorer: { type: "string", multiple: true },
90
+ "scorer-version": { type: "string", multiple: true },
91
+ mode: { type: "string" },
92
+ command: { type: "string" },
93
+ worker: { type: "boolean", default: false },
94
+ "max-runs": { type: "string" },
95
+ "agent-key": { type: "string" },
96
+ "agent-name": { type: "string" },
97
+ revision: { type: "string" },
98
+ "env-file": { type: "string" },
99
+ origin: { type: "string" },
100
+ name: { type: "string" },
101
+ baseline: { type: "string" },
102
+ json: { type: "boolean", default: false },
103
+ content: { type: "boolean", default: false },
104
+ "save-version": { type: "boolean", default: false },
105
+ "checkpoint-dir": { type: "string" },
106
+ concurrency: { type: "string" },
107
+ timeout: { type: "string" },
108
+ wait: { type: "string" },
109
+ help: { type: "boolean", short: "h", default: false },
110
+ },
111
+ });
112
+ }
113
+ catch (error) {
114
+ throw new UsageError(error instanceof Error ? error.message : "Invalid arguments");
115
+ }
116
+ }
117
+ function integer(name, value, fallback, min, max) {
118
+ if (value === undefined)
119
+ return fallback;
120
+ const parsed = Number(value);
121
+ if (!Number.isInteger(parsed) || parsed < min || parsed > max)
122
+ throw new UsageError(`--${name} must be an integer between ${min} and ${max}`);
123
+ return parsed;
124
+ }
125
+ function slug(value) {
126
+ return value
127
+ .toLowerCase()
128
+ .replace(/[^a-z0-9]+/g, "-")
129
+ .replace(/^-+|-+$/g, "")
130
+ .slice(0, 64);
131
+ }
132
+ const INTERPRETERS = new Set([
133
+ "node",
134
+ "nodejs",
135
+ "bun",
136
+ "bunx",
137
+ "deno",
138
+ "npx",
139
+ "pnpx",
140
+ "yarn",
141
+ "pnpm",
142
+ "npm",
143
+ "tsx",
144
+ "ts-node",
145
+ "python",
146
+ "python3",
147
+ "uv",
148
+ "uvx",
149
+ "sh",
150
+ "bash",
151
+ "zsh",
152
+ "env",
153
+ ]);
154
+ /** Agent key from the adapter filename, or the first script-like token of a command. */
155
+ function derivedAgentKey(adapterFile, command) {
156
+ if (adapterFile)
157
+ return slug(basename(adapterFile, extname(adapterFile)));
158
+ const tokens = (command ?? "").trim().split(/\s+/);
159
+ const names = tokens.map((token) => token.split(/[\\/]/).pop() ?? "");
160
+ const script = names.find((name) => name && !name.startsWith("-") && !INTERPRETERS.has(name.toLowerCase())) ??
161
+ names[0] ??
162
+ "";
163
+ return slug(basename(script, extname(script)));
164
+ }
165
+ function gitRevision() {
166
+ try {
167
+ const value = execFileSync("git", ["rev-parse", "--short=12", "HEAD"], {
168
+ encoding: "utf8",
169
+ stdio: ["ignore", "pipe", "ignore"],
170
+ timeout: 5000,
171
+ }).trim();
172
+ return /^[0-9a-f]{7,40}$/.test(value) ? value : undefined;
173
+ }
174
+ catch {
175
+ return undefined;
176
+ }
177
+ }
178
+ async function loadAdapter(file) {
179
+ const path = resolve(file);
180
+ try {
181
+ await access(path);
182
+ }
183
+ catch {
184
+ throw new UsageError(`Adapter file not found: ${file}`);
185
+ }
186
+ let loaded;
187
+ try {
188
+ loaded = (await import(pathToFileURL(path).href));
189
+ }
190
+ catch (error) {
191
+ const code = error.code;
192
+ const hint = code === "ERR_UNKNOWN_FILE_EXTENSION" ||
193
+ code === "ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING"
194
+ ? " (this Node.js version needs --experimental-strip-types or --import tsx for TypeScript adapters; non-erasable syntax such as enums or parameter properties always needs a loader)"
195
+ : "";
196
+ throw new Error(`Unable to load the adapter ${file}: ${error instanceof Error ? error.message : String(error)}${hint}`);
197
+ }
198
+ const candidate = loaded.default ?? loaded.runMyAgent;
199
+ if (typeof candidate !== "function")
200
+ throw new UsageError(`${file} must export a default function or runMyAgent(inputs, context)`);
201
+ return candidate;
202
+ }
203
+ /** Grace between the stop signal and SIGKILL for an agent command that ignores SIGTERM. */
204
+ const COMMAND_KILL_GRACE_MS = 5_000;
205
+ /**
206
+ * Spawns the agent command once in its own process group and returns its trimmed stdout. A
207
+ * timeout or Ctrl+C stops the agent the shell started, not only the shell: a survivor would still
208
+ * hold a world token and could write after Hue recorded the case as failed. Windows has no
209
+ * process group to signal, so the child alone is stopped there.
210
+ */
211
+ function spawnAgentCommand(command, options) {
212
+ return new Promise((resolvePromise, reject) => {
213
+ const group = process.platform !== "win32";
214
+ const child = spawn(command, {
215
+ shell: true,
216
+ detached: group,
217
+ ...(options.cwd ? { cwd: options.cwd } : {}),
218
+ env: options.env,
219
+ stdio: ["pipe", "pipe", "inherit"],
220
+ });
221
+ const chunks = [];
222
+ let size = 0;
223
+ let timedOut = false;
224
+ let oversized = false;
225
+ let escalation;
226
+ const signalTree = (signal) => {
227
+ if (child.exitCode !== null || child.signalCode !== null)
228
+ return;
229
+ try {
230
+ if (group && child.pid !== undefined)
231
+ process.kill(-child.pid, signal);
232
+ else
233
+ child.kill(signal);
234
+ }
235
+ catch {
236
+ // The group is already gone; nothing is left to stop.
237
+ }
238
+ };
239
+ const stop = (signal) => {
240
+ signalTree(signal);
241
+ escalation ??= setTimeout(() => signalTree("SIGKILL"), COMMAND_KILL_GRACE_MS).unref();
242
+ };
243
+ const timer = setTimeout(() => {
244
+ timedOut = true;
245
+ stop("SIGTERM");
246
+ }, options.timeoutSeconds * 1000);
247
+ const cancel = () => stop("SIGTERM");
248
+ options.signal?.addEventListener("abort", cancel, { once: true });
249
+ child.stdout.on("data", (chunk) => {
250
+ size += chunk.byteLength;
251
+ if (size > 4 * 1024 * 1024) {
252
+ oversized = true;
253
+ stop("SIGTERM");
254
+ return;
255
+ }
256
+ chunks.push(chunk);
257
+ });
258
+ child.stdin.on("error", () => undefined);
259
+ if (options.stdin !== undefined)
260
+ child.stdin.end(options.stdin);
261
+ else
262
+ child.stdin.end();
263
+ child.on("error", (error) => {
264
+ clearTimeout(timer);
265
+ clearTimeout(escalation);
266
+ options.signal?.removeEventListener("abort", cancel);
267
+ reject(new Error(`Unable to start the agent command: ${error.message}`));
268
+ });
269
+ child.on("close", (code, signal) => {
270
+ clearTimeout(timer);
271
+ clearTimeout(escalation);
272
+ options.signal?.removeEventListener("abort", cancel);
273
+ if (options.signal?.aborted)
274
+ return reject(new TargetCancelledError());
275
+ if (timedOut)
276
+ return reject(new Error(`The agent command timed out after ${options.timeoutSeconds} seconds`));
277
+ if (oversized)
278
+ return reject(new Error("The agent command printed more than 4 MiB"));
279
+ if (code !== 0)
280
+ return reject(new Error(signal
281
+ ? `The agent command was stopped by ${signal}`
282
+ : `The agent command exited with code ${code}`));
283
+ resolvePromise(Buffer.concat(chunks).toString("utf8").trim());
284
+ });
285
+ });
286
+ }
287
+ function parseAnswer(text) {
288
+ if (!text)
289
+ return undefined;
290
+ try {
291
+ return JSON.parse(text);
292
+ }
293
+ catch {
294
+ return text;
295
+ }
296
+ }
297
+ /** Runs the shell command once per case; the MCP token travels only through the child's environment. */
298
+ function commandAdapter(command, timeoutSeconds) {
299
+ return async (inputs, context) => parseAnswer(await spawnAgentCommand(command, {
300
+ env: {
301
+ ...process.env,
302
+ HUE_MCP_URL: context.mcp.url,
303
+ HUE_MCP_TOKEN: context.mcp.token,
304
+ HUE_MCP_EXPIRES_AT: context.mcp.expiresAt,
305
+ HUE_EXECUTION_ID: context.executionId,
306
+ HUE_ENVIRONMENT_RUN_ID: context.environmentRunId,
307
+ HUE_CASE_ID: context.item.id,
308
+ HUE_CASE_KEY: context.item.externalKey,
309
+ },
310
+ stdin: JSON.stringify({ inputs, config: context.config }),
311
+ timeoutSeconds,
312
+ ...(context.signal ? { signal: context.signal } : {}),
313
+ }));
314
+ }
315
+ /**
316
+ * Direct cases: the command works in a private case directory and writes its documents to
317
+ * `output/`. Its stdout is only used as the JSON output when it wrote no result or summary file.
318
+ */
319
+ function directCommandAdapter(command, timeoutSeconds) {
320
+ return async (inputs, context) => {
321
+ const layout = await stageDirectCase(context.outputDirectory, {
322
+ inputs,
323
+ config: context.config,
324
+ item: context.item,
325
+ executionId: context.executionId,
326
+ files: context.files,
327
+ });
328
+ const stdout = await spawnAgentCommand(command, {
329
+ cwd: layout.caseDirectory,
330
+ env: {
331
+ ...process.env,
332
+ HUE_CASE_DIR: layout.caseDirectory,
333
+ HUE_CASE_INPUTS: layout.inputsPath,
334
+ HUE_CASE_OUTPUT_DIR: layout.outputDirectory,
335
+ HUE_CASE_ID: context.item.id,
336
+ HUE_CASE_KEY: context.item.externalKey,
337
+ HUE_EXECUTION_ID: context.executionId,
338
+ },
339
+ stdin: JSON.stringify({ inputs, config: context.config }),
340
+ timeoutSeconds,
341
+ ...(context.signal ? { signal: context.signal } : {}),
342
+ });
343
+ return collectDirectOutputs(layout.outputDirectory, parseAnswer(stdout));
344
+ };
345
+ }
346
+ function metricText(metric) {
347
+ if (typeof metric.value === "boolean")
348
+ return metricPassed(metric) ? "PASS" : "FAIL";
349
+ if (typeof metric.value === "number")
350
+ return Number.isInteger(metric.value) ? String(metric.value) : metric.value.toFixed(3);
351
+ const text = metric.value.replace(/\s+/g, " ");
352
+ return text.length > 40 ? `${text.slice(0, 37)}...` : text;
353
+ }
354
+ const STATE_LABEL = {
355
+ passed: "PASSED",
356
+ failed: "FAILED",
357
+ error: "ERROR",
358
+ skipped: "SKIPPED",
359
+ pending: "PENDING",
360
+ };
361
+ function renderTable(verdicts, output) {
362
+ const names = [];
363
+ for (const item of verdicts.summary.cases)
364
+ for (const metric of item.metrics)
365
+ if (!names.includes(metric.name))
366
+ names.push(metric.name);
367
+ const header = ["Case", ...names, "Result"];
368
+ const rows = verdicts.summary.cases.map((item) => [
369
+ item.externalKey,
370
+ ...names.map((name) => {
371
+ const metric = item.metrics.find((candidate) => candidate.name === name);
372
+ return metric ? metricText(metric) : "-";
373
+ }),
374
+ STATE_LABEL[item.state],
375
+ ]);
376
+ const widths = header.map((cell, index) => Math.max(cell.length, ...rows.map((row) => row[index].length)));
377
+ const line = (cells) => cells
378
+ .map((cell, index) => cell.padEnd(widths[index]))
379
+ .join(" ")
380
+ .trimEnd();
381
+ output.log(line(header));
382
+ output.log(widths.map((width) => "-".repeat(width)).join(" "));
383
+ for (const row of rows)
384
+ output.log(line(row));
385
+ for (const item of verdicts.summary.cases) {
386
+ if (item.state === "passed")
387
+ continue;
388
+ const details = [...item.errors.map((type) => `scorer error ${type}`), ...item.explanations];
389
+ for (const detail of details.length ? details : [STATE_LABEL[item.state].toLowerCase()])
390
+ output.log(` ${item.externalKey}: ${detail}`);
391
+ }
392
+ const totals = verdicts.summary.totals;
393
+ const extra = [
394
+ totals.error ? `${totals.error} error` : "",
395
+ totals.skipped ? `${totals.skipped} skipped` : "",
396
+ totals.pending ? `${totals.pending} pending` : "",
397
+ ].filter(Boolean);
398
+ output.log(`${totals.passed} of ${totals.cases} case${totals.cases === 1 ? "" : "s"} passed${extra.length ? ` (${extra.join(", ")})` : ""}`);
399
+ if (!verdicts.results.complete)
400
+ output.log("Hue checks are still running; rerun with a longer --wait or open the run URL.");
401
+ }
402
+ function renderComparison(baselineId, comparison, output) {
403
+ output.log(`Baseline ${baselineId}: ${comparison.improvements} improved, ${comparison.regressions} regressed, ${comparison.unchanged} unchanged`);
404
+ for (const item of comparison.cases)
405
+ if (item.change !== "unchanged")
406
+ output.log(` ${item.externalKey}: ${item.before} -> ${item.after} (${item.change})`);
407
+ }
408
+ function redact(message, secrets) {
409
+ let text = message;
410
+ for (const secret of secrets)
411
+ if (secret)
412
+ text = text.replaceAll(secret, "[redacted]");
413
+ return text;
414
+ }
415
+ function explain(error) {
416
+ if (error instanceof HueApiError && (error.status === 401 || error.status === 403))
417
+ return `${error.message}. Check that HUE_API_KEY is a "Read and write" project key for this origin.`;
418
+ if (error instanceof Error) {
419
+ const causes = [];
420
+ let cause = error.cause;
421
+ while (cause instanceof Error && causes.length < 3) {
422
+ causes.push(cause.message);
423
+ cause = cause.cause;
424
+ }
425
+ return causes.length ? `${error.message} (${causes.join("; ")})` : error.message;
426
+ }
427
+ return String(error);
428
+ }
429
+ /** `.hue/eval/<agent-key>/<project>/<leaf>` unless overridden; the SDK binds each store to one
430
+ * project and origin, so switching keys or deployments must not collide. */
431
+ async function prepareCheckpointDirectory(explicit, agentKey, projectId, leaf) {
432
+ if (explicit)
433
+ return join(resolve(explicit), projectId, leaf);
434
+ const root = resolve(".hue", "eval");
435
+ await mkdir(root, { recursive: true, mode: 0o700 });
436
+ const ignore = join(root, ".gitignore");
437
+ try {
438
+ await access(ignore);
439
+ }
440
+ catch {
441
+ await writeFile(ignore, "*\n", { flag: "wx", mode: 0o600 }).catch(() => undefined);
442
+ }
443
+ return join(root, agentKey, projectId, leaf);
444
+ }
445
+ /** Worker-side client that reports registration and claims without changing the worker. */
446
+ class ObservedClient extends EvaluationClient {
447
+ registered = false;
448
+ onRegistered;
449
+ onClaimed;
450
+ async registerLocalAgent(input) {
451
+ const agent = await super.registerLocalAgent(input);
452
+ if (!this.registered) {
453
+ this.registered = true;
454
+ this.onRegistered?.(agent);
455
+ }
456
+ return agent;
457
+ }
458
+ async claimLocalAgentRun(input) {
459
+ const claim = await super.claimLocalAgentRun(input);
460
+ if (claim)
461
+ this.onClaimed?.(claim);
462
+ return claim;
463
+ }
464
+ }
465
+ const MAX_LISTED_SCORERS = 1000;
466
+ /** Newest published version of an evaluator named by ID, slug or display name. */
467
+ async function resolveScorerVersion(client, selector) {
468
+ const parsed = parseScenarioSelector(selector, ["scorers", "evaluators"]);
469
+ let scorer;
470
+ if (parsed.kind === "id")
471
+ scorer = await client.getScorer(parsed.id);
472
+ else {
473
+ const candidates = [];
474
+ let after;
475
+ for (;;) {
476
+ const page = await client.listScorers({ after, limit: 100 });
477
+ candidates.push(...page.items.filter((item) => !item.archivedAt));
478
+ if (!page.nextCursor || candidates.length >= MAX_LISTED_SCORERS)
479
+ break;
480
+ after = page.nextCursor;
481
+ }
482
+ const wanted = parsed.name.trim().toLowerCase();
483
+ const bySlug = candidates.filter((item) => item.slug.toLowerCase() === wanted);
484
+ const { matches } = bySlug.length ? { matches: bySlug } : matchByName(candidates, parsed.name);
485
+ if (matches.length > 1)
486
+ throw new UsageError(`Several evaluators match "${parsed.name}"; pass a slug or ID: ${matches.map((item) => item.slug).join(", ")}`);
487
+ if (!matches.length)
488
+ throw new UsageError(candidates.length
489
+ ? `No evaluator matches "${parsed.name}". Evaluators: ${candidates.map((item) => item.slug).join(", ")}`
490
+ : `No evaluator matches "${parsed.name}"`);
491
+ scorer = await client.getScorer(matches[0].id);
492
+ }
493
+ const versions = scorer.versions ?? [];
494
+ if (!versions.length)
495
+ throw new UsageError(`Evaluator "${scorer.slug}" has no published version to pin`);
496
+ // Servers list versions newest first; prefer the version number when the response carries it.
497
+ const latest = versions.reduce((best, item) => {
498
+ const candidate = item;
499
+ return best.version !== undefined && candidate.version !== undefined
500
+ ? candidate.version > best.version
501
+ ? candidate
502
+ : best
503
+ : best;
504
+ }, versions[0]);
505
+ return latest.id;
506
+ }
507
+ async function resolveSelection(client, values) {
508
+ const extra = [...(values["scorer-version"] ?? [])];
509
+ for (const selector of values.scorer ?? [])
510
+ extra.push(await resolveScorerVersion(client, selector));
511
+ if (values.scenario) {
512
+ if (values["set-version"])
513
+ throw new UsageError("--set-version applies to --set only");
514
+ const pins = await resolveScenarioPins(client, values.scenario);
515
+ pins.scorerVersionIds = [...new Set([...pins.scorerVersionIds, ...extra])];
516
+ return pins;
517
+ }
518
+ if (!extra.length)
519
+ throw new UsageError(`${values.set ? "--set" : "--dataset-version"} needs at least one --scorer or --scorer-version; use --scenario for published pins`);
520
+ if (values.set) {
521
+ const pins = await resolveEvalSetPins(client, values.set, { scorerVersionIds: extra });
522
+ if (values["set-version"] === undefined)
523
+ return pins;
524
+ const wanted = integer("set-version", values["set-version"], 1, 1, 1_000_000);
525
+ const dataset = await client.getDataset(pins.datasetId);
526
+ const version = dataset.versions.find((item) => item.version === wanted);
527
+ if (!version)
528
+ throw new UsageError(`Eval set "${dataset.name}" has no version ${wanted}; versions: ${dataset.versions.map((item) => item.version).join(", ")}`);
529
+ return {
530
+ ...pins,
531
+ datasetVersionId: version.id,
532
+ saved: version.frozenAt !== null,
533
+ revision: version.revision,
534
+ };
535
+ }
536
+ if (values["set-version"])
537
+ throw new UsageError("--set-version applies to --set only");
538
+ const version = await client.getDatasetVersion(values["dataset-version"]);
539
+ const dataset = await client.getDataset(version.datasetId);
540
+ return {
541
+ scenarioId: null,
542
+ name: dataset.name,
543
+ datasetId: dataset.id,
544
+ datasetVersionId: version.id,
545
+ scorerVersionIds: [...new Set(extra)],
546
+ environmentVersionId: null,
547
+ saved: version.frozenAt !== null,
548
+ revision: version.revision,
549
+ };
550
+ }
551
+ function parseMode(value) {
552
+ if (value === undefined)
553
+ return "auto";
554
+ if (value === "auto" || value === "direct" || value === "simulation")
555
+ return value;
556
+ throw new UsageError("--mode must be auto, direct or simulation");
557
+ }
558
+ /** Direct when nothing pins a simulated world: not a Scenario, and no case of the version does. */
559
+ async function detectDirect(client, pins, mode) {
560
+ if (mode !== "auto")
561
+ return mode === "direct";
562
+ if (pins.scenarioId || pins.environmentVersionId)
563
+ return false;
564
+ let after;
565
+ do {
566
+ const page = await client.listCases(pins.datasetVersionId, { after });
567
+ if (page.items.some((item) => item.environmentVersionId))
568
+ return false;
569
+ after = page.nextCursor ?? undefined;
570
+ } while (after);
571
+ return true;
572
+ }
573
+ function toJson(verdicts, runUrl, baseline, extra = {}) {
574
+ return {
575
+ experimentId: verdicts.experimentId,
576
+ runId: verdicts.runId,
577
+ runUrl,
578
+ complete: verdicts.results.complete,
579
+ cases: verdicts.summary.cases,
580
+ totals: verdicts.summary.totals,
581
+ ...extra,
582
+ ...(baseline
583
+ ? { baseline: { experimentId: baseline.experimentId, ...baseline.comparison } }
584
+ : {}),
585
+ };
586
+ }
587
+ /** Waits for Hue's verdicts, prints them (table or JSON, with the optional baseline) and returns the exit code. */
588
+ async function reportVerdicts(client, values, run, baselineId, output, signal) {
589
+ const wait = integer("wait", values.wait, 300, 0, 86_400);
590
+ output.log("Waiting for Hue checks...");
591
+ const verdicts = await collectExperimentVerdicts(client, {
592
+ experimentId: run.experimentId,
593
+ subjectIds: run.subjectIds,
594
+ timeoutMillis: wait * 1000,
595
+ signal,
596
+ });
597
+ // The wait returns its partial state on abort rather than throwing, so Ctrl+C here must not
598
+ // fall through to a baseline read and a verdict table that nobody asked to finish.
599
+ if (signal.aborted) {
600
+ process.stderr.write("Interrupted.\n");
601
+ return 130;
602
+ }
603
+ let baseline;
604
+ if (baselineId) {
605
+ const previous = await collectExperimentVerdicts(client, {
606
+ experimentId: baselineId,
607
+ timeoutMillis: 0,
608
+ });
609
+ baseline = {
610
+ experimentId: baselineId,
611
+ comparison: compareVerdicts(verdicts.summary, previous.summary),
612
+ };
613
+ }
614
+ if (values.json) {
615
+ process.stdout.write(`${JSON.stringify(toJson(verdicts, run.runUrl, baseline, run.extra))}\n`);
616
+ }
617
+ else {
618
+ renderTable(verdicts, output);
619
+ if (baseline)
620
+ renderComparison(baseline.experimentId, baseline.comparison, output);
621
+ output.log(`Run: ${run.runUrl}`);
622
+ }
623
+ const totals = verdicts.summary.totals;
624
+ return verdicts.results.complete && totals.cases > 0 && totals.passed === totals.cases ? 0 : 1;
625
+ }
626
+ function parseBaseline(value) {
627
+ if (!value)
628
+ return undefined;
629
+ const parsed = parseScenarioSelector(value, ["experiments"]);
630
+ if (parsed.kind !== "id")
631
+ throw new UsageError("--baseline must be an experiment ID or its Hue URL");
632
+ return parsed.id;
633
+ }
634
+ async function runOnce(values, connection, agents, agent, hue, output, signal) {
635
+ const client = new EvaluationClient(connection);
636
+ const concurrency = integer("concurrency", values.concurrency, 1, 1, 16);
637
+ const baselineId = parseBaseline(values.baseline);
638
+ const mode = parseMode(values.mode);
639
+ const pins = await resolveSelection(client, values);
640
+ if (!pins.saved) {
641
+ if (!values["save-version"]) {
642
+ output.error(`The ${pins.scenarioId ? "Scenario" : "eval set"} "${pins.name}" points at an unsaved version. Choose "Save eval-set version" in Hue, or pass --save-version to freeze it now, then rerun.`);
643
+ return 1;
644
+ }
645
+ const frozen = await client.freezeDatasetVersion(pins.datasetVersionId, pins.revision);
646
+ output.log(`Saved "${pins.name}" version ${frozen.version}.`);
647
+ }
648
+ const runName = values.name ?? `${pins.name} · ${agent.key} · ${agent.revision}`;
649
+ const project = await client.checkConnection();
650
+ if (await detectDirect(client, pins, mode))
651
+ return runDirect({
652
+ client,
653
+ values,
654
+ pins,
655
+ runName,
656
+ projectId: project.id,
657
+ agent,
658
+ agents,
659
+ hue,
660
+ output,
661
+ signal,
662
+ concurrency,
663
+ baselineId,
664
+ }, connection);
665
+ const environmentClient = createEnvironmentClient(connection);
666
+ const checkpointDirectory = await prepareCheckpointDirectory(values["checkpoint-dir"], agent.key, project.id, "simulation");
667
+ const caseKeys = new Map();
668
+ let runUrl = "";
669
+ const report = await runSimulation({
670
+ client,
671
+ environmentClient,
672
+ hue,
673
+ checkpointDirectory,
674
+ definition: {
675
+ kind: "pins",
676
+ datasetVersionId: pins.datasetVersionId,
677
+ scorerVersionIds: pins.scorerVersionIds,
678
+ },
679
+ runName,
680
+ persistResultContent: values.content,
681
+ traceEvidence: { mode: "required" },
682
+ concurrency,
683
+ signal,
684
+ target: agents.simulation,
685
+ async onProgress(event) {
686
+ if (event.type === "run_created") {
687
+ runUrl = event.runUrl;
688
+ output.log(`Run: ${event.runUrl}`);
689
+ output.log(`Experiment: ${event.experimentId}`);
690
+ try {
691
+ let after;
692
+ do {
693
+ const page = await client.listExperimentItems(event.experimentId, { after });
694
+ for (const item of page.items)
695
+ caseKeys.set(item.id, item.externalKey);
696
+ after = page.nextCursor ?? undefined;
697
+ } while (after);
698
+ }
699
+ catch {
700
+ // Progress labels fall back to case IDs; the run itself is unaffected.
701
+ }
702
+ return;
703
+ }
704
+ const label = caseKeys.get(event.caseId) ?? event.caseId;
705
+ if (event.type === "world_created")
706
+ output.log(`[${label}] world created`);
707
+ else if (event.type === "target_started")
708
+ output.log(`[${label}] agent started`);
709
+ else if (event.type === "world_sealed")
710
+ output.log(`[${label}] world sealed`);
711
+ else if (event.type === "attempt_prepared")
712
+ output.log(`[${label}] attempt prepared: ${event.status}`);
713
+ },
714
+ });
715
+ return reportVerdicts(client, values, {
716
+ experimentId: report.experimentId,
717
+ subjectIds: report.subjectIds,
718
+ runUrl: runUrl || report.runUrl,
719
+ }, baselineId, output, signal);
720
+ }
721
+ /**
722
+ * Direct cases: one ordinary experiment through `runExperiment`. The agent receives the case's
723
+ * pinned files and returns generated documents; code evaluators pinned to the run stay deferred
724
+ * for Hue's executor (`deferUnboundLocalScorers`), so no evaluator source runs on this machine.
725
+ * The attempt is checkpointed as in simulation mode: rerunning the same selection resumes the
726
+ * saved experiment without invoking the agent again for its finished cases.
727
+ */
728
+ async function runDirect(run, connection) {
729
+ const { client, values, pins, output, signal } = run;
730
+ const config = { agentKey: run.agent.key, agentRevision: run.agent.revision };
731
+ const checkpointDirectory = await prepareCheckpointDirectory(values["checkpoint-dir"], run.agent.key, run.projectId, "direct");
732
+ const store = await CheckpointStore.acquire(checkpointDirectory, {
733
+ kind: "direct",
734
+ projectId: run.projectId,
735
+ baseUrl: connection.baseUrl,
736
+ });
737
+ let experimentId = "";
738
+ let runUrl = "";
739
+ let report;
740
+ try {
741
+ const selectionDigest = digest({
742
+ datasetVersionId: pins.datasetVersionId,
743
+ scorerVersionIds: [...pins.scorerVersionIds].sort(),
744
+ config,
745
+ });
746
+ let attempt = await store.read("active-attempt");
747
+ if (attempt && attempt.stage !== "completed" && attempt.selectionDigest !== selectionDigest)
748
+ throw new Error("Recover the unfinished direct run before running a changed selection");
749
+ if (!attempt || attempt.stage === "completed") {
750
+ attempt = { selectionDigest, idempotencyKey: randomUUID(), stage: "preparing" };
751
+ await store.write("active-attempt", attempt);
752
+ }
753
+ if (!attempt.experimentId) {
754
+ const experiment = await client.createExperiment({
755
+ idempotencyKey: attempt.idempotencyKey,
756
+ name: run.runName.slice(0, 100),
757
+ datasetVersionId: pins.datasetVersionId,
758
+ scorerVersionIds: pins.scorerVersionIds,
759
+ config,
760
+ });
761
+ attempt.experimentId = experiment.id;
762
+ attempt.stage = "running";
763
+ await store.write("active-attempt", attempt);
764
+ }
765
+ experimentId = attempt.experimentId;
766
+ runUrl = new URL(`/experiments/${experimentId}`, connection.baseUrl).toString();
767
+ output.log(`Run: ${runUrl}`);
768
+ output.log(`Experiment: ${experimentId}`);
769
+ report = await runExperiment({
770
+ client,
771
+ hue: run.hue,
772
+ experimentId,
773
+ checkpointDirectory: join(store.directory, experimentId),
774
+ persistResultContent: values.content,
775
+ traceEvidence: { mode: "required" },
776
+ concurrency: run.concurrency,
777
+ scorers: [],
778
+ deferUnboundLocalScorers: true,
779
+ environmentEvidence: "when_pinned",
780
+ async target(inputs, context) {
781
+ if (signal.aborted)
782
+ throw new TargetCancelledError();
783
+ output.log(`[${context.item.externalKey}] agent started`);
784
+ const result = await run.agents.direct(inputs, {
785
+ mode: "direct",
786
+ config: structuredClone(context.config),
787
+ item: {
788
+ id: context.item.id,
789
+ externalKey: context.item.externalKey,
790
+ },
791
+ executionId: context.executionId,
792
+ files: structuredClone(context.files),
793
+ outputDirectory: context.outputDirectory,
794
+ signal,
795
+ });
796
+ const count = result instanceof TargetResult ? result.files.length : result === undefined ? 0 : null;
797
+ output.log(`[${context.item.externalKey}] ${count === null ? "answer returned" : `${count} file${count === 1 ? "" : "s"} produced`}`);
798
+ return result;
799
+ },
800
+ });
801
+ attempt.stage = "completed";
802
+ await store.write("active-attempt", attempt);
803
+ }
804
+ finally {
805
+ await store.release();
806
+ }
807
+ if (report.deferredScorerVersionIds.length)
808
+ output.log(`${report.deferredScorerVersionIds.length} evaluator version${report.deferredScorerVersionIds.length === 1 ? "" : "s"} left to Hue's executor`);
809
+ return reportVerdicts(client, values, {
810
+ experimentId,
811
+ subjectIds: report.subjectIds,
812
+ runUrl,
813
+ extra: { mode: "direct", deferredScorerVersionIds: report.deferredScorerVersionIds },
814
+ }, run.baselineId, output, signal);
815
+ }
816
+ async function runWorker(values, connection, adapter, agent, hue, output, signal) {
817
+ const client = new ObservedClient(connection);
818
+ const environmentClient = createEnvironmentClient(connection);
819
+ const wait = integer("wait", values.wait, 300, 0, 86_400);
820
+ const concurrency = integer("concurrency", values.concurrency, 1, 1, 16);
821
+ const maxRuns = values["max-runs"] === undefined
822
+ ? undefined
823
+ : integer("max-runs", values["max-runs"], 1, 1, 1_000_000);
824
+ const project = await client.checkConnection();
825
+ const checkpointDirectory = await prepareCheckpointDirectory(values["checkpoint-dir"], agent.key, project.id, join("worker", slug(agent.revision) || "dev"));
826
+ let current;
827
+ client.onRegistered = (registered) => {
828
+ output.log(`Registered agent ${registered.key} (revision ${registered.revision}) with ${connection.baseUrl}; waiting for runs launched from Hue${maxRuns ? ` (stops after ${maxRuns})` : " (Ctrl+C to stop)"}`);
829
+ };
830
+ client.onClaimed = (claim) => {
831
+ current = claim;
832
+ output.log(`Claimed run ${claim.runId}: ${new URL(`/experiments/${claim.experimentId}`, connection.baseUrl).toString()}`);
833
+ };
834
+ await runLocalAgent({
835
+ client,
836
+ environmentClient,
837
+ hue,
838
+ checkpointDirectory,
839
+ agent: {
840
+ key: agent.key,
841
+ name: agent.name,
842
+ revision: agent.revision,
843
+ capabilities: ["environment:v1"],
844
+ },
845
+ scorers: [],
846
+ concurrency,
847
+ signal,
848
+ ...(maxRuns === undefined ? {} : { maxRuns }),
849
+ target(inputs, tools, context) {
850
+ output.log(`[${context.item.externalKey}] agent started`);
851
+ return adapter(inputs, {
852
+ config: context.config,
853
+ item: context.item,
854
+ executionId: context.executionId,
855
+ environmentRunId: context.environmentRunId,
856
+ tools,
857
+ mcp: context.mcp,
858
+ ...(context.connectionBundle ? { connectionBundle: context.connectionBundle } : {}),
859
+ signal,
860
+ });
861
+ },
862
+ async onCompleted(report) {
863
+ output.log(`Run ${report.runId} completed: ${report.subjectIds.length} case${report.subjectIds.length === 1 ? "" : "s"}`);
864
+ if (!current)
865
+ return;
866
+ output.log("Waiting for Hue checks...");
867
+ try {
868
+ const verdicts = await collectExperimentVerdicts(client, {
869
+ experimentId: current.experimentId,
870
+ subjectIds: report.subjectIds,
871
+ timeoutMillis: wait * 1000,
872
+ signal,
873
+ });
874
+ if (signal.aborted)
875
+ return;
876
+ if (values.json)
877
+ process.stdout.write(`${JSON.stringify(toJson(verdicts, new URL(`/experiments/${current.experimentId}`, connection.baseUrl).toString()))}\n`);
878
+ else
879
+ renderTable(verdicts, output);
880
+ }
881
+ catch (error) {
882
+ output.error(`Unable to read verdicts: ${explain(error)}`);
883
+ }
884
+ },
885
+ });
886
+ if (signal.aborted) {
887
+ process.stderr.write("Interrupted.\n");
888
+ return 130;
889
+ }
890
+ return 0;
891
+ }
892
+ /**
893
+ * `hue eval`: runs a local adapter or command against a Scenario or eval set through
894
+ * `runSimulation`, or registers it as an outbound worker through `runLocalAgent`.
895
+ * Returns the process exit code.
896
+ */
897
+ export async function runEvalCommand(argv) {
898
+ const secrets = [];
899
+ const controller = new AbortController();
900
+ const interrupt = () => controller.abort(new Error("Interrupted"));
901
+ process.once("SIGINT", interrupt);
902
+ process.once("SIGTERM", interrupt);
903
+ let hue;
904
+ let json = false;
905
+ try {
906
+ const { values, positionals } = parse(argv);
907
+ json = values.json;
908
+ const output = {
909
+ log: (line) => (json ? process.stderr : process.stdout).write(`${line}\n`),
910
+ error: (line) => process.stderr.write(`${line}\n`),
911
+ };
912
+ if (values.help) {
913
+ process.stdout.write(USAGE);
914
+ return 0;
915
+ }
916
+ if (positionals.length > 1)
917
+ throw new UsageError("Pass at most one adapter file");
918
+ const adapterFile = positionals[0];
919
+ if ((adapterFile ? 1 : 0) + (values.command ? 1 : 0) !== 1)
920
+ throw new UsageError("Pass exactly one agent: an adapter file or --command");
921
+ const selections = [values.scenario, values.set, values["dataset-version"]].filter((value) => value !== undefined).length;
922
+ if (values.worker && selections)
923
+ throw new UsageError("--worker takes no selection; Hue chooses the run to execute");
924
+ if (!values.worker && selections !== 1)
925
+ throw new UsageError("Pass exactly one of --scenario, --set or --dataset-version");
926
+ if (values["env-file"]) {
927
+ try {
928
+ process.loadEnvFile(resolve(values["env-file"]));
929
+ }
930
+ catch (error) {
931
+ throw new UsageError(`Unable to load ${values["env-file"]}: ${error instanceof Error ? error.message : String(error)}`);
932
+ }
933
+ }
934
+ const apiKey = process.env.HUE_API_KEY?.trim();
935
+ if (!apiKey)
936
+ throw new UsageError('HUE_API_KEY is required: a "Read and write" project key, set in the environment or an ignored --env-file');
937
+ secrets.push(apiKey);
938
+ const baseUrl = values.origin ?? process.env.HUE_BASE_URL?.trim() ?? "https://app.hue.run";
939
+ const connection = { apiKey, baseUrl };
940
+ const timeout = integer("timeout", values.timeout, 600, 1, 86_400);
941
+ const key = values["agent-key"] ?? (derivedAgentKey(adapterFile, values.command) || "agent");
942
+ const agent = {
943
+ key,
944
+ name: values["agent-name"] ?? key,
945
+ revision: values.revision ?? process.env.AGENT_REVISION?.trim() ?? gitRevision() ?? "dev",
946
+ };
947
+ if (values.worker && (values.mode || values["set-version"] || values.scorer?.length))
948
+ throw new UsageError("--worker takes no selection; Hue chooses the run to execute");
949
+ const loaded = adapterFile ? await loadAdapter(adapterFile) : undefined;
950
+ // One adapter module serves both case kinds; the direct context announces itself with `mode`.
951
+ const agents = {
952
+ simulation: loaded
953
+ ? async (inputs, context) => {
954
+ const answer = await loaded(inputs, context);
955
+ if (answer instanceof TargetResult)
956
+ throw new Error("The adapter returned generated files for a simulated-world case");
957
+ return answer;
958
+ }
959
+ : commandAdapter(values.command, timeout),
960
+ direct: loaded ?? directCommandAdapter(values.command, timeout),
961
+ };
962
+ hue = createHue({ apiKey, baseUrl, serviceName: key, captureContent: values.content });
963
+ return values.worker
964
+ ? await runWorker(values, connection, agents.simulation, agent, hue, output, controller.signal)
965
+ : await runOnce(values, connection, agents, agent, hue, output, controller.signal);
966
+ }
967
+ catch (error) {
968
+ if (controller.signal.aborted || error instanceof TargetCancelledError) {
969
+ process.stderr.write("Interrupted.\n");
970
+ return 130;
971
+ }
972
+ if (error instanceof UsageError) {
973
+ process.stderr.write(`${redact(error.message, secrets)}\n${json ? "" : USAGE}`);
974
+ return 2;
975
+ }
976
+ process.stderr.write(`Error: ${redact(explain(error), secrets)}\n`);
977
+ return 1;
978
+ }
979
+ finally {
980
+ process.removeListener("SIGINT", interrupt);
981
+ process.removeListener("SIGTERM", interrupt);
982
+ if (hue)
983
+ await hue.shutdownSafe({ timeoutMillis: 5000 });
984
+ }
985
+ }