@hue-run/sdk 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLI.md +240 -49
- package/ENVIRONMENTS.md +48 -9
- package/EVALUATIONS.md +41 -21
- package/README.md +70 -30
- package/dist/cli/eval-direct.d.ts +12 -1
- package/dist/cli/eval-direct.js +145 -33
- package/dist/cli/eval.d.ts +28 -1
- package/dist/cli/eval.js +504 -81
- package/dist/cli/listen.d.ts +76 -0
- package/dist/cli/listen.js +816 -0
- package/dist/cli/mcp.d.ts +65 -7
- package/dist/cli/mcp.js +271 -73
- package/dist/client.d.ts +7 -4
- package/dist/client.js +36 -10
- package/dist/evals/checkpoint.d.ts +15 -1
- package/dist/evals/checkpoint.js +34 -3
- package/dist/evals/client.d.ts +24 -4
- package/dist/evals/client.js +92 -38
- package/dist/evals/environment-target.d.ts +18 -3
- package/dist/evals/environment-target.js +22 -10
- package/dist/evals/exit-cleanup.d.ts +4 -0
- package/dist/evals/exit-cleanup.js +26 -0
- package/dist/evals/files.d.ts +69 -3
- package/dist/evals/files.js +255 -42
- package/dist/evals/json.js +116 -9
- package/dist/evals/local-worker.d.ts +21 -3
- package/dist/evals/local-worker.js +14 -0
- package/dist/evals/runner.d.ts +45 -0
- package/dist/evals/runner.js +199 -21
- package/dist/evals/scenarios.d.ts +7 -5
- package/dist/evals/scenarios.js +71 -14
- package/dist/evals/scorer-publication.js +58 -3
- package/dist/evals/simulation.d.ts +17 -4
- package/dist/evals/simulation.js +8 -0
- package/dist/evals/types.d.ts +48 -0
- package/dist/evals/verdicts.d.ts +26 -2
- package/dist/evals/verdicts.js +72 -9
- package/dist/evals.d.ts +4 -3
- package/dist/evals.js +2 -2
- package/dist/inline-files.d.ts +4 -0
- package/dist/inline-files.js +48 -17
- package/dist/provider-tools.d.ts +12 -1
- package/dist/provider-tools.js +192 -106
- package/dist/safety.d.ts +7 -1
- package/dist/safety.js +9 -7
- package/dist/setup/cli.js +31 -8
- package/dist/setup/peers.d.ts +13 -0
- package/dist/setup/peers.js +44 -0
- package/dist/snapshot.d.ts +2 -2
- package/dist/snapshot.js +47 -12
- package/dist/tool-definitions.d.ts +18 -0
- package/dist/tool-definitions.js +232 -0
- package/dist/transport.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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();
|
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
/**
|
|
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>;
|
package/dist/evals/checkpoint.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|
package/dist/evals/client.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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;
|
package/dist/evals/client.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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()),
|
|
89
|
+
? Math.min(Math.max(0, graceEnd - Date.now()), timing.maxGraceWaitMs)
|
|
85
90
|
: 0;
|
|
86
|
-
const deadline = performance.now() + wait +
|
|
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 =
|
|
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
|
+
}
|