@hue-run/sdk 0.8.1 → 0.10.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 (46) hide show
  1. package/CLI.md +100 -29
  2. package/ENVIRONMENTS.md +73 -8
  3. package/EVALUATIONS.md +39 -19
  4. package/README.md +51 -22
  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 +469 -72
  9. package/dist/client.js +10 -20
  10. package/dist/environment/client.d.ts +10 -2
  11. package/dist/environment/client.js +43 -12
  12. package/dist/environment/types.d.ts +30 -10
  13. package/dist/environment.d.ts +1 -1
  14. package/dist/environment.js +1 -1
  15. package/dist/evals/checkpoint.d.ts +15 -1
  16. package/dist/evals/checkpoint.js +34 -3
  17. package/dist/evals/client.d.ts +19 -3
  18. package/dist/evals/client.js +82 -34
  19. package/dist/evals/environment-target.d.ts +23 -3
  20. package/dist/evals/environment-target.js +73 -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 +59 -1
  24. package/dist/evals/files.js +187 -27
  25. package/dist/evals/local-worker.d.ts +21 -3
  26. package/dist/evals/local-worker.js +14 -0
  27. package/dist/evals/runner.d.ts +45 -0
  28. package/dist/evals/runner.js +164 -13
  29. package/dist/evals/scenarios.d.ts +8 -6
  30. package/dist/evals/scenarios.js +75 -15
  31. package/dist/evals/simulation.d.ts +17 -4
  32. package/dist/evals/simulation.js +8 -0
  33. package/dist/evals/types.d.ts +10 -1
  34. package/dist/evals/verdicts.d.ts +15 -2
  35. package/dist/evals/verdicts.js +43 -9
  36. package/dist/evals.d.ts +4 -3
  37. package/dist/evals.js +2 -2
  38. package/dist/inline-files.d.ts +4 -0
  39. package/dist/inline-files.js +40 -17
  40. package/dist/provider-tools.js +30 -3
  41. package/dist/snapshot.d.ts +2 -2
  42. package/dist/snapshot.js +47 -12
  43. package/dist/transport.js +2 -2
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/package.json +1 -1
@@ -10,6 +10,10 @@ export const localAgentCapabilities = {
10
10
  /** Ordinary cases run directly on this machine: JSON inputs and pinned input files in,
11
11
  * JSON output and generated files out. */
12
12
  direct: "direct:v1",
13
+ /** Cases pinned to a world that also carry agent-visible input files: `target` receives them
14
+ * with the world and may return generated files. Declare it with `input:<extension>` for each
15
+ * accepted file type; it requires `target`. */
16
+ environmentFiles: "environment-files:v1",
13
17
  };
14
18
  /** Allowlist the candidate surface for an ordinary case; frozen expectations stay with grading. */
15
19
  export function localAgentDirectContext(context, signal) {
@@ -44,6 +48,8 @@ function localAgentTargetContext(context) {
44
48
  ...(context.connectionBundle
45
49
  ? { connectionBundle: structuredClone(context.connectionBundle) }
46
50
  : {}),
51
+ files: structuredClone(context.files),
52
+ outputDirectory: context.outputDirectory,
47
53
  };
48
54
  }
49
55
  /** The registration's capabilities: explicit values plus one per supplied callback. */
