@hue-run/sdk 0.10.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/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();
@@ -14,9 +14,13 @@ 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);
20
24
  }
21
25
  /** An artifact download that ran past the size its caller expected; the rest is not read. */
22
26
  export declare class ArtifactSizeError extends Error {
@@ -4,11 +4,15 @@ 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
  }
@@ -106,19 +110,21 @@ function productRunFields(value, kind) {
106
110
  const REFUSAL_RETRIES = 4;
107
111
  /** A refusal asking for a longer wait fails at once rather than holding the caller. */
108
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
+ }
109
120
  /**
110
121
  * The whole-second `Retry-After` of a 429 or 503 asking for at most 5 seconds. Hue sends one only
111
122
  * when it refused the request before acting on it, such as a busy key check, so sending any method
112
123
  * again is safe. A date, a longer wait or any other failure, a timeout included, is not retried.
113
124
  */
114
125
  function refusalRetryAfter(response) {
115
- if (response.status !== 429 && response.status !== 503)
116
- return undefined;
117
- const header = response.headers.get("retry-after")?.trim() ?? "";
118
- if (!/^\d{1,6}$/.test(header))
119
- return undefined;
120
- const seconds = Number(header);
121
- return seconds <= MAX_REFUSAL_RETRY_AFTER_SECONDS ? seconds : undefined;
126
+ const seconds = askedRetryAfter(response);
127
+ return seconds !== undefined && seconds <= MAX_REFUSAL_RETRY_AFTER_SECONDS ? seconds : undefined;
122
128
  }
123
129
  /**
124
130
  * Typed client for Hue's evaluation REST API: datasets, scorers, experiments, executions, runs,
@@ -178,7 +184,7 @@ export class EvaluationClient {
178
184
  }));
179
185
  if (!response.ok) {
180
186
  await response.body?.cancel();
181
- throw new HueApiError(response.status);
187
+ throw new HueApiError(response.status, askedRetryAfter(response));
182
188
  }
183
189
  try {
184
190
  const reader = response.body?.getReader();
@@ -220,7 +226,7 @@ export class EvaluationClient {
220
226
  }));
221
227
  if (!response.ok) {
222
228
  await response.body?.cancel();
223
- throw new HueApiError(response.status);
229
+ throw new HueApiError(response.status, askedRetryAfter(response));
224
230
  }
225
231
  try {
226
232
  const reader = response.body?.getReader();
@@ -273,7 +279,7 @@ export class EvaluationClient {
273
279
  }
274
280
  await response.body?.cancel().catch(() => undefined);
275
281
  if (!response.ok)
276
- throw new HueApiError(response.status);
282
+ throw new HueApiError(response.status, askedRetryAfter(response));
277
283
  }
278
284
  page(options = {}) {
279
285
  const query = new URLSearchParams();
@@ -99,10 +99,18 @@ export declare function downloadCaseInputs(client: EvaluationClient, files: Case
99
99
  }>;
100
100
  /** Copy the target's generated files next to the checkpoint and record their identities. */
101
101
  export declare function stageOutputFiles(files: OutputFile[], directory: string): Promise<StagedOutputFile[]>;
102
+ /** How long an upload waits for Hue to verify one artifact, and how often it looks. Hue verifies
103
+ * within a two-minute lease, which can outlast one request's timeout. */
104
+ export declare const artifactSettling: {
105
+ settleMillis: number;
106
+ pollMillis: number;
107
+ maxPollMillis: number;
108
+ };
102
109
  /**
103
110
  * Publish staged files as verified artifacts. Reservations use stable keys derived from the
104
- * execution and the bytes, so a resumed upload settles on the same artifact.
111
+ * execution and the bytes, so a resumed upload settles on the same artifact, including one Hue
112
+ * finished verifying after an earlier attempt stopped waiting.
105
113
  */
