@hue-run/sdk 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLI.md +240 -49
- package/ENVIRONMENTS.md +48 -9
- package/EVALUATIONS.md +41 -21
- package/README.md +70 -30
- package/dist/cli/eval-direct.d.ts +12 -1
- package/dist/cli/eval-direct.js +145 -33
- package/dist/cli/eval.d.ts +28 -1
- package/dist/cli/eval.js +504 -81
- package/dist/cli/listen.d.ts +76 -0
- package/dist/cli/listen.js +816 -0
- package/dist/cli/mcp.d.ts +65 -7
- package/dist/cli/mcp.js +271 -73
- package/dist/client.d.ts +7 -4
- package/dist/client.js +36 -10
- package/dist/evals/checkpoint.d.ts +15 -1
- package/dist/evals/checkpoint.js +34 -3
- package/dist/evals/client.d.ts +24 -4
- package/dist/evals/client.js +92 -38
- package/dist/evals/environment-target.d.ts +18 -3
- package/dist/evals/environment-target.js +22 -10
- package/dist/evals/exit-cleanup.d.ts +4 -0
- package/dist/evals/exit-cleanup.js +26 -0
- package/dist/evals/files.d.ts +69 -3
- package/dist/evals/files.js +255 -42
- package/dist/evals/json.js +116 -9
- package/dist/evals/local-worker.d.ts +21 -3
- package/dist/evals/local-worker.js +14 -0
- package/dist/evals/runner.d.ts +45 -0
- package/dist/evals/runner.js +199 -21
- package/dist/evals/scenarios.d.ts +7 -5
- package/dist/evals/scenarios.js +71 -14
- package/dist/evals/scorer-publication.js +58 -3
- package/dist/evals/simulation.d.ts +17 -4
- package/dist/evals/simulation.js +8 -0
- package/dist/evals/types.d.ts +48 -0
- package/dist/evals/verdicts.d.ts +26 -2
- package/dist/evals/verdicts.js +72 -9
- package/dist/evals.d.ts +4 -3
- package/dist/evals.js +2 -2
- package/dist/inline-files.d.ts +4 -0
- package/dist/inline-files.js +48 -17
- package/dist/provider-tools.d.ts +12 -1
- package/dist/provider-tools.js +192 -106
- package/dist/safety.d.ts +7 -1
- package/dist/safety.js +9 -7
- package/dist/setup/cli.js +31 -8
- package/dist/setup/peers.d.ts +13 -0
- package/dist/setup/peers.js +44 -0
- package/dist/snapshot.d.ts +2 -2
- package/dist/snapshot.js +47 -12
- package/dist/tool-definitions.d.ts +18 -0
- package/dist/tool-definitions.js +232 -0
- package/dist/transport.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/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
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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}
|
|
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.
|
|
264
|
-
|
|
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.
|
|
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
|
|
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;
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
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:
|
|
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 `
|
|
82
|
-
`vscode`, `windsurf`, `gemini`) writes the Hue MCP configuration that
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
`
|
|
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
|
|
310
|
-
and `mcp_servers` entries of
|
|
311
|
-
`output.value` or `llm.invocation_parameters
|
|
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
|
|
328
|
-
|
|
329
|
-
`sha256`
|
|
330
|
-
`
|
|
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
|
-
|
|
580
|
-
verifies the case's pinned input files, hands them to the agent, and
|
|
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
|
|
595
|
-
|
|
596
|
-
|
|
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)
|
|
600
|
-
|
|
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
|
|
603
|
-
private directory, uploads the documents it writes and waits for Hue's grading
|
|
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>;
|
package/dist/cli/eval-direct.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
63
|
-
for (let copy = 2; used.has(target); copy++)
|
|
64
|
-
target =
|
|
65
|
-
used.add(target);
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
+
info = await lstat(path);
|
|
82
101
|
}
|
|
83
102
|
catch (error) {
|
|
84
103
|
if (error.code === "ENOENT")
|
|
85
104
|
return undefined;
|
|
86
|
-
throw
|
|
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
|
-
/**
|
|
90
|
-
*
|
|
91
|
-
|
|
92
|
-
const
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
|
134
|
-
(await
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|