@hue-run/sdk 0.11.3 → 0.12.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 +33 -18
- package/EVALUATIONS.md +25 -9
- package/README.md +6 -5
- package/dist/cli/eval.js +23 -5
- package/dist/cli/login.js +2 -1
- package/dist/cli/mcp.d.ts +5 -4
- package/dist/cli/mcp.js +245 -31
- package/dist/client.d.ts +3 -0
- package/dist/client.js +36 -24
- package/dist/environment/world.d.ts +4 -2
- package/dist/environment/world.js +13 -4
- package/dist/evals/client.d.ts +25 -5
- package/dist/evals/client.js +93 -15
- package/dist/evals/environment-target.d.ts +15 -8
- package/dist/evals/environment-target.js +61 -21
- package/dist/evals/local-worker.d.ts +38 -3
- package/dist/evals/local-worker.js +158 -34
- package/dist/evals/runner.d.ts +24 -0
- package/dist/evals/runner.js +154 -12
- package/dist/evals.d.ts +3 -3
- package/dist/evals.js +2 -2
- package/dist/transport.d.ts +7 -0
- package/dist/transport.js +76 -15
- package/dist/types.d.ts +7 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { isTransientEnvironmentError } from "../environment/client.js";
|
|
4
|
+
import { HueApiError, isTransientApiError } from "./client.js";
|
|
5
|
+
import { HueExportError } from "../transport.js";
|
|
3
6
|
import { CheckpointStore, checkpointPath, checkpointSegment } from "./checkpoint.js";
|
|
4
7
|
import { pinRequestedAttemptV2, requestedAttemptV2, runEnvironmentTarget, } from "./environment-target.js";
|
|
5
|
-
import { runExperiment,
|
|
8
|
+
import { runExperiment, } from "./runner.js";
|
|
6
9
|
/** Capability strings a registration declares; Hue offers only matching cases. */
|
|
7
10
|
export const localAgentCapabilities = {
|
|
8
11
|
/** Cases pinned to a hosted synthetic world. */
|
|
@@ -50,6 +53,7 @@ function localAgentTargetContext(context) {
|
|
|
50
53
|
: {}),
|
|
51
54
|
files: structuredClone(context.files),
|
|
52
55
|
outputDirectory: context.outputDirectory,
|
|
56
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
53
57
|
};
|
|
54
58
|
}
|
|
55
59
|
/** The registration's capabilities: explicit values plus one per supplied callback. */
|
|
@@ -98,15 +102,22 @@ function wait(milliseconds, signal) {
|
|
|
98
102
|
signal?.addEventListener("abort", aborted, { once: true });
|
|
99
103
|
});
|
|
100
104
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
/** A failure worth resuming: one that changes on its own. Hue or a world not answering or
|
|
106
|
+
* refusing transiently past the client's retries, telemetry not accepted at the flush, or a run
|
|
107
|
+
* of such failures across concurrent cases. Everything else, an outcome unsafe to resume (the
|
|
108
|
+
* target may have run without a saved outcome, an output that cannot be saved), a saved outcome
|
|
109
|
+
* whose trace was never acknowledged (a resume does not export it again), a refusal Hue decided
|
|
110
|
+
* on, an input the SDK refuses (a case file whose bytes differ from its manifest), would recur on
|
|
111
|
+
* the next attempt and is given up as attention at once. */
|
|
112
|
+
function resumable(error, seen = new Set()) {
|
|
113
|
+
if (isTransientApiError(error) ||
|
|
114
|
+
isTransientEnvironmentError(error) ||
|
|
115
|
+
error instanceof HueExportError)
|
|
105
116
|
return true;
|
|
106
|
-
if (!(error instanceof AggregateError) || seen.has(error))
|
|
117
|
+
if (!(error instanceof AggregateError) || seen.has(error) || !error.errors.length)
|
|
107
118
|
return false;
|
|
108
119
|
seen.add(error);
|
|
109
|
-
return error.errors.
|
|
120
|
+
return error.errors.every((nested) => resumable(nested, seen));
|
|
110
121
|
}
|
|
111
122
|
/**
|
|
112
123
|
* Starts an outbound-only worker for one fixed local agent entry point. Hue chooses
|
|
@@ -119,10 +130,33 @@ export async function runLocalAgent(options) {
|
|
|
119
130
|
const maxRuns = options.maxRuns ?? Number.POSITIVE_INFINITY;
|
|
120
131
|
if (!(maxRuns === Number.POSITIVE_INFINITY || (Number.isInteger(maxRuns) && maxRuns > 0)))
|
|
121
132
|
throw new RangeError("maxRuns must be a positive integer");
|
|
133
|
+
const maxRunAttempts = options.maxRunAttempts ?? 5;
|
|
134
|
+
if (!Number.isInteger(maxRunAttempts) || maxRunAttempts < 1 || maxRunAttempts > 20)
|
|
135
|
+
throw new RangeError("maxRunAttempts must be 1–20");
|
|
136
|
+
if (options.worldTtlSeconds !== undefined &&
|
|
137
|
+
(!Number.isInteger(options.worldTtlSeconds) ||
|
|
138
|
+
options.worldTtlSeconds < 1 ||
|
|
139
|
+
options.worldTtlSeconds > 86_400))
|
|
140
|
+
throw new RangeError("worldTtlSeconds must be 1–86400");
|
|
122
141
|
if (options.environmentClient.baseUrl !== options.client.baseUrl)
|
|
123
142
|
throw new Error("Environments and evaluations must use the same Hue origin");
|
|
124
143
|
const directory = resolve(options.checkpointDirectory);
|
|
125
|
-
|
|
144
|
+
// The first read is polled like the claims that follow: Hue not answering at startup is not
|
|
145
|
+
// a reason for the worker to exit; a refusal Hue decided on still is.
|
|
146
|
+
let project;
|
|
147
|
+
for (let failures = 0;;) {
|
|
148
|
+
try {
|
|
149
|
+
project = await options.client.checkConnection();
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (!isTransientApiError(error) || options.signal?.aborted)
|
|
154
|
+
throw error;
|
|
155
|
+
failures++;
|
|
156
|
+
await options.onPollError?.(error);
|
|
157
|
+
await wait(Math.min(interval * 2 ** Math.min(failures, 6), 60_000), options.signal);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
126
160
|
const store = await CheckpointStore.acquire(directory, {
|
|
127
161
|
kind: "local-agent-worker",
|
|
128
162
|
projectId: project.id,
|
|
@@ -138,28 +172,87 @@ export async function runLocalAgent(options) {
|
|
|
138
172
|
workerId = randomUUID();
|
|
139
173
|
await store.write("worker-id", workerId);
|
|
140
174
|
}
|
|
141
|
-
let
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
175
|
+
let settled = 0;
|
|
176
|
+
/** Consecutive poll failures, for the backoff between polls; reset by a poll that answers. */
|
|
177
|
+
let pollFailures = 0;
|
|
178
|
+
/** Consecutive failed attempts per claimed run; a run is given up at `maxRunAttempts`. */
|
|
179
|
+
const attempts = new Map();
|
|
180
|
+
const bump = (claim) => {
|
|
181
|
+
const attempt = (attempts.get(claim.runId) ?? 0) + 1;
|
|
182
|
+
attempts.set(claim.runId, attempt);
|
|
183
|
+
return attempt;
|
|
184
|
+
};
|
|
185
|
+
/** The wait before the run is resumed: the poll interval doubled per attempt, at most a minute. */
|
|
186
|
+
const backoff = (attempt) => Math.min(interval * 2 ** Math.min(attempt, 6), 60_000);
|
|
187
|
+
/** Gives the run up as attention with the failure's name; a refusal to record it (the run
|
|
188
|
+
* was released or cancelled meanwhile) is reported as such and changes nothing else. */
|
|
189
|
+
const giveUp = async (claim, error) => {
|
|
190
|
+
const failureType = error instanceof Error ? error.name.slice(0, 200) : "WorkerError";
|
|
191
|
+
let outcome = "attention";
|
|
192
|
+
try {
|
|
193
|
+
await options.client.completeLocalAgentRun({
|
|
194
|
+
runId: claim.runId,
|
|
195
|
+
workerId,
|
|
196
|
+
state: "attention",
|
|
197
|
+
failureType,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch (completion) {
|
|
201
|
+
if (completion instanceof HueApiError && completion.status === 409)
|
|
202
|
+
outcome = "refused";
|
|
203
|
+
// A transient failure past the client's retries leaves the run claimed; the next claim
|
|
204
|
+
// returns it and the attempt count gives it up again. A refusal Hue decided on (a
|
|
205
|
+
// revoked key, a disabled agent) is what needs fixing, and is thrown as itself.
|
|
206
|
+
else if (isTransientApiError(completion))
|
|
207
|
+
return false;
|
|
208
|
+
else
|
|
209
|
+
throw completion;
|
|
210
|
+
}
|
|
211
|
+
const attempt = attempts.get(claim.runId) ?? 0;
|
|
212
|
+
attempts.delete(claim.runId);
|
|
213
|
+
settled++;
|
|
214
|
+
await options.onRunFailed?.({ ...claim, error, attempt, outcome });
|
|
215
|
+
return true;
|
|
216
|
+
};
|
|
217
|
+
while (settled < maxRuns && !options.signal?.aborted) {
|
|
218
|
+
let claim;
|
|
219
|
+
try {
|
|
220
|
+
const agent = await options.client.registerLocalAgent({
|
|
221
|
+
...options.agent,
|
|
222
|
+
capabilities,
|
|
223
|
+
scorerDigests: options.agent.scorerDigests ??
|
|
224
|
+
options.scorers?.map((item) => item.definition.sourceDigest) ??
|
|
225
|
+
[],
|
|
226
|
+
});
|
|
227
|
+
claim = await options.client.claimLocalAgentRun({ agentId: agent.id, workerId });
|
|
228
|
+
pollFailures = 0;
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
// A transient failure past the client's own retries (Hue restarting, a network blip)
|
|
232
|
+
// does not end the worker: it waits, longer each time, and polls again. A refusal Hue
|
|
233
|
+
// decided on (a revoked key, a disabled agent, a changed registration) is thrown.
|
|
234
|
+
if (!isTransientApiError(error))
|
|
235
|
+
throw error;
|
|
236
|
+
pollFailures++;
|
|
237
|
+
await options.onPollError?.(error);
|
|
238
|
+
await wait(Math.min(interval * 2 ** Math.min(pollFailures, 6), 60_000), options.signal);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
151
241
|
if (!claim) {
|
|
152
242
|
await wait(interval, options.signal);
|
|
153
243
|
continue;
|
|
154
244
|
}
|
|
245
|
+
// A claim naming an experiment id the checkpoint store refuses is a protocol violation,
|
|
246
|
+
// not an operational failure: it is thrown here, before the attempt, and never retried.
|
|
247
|
+
const experimentId = checkpointSegment(claim.experimentId, "experiment id");
|
|
155
248
|
const heartbeat = setInterval(() => {
|
|
156
249
|
void options.client
|
|
157
250
|
.heartbeatLocalAgentRun({ runId: claim.runId, workerId })
|
|
158
251
|
.catch(() => undefined);
|
|
159
252
|
}, Math.min(15_000, Math.max(1_000, interval)));
|
|
160
253
|
let experimentFinished = false;
|
|
254
|
+
let queueCompleted = false;
|
|
161
255
|
try {
|
|
162
|
-
const experimentId = checkpointSegment(claim.experimentId, "experiment id");
|
|
163
256
|
const requested = requestedConfiguration
|
|
164
257
|
? pinRequestedAttemptV2(requestedConfiguration, (await options.client.getExperiment(experimentId)).config)
|
|
165
258
|
: undefined;
|
|
@@ -201,6 +294,7 @@ export async function runLocalAgent(options) {
|
|
|
201
294
|
inputs,
|
|
202
295
|
context,
|
|
203
296
|
requested,
|
|
297
|
+
ttlSeconds: options.worldTtlSeconds,
|
|
204
298
|
// The registered revision is the agent revision under test.
|
|
205
299
|
agentRevision: options.agent.revision,
|
|
206
300
|
deprecationWarnings: options.deprecationWarnings,
|
|
@@ -218,24 +312,54 @@ export async function runLocalAgent(options) {
|
|
|
218
312
|
workerId,
|
|
219
313
|
state: "completed",
|
|
220
314
|
});
|
|
315
|
+
queueCompleted = true;
|
|
316
|
+
attempts.delete(claim.runId);
|
|
317
|
+
settled++;
|
|
221
318
|
await options.onCompleted?.(report);
|
|
222
|
-
completed++;
|
|
223
319
|
}
|
|
224
320
|
catch (error) {
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
321
|
+
// The run is completed in the queue: a failure after that is the caller's own
|
|
322
|
+
// (`onCompleted` threw) and is thrown as itself, not reported as a retry that cannot be.
|
|
323
|
+
if (queueCompleted)
|
|
324
|
+
throw error;
|
|
325
|
+
if (experimentFinished && error instanceof HueApiError && error.status === 409) {
|
|
326
|
+
// The experiment finished, but Hue no longer accepts this worker's completion: the
|
|
327
|
+
// run was released or cancelled meanwhile. Its outcome is recorded; nothing to resume.
|
|
328
|
+
attempts.delete(claim.runId);
|
|
329
|
+
settled++;
|
|
330
|
+
await options.onRunFailed?.({ ...claim, error, attempt: 0, outcome: "refused" });
|
|
331
|
+
}
|
|
332
|
+
else if (!experimentFinished && !resumable(error)) {
|
|
333
|
+
// A failure that would recur: unsafe to resume, refused by Hue, or an input the SDK
|
|
334
|
+
// refuses. A project member decides; the worker goes on to other runs. When Hue cannot
|
|
335
|
+
// record that for `maxRunAttempts` polls, the worker stops with the error: it can
|
|
336
|
+
// reach neither an outcome nor a record of one.
|
|
337
|
+
if (!(await giveUp(claim, error))) {
|
|
338
|
+
const attempt = bump(claim);
|
|
339
|
+
if (attempt >= maxRunAttempts)
|
|
340
|
+
throw error;
|
|
341
|
+
await wait(backoff(attempt), options.signal);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
// A transient failure: the outcome so far is checkpointed, and the claim stays with
|
|
346
|
+
// this worker, so the next claim returns the same run and the experiment resumes where
|
|
347
|
+
// it stopped. After `maxRunAttempts` such failures the run is given up as attention;
|
|
348
|
+
// when even that cannot be recorded, the worker stops with the error.
|
|
349
|
+
const attempt = bump(claim);
|
|
350
|
+
if (attempt >= maxRunAttempts) {
|
|
351
|
+
if (!(await giveUp(claim, error))) {
|
|
352
|
+
if (attempt >= 2 * maxRunAttempts)
|
|
353
|
+
throw error;
|
|
354
|
+
await wait(backoff(attempt), options.signal);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
const waitMillis = backoff(attempt);
|
|
359
|
+
await options.onRunFailed?.({ ...claim, error, attempt, outcome: "retry", waitMillis });
|
|
360
|
+
await wait(waitMillis, options.signal);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
239
363
|
}
|
|
240
364
|
finally {
|
|
241
365
|
clearInterval(heartbeat);
|
package/dist/evals/runner.d.ts
CHANGED
|
@@ -29,6 +29,30 @@ export declare class OutcomeSerializationError extends Error {
|
|
|
29
29
|
export declare class TargetCancelledError extends Error {
|
|
30
30
|
constructor();
|
|
31
31
|
}
|
|
32
|
+
/** Thrown by the environment target when the target did not return before its world's deadline.
|
|
33
|
+
* The world is sealed abandoned and the attempt saved as an `error` of type `TargetTimeout`, so a
|
|
34
|
+
* hung agent ends its case instead of holding the run open. */
|
|
35
|
+
export declare class TargetTimeoutError extends Error {
|
|
36
|
+
/** The world whose deadline passed. */
|
|
37
|
+
readonly environmentRunId: string;
|
|
38
|
+
/** The world's `expiresAt`. */
|
|
39
|
+
readonly expiresAt: string;
|
|
40
|
+
constructor(
|
|
41
|
+
/** The world whose deadline passed. */
|
|
42
|
+
environmentRunId: string,
|
|
43
|
+
/** The world's `expiresAt`. */
|
|
44
|
+
expiresAt: string);
|
|
45
|
+
}
|
|
46
|
+
/** The target's outcome is saved but the telemetry it requires was never acknowledged on an
|
|
47
|
+
* earlier attempt. Restore or export the trace, or complete with omitted evidence through the
|
|
48
|
+
* client; the target is never rerun. It is this case's own state, not the run's. */
|
|
49
|
+
export declare class TraceExportUnacknowledgedError extends Error {
|
|
50
|
+
/** The execution whose saved outcome waits for its trace. */
|
|
51
|
+
readonly executionId: string;
|
|
52
|
+
constructor(
|
|
53
|
+
/** The execution whose saved outcome waits for its trace. */
|
|
54
|
+
executionId: string);
|
|
55
|
+
}
|
|
32
56
|
/** Thrown when the target or world may have committed but acknowledgement is unavailable. */
|
|
33
57
|
export declare class TargetOutcomeUncertainError extends Error {
|
|
34
58
|
/** Execution whose target outcome must never be replayed automatically. */
|
package/dist/evals/runner.js
CHANGED
|
@@ -65,6 +65,36 @@ export class TargetCancelledError extends Error {
|
|
|
65
65
|
this.name = "TargetCancelledError";
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
+
/** Thrown by the environment target when the target did not return before its world's deadline.
|
|
69
|
+
* The world is sealed abandoned and the attempt saved as an `error` of type `TargetTimeout`, so a
|
|
70
|
+
* hung agent ends its case instead of holding the run open. */
|
|
71
|
+
export class TargetTimeoutError extends Error {
|
|
72
|
+
environmentRunId;
|
|
73
|
+
expiresAt;
|
|
74
|
+
constructor(
|
|
75
|
+
/** The world whose deadline passed. */
|
|
76
|
+
environmentRunId,
|
|
77
|
+
/** The world's `expiresAt`. */
|
|
78
|
+
expiresAt) {
|
|
79
|
+
super(`Target did not finish before its world ${environmentRunId} expired at ${expiresAt}`);
|
|
80
|
+
this.environmentRunId = environmentRunId;
|
|
81
|
+
this.expiresAt = expiresAt;
|
|
82
|
+
this.name = "TargetTimeoutError";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** The target's outcome is saved but the telemetry it requires was never acknowledged on an
|
|
86
|
+
* earlier attempt. Restore or export the trace, or complete with omitted evidence through the
|
|
87
|
+
* client; the target is never rerun. It is this case's own state, not the run's. */
|
|
88
|
+
export class TraceExportUnacknowledgedError extends Error {
|
|
89
|
+
executionId;
|
|
90
|
+
constructor(
|
|
91
|
+
/** The execution whose saved outcome waits for its trace. */
|
|
92
|
+
executionId) {
|
|
93
|
+
super("Target outcome is saved but trace export acknowledgement is unavailable. Restore/export the trace or explicitly complete with omitted evidence through the client; never rerun the target.");
|
|
94
|
+
this.executionId = executionId;
|
|
95
|
+
this.name = "TraceExportUnacknowledgedError";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
68
98
|
/** Thrown when the target or world may have committed but acknowledgement is unavailable. */
|
|
69
99
|
export class TargetOutcomeUncertainError extends Error {
|
|
70
100
|
executionId;
|
|
@@ -177,25 +207,82 @@ async function allPages(page, maximum = 5000) {
|
|
|
177
207
|
} while (after !== undefined);
|
|
178
208
|
return items;
|
|
179
209
|
}
|
|
210
|
+
/** A failure the next case would meet too: Hue not answering or refusing (a transient failure
|
|
211
|
+
* past the client's retries, a lost connection, a refusal such as a finished experiment or a
|
|
212
|
+
* world that cannot be sealed), telemetry not accepted by the one transport the cases share, or
|
|
213
|
+
* a target outcome left uncertain because its world could not be sealed or read back. Starting
|
|
214
|
+
* more cases into it only piles up the same failure; the run is resumed later instead. */
|
|
215
|
+
function systemic(error) {
|
|
216
|
+
// A saved outcome still waiting for its trace (`TraceExportUnacknowledgedError`) is not
|
|
217
|
+
// systemic: the flush failed on an earlier attempt, telemetry may have recovered since, and
|
|
218
|
+
// the cases beside it can complete now; only a live export failure stops new cases.
|
|
219
|
+
return (error instanceof HueApiError ||
|
|
220
|
+
error instanceof HueExportError ||
|
|
221
|
+
error instanceof TargetOutcomeUncertainError);
|
|
222
|
+
}
|
|
223
|
+
/** Runs every case, `concurrency` at a time. One case's own failure (an attempt without a saved
|
|
224
|
+
* outcome, an output that cannot be saved, inputs the SDK refuses) does not stop the cases
|
|
225
|
+
* beside or after it: each is attempted, and the failures are thrown together at the end, so a
|
|
226
|
+
* resume has only the failed cases left to publish. A systemic failure stops new cases from
|
|
227
|
+
* starting, while the cases in flight finish; the run resumes when Hue answers again. */
|
|
180
228
|
async function pool(items, concurrency, execute) {
|
|
181
229
|
let position = 0;
|
|
182
230
|
const failures = [];
|
|
231
|
+
let stop = false;
|
|
183
232
|
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
184
|
-
while (position < items.length && !
|
|
233
|
+
while (position < items.length && !stop) {
|
|
185
234
|
const item = items[position++];
|
|
186
235
|
try {
|
|
187
236
|
await execute(item);
|
|
188
237
|
}
|
|
189
238
|
catch (error) {
|
|
190
239
|
failures.push(error);
|
|
240
|
+
if (systemic(error))
|
|
241
|
+
stop = true;
|
|
191
242
|
}
|
|
192
243
|
}
|
|
193
244
|
}));
|
|
194
245
|
if (failures.length === 1)
|
|
195
246
|
throw failures[0];
|
|
196
|
-
if (failures.length)
|
|
197
|
-
|
|
247
|
+
if (failures.length) {
|
|
248
|
+
const first = failures[0];
|
|
249
|
+
throw new AggregateError(failures, `${failures.length} case operations failed; resume uses saved outcomes. First: ${first instanceof Error ? first.message : String(first)}`);
|
|
250
|
+
}
|
|
198
251
|
}
|
|
252
|
+
/** Whether Hue holds the case's telemetry, read from the trace receipt: its root span and at
|
|
253
|
+
* least as many spans as the case ended; false when they are not there by the wait's end or the
|
|
254
|
+
* receipt cannot be read, and for a checkpoint an older runner saved without the span. */
|
|
255
|
+
async function traceLanded(hue, trace) {
|
|
256
|
+
// Without the count of spans the case ended, the receipt cannot tell a lost child span from a
|
|
257
|
+
// landed trace, and does not accept.
|
|
258
|
+
if (!trace || trace.spans === undefined)
|
|
259
|
+
return false;
|
|
260
|
+
const deadline = Date.now() + RECEIPT_WAIT_MS;
|
|
261
|
+
try {
|
|
262
|
+
for (;;) {
|
|
263
|
+
// The root span present and every span the case ended held: a lost child span is not
|
|
264
|
+
// hidden behind a root that landed. The receipt answers as soon as the root is there, so
|
|
265
|
+
// spans still arriving behind it are polled for until the wait ends.
|
|
266
|
+
const verification = await hue.verifyTrace(trace.traceId, {
|
|
267
|
+
expectedSpanIds: [trace.spanId],
|
|
268
|
+
timeoutMillis: Math.max(250, deadline - Date.now()),
|
|
269
|
+
});
|
|
270
|
+
if (verification.verified && (verification.receipt?.spanCount ?? 0) >= trace.spans)
|
|
271
|
+
return true;
|
|
272
|
+
if (!verification.verified || Date.now() + RECEIPT_POLL_MS >= deadline)
|
|
273
|
+
return false;
|
|
274
|
+
await new Promise((resolve) => setTimeout(resolve, RECEIPT_POLL_MS));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** How long a receipt is polled for the root span after a flush reported a failure: ingestion
|
|
282
|
+
* of an accepted batch is not instant, and a span that is not there by then was not delivered. */
|
|
283
|
+
const RECEIPT_WAIT_MS = 5_000;
|
|
284
|
+
/** How often the receipt is read again while the root is there but spans are still arriving. */
|
|
285
|
+
const RECEIPT_POLL_MS = 500;
|
|
199
286
|
async function scoresFor(versions, context, options, executionId, hasEnvironment = true) {
|
|
200
287
|
const scores = [];
|
|
201
288
|
let environmentUnavailable = false;
|
|
@@ -329,8 +416,14 @@ export async function runExperiment(options) {
|
|
|
329
416
|
try {
|
|
330
417
|
const filesRoot = resolve(options.filesDirectory ?? join(options.checkpointDirectory, "files"));
|
|
331
418
|
const sanitize = (message) => message.slice(0, 4000).toWellFormed().replaceAll("\u0000", "");
|
|
419
|
+
// A target stopped at its world's deadline is a `TargetTimeout`, told apart from an error the
|
|
420
|
+
// target raised itself: the agent did not finish, rather than finishing wrongly.
|
|
332
421
|
const errorPayload = (error) => ({
|
|
333
|
-
type: error instanceof OutputTooLargeError
|
|
422
|
+
type: error instanceof OutputTooLargeError
|
|
423
|
+
? "OutputTooLarge"
|
|
424
|
+
: error instanceof TargetTimeoutError
|
|
425
|
+
? "TargetTimeout"
|
|
426
|
+
: "TargetError",
|
|
334
427
|
...(options.persistResultContent && error instanceof Error
|
|
335
428
|
? { message: sanitize(error.message) }
|
|
336
429
|
: {}),
|
|
@@ -378,6 +471,7 @@ export async function runExperiment(options) {
|
|
|
378
471
|
complete,
|
|
379
472
|
scores,
|
|
380
473
|
exportState: "pending",
|
|
474
|
+
...(saved.trace ? { trace: saved.trace } : {}),
|
|
381
475
|
};
|
|
382
476
|
await store.write(file, prepared);
|
|
383
477
|
return prepared;
|
|
@@ -533,6 +627,7 @@ export async function runExperiment(options) {
|
|
|
533
627
|
const uploading = {
|
|
534
628
|
stage: "uploading",
|
|
535
629
|
executionId: execution.id,
|
|
630
|
+
trace: { traceId: span.traceId, spanId: span.spanId },
|
|
536
631
|
state,
|
|
537
632
|
hasOutput: output !== undefined,
|
|
538
633
|
...(options.persistResultContent && output !== undefined ? { output } : {}),
|
|
@@ -553,13 +648,50 @@ export async function runExperiment(options) {
|
|
|
553
648
|
// End root before waiting for both OTLP signals. Export failure leaves the prepared checkpoint intact.
|
|
554
649
|
let exportError;
|
|
555
650
|
try {
|
|
556
|
-
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
651
|
+
// The flush itself throws for any failure since the last flush; the issues it recorded
|
|
652
|
+
// are read below, where they are attributed.
|
|
653
|
+
await options.hue.flush().catch((error) => {
|
|
654
|
+
if (!(error instanceof HueExportError))
|
|
655
|
+
throw error;
|
|
656
|
+
});
|
|
657
|
+
// The transport is shared by every case in flight, so a failure the flush reports may
|
|
658
|
+
// be another case's. Each issue names the traces of the records it concerned: one that
|
|
659
|
+
// names this trace is this case's failure, final because its evidence is incomplete;
|
|
660
|
+
// one that names only others is not this case's; one that names no trace (a processor
|
|
661
|
+
// flush that failed) may be anyone's, and this case's trace receipt decides: its root
|
|
662
|
+
// span landed, or the case stays pending for the receipt to decide again on resume.
|
|
663
|
+
const decided = checkpoint;
|
|
664
|
+
const traceId = decided.trace?.traceId;
|
|
665
|
+
// Every span the case ended has reached the exporter by now; the receipt is held to
|
|
666
|
+
// that count, so a lost child span is not hidden behind a root span that landed. The
|
|
667
|
+
// count is saved before anything is decided, so a process that stops here leaves a
|
|
668
|
+
// pending checkpoint the receipt can be held to; a transport that does not count (an
|
|
669
|
+
// older or borrowed one) leaves no count, and the receipt then cannot accept.
|
|
670
|
+
if (decided.trace && traceId !== undefined) {
|
|
671
|
+
const spans = options.hue.transport.spansEnded?.(traceId);
|
|
672
|
+
if (spans !== undefined) {
|
|
673
|
+
decided.trace.spans = spans;
|
|
674
|
+
await store.write(file, decided);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (options.hue.transport.getFailureSequence() !== failureSequenceBefore) {
|
|
678
|
+
const retained = options.hue.transport.getIssues();
|
|
679
|
+
const issues = retained.filter((issue) => issue.sequence > failureSequenceBefore && issue.kind !== "warning");
|
|
680
|
+
const named = issues.filter((issue) => issue.traceIds && (traceId === undefined || issue.traceIds.includes(traceId)));
|
|
681
|
+
// An issue naming no trace, and a failure whose issue the bounded history has already
|
|
682
|
+
// rolled past (the oldest retained issue is younger than the first this case could
|
|
683
|
+
// have caused), may be anyone's.
|
|
684
|
+
const unattributed = issues.filter((issue) => !issue.traceIds);
|
|
685
|
+
const evicted = issues.length === 0 || (retained[0]?.sequence ?? 0) > failureSequenceBefore + 1;
|
|
686
|
+
if (named.length) {
|
|
687
|
+
checkpoint.exportState = "failed";
|
|
688
|
+
await store.write(file, checkpoint);
|
|
689
|
+
throw new HueExportError([...named, ...unattributed], options.hue.transport.getReport());
|
|
690
|
+
}
|
|
691
|
+
if ((unattributed.length || evicted) &&
|
|
692
|
+
!(await traceLanded(options.hue, decided.trace)))
|
|
693
|
+
throw new HueExportError(unattributed, options.hue.transport.getReport());
|
|
694
|
+
}
|
|
563
695
|
}
|
|
564
696
|
catch (error) {
|
|
565
697
|
exportError = error;
|
|
@@ -606,8 +738,18 @@ export async function runExperiment(options) {
|
|
|
606
738
|
}
|
|
607
739
|
const prepared = checkpoint;
|
|
608
740
|
await options.client.getExecution(prepared.executionId);
|
|
741
|
+
// A saved outcome no flush decided on is asked about through its trace receipt: the root
|
|
742
|
+
// span there says the case's export landed before the process stopped, or that a later
|
|
743
|
+
// flush of the same transport delivered it. An export failure that named this trace is
|
|
744
|
+
// final: the evidence is incomplete whatever the receipt holds.
|
|
745
|
+
if (prepared.exportState === "pending" &&
|
|
746
|
+
prepared.complete.traceEvidence !== "omit" &&
|
|
747
|
+
(await traceLanded(options.hue, prepared.trace))) {
|
|
748
|
+
prepared.exportState = "accepted";
|
|
749
|
+
await store.write(file, prepared);
|
|
750
|
+
}
|
|
609
751
|
if (prepared.exportState !== "accepted" && prepared.complete.traceEvidence !== "omit")
|
|
610
|
-
throw new
|
|
752
|
+
throw new TraceExportUnacknowledgedError(prepared.executionId);
|
|
611
753
|
const save = () => store.write(file, prepared);
|
|
612
754
|
const notAccepted = prepared.exportState === "not_accepted"
|
|
613
755
|
? {
|
package/dist/evals.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { ArtifactSizeError, createEvaluationClient, EvaluationClient, HueApiError, } from "./evals/client.js";
|
|
1
|
+
export { ArtifactSizeError, createEvaluationClient, EvaluationClient, HueApiError, isTransientApiError, } from "./evals/client.js";
|
|
2
2
|
export type { EvaluationClientOptions } from "./evals/client.js";
|
|
3
|
-
export { runExperiment, rescore, UncertainExecutionError, OutcomeSerializationError, TargetCancelledError, TargetOutcomeUncertainError, } from "./evals/runner.js";
|
|
3
|
+
export { runExperiment, rescore, UncertainExecutionError, OutcomeSerializationError, TargetCancelledError, TargetOutcomeUncertainError, TargetTimeoutError, TraceExportUnacknowledgedError, } from "./evals/runner.js";
|
|
4
4
|
export type { RunExperimentOptions, RunExperimentTargetContext, RescoreOptions, RunnerReport, TelemetryIssueCount, TelemetryNotAccepted, } from "./evals/runner.js";
|
|
5
5
|
export { TargetResult, withFiles } from "./evals/types.js";
|
|
6
6
|
export { CaseFileError, outputContentTypes, outputFileLimits, OutputFileError, safeFilename, targetFileRoles, } from "./evals/files.js";
|
|
@@ -13,7 +13,7 @@ export { builtins, defineLocalScorer, scoreLocally } from "./evals/scorers.js";
|
|
|
13
13
|
export { sourceDigest } from "./evals/json.js";
|
|
14
14
|
export type * from "./evals/types.js";
|
|
15
15
|
export { localAgentCapabilities, registeredCapabilities, runLocalAgent, } from "./evals/local-worker.js";
|
|
16
|
-
export type { LocalAgentDirectContext, LocalAgentTargetContext, RunLocalAgentOptions, } from "./evals/local-worker.js";
|
|
16
|
+
export type { LocalAgentDirectContext, LocalAgentTargetContext, LocalRunFailure, RunLocalAgentOptions, } from "./evals/local-worker.js";
|
|
17
17
|
export { getScenario, listScenarios, matchByName, parseScenarioSelector, resolveEvalSetPins, resolveScenarioPins, } from "./evals/scenarios.js";
|
|
18
18
|
export type { NamedCandidate, NameMatch, ScenarioClient, ScenarioPins, ScenarioSelector, } from "./evals/scenarios.js";
|
|
19
19
|
export { collectExperimentVerdicts, compareVerdicts, metricPassed, summarizeVerdicts, waitForResults, } from "./evals/verdicts.js";
|
package/dist/evals.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { ArtifactSizeError, createEvaluationClient, EvaluationClient, HueApiError, } from "./evals/client.js";
|
|
2
|
-
export { runExperiment, rescore, UncertainExecutionError, OutcomeSerializationError, TargetCancelledError, TargetOutcomeUncertainError, } from "./evals/runner.js";
|
|
1
|
+
export { ArtifactSizeError, createEvaluationClient, EvaluationClient, HueApiError, isTransientApiError, } from "./evals/client.js";
|
|
2
|
+
export { runExperiment, rescore, UncertainExecutionError, OutcomeSerializationError, TargetCancelledError, TargetOutcomeUncertainError, TargetTimeoutError, TraceExportUnacknowledgedError, } from "./evals/runner.js";
|
|
3
3
|
export { TargetResult, withFiles } from "./evals/types.js";
|
|
4
4
|
export { CaseFileError, outputContentTypes, outputFileLimits, OutputFileError, safeFilename, targetFileRoles, } from "./evals/files.js";
|
|
5
5
|
export { runSimulation } from "./evals/simulation.js";
|
package/dist/transport.d.ts
CHANGED
|
@@ -75,6 +75,13 @@ export declare class HueTransport {
|
|
|
75
75
|
getIssues(): ExportIssue[];
|
|
76
76
|
/** Monotonic failure marker, retained even when the bounded issue history rolls over. */
|
|
77
77
|
getFailureSequence(): number;
|
|
78
|
+
/** Spans of a trace this transport has handed to its exporter, by trace id, for the newest
|
|
79
|
+
* traces only: a runner compares the count with the trace receipt's span count to know that
|
|
80
|
+
* every span of a case landed, not only the root. Undefined for a trace it does not remember,
|
|
81
|
+
* which is not the same as none: the caller must not take an unknown count for complete. */
|
|
82
|
+
spansEnded(traceId: string): number | undefined;
|
|
83
|
+
private endedSpans;
|
|
84
|
+
private countEnded;
|
|
78
85
|
/**
|
|
79
86
|
* Waits for the processors' and exporters' in-flight work; drain the providers first.
|
|
80
87
|
*
|