@hue-run/sdk 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLI.md +100 -29
- package/ENVIRONMENTS.md +44 -9
- 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/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 +18 -3
- package/dist/evals/environment-target.js +22 -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 +7 -5
- package/dist/evals/scenarios.js +71 -14
- package/dist/evals/simulation.d.ts +17 -4
- package/dist/evals/simulation.js +8 -0
- package/dist/evals/types.d.ts +4 -0
- 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/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/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`.
|