@hue-run/sdk 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CLI.md +27 -14
  2. package/ENVIRONMENTS.md +63 -1
  3. package/README.md +121 -5
  4. package/dist/ai-sdk.d.ts +3 -3
  5. package/dist/ai-sdk.js +3 -3
  6. package/dist/cli/env-file.d.ts +22 -0
  7. package/dist/cli/env-file.js +21 -0
  8. package/dist/cli/eval.js +100 -30
  9. package/dist/cli/login.d.ts +1 -1
  10. package/dist/cli/login.js +9 -4
  11. package/dist/client.d.ts +31 -3
  12. package/dist/client.js +199 -7
  13. package/dist/config.d.ts +2 -0
  14. package/dist/config.js +2 -0
  15. package/dist/environment/client.d.ts +16 -2
  16. package/dist/environment/client.js +46 -3
  17. package/dist/environment/tools.d.ts +2 -2
  18. package/dist/environment/tools.js +2 -2
  19. package/dist/environment/types.d.ts +134 -4
  20. package/dist/environment/world.d.ts +50 -0
  21. package/dist/environment/world.js +105 -0
  22. package/dist/environment.d.ts +2 -0
  23. package/dist/environment.js +1 -0
  24. package/dist/evals/environment-target.d.ts +53 -2
  25. package/dist/evals/environment-target.js +114 -10
  26. package/dist/evals/local-worker.d.ts +12 -5
  27. package/dist/evals/local-worker.js +13 -5
  28. package/dist/evals/runner.d.ts +1 -1
  29. package/dist/evals/runner.js +2 -2
  30. package/dist/evals/simulation.d.ts +14 -5
  31. package/dist/evals/simulation.js +26 -8
  32. package/dist/experimental-telemetry.d.ts +3 -2
  33. package/dist/experimental-telemetry.js +3 -2
  34. package/dist/inline-files.d.ts +10 -0
  35. package/dist/inline-files.js +86 -0
  36. package/dist/privacy.js +54 -5
  37. package/dist/provider-tools.d.ts +39 -0
  38. package/dist/provider-tools.js +222 -0
  39. package/dist/tool-definitions.d.ts +20 -0
  40. package/dist/tool-definitions.js +274 -0
  41. package/dist/transport.js +4 -2
  42. package/dist/types.d.ts +76 -3
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +1 -1
package/dist/client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { createHash } from "node:crypto";
2
3
  import { context, isSpanContextValid, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
3
4
  import { defaultTextMapGetter, defaultTextMapSetter } from "@opentelemetry/api";
4
5
  import { SeverityNumber } from "@opentelemetry/api-logs";
@@ -7,10 +8,12 @@ import { LoggerProvider } from "@opentelemetry/sdk-logs";
7
8
  import { TracerProvider } from "@opentelemetry/sdk-trace";
8
9
  import { defaultResource, resourceFromAttributes } from "@opentelemetry/resources";
9
10
  import { HUE_SCOPE } from "./config.js";
11
+ import { hostedServerAddresses, hostedToolActivity, hostedToolProvider } from "./provider-tools.js";
10
12
  import { encodeContent, noopSpan, safeSpan } from "./safety.js";
11
13
  import { createHueTransport, HueExportError } from "./transport.js";
12
14
  import { verifyTrace } from "./receipt.js";
13
15
  import { sdkVersion } from "./version.js";
16
+ import { MAX_FILE_DATA_BYTES } from "./config.js";
14
17
  /**
15
18
  * Thrown by {@link HueClient.checkConnection} and {@link HueClient.verifyTrace} when Hue cannot be
16
19
  * reached, rejects the project key or answers unexpectedly. The message is fixed and safe to log;
@@ -34,12 +37,28 @@ function identifier(value) {
34
37
  value.length > 4096 ||
35
38
  value.includes("\u0000") ||
36
39
  !value.isWellFormed())
37
- throw new TypeError("Session/user identifiers must contain 1–4096 valid characters");
40
+ throw new TypeError("Session/user/workspace identifiers must contain 1–4096 valid characters");
38
41
  return value;
39
42
  }
40
43
  /** A usable metadata label: a non-blank string of at most 256 characters. */
41
44
  function isLabel(value) {
42
- return typeof value === "string" && value.trim() !== "" && value.length <= 256;
45
+ return (typeof value === "string" &&
46
+ value.trim() !== "" &&
47
+ value.length <= 256 &&
48
+ !value.includes("\u0000") &&
49
+ value.isWellFormed());
50
+ }
51
+ /** A source label uses the stricter wire-safe validation without changing existing labels. */
52
+ function isSourceLabel(value) {
53
+ return (typeof value === "string" &&
54
+ value.trim() !== "" &&
55
+ value.length <= 256 &&
56
+ !value.includes("\u0000") &&
57
+ value.isWellFormed());
58
+ }
59
+ /** A label that is also free of NUL and unpaired surrogates, which export would reject. */
60
+ function isTextLabel(value) {
61
+ return isLabel(value) && !value.includes("\u0000") && value.isWellFormed();
43
62
  }
44
63
  /**
45
64
  * Runs `work` exactly once with `active` as OpenTelemetry's current context, so instrumentations
@@ -96,6 +115,7 @@ class ContextualTracer {
96
115
  ...options.attributes,
97
116
  ...(active?.sessionId ? { "gen_ai.conversation.id": active.sessionId } : {}),
98
117
  ...(active?.userId ? { "user.id": active.userId } : {}),
118
+ ...(active?.workspaceId ? { "hue.workspace.id": active.workspaceId } : {}),
99
119
  },
100
120
  }, parent ?? active?.context ?? context.active()), this.failed);
101
121
  }
@@ -238,6 +258,7 @@ export class HueClient {
238
258
  context: options.parentContext ?? inherited?.context ?? context.active(),
239
259
  sessionId: identifier(options.sessionId ?? inherited?.sessionId),
240
260
  userId: identifier(options.userId ?? inherited?.userId),
261
+ workspaceId: identifier(options.workspaceId ?? inherited?.workspaceId),
241
262
  model: inherited?.model,
242
263
  };
243
264
  span = this.storage.run(active, () => this.tracer.startSpan(name, { kind: options.kind ?? SpanKind.INTERNAL, attributes: options.attributes }, active.context));
@@ -301,18 +322,19 @@ export class HueClient {
301
322
  * `options.callId` is recorded as `gen_ai.tool.call.id`, like the Python `call_id=` keyword.
302
323
  * `options.mcp` records the MCP `initialize` `serverInfo` as `mcp.server.name` /
303
324
  * `mcp.server.version` so a generic tool name can be attributed to the server that
304
- * handled it. Pass `client.getServerVersion()`.
325
+ * handled it. Pass `client.getServerVersion()`. `mcp.provider` / `mcp.surface` record the Hue
326
+ * provider and surface as `hue.mcp.provider` / `hue.mcp.surface`.
305
327
  */
306
328
  async tool(name, input, execute, options = {}) {
307
329
  const attributes = {
308
330
  "gen_ai.operation.name": "execute_tool",
309
331
  "gen_ai.tool.name": name,
310
332
  };
311
- const stamp = (key, value) => {
333
+ const stamp = (key, value, valid = isLabel) => {
312
334
  if (value === undefined)
313
335
  return;
314
336
  // A blank or non-string label is omitted and counted; the tool call itself still runs.
315
- if (isLabel(value))
337
+ if (valid(value))
316
338
  attributes[key] = value;
317
339
  else if (this.enabled && !this.closed)
318
340
  this.transport.instrumentationFailure();
@@ -320,6 +342,8 @@ export class HueClient {
320
342
  stamp("gen_ai.tool.call.id", options.callId);
321
343
  stamp("mcp.server.name", options.mcp?.name);
322
344
  stamp("mcp.server.version", options.mcp?.version);
345
+ stamp("hue.mcp.provider", options.mcp?.provider, isSourceLabel);
346
+ stamp("hue.mcp.surface", options.mcp?.surface, isSourceLabel);
323
347
  return this.withSpan(`execute_tool ${name}`, async ({ span }) => {
324
348
  this.setContent(span, "gen_ai.tool.call.arguments", input);
325
349
  const result = await execute();
@@ -337,7 +361,8 @@ export class HueClient {
337
361
  * `gen_ai.request.model` and `gen_ai.provider.name`. The argument order matches `withSpan`. The
338
362
  * handle's `setInput`/`setOutput` record `gen_ai.input.messages` / `gen_ai.output.messages`,
339
363
  * which should use the GenAI semantic-convention message shape; `recordMessages` inside the
340
- * callback inherits the request metadata.
364
+ * callback inherits the request metadata. `options.systemInstructions` and `options.tools` are
365
+ * recorded as `gen_ai.system_instructions` and `gen_ai.tool.definitions`, content like `input`.
341
366
  */
342
367
  async model(model, callback, options) {
343
368
  // A disabled or closed client creates no span, so invalid metadata is not an instrumentation
@@ -357,7 +382,7 @@ export class HueClient {
357
382
  ? `${operation} ${requestModel}`
358
383
  : label(options.name, `${operation} ${requestModel}`);
359
384
  const metadata = { operation, provider, requestModel };
360
- const { sessionId, userId, parentContext, input } = options ?? {};
385
+ const { sessionId, userId, workspaceId, parentContext, input, systemInstructions, tools, } = options ?? {};
361
386
  return this.withSpan(name, (span) => {
362
387
  const handle = {
363
388
  ...span,
@@ -366,12 +391,17 @@ export class HueClient {
366
391
  };
367
392
  if (input !== undefined)
368
393
  handle.setInput(input);
394
+ if (systemInstructions !== undefined)
395
+ this.setContent(span.span, "gen_ai.system_instructions", systemInstructions);
396
+ if (tools !== undefined)
397
+ this.setContent(span.span, "gen_ai.tool.definitions", tools);
369
398
  // recordMessages inside the callback copies this request metadata onto its log record.
370
399
  const store = this.storage.getStore() ?? { context: span.context };
371
400
  return this.storage.run({ ...store, model: metadata }, () => callback(handle));
372
401
  }, {
373
402
  sessionId,
374
403
  userId,
404
+ workspaceId,
375
405
  parentContext,
376
406
  kind: SpanKind.CLIENT,
377
407
  attributes: {
@@ -462,6 +492,8 @@ export class HueClient {
462
492
  body["gen_ai.input.messages"] = messages.input;
463
493
  if (messages.output !== undefined)
464
494
  body["gen_ai.output.messages"] = messages.output;
495
+ if (messages.systemInstructions !== undefined)
496
+ body["gen_ai.system_instructions"] = messages.systemInstructions;
465
497
  // Request metadata is inherited only when the record correlates with the enclosing helper
466
498
  // scope; an unrelated explicit context carries caller-supplied values alone.
467
499
  const enclosing = explicitContext === undefined || explicitContext === store?.context ? store : undefined;
@@ -492,6 +524,166 @@ export class HueClient {
492
524
  this.transport.instrumentationFailure("logs");
493
525
  }
494
526
  }
527
+ /**
528
+ * Records the tools a model provider executed itself while producing `response`, which no
529
+ * `hue.tool()` call saw: OpenAI Responses `mcp_call`, `web_search_call`, `file_search_call` and
530
+ * `code_interpreter_call` items, and Anthropic Messages `mcp_tool_use` / `server_tool_use`
531
+ * blocks with their result blocks. Each becomes an `execute_tool {name}` child span of the active
532
+ * (or given) context with `gen_ai.tool.type` `extension` and `gen_ai.tool.call.id`; MCP calls add
533
+ * `mcp.server.name` (the provider's label, or the `servers` entry for it). Arguments and results
534
+ * follow `captureContent`; a failed call carries `error.type` and ERROR status. An OpenAI
535
+ * `mcp_list_tools` item becomes a `tools/list` child span carrying that server's tools as
536
+ * `gen_ai.tool.definitions`. Call it inside `hue.model()` so the spans nest under the model call
537
+ * and `provider` defaults to its provider; pass `request` to record each server's host as
538
+ * `server.address`. The spans have no duration of their own: the provider ran the tools inside
539
+ * the model request. Unreadable items are skipped and counted; nothing is thrown.
540
+ */
541
+ recordProviderToolCalls(response, options = {}) {
542
+ if (!this.enabled || this.closed)
543
+ return;
544
+ try {
545
+ const store = this.storage.getStore();
546
+ const provider = options.provider ?? hostedToolProvider(store?.model?.provider);
547
+ if (provider === undefined)
548
+ throw new TypeError("Unknown provider for hosted tool calls");
549
+ const parent = options.parentContext ?? store?.context ?? context.active();
550
+ const addresses = hostedServerAddresses(provider, options.request);
551
+ const activity = hostedToolActivity(provider, response);
552
+ if (activity.skipped > 0)
553
+ this.transport.instrumentationFailure("traces", undefined, activity.skipped);
554
+ const server = (label) => {
555
+ const attributes = {};
556
+ if (label === undefined)
557
+ return attributes;
558
+ const info = options.servers?.[label];
559
+ for (const [key, value] of [
560
+ ["mcp.server.name", info?.name ?? label],
561
+ ["mcp.server.version", info?.version],
562
+ ["hue.mcp.provider", info?.provider],
563
+ ["hue.mcp.surface", info?.surface],
564
+ ]) {
565
+ if (value === undefined)
566
+ continue;
567
+ if (isLabel(value))
568
+ attributes[key] = value;
569
+ else
570
+ this.transport.instrumentationFailure();
571
+ }
572
+ const address = addresses.get(label);
573
+ if (address !== undefined)
574
+ attributes["server.address"] = address;
575
+ return attributes;
576
+ };
577
+ const fail = (span, type) => {
578
+ span.setAttribute("error.type", type);
579
+ span.setStatus({ code: SpanStatusCode.ERROR });
580
+ };
581
+ for (const call of activity.calls) {
582
+ const span = this.tracer.startSpan(`execute_tool ${call.name}`, {
583
+ kind: SpanKind.INTERNAL,
584
+ attributes: {
585
+ "gen_ai.operation.name": "execute_tool",
586
+ "gen_ai.tool.name": call.name,
587
+ "gen_ai.tool.type": "extension",
588
+ ...(call.callId === undefined ? {} : { "gen_ai.tool.call.id": call.callId }),
589
+ ...server(call.server),
590
+ },
591
+ }, parent);
592
+ if (call.arguments !== undefined)
593
+ this.setContent(span, "gen_ai.tool.call.arguments", call.arguments);
594
+ if (call.result !== undefined)
595
+ this.setContent(span, "gen_ai.tool.call.result", call.result);
596
+ if (call.errorType !== undefined)
597
+ fail(span, call.errorType);
598
+ span.end();
599
+ }
600
+ for (const listing of activity.listings) {
601
+ const span = this.tracer.startSpan("tools/list", {
602
+ kind: SpanKind.INTERNAL,
603
+ attributes: { "mcp.method.name": "tools/list", ...server(listing.server) },
604
+ }, parent);
605
+ this.setContent(span, "gen_ai.tool.definitions", listing.definitions);
606
+ if (listing.errorType !== undefined)
607
+ fail(span, listing.errorType);
608
+ span.end();
609
+ }
610
+ }
611
+ catch {
612
+ this.transport.instrumentationFailure();
613
+ }
614
+ }
615
+ /**
616
+ * Adds a `hue.file` event to the active (or given) span for a file the work read, received or
617
+ * produced: `hue.file.sha256`, `hue.file.role`, `hue.file.media_type`, `hue.file.size` when known
618
+ * and, when `captureContent` is true, `hue.file.name`. `data` is hashed and measured locally and
619
+ * never exported. The event is metadata, so it is recorded in both capture modes. An invalid
620
+ * record, or one without an active span, is omitted and counted, never thrown; an invalid name
621
+ * alone is omitted and counted while the rest is recorded.
622
+ */
623
+ recordFile(file, explicitContext) {
624
+ if (!this.enabled || this.closed)
625
+ return;
626
+ try {
627
+ const span = trace.getSpan(explicitContext ?? this.storage.getStore()?.context ?? context.active());
628
+ if (!span?.isRecording())
629
+ throw new Error("File records require an active span");
630
+ const { role, mediaType, data, name } = file;
631
+ if (role !== "input" && role !== "attachment" && role !== "output")
632
+ throw new TypeError("Invalid file role");
633
+ if (!isTextLabel(mediaType))
634
+ throw new TypeError("Invalid media type");
635
+ let sha256 = typeof file.sha256 === "string" ? file.sha256.toLowerCase() : file.sha256;
636
+ let byteSize = file.byteSize;
637
+ if (data !== undefined) {
638
+ const bytes = typeof data === "string"
639
+ ? (() => {
640
+ // Buffer.byteLength measures UTF-8 without allocating the copy that hashing would
641
+ // otherwise require. Reject before Buffer.from/createHash can retain large input.
642
+ if (data.length > MAX_FILE_DATA_BYTES ||
643
+ Buffer.byteLength(data, "utf8") > MAX_FILE_DATA_BYTES)
644
+ throw new RangeError("File data exceeds Hue's 25 MiB limit");
645
+ return Buffer.from(data, "utf8");
646
+ })()
647
+ : data instanceof Uint8Array
648
+ ? data.byteLength <= MAX_FILE_DATA_BYTES
649
+ ? data
650
+ : (() => {
651
+ throw new RangeError("File data exceeds Hue's 25 MiB limit");
652
+ })()
653
+ : undefined;
654
+ if (!bytes)
655
+ throw new TypeError("File data must be bytes or a string");
656
+ const digest = createHash("sha256").update(bytes).digest("hex");
657
+ // A caller-supplied digest or size must describe the same bytes.
658
+ if ((sha256 !== undefined && sha256 !== digest) ||
659
+ (byteSize !== undefined && byteSize !== bytes.byteLength))
660
+ throw new TypeError("File digest or size does not match its data");
661
+ sha256 = digest;
662
+ byteSize = bytes.byteLength;
663
+ }
664
+ if (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/.test(sha256))
665
+ throw new TypeError("A file needs a SHA-256 digest or its data");
666
+ if (byteSize !== undefined && (!Number.isSafeInteger(byteSize) || byteSize < 0))
667
+ throw new TypeError("Invalid file size");
668
+ const attributes = {
669
+ "hue.file.sha256": sha256,
670
+ "hue.file.role": role,
671
+ "hue.file.media_type": mediaType,
672
+ };
673
+ if (byteSize !== undefined)
674
+ attributes["hue.file.size"] = byteSize;
675
+ if (this.captureContent && name !== undefined) {
676
+ if (isTextLabel(name))
677
+ attributes["hue.file.name"] = name;
678
+ else
679
+ this.transport.instrumentationFailure();
680
+ }
681
+ span.addEvent("hue.file", attributes);
682
+ }
683
+ catch {
684
+ this.transport.instrumentationFailure();
685
+ }
686
+ }
495
687
  setContent(span, key, value) {
496
688
  if (!this.enabled || this.closed || !this.captureContent)
497
689
  return;
package/dist/config.d.ts CHANGED
@@ -3,6 +3,8 @@ export declare const MAX_BODY_BYTES: number;
3
3
  export declare const MAX_CONTENT_BYTES: number;
4
4
  /** Instrumentation scope of the client's own tracer and logger. */
5
5
  export declare const HUE_SCOPE = "@hue-run/sdk";
6
+ /** Maximum bytes accepted when recordFile hashes caller-provided data locally. */
7
+ export declare const MAX_FILE_DATA_BYTES: number;
6
8
  /** Loopback hostnames that may use plain HTTP without opting in. */
7
9
  export declare function isLoopbackHost(hostname: string): boolean;
8
10
  /** True when a validated origin exports over plain HTTP to a host other than loopback. */
package/dist/config.js CHANGED
@@ -2,6 +2,8 @@ export const MAX_BODY_BYTES = 1024 * 1024;
2
2
  export const MAX_CONTENT_BYTES = 256 * 1024;
3
3
  /** Instrumentation scope of the client's own tracer and logger. */
4
4
  export const HUE_SCOPE = "@hue-run/sdk";
5
+ /** Maximum bytes accepted when recordFile hashes caller-provided data locally. */
6
+ export const MAX_FILE_DATA_BYTES = 25 * 1024 * 1024;
5
7
  /** Loopback hostnames that may use plain HTTP without opting in. */
6
8
  export function isLoopbackHost(hostname) {
7
9
  return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
@@ -1,4 +1,4 @@
1
- import type { ActionInput, ActionResult, CoverageGapInput, CoverageGapResult, CreateRunInput, Environment, PublishableEnvironmentDefinition, EnvironmentIdentity, EnvironmentPage, EnvironmentPageOptions, EnvironmentRun, EnvironmentSummary, EnvironmentVersion, EnvironmentVersionSummary, FinishRunInput, SealedRun, StepPage, StepPageOptions } from "./types.js";
1
+ import type { ActionInput, ActionResult, CoverageGapInput, CoverageGapResult, CreateRunInput, Environment, PublishableEnvironmentDefinition, EnvironmentIdentity, EnvironmentPage, EnvironmentPageOptions, EnvironmentRun, EnvironmentSummary, EnvironmentVersion, EnvironmentVersionSummary, FinishRunInput, SealedRun, StepPage, StepPageOptions, WorldEvidence, WorldEvidenceOptions } from "./types.js";
2
2
  /** Connection and retry options for {@link createEnvironmentClient}. */
3
3
  export interface EnvironmentClientOptions {
4
4
  /** Project service key sent as a bearer token; server-side only. */
@@ -14,9 +14,13 @@ export interface EnvironmentClientOptions {
14
14
  export declare class HueEnvironmentError extends Error {
15
15
  /** HTTP status when Hue answered; absent for transport, timeout or parse failure. */
16
16
  readonly status?: number | undefined;
17
+ /** Hue's `Retry-After` in milliseconds, bounded, when a 429 or 503 carried one. */
18
+ readonly retryAfterMs?: number | undefined;
17
19
  constructor(
18
20
  /** HTTP status when Hue answered; absent for transport, timeout or parse failure. */
19
- status?: number | undefined);
21
+ status?: number | undefined,
22
+ /** Hue's `Retry-After` in milliseconds, bounded, when a 429 or 503 carried one. */
23
+ retryAfterMs?: number | undefined);
20
24
  }
21
25
  /** Typed client for authored environments, isolated runs and immutable journals. */
22
26
  export declare class EnvironmentClient {
@@ -57,6 +61,13 @@ export declare class EnvironmentClient {
57
61
  sealedAt: string | null;
58
62
  stateDigest: string;
59
63
  finalState?: import("./types.js").JsonValue;
64
+ worldId?: string;
65
+ lifecycle?: import("./types.js").WorldLifecycle;
66
+ completingUntil?: string | null;
67
+ traceExternalId?: string | null;
68
+ surfaces?: import("./types.js").WorldSurface[];
69
+ flags?: import("./types.js").WorldFlag[];
70
+ connection?: null;
60
71
  validity: "environment_incomplete" | "not_assessed";
61
72
  coverageGap: import("./types.js").CoverageGap | null;
62
73
  }>;
@@ -68,6 +79,9 @@ export declare class EnvironmentClient {
68
79
  listSteps(runId: string, page?: StepPageOptions): Promise<StepPage>;
69
80
  /** Seals a world as completed or abandoned and freezes its evidence. */
70
81
  finishRun(runId: string, input: FinishRunInput): Promise<SealedRun>;
82
+ /** Reads a sealed world's evaluator-only evidence with the project key; a world token never
83
+ * can. An open world answers 409 until it is sealed. */
84
+ getEvidence(runId: string, options?: WorldEvidenceOptions): Promise<WorldEvidence>;
71
85
  }
72
86
  /** Creates a typed simulated-environment client. */
73
87
  export declare function createEnvironmentClient(options: EnvironmentClientOptions): EnvironmentClient;
@@ -3,17 +3,38 @@ import { aggregateBounds, json, uuid, valueBounds } from "../evals/json.js";
3
3
  /** Sanitized environment API failure that never includes response text or credentials. */
4
4
  export class HueEnvironmentError extends Error {
5
5
  status;
6
+ retryAfterMs;
6
7
  constructor(
7
8
  /** HTTP status when Hue answered; absent for transport, timeout or parse failure. */
8
- status) {
9
+ status,
10
+ /** Hue's `Retry-After` in milliseconds, bounded, when a 429 or 503 carried one. */
11
+ retryAfterMs) {
9
12
  super(status
10
13
  ? `Hue environment request failed (HTTP ${status})`
11
14
  : "Hue environment connection or response failed");
12
15
  this.status = status;
16
+ this.retryAfterMs = retryAfterMs;
13
17
  this.name = "HueEnvironmentError";
14
18
  }
15
19
  }
16
20
  const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
21
+ /** A `Retry-After` longer than this waits this long: Hue asks for a second, never minutes. */
22
+ const MAX_RETRY_AFTER_MS = 10_000;
23
+ const TRACEPARENT = /^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/;
24
+ /** Version 00 with real identifiers: an all-zero trace or span ID is invalid, and Hue refuses it. */
25
+ function validTraceparent(value) {
26
+ if (!TRACEPARENT.test(value))
27
+ return false;
28
+ const [, traceId, spanId] = value.split("-");
29
+ return !/^0+$/.test(traceId) && !/^0+$/.test(spanId);
30
+ }
31
+ /** Whole seconds only, as Hue sends them; a date or garbage is ignored. */
32
+ function retryAfterMillis(response) {
33
+ const header = response.headers.get("retry-after");
34
+ if (header === null || !/^\d{1,6}$/.test(header.trim()))
35
+ return undefined;
36
+ return Math.min(Number(header.trim()) * 1000, MAX_RETRY_AFTER_MS);
37
+ }
17
38
  const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
18
39
  const REQUEST_BOUNDS = { ...valueBounds, bytes: 1024 * 1024 };
19
40
  /** The server bounds JSON inside each entity independently, then permits the parsed
@@ -62,7 +83,7 @@ export class EnvironmentClient {
62
83
  }
63
84
  if (!response.ok) {
64
85
  await response.body?.cancel();
65
- throw new HueEnvironmentError(response.status);
86
+ throw new HueEnvironmentError(response.status, response.status === 429 || response.status === 503 ? retryAfterMillis(response) : undefined);
66
87
  }
67
88
  try {
68
89
  const reader = response.body?.getReader();
@@ -105,8 +126,10 @@ export class EnvironmentClient {
105
126
  const recoverable = error.status === undefined || RETRYABLE.has(error.status);
106
127
  if (!recoverable || attempt >= this.maxAttempts)
107
128
  throw error;
129
+ // Hue's admission refusals say how long to wait; anything else backs off.
108
130
  const backoff = Math.min(100 * 2 ** (attempt - 1), 2000);
109
- await new Promise((resolve) => setTimeout(resolve, backoff + Math.random() * backoff));
131
+ const wait = error.retryAfterMs ?? backoff + Math.random() * backoff;
132
+ await new Promise((resolve) => setTimeout(resolve, wait));
110
133
  }
111
134
  }
112
135
  }
@@ -157,6 +180,13 @@ export class EnvironmentClient {
157
180
  throw new RangeError("ttlSeconds must be 1–86400");
158
181
  if (input.seed !== undefined && !/^[a-f0-9]{32}$/.test(input.seed))
159
182
  throw new TypeError("Seed must be 32 lowercase hexadecimal characters");
183
+ if (input.traceparent !== undefined && !validTraceparent(input.traceparent))
184
+ throw new TypeError("traceparent must be a version-00 W3C trace context");
185
+ if (input.agentRevision !== undefined &&
186
+ (typeof input.agentRevision !== "string" ||
187
+ input.agentRevision.length < 1 ||
188
+ input.agentRevision.length > 256))
189
+ throw new RangeError("agentRevision must be 1–256 characters");
160
190
  return this.request("POST", "/environment-runs", {
161
191
  ...input,
162
192
  environmentVersionId: uuid(input.environmentVersionId),
@@ -202,6 +232,19 @@ export class EnvironmentClient {
202
232
  finishRun(runId, input) {
203
233
  return this.request("POST", `/environment-runs/${uuid(runId)}/finish`, input);
204
234
  }
235
+ /** Reads a sealed world's evaluator-only evidence with the project key; a world token never
236
+ * can. An open world answers 409 until it is sealed. */
237
+ getEvidence(runId, options = {}) {
238
+ const query = new URLSearchParams();
239
+ if (options.section !== undefined) {
240
+ if (!["all", "start", "end", "diff", "ledger"].includes(options.section))
241
+ throw new TypeError("Evidence section must be all, start, end, diff or ledger");
242
+ query.set("section", options.section);
243
+ }
244
+ if (options.bodies !== undefined)
245
+ query.set("bodies", options.bodies ? "true" : "false");
246
+ return this.request("GET", `/environment-runs/${uuid(runId)}/evidence${query.size ? `?${query}` : ""}`);
247
+ }
205
248
  }
206
249
  /** Creates a typed simulated-environment client. */
207
250
  export function createEnvironmentClient(options) {
@@ -29,7 +29,7 @@ export interface BindEnvironmentToolsOptions {
29
29
  /**
30
30
  * Binds a run's generated catalog to plain local callables without changing the agent
31
31
  * framework. Each call is recorded through {@link HueClient.tool}. Catalog entries that
32
- * include `mcp` stamp `mcp.server.name` / `mcp.server.version` so a generic verb is
33
- * attributed to that MCP server.
32
+ * include `mcp` stamp `mcp.server.name` / `mcp.server.version` and `hue.mcp.provider` /
33
+ * `hue.mcp.surface` so a generic verb is attributed to that MCP server and Hue surface.
34
34
  */
35
35
  export declare function bindEnvironmentTools(options: BindEnvironmentToolsOptions): Record<string, EnvironmentTool>;
@@ -2,8 +2,8 @@ import { randomUUID } from "node:crypto";
2
2
  /**
3
3
  * Binds a run's generated catalog to plain local callables without changing the agent
4
4
  * framework. Each call is recorded through {@link HueClient.tool}. Catalog entries that
5
- * include `mcp` stamp `mcp.server.name` / `mcp.server.version` so a generic verb is
6
- * attributed to that MCP server.
5
+ * include `mcp` stamp `mcp.server.name` / `mcp.server.version` and `hue.mcp.provider` /
6
+ * `hue.mcp.surface` so a generic verb is attributed to that MCP server and Hue surface.
7
7
  */
8
8
  export function bindEnvironmentTools(options) {
9
9
  const tools = {};