@hue-run/sdk 0.10.0 → 0.11.1
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 +154 -28
- package/ENVIRONMENTS.md +5 -1
- package/EVALUATIONS.md +2 -2
- package/README.md +19 -8
- package/dist/cli/eval.js +43 -17
- package/dist/cli/listen.d.ts +76 -0
- package/dist/cli/listen.js +816 -0
- package/dist/cli/mcp.d.ts +66 -7
- package/dist/cli/mcp.js +273 -74
- package/dist/client.d.ts +7 -4
- package/dist/client.js +36 -10
- package/dist/evals/client.d.ts +5 -1
- package/dist/evals/client.js +17 -11
- package/dist/evals/files.d.ts +10 -2
- package/dist/evals/files.js +68 -15
- package/dist/evals/json.js +116 -9
- package/dist/evals/runner.js +35 -8
- package/dist/evals/scorer-publication.js +58 -3
- package/dist/evals/types.d.ts +44 -0
- package/dist/evals/verdicts.d.ts +11 -0
- package/dist/evals/verdicts.js +33 -4
- package/dist/inline-files.js +11 -3
- 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/tool-definitions.d.ts +18 -0
- package/dist/tool-definitions.js +232 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
|
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
|
|
523
|
-
*
|
|
524
|
-
*
|
|
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({
|
|
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.
|
|
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();
|
package/dist/evals/client.d.ts
CHANGED
|
@@ -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 {
|
package/dist/evals/client.js
CHANGED
|
@@ -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
|
-
|
|
116
|
-
|
|
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();
|
package/dist/evals/files.d.ts
CHANGED
|
@@ -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
|
|
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[];
|
package/dist/evals/files.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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})`);
|
package/dist/evals/json.js
CHANGED
|
@@ -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
|
-
|
|
76
|
+
}
|
|
77
|
+
if (typeof item === "number" && Number.isFinite(item)) {
|
|
78
|
+
charge(JSON.stringify(item).length);
|
|
18
79
|
return item;
|
|
19
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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")
|
package/dist/evals/runner.js
CHANGED
|
@@ -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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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:
|
|
89
|
-
|
|
90
|
-
|
|
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 }),
|