@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.
Files changed (56) hide show
  1. package/CLI.md +240 -49
  2. package/ENVIRONMENTS.md +48 -9
  3. package/EVALUATIONS.md +41 -21
  4. package/README.md +70 -30
  5. package/dist/cli/eval-direct.d.ts +12 -1
  6. package/dist/cli/eval-direct.js +145 -33
  7. package/dist/cli/eval.d.ts +28 -1
  8. package/dist/cli/eval.js +504 -81
  9. package/dist/cli/listen.d.ts +76 -0
  10. package/dist/cli/listen.js +816 -0
  11. package/dist/cli/mcp.d.ts +65 -7
  12. package/dist/cli/mcp.js +271 -73
  13. package/dist/client.d.ts +7 -4
  14. package/dist/client.js +36 -10
  15. package/dist/evals/checkpoint.d.ts +15 -1
  16. package/dist/evals/checkpoint.js +34 -3
  17. package/dist/evals/client.d.ts +24 -4
  18. package/dist/evals/client.js +92 -38
  19. package/dist/evals/environment-target.d.ts +18 -3
  20. package/dist/evals/environment-target.js +22 -10
  21. package/dist/evals/exit-cleanup.d.ts +4 -0
  22. package/dist/evals/exit-cleanup.js +26 -0
  23. package/dist/evals/files.d.ts +69 -3
  24. package/dist/evals/files.js +255 -42
  25. package/dist/evals/json.js +116 -9
  26. package/dist/evals/local-worker.d.ts +21 -3
  27. package/dist/evals/local-worker.js +14 -0
  28. package/dist/evals/runner.d.ts +45 -0
  29. package/dist/evals/runner.js +199 -21
  30. package/dist/evals/scenarios.d.ts +7 -5
  31. package/dist/evals/scenarios.js +71 -14
  32. package/dist/evals/scorer-publication.js +58 -3
  33. package/dist/evals/simulation.d.ts +17 -4
  34. package/dist/evals/simulation.js +8 -0
  35. package/dist/evals/types.d.ts +48 -0
  36. package/dist/evals/verdicts.d.ts +26 -2
  37. package/dist/evals/verdicts.js +72 -9
  38. package/dist/evals.d.ts +4 -3
  39. package/dist/evals.js +2 -2
  40. package/dist/inline-files.d.ts +4 -0
  41. package/dist/inline-files.js +48 -17
  42. package/dist/provider-tools.d.ts +12 -1
  43. package/dist/provider-tools.js +192 -106
  44. package/dist/safety.d.ts +7 -1
  45. package/dist/safety.js +9 -7
  46. package/dist/setup/cli.js +31 -8
  47. package/dist/setup/peers.d.ts +13 -0
  48. package/dist/setup/peers.js +44 -0
  49. package/dist/snapshot.d.ts +2 -2
  50. package/dist/snapshot.js +47 -12
  51. package/dist/tool-definitions.d.ts +18 -0
  52. package/dist/tool-definitions.js +232 -0
  53. package/dist/transport.js +2 -2
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/package.json +1 -1
package/dist/client.js CHANGED
@@ -8,12 +8,13 @@ import { LoggerProvider } from "@opentelemetry/sdk-logs";
8
8
  import { TracerProvider } from "@opentelemetry/sdk-trace";
9
9
  import { defaultResource, resourceFromAttributes } from "@opentelemetry/resources";
10
10
  import { HUE_SCOPE } from "./config.js";
11
- import { hostedServerAddresses, hostedToolActivity, hostedToolProvider } from "./provider-tools.js";
11
+ import { hostedServerAddresses, hostedToolActivity, hostedToolProvider, providerErrorDescription, } from "./provider-tools.js";
12
+ import { toolCatalogSummary } from "./tool-definitions.js";
12
13
  import { encodeContent, noopSpan, safeSpan } from "./safety.js";
13
14
  import { createHueTransport, HueExportError } from "./transport.js";
14
15
  import { verifyTrace } from "./receipt.js";
15
16
  import { sdkVersion } from "./version.js";
