@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.
- package/CLI.md +100 -29
- package/ENVIRONMENTS.md +73 -8
- package/EVALUATIONS.md +39 -19
- package/README.md +51 -22
- package/dist/cli/eval-direct.d.ts +12 -1
- package/dist/cli/eval-direct.js +145 -33
- package/dist/cli/eval.d.ts +28 -1
- package/dist/cli/eval.js +469 -72
- package/dist/client.js +10 -20
- package/dist/environment/client.d.ts +10 -2
- package/dist/environment/client.js +43 -12
- package/dist/environment/types.d.ts +30 -10
- package/dist/environment.d.ts +1 -1
- package/dist/environment.js +1 -1
- package/dist/evals/checkpoint.d.ts +15 -1
- package/dist/evals/checkpoint.js +34 -3
- package/dist/evals/client.d.ts +19 -3
- package/dist/evals/client.js +82 -34
- package/dist/evals/environment-target.d.ts +23 -3
- package/dist/evals/environment-target.js +73 -10
- package/dist/evals/exit-cleanup.d.ts +4 -0
- package/dist/evals/exit-cleanup.js +26 -0
- package/dist/evals/files.d.ts +59 -1
- package/dist/evals/files.js +187 -27
- package/dist/evals/local-worker.d.ts +21 -3
- package/dist/evals/local-worker.js +14 -0
- package/dist/evals/runner.d.ts +45 -0
- package/dist/evals/runner.js +164 -13
- package/dist/evals/scenarios.d.ts +8 -6
- package/dist/evals/scenarios.js +75 -15
- package/dist/evals/simulation.d.ts +17 -4
- package/dist/evals/simulation.js +8 -0
- package/dist/evals/types.d.ts +10 -1
- package/dist/evals/verdicts.d.ts +15 -2
- package/dist/evals/verdicts.js +43 -9
- package/dist/evals.d.ts +4 -3
- package/dist/evals.js +2 -2
- package/dist/inline-files.d.ts +4 -0
- package/dist/inline-files.js +40 -17
- package/dist/provider-tools.js +30 -3
- package/dist/snapshot.d.ts +2 -2
- package/dist/snapshot.js +47 -12
- package/dist/transport.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/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:
|
|
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
|
|
310
|
-
and `mcp_servers` entries of
|
|
311
|
-
`output.value` or `llm.invocation_parameters
|
|
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
|
|
328
|
-
|
|
329
|
-
`sha256`
|
|
330
|
-
`
|
|
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
|
-
|
|
580
|
-
verifies the case's pinned input files, hands them to the agent, and
|
|
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
|
|
595
|
-
|
|
596
|
-
|
|
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)
|
|
600
|
-
|
|
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
|
|
603
|
-
private directory, uploads the documents it writes and waits for Hue's grading
|
|
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>;
|
package/dist/cli/eval-direct.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
63
|
-
for (let copy = 2; used.has(target); copy++)
|
|
64
|
-
target =
|
|
65
|
-
used.add(target);
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
+
info = await lstat(path);
|
|
82
101
|
}
|
|
83
102
|
catch (error) {
|
|
84
103
|
if (error.code === "ENOENT")
|
|
85
104
|
return undefined;
|
|
86
|
-
throw
|
|
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
|
-
/**
|
|
90
|
-
*
|
|
91
|
-
|
|
92
|
-
const
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
|
134
|
-
(await
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/cli/eval.d.ts
CHANGED
|
@@ -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`.
|