106
- export declare function uploadOutputFiles(client: EvaluationClient, executionId: string, files: StagedOutputFile[], save: () => Promise<void>): Promise<void>;
114
+ export declare function uploadOutputFiles(client: EvaluationClient, executionId: string, files: StagedOutputFile[], save: () => Promise<void>, timing?: typeof artifactSettling): Promise<void>;
107
115
  /** Project uploaded staged files as the verified local files handed to scorers. */
108
116
  export declare function localOutputFiles(files: StagedOutputFile[]): LocalFile[];
@@ -321,11 +321,74 @@ export async function stageOutputFiles(files, directory) {
321
321
  }
322
322
  return staged;
323
323
  }
324
+ /** How long an upload waits for Hue to verify one artifact, and how often it looks. Hue verifies
325
+ * within a two-minute lease, which can outlast one request's timeout. */
326
+ export const artifactSettling = { settleMillis: 180_000, pollMillis: 1000, maxPollMillis: 10_000 };
327
+ /** A completion failure that can mean Hue is still verifying: a lost or timed-out response, a
328
+ * verification already running (409), or a refusal to retry (429, 503). */
329
+ function mayStillVerify(error) {
330
+ return (error instanceof HueApiError &&
331
+ (error.status === undefined ||
332
+ error.status === 409 ||
333
+ error.status === 429 ||
334
+ error.status === 503));
335
+ }
336
+ /**
337
+ * Completes an artifact's verification and returns the state it settles in. When a completion
338
+ * times out, finds verification already running or is refused for now, the artifact is read after
339
+ * a pause: ready is returned; verifying is completed again, which Hue refuses while its lease
340
+ * holds and restarts once it lapsed; an artifact a retryable refusal (a lost response, 429 or 503)
341
+ * left unverified is completed again up to three times; any other state is returned for the
342
+ * caller to refuse (a resume uploads a rejected artifact again). All within `timing.settleMillis`.
343
+ */
344
+ async function settleArtifact(client, id, timing) {
345
+ const deadline = Date.now() + timing.settleMillis;
346
+ let pause = timing.pollMillis;
347
+ let retries = 0;
348
+ for (;;) {
349
+ let refusedForNow;
350
+ let asked = 0;
351
+ try {
352
+ return (await client.completeArtifact(id)).state;
353
+ }
354
+ catch (error) {
355
+ if (!mayStillVerify(error) || Date.now() >= deadline)
356
+ throw error;
357
+ refusedForNow = error.status !== 409;
358
+ // A refusal that says how long to wait is waited out, within the settling window.
359
+ asked = (error.retryAfterSeconds ?? 0) * 1000;
360
+ }
361
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, Math.min(Math.max(pause, asked), deadline - Date.now()))));
362
+ pause = Math.min(pause * 2, timing.maxPollMillis);
363
+ let current;
364
+ try {
365
+ current = await client.getArtifact(id);
366
+ }
367
+ catch (error) {
368
+ if (!mayStillVerify(error))
369
+ throw error;
370
+ }
371
+ if (current?.state === "ready")
372
+ return current.state;
373
+ const unverified = current &&
374
+ ["reserved", "rejected"].includes(current.state) &&
375
+ current.failureCode !== "mismatch";
376
+ if (current && current.state !== "verifying" && !(unverified && refusedForNow && retries++ < 3))
377
+ return current.state;
378
+ if (Date.now() >= deadline) {
379
+ const seconds = Math.round(timing.settleMillis / 1000);
380
+ throw new Error(current?.state === "verifying"
381
+ ? `Hue was still verifying generated file ${id} after ${seconds} seconds`
382
+ : `Hue had not verified generated file ${id} after ${seconds} seconds (${current ? `it was ${current.state}` : "its state could not be read"})`);
383
+ }
384
+ }
385
+ }
324
386
  /**
325
387
  * Publish staged files as verified artifacts. Reservations use stable keys derived from the
326
- * execution and the bytes, so a resumed upload settles on the same artifact.
388
+ * execution and the bytes, so a resumed upload settles on the same artifact, including one Hue
389
+ * finished verifying after an earlier attempt stopped waiting.
327
390
  */
