@hue-run/sdk 0.8.1 → 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.
Files changed (46) hide show
  1. package/CLI.md +100 -29
  2. package/ENVIRONMENTS.md +73 -8
  3. package/EVALUATIONS.md +39 -19
  4. package/README.md +51 -22
  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 +469 -72
  9. package/dist/client.js +10 -20
  10. package/dist/environment/client.d.ts +10 -2
  11. package/dist/environment/client.js +43 -12
  12. package/dist/environment/types.d.ts +30 -10
  13. package/dist/environment.d.ts +1 -1
  14. package/dist/environment.js +1 -1
  15. package/dist/evals/checkpoint.d.ts +15 -1
  16. package/dist/evals/checkpoint.js +34 -3
  17. package/dist/evals/client.d.ts +19 -3
  18. package/dist/evals/client.js +82 -34
  19. package/dist/evals/environment-target.d.ts +23 -3
  20. package/dist/evals/environment-target.js +73 -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 +59 -1
  24. package/dist/evals/files.js +187 -27
  25. package/dist/evals/local-worker.d.ts +21 -3
  26. package/dist/evals/local-worker.js +14 -0
  27. package/dist/evals/runner.d.ts +45 -0
  28. package/dist/evals/runner.js +164 -13
  29. package/dist/evals/scenarios.d.ts +8 -6
  30. package/dist/evals/scenarios.js +75 -15
  31. package/dist/evals/simulation.d.ts +17 -4
  32. package/dist/evals/simulation.js +8 -0
  33. package/dist/evals/types.d.ts +10 -1
  34. package/dist/evals/verdicts.d.ts +15 -2
  35. package/dist/evals/verdicts.js +43 -9
  36. package/dist/evals.d.ts +4 -3
  37. package/dist/evals.js +2 -2
  38. package/dist/inline-files.d.ts +4 -0
  39. package/dist/inline-files.js +40 -17
  40. package/dist/provider-tools.js +30 -3
  41. package/dist/snapshot.d.ts +2 -2
  42. package/dist/snapshot.js +47 -12
  43. package/dist/transport.js +2 -2
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/package.json +1 -1
package/README.md CHANGED
@@ -35,7 +35,7 @@ import { createHue, HueExportError } from "@hue-run/sdk";
35
35
  const hue = createHue({
36
36
  apiKey: process.env.HUE_API_KEY!, // a project service key, on the server only
37
37
  serviceName: "my-agent",
38
- captureContent: false, // required: explicitly choose true or false
38
+ captureContent: true, // required: true records content; false sends metadata only
39
39
  onExportIssue: (issue) => console.error(issue), // sanitized counts, never server bodies
40
40
  });
41
41
 
