@hue-run/sdk 0.11.4 → 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 CHANGED
@@ -42,7 +42,7 @@ project manifest are refused because managers can update ancestor locks; Python
42
42
  The generated `hue.setup.mjs` or `hue_setup.py` always selects `captureContent: false` /
43
43
  `capture_content=False`. For a supported application, setup installs the dependency and adds the
44
44
  managed import and middleware registration to the existing entrypoint; an unreferenced helper is
45
- not a completed integration. TypeScript uses `@hue-run/sdk@0.11.4`, `@opentelemetry/api@1.9.1` and
45
+ not a completed integration. TypeScript uses `@hue-run/sdk@0.12.0`, `@opentelemetry/api@1.9.1` and
46
46
  `@opentelemetry/context-async-hooks@2.11.0`; Python setup uses its separately tested package pin.
47
47
  Content capture requires an ordinary account-managed key and a later explicit application decision.
48
48
 
@@ -469,7 +469,7 @@ node packages/sdk-typescript/scripts/verify-package.mjs --artifacts-dir .artifac
469
469
  # Set project to an existing supported fixture; use the same directory on resume.
470
470
  project=/absolute/path/to/supported-fixture
471
471
  node packages/sdk-typescript/scripts/verify-setup-live.mjs \
472
- --archive .artifacts/typescript/hue-run-sdk-0.11.4.tgz \
472
+ --archive .artifacts/typescript/hue-run-sdk-0.12.0.tgz \
473
473
  --origin https://STAGING_ORIGIN \
474
474
  --project "$project" --command setup \
475
475
  --evidence .context/setup-staging-before-claim.json
@@ -480,7 +480,7 @@ the private local handoff and finish the real browser claim, then reconcile the
480
480
 
