@hue-run/sdk 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CLI.md +240 -49
  2. package/ENVIRONMENTS.md +48 -9
  3. package/EVALUATIONS.md +41 -21
  4. package/README.md +70 -30
  5. package/dist/cli/eval-direct.d.ts +12 -1
  6. package/dist/cli/eval-direct.js +145 -33
  7. package/dist/cli/eval.d.ts +28 -1
  8. package/dist/cli/eval.js +504 -81
  9. package/dist/cli/listen.d.ts +76 -0
  10. package/dist/cli/listen.js +816 -0
  11. package/dist/cli/mcp.d.ts +65 -7
  12. package/dist/cli/mcp.js +271 -73
  13. package/dist/client.d.ts +7 -4
  14. package/dist/client.js +36 -10
  15. package/dist/evals/checkpoint.d.ts +15 -1
  16. package/dist/evals/checkpoint.js +34 -3
  17. package/dist/evals/client.d.ts +24 -4
  18. package/dist/evals/client.js +92 -38
  19. package/dist/evals/environment-target.d.ts +18 -3
  20. package/dist/evals/environment-target.js +22 -10
  21. package/dist/evals/exit-cleanup.d.ts +4 -0
  22. package/dist/evals/exit-cleanup.js +26 -0
  23. package/dist/evals/files.d.ts +69 -3
  24. package/dist/evals/files.js +255 -42
  25. package/dist/evals/json.js +116 -9
  26. package/dist/evals/local-worker.d.ts +21 -3
  27. package/dist/evals/local-worker.js +14 -0
  28. package/dist/evals/runner.d.ts +45 -0
  29. package/dist/evals/runner.js +199 -21
  30. package/dist/evals/scenarios.d.ts +7 -5
  31. package/dist/evals/scenarios.js +71 -14
  32. package/dist/evals/scorer-publication.js +58 -3
  33. package/dist/evals/simulation.d.ts +17 -4
  34. package/dist/evals/simulation.js +8 -0
  35. package/dist/evals/types.d.ts +48 -0
  36. package/dist/evals/verdicts.d.ts +26 -2
  37. package/dist/evals/verdicts.js +72 -9
  38. package/dist/evals.d.ts +4 -3
  39. package/dist/evals.js +2 -2
  40. package/dist/inline-files.d.ts +4 -0
  41. package/dist/inline-files.js +48 -17
  42. package/dist/provider-tools.d.ts +12 -1
  43. package/dist/provider-tools.js +192 -106
  44. package/dist/safety.d.ts +7 -1
  45. package/dist/safety.js +9 -7
  46. package/dist/setup/cli.js +31 -8
  47. package/dist/setup/peers.d.ts +13 -0
  48. package/dist/setup/peers.js +44 -0
  49. package/dist/snapshot.d.ts +2 -2
  50. package/dist/snapshot.js +47 -12
  51. package/dist/tool-definitions.d.ts +18 -0
  52. package/dist/tool-definitions.js +232 -0
  53. package/dist/transport.js +2 -2
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/package.json +1 -1
package/EVALUATIONS.md CHANGED
@@ -18,7 +18,8 @@ import { builtins, createEvaluationClient, runExperiment, rescore } from "@hue-r
18
18
 
19
19
  const connection = { apiKey: process.env.HUE_API_KEY! };
20
20
  const client = createEvaluationClient(connection);
21
- const hue = createHue({ ...connection, serviceName: "evaluation-demo", captureContent: false });
21
+ // captureContent: false sends metadata only.
22
+ const hue = createHue({ ...connection, serviceName: "evaluation-demo", captureContent: true });
22
23
 
23
24
  // Registry writes use optimistic revisions; they do not have automatic retries.
24
25
  const dataset = await client.createDataset({ name: "Greetings", slug: "greetings" });
@@ -115,7 +116,7 @@ Both choices are required and independent:
115
116
 
116
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.
117
118
 
