@hue-run/sdk 0.15.2 → 0.17.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 +30 -21
- package/EVALUATIONS.md +1 -1
- package/README.md +71 -49
- package/dist/blobs.d.ts +35 -17
- package/dist/blobs.js +141 -75
- package/dist/cli/env-file.d.ts +2 -0
- package/dist/cli/env-file.js +2 -0
- package/dist/cli/eval-direct.d.ts +3 -2
- package/dist/cli/eval-direct.js +49 -6
- package/dist/cli/eval.js +109 -22
- package/dist/cli/login.js +2 -3
- package/dist/client.d.ts +3 -1
- package/dist/client.js +70 -20
- package/dist/evals/attempt.d.ts +1 -1
- package/dist/evals/attempt.js +1 -1
- package/dist/evals/checkpoint.d.ts +6 -2
- package/dist/evals/checkpoint.js +75 -12
- package/dist/evals/files.js +12 -0
- package/dist/evals/runner.d.ts +9 -0
- package/dist/evals/runner.js +63 -10
- package/dist/evals/scorer-publication.js +1 -1
- package/dist/evals/simulation.js +4 -2
- package/dist/inline-files.d.ts +38 -29
- package/dist/inline-files.js +288 -83
- package/dist/privacy.d.ts +1 -1
- package/dist/privacy.js +4 -10
- package/dist/safety.d.ts +1 -0
- package/dist/safety.js +7 -1
- package/dist/snapshot.d.ts +6 -3
- package/dist/snapshot.js +70 -25
- package/dist/transport.d.ts +11 -0
- package/dist/transport.js +296 -20
- package/dist/types.d.ts +7 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -10
package/dist/cli/eval.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { access, mkdir, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { basename, extname, join, resolve } from "node:path";
|
|
@@ -22,7 +22,7 @@ import { matchByName, parseScenarioSelector, resolveEvalSetPins, resolveScenario
|
|
|
22
22
|
import { collectDirectOutputs, stageDirectCase } from "./eval-direct.js";
|
|
23
23
|
import { runSimulation, } from "../evals/simulation.js";
|
|
24
24
|
import { collectExperimentVerdicts, compareVerdicts, metricPassed, } from "../evals/verdicts.js";
|
|
25
|
-
import { envFileArgument, envFileOptions } from "./env-file.js";
|
|
25
|
+
import { DEFAULT_ENV_FILE, envFileArgument, envFileOptions } from "./env-file.js";
|
|
26
26
|
const USAGE = `Usage: hue eval [adapter-file] [options]
|
|
27
27
|
|
|
28
28
|
Run a local agent against a published case or eval set, then print Hue's verdicts.
|
|
@@ -70,13 +70,15 @@ Modes:
|
|
|
70
70
|
environment-files:v1 and input:pdf for world cases with files
|
|
71
71
|
|
|
72
72
|
Connection:
|
|
73
|
-
--env-file <path> Load a dotenv file (HUE_API_KEY, HUE_BASE_URL) first
|
|
73
|
+
--env-file <path> Load a dotenv file (HUE_API_KEY, HUE_BASE_URL) first;
|
|
74
|
+
./.env.hue loads by default when HUE_API_KEY is not
|
|
75
|
+
set. Variables already set in the environment win
|
|
74
76
|
--env-path <path> Same as --env-file
|
|
75
77
|
--origin <url> Hue origin (default: HUE_BASE_URL or https://app.hue.run)
|
|
76
78
|
|
|
77
79
|
Output and limits:
|
|
78
80
|
--name <run name> Run name (default: <agent key> @ <revision>, hashes shortened)
|
|
79
|
-
--baseline <
|
|
81
|
+
--baseline <run id|url> Compare verdicts with a previous run
|
|
80
82
|
--json Print one JSON document on stdout; progress goes to stderr
|
|
81
83
|
--content Capture telemetry content (the default)
|
|
82
84
|
--no-content Omit telemetry inputs, outputs and messages
|
|
@@ -84,6 +86,8 @@ Output and limits:
|
|
|
84
86
|
explanations (stored by default; --worker always stores); with
|
|
85
87
|
--content the case span still carries the output
|
|
86
88
|
--save-version Freeze an unsaved eval-set version before running
|
|
89
|
+
--check One-shot: check the key, selection and agent, print what the
|
|
90
|
+
run would do and exit without creating a run or a world
|
|
87
91
|
--checkpoint-dir <path> Private checkpoint directory (default: .hue/eval/<agent-key>)
|
|
88
92
|
--concurrency <n> Cases in flight, 1-100 (default: 1)
|
|
89
93
|
--trace-not-accepted <policy> A case whose telemetry Hue did not accept in time: fail_case
|
|
@@ -147,6 +151,7 @@ function parse(argv) {
|
|
|
147
151
|
"no-output": { type: "boolean", default: false },
|
|
148
152
|
"trace-not-accepted": { type: "string" },
|
|
149
153
|
"save-version": { type: "boolean", default: false },
|
|
154
|
+
check: { type: "boolean", default: false },
|
|
150
155
|
"checkpoint-dir": { type: "string" },
|
|
151
156
|
concurrency: { type: "string" },
|
|
152
157
|
timeout: { type: "string" },
|
|
@@ -971,20 +976,27 @@ function parseMode(value) {
|
|
|
971
976
|
return value;
|
|
972
977
|
throw new UsageError("--mode must be auto, direct or simulation");
|
|
973
978
|
}
|
|
974
|
-
/**
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
979
|
+
/** The selection's cases, and whether the run is direct: when `--mode` leaves it open, direct
|
|
980
|
+
* means nothing pins a simulated world, not a Scenario and no case of the version. A simulation
|
|
981
|
+
* run cannot execute a case that pins no world, so a selection mixing both kinds is refused. */
|
|
982
|
+
async function inspectSelection(client, pins, mode) {
|
|
983
|
+
let cases = 0;
|
|
984
|
+
let worldCases = 0;
|
|
980
985
|
let after;
|
|
981
986
|
do {
|
|
982
987
|
const page = await client.listCases(pins.datasetVersionId, { after });
|
|
983
|
-
|
|
984
|
-
|
|
988
|
+
cases += page.items.length;
|
|
989
|
+
worldCases += page.items.filter((item) => item.environmentVersionId).length;
|
|
985
990
|
after = page.nextCursor ?? undefined;
|
|
986
991
|
} while (after);
|
|
987
|
-
|
|
992
|
+
const direct = mode === "auto"
|
|
993
|
+
? !pins.scenarioId && !pins.environmentVersionId && worldCases === 0
|
|
994
|
+
: mode === "direct";
|
|
995
|
+
const answerOnly = cases - worldCases;
|
|
996
|
+
const refusal = !direct && answerOnly > 0
|
|
997
|
+
? `${answerOnly} of the ${cases} ${cases === 1 ? "case pins" : "cases pin"} no world, so a simulation run would fail ${answerOnly === 1 ? "it" : "them"} before the agent starts. Put the answer-only cases in their own eval set${mode === "simulation" ? ", or drop --mode simulation" : ""}.`
|
|
998
|
+
: undefined;
|
|
999
|
+
return { direct, cases, worldCases, ...(refusal ? { refusal } : {}) };
|
|
988
1000
|
}
|
|
989
1001
|
function toJson(verdicts, runUrl, baseline, extra = {}, telemetry = []) {
|
|
990
1002
|
const notAccepted = new Map(telemetry.map((entry) => [entry.caseId, entry.issues]));
|
|
@@ -1055,7 +1067,7 @@ async function reportVerdicts(client, values, run, baselineId, output, signal) {
|
|
|
1055
1067
|
// The wait returns its partial state on abort rather than throwing, so Ctrl+C here must not
|
|
1056
1068
|
// fall through to a baseline read and a verdict table that nobody asked to finish.
|
|
1057
1069
|
if (signal.aborted) {
|
|
1058
|
-
process.stderr.write(
|
|
1070
|
+
process.stderr.write(`Interrupted. The cases already finished and Hue is still checking them; open ${run.runUrl} instead of rerunning.\n`);
|
|
1059
1071
|
return 130;
|
|
1060
1072
|
}
|
|
1061
1073
|
let baseline;
|
|
@@ -1086,7 +1098,7 @@ async function reportVerdicts(client, values, run, baselineId, output, signal) {
|
|
|
1086
1098
|
function parseBaseline(value) {
|
|
1087
1099
|
if (!value)
|
|
1088
1100
|
return undefined;
|
|
1089
|
-
const parsed = parseScenarioSelector(value, ["experiments"]);
|
|
1101
|
+
const parsed = parseScenarioSelector(value, ["runs", "experiments"]);
|
|
1090
1102
|
if (parsed.kind !== "id")
|
|
1091
1103
|
throw new UsageError("--baseline must be an experiment ID or its Hue URL");
|
|
1092
1104
|
return parsed.id;
|
|
@@ -1096,7 +1108,15 @@ async function runOnce(values, connection, agents, agent, hue, output, signal) {
|
|
|
1096
1108
|
const concurrency = integer("concurrency", values.concurrency, 1, 1, 100);
|
|
1097
1109
|
const baselineId = parseBaseline(values.baseline);
|
|
1098
1110
|
const mode = parseMode(values.mode);
|
|
1111
|
+
integer("wait", values.wait, 300, 0, 86_400);
|
|
1099
1112
|
const pins = await resolveSelection(client, values);
|
|
1113
|
+
const selection = await inspectSelection(client, pins, mode);
|
|
1114
|
+
if (values.check)
|
|
1115
|
+
return checkRun(client, values, pins, selection, agent, output);
|
|
1116
|
+
if (selection.refusal) {
|
|
1117
|
+
output.error(selection.refusal);
|
|
1118
|
+
return 1;
|
|
1119
|
+
}
|
|
1100
1120
|
if (!pins.saved) {
|
|
1101
1121
|
if (!values["save-version"]) {
|
|
1102
1122
|
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.`);
|
|
@@ -1107,7 +1127,7 @@ async function runOnce(values, connection, agents, agent, hue, output, signal) {
|
|
|
1107
1127
|
}
|
|
1108
1128
|
const runName = values.name ?? defaultRunName(agent);
|
|
1109
1129
|
const project = await client.checkConnection();
|
|
1110
|
-
if (
|
|
1130
|
+
if (selection.direct)
|
|
1111
1131
|
return runDirect({
|
|
1112
1132
|
client,
|
|
1113
1133
|
values,
|
|
@@ -1190,6 +1210,62 @@ async function runOnce(values, connection, agents, agent, hue, output, signal) {
|
|
|
1190
1210
|
telemetryNotAccepted: report.telemetryNotAccepted,
|
|
1191
1211
|
}, baselineId, output, signal);
|
|
1192
1212
|
}
|
|
1213
|
+
/**
|
|
1214
|
+
* `--check`: everything a one-shot run resolves before it creates anything (the key's project,
|
|
1215
|
+
* the selection, the case kind and the agent), printed with the command that would start it.
|
|
1216
|
+
* Nothing is created, frozen or checkpointed.
|
|
1217
|
+
*/
|
|
1218
|
+
async function checkRun(client, values, pins, selection, agent, output) {
|
|
1219
|
+
const project = await client.checkConnection();
|
|
1220
|
+
for (const id of pins.scorerVersionIds) {
|
|
1221
|
+
const found = await client.getScorerVersion(id).then(() => true, (error) => {
|
|
1222
|
+
if (error instanceof HueApiError && (error.status === 404 || error.status === 403))
|
|
1223
|
+
return false;
|
|
1224
|
+
throw error;
|
|
1225
|
+
});
|
|
1226
|
+
if (!found) {
|
|
1227
|
+
output.error(`Evaluator version ${id} was not found in project "${project.name}".`);
|
|
1228
|
+
return 1;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
const { direct, cases, worldCases, refusal } = selection;
|
|
1232
|
+
const unsaved = !pins.saved && !values["save-version"];
|
|
1233
|
+
const check = {
|
|
1234
|
+
ok: !unsaved && !refusal,
|
|
1235
|
+
project: { id: project.id, name: project.name },
|
|
1236
|
+
selection: {
|
|
1237
|
+
kind: pins.scenarioId ? "case" : "eval_set",
|
|
1238
|
+
name: pins.name,
|
|
1239
|
+
datasetVersionId: pins.datasetVersionId,
|
|
1240
|
+
saved: pins.saved,
|
|
1241
|
+
cases,
|
|
1242
|
+
evaluators: pins.scorerVersionIds.length,
|
|
1243
|
+
},
|
|
1244
|
+
mode: direct ? "direct" : "simulation",
|
|
1245
|
+
worldCases: direct ? 0 : worldCases,
|
|
1246
|
+
agent: { key: agent.key, revision: agent.revision, concurrency: values.concurrency ?? "1" },
|
|
1247
|
+
};
|
|
1248
|
+
if (values.json)
|
|
1249
|
+
process.stdout.write(`${JSON.stringify({ check })}\n`);
|
|
1250
|
+
else {
|
|
1251
|
+
output.log(`Project: ${project.name} (${project.id})`);
|
|
1252
|
+
output.log(`Selection: ${check.selection.kind === "case" ? "case" : "eval set"} "${pins.name}", ${cases} ${cases === 1 ? "case" : "cases"}, ${pins.scorerVersionIds.length} ${pins.scorerVersionIds.length === 1 ? "evaluator" : "evaluators"}`);
|
|
1253
|
+
output.log(direct
|
|
1254
|
+
? "Mode: direct; the agent answers without a simulated world or app mirrors"
|
|
1255
|
+
: `Mode: simulation; ${worldCases} ${worldCases === 1 ? "case gets" : "cases get"} a fresh world, reached through the HUE_SIM_*_URL mirrors and HUE_WORLD_TOKEN`);
|
|
1256
|
+
output.log(`Agent: ${agent.key} @ ${agent.revision}`);
|
|
1257
|
+
}
|
|
1258
|
+
if (refusal) {
|
|
1259
|
+
output.error(refusal);
|
|
1260
|
+
return 1;
|
|
1261
|
+
}
|
|
1262
|
+
if (unsaved) {
|
|
1263
|
+
output.error(`The ${pins.scenarioId ? "Scenario" : "eval set"} "${pins.name}" points at an unsaved version; add --save-version to freeze it when the run starts.`);
|
|
1264
|
+
return 1;
|
|
1265
|
+
}
|
|
1266
|
+
output.log("Check passed. Nothing was created; run the same command without --check to start one run.");
|
|
1267
|
+
return 0;
|
|
1268
|
+
}
|
|
1193
1269
|
/**
|
|
1194
1270
|
* Direct cases: one ordinary experiment through `runExperiment`. The agent receives the case's
|
|
1195
1271
|
* pinned files and returns generated documents; code evaluators pinned to the run stay deferred
|
|
@@ -1218,7 +1294,7 @@ async function runDirect(run, connection) {
|
|
|
1218
1294
|
});
|
|
1219
1295
|
let attempt = await store.read("active-attempt");
|
|
1220
1296
|
if (attempt && attempt.stage !== "completed" && attempt.selectionDigest !== selectionDigest)
|
|
1221
|
-
throw new Error("Recover the unfinished direct run before running a changed selection");
|
|
1297
|
+
throw new Error("Recover the unfinished direct run before running a changed selection: run the unchanged command again to finish it, or pass another --checkpoint-dir");
|
|
1222
1298
|
if (!attempt || attempt.stage === "completed") {
|
|
1223
1299
|
attempt = { selectionDigest, idempotencyKey: randomUUID(), stage: "preparing" };
|
|
1224
1300
|
await store.write("active-attempt", attempt);
|
|
@@ -1236,7 +1312,7 @@ async function runDirect(run, connection) {
|
|
|
1236
1312
|
await store.write("active-attempt", attempt);
|
|
1237
1313
|
}
|
|
1238
1314
|
experimentId = attempt.experimentId;
|
|
1239
|
-
runUrl = new URL(`/
|
|
1315
|
+
runUrl = new URL(`/runs/${experimentId}`, connection.baseUrl).toString();
|
|
1240
1316
|
output.log(`Run: ${runUrl}`);
|
|
1241
1317
|
output.log(`Experiment: ${experimentId}`);
|
|
1242
1318
|
report = await runExperiment({
|
|
@@ -1251,6 +1327,7 @@ async function runDirect(run, connection) {
|
|
|
1251
1327
|
traceNotAccepted: traceNotAcceptedPolicy(values["trace-not-accepted"]),
|
|
1252
1328
|
onTelemetryNotAccepted: telemetry.report,
|
|
1253
1329
|
concurrency: run.concurrency,
|
|
1330
|
+
signal,
|
|
1254
1331
|
scorers: [],
|
|
1255
1332
|
deferUnboundLocalScorers: true,
|
|
1256
1333
|
environmentEvidence: "when_pinned",
|
|
@@ -1308,7 +1385,7 @@ async function runWorker(values, connection, agents, agent, hue, output, signal)
|
|
|
1308
1385
|
};
|
|
1309
1386
|
client.onClaimed = (claim) => {
|
|
1310
1387
|
current = claim;
|
|
1311
|
-
output.log(`Claimed run ${claim.runId}: ${new URL(`/
|
|
1388
|
+
output.log(`Claimed run ${claim.runId}: ${new URL(`/runs/${claim.experimentId}`, connection.baseUrl).toString()}`);
|
|
1312
1389
|
};
|
|
1313
1390
|
const telemetry = telemetryReporter(output);
|
|
1314
1391
|
// A command can use the same stdin contract for both case kinds. Existing adapter files
|
|
@@ -1396,7 +1473,7 @@ async function runWorker(values, connection, agents, agent, hue, output, signal)
|
|
|
1396
1473
|
if (signal.aborted)
|
|
1397
1474
|
return;
|
|
1398
1475
|
if (values.json)
|
|
1399
|
-
process.stdout.write(`${JSON.stringify(toJson(verdicts, new URL(`/
|
|
1476
|
+
process.stdout.write(`${JSON.stringify(toJson(verdicts, new URL(`/runs/${current.experimentId}`, connection.baseUrl).toString(), undefined, {}, report.telemetryNotAccepted))}\n`);
|
|
1400
1477
|
else
|
|
1401
1478
|
renderTable(verdicts, output);
|
|
1402
1479
|
}
|
|
@@ -1446,8 +1523,10 @@ export async function runEvalCommand(argv) {
|
|
|
1446
1523
|
process.on("SIGTERM", interrupt);
|
|
1447
1524
|
let hue;
|
|
1448
1525
|
let json = false;
|
|
1526
|
+
let worker = false;
|
|
1449
1527
|
try {
|
|
1450
1528
|
const { values, positionals } = parse(argv);
|
|
1529
|
+
worker = values.worker;
|
|
1451
1530
|
if (values.content && values["no-content"])
|
|
1452
1531
|
throw new UsageError("Choose either --content or --no-content");
|
|
1453
1532
|
// A usage error before anything is prepared or created: no run is left to recover from it.
|
|
@@ -1478,6 +1557,10 @@ export async function runEvalCommand(argv) {
|
|
|
1478
1557
|
catch (error) {
|
|
1479
1558
|
throw new UsageError(error.message);
|
|
1480
1559
|
}
|
|
1560
|
+
// A key already in the environment keeps its own origin: the checkout's .env.hue is
|
|
1561
|
+
// consulted only when it has to supply the key, so it cannot point an inherited key elsewhere.
|
|
1562
|
+
if (envFile === undefined && !process.env.HUE_API_KEY && existsSync(DEFAULT_ENV_FILE))
|
|
1563
|
+
envFile = DEFAULT_ENV_FILE;
|
|
1481
1564
|
if (envFile) {
|
|
1482
1565
|
try {
|
|
1483
1566
|
process.loadEnvFile(resolve(envFile));
|
|
@@ -1488,7 +1571,7 @@ export async function runEvalCommand(argv) {
|
|
|
1488
1571
|
}
|
|
1489
1572
|
const apiKey = process.env.HUE_API_KEY?.trim();
|
|
1490
1573
|
if (!apiKey)
|
|
1491
|
-
throw new UsageError('HUE_API_KEY is required: a "Read and write" project key, set in the environment or an ignored --env-file');
|
|
1574
|
+
throw new UsageError('HUE_API_KEY is required: a "Read and write" project key, set in the environment, ./.env.hue or an ignored --env-file');
|
|
1492
1575
|
secrets.push(apiKey);
|
|
1493
1576
|
const baseUrl = values.origin ?? process.env.HUE_BASE_URL?.trim() ?? "https://app.hue.run";
|
|
1494
1577
|
const connection = { apiKey, baseUrl };
|
|
@@ -1503,6 +1586,8 @@ export async function runEvalCommand(argv) {
|
|
|
1503
1586
|
throw new UsageError("--worker takes no selection; Hue chooses the run to execute");
|
|
1504
1587
|
if (values.worker && values["no-output"])
|
|
1505
1588
|
throw new UsageError("--no-output applies to one-shot runs; a run launched from Hue always stores its outputs");
|
|
1589
|
+
if (values.worker && values.check)
|
|
1590
|
+
throw new UsageError("--check applies to one-shot runs");
|
|
1506
1591
|
if (!values.worker && values.capability?.length)
|
|
1507
1592
|
throw new UsageError("--capability applies to --worker only");
|
|
1508
1593
|
const loaded = adapterFile ? await loadAdapter(adapterFile) : undefined;
|
|
@@ -1535,7 +1620,9 @@ export async function runEvalCommand(argv) {
|
|
|
1535
1620
|
}
|
|
1536
1621
|
catch (error) {
|
|
1537
1622
|
if (controller.signal.aborted || error instanceof TargetCancelledError) {
|
|
1538
|
-
process.stderr.write(
|
|
1623
|
+
process.stderr.write(worker
|
|
1624
|
+
? "Interrupted.\n"
|
|
1625
|
+
: "Interrupted. The run stays open; run the same command again to resume it.\n");
|
|
1539
1626
|
return 130;
|
|
1540
1627
|
}
|
|
1541
1628
|
if (error instanceof UsageError) {
|
package/dist/cli/login.js
CHANGED
|
@@ -7,9 +7,8 @@ import { createInterface } from "node:readline";
|
|
|
7
7
|
import { Writable } from "node:stream";
|
|
8
8
|
import { parseArgs } from "node:util";
|
|
9
9
|
import { isLoopbackHost } from "../config.js";
|
|
10
|
-
import { envFileArgument, envFileOptions } from "./env-file.js";
|
|
10
|
+
import { DEFAULT_ENV_FILE, envFileArgument, envFileOptions } from "./env-file.js";
|
|
11
11
|
const DEFAULT_ORIGIN = "https://app.hue.run";
|
|
12
|
-
const DEFAULT_ENV_FILE = ".env.hue";
|
|
13
12
|
/** Settings section that lists and creates project service keys. */
|
|
14
13
|
const KEY_SETTINGS_PATH = "/settings/integrations";
|
|
15
14
|
const REQUEST_TIMEOUT_MILLIS = 10_000;
|
|
@@ -745,6 +744,6 @@ export async function runLoginCommand(argv, io = {}) {
|
|
|
745
744
|
// Without --auth it configures sign-in unless HUE_MCP_KEY is exported, so name the key stored.
|
|
746
745
|
out(` hue mcp install --client claude-code --auth key${mcpUrl === mcpUrlForOrigin(DEFAULT_ORIGIN) ? "" : ` --url ${mcpUrl}`}`);
|
|
747
746
|
if (stored.includes("evaluations"))
|
|
748
|
-
out(` hue eval --case "<name>" ./hue-agent.ts --env-file ${envDisplay}`);
|
|
747
|
+
out(` hue eval --case "<name>" ./hue-agent.ts${envDisplay === DEFAULT_ENV_FILE ? "" : ` --env-file ${envDisplay}`}`);
|
|
749
748
|
return 0;
|
|
750
749
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -149,13 +149,15 @@ export declare class HueClient {
|
|
|
149
149
|
* Adds a `hue.file` event to the active (or given) span for a file the work read, received or
|
|
150
150
|
* produced: `hue.file.sha256`, `hue.file.role`, `hue.file.media_type`, `hue.file.size` when known
|
|
151
151
|
* and, when `captureContent` is true, `hue.file.name`. `data` is hashed and measured locally and
|
|
152
|
-
*
|
|
152
|
+
* uploaded only with `upload: true`. The event is metadata, so it is recorded in both capture modes. An invalid
|
|
153
153
|
* record, or one without an active span, is omitted and counted, never thrown; an invalid name
|
|
154
154
|
* alone is omitted and counted while the rest is recorded.
|
|
155
155
|
*/
|
|
156
156
|
recordFile(file: FileRecord, explicitContext?: Context): void;
|
|
157
157
|
/** The keys of each span's content values that were cut to Hue's value cap, for `hue.truncated`. */
|
|
158
158
|
private truncatedKeys;
|
|
159
|
+
/** Distinguishes requests whose helper events otherwise have identical metadata. */
|
|
160
|
+
private fileEventTimes;
|
|
159
161
|
/** A content value's bounds: Hue's value cap as the receiver last advertised it. */
|
|
160
162
|
private contentLimits;
|
|
161
163
|
/**
|
package/dist/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import { isMediaName } from "./inline-files.js";
|
|
3
4
|
import { context, isSpanContextValid, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
|
|
4
5
|
import { defaultTextMapGetter, defaultTextMapSetter } from "@opentelemetry/api";
|
|
5
6
|
import { SeverityNumber } from "@opentelemetry/api-logs";
|
|
@@ -10,7 +11,7 @@ import { defaultResource, resourceFromAttributes } from "@opentelemetry/resource
|
|
|
10
11
|
import { HUE_SCOPE } from "./config.js";
|
|
11
12
|
import { hostedServerAddresses, hostedToolActivity, hostedToolProvider, providerErrorDescription, } from "./provider-tools.js";
|
|
12
13
|
import { toolCatalogSummary } from "./tool-definitions.js";
|
|
13
|
-
import { contentLimitsOf, encodeBoundedContent, encodeContent, noopSpan, safeSpan, } from "./safety.js";
|
|
14
|
+
import { contentLimitsOf, encodeBoundedContent, encodeContent, noopSpan, safeSpan, spanSource, } from "./safety.js";
|
|
14
15
|
import { TRUNCATED_KEY, withTruncatedKeys } from "./privacy.js";
|
|
15
16
|
import { MAX_BLOB_BYTES } from "./blobs.js";
|
|
16
17
|
import { createHueTransport, exportBudgetMillis, HueExportError, } from "./transport.js";
|
|
@@ -662,7 +663,7 @@ export class HueClient {
|
|
|
662
663
|
* Adds a `hue.file` event to the active (or given) span for a file the work read, received or
|
|
663
664
|
* produced: `hue.file.sha256`, `hue.file.role`, `hue.file.media_type`, `hue.file.size` when known
|
|
664
665
|
* and, when `captureContent` is true, `hue.file.name`. `data` is hashed and measured locally and
|
|
665
|
-
*
|
|
666
|
+
* uploaded only with `upload: true`. The event is metadata, so it is recorded in both capture modes. An invalid
|
|
666
667
|
* record, or one without an active span, is omitted and counted, never thrown; an invalid name
|
|
667
668
|
* alone is omitted and counted while the rest is recorded.
|
|
668
669
|
*/
|
|
@@ -680,26 +681,30 @@ export class HueClient {
|
|
|
680
681
|
throw new TypeError("Invalid media type");
|
|
681
682
|
let sha256 = typeof file.sha256 === "string" ? file.sha256.toLowerCase() : file.sha256;
|
|
682
683
|
let byteSize = file.byteSize;
|
|
684
|
+
let bytes;
|
|
683
685
|
if (data !== undefined) {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
return Buffer.from(data, "utf8");
|
|
692
|
-
})()
|
|
693
|
-
: data instanceof Uint8Array
|
|
694
|
-
? data.byteLength <= MAX_FILE_DATA_BYTES
|
|
695
|
-
? data
|
|
696
|
-
: (() => {
|
|
686
|
+
bytes =
|
|
687
|
+
typeof data === "string"
|
|
688
|
+
? (() => {
|
|
689
|
+
// Buffer.byteLength measures UTF-8 without allocating the copy that hashing would
|
|
690
|
+
// otherwise require. Reject before Buffer.from/createHash can retain large input.
|
|
691
|
+
if (data.length > MAX_FILE_DATA_BYTES ||
|
|
692
|
+
Buffer.byteLength(data, "utf8") > MAX_FILE_DATA_BYTES)
|
|
697
693
|
throw new RangeError("File data exceeds Hue's 25 MiB limit");
|
|
698
|
-
|
|
699
|
-
|
|
694
|
+
return Buffer.from(data, "utf8");
|
|
695
|
+
})()
|
|
696
|
+
: data instanceof Uint8Array
|
|
697
|
+
? data.byteLength <= MAX_FILE_DATA_BYTES
|
|
698
|
+
? data
|
|
699
|
+
: (() => {
|
|
700
|
+
throw new RangeError("File data exceeds Hue's 25 MiB limit");
|
|
701
|
+
})()
|
|
702
|
+
: undefined;
|
|
700
703
|
if (!bytes)
|
|
701
704
|
throw new TypeError("File data must be bytes or a string");
|
|
702
|
-
const digest =
|
|
705
|
+
const digest = bytes.byteLength > 0 && file.upload === true && this.transport.holdsValues()
|
|
706
|
+
? this.transport.digestMedia(bytes, span.spanContext().traceId).sha256
|
|
707
|
+
: createHash("sha256").update(bytes).digest("hex");
|
|
703
708
|
// A caller-supplied digest or size must describe the same bytes.
|
|
704
709
|
if ((sha256 !== undefined && sha256 !== digest) ||
|
|
705
710
|
(byteSize !== undefined && byteSize !== bytes.byteLength))
|
|
@@ -719,12 +724,55 @@ export class HueClient {
|
|
|
719
724
|
if (byteSize !== undefined)
|
|
720
725
|
attributes["hue.file.size"] = byteSize;
|
|
721
726
|
if (this.captureContent && name !== undefined) {
|
|
722
|
-
if (
|
|
727
|
+
if (isMediaName(name))
|
|
723
728
|
attributes["hue.file.name"] = name;
|
|
724
729
|
else
|
|
725
730
|
this.instrumentationFailed();
|
|
726
731
|
}
|
|
727
|
-
span
|
|
732
|
+
// Let OpenTelemetry use the span's wall-clock anchor, then keep this event's exact
|
|
733
|
+
// timestamp for upload admission. A dropped event cannot authorize an upload.
|
|
734
|
+
const source = spanSource(span);
|
|
735
|
+
const previous = this.fileEventTimes.get(source);
|
|
736
|
+
const after = (time) => {
|
|
737
|
+
if (previous &&
|
|
738
|
+
(time[0] < previous[0] || (time[0] === previous[0] && time[1] <= previous[1]))) {
|
|
739
|
+
time[0] = previous[0];
|
|
740
|
+
time[1] = previous[1] + 1;
|
|
741
|
+
if (time[1] === 1_000_000_000) {
|
|
742
|
+
time[0]++;
|
|
743
|
+
time[1] = 0;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return time;
|
|
747
|
+
};
|
|
748
|
+
let eventTime;
|
|
749
|
+
if (Array.isArray(source.events)) {
|
|
750
|
+
const previousEvent = source.events.at(-1);
|
|
751
|
+
span.addEvent("hue.file", attributes);
|
|
752
|
+
const event = source.events.at(-1);
|
|
753
|
+
eventTime = event && event !== previousEvent ? after(event.time) : undefined;
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
// A span that does not expose its events (an API-only wrapper in attach mode): stamp the
|
|
757
|
+
// event with the wall clock and keep that exact time for upload admission.
|
|
758
|
+
const now = Date.now();
|
|
759
|
+
eventTime = after([Math.floor(now / 1000), (now % 1000) * 1_000_000]);
|
|
760
|
+
span.addEvent("hue.file", attributes, [eventTime[0], eventTime[1]]);
|
|
761
|
+
}
|
|
762
|
+
if (eventTime)
|
|
763
|
+
this.fileEventTimes.set(source, eventTime);
|
|
764
|
+
if (file.upload === true && this.transport.offloadsValues()) {
|
|
765
|
+
if (!this.transport.stageMedia(span, bytes && eventTime
|
|
766
|
+
? {
|
|
767
|
+
bytes,
|
|
768
|
+
digest: { sha256, size: bytes.byteLength },
|
|
769
|
+
contentType: mediaType,
|
|
770
|
+
eventTime,
|
|
771
|
+
...(isMediaName(name) ? { name } : {}),
|
|
772
|
+
}
|
|
773
|
+
: undefined))
|
|
774
|
+
this.instrumentationFailed();
|
|
775
|
+
}
|
|
728
776
|
}
|
|
729
777
|
catch {
|
|
730
778
|
this.instrumentationFailed();
|
|
@@ -732,6 +780,8 @@ export class HueClient {
|
|
|
732
780
|
}
|
|
733
781
|
/** The keys of each span's content values that were cut to Hue's value cap, for `hue.truncated`. */
|
|
734
782
|
truncatedKeys = new WeakMap();
|
|
783
|
+
/** Distinguishes requests whose helper events otherwise have identical metadata. */
|
|
784
|
+
fileEventTimes = new WeakMap();
|
|
735
785
|
/** A content value's bounds: Hue's value cap as the receiver last advertised it. */
|
|
736
786
|
contentLimits() {
|
|
737
787
|
return contentLimitsOf(this.transport.receiverLimits().valueBytes);
|
package/dist/evals/attempt.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
2
|
import type { CoverageGap } from "../environment/types.js";
|
|
3
3
|
import type { SimulationMcpCapability } from "./types.js";
|
|
4
4
|
/** Canonical caller-observed agent configuration after missing evidence is made explicit. */
|
package/dist/evals/attempt.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { z } from "zod";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
3
|
import { json, valueBounds } from "./json.js";
|
|
4
4
|
const canonicalUuid = z.uuid().transform((value) => value.toLowerCase());
|
|
5
5
|
const sha256Digest = z.string().regex(/^sha256:[0-9a-f]{64}$/);
|
|
@@ -15,11 +15,15 @@ export declare class CheckpointIdentityError extends Error {
|
|
|
15
15
|
captureContent?: unknown;
|
|
16
16
|
} | undefined);
|
|
17
17
|
}
|
|
18
|
-
/** One owner per directory. A
|
|
19
|
-
*
|
|
18
|
+
/** One owner per directory. A forced exit of `hue eval`, which stops its agents first, releases
|
|
19
|
+
* it; a crash leaves .lock, which the next owner reclaims once the process that held it on this
|
|
20
|
+
* machine has exited. Any other lock is left for explicit operator recovery. */
|
|
20
21
|
export declare class CheckpointStore {
|
|
21
22
|
readonly directory: string;
|
|
22
23
|
private untrack;
|
|
24
|
+
/** Whether this store took over the lock of a process that died here, so whatever that process
|
|
25
|
+
* was running never finished. */
|
|
26
|
+
reclaimed: boolean;
|
|
23
27
|
private constructor();
|
|
24
28
|
static acquire(directory: string, identity: unknown): Promise<CheckpointStore>;
|
|
25
29
|
read<T>(key: string): Promise<T | undefined>;
|
package/dist/evals/checkpoint.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { constants, rmSync } from "node:fs";
|
|
2
|
-
import { lstat, mkdir, open, rename, rm } from "node:fs/promises";
|
|
2
|
+
import { lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { hostname } from "node:os";
|
|
3
4
|
import { join, resolve, sep } from "node:path";
|
|
4
|
-
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
6
|
import { onForcedExit } from "./exit-cleanup.js";
|
|
6
7
|
import { digest } from "./json.js";
|
|
7
8
|
const CONTENT_POLICY = ["persistResultContent", "captureContent"];
|
|
@@ -46,11 +47,74 @@ function contentPolicyOnly(prior, identity) {
|
|
|
46
47
|
return undefined;
|
|
47
48
|
return Object.fromEntries(CONTENT_POLICY.map((key) => [key, before[key]]));
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
async function createLock(lock) {
|
|
51
|
+
try {
|
|
52
|
+
await mkdir(lock, { mode: 0o700 });
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error.code === "EEXIST")
|
|
57
|
+
return false;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Whether a process with this ID is running; one owned by another user still counts. */
|
|
62
|
+
function running(pid) {
|
|
63
|
+
try {
|
|
64
|
+
process.kill(pid, 0);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
return error.code === "EPERM";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Takes over a lock whose owner ran on this machine and has exited. The lock moves to a name
|
|
72
|
+
* derived from its unique owner record, which a rename can claim only once, so of the processes
|
|
73
|
+
* that saw the same dead owner one takes the lock and no live lock is ever moved; that name stays
|
|
74
|
+
* behind as the record. A lock without a readable owner, from another machine or from an SDK that
|
|
75
|
+
* did not record the machine is never reclaimed. */
|
|
76
|
+
async function reclaimLock(root, lock) {
|
|
77
|
+
const owner = await readFile(join(lock, "owner.json"), "utf8").catch(() => undefined);
|
|
78
|
+
if (owner === undefined)
|
|
79
|
+
return false;
|
|
80
|
+
let pid;
|
|
81
|
+
let host;
|
|
82
|
+
try {
|
|
83
|
+
({ pid, host } =
|
|
84
|
+
JSON.parse(owner).value ?? {});
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
if (typeof pid !== "number" ||
|
|
90
|
+
!Number.isInteger(pid) ||
|
|
91
|
+
pid <= 0 ||
|
|
92
|
+
pid === process.pid ||
|
|
93
|
+
host !== hostname() ||
|
|
94
|
+
running(pid))
|
|
95
|
+
return false;
|
|
96
|
+
const dead = join(root, `.lock-dead-${createHash("sha256").update(owner).digest("hex")}`);
|
|
97
|
+
try {
|
|
98
|
+
await rename(lock, dead);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if ((await readFile(join(dead, "owner.json"), "utf8").catch(() => undefined)) !== owner) {
|
|
104
|
+
await rename(dead, lock).catch(() => { });
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
return createLock(lock);
|
|
108
|
+
}
|
|
109
|
+
/** One owner per directory. A forced exit of `hue eval`, which stops its agents first, releases
|
|
110
|
+
* it; a crash leaves .lock, which the next owner reclaims once the process that held it on this
|
|
111
|
+
* machine has exited. Any other lock is left for explicit operator recovery. */
|
|
51
112
|
export class CheckpointStore {
|
|
52
113
|
directory;
|
|
53
114
|
untrack = () => { };
|
|
115
|
+
/** Whether this store took over the lock of a process that died here, so whatever that process
|
|
116
|
+
* was running never finished. */
|
|
117
|
+
reclaimed = false;
|
|
54
118
|
constructor(directory) {
|
|
55
119
|
this.directory = directory;
|
|
56
120
|
}
|
|
@@ -61,15 +125,14 @@ export class CheckpointStore {
|
|
|
61
125
|
if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0)
|
|
62
126
|
throw new Error("Use a private checkpoint directory (mode 0700, no symlink)");
|
|
63
127
|
const store = new CheckpointStore(root);
|
|
128
|
+
const lock = join(root, ".lock");
|
|
129
|
+
const created = await createLock(lock);
|
|
130
|
+
if (!created && !(await reclaimLock(root, lock)))
|
|
131
|
+
throw new Error("Checkpoint directory is locked by a process that may still be running; confirm it stopped before explicitly removing .lock");
|
|
132
|
+
store.reclaimed = !created;
|
|
133
|
+
store.untrack = onForcedExit(() => rmSync(lock, { recursive: true, force: true }));
|
|
64
134
|
try {
|
|
65
|
-
await
|
|
66
|
-
store.untrack = onForcedExit(() => rmSync(join(root, ".lock"), { recursive: true, force: true }));
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
throw new Error("Checkpoint directory is locked; confirm its owner stopped before explicitly removing .lock");
|
|
70
|
-
}
|
|
71
|
-
try {
|
|
72
|
-
await store.write(".lock/owner", { pid: process.pid });
|
|
135
|
+
await store.write(".lock/owner", { pid: process.pid, host: hostname(), id: randomUUID() });
|
|
73
136
|
const expected = { format: 1, identity, digest: digest(identity) };
|
|
74
137
|
const prior = await store.read("manifest");
|
|
75
138
|
if (prior && (prior.format !== 1 || prior.digest !== expected.digest))
|
package/dist/evals/files.js
CHANGED
|
@@ -30,6 +30,18 @@ export const outputContentTypes = [
|
|
|
30
30
|
"image/png",
|
|
31
31
|
"image/jpeg",
|
|
32
32
|
"image/webp",
|
|
33
|
+
"image/gif",
|
|
34
|
+
"image/avif",
|
|
35
|
+
"audio/mpeg",
|
|
36
|
+
"audio/wav",
|
|
37
|
+
"audio/ogg",
|
|
38
|
+
"audio/webm",
|
|
39
|
+
"audio/mp4",
|
|
40
|
+
"audio/flac",
|
|
41
|
+
"audio/opus",
|
|
42
|
+
"audio/aac",
|
|
43
|
+
"video/mp4",
|
|
44
|
+
"video/webm",
|
|
33
45
|
];
|
|
34
46
|
/** Thrown when the target's declared files cannot be used; recorded as the target's error. */
|
|
35
47
|
export class OutputFileError extends Error {
|
package/dist/evals/runner.d.ts
CHANGED
|
@@ -105,6 +105,15 @@ export interface RunExperimentTargetContext {
|
|
|
105
105
|
export interface RunExperimentOptions extends RunnerOptions {
|
|
106
106
|
/** Hue tracing client; each case runs inside a `hue.experiment.case` span. */
|
|
107
107
|
hue: HueClient;
|
|
108
|
+
/** Caller cancellation. Once it aborts, no further case starts, a case whose target stops with
|
|
109
|
+
* `TargetCancelledError` is saved as interrupted rather than completed, and the experiment is
|
|
110
|
+
* left unfinished: the call rejects with `TargetCancelledError`, and calling it again with the
|
|
111
|
+
* same checkpoint directory keeps the finished cases and runs the interrupted ones anew. */
|
|
112
|
+
signal?: AbortSignal;
|
|
113
|
+
/** Rerun, as a new attempt, a case that a process which died on this machine left running.
|
|
114
|
+
* Only for targets that give every attempt a fresh world: elsewhere the dead run's agent may
|
|
115
|
+
* still be acting, so such a case stays uncertain. */
|
|
116
|
+
rerunCrashedCases?: boolean;
|
|
108
117
|
/** Experiment to run; its dataset version must be frozen. */
|
|
109
118
|
experimentId: string;
|
|
110
119
|
/** Whether each case waits for acknowledged trace export or explicitly omits evidence. Required. */
|