@@ -58,6 +58,11 @@ try {
58
58
  await hue.shutdown(); // flushes and releases providers owned by this client
59
59
  ```
60
60
 
61
+ `captureContent` is required. The example sets `true`, the recommended choice: trace inspection,
62
+ evaluations and judges read the prompts, responses and tool inputs/outputs it records. Choose
63
+ `false` for metadata only when a policy forbids sending that content; see
64
+ [Privacy and content](#privacy-and-content).
65
+
61
66
  The default destination is `https://app.hue.run`. Data goes to
62
67
  `/api/v1/otlp/v1/traces` and `/api/v1/otlp/v1/logs` with a Bearer project key.
63
68
  Set `baseUrl` only for another Hue deployment. It must be an origin without an API path; a trailing slash is accepted.
@@ -287,6 +292,12 @@ outputs and exports through Hue's attached processors keeps the label.
287
292
 
288
293
  ## Privacy and content
289
294
 
295
+ Set `captureContent: true` for full traces: prompts and messages, responses and tool inputs/outputs,
296
+ alongside model, usage, timing and errors. Trace inspection, evaluations and judges read that
297
+ content. Choose `false`, metadata-only mode, when your users decline or an application or data
298
+ policy forbids sending that content to another service. `redact` and the credential filtering
299
+ described below apply in both modes.
300
+
290
301
  `captureContent: false` disables manual input/output/messages/tool content and
291
302
  removes recognized GenAI, Vercel, OpenInference and OpenLLMetry content attributes,
292
303
  legacy GenAI content events, log bodies, status messages and exception text before
@@ -306,12 +317,18 @@ an OpenAI hosted MCP tool. Before export, and before `redact`, Hue replaces the
306
317
  credential-like fields including `authorization`, `authorization_token`, `headers`, `api_key`,
307
318
  `access_token`, `x-api-key`, and keys ending in `token`, `secret`, `password`, `apikey` or
308
319
  `credential` (case-insensitively, ignoring `-` and `_`) with `"[redacted]"` in recorded tool definitions
309
- (`gen_ai.tool.definitions`, `ai.prompt.tools`, `llm.tools.*.tool.json_schema`) and in the `tools`
310
- and `mcp_servers` entries of a raw provider request or response recorded as `input.value`,
311
- `output.value` or `llm.invocation_parameters`. Parameters named in a JSON Schema `properties`
320
+ (`gen_ai.tool.definitions`, `ai.prompt.tools`, `llm.tools.*.tool.json_schema`) and in the
321
+ top-level `tools` and `mcp_servers` entries of any JSON object recorded as `input.value`,
322
+ `output.value` or `llm.invocation_parameters`, whether or not it is a provider request; nothing
323
+ else in that object changes. Parameters named in a JSON Schema `properties`
312
324
  object keep their schemas, so a tool that takes a `headers` argument is still described. A
313
325
  definition nested more than 256 levels deep rejects its record. Sensitive `default`, `const`,
314
- `examples` and `enum` values under credential-named schema parameters are redacted too.
326
+ `examples` and `enum` values under credential-named schema parameters are redacted too. A `url`
327
+ or `server_url` field (named like the credential fields, so `serverUrl` too) that has userinfo, a
328
+ query or a fragment loses its userinfo and fragment, and each query value becomes `[redacted]`
329
+ under its original name; the result is serialized as WHATWG `URL` does (lowercase scheme and
330
+ host, IDN hosts in Punycode, default ports dropped, dot segments resolved). A URL with none of
331
+ them is exported as recorded, and a value `URL` cannot parse becomes `"[redacted]"`.
315
332
 
316
333
  With `captureContent: false`, export removes tool definitions but keeps a summary of them on the
317
334
  same record: `hue.tool.names` lists each definition's `name` (Chat Completions `function.name`,
@@ -324,12 +341,18 @@ rotates. Only definitions another integration recorded can be summarized: `hueTe
324
341
  inputs and exports through Hue's attached processors gets the summary.
325
342
  Recorded messages can inline files: GenAI `blob` parts in `gen_ai.input.messages` /
326
343
  `gen_ai.output.messages` (what the AI SDK 7 adapter records for a file part) and AI SDK 6 `file`
327
- parts in `ai.prompt.messages`. A span whose messages exceed 256 KiB would be rejected, so before
328
- export Hue replaces the `content`/`data` of any such part longer than 64 KiB with the file's
329
- `sha256` (of the decoded bytes for base64 and `data:` URLs, of the UTF-8 text otherwise) and
330
- `size`, keeping the part's other fields such as `type`, `mime_type` and `mediaType`. Smaller inline
344
+ parts in `ai.prompt.messages`. A span whose messages exceed 256 KiB would be rejected, so when a
345
+ record is queued Hue replaces the `content`/`data` of any such part longer than 64 KiB with the
346
+ file's `sha256` and `size`, both of the file's own bytes whatever its media type (base64 content
347
+ and `;base64` `data:` URLs decoded, other `data:` URLs percent-decoded, anything else as UTF-8 text), keeping the part's other fields such as `type`, `mime_type` and `mediaType`. Smaller inline
331
348
  files are exported as recorded. The digest matches `hue.recordFile`'s `hue.file.sha256` for the same
332
- bytes, so a file can be recognized wherever it appears.
349
+ bytes, so a file can be recognized wherever it appears. The replacement happens before the record is
350
+ charged to the queue budget, so a large file does not drop its span. It is bounded: a message
351
+ attribute longer than 8 MiB of text is left unchanged, and the default 8 MiB `maxQueueBytes` then
352
+ drops the span. Base64 grows a file by a third, so that ceiling is an inline file of about 6 MiB.
353
+ The same message text is hashed once per record, and a record inspects at most 16 MiB of message
354
+ text in all; messages past that are charged as recorded. With `captureContent: false` the messages,
355
+ which export removes, are neither hashed nor charged.
333
356
 
334
357
  Manual helpers encode JSON values without converting null into absence. Unknown
335
358
  outputs and usage remain absent. This SDK does not estimate tokens or cost. A thrown
@@ -361,7 +384,7 @@ import { createHue, createHueTransport } from "@hue-run/sdk";
361
384
  const transport = createHueTransport({
362
385
  apiKey: process.env.HUE_API_KEY!,
363
386
  serviceName: "existing-app",
364
- captureContent: false,
387
+ captureContent: true, // false sends metadata only
365
388
  });
366
389
  const tracerProvider = new TracerProvider({ spanProcessors: [transport.spanProcessor] });
367
390
  const loggerProvider = new LoggerProvider({ processors: [transport.logRecordProcessor] });
@@ -576,9 +599,9 @@ an uncertain preparation. See the
576
599
  an issued facade endpoint or the official Gmail service, and do not claim universal provider
577
600
  parity.
578
601
 
579
- An unreleased `directTarget` callback extends the same worker to cases without a world: the runner
580
- verifies the case's pinned input files, hands them to the agent, and uploads the documents it
581
- returns as verified Hue artifacts for scoring. See
602
+ The `directTarget` callback, shipped in TypeScript `0.5.0`, extends the same worker to cases
603
+ without a world: the runner verifies the case's pinned input files, hands them to the agent, and
604
+ uploads the documents it returns as verified Hue artifacts for scoring. See
582
605
  [direct cases and files](EVALUATIONS.md#direct-cases-and-files).
583
606
 
584
607
  Scorer deferral shipped in TypeScript `0.3.1`. Only built-ins
@@ -591,17 +614,23 @@ their agents with `runLocalAgent()`; setup does not register workers or launch s
591
614
 
592
615
  ### Command-line evaluation
593
616
 
594
- The unreleased `hue eval` command wraps `runSimulation()` and `runLocalAgent()` for an adapter
595
- file or a shell command: `hue eval --case "<name>" ./hue-agent.ts` creates a fresh run
596
- from a published case's immutable pins, runs the agent in one isolated world per case, waits
617
+ The `hue eval` command, shipped in TypeScript `0.5.0` (`--case` since `0.6.0`, `--scenario`
618
+ before), wraps `runSimulation()` and `runLocalAgent()` for an adapter file or a shell command:
619
+ `hue eval --case "<name>" ./hue-agent.ts --content` creates a fresh run from a published case's
620
+ immutable pins, runs the agent in one isolated world per case, waits
597
621
  for Hue's outcome checks and prints the run URL and per-case PASS/FAIL verdicts with an exit code;
598
622
  `--worker` registers the same adapter for runs launched from Hue. It needs a Read and
599
- write key in `HUE_API_KEY` (never printed) and keeps content capture off unless `--content`
600
- is passed. See [Evaluate an agent against a case](CLI.md#evaluate-an-agent-against-a-case).
623
+ write key in `HUE_API_KEY` (never printed). Content capture stays off unless `--content` is
624
+ passed; the example passes it so case spans carry content. Case outputs, error messages and
625
+ explanations are stored in Hue either way (`--no-output` keeps them out of a one-shot run's
626
+ results); a command's stdout is its stored answer, with the credentials `hue eval` handed the case
627
+ replaced by `[redacted]`.
628
+ Instrument the agent itself for its model and tool spans. See
629
+ [Evaluate an agent against a case](CLI.md#evaluate-an-agent-against-a-case).
601
630
  Eval sets whose cases pin files instead of a world run as direct cases through `runExperiment()`:
602
- `hue eval --set <slug> --scorer <slug> --command "…"` hands the agent each case's pinned files in a
603
- private directory, uploads the documents it writes and waits for Hue's grading executor to score
604
- them. See [Evaluate a document eval set](CLI.md#evaluate-a-document-eval-set).
631
+ `hue eval --set <slug> --scorer <slug> --command "…" --content` hands the agent each case's
632
+ pinned files in a private directory, uploads the documents it writes and waits for Hue's grading
633
+ executor to score them. See [Evaluate a document eval set](CLI.md#evaluate-a-document-eval-set).
605
634
 
606
635
  ## Managed targets
607
636
 
@@ -38,10 +38,21 @@ export declare function stageDirectCase(root: string, input: {
38
38
  executionId: string;
39
39
  files: LocalFile[];
40
40
  }): Promise<DirectCaseLayout>;
41
+ /** Test seams: `beforeEntry` runs before each listed entry is examined, `afterListing` between
42
+ * the listing and the reads. */
43
+ export interface CollectionSeams {
44
+ beforeEntry?: (name: string) => Promise<void>;
45
+ afterListing?: () => Promise<void>;
46
+ }
41
47
  /**
42
48
  * Turns the output folder into the target's result. Every regular file, in subdirectories too,
43
49
  * becomes a generated file; an unsupported extension is the agent's error rather than a silently
44
50
  * dropped document. `fallbackOutput` (for example the command's stdout) is used when no JSON
45
51
  * output or summary file was written.
52
+ *
53
+ * Nothing here trusts a path the agent could have changed: the output directory must be a real
54
+ * directory, every file is opened without following a final symlink or blocking on a FIFO, must
55
+ * be the regular file the listing saw and within its size limit, and the result carries the bytes
56
+ * read from that open file. `seams` are for tests only.
46
57
  */
47
- export declare function collectDirectOutputs(outputDirectory: string, fallbackOutput?: JsonValue): Promise<TargetResult>;
58
+ export declare function collectDirectOutputs(outputDirectory: string, fallbackOutput?: JsonValue, seams?: CollectionSeams): Promise<TargetResult>;
@@ -1,6 +1,7 @@
1
- import { copyFile, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
1
+ import { constants } from "node:fs";
2
+ import { copyFile, lstat, mkdir, opendir, writeFile } from "node:fs/promises";
2
3
  import { basename, extname, join } from "node:path";
3
- import { outputContentTypes } from "../evals/files.js";
4
+ import { outputContentTypes, outputFileLimits, readAgentFile, safeFilename, } from "../evals/files.js";
4
5
  import { withFiles } from "../evals/types.js";
5
6
  /**
6
7
  * Direct (file) cases for `hue eval`: a case is a task plus pinned input files, and the agent's
@@ -43,6 +44,11 @@ for (const type of Object.values(outputExtensions))
43
44
  throw new Error(`outputExtensions maps to a content type Hue does not accept: ${type}`);
44
45
  const HELPER_FILES = new Set(["manifest.json", "result.json", "output.json"]);
45
46
  const SUMMARY_FILES = ["summary.txt", "summary.md", "final.txt", "resumen.txt"];
47
+ /** The most a helper (`manifest.json`, `result.json`, `summary.txt`, …) may hold: the same bound
48
+ * as the command's stdout. */
49
+ const HELPER_BYTES = 4 * 1024 * 1024;
50
+ /** Entries a collection visits at most, hidden and skipped ones included. */
51
+ const MAX_ENTRIES = 1024;
46
52
  /** Writes the case directory: inputs, descriptor, verified file copies and an empty output folder. */
47
53
  export async function stageDirectCase(root, input) {
48
54
  const caseDirectory = join(root, "case");
@@ -50,21 +56,25 @@ export async function stageDirectCase(root, input) {
50
56
  await mkdir(outputDirectory, { recursive: true, mode: 0o700 });
51
57
  const inputsPath = join(caseDirectory, "inputs.json");
52
58
  await writeFile(inputsPath, JSON.stringify(input.inputs, null, 2), { mode: 0o600 });
59
+ // Names that differ only in case or Unicode normalization are the same file on macOS and
60
+ // Windows filesystems; keep both inputs under distinct names, and never overwrite one.
53
61
  const used = new Set();
62
+ const key = (folder, name) => `${folder}/${name.normalize("NFC").toLowerCase()}`;
54
63
  const files = [];
55
64
  for (const file of input.files) {
56
65
  const folder = join(caseDirectory, "files", file.role);
57
66
  await mkdir(folder, { recursive: true, mode: 0o700 });
58
67
  // Two inputs may share a filename (two incident reports, say); keep both.
59
- const name = basename(file.filename);
68
+ const name = safeFilename(basename(file.filename));
60
69
  const extension = extname(name);
61
70
  const stem = name.slice(0, name.length - extension.length);
62
- let target = join(folder, name);
63
- for (let copy = 2; used.has(target); copy++)
64
- target = join(folder, `${stem} (${copy})${extension}`);
65
- used.add(target);
66
- await copyFile(file.path, target);
67
- files.push({ role: file.role, filename: basename(target), path: target });
71
+ let target = name;
72
+ for (let copy = 2; used.has(key(folder, target)); copy++)
73
+ target = `${stem} (${copy})${extension}`;
74
+ used.add(key(folder, target));
75
+ const path = join(folder, target);
76
+ await copyFile(file.path, path, constants.COPYFILE_EXCL);
77
+ files.push({ role: file.role, filename: target, path });
68
78
  }
69
79
  await writeFile(join(caseDirectory, "case.json"), JSON.stringify({
70
80
  id: input.item.id,
@@ -76,33 +86,119 @@ export async function stageDirectCase(root, input) {
76
86
  }, null, 2), { mode: 0o600 });
77
87
  return { caseDirectory, inputsPath, outputDirectory, files };
78
88
  }
79
- async function readJsonFile(path) {
89
+ const identityOf = (info) => ({
90
+ dev: info.dev,
91
+ ino: info.ino,
92
+ });
93
+ const sameIdentity = (a, b) => a.dev === b.dev && a.ino === b.ino;
94
+ /** A real directory (not a symlink) and its identity, or undefined when `path` is absent. A
95
+ * parent that is no longer a directory (ENOTDIR) is a change, never an absent directory: a case
96
+ * directory replaced by a file must not read as "wrote nothing". */
97
+ async function directoryIdentity(path, label) {
98
+ let info;
80
99
  try {
81
- return JSON.parse(await readFile(path, "utf8"));
100
+ info = await lstat(path);
82
101
  }
83
102
  catch (error) {
84
103
  if (error.code === "ENOENT")
85
104
  return undefined;
86
- throw new Error(`${basename(path)} in the output directory is not valid JSON`);
105
+ throw unexamined(label, error);
87
106
  }
107
+ if (info.isSymbolicLink() || !info.isDirectory())
108
+ throw new Error(`${label} is not a directory; a symbolic link or file is never collected`);
109
+ return identityOf(info);
88
110
  }
89
- /** Regular files anywhere under the output directory, named by their `/`-joined relative path.
90
- * Hidden and lock entries (dot names, `~$…`) and symlinks are skipped at every level. */
91
- async function listOutputFiles(directory, prefix = "") {
92
- const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
111
+ /** The error for an entry that vanished or changed kind while it was being collected, or could
112
+ * not be examined at all. */
113
+ function unexamined(label, error) {
114
+ const code = error.code;
115
+ return new Error(code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP"
116
+ ? `${label} changed while it was collected`
117
+ : `${label} could not be read`);
118
+ }
119
+ /**
120
+ * Regular files anywhere under the output directory, named by their `/`-joined relative path,
121
+ * with the device and inode each had when listed. Hidden and lock entries (dot names, `~$…`),
122
+ * symlinks and anything that is neither a regular file nor a directory are skipped at every
123
+ * level, except that a top-level helper name must be a regular file when present. The walk stops
124
+ * past 32 documents or 1024 entries, and an entry that vanished or a directory that changed while
125
+ * it was listed is refused.
126
+ */
127
+ async function listOutputFiles(root, rootIdentity, seams) {
93
128
  const found = [];
94
- for (const entry of entries) {
95
- if (entry.name.startsWith(".") || entry.name.startsWith("~$"))
96
- continue;
97
- const path = join(directory, entry.name);
98
- const name = `${prefix}${entry.name}`;
99
- if (entry.isDirectory())
100
- found.push(...(await listOutputFiles(path, `${name}/`)));
101
- else if (entry.isFile())
102
- found.push({ name, path });
129
+ const directories = [
130
+ { path: root, label: "output/", identity: rootIdentity },
131
+ ];
132
+ let visited = 0;
133
+ let documents = 0;
134
+ for (let index = 0; index < directories.length; index++) {
135
+ const directory = directories[index];
136
+ const prefix = index === 0 ? "" : `${directory.label.slice("output/".length)}`;
137
+ let entries;
138
+ try {
139
+ entries = await opendir(directory.path);
140
+ }
141
+ catch (error) {
142
+ throw unexamined(directory.label, error);
143
+ }
144
+ for await (const entry of entries) {
145
+ if (++visited > MAX_ENTRIES)
146
+ throw new Error(`The output directory holds more than ${MAX_ENTRIES} entries`);
147
+ const name = `${prefix}${entry.name}`;
148
+ const helper = index === 0 && (HELPER_FILES.has(name) || SUMMARY_FILES.includes(name));
149
+ if (!helper && (entry.name.startsWith(".") || entry.name.startsWith("~$")))
150
+ continue;
151
+ const path = join(directory.path, entry.name);
152
+ if (helper && !entry.isFile())
153
+ throw new Error(`output/${name} is not a regular file; the agent must write it itself`);
154
+ if (entry.isDirectory() || entry.isFile())
155
+ await seams.beforeEntry?.(name);
156
+ if (entry.isDirectory()) {
157
+ const identity = await directoryIdentity(path, `output/${name}`);
158
+ if (!identity)
159
+ throw new Error(`output/${name} changed while it was collected`);
160
+ directories.push({ path, label: `output/${name}/`, identity });
161
+ }
162
+ else if (entry.isFile()) {
163
+ let info;
164
+ try {
165
+ info = await lstat(path);
166
+ }
167
+ catch (error) {
168
+ throw unexamined(`output/${name}`, error);
169
+ }
170
+ if (!info.isFile())
171
+ throw new Error(`output/${name} changed while it was collected`);
172
+ found.push({ name, path, identity: identityOf(info) });
173
+ if (!helper && ++documents > outputFileLimits.count)
174
+ throw new Error(`The agent wrote more than ${outputFileLimits.count} files`);
175
+ }
176
+ }
177
+ }
178
+ // Every directory must still be the one listed: none was swapped for a link meanwhile.
179
+ for (const directory of directories) {
180
+ const now = await directoryIdentity(directory.path, directory.label);
181
+ if (!now || !sameIdentity(now, directory.identity))
182
+ throw new Error(`${directory.label} changed while it was collected`);
103
183
  }
104
184
  return found;
105
185
  }
186
+ async function readHelper(listed) {
187
+ if (!listed)
188
+ return undefined;
189
+ const bytes = await readAgentFile(listed.path, `output/${listed.name}`, HELPER_BYTES, listed.identity);
190
+ return Buffer.from(bytes).toString("utf8");
191
+ }
192
+ function parseHelper(listed, text) {
193
+ if (text === undefined)
194
+ return undefined;
195
+ try {
196
+ return JSON.parse(text);
197
+ }
198
+ catch {
199
+ throw new Error(`${listed.name} in the output directory is not valid JSON`);
200
+ }
201
+ }
106
202
  /** Artifact filenames cannot contain path separators or controls. Encode only those forbidden
107
203
  * characters plus `%`, then represent `/` as `%2F`; distinct output paths stay distinct. */
108
204
  function artifactFilename(relativePath) {
@@ -120,9 +216,18 @@ function artifactFilename(relativePath) {
120
216
  * becomes a generated file; an unsupported extension is the agent's error rather than a silently
121
217
  * dropped document. `fallbackOutput` (for example the command's stdout) is used when no JSON
122
218
  * output or summary file was written.
219
+ *
220
+ * Nothing here trusts a path the agent could have changed: the output directory must be a real
221
+ * directory, every file is opened without following a final symlink or blocking on a FIFO, must
222
+ * be the regular file the listing saw and within its size limit, and the result carries the bytes
223
+ * read from that open file. `seams` are for tests only.
123
224
  */
124
- export async function collectDirectOutputs(outputDirectory, fallbackOutput) {
125
- const manifest = await readJsonFile(join(outputDirectory, "manifest.json"));
225
+ export async function collectDirectOutputs(outputDirectory, fallbackOutput, seams = {}) {
226
+ const rootIdentity = await directoryIdentity(outputDirectory, "The output directory");
227
+ const listed = rootIdentity ? await listOutputFiles(outputDirectory, rootIdentity, seams) : [];
228
+ await seams.afterListing?.();
229
+ const top = (name) => listed.find((entry) => entry.name === name);
230
+ const manifest = parseHelper(top("manifest.json"), await readHelper(top("manifest.json")));
126
231
  const declaredPrimary = manifest && typeof manifest === "object" && !Array.isArray(manifest)
127
232
  ? manifest.primary
128
233
  : undefined;
@@ -130,11 +235,11 @@ export async function collectDirectOutputs(outputDirectory, fallbackOutput) {
130
235
  ? manifest.output
131
236
  : undefined;
132
237
  let output = declaredOutput ??
133
- (await readJsonFile(join(outputDirectory, "result.json"))) ??
134
- (await readJsonFile(join(outputDirectory, "output.json")));
238
+ parseHelper(top("result.json"), await readHelper(top("result.json"))) ??
239
+ parseHelper(top("output.json"), await readHelper(top("output.json")));
135
240
  if (output === undefined)
136
241
  for (const name of SUMMARY_FILES) {
137
- const text = await readFile(join(outputDirectory, name), "utf8").catch(() => undefined);
242
+ const text = await readHelper(top(name));
138
243
  if (text !== undefined) {
139
244
  output = { summary: text };
140
245
  break;
@@ -143,7 +248,7 @@ export async function collectDirectOutputs(outputDirectory, fallbackOutput) {
143
248
  if (output === undefined)
144
249
  output = fallbackOutput;
145
250
  // Helper and summary names are reserved at the top level only; a nested one is a document.
146
- const entries = (await listOutputFiles(outputDirectory)).filter((entry) => !HELPER_FILES.has(entry.name) && !SUMMARY_FILES.includes(entry.name));
251
+ const entries = listed.filter((entry) => !HELPER_FILES.has(entry.name) && !SUMMARY_FILES.includes(entry.name));
147
252
  // Directory order differs between filesystems; keep uploads stable.
148
253
  entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
149
254
  const unsupported = entries.filter((entry) => !outputExtensions[extname(entry.name).toLowerCase()]);
@@ -151,10 +256,11 @@ export async function collectDirectOutputs(outputDirectory, fallbackOutput) {
151
256
  throw new Error(`The agent wrote files Hue does not accept as generated documents: ${unsupported.map((entry) => entry.name).join(", ")} (accepted: ${Object.keys(outputExtensions).join(" ")})`);
152
257
  const files = [];
153
258
  for (const entry of entries) {
154
- if ((await stat(entry.path)).size === 0)
259
+ const bytes = (await readAgentFile(entry.path, `Generated file ${entry.name}`, outputFileLimits.bytes, entry.identity));
260
+ if (!bytes.byteLength)
155
261
  throw new Error(`The agent wrote an empty file: ${entry.name}`);
156
262
  files.push({
157
- path: entry.path,
263
+ bytes,
158
264
  filename: artifactFilename(entry.name),
159
265
  contentType: outputExtensions[extname(entry.name).toLowerCase()],
160
266
  });
@@ -167,5 +273,11 @@ export async function collectDirectOutputs(outputDirectory, fallbackOutput) {
167
273
  }
168
274
  else if (files.length === 1)
169
275
  files[0].primary = true;
276
+ // The directory must still be the one listed when every byte has been read.
277
+ if (rootIdentity) {
278
+ const now = await directoryIdentity(outputDirectory, "The output directory");
279
+ if (!now || !sameIdentity(now, rootIdentity))
280
+ throw new Error("The output directory changed while it was collected");
281
+ }
170
282
  return withFiles(output, files);
171
283
  }
@@ -1,9 +1,10 @@
1
1
  import type { ExperimentCase, LocalFile } from "../evals/types.js";
2
2
  import { TargetResult } from "../evals/types.js";
3
3
  import { type SimulationTargetContext } from "../evals/simulation.js";
4
+ import { type ExperimentVerdicts } from "../evals/verdicts.js";
4
5
  import type { JsonValue } from "../types.js";
5
6
  /** Adapter contract: the module's `default` or `runMyAgent` export. */
6
- export type EvalAdapter = (inputs: JsonValue, context: SimulationTargetContext) => JsonValue | undefined | Promise<JsonValue | undefined>;
7
+ export type EvalAdapter = (inputs: JsonValue, context: SimulationTargetContext) => JsonValue | TargetResult | undefined | Promise<JsonValue | TargetResult | undefined>;
7
8
  /**
8
9
  * Context of a direct (file) case: pinned input files in, generated documents out. The same
9
10
  * adapter module serves both modes; `mode` tells it which context it received.
@@ -20,6 +21,32 @@ export interface DirectTargetContext {
20
21
  signal?: AbortSignal;
21
22
  }
22
23
  export type DirectEvalAdapter = (inputs: JsonValue, context: DirectTargetContext) => JsonValue | TargetResult | undefined | Promise<JsonValue | TargetResult | undefined>;
24
+ export interface Output {
25
+ /** Human progress lines: stdout normally, stderr with --json. */
26
+ log(line: string): void;
27
+ /** Diagnostics; always stderr. */
28
+ error(line: string): void;
29
+ }
30
+ /**
31
+ * Spawns the agent command once in its own process group and returns its trimmed stdout. A
32
+ * timeout or Ctrl+C stops the agent the shell started, not only the shell: a survivor would still
33
+ * hold a world token and could write after Hue recorded the case as failed. The group is signalled
34
+ * even once the shell has exited, since a compound command's agent can outlive it, and a stopped
35
+ * command settles only when nothing in the group is left. A command that exits normally but
36
+ * leaves processes behind has them stopped the same way before it settles, so none can touch its
37
+ * outputs while they are collected. Once the group is found empty it is never signalled again,
38
+ * since its ID could be reused. Windows has no process group to signal, so the child alone is
39
+ * stopped there.
40
+ */
41
+ export declare function spawnAgentCommand(command: string, options: {
42
+ cwd?: string;
43
+ env: Record<string, string | undefined>;
44
+ stdin?: string;
45
+ timeoutSeconds: number;
46
+ signal?: AbortSignal;
47
+ }): Promise<string>;
48
+ export declare function renderTable(verdicts: ExperimentVerdicts, output: Output): void;
49
+ export declare function explain(error: unknown): string;
23
50
  /**
24
51
  * `hue eval`: runs a local adapter or command against a Scenario or eval set through
25
52
  * `runSimulation`, or registers it as an outbound worker through `runLocalAgent`.