118
- `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.
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
120
 
120
121
  ## Local scorers
121
122
 
@@ -158,7 +159,7 @@ built-ins keep their usual missing-output behavior. See
158
159
 
159
160
  Scorer deferral shipped in TypeScript `0.3.1`: the local runner executes only the three known built-in entries and bound `local_code` scorers. It leaves every other pin pending and reports its ID in `deferredScorerVersionIds`, including kinds and built-in entries introduced by a newer server. It never uploads a placeholder result that would occupy the immutable result slot, including placeholders already saved in an older SDK's checkpoint. Direct `scoreLocally()` calls reject pins that require another executor.
160
161
 
161
- `world_outcome` pins run inside Hue and need no local callback or executable source digest. A supporting server owns their execution from saved world evidence. Legacy `local_code` pins still require the exact registered callback; changing the worker cannot convert those immutable pins into hosted ones.
162
+ `world_outcome` pins (`hue.conversion_outcome.v1` and `.v2`, and the `hue.outcome_assertions.v2` and `.v3` case-outcome evaluators, `.v3` with its pinned judge in `config.judge`) run inside Hue and need no local callback or executable source digest. A supporting server owns their execution from saved world evidence. Legacy `local_code` pins still require the exact registered callback; changing the worker cannot convert those immutable pins into hosted ones.
162
163
 
163
164
  Manual results require a human session. Hosted model-judge dispatch is an explicit separate API operation: inspect `getJudgeBudget()`, then call `createJudgeJobs(runId,{idempotencyKey,jobs:[{evaluationItemId,scorerVersionId}]})`. `listJudgeJobs`, `getJudgeJob` and `cancelJudgeJob` expose job progress and cancellation requests. These methods never claim that local execution has hosted provenance. Hosted job endpoints are covered by HTTP contract tests here; live hosted model execution is a separate platform acceptance phase. `listResults` and `getResult` read recorded local or hosted results.
164
165
 
@@ -182,7 +183,7 @@ An exclusive `.lock` prevents two processes from invoking targets through the sa
182
183
 
183
184
  The runner saves a starting marker before `start`, and a running marker before invoking a target. If an outcome is not durably saved, resume throws `UncertainExecutionError` and never reruns the target. Replaying an original start key only recovers its execution ID. The low-level `startExecution` API requires an explicit `previousExecutionId`; replacing a still-started attempt additionally requires `allowUncertainRetry:true`. The convenience runner does not automatically adopt externally created attempts. Create a fresh experiment for a fresh target run after investigating side effects.
184
185
 
185
- After a target completes and local scoring finishes, the runner saves the allowed completion/result payloads before uploading. Network/API errors leave those payloads and stable keys available for another call to `runExperiment` with the same directory. Saved receipt IDs prevent duplicate result writes. Serialization failure raises `OutcomeSerializationError` with the execution ID; it does not relabel the target as failed or invoke it again. Scoring/upload failures never change target state.
186
+ After a target completes and local scoring finishes, the runner saves the allowed completion/result payloads before uploading. Network/API errors leave those payloads and stable keys available for another call to `runExperiment` with the same directory. Saved receipt IDs prevent duplicate result writes. An output beyond what Hue stores for one case (200,000 bytes of JSON, 20,000 values or 32 levels of nesting) is that case's own failure: the case completes as `error` with the error type `OutputTooLarge` (and, when result content is persisted, a message naming the bound), no output is stored and the other cases keep running; return a large result as a generated file instead. Output that is not JSON (a cycle, a class instance, a non-finite number) raises `OutcomeSerializationError` with the execution ID. The output is checked as it is read, and the first value that is not JSON or past the value or nesting bound decides which; the byte bound is checked last, so an output past it that also holds a value that is not JSON raises `OutcomeSerializationError`, and one too long for the runtime to serialize is refused before it is serialized. `OutcomeSerializationError` never relabels the target as failed or invokes it again. Scoring/upload failures never change target state.
186
187
 
187
188
  A crash between target completion and saving its permitted result still leaves an uncertain outcome; metadata-only mode intentionally cannot reconstruct discarded output. Export acknowledgement is saved separately. If required telemetry export was not acknowledged, a fresh empty exporter is not evidence of prior receipt: automatic completion is refused. Inspect/export the original trace or use the low-level completion API with an explicit omission policy; do not rerun a known completed target to manufacture telemetry. API completion/results retain their own idempotency guarantees for explicit recovery.
188
189
 
@@ -192,7 +193,7 @@ The runner stops scheduling more cases after an operational failure and waits fo
192
193
 
193
194
  ## Verification boundaries
194
195
 
195
- `scripts/verify-package.mjs` installs a real packed tarball outside the monorepo and runs HTTP contract tests against a synthetic service plus actual OpenTelemetry exporters. It checks two configurations, rescoring without target invocation, absent/null output, upload resume, uncertain execution, exclusive checkpoints, source/metric contracts, content policy and terminating schema workers. It also exercises the local worker's ready, incomplete and uncertain provider-attempt control-plane paths, but does not call an issued provider facade. `scripts/verify-file-cases.mjs` runs against the same installed consumer: pinned input download and verification, artifact publication of a generated document, file-aware local scoring during the run and again on a later rescore, and a declared file the target could not deliver recorded as the target's own error. `scripts/verify-evaluation-api.mjs` is a separate opt-in acceptance against a real Hue receiver/API; it creates synthetic datasets/scorers/experiments in the project associated with the supplied development key.
196
+ `scripts/verify-package.mjs` installs a real packed tarball outside the monorepo and runs HTTP contract tests against a synthetic service plus actual OpenTelemetry exporters. It checks two configurations, rescoring without target invocation, absent/null output, upload resume, uncertain execution, exclusive checkpoints, source/metric contracts, content policy and terminating schema workers. It also exercises the local worker's ready, incomplete and uncertain provider-attempt control-plane paths, but does not call an issued provider facade. `scripts/verify-file-cases.mjs` runs against the same installed consumer: pinned input download and verification, artifact publication of a generated document, file-aware local scoring during the run and again on a later rescore, a declared file the target could not deliver recorded as the target's own error, and a worker world case that receives its agent-visible files with the world, uploads the returned document and refuses altered bytes as `case_file_mismatch`. `scripts/verify-evaluation-api.mjs` is a separate opt-in acceptance against a real Hue receiver/API; it creates synthetic datasets/scorers/experiments in the project associated with the supplied development key.
196
197
 
197
198
  ## Outbound local agent worker
198
199
 
@@ -215,7 +216,8 @@ import { createEvaluationClient, runLocalAgent } from "@hue-run/sdk/evals";
215
216
  import { runMyAgent } from "./agent.js"; // Your existing application entry point.
216
217
 
217
218
  const connection = { apiKey: process.env.HUE_API_KEY! };
218
- const hue = createHue({ ...connection, serviceName: "local-worker", captureContent: false });
219
+ // captureContent: false sends metadata only.
220
+ const hue = createHue({ ...connection, serviceName: "local-worker", captureContent: true });
219
221
  try {
220
222
  await runLocalAgent({
221
223
  client: createEvaluationClient(connection),
@@ -233,7 +235,9 @@ try {
233
235
 
234
236
  The callback receives cloned inputs, local tools, and an allowlisted context containing
235
237
  `config`, `item: {id, externalKey}`, `executionId`, `environmentRunId`,
236
- `trace: {traceId,spanId}` and a short-lived `mcp` capability. Expected outcomes, case metadata
238
+ `trace: {traceId,spanId}`, a short-lived `mcp` capability, `files` (verified copies of the case's
239
+ agent-visible input files, empty when it has none) and a private `outputDirectory`; return
240
+ `withFiles(output, files)` to upload generated files, as for direct cases. Expected outcomes, case metadata
237
241
  and original source pins remain private to grading. Pass the tools or scoped MCP capability into
238
242
  the agent's actual tool boundary; their presence does not redirect provider calls. Capabilities
239
243
  are not written to checkpoints. `maxRuns` limits completed runs for one-shot workers, while
@@ -260,8 +264,8 @@ installed-registry-package to hosted-facade acceptance remains a post-publicatio
260
264
  ## Direct cases and files
261
265
 
262
266
  Cases without a simulated world — document workflows whose inputs are a task plus pinned files —
263
- run on the same runner under the same checkpoint rules. This is unreleased and ships in the next
264
- `@hue-run/sdk` release. It requires a Hue deployment that serves case `inputFiles` on experiment
267
+ run on the same runner under the same checkpoint rules. They shipped in `@hue-run/sdk` `0.5.0`
268
+ and require a Hue deployment that serves case `inputFiles` on experiment
265
269
  items, subject `files`, and the artifact reservation, upload, completion and download APIs. The
266
270
  Python SDK has no equivalent.
267
271
 
@@ -270,7 +274,14 @@ On the outbound worker, supply `directTarget` beside or instead of `target`. Sup
270
274
  `environment:v1` (both strings are exported as `localAgentCapabilities`). Declare the file
271
275
  capabilities the agent accepts and returns — `input:docx`, `input:pdf`, `output:docx` and so on — so
272
276
  Hue matches them against each case's `hue.requiredCapabilities` metadata and offers only matching
273
- cases. `registeredCapabilities` refuses a registration that names a capability without its callback.
277
+ cases. A case pinned to a world whose manifest also holds agent-visible files is offered only to a
278
+ registration that declares `environment-files:v1` (`localAgentCapabilities.environmentFiles`)
279
+ and `input:<extension>` for each file; `target` then receives them as `context.files` with the
280
+ world, as described in
281
+ [ENVIRONMENTS.md](ENVIRONMENTS.md#worlds-served-by-the-simulation-gateway). The worker never adds
282
+ `environment-files:v1` itself: Hue keeps each registered revision's capabilities fixed, so declare
283
+ it under a new revision. `registeredCapabilities` refuses a registration that names a capability
284
+ without its callback.
274
285
 
275
286
  ```ts
