@hue-run/sdk 0.9.0 → 0.11.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 +240 -49
- package/ENVIRONMENTS.md +48 -9
- package/EVALUATIONS.md +41 -21
- package/README.md +70 -30
- 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 +504 -81
- package/dist/cli/listen.d.ts +76 -0
- package/dist/cli/listen.js +816 -0
- package/dist/cli/mcp.d.ts +65 -7
- package/dist/cli/mcp.js +271 -73
- package/dist/client.d.ts +7 -4
- package/dist/client.js +36 -10
- package/dist/evals/checkpoint.d.ts +15 -1
- package/dist/evals/checkpoint.js +34 -3
- package/dist/evals/client.d.ts +24 -4
- package/dist/evals/client.js +92 -38
- 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 +69 -3
- package/dist/evals/files.js +255 -42
- package/dist/evals/json.js +116 -9
- 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 +199 -21
- package/dist/evals/scenarios.d.ts +7 -5
- package/dist/evals/scenarios.js +71 -14
- package/dist/evals/scorer-publication.js +58 -3
- package/dist/evals/simulation.d.ts +17 -4
- package/dist/evals/simulation.js +8 -0
- package/dist/evals/types.d.ts +48 -0
- package/dist/evals/verdicts.d.ts +26 -2
- package/dist/evals/verdicts.js +72 -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 +48 -17
- package/dist/provider-tools.d.ts +12 -1
- package/dist/provider-tools.js +192 -106
- package/dist/safety.d.ts +7 -1
- package/dist/safety.js +9 -7
- package/dist/setup/cli.js +31 -8
- package/dist/setup/peers.d.ts +13 -0
- package/dist/setup/peers.js +44 -0
- package/dist/snapshot.d.ts +2 -2
- package/dist/snapshot.js +47 -12
- package/dist/tool-definitions.d.ts +18 -0
- package/dist/tool-definitions.js +232 -0
- package/dist/transport.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@ import type { EnvironmentTool } from "../environment/tools.js";
|
|
|
4
4
|
import type { WorldHandoff } from "../environment/types.js";
|
|
5
5
|
import type { ActualAgentManifestInputV2, AttemptConnectionBundleV2, RequestedAttemptProviderV2 } from "./attempt.js";
|
|
6
6
|
import type { EvaluationClient } from "./client.js";
|
|
7
|
-
import { type RunExperimentTargetContext, type RunnerReport } from "./runner.js";
|
|
7
|
+
import { type RunExperimentTargetContext, type RunnerReport, type TelemetryNotAccepted } from "./runner.js";
|
|
8
8
|
import type { ExperimentCase, JsonValue, LocalAgentRegistration, LocalFile, LocalScorer, TargetResult } from "./types.js";
|
|
9
9
|
/** Capability strings a registration declares; Hue offers only matching cases. */
|
|
10
10
|
export declare const localAgentCapabilities: {
|
|
@@ -13,6 +13,10 @@ export declare const localAgentCapabilities: {
|
|
|
13
13
|
/** Ordinary cases run directly on this machine: JSON inputs and pinned input files in,
|
|
14
14
|
* JSON output and generated files out. */
|
|
15
15
|
readonly direct: "direct:v1";
|
|
16
|
+
/** Cases pinned to a world that also carry agent-visible input files: `target` receives them
|
|
17
|
+
* with the world and may return generated files. Declare it with `input:<extension>` for each
|
|
18
|
+
* accepted file type; it requires `target`. */
|
|
19
|
+
readonly environmentFiles: "environment-files:v1";
|
|
16
20
|
};
|
|
17
21
|
/** Candidate-visible context for one queued local agent execution. */
|
|
18
22
|
export interface LocalAgentTargetContext {
|
|
@@ -47,6 +51,13 @@ export interface LocalAgentTargetContext {
|
|
|
47
51
|
/** Credential-bearing provider connections for this callback only. Hue never
|
|
48
52
|
* checkpoints, logs or adds this response to parity digests. */
|
|
49
53
|
connectionBundle?: AttemptConnectionBundleV2;
|
|
54
|
+
/** Verified copies of the case's agent-visible input files; evaluator-only files are
|
|
55
|
+
* withheld. Hue offers a world case with such files only to a registration declaring
|
|
56
|
+
* `environment-files:v1`. Empty when the case has none. */
|
|
57
|
+
files: LocalFile[];
|
|
58
|
+
/** Private directory for this case, removed after it; return generated files with
|
|
59
|
+
* `withFiles`. */
|
|
60
|
+
outputDirectory: string;
|
|
50
61
|
}
|
|
51
62
|
/** Candidate-visible context for one queued ordinary case run directly on this machine. */
|
|
52
63
|
export interface LocalAgentDirectContext {
|
|
@@ -88,6 +99,12 @@ export interface RunLocalAgentOptions {
|
|
|
88
99
|
scorers?: LocalScorer[];
|
|
89
100
|
/** Cases in flight, between 1 and 16; defaults to 1. */
|
|
90
101
|
concurrency?: number;
|
|
102
|
+
/** What a case does when its telemetry is not accepted; see
|
|
103
|
+
* `traceNotAccepted` of {@link runExperiment}. Defaults to `"stop"`. */
|
|
104
|
+
traceNotAccepted?: "stop" | "fail_case";
|
|
105
|
+
/** Called as each case is failed for its telemetry; see `onTelemetryNotAccepted` of
|
|
106
|
+
* {@link runExperiment}. */
|
|
107
|
+
onTelemetryNotAccepted?(entry: TelemetryNotAccepted): void | Promise<void>;
|
|
91
108
|
/** Polling interval in milliseconds, 250–60000; defaults to 2000. */
|
|
92
109
|
pollIntervalMillis?: number;
|
|
93
110
|
/** Stops polling cooperatively; does not cancel an active callback. */
|
|
@@ -116,8 +133,9 @@ export interface RunLocalAgentOptions {
|
|
|
116
133
|
surfaceKey: "google.gmail/mcp" | "slack/mcp";
|
|
117
134
|
};
|
|
118
135
|
/** Runs cases pinned to a hosted world (`environment:v1`): invokes the existing agent against
|
|
119
|
-
* isolated tools and candidate-safe context.
|
|
120
|
-
|
|
136
|
+
* isolated tools and candidate-safe context. Return the output, or `withFiles(output, files)`
|
|
137
|
+
* when it generated files. */
|
|
138
|
+
target?(inputs: JsonValue, tools: Record<string, EnvironmentTool>, context: LocalAgentTargetContext): JsonValue | TargetResult | undefined | Promise<JsonValue | TargetResult | undefined>;
|
|
121
139
|
/** Runs ordinary cases without a world (`direct:v1`): the agent receives the case inputs
|
|
122
140
|
* and verified input files and returns JSON output and/or generated files. */
|
|
123
141
|
directTarget?(inputs: JsonValue, context: LocalAgentDirectContext): JsonValue | TargetResult | undefined | Promise<JsonValue | TargetResult | undefined>;
|
|
@@ -10,6 +10,10 @@ export const localAgentCapabilities = {
|
|
|
10
10
|
/** Ordinary cases run directly on this machine: JSON inputs and pinned input files in,
|
|
11
11
|
* JSON output and generated files out. */
|
|
12
12
|
direct: "direct:v1",
|
|
13
|
+
/** Cases pinned to a world that also carry agent-visible input files: `target` receives them
|
|
14
|
+
* with the world and may return generated files. Declare it with `input:<extension>` for each
|
|
15
|
+
* accepted file type; it requires `target`. */
|
|
16
|
+
environmentFiles: "environment-files:v1",
|
|
13
17
|
};
|
|
14
18
|
/** Allowlist the candidate surface for an ordinary case; frozen expectations stay with grading. */
|
|
15
19
|
export function localAgentDirectContext(context, signal) {
|
|
@@ -44,6 +48,8 @@ function localAgentTargetContext(context) {
|
|
|
44
48
|
...(context.connectionBundle
|
|
45
49
|
? { connectionBundle: structuredClone(context.connectionBundle) }
|
|
46
50
|
: {}),
|
|
51
|
+
files: structuredClone(context.files),
|
|
52
|
+
outputDirectory: context.outputDirectory,
|
|
47
53
|
};
|
|
48
54
|
}
|
|
49
55
|
/** The registration's capabilities: explicit values plus one per supplied callback. */
|
|
@@ -55,6 +61,8 @@ export function registeredCapabilities(options) {
|
|
|
55
61
|
throw new TypeError("direct:v1 requires a directTarget callback");
|
|
56
62
|
if (declared.includes(localAgentCapabilities.environment) && !options.target)
|
|
57
63
|
throw new TypeError("environment:v1 requires a target callback");
|
|
64
|
+
if (declared.includes(localAgentCapabilities.environmentFiles) && !options.target)
|
|
65
|
+
throw new TypeError("environment-files:v1 requires a target callback");
|
|
58
66
|
return [
|
|
59
67
|
...new Set([
|
|
60
68
|
...declared,
|
|
@@ -163,6 +171,12 @@ export async function runLocalAgent(options) {
|
|
|
163
171
|
// Worker dispatch uses the case pin: directTarget never receives a world.
|
|
164
172
|
environmentEvidence: options.directTarget ? "when_pinned" : "required",
|
|
165
173
|
traceEvidence: { mode: "required" },
|
|
174
|
+
...(options.traceNotAccepted ? { traceNotAccepted: options.traceNotAccepted } : {}),
|
|
175
|
+
...(options.onTelemetryNotAccepted
|
|
176
|
+
? {
|
|
177
|
+
onTelemetryNotAccepted: (entry) => options.onTelemetryNotAccepted(entry),
|
|
178
|
+
}
|
|
179
|
+
: {}),
|
|
166
180
|
scorers: options.scorers,
|
|
167
181
|
concurrency: options.concurrency,
|
|
168
182
|
target: (inputs, context) => {
|
package/dist/evals/runner.d.ts
CHANGED
|
@@ -93,6 +93,19 @@ export interface RunExperimentOptions extends RunnerOptions {
|
|
|
93
93
|
/** Why evidence is omitted, up to 4000 characters. */
|
|
94
94
|
reason: string;
|
|
95
95
|
};
|
|
96
|
+
/**
|
|
97
|
+
* What a case does when evidence is required and its trace or logs are not fully accepted.
|
|
98
|
+
* `"stop"` (the default) keeps the saved outcome, leaves the execution started for recovery and
|
|
99
|
+
* rejects. `"fail_case"` completes the execution as failed instead, with the evidence omitted
|
|
100
|
+
* under the reason `telemetry_not_accepted` and without its output or generated files, so no
|
|
101
|
+
* scorer can pass it; reports it in {@link RunnerReport.telemetryNotAccepted} and goes on with
|
|
102
|
+
* the other cases.
|
|
103
|
+
*/
|
|
104
|
+
traceNotAccepted?: "stop" | "fail_case";
|
|
105
|
+
/** Called when this call completes a case as failed under `traceNotAccepted: "fail_case"`,
|
|
106
|
+
* before the run goes on, so the failure is known even if a later case stops the run. A call
|
|
107
|
+
* that resumes an already completed case lists it in the report without calling again. */
|
|
108
|
+
onTelemetryNotAccepted?(entry: TelemetryNotAccepted): void | Promise<void>;
|
|
96
109
|
/** Runs the application for one frozen case; return the output, `withFiles(output, files)`
|
|
97
110
|
* when it generated files, or `undefined` when unavailable. */
|
|
98
111
|
target(inputs: JsonValue, context: RunExperimentTargetContext): JsonValue | TargetResult | undefined | Promise<JsonValue | TargetResult | undefined>;
|
|
@@ -112,7 +125,39 @@ export interface RunnerReport {
|
|
|
112
125
|
resultIds: string[];
|
|
113
126
|
/** Pins without a local implementation, left pending for their authorized executor. */
|
|
114
127
|
deferredScorerVersionIds: string[];
|
|
128
|
+
/** Cases completed as failed because their telemetry was not accepted, under
|
|
129
|
+
* `traceNotAccepted: "fail_case"`; absent when there were none. */
|
|
130
|
+
telemetryNotAccepted?: TelemetryNotAccepted[];
|
|
115
131
|
}
|
|
132
|
+
/** Export issue counts, never content: which signal, what happened, the HTTP status when Hue
|
|
133
|
+
* answered, and how many records. */
|
|
134
|
+
export interface TelemetryIssueCount {
|
|
135
|
+
/** Signal the records belong to. */
|
|
136
|
+
signal: "traces" | "logs";
|
|
137
|
+
/** `rejected` by Hue, `failed` to deliver, `dropped` from the queue, or an `invalid` record. */
|
|
138
|
+
kind: "rejected" | "failed" | "dropped" | "invalid";
|
|
139
|
+
/** HTTP status when the issue came from Hue's answer. */
|
|
140
|
+
status?: number;
|
|
141
|
+
/** Records affected. */
|
|
142
|
+
count: number;
|
|
143
|
+
}
|
|
144
|
+
/** A case whose trace or logs were not fully accepted, completed as failed. */
|
|
145
|
+
export interface TelemetryNotAccepted {
|
|
146
|
+
/** Experiment case (item) ID. */
|
|
147
|
+
caseId: string;
|
|
148
|
+
/** The case's caller-chosen key. */
|
|
149
|
+
caseKey: string;
|
|
150
|
+
/** Execution completed as failed. */
|
|
151
|
+
executionId: string;
|
|
152
|
+
/** Sanitized issues the transport reported while the case's telemetry was exported. */
|
|
153
|
+
issues: TelemetryIssueCount[];
|
|
154
|
+
}
|
|
155
|
+
/** Stable reason, and first word of the omission reason, of a case failed for its telemetry. */
|
|
156
|
+
export declare const TELEMETRY_NOT_ACCEPTED = "telemetry_not_accepted";
|
|
157
|
+
/** The issues of a failed export, summed by signal, kind and status. */
|
|
158
|
+
export declare function telemetryIssueCounts(error: unknown): TelemetryIssueCount[];
|
|
159
|
+
/** `telemetry_not_accepted`, then the issue counts, for example `traces failed 1 (HTTP 400)`. */
|
|
160
|
+
export declare function describeTelemetryIssues(issues: TelemetryIssueCount[]): string;
|
|
116
161
|
/**
|
|
117
162
|
* Runs every case of a frozen experiment through `target` on this machine, completes each
|
|
118
163
|
* execution, scores it with local scorers and uploads the results, checkpointing so an interrupted
|
package/dist/evals/runner.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
3
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
3
4
|
import { join, resolve } from "node:path";
|
|
4
5
|
import { ROOT_CONTEXT } from "@opentelemetry/api";
|
|
5
6
|
import { HueExportError } from "../transport.js";
|
|
6
7
|
import { HueApiError } from "./client.js";
|
|
7
8
|
import { loadEnvironmentEvidence } from "./environment-evidence.js";
|
|
8
9
|
import { CheckpointStore } from "./checkpoint.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
10
|
+
import { onForcedExit } from "./exit-cleanup.js";
|
|
11
|
+
import { assertSafeFileNames, downloadCaseFiles, downloadCaseInputs, localOutputFiles, verifyCaseFiles, OutputFileError, stageOutputFiles, targetFileRoles, uploadOutputFiles, } from "./files.js";
|
|
12
|
+
import { json, uuid, valueBounds } from "./json.js";
|
|
11
13
|
import { executableHere, persistedScore, scoreLocally, validateScorerBindings } from "./scorers.js";
|
|
12
14
|
import { TargetResult, } from "./types.js";
|
|
13
15
|
/**
|
|
@@ -39,6 +41,23 @@ export class OutcomeSerializationError extends Error {
|
|
|
39
41
|
this.name = "OutcomeSerializationError";
|
|
40
42
|
}
|
|
41
43
|
}
|
|
44
|
+
/** The bound `json` names when a valid output is over what Hue stores for one case. */
|
|
45
|
+
const outputLimits = new Map([
|
|
46
|
+
["JSON exceeds byte limit", "bytes"],
|
|
47
|
+
["JSON exceeds depth/node limits", "structure"],
|
|
48
|
+
]);
|
|
49
|
+
/** `200000` as `200,000`, without depending on the runtime's locale data. */
|
|
50
|
+
const grouped = (value) => String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
51
|
+
/** A target's output beyond what Hue stores for one case. Recorded as that case's error (type
|
|
52
|
+
* `OutputTooLarge`), never thrown: the other cases keep running. */
|
|
53
|
+
class OutputTooLargeError extends Error {
|
|
54
|
+
constructor(limit) {
|
|
55
|
+
super(limit === "bytes"
|
|
56
|
+
? `The output is larger than ${grouped(valueBounds.bytes)} bytes of JSON, the most Hue stores for one case; return a large result as a generated file`
|
|
57
|
+
: `The output has more than ${grouped(valueBounds.nodes)} JSON values or nests deeper than ${valueBounds.depth} levels, the most Hue stores for one case`);
|
|
58
|
+
this.name = "OutputTooLargeError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
42
61
|
/** Thrown when cooperative caller cancellation stops target execution. */
|
|
43
62
|
export class TargetCancelledError extends Error {
|
|
44
63
|
constructor() {
|
|
@@ -72,6 +91,59 @@ function neededInputFiles(files, versions, options) {
|
|
|
72
91
|
? files
|
|
73
92
|
: files.filter((file) => targetFileRoles.includes(file.role));
|
|
74
93
|
}
|
|
94
|
+
/** Remove a case's files; a failure is reported as a warning and never fails the case. */
|
|
95
|
+
async function removeCaseFiles(directory) {
|
|
96
|
+
try {
|
|
97
|
+
await rm(directory, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
process.emitWarning(`Could not remove the case files in ${directory}`, {
|
|
101
|
+
code: "HUE_CASE_FILES_NOT_REMOVED",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Everything of a world case's directory except the staged outputs. */
|
|
106
|
+
const WORK_PARTS = ["inputs", "evaluator-inputs", "work"];
|
|
107
|
+
async function releaseWorldFiles(directory, keepOutputs) {
|
|
108
|
+
if (!keepOutputs)
|
|
109
|
+
return removeCaseFiles(directory);
|
|
110
|
+
for (const part of WORK_PARTS)
|
|
111
|
+
await removeCaseFiles(join(directory, part));
|
|
112
|
+
}
|
|
113
|
+
function releaseWorldFilesSync(directory, keepOutputs) {
|
|
114
|
+
for (const path of keepOutputs ? WORK_PARTS.map((part) => join(directory, part)) : [directory])
|
|
115
|
+
rmSync(path, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
/** Stable reason, and first word of the omission reason, of a case failed for its telemetry. */
|
|
118
|
+
export const TELEMETRY_NOT_ACCEPTED = "telemetry_not_accepted";
|
|
119
|
+
/** The issues of a failed export, summed by signal, kind and status. */
|
|
120
|
+
export function telemetryIssueCounts(error) {
|
|
121
|
+
if (!(error instanceof HueExportError))
|
|
122
|
+
return [];
|
|
123
|
+
const counts = new Map();
|
|
124
|
+
for (const issue of error.issues) {
|
|
125
|
+
if (issue.kind === "warning")
|
|
126
|
+
continue;
|
|
127
|
+
const key = `${issue.signal}:${issue.kind}:${issue.status ?? ""}`;
|
|
128
|
+
const entry = counts.get(key) ?? {
|
|
129
|
+
signal: issue.signal,
|
|
130
|
+
kind: issue.kind,
|
|
131
|
+
...(issue.status === undefined ? {} : { status: issue.status }),
|
|
132
|
+
count: 0,
|
|
133
|
+
};
|
|
134
|
+
entry.count += issue.count;
|
|
135
|
+
counts.set(key, entry);
|
|
136
|
+
}
|
|
137
|
+
// A zero-count entry, such as the processor's own flush failure, only repeats a signal that
|
|
138
|
+
// already has counted records.
|
|
139
|
+
const counted = new Set([...counts.values()].filter((entry) => entry.count).map((e) => e.signal));
|
|
140
|
+
return [...counts.values()].filter((entry) => entry.count || !counted.has(entry.signal));
|
|
141
|
+
}
|
|
142
|
+
/** `telemetry_not_accepted`, then the issue counts, for example `traces failed 1 (HTTP 400)`. */
|
|
143
|
+
export function describeTelemetryIssues(issues) {
|
|
144
|
+
const parts = issues.map((issue) => `${issue.signal} ${issue.kind} ${issue.count}${issue.status === undefined ? "" : ` (HTTP ${issue.status})`}`);
|
|
145
|
+
return parts.length ? `${TELEMETRY_NOT_ACCEPTED}: ${parts.join(", ")}` : TELEMETRY_NOT_ACCEPTED;
|
|
146
|
+
}
|
|
75
147
|
function settings(options) {
|
|
76
148
|
if (options.environmentEvidence !== undefined &&
|
|
77
149
|
options.environmentEvidence !== "required" &&
|
|
@@ -258,7 +330,7 @@ export async function runExperiment(options) {
|
|
|
258
330
|
const filesRoot = resolve(options.filesDirectory ?? join(options.checkpointDirectory, "files"));
|
|
259
331
|
const sanitize = (message) => message.slice(0, 4000).toWellFormed().replaceAll("\u0000", "");
|
|
260
332
|
const errorPayload = (error) => ({
|
|
261
|
-
type: "TargetError",
|
|
333
|
+
type: error instanceof OutputTooLargeError ? "OutputTooLarge" : "TargetError",
|
|
262
334
|
...(options.persistResultContent && error instanceof Error
|
|
263
335
|
? { message: sanitize(error.message) }
|
|
264
336
|
: {}),
|
|
@@ -269,7 +341,7 @@ export async function runExperiment(options) {
|
|
|
269
341
|
output) {
|
|
270
342
|
const needed = neededInputFiles(frozenCase.inputFiles, versions, options);
|
|
271
343
|
const inputs = needed.length
|
|
272
|
-
? await
|
|
344
|
+
? (await downloadCaseInputs(options.client, needed, caseDirectory)).all
|
|
273
345
|
: [];
|
|
274
346
|
await uploadOutputFiles(options.client, saved.executionId, saved.files, () => store.write(file, saved));
|
|
275
347
|
const outputs = localOutputFiles(saved.files);
|
|
@@ -310,10 +382,25 @@ export async function runExperiment(options) {
|
|
|
310
382
|
await store.write(file, prepared);
|
|
311
383
|
return prepared;
|
|
312
384
|
}
|
|
313
|
-
|
|
385
|
+
// A case pinned to a world keeps its files apart and removes them when it ends: the agent's
|
|
386
|
+
// copies of its inputs, its work, the evaluator's downloads and the staged outputs.
|
|
387
|
+
const worldDirectoryOf = (itemId) => join(filesRoot, `world-case-${uuid(itemId)}`);
|
|
388
|
+
const runCase = async (item, files) => {
|
|
314
389
|
const file = `case-${uuid(item.id)}`;
|
|
315
|
-
const
|
|
316
|
-
|
|
390
|
+
const worldDirectory = worldDirectoryOf(item.id);
|
|
391
|
+
const caseDirectoryOf = (frozen) => frozen.environmentVersionId ? worldDirectory : join(filesRoot, `case-${uuid(item.id)}`);
|
|
392
|
+
let checkpoint;
|
|
393
|
+
try {
|
|
394
|
+
checkpoint = await store.read(file);
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
// A checkpoint that is unsafe or fails its integrity check is never resumed from, so its
|
|
398
|
+
// staged outputs are not kept; an I/O failure may pass, so they are.
|
|
399
|
+
if (!error.code)
|
|
400
|
+
files.keepOutputs = false;
|
|
401
|
+
throw error;
|
|
402
|
+
}
|
|
403
|
+
files.keepOutputs = checkpoint?.stage === "uploading";
|
|
317
404
|
if (checkpoint && checkpoint.stage !== "prepared" && checkpoint.stage !== "uploading") {
|
|
318
405
|
if (checkpoint.stage === "serialization_failed")
|
|
319
406
|
throw new OutcomeSerializationError(checkpoint.executionId);
|
|
@@ -331,7 +418,8 @@ export async function runExperiment(options) {
|
|
|
331
418
|
if (checkpoint.hasOutput && checkpoint.output === undefined)
|
|
332
419
|
throw new UncertainExecutionError(item.id, checkpoint.executionId);
|
|
333
420
|
const frozenCase = await options.client.getExperimentCase(experiment.id, item.id);
|
|
334
|
-
checkpoint = await prepare(file, checkpoint, frozenCase,
|
|
421
|
+
checkpoint = await prepare(file, checkpoint, frozenCase, caseDirectoryOf(frozenCase), checkpoint.output);
|
|
422
|
+
files.keepOutputs = false;
|
|
335
423
|
}
|
|
336
424
|
if (!checkpoint) {
|
|
337
425
|
if (item.execution)
|
|
@@ -343,10 +431,20 @@ export async function runExperiment(options) {
|
|
|
343
431
|
// target failures and cannot consume a case's execution slot.
|
|
344
432
|
const targetInputs = json(frozenCase.inputs);
|
|
345
433
|
const targetConfig = json(experiment.config);
|
|
346
|
-
// Pinned input files are verified
|
|
434
|
+
// Pinned input files are verified before an execution exists for the same reason. Only
|
|
435
|
+
// the agent-visible ones are saved now: evaluator-only files a local code evaluator needs
|
|
436
|
+
// are checked and dropped, and saved for scoring after the target finished, so they are
|
|
437
|
+
// not on disk while it runs.
|
|
438
|
+
const caseDirectory = caseDirectoryOf(frozenCase);
|
|
439
|
+
const isTargetFile = (entry) => targetFileRoles.includes(entry.role);
|
|
347
440
|
const needed = neededInputFiles(frozenCase.inputFiles, versions, options);
|
|
348
|
-
const
|
|
349
|
-
|
|
441
|
+
const agentFiles = needed.filter(isTargetFile);
|
|
442
|
+
// An agent working in a world receives its files by name: each must be one safe name.
|
|
443
|
+
if (frozenCase.environmentVersionId)
|
|
444
|
+
assertSafeFileNames(agentFiles);
|
|
445
|
+
await verifyCaseFiles(options.client, needed.filter((entry) => !isTargetFile(entry)));
|
|
446
|
+
const inputFiles = agentFiles.length
|
|
447
|
+
? (await downloadCaseInputs(options.client, agentFiles, caseDirectory)).target
|
|
350
448
|
: [];
|
|
351
449
|
const outputDirectory = join(caseDirectory, "work");
|
|
352
450
|
await mkdir(outputDirectory, { recursive: true, mode: 0o700 });
|
|
@@ -373,7 +471,7 @@ export async function runExperiment(options) {
|
|
|
373
471
|
item: structuredClone(frozenCase),
|
|
374
472
|
span,
|
|
375
473
|
executionId: execution.id,
|
|
376
|
-
files: structuredClone(inputFiles
|
|
474
|
+
files: structuredClone(inputFiles),
|
|
377
475
|
outputDirectory,
|
|
378
476
|
});
|
|
379
477
|
if (result instanceof TargetResult) {
|
|
@@ -395,12 +493,22 @@ export async function runExperiment(options) {
|
|
|
395
493
|
try {
|
|
396
494
|
output = json(output);
|
|
397
495
|
}
|
|
398
|
-
catch {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
496
|
+
catch (error) {
|
|
497
|
+
// An output over the size, count or depth bounds is the target's own failure, as
|
|
498
|
+
// a file it cannot deliver is: the case completes as an error and the run goes
|
|
499
|
+
// on. Output that is not JSON at all still stops the run for inspection.
|
|
500
|
+
const limit = error instanceof RangeError ? outputLimits.get(error.message) : undefined;
|
|
501
|
+
if (limit === undefined) {
|
|
502
|
+
await store.write(file, {
|
|
503
|
+
stage: "serialization_failed",
|
|
504
|
+
executionId: execution.id,
|
|
505
|
+
});
|
|
506
|
+
throw new OutcomeSerializationError(execution.id);
|
|
507
|
+
}
|
|
508
|
+
state = "error";
|
|
509
|
+
output = undefined;
|
|
510
|
+
targetError = new OutputTooLargeError(limit);
|
|
511
|
+
options.hue.recordError(span.span, targetError);
|
|
404
512
|
}
|
|
405
513
|
}
|
|
406
514
|
if (output !== undefined)
|
|
@@ -433,6 +541,7 @@ export async function runExperiment(options) {
|
|
|
433
541
|
};
|
|
434
542
|
// Without persisted result content a restart cannot reconstruct the outcome; the
|
|
435
543
|
// saved stage then reports the execution as uncertain instead of guessing.
|
|
544
|
+
files.keepOutputs = true;
|
|
436
545
|
await store.write(file, uploading);
|
|
437
546
|
return prepare(file, uploading, frozenCase, caseDirectory, output);
|
|
438
547
|
}, {
|
|
@@ -440,7 +549,9 @@ export async function runExperiment(options) {
|
|
|
440
549
|
input: frozenCase.inputs,
|
|
441
550
|
attributes: { "hue.experiment.id": experiment.id, "hue.dataset.case.id": item.id },
|
|
442
551
|
});
|
|
552
|
+
files.keepOutputs = false;
|
|
443
553
|
// End root before waiting for both OTLP signals. Export failure leaves the prepared checkpoint intact.
|
|
554
|
+
let exportError;
|
|
444
555
|
try {
|
|
445
556
|
await options.hue.flush();
|
|
446
557
|
// Another concurrent flush may already have surfaced this failure. OTLP
|
|
@@ -449,29 +560,96 @@ export async function runExperiment(options) {
|
|
|
449
560
|
throw new HueExportError(options.hue.transport
|
|
450
561
|
.getIssues()
|
|
451
562
|
.filter((issue) => issue.sequence > failureSequenceBefore && issue.kind !== "warning"), options.hue.transport.getReport());
|
|
452
|
-
|
|
453
|
-
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
exportError = error;
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
if (exportError === undefined) {
|
|
569
|
+
checkpoint.exportState = "accepted";
|
|
570
|
+
await store.write(file, checkpoint);
|
|
571
|
+
}
|
|
454
572
|
}
|
|
455
573
|
catch (error) {
|
|
456
574
|
// The explicitly chosen omission policy can complete without acknowledged telemetry.
|
|
457
575
|
if (options.traceEvidence.mode !== "omit")
|
|
458
576
|
throw error;
|
|
459
577
|
}
|
|
578
|
+
if (exportError !== undefined && options.traceEvidence.mode !== "omit") {
|
|
579
|
+
if (options.traceNotAccepted !== "fail_case")
|
|
580
|
+
throw exportError;
|
|
581
|
+
// Required evidence that Hue did not accept fails the case rather than leaving its
|
|
582
|
+
// execution started: the outcome is kept, the evidence is declared omitted. Only an
|
|
583
|
+
// export failure takes this path; a checkpoint that cannot be saved is raised above.
|
|
584
|
+
const failed = checkpoint;
|
|
585
|
+
const issues = telemetryIssueCounts(exportError);
|
|
586
|
+
// Without its evidence the case fails whatever it answered: the output and generated
|
|
587
|
+
// files are not attached, so no scorer can pass it, and local scores taken of them go.
|
|
588
|
+
const { traceEvidence: _required, output: _output, artifactIds: _artifacts, primaryArtifactId: _primary, ...complete } = failed.complete;
|
|
589
|
+
failed.scores = [];
|
|
590
|
+
if (complete.state === "succeeded") {
|
|
591
|
+
complete.state = "error";
|
|
592
|
+
complete.error = {
|
|
593
|
+
type: "TelemetryNotAccepted",
|
|
594
|
+
...(options.persistResultContent ? { message: describeTelemetryIssues(issues) } : {}),
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
failed.complete = {
|
|
598
|
+
...complete,
|
|
599
|
+
traceEvidence: "omit",
|
|
600
|
+
omissionReason: describeTelemetryIssues(issues).slice(0, 4000),
|
|
601
|
+
};
|
|
602
|
+
failed.exportState = "not_accepted";
|
|
603
|
+
failed.telemetryIssues = issues;
|
|
604
|
+
await store.write(file, failed);
|
|
605
|
+
}
|
|
460
606
|
}
|
|
461
607
|
const prepared = checkpoint;
|
|
462
608
|
await options.client.getExecution(prepared.executionId);
|
|
463
609
|
if (prepared.exportState !== "accepted" && prepared.complete.traceEvidence !== "omit")
|
|
464
610
|
throw new Error("Target outcome is saved but trace export acknowledgement is unavailable. Restore/export the trace or explicitly complete with omitted evidence through the client; never rerun the target.");
|
|
465
611
|
const save = () => store.write(file, prepared);
|
|
612
|
+
const notAccepted = prepared.exportState === "not_accepted"
|
|
613
|
+
? {
|
|
614
|
+
caseId: item.id,
|
|
615
|
+
caseKey: item.externalKey,
|
|
616
|
+
executionId: prepared.executionId,
|
|
617
|
+
issues: prepared.telemetryIssues ?? [],
|
|
618
|
+
}
|
|
619
|
+
: undefined;
|
|
466
620
|
if (!prepared.completion) {
|
|
467
621
|
prepared.completion = await options.client.completeExecution(prepared.executionId, prepared.complete);
|
|
468
622
|
for (const score of prepared.scores)
|
|
469
623
|
score.payload.evaluationItemId = prepared.completion.evaluationItemId;
|
|
470
624
|
await save();
|
|
625
|
+
// Once per failed case: a resumed call finds the completion and only reports it.
|
|
626
|
+
if (notAccepted)
|
|
627
|
+
await options.onTelemetryNotAccepted?.(notAccepted);
|
|
471
628
|
}
|
|
629
|
+
if (notAccepted)
|
|
630
|
+
(report.telemetryNotAccepted ??= []).push(notAccepted);
|
|
472
631
|
const results = await uploadScores(options, experiment.evaluation.id, prepared.scores, save, versions);
|
|
473
632
|
report.subjectIds.push(prepared.completion.subjectId);
|
|
474
633
|
report.resultIds.push(...results);
|
|
634
|
+
};
|
|
635
|
+
await pool(items, concurrency, async (item) => {
|
|
636
|
+
// A world case's directory goes however the case ends, a forced exit included. An
|
|
637
|
+
// interrupted upload keeps only the staged outputs it resumes from; inputs are downloaded
|
|
638
|
+
// anew for scoring. A direct case has no such directory, so nothing is removed for it.
|
|
639
|
+
const worldDirectory = worldDirectoryOf(item.id);
|
|
640
|
+
const files = { keepOutputs: true };
|
|
641
|
+
const untrack = onForcedExit(() => releaseWorldFilesSync(worldDirectory, files.keepOutputs));
|
|
642
|
+
try {
|
|
643
|
+
await runCase(item, files);
|
|
644
|
+
await releaseWorldFiles(worldDirectory, false);
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
await releaseWorldFiles(worldDirectory, files.keepOutputs);
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
650
|
+
finally {
|
|
651
|
+
untrack();
|
|
652
|
+
}
|
|
475
653
|
});
|
|
476
654
|
let finish = await store.read("finish");
|
|
477
655
|
if (!finish) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EvaluationClient } from "./client.js";
|
|
2
2
|
import type { CaseConversion, CaseConversionSummary, Page, PageOptions } from "./types.js";
|
|
3
3
|
/** Immutable pins resolved from a published Scenario or a saved eval set. */
|
|
4
4
|
export interface ScenarioPins {
|
|
@@ -59,11 +59,13 @@ export interface NameMatch<T extends NamedCandidate> {
|
|
|
59
59
|
/** Case-insensitive exact matches first, then unique prefix/substring matches. */
|
|
60
60
|
export declare function matchByName<T extends NamedCandidate>(candidates: T[], name: string): NameMatch<T>;
|
|
61
61
|
/**
|
|
62
|
-
* Resolves a published Scenario's immutable pins from its ID,
|
|
63
|
-
*
|
|
64
|
-
*
|
|
62
|
+
* Resolves a published Scenario's immutable pins from its ID, the ID of the eval set case it
|
|
63
|
+
* published, a Hue URL naming either (`/scenarios/<id>`, `/case-conversions/<id>` or
|
|
64
|
+
* `/cases/<id>`) or its name. A name matches the dataset name of published Scenarios
|
|
65
|
+
* case-insensitively: exact matches first, then a unique prefix or substring.
|
|
65
66
|
*
|
|
66
|
-
* @throws Error when no Scenario matches, several match, or the Scenario is an unpublished draft
|
|
67
|
+
* @throws Error when no Scenario matches, several match, or the Scenario is an unpublished draft;
|
|
68
|
+
* {@link HueApiError} with status 404 when neither a Scenario nor a published case has the ID.
|
|
67
69
|
*/
|
|
68
70
|
export declare function resolveScenarioPins(client: ScenarioClient, selector: string): Promise<ScenarioPins>;
|
|
69
71
|
/**
|
package/dist/evals/scenarios.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { HueApiError } from "./client.js";
|
|
1
2
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2
|
-
/** Scenarios
|
|
3
|
+
/** Published Scenarios a name is matched against; bounds the registry reads of one selection. */
|
|
3
4
|
const MAX_LISTED = 200;
|
|
5
|
+
/** Scenarios, drafts included, one selection lists at most: a case ID is found among them, and a
|
|
6
|
+
* mistyped one ends after a bounded number of reads. */
|
|
7
|
+
const MAX_SCANNED = 1000;
|
|
4
8
|
/** Lists Scenarios of the project; requires a Read and write key. */
|
|
5
9
|
export function listScenarios(client, page) {
|
|
6
10
|
return client.listCaseConversions(page);
|
|
@@ -53,17 +57,57 @@ export function matchByName(candidates, name) {
|
|
|
53
57
|
exact: false,
|
|
54
58
|
};
|
|
55
59
|
}
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
/** Published Scenario summaries in listing order, page by page, ending after
|
|
61
|
+
* {@link MAX_SCANNED} Scenarios, at an empty page or when the listing repeats a cursor. */
|
|
62
|
+
async function* publishedScenarios(client) {
|
|
63
|
+
const cursors = new Set();
|
|
64
|
+
let listed = 0;
|
|
58
65
|
let after;
|
|
59
66
|
for (;;) {
|
|
60
67
|
const page = await client.listCaseConversions({ after, limit: 100 });
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
for (const item of page.items) {
|
|
69
|
+
if (++listed > MAX_SCANNED)
|
|
70
|
+
return;
|
|
71
|
+
if (item.status === "published")
|
|
72
|
+
yield item;
|
|
73
|
+
}
|
|
74
|
+
if (!page.items.length || !page.nextCursor || cursors.has(page.nextCursor))
|
|
75
|
+
return;
|
|
76
|
+
cursors.add(page.nextCursor);
|
|
64
77
|
after = page.nextCursor;
|
|
65
78
|
}
|
|
66
79
|
}
|
|
80
|
+
async function listPublishedScenarios(client) {
|
|
81
|
+
const items = [];
|
|
82
|
+
for await (const item of publishedScenarios(client))
|
|
83
|
+
if (items.push(item) >= MAX_LISTED)
|
|
84
|
+
break;
|
|
85
|
+
return items;
|
|
86
|
+
}
|
|
87
|
+
/** A listed Scenario, or undefined when it can no longer be read (removed since the listing). */
|
|
88
|
+
async function readListed(client, id) {
|
|
89
|
+
try {
|
|
90
|
+
return await client.getCaseConversion(id);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error instanceof HueApiError && error.status === 404)
|
|
94
|
+
return undefined;
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The published Scenario whose eval set case has this ID, the one Hue shows on the case page.
|
|
100
|
+
* Published Scenarios are read in listing order until one matches, past the bound a name search
|
|
101
|
+
* uses; undefined when none does within {@link MAX_SCANNED}.
|
|
102
|
+
*/
|
|
103
|
+
async function scenarioOfPublishedCase(client, caseId) {
|
|
104
|
+
for await (const summary of publishedScenarios(client)) {
|
|
105
|
+
const scenario = await readListed(client, summary.id);
|
|
106
|
+
if (scenario?.publication?.caseId.toLowerCase() === caseId)
|
|
107
|
+
return scenario;
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
67
111
|
const describe = (candidates) => candidates.map((candidate) => `${candidate.name} (${candidate.id})`).join(", ");
|
|
68
112
|
async function pinsFromScenario(client, scenario, dataset) {
|
|
69
113
|
if (!scenario.publication)
|
|
@@ -84,16 +128,29 @@ async function pinsFromScenario(client, scenario, dataset) {
|
|
|
84
128
|
};
|
|
85
129
|
}
|
|
86
130
|
/**
|
|
87
|
-
* Resolves a published Scenario's immutable pins from its ID,
|
|
88
|
-
*
|
|
89
|
-
*
|
|
131
|
+
* Resolves a published Scenario's immutable pins from its ID, the ID of the eval set case it
|
|
132
|
+
* published, a Hue URL naming either (`/scenarios/<id>`, `/case-conversions/<id>` or
|
|
133
|
+
* `/cases/<id>`) or its name. A name matches the dataset name of published Scenarios
|
|
134
|
+
* case-insensitively: exact matches first, then a unique prefix or substring.
|
|
90
135
|
*
|
|
91
|
-
* @throws Error when no Scenario matches, several match, or the Scenario is an unpublished draft
|
|
136
|
+
* @throws Error when no Scenario matches, several match, or the Scenario is an unpublished draft;
|
|
137
|
+
* {@link HueApiError} with status 404 when neither a Scenario nor a published case has the ID.
|
|
92
138
|
*/
|
|
93
139
|
export async function resolveScenarioPins(client, selector) {
|
|
94
|
-
const parsed = parseScenarioSelector(selector);
|
|
140
|
+
const parsed = parseScenarioSelector(selector, ["scenarios", "case-conversions", "cases"]);
|
|
95
141
|
if (parsed.kind === "id") {
|
|
96
|
-
|
|
142
|
+
let scenario;
|
|
143
|
+
try {
|
|
144
|
+
scenario = await client.getCaseConversion(parsed.id);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (!(error instanceof HueApiError && error.status === 404))
|
|
148
|
+
throw error;
|
|
149
|
+
const published = await scenarioOfPublishedCase(client, parsed.id);
|
|
150
|
+
if (!published)
|
|
151
|
+
throw error;
|
|
152
|
+
scenario = published;
|
|
153
|
+
}
|
|
97
154
|
if (!scenario.publication)
|
|
98
155
|
throw new Error(`Scenario ${scenario.id} is a draft without published pins; publish it in Hue first`);
|
|
99
156
|
return pinsFromScenario(client, scenario, await client.getDataset(scenario.publication.datasetId));
|
|
@@ -102,8 +159,8 @@ export async function resolveScenarioPins(client, selector) {
|
|
|
102
159
|
const datasets = new Map();
|
|
103
160
|
const candidates = [];
|
|
104
161
|
for (const summary of published) {
|
|
105
|
-
const scenario = await client
|
|
106
|
-
if (!scenario
|
|
162
|
+
const scenario = await readListed(client, summary.id);
|
|
163
|
+
if (!scenario?.publication)
|
|
107
164
|
continue;
|
|
108
165
|
let dataset = datasets.get(scenario.publication.datasetId);
|
|
109
166
|
if (!dataset) {
|