328
- export async function uploadOutputFiles(client, executionId, files, save) {
391
+ export async function uploadOutputFiles(client, executionId, files, save, timing = artifactSettling) {
329
392
  for (const file of files) {
330
393
  if (file.artifactId)
331
394
  continue;
@@ -339,7 +402,8 @@ export async function uploadOutputFiles(client, executionId, files, save) {
339
402
  });
340
403
  let state = reserved.state;
341
404
  if (state !== "ready") {
342
- if (reserved.copyState !== "acknowledged") {
405
+ // A verification already running holds the uploaded bytes; Hue refuses another upload then.
406
+ if (reserved.copyState !== "acknowledged" && state !== "verifying") {
343
407
  const upload = await client.requestArtifactUpload(reserved.id);
344
408
  try {
345
409
  await client.uploadArtifactBytes(upload, bytes, file.contentType);
@@ -349,18 +413,7 @@ export async function uploadOutputFiles(client, executionId, files, save) {
349
413
  // settled by verified completion; never rewrite a final object here.
350
414
  }
351
415
  }
352
- let attempt = 0;
353
- for (;;) {
354
- try {
355
- state = (await client.completeArtifact(reserved.id)).state;
356
- break;
357
- }
358
- catch (error) {
359
- if (!(error instanceof HueApiError) || error.status !== 503 || attempt++ >= 2)
360
- throw error;
361
- await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
362
- }
363
- }
416
+ state = await settleArtifact(client, reserved.id, timing);
364
417
  }
365
418
  if (state !== "ready")
366
419
  throw new Error(`Generated file ${file.filename} was not verified by Hue (${state})`);
@@ -4,27 +4,96 @@ export function aggregateBounds(bytes) {
4
4
  return { bytes, nodes: Math.ceil(bytes / 2), depth: valueBounds.depth + 8 };
5
5
  }
6
6
  const isText = (item) => item.isWellFormed() && !item.includes("\u0000");
7
+ /** A string's code points: its UTF-16 length less one for each surrogate pair. */
8
+ function codePoints(text) {
9
+ let count = text.length;
10
+ for (let index = 0; index < text.length - 1; index++) {
11
+ const unit = text.charCodeAt(index);
12
+ if (unit < 0xd800 || unit > 0xdbff)
13
+ continue;
14
+ const next = text.charCodeAt(index + 1);
15
+ if (next >= 0xdc00 && next <= 0xdfff) {
16
+ count--;
17
+ index++;
18
+ }
19
+ }
20
+ return count;
21
+ }
22
+ /** Thrown within `json` when the byte bound is passed, which is checked last. */
23
+ class PastByteBound extends Error {
24
+ }
7
25
  /** Reject lossy JSON serialization before creating requests/checkpoints. */
8
26
  export function json(value, requested = valueBounds) {
9
27
  const bounds = typeof requested === "number" ? { ...valueBounds, bytes: requested } : requested;
10
28
  const ancestors = new Set();
11
29
  let nodes = 0;
30
+ // The UTF-8 length of the JSON text, counted as the value is read, so a value too long to
31
+ // serialize (past the runtime's longest string) is refused before `JSON.stringify` is asked to.
32
+ let bytes = 0;
33
+ // Once the byte bound is passed, the value is read again only to check it (below).
34
+ let checking = false;
35
+ // Text the second read has found valid is not checked again, so a string referenced many times
36
+ // is scanned once: by content up to 16,383 UTF-16 units, and a longer string when it is the last
37
+ // checked of its length. V8 hashes those by length alone, so a set of them would compare each
38
+ // with every other of that length.
39
+ const checkedText = new Set();
40
+ const checkedLong = new Map();
41
+ const text = (item) => {
42
+ if (!checking)
43
+ return isText(item);
44
+ if (item.length <= 16_383 ? checkedText.has(item) : checkedLong.get(item.length) === item)
45
+ return true;
46
+ if (!isText(item))
47
+ return false;
48
+ if (item.length <= 16_383)
49
+ checkedText.add(item);
50
+ else
51
+ checkedLong.set(item.length, item);
52
+ return true;
53
+ };
54
+ const charge = (amount) => {
55
+ if (checking)
56
+ return;
57
+ bytes += amount;
58
+ if (bytes > bounds.bytes)
59
+ throw new PastByteBound();
60
+ };
61
+ // A string's JSON text is at least as long as the string, so one longer than what is left is
62
+ // refused before it is escaped.
63
+ const chargeText = (text) => {
64
+ if (checking)
65
+ return;
66
+ if (text.length + 2 > bounds.bytes - bytes)
67
+ throw new PastByteBound();
68
+ charge(Buffer.byteLength(JSON.stringify(text)));
69
+ };
12
70
  const visit = (item, depth) => {
13
71
  if (++nodes > bounds.nodes || depth > bounds.depth)
14
72
  throw new RangeError("JSON exceeds depth/node limits");
15
- if (item === null || typeof item === "boolean")
73
+ if (item === null || typeof item === "boolean") {
74
+ charge(item === false ? 5 : 4);
16
75
  return item;
17
- if (typeof item === "number" && Number.isFinite(item))
76
+ }
77
+ if (typeof item === "number" && Number.isFinite(item)) {
78
+ charge(JSON.stringify(item).length);
18
79
  return item;
19
- if (typeof item === "string" && isText(item))
80
+ }
81
+ if (typeof item === "string" && text(item)) {
82
+ chargeText(item);
20
83
  return item;
84
+ }
21
85
  if (!item || typeof item !== "object" || ancestors.has(item))
22
86
  throw new TypeError("Expected finite JSON without cycles or invalid Unicode");
23
87
  ancestors.add(item);
24
88
  try {
25
89
  if (Array.isArray(item)) {
90
+ // Each element is a value: an array longer than the values left is refused before its
91
+ // keys are listed.
92
+ if (item.length > bounds.nodes - nodes)
93
+ throw new RangeError("JSON exceeds depth/node limits");
26
94
  if (Object.keys(item).length !== item.length)
27
95
  throw new TypeError("Sparse/extended arrays are not JSON");
96
+ charge(item.length ? item.length + 1 : 2);
28
97
  return item.map((entry) => visit(entry, depth + 1));
29
98
  }
30
99
  if (Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null)
@@ -32,9 +101,33 @@ export function json(value, requested = valueBounds) {
32
101
  if (Object.getOwnPropertySymbols(item).length)
33
102
  throw new TypeError("JSON cannot contain symbol properties");
34
103
  const result = Object.create(null);
35
- for (const key of Object.keys(item).sort()) {
36
- if (!isText(key))
104
+ // Each member is a value: an object with more than the values left is refused before its
105
+ // keys are sorted.
106
+ const keys = Object.keys(item);
107
+ if (keys.length > bounds.nodes - nodes)
108
+ throw new RangeError("JSON exceeds depth/node limits");
109
+ if (!checking) {
110
+ // Keys whose JSON text alone (at least their code points, two quotes and a colon each)
111
+ // needs more bytes than are left pass the byte bound before they are sorted, as in the
112
+ // Python SDK, so both read such an object the same way. A key has at least half its
113
+ // UTF-16 length in code points, so a long one passes it without counting them.
114
+ let left = bounds.bytes - bytes;
115
+ for (const key of keys) {
116
+ left -= 3;
117
+ if (key.length / 2 > left)
118
+ throw new PastByteBound();
119
+ left -= codePoints(key);
120
+ if (left < 0)
121
+ throw new PastByteBound();
122
+ }
123
+ keys.sort();
124
+ }
125
+ charge(keys.length ? keys.length + 1 : 2);
126
+ for (const key of keys) {
127
+ if (!text(key))
37
128
  throw new TypeError("Expected finite JSON without cycles or invalid Unicode");
129
+ chargeText(key);
130
+ charge(1);
38
131
  const descriptor = Object.getOwnPropertyDescriptor(item, key);
39
132
  if (!("value" in descriptor))
40
133
  throw new TypeError("JSON cannot contain accessors");
@@ -47,10 +140,24 @@ export function json(value, requested = valueBounds) {
47
140
  ancestors.delete(item);
48
141
  }
49
142
  };
50
- const result = visit(value, 0);
51
- if (Buffer.byteLength(JSON.stringify(result)) > bounds.bytes)
52
- throw new RangeError("JSON exceeds byte limit");
53
- return result;
143
+ try {
144
+ const result = visit(value, 0);
145
+ if (Buffer.byteLength(JSON.stringify(result)) > bounds.bytes)
146
+ throw new RangeError("JSON exceeds byte limit");
147
+ return result;
148
+ }
149
+ catch (error) {
150
+ if (!(error instanceof PastByteBound))
151
+ throw error;
152
+ }
153
+ // The byte bound is checked last, as it was before the length was counted as the value is
154
+ // read: a value past it that holds a value that is not JSON, or passes the value or depth bound,
155
+ // is refused for that. So the value is read again with each object's keys in their own order,
156
+ // and nothing is counted, escaped or sorted, nor text checked twice (`text`).
157
+ checking = true;
158
+ nodes = 0;
159
+ visit(value, 0);
160
+ throw new RangeError("JSON exceeds byte limit");
54
161
  }
55
162
  export function digest(value) {
56
163
  return createHash("sha256")
@@ -9,7 +9,7 @@ import { loadEnvironmentEvidence } from "./environment-evidence.js";
9
9
  import { CheckpointStore } from "./checkpoint.js";
10
10
  import { onForcedExit } from "./exit-cleanup.js";
11
11
  import { assertSafeFileNames, downloadCaseFiles, downloadCaseInputs, localOutputFiles, verifyCaseFiles, OutputFileError, stageOutputFiles, targetFileRoles, uploadOutputFiles, } from "./files.js";
12
- import { json, uuid } from "./json.js";
12
+ import { json, uuid, valueBounds } from "./json.js";
13
13
  import { executableHere, persistedScore, scoreLocally, validateScorerBindings } from "./scorers.js";
14
14
  import { TargetResult, } from "./types.js";
15
15
  /**
@@ -41,6 +41,23 @@ export class OutcomeSerializationError extends Error {
41
41
  this.name = "OutcomeSerializationError";
42
42
  }
43
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
+ }
44
61
  /** Thrown when cooperative caller cancellation stops target execution. */
45
62
  export class TargetCancelledError extends Error {
46
63
  constructor() {
@@ -313,7 +330,7 @@ export async function runExperiment(options) {
313
330
  const filesRoot = resolve(options.filesDirectory ?? join(options.checkpointDirectory, "files"));
314
331
  const sanitize = (message) => message.slice(0, 4000).toWellFormed().replaceAll("\u0000", "");
315
332
  const errorPayload = (error) => ({
316
- type: "TargetError",
333
+ type: error instanceof OutputTooLargeError ? "OutputTooLarge" : "TargetError",
317
334
  ...(options.persistResultContent && error instanceof Error
318
335
  ? { message: sanitize(error.message) }
319
336
  : {}),
@@ -476,12 +493,22 @@ export async function runExperiment(options) {
476
493
  try {
477
494
  output = json(output);
478
495
  }
479
- catch {
480
- await store.write(file, {
481
- stage: "serialization_failed",
482
- executionId: execution.id,
483
- });
484
- throw new OutcomeSerializationError(execution.id);
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);
485
512
  }
486
513
  }
487
514
  if (output !== undefined)
@@ -30,6 +30,47 @@ const worldOutcomeMetrics = [
30
30
  "process_constraints",
31
31
  "task_success",
32
32
  ].map((name) => ({ name, type: "boolean" }));
33
+ /** `hue.conversion_outcome.v2`: v1's rubric with the destination reported as `recipient`,
34
+ * `thread` and `subject`. */
35
+ const conversionOutcomeV2Metrics = [
36
+ "completed_run",
37
+ "saved_draft",
38
+ "correct_destination",
39
+ "recipient",
40
+ "thread",
41
+ "subject",
42
+ "content",
43
+ "unrelated_preserved",
44
+ "process_constraints",
45
+ "task_success",
46
+ ].map((name) => ({ name, type: "boolean" }));
47
+ /** `hue.outcome_assertions.v2`'s counts; `.v3` adds the judge counts. */
48
+ const outcomeAssertionMetrics = [
49
+ { name: "task_success", type: "boolean" },
50
+ ...["assertions_passed", "assertions_failed", "advisory_failed", "agent_mistakes"].map((name) => ({ name, type: "number", min: 0 })),
51
+ ];
52
+ const outcomeAssertionV3Metrics = [
53
+ ...outcomeAssertionMetrics,
54
+ ...["judges_passed", "judges_failed", "judges_advisory"].map((name) => ({
55
+ name,
56
+ type: "number",
57
+ min: 0,
58
+ })),
59
+ ];
60
+ /** The judge pin a `hue.outcome_assertions.v3` version carries; every field is required. */
61
+ const outcomeJudgeConfig = z.strictObject({
62
+ model: z.string().min(1).max(200),
63
+ provider: z.string().min(1).max(64),
64
+ template: z.string().regex(/^[a-f0-9]{64}$/),
65
+ samples: z.number().int().min(1).max(9),
66
+ temperature: z.number().min(0).max(2),
67
+ maxOutputTokens: z.number().int().min(1).max(32_000),
68
+ timeoutMs: z.number().int().min(1_000).max(300_000),
69
+ });
70
+ /** A Hue-executed entry whose metrics are fixed by the entry itself. */
71
+ const fixedMetrics = (fixed) => metrics
72
+ .default(fixed)
73
+ .refine((value) => digest(value) === digest(fixed), "World outcome metrics are fixed by the pinned entry");
33
74
  const jsonValue = z.custom((value) => {
34
75
  try {
35
76
  json(value);
@@ -85,9 +126,23 @@ const sdkScorerPublication = z.union([
85
126
  z.strictObject({
86
127
  kind: z.literal("world_outcome"),
87
128
  entry: z.literal("hue.conversion_outcome.v1"),
88
- metrics: metrics
89
- .default(worldOutcomeMetrics)
90
- .refine((value) => digest(value) === digest(worldOutcomeMetrics), "World outcome metrics are fixed by the pinned entry"),
129
+ metrics: fixedMetrics(worldOutcomeMetrics),
130
+ }),
131
+ z.strictObject({
132
+ kind: z.literal("world_outcome"),
133
+ entry: z.literal("hue.conversion_outcome.v2"),
134
+ metrics: fixedMetrics(conversionOutcomeV2Metrics),
135
+ }),
136
+ z.strictObject({
137
+ kind: z.literal("world_outcome"),
138
+ entry: z.literal("hue.outcome_assertions.v2"),
139
+ metrics: fixedMetrics(outcomeAssertionMetrics),
140
+ }),
141
+ z.strictObject({
142
+ kind: z.literal("world_outcome"),
143
+ entry: z.literal("hue.outcome_assertions.v3"),
144
+ metrics: fixedMetrics(outcomeAssertionV3Metrics),
145
+ config: z.strictObject({ judge: outcomeJudgeConfig }),
91
146
  }),
92
147
  z.strictObject({ kind: z.literal("manual"), metrics }),
93
148
  z.strictObject({ kind: z.literal("llm_judge"), config: judgeConfig, metrics }),