276
287
  import { basename } from "node:path";
@@ -312,16 +323,23 @@ The direct callback receives cloned inputs and an allowlisted context of `config
312
323
  plus the worker's `signal` when one was supplied. Expected outcomes and case metadata stay with
313
324
  grading, as they do for world cases.
314
325
 
315
- Before an execution exists, `runExperiment` downloads every pinned input file named by the frozen
326
+ Before an execution exists, `runExperiment` downloads the pinned input files named by the frozen
316
327
  case's `inputFiles` and verifies byte count and SHA-256. A download failure is an SDK failure and
317
- consumes no execution slot; a file already saved with the pinned identity is reused instead of
318
- downloaded again. The target sees only the agent-visible roles — `source`, `attached_template`,
319
- `attached_reference` and `original` — as `context.files`; evaluator-only
328
+ consumes no execution slot; bytes that differ from the manifest raise `CaseFileError` with the
329
+ stable code `case_file_mismatch`, and a download stops one byte past the pinned size. A file
330
+ already saved with the pinned identity is reused instead of downloaded again. The target sees only the agent-visible roles — `source`,
331
+ `attached_template`, `attached_reference` and `original` — as `context.files`; evaluator-only
320
332
  `org_template` and `evaluator_reference` files (an organization's template, a legal corpus, an
321
- answer key) reach scorers but not the agent, and are not even downloaded when no bound code
322
- evaluator runs in this process. Verified copies live under `filesDirectory` (default
323
- `<checkpointDirectory>/files`, created mode 0700), and each case gets its own private
324
- `context.outputDirectory` to write into.
333
+ answer key) reach scorers but not the agent. They are fetched only when a bound code evaluator
334
+ runs in this process: checked with the others before the execution starts but not kept, then
335
+ downloaded again and saved after the target finished, so they are not on disk while the agent
336
+ runs. An agent running as the same operating-system user
337
+ as the runner can still read whatever the runner can; run it under another account when local
338
+ evaluator material must stay out of its reach. Verified copies live under `filesDirectory` (default
339
+ `<checkpointDirectory>/files`, created mode 0700), evaluator-only files apart from the agent's, and
340
+ each case gets its own private `context.outputDirectory` to write into. The files of a case pinned
341
+ to a world are removed when it ends, except staged outputs an interrupted upload resumes from;
342
+ those of a direct case stay for inspection.
325
343
 
326
344
  Return generated documents by wrapping the output in `withFiles(output, files)`. Each entry names a
327
345
  `path` or in-memory `bytes`, a `filename`, a Hue-accepted `contentType` and at most one `primary`.
@@ -329,9 +347,11 @@ The runner copies them next to its checkpoint, publishes them through the artifa
329
347
  upload and verified completion APIs with stable per-execution keys, and completes the execution with
330
348
  `artifactIds` and `primaryArtifactId`, so the subject's frozen manifest holds inputs and outputs
331
349
  together. Generated files are always uploaded regardless of `persistResultContent`: they are the
332
- execution's evidence. A declared file that cannot be read, exceeds 25 MiB, repeats a filename or has
333
- an unsupported content type is saved as the target's error (`TargetError`), not as an uncertain
334
- execution. A crash after the target finished resumes from the staged files without invoking the
350
+ execution's evidence. A declared `path` is read once, as a regular file (a symlink, FIFO or device
351
+ is refused) whose size is checked before reading, and staged owner-only from those bytes. A
352
+ declared file that cannot be read, exceeds 25 MiB, repeats a filename or has an unsupported
353
+ content type is saved as the target's error (`TargetError`), not as an uncertain execution. A
354
+ files directory must be owned by the current user and closed to everyone else (mode 0700). A crash after the target finished resumes from the staged files without invoking the
335
355
  target again; if result content is not persisted, the JSON output cannot be reconstructed and the
336
356
  case is reported as uncertain.
337
357
 
package/README.md CHANGED
@@ -35,7 +35,7 @@ import { createHue, HueExportError } from "@hue-run/sdk";
35
35
  const hue = createHue({
36
36
  apiKey: process.env.HUE_API_KEY!, // a project service key, on the server only
37
37
  serviceName: "my-agent",
38
- captureContent: false, // required: explicitly choose true or false
38
+ captureContent: true, // required: true records content; false sends metadata only
39
39
  onExportIssue: (issue) => console.error(issue), // sanitized counts, never server bodies
40
40
  });
41
41
 
@@ -58,6 +58,11 @@ try {
58
58
  await hue.shutdown(); // flushes and releases providers owned by this client
59
59
  ```