@@ -55,6 +61,8 @@ export function registeredCapabilities(options) {
55
61
  throw new TypeError("direct:v1 requires a directTarget callback");
56
62
  if (declared.includes(localAgentCapabilities.environment) && !options.target)
57
63
  throw new TypeError("environment:v1 requires a target callback");
64
+ if (declared.includes(localAgentCapabilities.environmentFiles) && !options.target)
65
+ throw new TypeError("environment-files:v1 requires a target callback");
58
66
  return [
59
67
  ...new Set([
60
68
  ...declared,
@@ -163,6 +171,12 @@ export async function runLocalAgent(options) {
163
171
  // Worker dispatch uses the case pin: directTarget never receives a world.
164
172
  environmentEvidence: options.directTarget ? "when_pinned" : "required",
165
173
  traceEvidence: { mode: "required" },
174
+ ...(options.traceNotAccepted ? { traceNotAccepted: options.traceNotAccepted } : {}),
175
+ ...(options.onTelemetryNotAccepted
176
+ ? {
177
+ onTelemetryNotAccepted: (entry) => options.onTelemetryNotAccepted(entry),
178
+ }
179
+ : {}),
166
180
  scorers: options.scorers,
167
181
  concurrency: options.concurrency,
168
182
  target: (inputs, context) => {
@@ -93,6 +93,19 @@ export interface RunExperimentOptions extends RunnerOptions {
93
93
  /** Why evidence is omitted, up to 4000 characters. */
94
94
  reason: string;
95
95
  };
96
+ /**
97
+ * What a case does when evidence is required and its trace or logs are not fully accepted.
98
+ * `"stop"` (the default) keeps the saved outcome, leaves the execution started for recovery and
99
+ * rejects. `"fail_case"` completes the execution as failed instead, with the evidence omitted
100
+ * under the reason `telemetry_not_accepted` and without its output or generated files, so no
101
+ * scorer can pass it; reports it in {@link RunnerReport.telemetryNotAccepted} and goes on with
102
+ * the other cases.
103
+ */
104
+ traceNotAccepted?: "stop" | "fail_case";
105
+ /** Called when this call completes a case as failed under `traceNotAccepted: "fail_case"`,
106
+ * before the run goes on, so the failure is known even if a later case stops the run. A call
107
+ * that resumes an already completed case lists it in the report without calling again. */
108
+ onTelemetryNotAccepted?(entry: TelemetryNotAccepted): void | Promise<void>;
96
109
  /** Runs the application for one frozen case; return the output, `withFiles(output, files)`
97
110
  * when it generated files, or `undefined` when unavailable. */
98
111
  target(inputs: JsonValue, context: RunExperimentTargetContext): JsonValue | TargetResult | undefined | Promise<JsonValue | TargetResult | undefined>;
@@ -112,7 +125,39 @@ export interface RunnerReport {
112
125
  resultIds: string[];
113
126
  /** Pins without a local implementation, left pending for their authorized executor. */
114
127
  deferredScorerVersionIds: string[];
128
+ /** Cases completed as failed because their telemetry was not accepted, under
129
+ * `traceNotAccepted: "fail_case"`; absent when there were none. */
130
+ telemetryNotAccepted?: TelemetryNotAccepted[];
115
131
  }
132
+ /** Export issue counts, never content: which signal, what happened, the HTTP status when Hue
133
+ * answered, and how many records. */
134
+ export interface TelemetryIssueCount {
135
+ /** Signal the records belong to. */
136
+ signal: "traces" | "logs";
137
+ /** `rejected` by Hue, `failed` to deliver, `dropped` from the queue, or an `invalid` record. */
138
+ kind: "rejected" | "failed" | "dropped" | "invalid";
139
+ /** HTTP status when the issue came from Hue's answer. */
140
+ status?: number;
141
+ /** Records affected. */
142
+ count: number;
143
+ }
144
+ /** A case whose trace or logs were not fully accepted, completed as failed. */
145
+ export interface TelemetryNotAccepted {
146
+ /** Experiment case (item) ID. */
147
+ caseId: string;
148
+ /** The case's caller-chosen key. */
149
+ caseKey: string;
150
+ /** Execution completed as failed. */
151
+ executionId: string;
152
+ /** Sanitized issues the transport reported while the case's telemetry was exported. */
153
+ issues: TelemetryIssueCount[];
154
+ }
155
+ /** Stable reason, and first word of the omission reason, of a case failed for its telemetry. */
156
+ export declare const TELEMETRY_NOT_ACCEPTED = "telemetry_not_accepted";
157
+ /** The issues of a failed export, summed by signal, kind and status. */
158
+ export declare function telemetryIssueCounts(error: unknown): TelemetryIssueCount[];
159
+ /** `telemetry_not_accepted`, then the issue counts, for example `traces failed 1 (HTTP 400)`. */
160
+ export declare function describeTelemetryIssues(issues: TelemetryIssueCount[]): string;
116
161
  /**
117
162
  * Runs every case of a frozen experiment through `target` on this machine, completes each
118
163
  * execution, scores it with local scorers and uploads the results, checkpointing so an interrupted
@@ -1,12 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdir } from "node:fs/promises";
2
+ import { rmSync } from "node:fs";
3
+ import { mkdir, rm } from "node:fs/promises";
3
4
  import { join, resolve } from "node:path";
4
5
  import { ROOT_CONTEXT } from "@opentelemetry/api";
5
6
  import { HueExportError } from "../transport.js";
6
7
  import { HueApiError } from "./client.js";
7
8
  import { loadEnvironmentEvidence } from "./environment-evidence.js";
8
9
  import { CheckpointStore } from "./checkpoint.js";
9
- import { downloadCaseFiles, localOutputFiles, OutputFileError, stageOutputFiles, targetFileRoles, uploadOutputFiles, } from "./files.js";
10
+ import { onForcedExit } from "./exit-cleanup.js";
11
+ import { assertSafeFileNames, downloadCaseFiles, downloadCaseInputs, localOutputFiles, verifyCaseFiles, OutputFileError, stageOutputFiles, targetFileRoles, uploadOutputFiles, } from "./files.js";
10
12
  import { json, uuid } from "./json.js";
11
13
  import { executableHere, persistedScore, scoreLocally, validateScorerBindings } from "./scorers.js";
12
14
  import { TargetResult, } from "./types.js";
@@ -72,6 +74,59 @@ function neededInputFiles(files, versions, options) {
72
74
  ? files
73
75
  : files.filter((file) => targetFileRoles.includes(file.role));
74
76
  }
77
+ /** Remove a case's files; a failure is reported as a warning and never fails the case. */
78
+ async function removeCaseFiles(directory) {
79
+ try {
80
+ await rm(directory, { recursive: true, force: true });
81
+ }
82
+ catch {
83
+ process.emitWarning(`Could not remove the case files in ${directory}`, {
84
+ code: "HUE_CASE_FILES_NOT_REMOVED",
85
+ });
86
+ }
87
+ }
88
+ /** Everything of a world case's directory except the staged outputs. */
89
+ const WORK_PARTS = ["inputs", "evaluator-inputs", "work"];
90
+ async function releaseWorldFiles(directory, keepOutputs) {
91
+ if (!keepOutputs)
92
+ return removeCaseFiles(directory);
93
+ for (const part of WORK_PARTS)
94
+ await removeCaseFiles(join(directory, part));
95
+ }
96
+ function releaseWorldFilesSync(directory, keepOutputs) {
97
+ for (const path of keepOutputs ? WORK_PARTS.map((part) => join(directory, part)) : [directory])
98
+ rmSync(path, { recursive: true, force: true });
99
+ }
100
+ /** Stable reason, and first word of the omission reason, of a case failed for its telemetry. */
101
+ export const TELEMETRY_NOT_ACCEPTED = "telemetry_not_accepted";
102
+ /** The issues of a failed export, summed by signal, kind and status. */
103
+ export function telemetryIssueCounts(error) {
104
+ if (!(error instanceof HueExportError))
105
+ return [];
106
+ const counts = new Map();
107
+ for (const issue of error.issues) {
108
+ if (issue.kind === "warning")
109
+ continue;
110
+ const key = `${issue.signal}:${issue.kind}:${issue.status ?? ""}`;
111
+ const entry = counts.get(key) ?? {
112
+ signal: issue.signal,
113
+ kind: issue.kind,
114
+ ...(issue.status === undefined ? {} : { status: issue.status }),
115
+ count: 0,
116
+ };
117
+ entry.count += issue.count;
118
+ counts.set(key, entry);
119
+ }
120
+ // A zero-count entry, such as the processor's own flush failure, only repeats a signal that
121
+ // already has counted records.
122
+ const counted = new Set([...counts.values()].filter((entry) => entry.count).map((e) => e.signal));
123
+ return [...counts.values()].filter((entry) => entry.count || !counted.has(entry.signal));
124
+ }
125
+ /** `telemetry_not_accepted`, then the issue counts, for example `traces failed 1 (HTTP 400)`. */
126
+ export function describeTelemetryIssues(issues) {
127
+ const parts = issues.map((issue) => `${issue.signal} ${issue.kind} ${issue.count}${issue.status === undefined ? "" : ` (HTTP ${issue.status})`}`);
128
+ return parts.length ? `${TELEMETRY_NOT_ACCEPTED}: ${parts.join(", ")}` : TELEMETRY_NOT_ACCEPTED;
129
+ }
75
130
  function settings(options) {
76
131
  if (options.environmentEvidence !== undefined &&
77
132
  options.environmentEvidence !== "required" &&
@@ -269,7 +324,7 @@ export async function runExperiment(options) {
269
324
  output) {
270
325
  const needed = neededInputFiles(frozenCase.inputFiles, versions, options);
271
326
  const inputs = needed.length
272
- ? await downloadCaseFiles(options.client, needed, join(caseDirectory, "inputs"))
327
+ ? (await downloadCaseInputs(options.client, needed, caseDirectory)).all
273
328
  : [];
274
329
  await uploadOutputFiles(options.client, saved.executionId, saved.files, () => store.write(file, saved));
275
330
  const outputs = localOutputFiles(saved.files);
@@ -310,10 +365,25 @@ export async function runExperiment(options) {
310
365
  await store.write(file, prepared);
311
366
  return prepared;
312
367
  }
313
- await pool(items, concurrency, async (item) => {
368
+ // A case pinned to a world keeps its files apart and removes them when it ends: the agent's
369
+ // copies of its inputs, its work, the evaluator's downloads and the staged outputs.
370
+ const worldDirectoryOf = (itemId) => join(filesRoot, `world-case-${uuid(itemId)}`);
371
+ const runCase = async (item, files) => {
314
372
  const file = `case-${uuid(item.id)}`;
315
- const caseDirectory = join(filesRoot, `case-${uuid(item.id)}`);
316
- let checkpoint = await store.read(file);
373
+ const worldDirectory = worldDirectoryOf(item.id);
374
+ const caseDirectoryOf = (frozen) => frozen.environmentVersionId ? worldDirectory : join(filesRoot, `case-${uuid(item.id)}`);
375
+ let checkpoint;
376
+ try {
377
+ checkpoint = await store.read(file);
378
+ }
379
+ catch (error) {
380
+ // A checkpoint that is unsafe or fails its integrity check is never resumed from, so its
381
+ // staged outputs are not kept; an I/O failure may pass, so they are.
382
+ if (!error.code)
383
+ files.keepOutputs = false;
384
+ throw error;
385
+ }
386
+ files.keepOutputs = checkpoint?.stage === "uploading";
317
387
  if (checkpoint && checkpoint.stage !== "prepared" && checkpoint.stage !== "uploading") {
318
388
  if (checkpoint.stage === "serialization_failed")
319
389
  throw new OutcomeSerializationError(checkpoint.executionId);
@@ -331,7 +401,8 @@ export async function runExperiment(options) {
331
401
  if (checkpoint.hasOutput && checkpoint.output === undefined)
332
402
  throw new UncertainExecutionError(item.id, checkpoint.executionId);
333
403
  const frozenCase = await options.client.getExperimentCase(experiment.id, item.id);
334
- checkpoint = await prepare(file, checkpoint, frozenCase, caseDirectory, checkpoint.output);
404
+ checkpoint = await prepare(file, checkpoint, frozenCase, caseDirectoryOf(frozenCase), checkpoint.output);
405
+ files.keepOutputs = false;
335
406
  }
336
407
  if (!checkpoint) {
337
408
  if (item.execution)
@@ -343,10 +414,20 @@ export async function runExperiment(options) {
343
414
  // target failures and cannot consume a case's execution slot.
344
415
  const targetInputs = json(frozenCase.inputs);
345
416
  const targetConfig = json(experiment.config);
346
- // Pinned input files are verified on disk before an execution exists for the same reason.
417
+ // Pinned input files are verified before an execution exists for the same reason. Only
418
+ // the agent-visible ones are saved now: evaluator-only files a local code evaluator needs
419
+ // are checked and dropped, and saved for scoring after the target finished, so they are
420
+ // not on disk while it runs.
421
+ const caseDirectory = caseDirectoryOf(frozenCase);
422
+ const isTargetFile = (entry) => targetFileRoles.includes(entry.role);
347
423
  const needed = neededInputFiles(frozenCase.inputFiles, versions, options);
348
- const inputFiles = needed.length
349
- ? await downloadCaseFiles(options.client, needed, join(caseDirectory, "inputs"))
424
+ const agentFiles = needed.filter(isTargetFile);
425
+ // An agent working in a world receives its files by name: each must be one safe name.
426
+ if (frozenCase.environmentVersionId)
427
+ assertSafeFileNames(agentFiles);
428
+ await verifyCaseFiles(options.client, needed.filter((entry) => !isTargetFile(entry)));
429
+ const inputFiles = agentFiles.length
430
+ ? (await downloadCaseInputs(options.client, agentFiles, caseDirectory)).target
350
431
  : [];
351
432
  const outputDirectory = join(caseDirectory, "work");
352
433
  await mkdir(outputDirectory, { recursive: true, mode: 0o700 });
@@ -373,7 +454,7 @@ export async function runExperiment(options) {
373
454
  item: structuredClone(frozenCase),
374
455
  span,
375
456
  executionId: execution.id,
376
- files: structuredClone(inputFiles.filter((entry) => targetFileRoles.includes(entry.role))),
457
+ files: structuredClone(inputFiles),
377
458
  outputDirectory,
378
459
  });
379
460
  if (result instanceof TargetResult) {
@@ -433,6 +514,7 @@ export async function runExperiment(options) {
433
514
  };
434
515
  // Without persisted result content a restart cannot reconstruct the outcome; the
435
516
  // saved stage then reports the execution as uncertain instead of guessing.
517
+ files.keepOutputs = true;
436
518
  await store.write(file, uploading);
437
519
  return prepare(file, uploading, frozenCase, caseDirectory, output);
438
520
  }, {
@@ -440,7 +522,9 @@ export async function runExperiment(options) {
440
522
  input: frozenCase.inputs,
441
523
  attributes: { "hue.experiment.id": experiment.id, "hue.dataset.case.id": item.id },
442
524
  });
525
+ files.keepOutputs = false;
443
526
  // End root before waiting for both OTLP signals. Export failure leaves the prepared checkpoint intact.
527
+ let exportError;
444
528
  try {
445
529
  await options.hue.flush();
446
530
  // Another concurrent flush may already have surfaced this failure. OTLP
@@ -449,29 +533,96 @@ export async function runExperiment(options) {
449
533
  throw new HueExportError(options.hue.transport
450
534
  .getIssues()
451
535
  .filter((issue) => issue.sequence > failureSequenceBefore && issue.kind !== "warning"), options.hue.transport.getReport());
452
- checkpoint.exportState = "accepted";
453
- await store.write(file, checkpoint);
536
+ }
537
+ catch (error) {
538
+ exportError = error;
539
+ }
540
+ try {
541
+ if (exportError === undefined) {
542
+ checkpoint.exportState = "accepted";
543
+ await store.write(file, checkpoint);
544
+ }
454
545
  }
455
546
  catch (error) {
456
547
  // The explicitly chosen omission policy can complete without acknowledged telemetry.
457
548
  if (options.traceEvidence.mode !== "omit")
458
549
  throw error;
459
550
  }
551
+ if (exportError !== undefined && options.traceEvidence.mode !== "omit") {
552
+ if (options.traceNotAccepted !== "fail_case")
553
+ throw exportError;
554
+ // Required evidence that Hue did not accept fails the case rather than leaving its
555
+ // execution started: the outcome is kept, the evidence is declared omitted. Only an
556
+ // export failure takes this path; a checkpoint that cannot be saved is raised above.
557
+ const failed = checkpoint;
558
+ const issues = telemetryIssueCounts(exportError);
559
+ // Without its evidence the case fails whatever it answered: the output and generated
560
+ // files are not attached, so no scorer can pass it, and local scores taken of them go.
561
+ const { traceEvidence: _required, output: _output, artifactIds: _artifacts, primaryArtifactId: _primary, ...complete } = failed.complete;
562
+ failed.scores = [];
563
+ if (complete.state === "succeeded") {
564
+ complete.state = "error";
565
+ complete.error = {
566
+ type: "TelemetryNotAccepted",
567
+ ...(options.persistResultContent ? { message: describeTelemetryIssues(issues) } : {}),
568
+ };
569
+ }
570
+ failed.complete = {
571
+ ...complete,
572
+ traceEvidence: "omit",
573
+ omissionReason: describeTelemetryIssues(issues).slice(0, 4000),
574
+ };
575
+ failed.exportState = "not_accepted";
576
+ failed.telemetryIssues = issues;
577
+ await store.write(file, failed);
578
+ }
460
579
  }
461
580
  const prepared = checkpoint;
462
581
  await options.client.getExecution(prepared.executionId);
463
582
  if (prepared.exportState !== "accepted" && prepared.complete.traceEvidence !== "omit")
464
583
  throw new Error("Target outcome is saved but trace export acknowledgement is unavailable. Restore/export the trace or explicitly complete with omitted evidence through the client; never rerun the target.");
465
584
  const save = () => store.write(file, prepared);
585
+ const notAccepted = prepared.exportState === "not_accepted"
586
+ ? {
587
+ caseId: item.id,
588
+ caseKey: item.externalKey,
589
+ executionId: prepared.executionId,
590
+ issues: prepared.telemetryIssues ?? [],
591
+ }
592
+ : undefined;
466
593
  if (!prepared.completion) {
467
594
  prepared.completion = await options.client.completeExecution(prepared.executionId, prepared.complete);
468
595
  for (const score of prepared.scores)
469
596
  score.payload.evaluationItemId = prepared.completion.evaluationItemId;
470
597
  await save();
598
+ // Once per failed case: a resumed call finds the completion and only reports it.
599
+ if (notAccepted)
600
+ await options.onTelemetryNotAccepted?.(notAccepted);
471
601
  }
602
+ if (notAccepted)
603
+ (report.telemetryNotAccepted ??= []).push(notAccepted);
472
604
  const results = await uploadScores(options, experiment.evaluation.id, prepared.scores, save, versions);
473
605
  report.subjectIds.push(prepared.completion.subjectId);
474
606
  report.resultIds.push(...results);
607
+ };
608
+ await pool(items, concurrency, async (item) => {
609
+ // A world case's directory goes however the case ends, a forced exit included. An
610
+ // interrupted upload keeps only the staged outputs it resumes from; inputs are downloaded
611
+ // anew for scoring. A direct case has no such directory, so nothing is removed for it.
612
+ const worldDirectory = worldDirectoryOf(item.id);
613
+ const files = { keepOutputs: true };
614
+ const untrack = onForcedExit(() => releaseWorldFilesSync(worldDirectory, files.keepOutputs));
615
+ try {
616
+ await runCase(item, files);
617
+ await releaseWorldFiles(worldDirectory, false);
618
+ }
619
+ catch (error) {
620
+ await releaseWorldFiles(worldDirectory, files.keepOutputs);
621
+ throw error;
622
+ }
623
+ finally {
624
+ untrack();
625
+ }
475
626
  });
476
627
  let finish = await store.read("finish");
477
628
  if (!finish) {
@@ -1,4 +1,4 @@
1
- import type { EvaluationClient } from "./client.js";
1
+ import { type EvaluationClient } from "./client.js";
2
2
  import type { CaseConversion, CaseConversionSummary, Page, PageOptions } from "./types.js";
3
3
  /** Immutable pins resolved from a published Scenario or a saved eval set. */
4
4
  export interface ScenarioPins {
@@ -10,7 +10,7 @@ export interface ScenarioPins {
10
10
  datasetId: string;
11
11
  /** Pinned dataset version; frozen only when `saved` is true. */
12
12
  datasetVersionId: string;
13
- /** Pinned scorer versions; a Scenario pins exactly one. */
13
+ /** Pinned scorer versions; a Scenario's publication lists them, its outcome scorer first. */
14
14
  scorerVersionIds: string[];
15
15
  /** Pinned simulated-world version, or `null` when the selection does not pin one. */
16
16
  environmentVersionId: string | null;
@@ -59,11 +59,13 @@ export interface NameMatch<T extends NamedCandidate> {
59
59
  /** Case-insensitive exact matches first, then unique prefix/substring matches. */
60
60
  export declare function matchByName<T extends NamedCandidate>(candidates: T[], name: string): NameMatch<T>;
61
61
  /**
62
- * Resolves a published Scenario's immutable pins from its ID, its Hue URL or its name. A name
63
- * matches the dataset name of published Scenarios case-insensitively: exact matches first, then
64
- * a unique prefix or substring.
62
+ * Resolves a published Scenario's immutable pins from its ID, the ID of the eval set case it
63
+ * published, a Hue URL naming either (`/scenarios/<id>`, `/case-conversions/<id>` or
64
+ * `/cases/<id>`) or its name. A name matches the dataset name of published Scenarios
65
+ * case-insensitively: exact matches first, then a unique prefix or substring.
65
66
  *
66
- * @throws Error when no Scenario matches, several match, or the Scenario is an unpublished draft.
67
+ * @throws Error when no Scenario matches, several match, or the Scenario is an unpublished draft;
68
+ * {@link HueApiError} with status 404 when neither a Scenario nor a published case has the ID.
67
69
  */
68
70
  export declare function resolveScenarioPins(client: ScenarioClient, selector: string): Promise<ScenarioPins>;
69
71
  /**
@@ -1,6 +1,10 @@
1
+ import { HueApiError } from "./client.js";
1
2
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2
- /** Scenarios listed while resolving a name; bounds the registry reads of one selection. */
3
+ /** Published Scenarios a name is matched against; bounds the registry reads of one selection. */
3
4
  const MAX_LISTED = 200;
5
+ /** Scenarios, drafts included, one selection lists at most: a case ID is found among them, and a
6
+ * mistyped one ends after a bounded number of reads. */
7
+ const MAX_SCANNED = 1000;
4
8
  /** Lists Scenarios of the project; requires a Read and write key. */
5
9
  export function listScenarios(client, page) {
6
10
  return client.listCaseConversions(page);
@@ -53,17 +57,57 @@ export function matchByName(candidates, name) {
53
57
  exact: false,
54
58
  };
55
59
  }
56
- async function listPublishedScenarios(client) {
57
- const items = [];
60
+ /** Published Scenario summaries in listing order, page by page, ending after
61
+ * {@link MAX_SCANNED} Scenarios, at an empty page or when the listing repeats a cursor. */
62
+ async function* publishedScenarios(client) {
63
+ const cursors = new Set();
64
+ let listed = 0;
58
65
  let after;
59
66
  for (;;) {
60
67
  const page = await client.listCaseConversions({ after, limit: 100 });
61
- items.push(...page.items.filter((item) => item.status === "published"));
62
- if (!page.nextCursor || items.length >= MAX_LISTED)
63
- return items.slice(0, MAX_LISTED);
68
+ for (const item of page.items) {
69
+ if (++listed > MAX_SCANNED)
70
+ return;
71
+ if (item.status === "published")
72
+ yield item;
73
+ }
74
+ if (!page.items.length || !page.nextCursor || cursors.has(page.nextCursor))
75
+ return;
76
+ cursors.add(page.nextCursor);
64
77
  after = page.nextCursor;
65
78
  }
66
79
  }
80
+ async function listPublishedScenarios(client) {
81
+ const items = [];
82
+ for await (const item of publishedScenarios(client))
83
+ if (items.push(item) >= MAX_LISTED)
84
+ break;
85
+ return items;
86
+ }
87
+ /** A listed Scenario, or undefined when it can no longer be read (removed since the listing). */
88
+ async function readListed(client, id) {
89
+ try {
90
+ return await client.getCaseConversion(id);
91
+ }
92
+ catch (error) {
93
+ if (error instanceof HueApiError && error.status === 404)
94
+ return undefined;
95
+ throw error;
96
+ }
97
+ }
98
+ /**
99
+ * The published Scenario whose eval set case has this ID, the one Hue shows on the case page.
100
+ * Published Scenarios are read in listing order until one matches, past the bound a name search
101
+ * uses; undefined when none does within {@link MAX_SCANNED}.
102
+ */
103
+ async function scenarioOfPublishedCase(client, caseId) {
104
+ for await (const summary of publishedScenarios(client)) {
105
+ const scenario = await readListed(client, summary.id);
106
+ if (scenario?.publication?.caseId.toLowerCase() === caseId)
107
+ return scenario;
108
+ }
109
+ return undefined;
110
+ }
67
111
  const describe = (candidates) => candidates.map((candidate) => `${candidate.name} (${candidate.id})`).join(", ");
68
112
  async function pinsFromScenario(client, scenario, dataset) {
69
113
  if (!scenario.publication)
@@ -74,23 +118,39 @@ async function pinsFromScenario(client, scenario, dataset) {
74
118
  name: dataset.name,
75
119
  datasetId: scenario.publication.datasetId,
76
120
  datasetVersionId: version.id,
77
- scorerVersionIds: [scenario.publication.scorerVersionId],
121
+ // Publications list every pin, the outcome scorer first; older ones carry the single field.
122
+ scorerVersionIds: scenario.publication.scorerVersionIds?.length
123
+ ? [...new Set(scenario.publication.scorerVersionIds)]
124
+ : [scenario.publication.scorerVersionId],
78
125
  environmentVersionId: scenario.publication.environmentVersionId,
79
126
  saved: version.frozenAt !== null,
80
127
  revision: version.revision,
81
128
  };
82
129
  }
83
130
  /**
84
- * Resolves a published Scenario's immutable pins from its ID, its Hue URL or its name. A name
85
- * matches the dataset name of published Scenarios case-insensitively: exact matches first, then
86
- * a unique prefix or substring.
131
+ * Resolves a published Scenario's immutable pins from its ID, the ID of the eval set case it
132
+ * published, a Hue URL naming either (`/scenarios/<id>`, `/case-conversions/<id>` or
133
+ * `/cases/<id>`) or its name. A name matches the dataset name of published Scenarios
134
+ * case-insensitively: exact matches first, then a unique prefix or substring.
87
135
  *
88
- * @throws Error when no Scenario matches, several match, or the Scenario is an unpublished draft.
136
+ * @throws Error when no Scenario matches, several match, or the Scenario is an unpublished draft;
137
+ * {@link HueApiError} with status 404 when neither a Scenario nor a published case has the ID.
89
138
  */
90
139
  export async function resolveScenarioPins(client, selector) {
91
- const parsed = parseScenarioSelector(selector);
140
+ const parsed = parseScenarioSelector(selector, ["scenarios", "case-conversions", "cases"]);
92
141
  if (parsed.kind === "id") {
93
- const scenario = await client.getCaseConversion(parsed.id);
142
+ let scenario;
143
+ try {
144
+ scenario = await client.getCaseConversion(parsed.id);
145
+ }
146
+ catch (error) {
147
+ if (!(error instanceof HueApiError && error.status === 404))
148
+ throw error;
149
+ const published = await scenarioOfPublishedCase(client, parsed.id);
150
+ if (!published)
151
+ throw error;
152
+ scenario = published;
153
+ }
94
154
  if (!scenario.publication)
95
155
  throw new Error(`Scenario ${scenario.id} is a draft without published pins; publish it in Hue first`);
96
156
  return pinsFromScenario(client, scenario, await client.getDataset(scenario.publication.datasetId));
@@ -99,8 +159,8 @@ export async function resolveScenarioPins(client, selector) {
99
159
  const datasets = new Map();
100
160
  const candidates = [];
101
161
  for (const summary of published) {
102
- const scenario = await client.getCaseConversion(summary.id);
103
- if (!scenario.publication)
162
+ const scenario = await readListed(client, summary.id);
163
+ if (!scenario?.publication)
104
164
  continue;
105
165
  let dataset = datasets.get(scenario.publication.datasetId);
106
166
  if (!dataset) {
@@ -4,8 +4,8 @@ import type { EnvironmentTool } from "../environment/tools.js";
4
4
  import type { EnvironmentIdentity, PublishableEnvironmentDefinition, WorldHandoff } from "../environment/types.js";
5
5
  import { type EvaluationClient } from "./client.js";
6
6
  import type { ActualAgentManifestInputV2, AttemptConnectionBundleV2, RequestedAttemptProviderV2 } from "./attempt.js";
7
- import { type RunnerReport } from "./runner.js";
8
- import type { ExperimentCase, Identity, JsonValue, LocalScorer, ScorerDefinition, SimulationMcpCapability } from "./types.js";
7
+ import { type RunnerReport, type TelemetryNotAccepted } from "./runner.js";
8
+ import type { ExperimentCase, Identity, JsonValue, LocalFile, LocalScorer, ScorerDefinition, SimulationMcpCapability, TargetResult } from "./types.js";
9
9
  /** Repository-authored scorer identity and its public definition or local binding. */
10
10
  export interface RepositorySimulationScorer extends Identity {
11
11
  /** Scorer definition to publish, or a local callback bound to its source digest. */
@@ -121,6 +121,12 @@ export interface SimulationTargetContext {
121
121
  /** Credential-bearing provider connections for this callback only. Hue never
122
122
  * checkpoints, logs or adds this response to parity digests. */
123
123
  connectionBundle?: AttemptConnectionBundleV2;
124
+ /** Verified copies of the case's agent-visible input files (`source`, templates,
125
+ * references, originals); evaluator-only files are withheld. Empty when the case has none. */
126
+ files: LocalFile[];
127
+ /** Private directory for this case, removed after it; return generated files with
128
+ * `withFiles`. */
129
+ outputDirectory: string;
124
130
  /** Cancellation is cooperative: pass this signal into the real agent/provider call. */
125
131
  signal?: AbortSignal;
126
132
  }
@@ -152,6 +158,12 @@ export interface RunSimulationOptions {
152
158
  /** Bounded explanation for the omission. */
153
159
  reason: string;
154
160
  };
161
+ /** What a case does when required telemetry is not accepted; see
162
+ * `traceNotAccepted` of {@link runExperiment}. Defaults to `"stop"`. */
163
+ traceNotAccepted?: "stop" | "fail_case";
164
+ /** Called as each case is failed for its telemetry; see `onTelemetryNotAccepted` of
165
+ * {@link runExperiment}. */
166
+ onTelemetryNotAccepted?(entry: TelemetryNotAccepted): void | Promise<void>;
155
167
  /** Local scorer callbacks bound by their declared source digests. */
156
168
  localScorers?: LocalScorer[];
157
169
  /** Cases in flight, 1–64; defaults to 1. */
@@ -190,8 +202,9 @@ export interface RunSimulationOptions {
190
202
  /** Selected MCP surface. */
191
203
  surfaceKey: "google.gmail/mcp" | "slack/mcp";
192
204
  };
193
- /** Invokes the existing local agent exactly once for this attempt. */
194
- target(inputs: JsonValue, context: SimulationTargetContext): JsonValue | undefined | Promise<JsonValue | undefined>;
205
+ /** Invokes the existing local agent exactly once for this attempt; return the output, or
206
+ * `withFiles(output, files)` when it generated files. */
207
+ target(inputs: JsonValue, context: SimulationTargetContext): JsonValue | TargetResult | undefined | Promise<JsonValue | TargetResult | undefined>;
195
208
  /** Receives bounded nonsecret lifecycle progress. */
196
209
  onProgress?(event: SimulationProgress): void | Promise<void>;
197
210
  }
@@ -430,6 +430,12 @@ export async function runSimulation(options) {
430
430
  checkpointDirectory: join(store.directory, `experiment-${experimentId}`),
431
431
  persistResultContent: options.persistResultContent,
432
432
  traceEvidence: options.traceEvidence,
433
+ ...(options.traceNotAccepted ? { traceNotAccepted: options.traceNotAccepted } : {}),
434
+ ...(options.onTelemetryNotAccepted
435
+ ? {
436
+ onTelemetryNotAccepted: (entry) => options.onTelemetryNotAccepted(entry),
437
+ }
438
+ : {}),
433
439
  environmentEvidence: "required",
434
440
  scorers: bindings,
435
441
  concurrency: options.concurrency,
@@ -474,6 +480,8 @@ export async function runSimulation(options) {
474
480
  ...(targetContext.connectionBundle
475
481
  ? { connectionBundle: structuredClone(targetContext.connectionBundle) }
476
482
  : {}),
483
+ files: structuredClone(targetContext.files),
484
+ outputDirectory: targetContext.outputDirectory,
477
485
  signal: targetContext.signal,
478
486
  }),
479
487
  }),
@@ -264,6 +264,10 @@ export interface StoredResult extends ResultSummary {
264
264
  error: TypedError | null;
265
265
  /** Source digest of the local scorer that produced it, when applicable. */
266
266
  sourceDigest: string | null;
267
+ /** True only for a `skipped` result Hue's own outcome scoring recorded because the evaluator
268
+ * does not apply to the case (it has no outcome criteria or conversion rubric to grade); a
269
+ * submitted result never reads true. Absent from servers that predate it. */
270
+ notApplicable?: boolean;
267
271
  }
268
272
  /** A hosted judge job and its charge accounting. */
269
273
  export interface JudgeJob {
@@ -879,8 +883,13 @@ export interface CaseConversionPublication {
879
883
  environmentVersionId: string;
880
884
  /** Scorer identity of the published outcome checks. */
881
885
  scorerId: string;
882
- /** Immutable scorer version pinned by the Scenario. */
886
+ /** Immutable scorer version pinned by the Scenario for its outcome checks. */
883
887
  scorerVersionId: string;
888
+ /**
889
+ * Every immutable scorer version the Scenario pins, `scorerVersionId` first. Absent on
890
+ * Scenarios published before publications listed their pins.
891
+ */
892
+ scorerVersionIds?: string[];
884
893
  }
885
894
  /** A Scenario read by {@link EvaluationClient.getCaseConversion}. Extra server fields are ignored. */
886
895
  export interface CaseConversion extends Partial<Omit<CaseConversionSummary, "id" | "status">> {