16
- import { MAX_FILE_DATA_BYTES } from "./config.js";
17
+ import { MAX_BODY_BYTES, MAX_FILE_DATA_BYTES } from "./config.js";
17
18
  /**
18
19
  * Thrown by {@link HueClient.checkConnection} and {@link HueClient.verifyTrace} when Hue cannot be
19
20
  * reached, rejects the project key or answers unexpectedly. The message is fixed and safe to log;
@@ -149,6 +150,9 @@ class ContextualTracer {
149
150
  }
150
151
  }
151
152
  const propagator = new W3CTraceContextPropagator();
153
+ /** A provider tool listing's bounds before its catalog summary: one export request, 64 levels and
154
+ * 65,536 values, as the Python SDK's content snapshot. */
155
+ const catalogLimits = { bytes: MAX_BODY_BYTES, nodes: 65_536, depth: 64 };
152
156
  /**
153
157
  * Hue tracing client. Helpers create spans through a private tracer and local async context, never
154
158
  * through global OpenTelemetry registration, and they fail open: capture problems are counted in
@@ -517,11 +521,14 @@ export class HueClient {
517
521
  * `hue.tool()` call saw: OpenAI Responses `mcp_call`, `web_search_call`, `file_search_call` and
518
522
  * `code_interpreter_call` items, and Anthropic Messages `mcp_tool_use` / `server_tool_use`
519
523
  * blocks with their result blocks. Each becomes an `execute_tool {name}` child span of the active
520
- * (or given) context with `gen_ai.tool.type` `extension` and `gen_ai.tool.call.id`; MCP calls add
524
+ * (or given) context with `gen_ai.tool.type` `extension`, `gen_ai.tool.call.id` and
525
+ * `hue.tool.call.position` (the item's 0-based position in the response); MCP calls add
521
526
  * `mcp.server.name` (the provider's label, or the `servers` entry for it). Arguments and results
522
- * follow `captureContent`; a failed call carries `error.type` and ERROR status. An OpenAI
523
- * `mcp_list_tools` item becomes a `tools/list` child span carrying that server's tools as
524
- * `gen_ai.tool.definitions`. Call it inside `hue.model()` so the spans nest under the model call
527
+ * follow `captureContent`; a failed call carries `error.type` and ERROR status, and with content
528
+ * capture a failed OpenAI MCP call's status description is the provider's scrubbed, bounded error
529
+ * text. An OpenAI `mcp_list_tools` item becomes a `tools/list` child span carrying that server's
530
+ * tools as `gen_ai.tool.definitions`, or without content capture only `hue.tool.names` and
531
+ * `hue.tool.definitions.sha256`. Call it inside `hue.model()` so the spans nest under the model call
525
532
  * and `provider` defaults to its provider; pass `request` to record each server's host as
526
533
  * `server.address`. The spans have no duration of their own: the provider ran the tools inside
527
534
  * the model request. Unreadable items are skipped and counted; nothing is thrown.
@@ -564,9 +571,12 @@ export class HueClient {
564
571
  attributes["server.address"] = address;
565
572
  return attributes;
566
573
  };
567
- const fail = (span, type) => {
574
+ const fail = (span, type, description) => {
568
575
  span.setAttribute("error.type", type);
569
- span.setStatus({ code: SpanStatusCode.ERROR });
576
+ span.setStatus({
577
+ code: SpanStatusCode.ERROR,
578
+ ...(description === undefined ? {} : { message: description }),
579
+ });
570
580
  };
571
581
  for (const call of activity.calls) {
572
582
  const span = this.tracer.startSpan(`execute_tool ${call.name}`, {
@@ -576,6 +586,7 @@ export class HueClient {
576
586
  "gen_ai.tool.name": call.name,
577
587
  "gen_ai.tool.type": "extension",
578
588
  ...(call.callId === undefined ? {} : { "gen_ai.tool.call.id": call.callId }),
589
+ "hue.tool.call.position": call.position,
579
590
  ...server(call.server),
580
591
  },
581
592
  }, parent);
@@ -584,7 +595,11 @@ export class HueClient {
584
595
  if (call.result !== undefined)
585
596
  this.setContent(span, "gen_ai.tool.call.result", call.result);
586
597
  if (call.errorType !== undefined)
587
- fail(span, call.errorType);
598
+ fail(span, call.errorType,
599
+ // The provider's error text is content: exported only when content is captured.
600
+ this.captureContent && call.errorText !== undefined
601
+ ? providerErrorDescription(call.errorText)
602
+ : undefined);
588
603
  span.end();
589
604
  }
590
605
  for (const listing of activity.listings) {
@@ -592,7 +607,18 @@ export class HueClient {
592
607
  kind: SpanKind.INTERNAL,
593
608
  attributes: { "mcp.method.name": "tools/list", ...server(listing.server) },
594
609
  }, parent);
595
- this.setContent(span, "gen_ai.tool.definitions", listing.definitions);
610
+ if (this.captureContent)
611
+ this.setContent(span, "gen_ai.tool.definitions", listing.definitions);
612
+ // Names and the catalog digest are metadata: without content, the listing carries the
613
+ // summary metadata-only export gives any record's tool definitions, within the summary's
614
+ // bounds rather than one content field's.
615
+ else
616
+ try {
617
+ span.setAttributes(toolCatalogSummary(encodeContent(listing.definitions, catalogLimits)));
618
+ }
619
+ catch {
620
+ this.transport.instrumentationFailure();
621
+ }
596
622
  if (listing.errorType !== undefined)
597
623
  fail(span, listing.errorType);
598
624
  span.end();
@@ -1,6 +1,20 @@
1
- /** One owner per directory. A crash leaves .lock for explicit operator recovery. */
1
+ /** The checkpoint belongs to a run started with a different identity. When only its content
2
+ * policy differs, `startedWith` holds the policy the unfinished run was started with. */
3
+ export declare class CheckpointIdentityError extends Error {
4
+ readonly startedWith?: {
5
+ persistResultContent?: unknown;
6
+ captureContent?: unknown;
7
+ } | undefined;
8
+ constructor(startedWith?: {
9
+ persistResultContent?: unknown;
10
+ captureContent?: unknown;
11
+ } | undefined);
12
+ }
13
+ /** One owner per directory. A crash leaves .lock for explicit operator recovery; a forced exit of
14
+ * `hue eval`, which stops its agents first, releases it. */
2
15
  export declare class CheckpointStore {
3
16
  readonly directory: string;
17
+ private untrack;
4
18
  private constructor();
5
19
  static acquire(directory: string, identity: unknown): Promise<CheckpointStore>;
6
20
  read<T>(key: string): Promise<T | undefined>;
@@ -1,11 +1,40 @@
1
- import { constants } from "node:fs";
1
+ import { constants, rmSync } from "node:fs";
2
2
  import { lstat, mkdir, open, rename, rm } from "node:fs/promises";
3
3
  import { join, resolve } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
+ import { onForcedExit } from "./exit-cleanup.js";
5
6
  import { digest } from "./json.js";
6
- /** One owner per directory. A crash leaves .lock for explicit operator recovery. */
7
+ const CONTENT_POLICY = ["persistResultContent", "captureContent"];
8
+ /** The checkpoint belongs to a run started with a different identity. When only its content
9
+ * policy differs, `startedWith` holds the policy the unfinished run was started with. */
10
+ export class CheckpointIdentityError extends Error {
11
+ startedWith;
12
+ constructor(startedWith) {
13
+ super(startedWith
14
+ ? "Checkpoint content policy differs from the one this unfinished run was started with"
15
+ : "Checkpoint identity differs from this project, run, pins or content policy");
16
+ this.startedWith = startedWith;
17
+ this.name = "CheckpointIdentityError";
18
+ }
19
+ }
20
+ /** The prior policy when two identities differ only in their content policy. */
21
+ function contentPolicyOnly(prior, identity) {
22
+ if (!prior || !identity || typeof prior !== "object" || typeof identity !== "object")
23
+ return undefined;
24
+ const before = prior;
25
+ const after = identity;
26
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
27
+ for (const key of keys)
28
+ if (!CONTENT_POLICY.includes(key) &&
29
+ JSON.stringify(before[key]) !== JSON.stringify(after[key]))
30
+ return undefined;
31
+ return Object.fromEntries(CONTENT_POLICY.map((key) => [key, before[key]]));
32
+ }
33
+ /** One owner per directory. A crash leaves .lock for explicit operator recovery; a forced exit of
34
+ * `hue eval`, which stops its agents first, releases it. */
7
35
  export class CheckpointStore {
8
36
  directory;
37
+ untrack = () => { };
9
38
  constructor(directory) {
10
39
  this.directory = directory;
11
40
  }
@@ -18,6 +47,7 @@ export class CheckpointStore {
18
47
  const store = new CheckpointStore(root);
19
48
  try {
20
49
  await mkdir(join(root, ".lock"), { mode: 0o700 });
50
+ store.untrack = onForcedExit(() => rmSync(join(root, ".lock"), { recursive: true, force: true }));
21
51
  }
22
52
  catch {
23
53
  throw new Error("Checkpoint directory is locked; confirm its owner stopped before explicitly removing .lock");
@@ -27,7 +57,7 @@ export class CheckpointStore {
27
57
  const expected = { format: 1, identity, digest: digest(identity) };
28
58
  const prior = await store.read("manifest");
29
59
  if (prior && (prior.format !== 1 || prior.digest !== expected.digest))
30
- throw new Error("Checkpoint identity differs from this project, run, pins or content policy");
60
+ throw new CheckpointIdentityError(prior.format === 1 ? contentPolicyOnly(prior.identity, identity) : undefined);
31
61
  if (!prior)
32
62
  await store.write("manifest", expected);
33
63
  return store;
@@ -85,5 +115,6 @@ export class CheckpointStore {
85
115
  }
86
116
  async release() {
87
117
  await rm(join(this.directory, ".lock"), { recursive: true });
118
+ this.untrack();
88
119
  }
89
120
  }
@@ -14,14 +14,28 @@ export interface EvaluationClientOptions {
14
14
  export declare class HueApiError extends Error {
15
15
  /** HTTP status when Hue answered; absent for network, timeout and parsing failures. */
16
16
  readonly status?: number | undefined;
17
+ /** Seconds Hue asked the caller to wait (`Retry-After` on a 429 or 503), when it said. */
18
+ readonly retryAfterSeconds?: number | undefined;
17
19
  constructor(
18
20
  /** HTTP status when Hue answered; absent for network, timeout and parsing failures. */
19
- status?: number | undefined);
21
+ status?: number | undefined,
22
+ /** Seconds Hue asked the caller to wait (`Retry-After` on a 429 or 503), when it said. */
23
+ retryAfterSeconds?: number | undefined);
24
+ }
25
+ /** An artifact download that ran past the size its caller expected; the rest is not read. */
26
+ export declare class ArtifactSizeError extends Error {
27
+ /** The size the caller expected, in bytes. */
28
+ readonly maxBytes: number;
29
+ constructor(
30
+ /** The size the caller expected, in bytes. */
31
+ maxBytes: number);
20
32
  }
21
33
  /**
22
34
  * Typed client for Hue's evaluation REST API: datasets, scorers, experiments, executions, runs,
23
35
  * results and hosted judge jobs. No implicit mutation retry: callers retain stable idempotency keys
24
- * for experiments and results. Responses are bounded to 4 MiB.
36
+ * for experiments and results. The one exception is a request Hue refused before acting on it with
37
+ * a short `Retry-After` (HTTP 429 or 503, at most 5 seconds): it is sent again after that wait, up
38
+ * to four times. Responses are bounded to 4 MiB.
25
39
  */
26
40
  export declare class EvaluationClient {
27
41
  /** Validated Hue origin. */
@@ -29,9 +43,15 @@ export declare class EvaluationClient {
29
43
  private readonly apiKey;
30
44
  private readonly timeoutMillis;
31
45
  constructor(options: EvaluationClientOptions);
46
+ /** Sends one request to Hue, again after the wait while Hue refuses it with a short
47
+ * `Retry-After`. `init` runs for every attempt, so each has its own timeout. */
48
+ private send;
32
49
  private request;
33
- /** Verified bytes of one ready artifact in this project, bounded to the 25 MiB pilot file size. */
34
- downloadArtifact(id: string): Promise<Uint8Array>;
50
+ /** Verified bytes of one ready artifact in this project, bounded to the 25 MiB pilot file size.
51
+ * With `maxBytes`, the download stops one byte past it and throws `ArtifactSizeError`. */
52
+ downloadArtifact(id: string, options?: {
53
+ maxBytes?: number;
54
+ }): Promise<Uint8Array>;
35
55
  /** Stage bytes at the storage capability Hue issued. The Hue key is never sent to storage. */
36
56
  uploadArtifactBytes(upload: ArtifactUpload, bytes: Uint8Array, contentType: string): Promise<void>;
37
57
  private page;
@@ -4,14 +4,29 @@ import { attemptBindingRead, parsePrepareAttemptResultV2, parseRefreshedAttemptR
4
4
  /** Thrown for a failed evaluation API request; the message is fixed and never includes response text. */
5
5
  export class HueApiError extends Error {
6
6
  status;
7
+ retryAfterSeconds;
7
8
  constructor(
8
9
  /** HTTP status when Hue answered; absent for network, timeout and parsing failures. */
9
- status) {
10
+ status,
11
+ /** Seconds Hue asked the caller to wait (`Retry-After` on a 429 or 503), when it said. */
12
+ retryAfterSeconds) {
10
13
  super(status ? `Hue API request failed (HTTP ${status})` : "Hue API connection or response failed");
11
14
  this.status = status;
15
+ this.retryAfterSeconds = retryAfterSeconds;
12
16
  this.name = "HueApiError";
13
17
  }
14
18
  }
19
+ /** An artifact download that ran past the size its caller expected; the rest is not read. */
20
+ export class ArtifactSizeError extends Error {
21
+ maxBytes;
22
+ constructor(
23
+ /** The size the caller expected, in bytes. */
24
+ maxBytes) {
25
+ super(`Artifact download exceeded the expected ${maxBytes} bytes`);
26
+ this.maxBytes = maxBytes;
27
+ this.name = "ArtifactSizeError";
28
+ }
29
+ }
15
30
  const registryFieldAliases = [
16
31
  ["datasetId", "evalSetId"],
17
32
  ["datasetVersionId", "evalSetVersionId"],
@@ -91,10 +106,32 @@ function productRunFields(value, kind) {
91
106
  result.scoring = result.evaluation;
92
107
  return result;
93
108
  }
109
+ /** Times a refused request is sent again before its refusal is the caller's error. */
110
+ const REFUSAL_RETRIES = 4;
111
+ /** A refusal asking for a longer wait fails at once rather than holding the caller. */
112
+ const MAX_REFUSAL_RETRY_AFTER_SECONDS = 5;
113
+ /** The whole seconds a 429 or 503 asks the caller to wait, up to a day, when it says. */
114
+ function askedRetryAfter(response) {
115
+ if (response.status !== 429 && response.status !== 503)
116
+ return undefined;
117
+ const header = response.headers.get("retry-after")?.trim() ?? "";
118
+ return /^\d{1,6}$/.test(header) ? Math.min(Number(header), 86_400) : undefined;
119
+ }
120
+ /**
121
+ * The whole-second `Retry-After` of a 429 or 503 asking for at most 5 seconds. Hue sends one only
122
+ * when it refused the request before acting on it, such as a busy key check, so sending any method
123
+ * again is safe. A date, a longer wait or any other failure, a timeout included, is not retried.
124
+ */
125
+ function refusalRetryAfter(response) {
126
+ const seconds = askedRetryAfter(response);
127
+ return seconds !== undefined && seconds <= MAX_REFUSAL_RETRY_AFTER_SECONDS ? seconds : undefined;
128
+ }
94
129
  /**
95
130
  * Typed client for Hue's evaluation REST API: datasets, scorers, experiments, executions, runs,
96
131
  * results and hosted judge jobs. No implicit mutation retry: callers retain stable idempotency keys
97
- * for experiments and results. Responses are bounded to 4 MiB.
132
+ * for experiments and results. The one exception is a request Hue refused before acting on it with
133
+ * a short `Retry-After` (HTTP 429 or 503, at most 5 seconds): it is sent again after that wait, up
134
+ * to four times. Responses are bounded to 4 MiB.
98
135
  */
99
136
  export class EvaluationClient {
100
137
  /** Validated Hue origin. */
@@ -111,30 +148,43 @@ export class EvaluationClient {
111
148
  this.apiKey = validated.apiKey;
112
149
  this.timeoutMillis = validated.timeoutMillis;
113
150
  }
151
+ /** Sends one request to Hue, again after the wait while Hue refuses it with a short
152
+ * `Retry-After`. `init` runs for every attempt, so each has its own timeout. */
153
+ async send(url, init) {
154
+ for (let attempt = 0;; attempt++) {
155
+ let response;
156
+ try {
157
+ response = await fetch(url, init());
158
+ }
159
+ catch {
160
+ throw new HueApiError();
161
+ }
162
+ const seconds = attempt < REFUSAL_RETRIES ? refusalRetryAfter(response) : undefined;
163
+ if (seconds === undefined)
164
+ return response;
165
+ await response.body?.cancel().catch(() => undefined);
166
+ // Never sooner than asked; the jitter spreads out parallel requests refused together.
167
+ await new Promise((resolve) => setTimeout(resolve, seconds * 1000 * (1 + Math.random() * 0.5)));
168
+ }
169
+ }
114
170
  async request(method, path, body, bounds = { ...valueBounds, bytes: 1024 * 1024 }) {
115
171
  // Optional top-level fields are omitted intentionally; nested undefined remains invalid.
116
172
  const payload = body === undefined
117
173
  ? undefined
118
174
  : JSON.stringify(json(Object.fromEntries(Object.entries(body).filter(([, value]) => value !== undefined)), bounds));
119
- let response;
120
- try {
121
- response = await fetch(`${this.baseUrl}/api/v1${path}`, {
122
- method,
123
- headers: {
124
- Authorization: `Bearer ${this.apiKey}`,
125
- ...(payload ? { "Content-Type": "application/json" } : {}),
126
- },
127
- body: payload,
128
- redirect: "error",
129
- signal: AbortSignal.timeout(this.timeoutMillis),
130
- });
131
- }
132
- catch {
133
- throw new HueApiError();
134
- }
175
+ const response = await this.send(`${this.baseUrl}/api/v1${path}`, () => ({
176
+ method,
177
+ headers: {
178
+ Authorization: `Bearer ${this.apiKey}`,
179
+ ...(payload ? { "Content-Type": "application/json" } : {}),
180
+ },
181
+ body: payload,
182
+ redirect: "error",
183
+ signal: AbortSignal.timeout(this.timeoutMillis),
184
+ }));
135
185
  if (!response.ok) {
136
186
  await response.body?.cancel();
137
- throw new HueApiError(response.status);
187
+ throw new HueApiError(response.status, askedRetryAfter(response));
138
188
  }
139
189
  try {
140
190
  const reader = response.body?.getReader();
@@ -162,23 +212,21 @@ export class EvaluationClient {
162
212
  throw new HueApiError();
163
213
  }
164
214
  }
165
- /** Verified bytes of one ready artifact in this project, bounded to the 25 MiB pilot file size. */
166
- async downloadArtifact(id) {
167
- let response;
168
- try {
169
- response = await fetch(`${this.baseUrl}/api/v1/artifacts/${uuid(id)}/download`, {
170
- method: "GET",
171
- headers: { Authorization: `Bearer ${this.apiKey}` },
172
- redirect: "error",
173
- signal: AbortSignal.timeout(Math.max(this.timeoutMillis, 120_000)),
174
- });
175
- }
176
- catch {
177
- throw new HueApiError();
178
- }
215
+ /** Verified bytes of one ready artifact in this project, bounded to the 25 MiB pilot file size.
216
+ * With `maxBytes`, the download stops one byte past it and throws `ArtifactSizeError`. */
217
+ async downloadArtifact(id, options = {}) {
218
+ const expected = options.maxBytes;
219
+ if (expected !== undefined && (!Number.isSafeInteger(expected) || expected < 0))
220
+ throw new RangeError("maxBytes must be a non-negative integer");
221
+ const response = await this.send(`${this.baseUrl}/api/v1/artifacts/${uuid(id)}/download`, () => ({
222
+ method: "GET",
223
+ headers: { Authorization: `Bearer ${this.apiKey}` },
224
+ redirect: "error",
225
+ signal: AbortSignal.timeout(Math.max(this.timeoutMillis, 120_000)),
226
+ }));
179
227
  if (!response.ok) {
180
228
  await response.body?.cancel();
181
- throw new HueApiError(response.status);
229
+ throw new HueApiError(response.status, askedRetryAfter(response));
182
230
  }
183
231
  try {
184
232
  const reader = response.body?.getReader();
@@ -192,6 +240,8 @@ export class EvaluationClient {
192
240
  if (done)
193
241
  break;
194
242
  size += value.byteLength;
243
+ if (expected !== undefined && size > expected)
244
+ throw new ArtifactSizeError(expected);
195
245
  if (size > 25 * 1024 * 1024)
196
246
  throw new Error("Oversized artifact");
197
247
  chunks.push(value);
@@ -202,7 +252,9 @@ export class EvaluationClient {
202
252
  }
203
253
  return new Uint8Array(Buffer.concat(chunks));
204
254
  }
205
- catch {
255
+ catch (error) {
256
+ if (error instanceof ArtifactSizeError)
257
+ throw error;
206
258
  throw new HueApiError();
207
259
  }
208
260
  }
@@ -227,7 +279,7 @@ export class EvaluationClient {
227
279
  }
228
280
  await response.body?.cancel().catch(() => undefined);
229
281
  if (!response.ok)
230
- throw new HueApiError(response.status);
282
+ throw new HueApiError(response.status, askedRetryAfter(response));
231
283
  }
232
284
  page(options = {}) {
233
285
  const query = new URLSearchParams();
@@ -592,8 +644,10 @@ export class EvaluationClient {
592
644
  return this.request("GET", "/judge-budget");
593
645
  }
594
646
  /** Register or refresh the fixed local agent key and revision. */
595
- registerLocalAgent(input) {
596
- return this.request("POST", "/local-agent-worker/register", input);
647
+ async registerLocalAgent(input) {
648
+ const agent = await this.request("POST", "/local-agent-worker/register", input);
649
+ // Hue's response names the key `agentKey`; read either name.
650
+ return { ...agent, key: agent.key ?? agent.agentKey ?? input.key };
597
651
  }
598
652
  /** Claim a queued run for this agent and durable worker identity. */
599
653
  claimLocalAgentRun(input) {
@@ -5,7 +5,7 @@ import type { EnvironmentRun, WorldHandoff } from "../environment/types.js";
5
5
  import type { HueSpan } from "../types.js";
6
6
  import { type ActualAgentManifestInputV2, type AttemptBaselineV2, type AttemptConnectionBundleV2, type RequestedAttemptProviderV2, type SurfaceBindingV2 } from "./attempt.js";
7
7
  import type { EvaluationClient } from "./client.js";
8
- import type { ExperimentCase, JsonValue, SimulationMcpCapability } from "./types.js";
8
+ import type { ExperimentCase, JsonValue, LocalFile, SimulationMcpCapability, TargetResult } from "./types.js";
9
9
  export type McpSurfaceKeyV2 = Extract<SurfaceBindingV2["surfaceKey"], `${string}/mcp`>;
10
10
  export type ActualAgentManifestResolverV2 = ActualAgentManifestInputV2 | ((context: {
11
11
  config: JsonValue;
@@ -64,6 +64,11 @@ export interface EnvironmentTargetContext {
64
64
  * gateway world has no MCP surface. */
65
65
  mcp?: SimulationMcpCapability;
66
66
  connectionBundle?: AttemptConnectionBundleV2;
67
+ /** Verified copies of the case's agent-visible input files; evaluator-only files are never
68
+ * among them. Empty when the case has none. */
69
+ files: LocalFile[];
70
+ /** Private directory for this case; return generated files with `withFiles`. */
71
+ outputDirectory: string;
67
72
  signal?: AbortSignal;
68
73
  }
69
74
  interface RunnerTargetContext {
@@ -71,6 +76,8 @@ interface RunnerTargetContext {
71
76
  item: ExperimentCase;
72
77
  executionId: string;
73
78
  span: HueSpan;
79
+ files: LocalFile[];
80
+ outputDirectory: string;
74
81
  }
75
82
  export interface RunEnvironmentTargetOptions {
76
83
  client: EvaluationClient;
@@ -88,14 +95,22 @@ export interface RunEnvironmentTargetOptions {
88
95
  * the deployment serves, such as `hue eval`, turns it off. */
89
96
  deprecationWarnings?: boolean;
90
97
  signal?: AbortSignal;
98
+ /** Test-only: shortens the seal wait. */
99
+ sealTiming?: Partial<SealTiming>;
91
100
  onProgress?(event: EnvironmentTargetProgress): void | Promise<void>;
92
- target(inputs: JsonValue, context: EnvironmentTargetContext): JsonValue | undefined | Promise<JsonValue | undefined>;
101
+ target(inputs: JsonValue, context: EnvironmentTargetContext): JsonValue | TargetResult | undefined | Promise<JsonValue | TargetResult | undefined>;
93
102
  }
94
103
  /** The completion grace is five seconds; cap an unexpectedly distant timestamp and let reads
95
104
  * force the seal after the grace. */
96
105
  export declare const MAX_GRACE_WAIT_MS = 10000;
97
106
  export declare const SEAL_POLL_MS = 250;
98
107
  export declare const SEAL_WAIT_MS = 30000;
108
+ /** The seal wait's bounds. Only tests shorten them; the helpers always use the defaults. */
109
+ export interface SealTiming {
110
+ maxGraceWaitMs: number;
111
+ pollMs: number;
112
+ waitMs: number;
113
+ }
99
114
  /** The W3C context of the case span, sent on create so the world span parents on it. The
100
115
  * flags are the span's own: an unsampled case span is not exported, and the World API must not
101
116
  * be told otherwise. */
@@ -136,5 +151,5 @@ export declare function createWorldForExecution(client: Pick<EnvironmentClient,
136
151
  * outbound local workers. Credential-bearing connections stay in this call frame and
137
152
  * are never returned to either runner's checkpoint state.
138
153
  */
139
- export declare function runEnvironmentTarget(options: RunEnvironmentTargetOptions): Promise<JsonValue | undefined>;
154
+ export declare function runEnvironmentTarget(options: RunEnvironmentTargetOptions): Promise<JsonValue | TargetResult | undefined>;
140
155
  export {};
@@ -47,8 +47,13 @@ export function pinRequestedAttemptV2(requested, config) {
47
47
  export const MAX_GRACE_WAIT_MS = 10_000;
48
48
  export const SEAL_POLL_MS = 250;
49
49
  export const SEAL_WAIT_MS = 30_000;
50
+ const SEAL_TIMING = {
51
+ maxGraceWaitMs: MAX_GRACE_WAIT_MS,
52
+ pollMs: SEAL_POLL_MS,
53
+ waitMs: SEAL_WAIT_MS,
54
+ };
50
55
  /** Finish, then wait for the authoritative run to leave open. */
51
- async function seal(client, runId, executionId, status) {
56
+ async function seal(client, runId, executionId, status, timing) {
52
57
  let completingUntil;
53
58
  try {
54
59
  const finished = await client.finishRun(runId, {
@@ -70,7 +75,7 @@ async function seal(client, runId, executionId, status) {
70
75
  }
71
76
  if (completingUntil !== undefined) {
72
77
  try {
73
- await awaitSeal(client, runId, completingUntil);
78
+ await awaitSeal(client, runId, completingUntil, timing);
74
79
  }
75
80
  catch (error) {
76
81
  throw new TargetOutcomeUncertainError(executionId, { cause: error });
@@ -78,19 +83,21 @@ async function seal(client, runId, executionId, status) {
78
83
  }
79
84
  }
80
85
  /** Wait out a completing world's grace, then read until the server seals it. */
81
- async function awaitSeal(client, runId, completingUntil) {
86
+ async function awaitSeal(client, runId, completingUntil, timing) {
82
87
  const graceEnd = Date.parse(completingUntil ?? "");
83
88
  let wait = Number.isFinite(graceEnd)
84
- ? Math.min(Math.max(0, graceEnd - Date.now()), MAX_GRACE_WAIT_MS)
89
+ ? Math.min(Math.max(0, graceEnd - Date.now()), timing.maxGraceWaitMs)
85
90
  : 0;
86
- const deadline = performance.now() + wait + SEAL_WAIT_MS;
91
+ const deadline = performance.now() + wait + timing.waitMs;
87
92
  for (;;) {
88
93
  await new Promise((resolve) => setTimeout(resolve, wait));
89
94
  const remaining = deadline - performance.now();
90
95
  if (remaining <= 0)
91
96
  throw new Error(`World ${runId} was not sealed after its completion grace`);
97
+ // The deadline ends the read and the client's retries inside it.
92
98
  const controller = new AbortController();
93
99
  const timer = setTimeout(() => controller.abort(), remaining);
100
+ wait = timing.pollMs;
94
101
  try {
95
102
  if ((await client.getRun(runId, { signal: controller.signal })).status !== "open")
96
103
  return;
@@ -102,13 +109,15 @@ async function awaitSeal(client, runId, completingUntil) {
102
109
  throw new Error(`World ${runId} was not sealed after its completion grace`, {
103
110
  cause: error,
104
111
  });
112
+ // A 429 or 503 that outlasted the client's retries still says how long to wait.
113
+ wait = Math.max(wait, error.retryAfterMs ?? 0);
105
114
  }
106
115
  finally {
107
116
  clearTimeout(timer);
108
117
  }
109
118
  if (performance.now() >= deadline)
110
119
  throw new Error(`World ${runId} was not sealed after its completion grace`);
111
- wait = SEAL_POLL_MS;
120
+ wait = Math.min(wait, deadline - performance.now());
112
121
  }
113
122
  }
114
123
  const deprecations = new Set();
@@ -208,6 +217,7 @@ export async function runEnvironmentTarget(options) {
208
217
  agentRevision: options.agentRevision,
209
218
  });
210
219
  const world = worldHandoff(run);
220
+ const timing = { ...SEAL_TIMING, ...options.sealTiming };
211
221
  const progress = (event) => options.onProgress?.(event);
212
222
  let finalized = false;
213
223
  try {
@@ -268,7 +278,7 @@ export async function runEnvironmentTarget(options) {
268
278
  : {}),
269
279
  });
270
280
  if (prepared.status === "environment_incomplete") {
271
- await seal(options.environmentClient, run.id, context.executionId, "completed");
281
+ await seal(options.environmentClient, run.id, context.executionId, "completed", timing);
272
282
  finalized = true;
273
283
  await Promise.resolve(progress({ type: "world_sealed", environmentRunId: run.id })).catch(() => undefined);
274
284
  return undefined;
@@ -307,9 +317,11 @@ export async function runEnvironmentTarget(options) {
307
317
  ...(world ? { world } : {}),
308
318
  ...(mcp ? { mcp } : {}),
309
319
  ...(connectionBundle ? { connectionBundle } : {}),
320
+ files: structuredClone(context.files),
321
+ outputDirectory: context.outputDirectory,
310
322
  signal: options.signal,
311
323
  });
312
- await seal(options.environmentClient, run.id, context.executionId, "completed");
324
+ await seal(options.environmentClient, run.id, context.executionId, "completed", timing);
313
325
  finalized = true;
314
326
  await Promise.resolve(progress({ type: "world_sealed", environmentRunId: run.id })).catch(() => undefined);
315
327
  return output;
@@ -329,7 +341,7 @@ export async function runEnvironmentTarget(options) {
329
341
  }
330
342
  if (environmentIncomplete) {
331
343
  try {
332
- await seal(options.environmentClient, run.id, context.executionId, "completed");
344
+ await seal(options.environmentClient, run.id, context.executionId, "completed", timing);
333
345
  }
334
346
  catch (finalizationError) {
335
347
  throw new TargetOutcomeUncertainError(context.executionId, {
@@ -341,7 +353,7 @@ export async function runEnvironmentTarget(options) {
341
353
  return undefined;
342
354
  }
343
355
  try {
344
- await seal(options.environmentClient, run.id, context.executionId, "abandoned");
356
+ await seal(options.environmentClient, run.id, context.executionId, "abandoned", timing);
345
357
  }
346
358
  catch (finalizationError) {
347
359
  throw new TargetOutcomeUncertainError(context.executionId, {
@@ -0,0 +1,4 @@
1
+ /** Register a synchronous removal; the returned function unregisters it. */
2
+ export declare function onForcedExit(cleanup: () => void): () => void;
3
+ /** Run every registered removal, newest first: files before the lock that guards them. */
4
+ export declare function runForcedExitCleanups(): void;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * What this process holds on disk while it runs — a world case's files, a checkpoint lock, a
3
+ * token-bearing MCP configuration — each with a synchronous removal. Normal paths remove them
4
+ * themselves and unregister; a forced exit (a second Ctrl+C in `hue eval`) cannot wait for them,
5
+ * so it runs whatever is still registered, newest first, before `process.exit`.
6
+ */
7
+ const cleanups = new Set();
8
+ /** Register a synchronous removal; the returned function unregisters it. */
9
+ export function onForcedExit(cleanup) {
10
+ cleanups.add(cleanup);
11
+ return () => {
12
+ cleanups.delete(cleanup);
13
+ };
14
+ }
15
+ /** Run every registered removal, newest first: files before the lock that guards them. */
16
+ export function runForcedExitCleanups() {
17
+ for (const cleanup of [...cleanups].reverse()) {
18
+ try {
19
+ cleanup();
20
+ }
21
+ catch {
22
+ // Best effort on the way out; the rest still run.
23
+ }
24
+ }
25
+ cleanups.clear();
26
+ }