481
481
  ```sh
482
482
  node packages/sdk-typescript/scripts/verify-setup-live.mjs \
483
- --archive .artifacts/typescript/hue-run-sdk-0.11.4.tgz \
483
+ --archive .artifacts/typescript/hue-run-sdk-0.12.0.tgz \
484
484
  --origin https://STAGING_ORIGIN \
485
485
  --project "$project" --command claim \
486
486
  --evidence .context/setup-staging-after-claim.json
package/EVALUATIONS.md CHANGED
@@ -116,7 +116,7 @@ Both choices are required and independent:
116
116
 
117
117
  Frozen dataset inputs/references already exist on Hue. Their presence is independent of these switches. No output is inferred from telemetry. JavaScript `undefined` means unavailable output; JSON `null`, false, zero and empty string remain present values. Historical subjects with unavailable output produce skipped results without executing a scorer callback. A failed quality metric remains `state:"scored"` with `passed:false`. Target errors and scorer errors remain separate. Target error types are generalized to `TargetError`; when content is enabled, bounded messages may be stored. Never put credentials in error messages, output, custom metric values, names or metadata.
118
118
 
119
- `traceEvidence:{mode:"required"}` waits for trace and log export acknowledgement after the root span ends. The explicit alternative `{mode:"omit",reason:"..."}` stores the omission reason and declared trace ID without a fake snapshot. It can complete despite an export failure; export diagnostics remain available on the Hue client. There is no automatic fallback to omission. When required telemetry is not accepted, the runner by default keeps the saved outcome, leaves the execution started and rejects (`traceNotAccepted: "stop"`). With `traceNotAccepted: "fail_case"` it instead completes that case as failed (a `TelemetryNotAccepted` error, the evidence omitted with a reason starting `telemetry_not_accepted`, and no output or generated files attached, so no scorer can pass it), calls `onTelemetryNotAccepted(entry)` once, as soon as it completes the case (a resumed call only lists it), lists it in `report.telemetryNotAccepted` with sanitized issue counts, and goes on; `hue eval` uses this. A scorer that grades only the world can still score such a case, so treat a listed case as failed whatever its results say, as `hue eval` does.
119
+ `traceEvidence:{mode:"required"}` waits for trace and log export acknowledgement after the root span ends. The transport is shared by every case in flight, so each export issue names the traces of the records it concerned (`ExportIssue.traceIds`): a refused or rejected batch, a dropped record, a capture failure inside a case. A failure that names the case's trace is that case's own and final (its checkpoint reads `failed`, and resume refuses it); one that names only other traces leaves the case accepted; one that names no trace (a processor flush that failed) or a rejection in a batch of several traces is decided by the case's trace receipt, at once or on resume for a checkpoint still `pending`: the receipt must hold the case's root span and at least as many spans as the case ended (the checkpoint records the count at the flush; without it the receipt does not accept). The explicit alternative `{mode:"omit",reason:"..."}` stores the omission reason and declared trace ID without a fake snapshot. It can complete despite an export failure; export diagnostics remain available on the Hue client. There is no automatic fallback to omission. When required telemetry is not accepted, the runner by default keeps the saved outcome, leaves the execution started and rejects (`traceNotAccepted: "stop"`). With `traceNotAccepted: "fail_case"` it instead completes that case as failed (a `TelemetryNotAccepted` error, the evidence omitted with a reason starting `telemetry_not_accepted`, and no output or generated files attached, so no scorer can pass it), calls `onTelemetryNotAccepted(entry)` once, as soon as it completes the case (a resumed call only lists it), lists it in `report.telemetryNotAccepted` with sanitized issue counts, and goes on; `hue eval` uses this. A scorer that grades only the world can still score such a case, so treat a listed case as failed whatever its results say, as `hue eval` does.
120
120
 
121
121
  ## Local scorers
122
122
 
@@ -252,14 +252,30 @@ without invoking the target or scorers. A lost preparation acknowledgement remai
252
252
  and is never recovered through binding reads, credential refresh or target replay. Synthetic
253
253
  acceptance does not contact official Gmail or claim universal provider parity.
254
254
 
255
- Completion or result-upload failures keep the run claimed by the durable worker identity.
256
- Restart with the same checkpoint directory to resume saved uploads without invoking the
257
- candidate again. A lost world-seal acknowledgement is recovered by reading authoritative world
258
- state. If the seal or candidate outcome cannot be confirmed, or an outcome cannot be serialized,
259
- the worker reports `attention` and stops; operator investigation is required. Such runs are not
260
- automatically reclaimed, and presenting the same uncertain checkpoint again cannot replay the
261
- candidate. Public package acceptance proves this lifecycle against local fixtures; exact
262
- installed-registry-package to hosted-facade acceptance remains a post-publication Fern gate.
255
+ The worker does not die with one failure. A read, or a mutation the server deduplicates by its
256
+ idempotency key (the worker's own register, claim, heartbeat and completion included), is sent
257
+ again after a connection failure, a timeout or a 408 or 5xx that carried no `Retry-After`, up to
258
+ `maxAttempts` (default 4) times; a mutation without a key is sent once. A registration or claim
259
+ that still fails transiently is reported through `onPollError` and polled again with a growing
260
+ wait; a refusal Hue decided on (a revoked key, a disabled agent) is thrown. A transient failure
261
+ of a completion or result upload keeps the run claimed by the durable worker identity: the next
262
+ claim returns it to the same process, which resumes saved uploads without invoking the candidate
263
+ again (`onRunFailed` says `retry`), and after `maxRunAttempts` (default 5) such failures the run
264
+ is given up as `attention` for a project member to requeue or cancel from its run page; a failure
265
+ that would recur (a refusal Hue decided on, a case file whose bytes differ from its manifest) is
266
+ given up at once. A lost
267
+ world-seal acknowledgement is recovered by reading authoritative world state. If the seal or
268
+ candidate outcome cannot be confirmed, or an outcome cannot be serialized, the run is given up
269
+ as `attention` at once and the worker goes on to other runs; when Hue cannot record that either,
270
+ the worker stops with the error. Presenting the same uncertain checkpoint again cannot replay the
271
+ candidate. `maxRuns` counts runs that settled either way. Inside a run, one case's own failure
272
+ does not stop the cases beside it; a failure every case would share (Hue not answering or
273
+ refusing, telemetry not accepted, a world that could not be sealed) stops new cases from
274
+ starting until the run resumes. Each world's lifetime is `worldTtlSeconds` (the server's default
275
+ is an hour): a target still running past it is told through `context.signal`, its case ends as
276
+ an `error` of type `TargetTimeout` and the world is sealed abandoned. Public package acceptance
277
+ proves this lifecycle against local fixtures; exact installed-registry-package to hosted-facade
278
+ acceptance remains a post-publication Fern gate.
263
279
 
264
280
  ## Direct cases and files
265
281
 
package/dist/cli/eval.js CHANGED
@@ -1302,6 +1302,20 @@ async function runWorker(values, connection, adapter, agent, hue, output, signal
1302
1302
  deprecationWarnings: false,
1303
1303
  signal,
1304
1304
  ...(maxRuns === undefined ? {} : { maxRuns }),
1305
+ onPollError(error) {
1306
+ output.error(`Hue did not answer the poll (${explain(error)}); polling again`);
1307
+ },
1308
+ onRunFailed(failure) {
1309
+ const run = `Run ${failure.runId}`;
1310
+ if (failure.outcome === "retry") {
1311
+ const waitMillis = failure.waitMillis ?? 0;
1312
+ output.error(`${run} attempt ${failure.attempt} failed (${explain(failure.error)}); resuming in ${Math.round(waitMillis / 1000)} s`);
1313
+ }
1314
+ else if (failure.outcome === "attention")
1315
+ output.error(`${run} needs attention (${explain(failure.error)}); requeue or cancel it from its run page`);
1316
+ else
1317
+ output.error(`${run} was released or cancelled in Hue; its outcome here is not recorded`);
1318
+ },
1305
1319
  target(inputs, tools, context) {
1306
1320
  output.log(`[${context.item.externalKey}] agent started`);
1307
1321
  return adapter(inputs, {
@@ -1315,7 +1329,9 @@ async function runWorker(values, connection, adapter, agent, hue, output, signal
1315
1329
  ...(context.connectionBundle ? { connectionBundle: context.connectionBundle } : {}),
1316
1330
  files: context.files,
1317
1331
  outputDirectory: context.outputDirectory,
1318
- signal,
1332
+ // The case's own signal: the worker stopping, or the world's deadline passing, both
1333
+ // end a command that would otherwise keep running past its timed-out case.
1334
+ signal: context.signal ?? signal,
1319
1335
  });
1320
1336
  },
1321
1337
  async onCompleted(report) {
package/dist/client.d.ts CHANGED
@@ -43,6 +43,9 @@ export declare class HueClient {
43
43
  readonly enabled: boolean;
44
44
  private logger;
45
45
  private storage;
46
+ /** Reports a contained capture or instrumentation failure, named to the trace active where
47
+ * it happened when there is one. */
48
+ private instrumentationFailed;
46
49
  private tracerProvider;
47
50
  private loggerProvider;
48
51
  private ownedProviders?;
package/dist/client.js CHANGED
@@ -169,6 +169,20 @@ export class HueClient {
169
169
  enabled;
170
170
  logger;
171
171
  storage = new AsyncLocalStorage();
172
+ /** Reports a contained capture or instrumentation failure, named to the trace active where
173
+ * it happened when there is one. */
174
+ instrumentationFailed(signal = "traces", count = 1) {
175
+ let traceId;
176
+ try {
177
+ traceId = trace
178
+ .getSpan(this.storage.getStore()?.context ?? context.active())
179
+ ?.spanContext().traceId;
180
+ }
181
+ catch {
182
+ // The failure is recorded without a trace.
183
+ }
184
+ this.transport.instrumentationFailure(signal, undefined, count, traceId);
185
+ }
172
186
  tracerProvider;
173
187
  loggerProvider;
174
188
  ownedProviders;
@@ -210,7 +224,7 @@ export class HueClient {
210
224
  }
211
225
  this.captureContent = this.transport.options.captureContent;
212
226
  this.enabled = this.transport.options.enabled !== false;
213
- this.tracer = new ContextualTracer(this.tracerProvider.getTracer(HUE_SCOPE, sdkVersion), this.storage, () => this.enabled && !this.closed, () => this.transport.instrumentationFailure());
227
+ this.tracer = new ContextualTracer(this.tracerProvider.getTracer(HUE_SCOPE, sdkVersion), this.storage, () => this.enabled && !this.closed, () => this.instrumentationFailed());
214
228
  this.logger = this.loggerProvider.getLogger(HUE_SCOPE, sdkVersion);
215
229
  }
216
230
  /**
@@ -232,7 +246,7 @@ export class HueClient {
232
246
  return this.storage.getStore()?.context ?? context.active();
233
247
  }
234
248
  catch {
235
- this.transport.instrumentationFailure();
249
+ this.instrumentationFailed();
236
250
  return ROOT_CONTEXT;
237
251
  }
238
252
  }
@@ -256,7 +270,7 @@ export class HueClient {
256
270
  span = this.storage.run(active, () => this.tracer.startSpan(name, { kind: options.kind ?? SpanKind.INTERNAL, attributes: options.attributes }, active.context));
257
271
  }
258
272
  catch {
259
- this.transport.instrumentationFailure();
273
+ this.instrumentationFailed();
260
274
  }
261
275
  }
262
276
  // A disabled or failed client creates no span; the application's own active span then stays
@@ -268,7 +282,7 @@ export class HueClient {
268
282
  spanContext = trace.setSpan(active.context, span);
269
283
  }
270
284
  catch {
271
- this.transport.instrumentationFailure();
285
+ this.instrumentationFailed();
272
286
  spanContext = trace.setSpan(ROOT_CONTEXT, span);
273
287
  }
274
288
  const handle = {
@@ -287,7 +301,7 @@ export class HueClient {
287
301
  handle.setInput(options.input);
288
302
  }
289
303
  catch {
290
- this.transport.instrumentationFailure();
304
+ this.instrumentationFailed();
291
305
  }
292
306
  try {
293
307
  return await callback(handle);
@@ -303,9 +317,7 @@ export class HueClient {
303
317
  // The span is also OpenTelemetry's active span while the callback runs, so spans from other
304
318
  // instrumentations (HTTP clients, provider SDKs) join this trace when the application has
305
319
  // registered a context manager. Hue still registers none itself.
306
- return this.storage.run({ ...active, context: spanContext }, () => created
307
- ? runInContext(spanContext, execute, () => this.transport.instrumentationFailure())
308
- : execute());
320
+ return this.storage.run({ ...active, context: spanContext }, () => created ? runInContext(spanContext, execute, () => this.instrumentationFailed()) : execute());
309
321
  }
310
322
  /**
311
323
  * Runs `execute` inside an `execute_tool` span named after the tool. `input` and a defined result
@@ -329,7 +341,7 @@ export class HueClient {
329
341
  if (isLabel(value))
330
342
  attributes[key] = value;
331
343
  else if (this.enabled && !this.closed)
332
- this.transport.instrumentationFailure();
344
+ this.instrumentationFailed();
333
345
  };
334
346
  stamp("gen_ai.tool.call.id", options.callId);
335
347
  stamp("mcp.server.name", options.mcp?.name);
@@ -364,7 +376,7 @@ export class HueClient {
364
376
  if (isLabel(value))
365
377
  return value;
366
378
  if (active)
367
- this.transport.instrumentationFailure();
379
+ this.instrumentationFailed();
368
380
  return fallback;
369
381
  };
370
382
  const requestModel = label(model, "unknown");
@@ -413,14 +425,14 @@ export class HueClient {
413
425
  if (value === undefined)
414
426
  continue;
415
427
  if (!Number.isInteger(value) || value < 0) {
416
- this.transport.instrumentationFailure();
428
+ this.instrumentationFailed();
417
429
  continue;
418
430
  }
419
431
  try {
420
432
  span.setAttribute(key, value);
421
433
  }
422
434
  catch {
423
- this.transport.instrumentationFailure();
435
+ this.instrumentationFailed();
424
436
  }
425
437
  }
426
438
  }
@@ -433,7 +445,7 @@ export class HueClient {
433
445
  propagator.inject(activeContext, carrier, defaultTextMapSetter);
434
446
  }
435
447
  catch {
436
- this.transport.instrumentationFailure();
448
+ this.instrumentationFailed();
437
449
  }
438
450
  }
439
451
  /** Reads W3C trace context from a carrier for use as `parentContext`. */
@@ -442,7 +454,7 @@ export class HueClient {
442
454
  return propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter);
443
455
  }
444
456
  catch {
445
- this.transport.instrumentationFailure();
457
+ this.instrumentationFailed();
446
458
  return ROOT_CONTEXT;
447
459
  }
448
460
  }
@@ -461,7 +473,7 @@ export class HueClient {
461
473
  span.addEvent("exception", { "exception.type": type });
462
474
  }
463
475
  catch {
464
- this.transport.instrumentationFailure();
476
+ this.instrumentationFailed();
465
477
  }
466
478
  }
467
479
  /**
@@ -498,7 +510,7 @@ export class HueClient {
498
510
  else if (typeof explicit === "string" && explicit.trim() && explicit.length <= 256)
499
511
  attributes[key] = explicit;
500
512
  else
501
- this.transport.instrumentationFailure("logs");
513
+ this.instrumentationFailed("logs");
502
514
  };
503
515
  stamp("gen_ai.operation.name", messages.operation, enclosing?.model?.operation);
504
516
  stamp("gen_ai.provider.name", messages.provider, enclosing?.model?.provider);
@@ -513,7 +525,7 @@ export class HueClient {
513
525
  });
514
526
  }
515
527
  catch {
516
- this.transport.instrumentationFailure("logs");
528
+ this.instrumentationFailed("logs");
517
529
  }
518
530
  }
519
531
  /**
@@ -545,7 +557,7 @@ export class HueClient {
545
557
  const addresses = hostedServerAddresses(provider, options.request);
546
558
  const activity = hostedToolActivity(provider, response);
547
559
  if (activity.skipped > 0)
548
- this.transport.instrumentationFailure("traces", undefined, activity.skipped);
560
+ this.instrumentationFailed("traces", activity.skipped);
549
561
  const server = (label) => {
550
562
  const attributes = {};
551
563
  if (label === undefined)
@@ -564,7 +576,7 @@ export class HueClient {
564
576
  if (isLabel(value))
565
577
  attributes[key] = value;
566
578
  else
567
- this.transport.instrumentationFailure();
579
+ this.instrumentationFailed();
568
580
  }
569
581
  const address = addresses.get(label);
570
582
  if (address !== undefined)
@@ -617,7 +629,7 @@ export class HueClient {
617
629
  span.setAttributes(toolCatalogSummary(encodeContent(listing.definitions, catalogLimits)));
618
630
  }
619
631
  catch {
620
- this.transport.instrumentationFailure();
632
+ this.instrumentationFailed();
621
633
  }
622
634
  if (listing.errorType !== undefined)
623
635
  fail(span, listing.errorType);
@@ -625,7 +637,7 @@ export class HueClient {
625
637
  }
626
638
  }
627
639
  catch {
628
- this.transport.instrumentationFailure();
640
+ this.instrumentationFailed();
629
641
  }
630
642
  }
631
643
  /**
@@ -692,12 +704,12 @@ export class HueClient {
692
704
  if (isLabel(name))
693
705
  attributes["hue.file.name"] = name;
694
706
  else
695
- this.transport.instrumentationFailure();
707
+ this.instrumentationFailed();
696
708
  }
697
709
  span.addEvent("hue.file", attributes);
698
710
  }
699
711
  catch {
700
- this.transport.instrumentationFailure();
712
+ this.instrumentationFailed();
701
713
  }
702
714
  }
703
715
  setContent(span, key, value) {
@@ -707,7 +719,7 @@ export class HueClient {
707
719
  span.setAttribute(key, encodeContent(value));
708
720
  }
709
721
  catch {
710
- this.transport.instrumentationFailure();
722
+ this.instrumentationFailed();
711
723
  }
712
724
  }
713
725
  /**
@@ -9,6 +9,10 @@ export interface EvaluationClientOptions {
9
9
  baseUrl?: string;
10
10
  /** Per-request budget in milliseconds, 100–60000. Default 10000. */
11
11
  timeoutMillis?: number;
12
+ /** Bounded attempts for a read or a mutation the server deduplicates by its key, after a
13
+ * connection failure, a timeout or a 408 or 5xx Hue did not time with `Retry-After`. Default 4,
14
+ * maximum 10; 1 sends every such request once. */
15
+ maxAttempts?: number;
12
16
  }
13
17
  /** Thrown for a failed evaluation API request; the message is fixed and never includes response text. */
14
18
  export declare class HueApiError extends Error {
@@ -16,12 +20,22 @@ export declare class HueApiError extends Error {
16
20
  readonly status?: number | undefined;
17
21
  /** Seconds Hue asked the caller to wait (`Retry-After` on a 429 or 503), when it said. */
18
22
  readonly retryAfterSeconds?: number | undefined;
23
+ /** Hue's `X-Hue-Diagnostic` code, which tells refusals of one status apart, when it sent one. */
24
+ readonly diagnostic?: string | undefined;
19
25
  constructor(
20
26
  /** HTTP status when Hue answered; absent for network, timeout and parsing failures. */
21
27
  status?: number | undefined,
22
28
  /** Seconds Hue asked the caller to wait (`Retry-After` on a 429 or 503), when it said. */
23
- retryAfterSeconds?: number | undefined);
29
+ retryAfterSeconds?: number | undefined,
30
+ /** Hue's `X-Hue-Diagnostic` code, which tells refusals of one status apart, when it sent one. */
31
+ diagnostic?: string | undefined);
24
32
  }
33
+ /**
34
+ * Whether the same request, sent again, can succeed: a connection failure, a timeout, or a status
35
+ * Hue answers while it cannot act yet (408, 429, 500, 502, 503, 504). A refusal Hue decided on,
36
+ * any other 4xx, is not, and no number of attempts changes it.
37
+ */
38
+ export declare function isTransientApiError(error: unknown): boolean;
25
39
  /** An artifact download that ran past the size its caller expected; the rest is not read. */
26
40
  export declare class ArtifactSizeError extends Error {
27
41
  /** The size the caller expected, in bytes. */
@@ -32,22 +46,28 @@ export declare class ArtifactSizeError extends Error {
32
46
  }
33
47
  /**
34
48
  * Typed client for Hue's evaluation REST API: datasets, scorers, experiments, executions, runs,
35
- * results and hosted judge jobs. No implicit mutation retry: callers retain stable idempotency keys
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.
49
+ * results and hosted judge jobs. A request Hue refused before acting on it with a short
50
+ * `Retry-After` (HTTP 429 or 503, at most 5 seconds) is sent again after that wait whatever its
51
+ * method, up to four times; a longer wait is the caller's error, carried as `retryAfterSeconds`.
52
+ * A read, or a mutation the server deduplicates by its idempotency key, is also sent again after
53
+ * a failure Hue did not time, a connection failure, a timeout or a 408 or 5xx without the header,
54
+ * up to `maxAttempts` times with a jittered backoff. A mutation without a key is never retried
55
+ * implicitly: callers retain stable idempotency keys for experiments and results. Responses are
56
+ * bounded to 4 MiB.
39
57
  */
40
58
  export declare class EvaluationClient {
41
59
  /** Validated Hue origin. */
42
60
  readonly baseUrl: string;
43
61
  private readonly apiKey;
44
62
  private readonly timeoutMillis;
63
+ private readonly maxAttempts;
45
64
  constructor(options: EvaluationClientOptions);
46
65
  /** Sends one request to Hue, again after the wait while Hue refuses it with a short
47
66
  * `Retry-After`. `init` runs for every attempt, so each has its own timeout; `signal` also ends
48
67
  * each attempt, and a wait to send one again, whose refusal is then the caller's error. */
49
68
  private send;
50
69
  private request;
70
+ private requestOnce;
51
71
  /** Verified bytes of one ready artifact in this project, bounded to the 25 MiB pilot file size.
52
72
  * With `maxBytes`, the download stops one byte past it and throws `ArtifactSizeError`. */
53
73
  downloadArtifact(id: string, options?: {
@@ -5,17 +5,51 @@ import { attemptBindingRead, parsePrepareAttemptResultV2, parseRefreshedAttemptR
5
5
  export class HueApiError extends Error {
6
6
  status;
7
7
  retryAfterSeconds;
8
+ diagnostic;
8
9
  constructor(
9
10
  /** HTTP status when Hue answered; absent for network, timeout and parsing failures. */
10
11
  status,
11
12
  /** Seconds Hue asked the caller to wait (`Retry-After` on a 429 or 503), when it said. */
12
- retryAfterSeconds) {
13
- super(status ? `Hue API request failed (HTTP ${status})` : "Hue API connection or response failed");
13
+ retryAfterSeconds,
14
+ /** Hue's `X-Hue-Diagnostic` code, which tells refusals of one status apart, when it sent one. */
15
+ diagnostic) {
16
+ super(status
17
+ ? `Hue API request failed (HTTP ${status}${diagnostic ? `, ${diagnostic}` : ""})`
18
+ : "Hue API connection or response failed");
14
19
  this.status = status;
15
20
  this.retryAfterSeconds = retryAfterSeconds;
21
+ this.diagnostic = diagnostic;
16
22
  this.name = "HueApiError";
17
23
  }
18
24
  }
25
+ const TRANSIENT = new Set([408, 429, 500, 502, 503, 504]);
26
+ /**
27
+ * Whether the same request, sent again, can succeed: a connection failure, a timeout, or a status
28
+ * Hue answers while it cannot act yet (408, 429, 500, 502, 503, 504). A refusal Hue decided on,
29
+ * any other 4xx, is not, and no number of attempts changes it.
30
+ */
31
+ export function isTransientApiError(error) {
32
+ return (error instanceof HueApiError && (error.status === undefined || TRANSIENT.has(error.status)));
33
+ }
34
+ const DIAGNOSTIC = /^[a-z_]{1,64}$/;
35
+ function diagnosticOf(response) {
36
+ const value = response.headers.get("x-hue-diagnostic");
37
+ return value !== null && DIAGNOSTIC.test(value) ? value : undefined;
38
+ }
39
+ /** Whether a request may be sent again after a transient failure: every read, and a mutation the
40
+ * server deduplicates, either by the `idempotencyKey` in its body or because its route is
41
+ * idempotent by design (the local worker's register, claim, heartbeat, completion and capability
42
+ * routes). A mutation without a key is sent once; its caller resolves the outcome before asking
43
+ * again. */
44
+ function retryable(method, body, idempotent) {
45
+ if (idempotent !== undefined)
46
+ return idempotent;
47
+ if (method === "GET")
48
+ return true;
49
+ return (body !== null &&
50
+ typeof body === "object" &&
51
+ typeof body.idempotencyKey === "string");
52
+ }
19
53
  /** An artifact download that ran past the size its caller expected; the rest is not read. */
20
54
  export class ArtifactSizeError extends Error {
21
55
  maxBytes;
@@ -128,16 +162,21 @@ function refusalRetryAfter(response) {
128
162
  }
129
163
  /**
130
164
  * Typed client for Hue's evaluation REST API: datasets, scorers, experiments, executions, runs,
131
- * results and hosted judge jobs. No implicit mutation retry: callers retain stable idempotency keys
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.
165
+ * results and hosted judge jobs. A request Hue refused before acting on it with a short
166
+ * `Retry-After` (HTTP 429 or 503, at most 5 seconds) is sent again after that wait whatever its
167
+ * method, up to four times; a longer wait is the caller's error, carried as `retryAfterSeconds`.
168
+ * A read, or a mutation the server deduplicates by its idempotency key, is also sent again after
169
+ * a failure Hue did not time, a connection failure, a timeout or a 408 or 5xx without the header,
170
+ * up to `maxAttempts` times with a jittered backoff. A mutation without a key is never retried
171
+ * implicitly: callers retain stable idempotency keys for experiments and results. Responses are
172
+ * bounded to 4 MiB.
135
173
  */
136
174
  export class EvaluationClient {
137
175
  /** Validated Hue origin. */
138
176
  baseUrl;
139
177
  apiKey;
140
178
  timeoutMillis;
179
+ maxAttempts;
141
180
  constructor(options) {
142
181
  const validated = validateOptions({
143
182
  ...options,
@@ -147,6 +186,10 @@ export class EvaluationClient {
147
186
  this.baseUrl = validated.baseUrl;
148
187
  this.apiKey = validated.apiKey;
149
188
  this.timeoutMillis = validated.timeoutMillis;
189
+ const attempts = options.maxAttempts ?? 4;
190
+ if (!Number.isInteger(attempts) || attempts < 1 || attempts > 10)
191
+ throw new RangeError("maxAttempts must be 1–10");
192
+ this.maxAttempts = attempts;
150
193
  }
151
194
  /** Sends one request to Hue, again after the wait while Hue refuses it with a short
152
195
  * `Retry-After`. `init` runs for every attempt, so each has its own timeout; `signal` also ends
@@ -183,11 +226,44 @@ export class EvaluationClient {
183
226
  throw new HueApiError(response.status, askedRetryAfter(response));
184
227
  }
185
228
  }
186
- async request(method, path, body, bounds = { ...valueBounds, bytes: 1024 * 1024 }, signal) {
187
- // Optional top-level fields are omitted intentionally; nested undefined remains invalid.
229
+ async request(method, path, body, bounds = { ...valueBounds, bytes: 1024 * 1024 }, signal,
230
+ /** Overrides the retry decision `retryable` takes from the method and body. */
231
+ idempotent) {
232
+ // Serialized once, outside the attempts: a body this client cannot encode is a caller error
233
+ // no attempt fixes. Optional top-level fields are omitted intentionally; nested undefined
234
+ // remains invalid.
188
235
  const payload = body === undefined
189
236
  ? undefined
190
237
  : JSON.stringify(json(Object.fromEntries(Object.entries(body).filter(([, value]) => value !== undefined)), bounds));
238
+ const attempts = retryable(method, body, idempotent) ? this.maxAttempts : 1;
239
+ for (let attempt = 1;; attempt++) {
240
+ try {
241
+ return await this.requestOnce(method, path, payload, signal);
242
+ }
243
+ catch (error) {
244
+ // A refusal that carried `Retry-After` was already decided by `send`: sent again while
245
+ // the wait was short, the caller's error when it was long. This loop covers the failures
246
+ // Hue did not time: a lost connection, a timeout, a 5xx or 408 without the header.
247
+ if (!isTransientApiError(error) ||
248
+ error.retryAfterSeconds !== undefined ||
249
+ attempt >= attempts ||
250
+ signal?.aborted)
251
+ throw error;
252
+ const backoff = Math.min(100 * 2 ** (attempt - 1), 2000);
253
+ const wait = backoff + Math.random() * backoff;
254
+ await new Promise((resolve) => {
255
+ const done = () => {
256
+ clearTimeout(timer);
257
+ signal?.removeEventListener("abort", done);
258
+ resolve();
259
+ };
260
+ const timer = setTimeout(done, wait);
261
+ signal?.addEventListener("abort", done, { once: true });
262
+ });
263
+ }
264
+ }
265
+ }
266
+ async requestOnce(method, path, payload, signal) {
191
267
  const response = await this.send(`${this.baseUrl}/api/v1${path}`, () => ({
192
268
  method,
193
269
  headers: {
@@ -200,7 +276,7 @@ export class EvaluationClient {
200
276
  }), signal);
201
277
  if (!response.ok) {
202
278
  await response.body?.cancel();
203
- throw new HueApiError(response.status, askedRetryAfter(response));
279
+ throw new HueApiError(response.status, askedRetryAfter(response), diagnosticOf(response));
204
280
  }
205
281
  try {
206
282
  const reader = response.body?.getReader();
@@ -474,7 +550,9 @@ export class EvaluationClient {
474
550
  async prepareAttempt(input) {
475
551
  const request = prepareAttemptInputV2.parse(input);
476
552
  const { executionId, ...body } = request;
477
- const response = await this.request("POST", `/experiment-executions/${executionId}/prepare-attempt`, body, { ...valueBounds, bytes: 128_000 });
553
+ // Sent once although keyed: a lost acknowledgement of a credential-bearing decision stays
554
+ // uncertain, and the worker never reacquires credentials.
555
+ const response = await this.request("POST", `/experiment-executions/${executionId}/prepare-attempt`, body, { ...valueBounds, bytes: 128_000 }, undefined, false);
478
556
  try {
479
557
  return parsePrepareAttemptResultV2(response, request);
480
558
  }
@@ -662,21 +740,21 @@ export class EvaluationClient {
662
740
  }
663
741
  /** Register or refresh the fixed local agent key and revision. */
664
742
  async registerLocalAgent(input) {
665
- const agent = await this.request("POST", "/local-agent-worker/register", input);
743
+ const agent = await this.request("POST", "/local-agent-worker/register", input, undefined, undefined, true);
666
744
  // Hue's response names the key `agentKey`; read either name.
667
745
  return { ...agent, key: agent.key ?? agent.agentKey ?? input.key };
668
746
  }
669
747
  /** Claim a queued run for this agent and durable worker identity. */
670
748
  claimLocalAgentRun(input) {
671
- return this.request("POST", "/local-agent-worker/claim", input);
749
+ return this.request("POST", "/local-agent-worker/claim", input, undefined, undefined, true);
672
750
  }
673
751
  /** Refresh the lease of a claimed local run. */
674
752
  heartbeatLocalAgentRun(input) {
675
- return this.request("POST", "/local-agent-worker/runs/heartbeat", input);
753
+ return this.request("POST", "/local-agent-worker/runs/heartbeat", input, undefined, undefined, true);
676
754
  }
677
755
  /** Report acknowledged completion or an execution requiring attention. */
678
756
  completeLocalAgentRun(input) {
679
- return this.request("POST", "/local-agent-worker/runs/complete", input);
757
+ return this.request("POST", "/local-agent-worker/runs/complete", input, undefined, undefined, true);
680
758
  }
681
759
  /** Lists Scenarios (draft and published) of the project; requires a Read and write key. */
682
760
  listCaseConversions(page) {
@@ -688,7 +766,7 @@ export class EvaluationClient {
688
766
  }
689
767
  /** Creates the legacy execution-scoped generic MCP capability for one world. */
690
768
  createSimulationMcpCapability(input) {
691
- return this.request("POST", "/local-agent-worker/mcp-capability", input);
769
+ return this.request("POST", "/local-agent-worker/mcp-capability", input, undefined, undefined, true);
692
770
  }
693
771
  }
694
772
  /**
@@ -105,6 +105,9 @@ export interface RunEnvironmentTargetOptions {
105
105
  export declare const MAX_GRACE_WAIT_MS = 10000;
106
106
  export declare const SEAL_POLL_MS = 250;
107
107
  export declare const SEAL_WAIT_MS = 30000;
108
+ /** How long past the world's `expiresAt` the target is given before it is stopped: room for a
109
+ * last call in flight at the deadline and for clock skew between the worker and Hue. */
110
+ export declare const DEADLINE_MARGIN_MS = 5000;
108
111
  /** The seal wait's bounds. Only tests shorten them; the helpers always use the defaults. */
109
112
  export interface SealTiming {
110
113
  maxGraceWaitMs: number;
@@ -123,16 +126,20 @@ export declare function caseTraceparent(span: {
123
126
  };
124
127
  };
125
128
  }): string;
126
- /** What a deployment's credential-free gateway health said: `on` (200 with
127
- * `gateway: "simulation"`), `off` (the empty 404 the disabled handler answers, with no
128
- * `x-hue-diagnostic`), or `unknown` for anything else. */
129
+ /** What a deployment's credential-free gateway probe said: `on` (the gateway answered, with its
130
+ * health or with a refusal carrying `x-hue-diagnostic`), `off` (the empty 404 the disabled
131
+ * handler answers, with no `x-hue-diagnostic`), or `unknown` for anything else. */
129
132
  export type GatewayState = "on" | "off" | "unknown";
130
133
  /**
131
- * Whether the deployment serves the simulation gateway, from its credential-free health
132
- * endpoint: 200 with `gateway: "simulation"` is on, the disabled handler's empty 404 (no
133
- * `x-hue-diagnostic`) is off, and anything else (a network failure, a timeout, a redirect, a
134
- * refusal carrying a diagnostic, another status or body) is unknown. Probed only after a create
135
- * was refused; on and off are remembered per origin, unknown is probed again next time.
134
+ * Whether the deployment serves the simulation gateway, from a credential-free probe of the
135
+ * gateway's reserved health path with no app named (`/api/sim/_hue/health`), so the answer does
136
+ * not depend on which apps the deployment mirrors. The gateway answers it on its own host with
137
+ * its health, 200 with `gateway: "simulation"`, and on the application host with a refusal that
138
+ * carries `x-hue-diagnostic` (`unmirrored_provider`: the reserved segment is no app); either is
139
+ * on, since only the enabled gateway writes that header. The disabled handler's empty 404 (no
140
+ * `x-hue-diagnostic`) is off, and anything else (a network failure, a timeout, a redirect, another
141
+ * status or body) is unknown. Probed only after a create was refused; on and off are remembered
142
+ * per origin, unknown is probed again next time.
136
143
  */
137
144
  export declare function gatewayState(baseUrl: string, fetchImpl?: (url: string, init?: RequestInit) => Promise<Response>): Promise<GatewayState>;
138
145
  /**