60
60
 
61
+ `captureContent` is required. The example sets `true`, the recommended choice: trace inspection,
62
+ evaluations and judges read the prompts, responses and tool inputs/outputs it records. Choose
63
+ `false` for metadata only when a policy forbids sending that content; see
64
+ [Privacy and content](#privacy-and-content).
65
+
61
66
  The default destination is `https://app.hue.run`. Data goes to
62
67
  `/api/v1/otlp/v1/traces` and `/api/v1/otlp/v1/logs` with a Bearer project key.
63
68
  Set `baseUrl` only for another Hue deployment. It must be an origin without an API path; a trailing slash is accepted.
@@ -78,8 +83,9 @@ claim. Setup never enables content capture or creates a simulation, Hue Run, eva
78
83
  or remote execution. See the [setup CLI contract](./CLI.md) for the supported shapes and release gates.
79
84
 
80
85
  For an existing account, `hue login` validates keys created in Hue Settings and stores them in
81
- `.env.hue` without printing them, and `hue mcp install --client claude-code` (or `cursor`, `codex`,
82
- `vscode`, `windsurf`, `gemini`) writes the Hue MCP configuration that references `HUE_MCP_KEY`. See
86
+ `.env.hue` without printing them, and `hue mcp install --client claude-code` (or `codex`,
87
+ `conductor`, `cursor`, `vscode`, `windsurf`, `gemini`) writes the Hue MCP configuration that
88
+ references `HUE_MCP_KEY`, or with `--auth oauth` only the URL, for signing in with Hue. See
83
89
  [Sign in and store keys](./CLI.md#sign-in-and-store-keys) and
84
90
  [Install the MCP for your coding agent](./CLI.md#install-the-mcp-for-your-coding-agent).
85
91
 
@@ -150,12 +156,22 @@ await hue.model(
150
156
 
151
157
  Each OpenAI Responses `mcp_call`, `web_search_call`, `file_search_call` or `code_interpreter_call`
152
158
  item, and each Anthropic `mcp_tool_use` or `server_tool_use` block with its result, becomes an
153
- `execute_tool {name}` child span with `gen_ai.tool.type` `extension`, `gen_ai.tool.call.id` and,
154
- for MCP calls, `mcp.server.name` (the provider's label). Arguments and results follow
155
- `captureContent`; a failed call carries `error.type` (`mcp_error`, the provider's status or error
156
- code) and ERROR status. An `mcp_list_tools` item becomes a `tools/list` child span with that
157
- server's `gen_ai.tool.definitions`. Pass the request so each server's host is recorded as
158
- `server.address` (only server URLs are read, never credentials), and `servers` to record a
159
+ `execute_tool {name}` child span with `gen_ai.tool.type` `extension`, `gen_ai.tool.call.id`,
160
+ `hue.tool.call.position` (the 0-based position of its item in the response: the OpenAI `output`
161
+ index or the Anthropic `content` block index, in both capture modes) and, for MCP calls,
162
+ `mcp.server.name` (the provider's label). Arguments and results follow `captureContent`; a failed
163
+ call carries `error.type` (`mcp_error`, the provider's status or error code) and ERROR status. With
164
+ `captureContent: true`, a failed OpenAI MCP call's status description is the provider's error
165
+ text, credentials scrubbed (URL userinfo, query values and fragments, or the whole URL for a scheme
166
+ other than `http(s)`, `ws(s)` and `ftp`; tokens with a known credential prefix such as `hue_sk_`,
167
+ `sk-` or `xoxb-`; authorization-scheme credentials and an `Authorization` header's whole value;
168
+ the value of a credential-named `key=value` or `key: value` pair, quoted or not) and cut to 1,024
169
+ characters. An `mcp_list_tools`
170
+ item becomes a `tools/list` child span with that server's `gen_ai.tool.definitions`; with
171
+ `captureContent: false` it carries only `hue.tool.names` and `hue.tool.definitions.sha256`, the
172
+ summary described below. Pass the request so each server's host is recorded as `server.address`
173
+ (only server URLs are read, never credentials; a host name keeps its underscores, as WHATWG URL
174
+ parsing does), and `servers` to record a
159
175
  server's real `name`, `version`, Hue `provider` and `surface` under its label. `provider` defaults
160
176
  to the enclosing `model()` call's provider (`openai` or `anthropic`). The spans have no duration of
161
177
  their own: the provider ran the tools inside the model request.
@@ -287,6 +303,12 @@ outputs and exports through Hue's attached processors keeps the label.
287
303
 
288
304
  ## Privacy and content
289
305
 
306
+ Set `captureContent: true` for full traces: prompts and messages, responses and tool inputs/outputs,
307
+ alongside model, usage, timing and errors. Trace inspection, evaluations and judges read that
308
+ content. Choose `false`, metadata-only mode, when your users decline or an application or data
309
+ policy forbids sending that content to another service. `redact` and the credential filtering
310
+ described below apply in both modes.
311
+
290
312
  `captureContent: false` disables manual input/output/messages/tool content and
291
313
  removes recognized GenAI, Vercel, OpenInference and OpenLLMetry content attributes,
292
314
  legacy GenAI content events, log bodies, status messages and exception text before
@@ -306,12 +328,18 @@ an OpenAI hosted MCP tool. Before export, and before `redact`, Hue replaces the
306
328
  credential-like fields including `authorization`, `authorization_token`, `headers`, `api_key`,
307
329
  `access_token`, `x-api-key`, and keys ending in `token`, `secret`, `password`, `apikey` or
308
330
  `credential` (case-insensitively, ignoring `-` and `_`) with `"[redacted]"` in recorded tool definitions
309
- (`gen_ai.tool.definitions`, `ai.prompt.tools`, `llm.tools.*.tool.json_schema`) and in the `tools`
310
- and `mcp_servers` entries of a raw provider request or response recorded as `input.value`,
311
- `output.value` or `llm.invocation_parameters`. Parameters named in a JSON Schema `properties`
331
+ (`gen_ai.tool.definitions`, `ai.prompt.tools`, `llm.tools.*.tool.json_schema`) and in the
332
+ top-level `tools` and `mcp_servers` entries of any JSON object recorded as `input.value`,
333
+ `output.value` or `llm.invocation_parameters`, whether or not it is a provider request; nothing
334
+ else in that object changes. Parameters named in a JSON Schema `properties`
312
335
  object keep their schemas, so a tool that takes a `headers` argument is still described. A
313
336
  definition nested more than 256 levels deep rejects its record. Sensitive `default`, `const`,
314
- `examples` and `enum` values under credential-named schema parameters are redacted too.
337
+ `examples` and `enum` values under credential-named schema parameters are redacted too. A `url`
338
+ or `server_url` field (named like the credential fields, so `serverUrl` too) that has userinfo, a
339
+ query or a fragment loses its userinfo and fragment, and each query value becomes `[redacted]`
340
+ under its original name; the result is serialized as WHATWG `URL` does (lowercase scheme and
341
+ host, IDN hosts in Punycode, default ports dropped, dot segments resolved). A URL with none of
342
+ them is exported as recorded, and a value `URL` cannot parse becomes `"[redacted]"`.
315
343
 
316
344
  With `captureContent: false`, export removes tool definitions but keeps a summary of them on the
317
345
  same record: `hue.tool.names` lists each definition's `name` (Chat Completions `function.name`,
@@ -324,12 +352,18 @@ rotates. Only definitions another integration recorded can be summarized: `hueTe
324
352
  inputs and exports through Hue's attached processors gets the summary.
325
353
  Recorded messages can inline files: GenAI `blob` parts in `gen_ai.input.messages` /
326
354
  `gen_ai.output.messages` (what the AI SDK 7 adapter records for a file part) and AI SDK 6 `file`
327
- parts in `ai.prompt.messages`. A span whose messages exceed 256 KiB would be rejected, so before
328
- export Hue replaces the `content`/`data` of any such part longer than 64 KiB with the file's
329
- `sha256` (of the decoded bytes for base64 and `data:` URLs, of the UTF-8 text otherwise) and
330
- `size`, keeping the part's other fields such as `type`, `mime_type` and `mediaType`. Smaller inline
355
+ parts in `ai.prompt.messages`. A span whose messages exceed 256 KiB would be rejected, so when a
356
+ record is queued Hue replaces the `content`/`data` of any such part longer than 64 KiB with the
357
+ file's `sha256` and `size`, both of the file's own bytes whatever its media type (base64 content
358
+ and `;base64` `data:` URLs decoded, other `data:` URLs percent-decoded, anything else as UTF-8 text), keeping the part's other fields such as `type`, `mime_type` and `mediaType`. Smaller inline
331
359
  files are exported as recorded. The digest matches `hue.recordFile`'s `hue.file.sha256` for the same
332
- bytes, so a file can be recognized wherever it appears.
360
+ bytes, so a file can be recognized wherever it appears. The replacement happens before the record is
361
+ charged to the queue budget, so a large file does not drop its span. It is bounded: a message
362
+ attribute longer than 8 MiB of text is left unchanged, and the default 8 MiB `maxQueueBytes` then
363
+ drops the span. Base64 grows a file by a third, so that ceiling is an inline file of about 6 MiB.
364
+ The same message text is hashed once per record, and a record inspects at most 16 MiB of message
365
+ text in all; messages past that are charged as recorded. With `captureContent: false` the messages,
366
+ which export removes, are neither hashed nor charged.
333
367
 
334
368
  Manual helpers encode JSON values without converting null into absence. Unknown
335
369
  outputs and usage remain absent. This SDK does not estimate tokens or cost. A thrown
@@ -361,7 +395,7 @@ import { createHue, createHueTransport } from "@hue-run/sdk";
361
395
  const transport = createHueTransport({
362
396
  apiKey: process.env.HUE_API_KEY!,
363
397
  serviceName: "existing-app",
364
- captureContent: false,
398
+ captureContent: true, // false sends metadata only
365
399
  });
366
400
  const tracerProvider = new TracerProvider({ spanProcessors: [transport.spanProcessor] });
367
401
  const loggerProvider = new LoggerProvider({ processors: [transport.logRecordProcessor] });
@@ -576,9 +610,9 @@ an uncertain preparation. See the
576
610
  an issued facade endpoint or the official Gmail service, and do not claim universal provider
577
611
  parity.
578
612
 
579
- An unreleased `directTarget` callback extends the same worker to cases without a world: the runner
580
- verifies the case's pinned input files, hands them to the agent, and uploads the documents it
581
- returns as verified Hue artifacts for scoring. See
613
+ The `directTarget` callback, shipped in TypeScript `0.5.0`, extends the same worker to cases
614
+ without a world: the runner verifies the case's pinned input files, hands them to the agent, and
615
+ uploads the documents it returns as verified Hue artifacts for scoring. See
582
616
  [direct cases and files](EVALUATIONS.md#direct-cases-and-files).
583
617
 
584
618
  Scorer deferral shipped in TypeScript `0.3.1`. Only built-ins
@@ -591,17 +625,23 @@ their agents with `runLocalAgent()`; setup does not register workers or launch s
591
625
 
592
626
  ### Command-line evaluation
593
627
 
594
- The unreleased `hue eval` command wraps `runSimulation()` and `runLocalAgent()` for an adapter
595
- file or a shell command: `hue eval --case "<name>" ./hue-agent.ts` creates a fresh run
596
- from a published case's immutable pins, runs the agent in one isolated world per case, waits
628
+ The `hue eval` command, shipped in TypeScript `0.5.0` (`--case` since `0.6.0`, `--scenario`
629
+ before), wraps `runSimulation()` and `runLocalAgent()` for an adapter file or a shell command:
630
+ `hue eval --case "<name>" ./hue-agent.ts --content` creates a fresh run from a published case's
631
+ immutable pins, runs the agent in one isolated world per case, waits
597
632
  for Hue's outcome checks and prints the run URL and per-case PASS/FAIL verdicts with an exit code;
598
633
  `--worker` registers the same adapter for runs launched from Hue. It needs a Read and
599
- write key in `HUE_API_KEY` (never printed) and keeps content capture off unless `--content`
600
- is passed. See [Evaluate an agent against a case](CLI.md#evaluate-an-agent-against-a-case).
634
+ write key in `HUE_API_KEY` (never printed). Content capture stays off unless `--content` is
635
+ passed; the example passes it so case spans carry content. Case outputs, error messages and
636
+ explanations are stored in Hue either way (`--no-output` keeps them out of a one-shot run's
637
+ results); a command's stdout is its stored answer, with the credentials `hue eval` handed the case
638
+ replaced by `[redacted]`.
639
+ Instrument the agent itself for its model and tool spans. See
640
+ [Evaluate an agent against a case](CLI.md#evaluate-an-agent-against-a-case).
601
641
  Eval sets whose cases pin files instead of a world run as direct cases through `runExperiment()`:
602
- `hue eval --set <slug> --scorer <slug> --command "…"` hands the agent each case's pinned files in a
603
- private directory, uploads the documents it writes and waits for Hue's grading executor to score
604
- them. See [Evaluate a document eval set](CLI.md#evaluate-a-document-eval-set).
642
+ `hue eval --set <slug> --scorer <slug> --command "…" --content` hands the agent each case's
643
+ pinned files in a private directory, uploads the documents it writes and waits for Hue's grading
644
+ executor to score them. See [Evaluate a document eval set](CLI.md#evaluate-a-document-eval-set).
605
645
 
606
646
  ## Managed targets
607
647
 
@@ -38,10 +38,21 @@ export declare function stageDirectCase(root: string, input: {
38
38
  executionId: string;
39
39
  files: LocalFile[];
40
40
  }): Promise<DirectCaseLayout>;
41
+ /** Test seams: `beforeEntry` runs before each listed entry is examined, `afterListing` between
42
+ * the listing and the reads. */
43
+ export interface CollectionSeams {
44
+ beforeEntry?: (name: string) => Promise<void>;
45
+ afterListing?: () => Promise<void>;
46
+ }
41
47
  /**
42
48
  * Turns the output folder into the target's result. Every regular file, in subdirectories too,
43
49
  * becomes a generated file; an unsupported extension is the agent's error rather than a silently
44
50
  * dropped document. `fallbackOutput` (for example the command's stdout) is used when no JSON
45
51
  * output or summary file was written.
52
+ *
53
+ * Nothing here trusts a path the agent could have changed: the output directory must be a real
54
+ * directory, every file is opened without following a final symlink or blocking on a FIFO, must
55
+ * be the regular file the listing saw and within its size limit, and the result carries the bytes
56
+ * read from that open file. `seams` are for tests only.
46
57
  */
47
- export declare function collectDirectOutputs(outputDirectory: string, fallbackOutput?: JsonValue): Promise<TargetResult>;
58
+ export declare function collectDirectOutputs(outputDirectory: string, fallbackOutput?: JsonValue, seams?: CollectionSeams): Promise<TargetResult>;
@@ -1,6 +1,7 @@
1
- import { copyFile, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
1
+ import { constants } from "node:fs";
2
+ import { copyFile, lstat, mkdir, opendir, writeFile } from "node:fs/promises";
2
3
  import { basename, extname, join } from "node:path";
3
- import { outputContentTypes } from "../evals/files.js";
4
+ import { outputContentTypes, outputFileLimits, readAgentFile, safeFilename, } from "../evals/files.js";
4
5
  import { withFiles } from "../evals/types.js";
5
6
  /**
6
7
  * Direct (file) cases for `hue eval`: a case is a task plus pinned input files, and the agent's
@@ -43,6 +44,11 @@ for (const type of Object.values(outputExtensions))
43
44
  throw new Error(`outputExtensions maps to a content type Hue does not accept: ${type}`);
44
45
  const HELPER_FILES = new Set(["manifest.json", "result.json", "output.json"]);
45
46
  const SUMMARY_FILES = ["summary.txt", "summary.md", "final.txt", "resumen.txt"];
47
+ /** The most a helper (`manifest.json`, `result.json`, `summary.txt`, …) may hold: the same bound
48
+ * as the command's stdout. */
49
+ const HELPER_BYTES = 4 * 1024 * 1024;
50
+ /** Entries a collection visits at most, hidden and skipped ones included. */
51
+ const MAX_ENTRIES = 1024;
46
52
  /** Writes the case directory: inputs, descriptor, verified file copies and an empty output folder. */
47
53
  export async function stageDirectCase(root, input) {
48
54
  const caseDirectory = join(root, "case");
@@ -50,21 +56,25 @@ export async function stageDirectCase(root, input) {
50
56
  await mkdir(outputDirectory, { recursive: true, mode: 0o700 });
51
57
  const inputsPath = join(caseDirectory, "inputs.json");
52
58
  await writeFile(inputsPath, JSON.stringify(input.inputs, null, 2), { mode: 0o600 });
59
+ // Names that differ only in case or Unicode normalization are the same file on macOS and
60
+ // Windows filesystems; keep both inputs under distinct names, and never overwrite one.
53
61
  const used = new Set();
62
+ const key = (folder, name) => `${folder}/${name.normalize("NFC").toLowerCase()}`;
54
63
  const files = [];
55
64
  for (const file of input.files) {
56
65
  const folder = join(caseDirectory, "files", file.role);
57
66
  await mkdir(folder, { recursive: true, mode: 0o700 });
58
67
  // Two inputs may share a filename (two incident reports, say); keep both.
59
- const name = basename(file.filename);
68
+ const name = safeFilename(basename(file.filename));
60
69
  const extension = extname(name);
61
70
  const stem = name.slice(0, name.length - extension.length);
62
- let target = join(folder, name);
63
- for (let copy = 2; used.has(target); copy++)
64
- target = join(folder, `${stem} (${copy})${extension}`);
65
- used.add(target);
66
- await copyFile(file.path, target);
67
- files.push({ role: file.role, filename: basename(target), path: target });
71
+ let target = name;
72
+ for (let copy = 2; used.has(key(folder, target)); copy++)
73
+ target = `${stem} (${copy})${extension}`;
74
+ used.add(key(folder, target));
75
+ const path = join(folder, target);
76
+ await copyFile(file.path, path, constants.COPYFILE_EXCL);
77
+ files.push({ role: file.role, filename: target, path });
68
78
  }
69
79
  await writeFile(join(caseDirectory, "case.json"), JSON.stringify({
70
80
  id: input.item.id,
@@ -76,33 +86,119 @@ export async function stageDirectCase(root, input) {
76
86
  }, null, 2), { mode: 0o600 });
77
87
  return { caseDirectory, inputsPath, outputDirectory, files };
78
88
  }
79
- async function readJsonFile(path) {
89
+ const identityOf = (info) => ({
90
+ dev: info.dev,
91
+ ino: info.ino,
92
+ });
93
+ const sameIdentity = (a, b) => a.dev === b.dev && a.ino === b.ino;
94
+ /** A real directory (not a symlink) and its identity, or undefined when `path` is absent. A
95
+ * parent that is no longer a directory (ENOTDIR) is a change, never an absent directory: a case
96
+ * directory replaced by a file must not read as "wrote nothing". */
97
+ async function directoryIdentity(path, label) {
98
+ let info;
80
99
  try {
81
- return JSON.parse(await readFile(path, "utf8"));
100
+ info = await lstat(path);
82
101
  }
83
102
  catch (error) {
84
103
  if (error.code === "ENOENT")
85
104
  return undefined;
86
- throw new Error(`${basename(path)} in the output directory is not valid JSON`);
105
+ throw unexamined(label, error);
87
106
  }
107
+ if (info.isSymbolicLink() || !info.isDirectory())
108
+ throw new Error(`${label} is not a directory; a symbolic link or file is never collected`);
109
+ return identityOf(info);
88
110
  }
89
- /** Regular files anywhere under the output directory, named by their `/`-joined relative path.
90
- * Hidden and lock entries (dot names, `~$…`) and symlinks are skipped at every level. */
91
- async function listOutputFiles(directory, prefix = "") {
92
- const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
111
+ /** The error for an entry that vanished or changed kind while it was being collected, or could
112
+ * not be examined at all. */
113
+ function unexamined(label, error) {
114
+ const code = error.code;
115
+ return new Error(code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP"
116
+ ? `${label} changed while it was collected`
117
+ : `${label} could not be read`);
118
+ }
119
+ /**
120
+ * Regular files anywhere under the output directory, named by their `/`-joined relative path,
121
+ * with the device and inode each had when listed. Hidden and lock entries (dot names, `~$…`),
122
+ * symlinks and anything that is neither a regular file nor a directory are skipped at every
123
+ * level, except that a top-level helper name must be a regular file when present. The walk stops
124
+ * past 32 documents or 1024 entries, and an entry that vanished or a directory that changed while
125
+ * it was listed is refused.
126
+ */
127
+ async function listOutputFiles(root, rootIdentity, seams) {
93
128
  const found = [];
94
- for (const entry of entries) {
95
- if (entry.name.startsWith(".") || entry.name.startsWith("~$"))
96
- continue;
97
- const path = join(directory, entry.name);
98
- const name = `${prefix}${entry.name}`;
99
- if (entry.isDirectory())
100
- found.push(...(await listOutputFiles(path, `${name}/`)));
101
- else if (entry.isFile())
102
- found.push({ name, path });
129
+ const directories = [
130
+ { path: root, label: "output/", identity: rootIdentity },
131
+ ];
132
+ let visited = 0;
133
+ let documents = 0;
134
+ for (let index = 0; index < directories.length; index++) {
135
+ const directory = directories[index];
136
+ const prefix = index === 0 ? "" : `${directory.label.slice("output/".length)}`;
137
+ let entries;
138
+ try {
139
+ entries = await opendir(directory.path);
140
+ }
141
+ catch (error) {
142
+ throw unexamined(directory.label, error);
143
+ }
144
+ for await (const entry of entries) {
145
+ if (++visited > MAX_ENTRIES)
146
+ throw new Error(`The output directory holds more than ${MAX_ENTRIES} entries`);
147
+ const name = `${prefix}${entry.name}`;
148
+ const helper = index === 0 && (HELPER_FILES.has(name) || SUMMARY_FILES.includes(name));
149
+ if (!helper && (entry.name.startsWith(".") || entry.name.startsWith("~$")))
150
+ continue;
151
+ const path = join(directory.path, entry.name);
152
+ if (helper && !entry.isFile())
153
+ throw new Error(`output/${name} is not a regular file; the agent must write it itself`);
154
+ if (entry.isDirectory() || entry.isFile())
155
+ await seams.beforeEntry?.(name);
156
+ if (entry.isDirectory()) {
157
+ const identity = await directoryIdentity(path, `output/${name}`);
158
+ if (!identity)
159
+ throw new Error(`output/${name} changed while it was collected`);
160
+ directories.push({ path, label: `output/${name}/`, identity });
161
+ }
162
+ else if (entry.isFile()) {
163
+ let info;
164
+ try {
165
+ info = await lstat(path);
166
+ }
167
+ catch (error) {
168
+ throw unexamined(`output/${name}`, error);
169
+ }
170
+ if (!info.isFile())
171
+ throw new Error(`output/${name} changed while it was collected`);
172
+ found.push({ name, path, identity: identityOf(info) });
173
+ if (!helper && ++documents > outputFileLimits.count)
174
+ throw new Error(`The agent wrote more than ${outputFileLimits.count} files`);
175
+ }
176
+ }
177
+ }
178
+ // Every directory must still be the one listed: none was swapped for a link meanwhile.
179
+ for (const directory of directories) {
180
+ const now = await directoryIdentity(directory.path, directory.label);
181
+ if (!now || !sameIdentity(now, directory.identity))
182
+ throw new Error(`${directory.label} changed while it was collected`);
103
183
  }
104
184
  return found;
105
185
  }
186
+ async function readHelper(listed) {
187
+ if (!listed)
188
+ return undefined;
189
+ const bytes = await readAgentFile(listed.path, `output/${listed.name}`, HELPER_BYTES, listed.identity);
190
+ return Buffer.from(bytes).toString("utf8");
191
+ }
192
+ function parseHelper(listed, text) {
193
+ if (text === undefined)
194
+ return undefined;
195
+ try {
196
+ return JSON.parse(text);
197
+ }
198
+ catch {
199
+ throw new Error(`${listed.name} in the output directory is not valid JSON`);
200
+ }
201
+ }
106
202
  /** Artifact filenames cannot contain path separators or controls. Encode only those forbidden
107
203
  * characters plus `%`, then represent `/` as `%2F`; distinct output paths stay distinct. */
108
204
  function artifactFilename(relativePath) {
@@ -120,9 +216,18 @@ function artifactFilename(relativePath) {
120
216
  * becomes a generated file; an unsupported extension is the agent's error rather than a silently
121
217
  * dropped document. `fallbackOutput` (for example the command's stdout) is used when no JSON
122
218
  * output or summary file was written.
219
+ *
220
+ * Nothing here trusts a path the agent could have changed: the output directory must be a real
221
+ * directory, every file is opened without following a final symlink or blocking on a FIFO, must
222
+ * be the regular file the listing saw and within its size limit, and the result carries the bytes
223
+ * read from that open file. `seams` are for tests only.
123
224
  */
124
- export async function collectDirectOutputs(outputDirectory, fallbackOutput) {
125
- const manifest = await readJsonFile(join(outputDirectory, "manifest.json"));
225
+ export async function collectDirectOutputs(outputDirectory, fallbackOutput, seams = {}) {
226
+ const rootIdentity = await directoryIdentity(outputDirectory, "The output directory");
227
+ const listed = rootIdentity ? await listOutputFiles(outputDirectory, rootIdentity, seams) : [];
228
+ await seams.afterListing?.();
229
+ const top = (name) => listed.find((entry) => entry.name === name);
230
+ const manifest = parseHelper(top("manifest.json"), await readHelper(top("manifest.json")));
126
231
  const declaredPrimary = manifest && typeof manifest === "object" && !Array.isArray(manifest)
127
232
  ? manifest.primary
128
233
  : undefined;
@@ -130,11 +235,11 @@ export async function collectDirectOutputs(outputDirectory, fallbackOutput) {
130
235
  ? manifest.output
131
236
  : undefined;
132
237
  let output = declaredOutput ??
133
- (await readJsonFile(join(outputDirectory, "result.json"))) ??
134
- (await readJsonFile(join(outputDirectory, "output.json")));
238
+ parseHelper(top("result.json"), await readHelper(top("result.json"))) ??
239
+ parseHelper(top("output.json"), await readHelper(top("output.json")));
135
240
  if (output === undefined)
136
241
  for (const name of SUMMARY_FILES) {
137
- const text = await readFile(join(outputDirectory, name), "utf8").catch(() => undefined);
242
+ const text = await readHelper(top(name));
138
243
  if (text !== undefined) {
139
244
  output = { summary: text };
140
245
  break;
@@ -143,7 +248,7 @@ export async function collectDirectOutputs(outputDirectory, fallbackOutput) {
143
248
  if (output === undefined)
144
249
  output = fallbackOutput;
145
250
  // Helper and summary names are reserved at the top level only; a nested one is a document.
146
- const entries = (await listOutputFiles(outputDirectory)).filter((entry) => !HELPER_FILES.has(entry.name) && !SUMMARY_FILES.includes(entry.name));
251
+ const entries = listed.filter((entry) => !HELPER_FILES.has(entry.name) && !SUMMARY_FILES.includes(entry.name));
147
252
  // Directory order differs between filesystems; keep uploads stable.
148
253
  entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
149
254
  const unsupported = entries.filter((entry) => !outputExtensions[extname(entry.name).toLowerCase()]);
@@ -151,10 +256,11 @@ export async function collectDirectOutputs(outputDirectory, fallbackOutput) {
151
256
  throw new Error(`The agent wrote files Hue does not accept as generated documents: ${unsupported.map((entry) => entry.name).join(", ")} (accepted: ${Object.keys(outputExtensions).join(" ")})`);
152
257
  const files = [];
153
258
  for (const entry of entries) {
154
- if ((await stat(entry.path)).size === 0)
259
+ const bytes = (await readAgentFile(entry.path, `Generated file ${entry.name}`, outputFileLimits.bytes, entry.identity));
260
+ if (!bytes.byteLength)
155
261
  throw new Error(`The agent wrote an empty file: ${entry.name}`);
156
262
  files.push({
157
- path: entry.path,
263
+ bytes,
158
264
  filename: artifactFilename(entry.name),
159
265
  contentType: outputExtensions[extname(entry.name).toLowerCase()],
160
266
  });
@@ -167,5 +273,11 @@ export async function collectDirectOutputs(outputDirectory, fallbackOutput) {
167
273
  }
168
274
  else if (files.length === 1)
169
275
  files[0].primary = true;
276
+ // The directory must still be the one listed when every byte has been read.
277
+ if (rootIdentity) {
278
+ const now = await directoryIdentity(outputDirectory, "The output directory");
279
+ if (!now || !sameIdentity(now, rootIdentity))
280
+ throw new Error("The output directory changed while it was collected");
281
+ }
170
282
  return withFiles(output, files);
171
283
  }