@hue-run/sdk 0.9.0 → 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.
- package/CLI.md +100 -29
- package/ENVIRONMENTS.md +44 -9
- package/EVALUATIONS.md +39 -19
- package/README.md +51 -22
- 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 +469 -72
- package/dist/evals/checkpoint.d.ts +15 -1
- package/dist/evals/checkpoint.js +34 -3
- package/dist/evals/client.d.ts +19 -3
- package/dist/evals/client.js +82 -34
- 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 +59 -1
- package/dist/evals/files.js +187 -27
- 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 +164 -13
- package/dist/evals/scenarios.d.ts +7 -5
- package/dist/evals/scenarios.js +71 -14
- package/dist/evals/simulation.d.ts +17 -4
- package/dist/evals/simulation.js +8 -0
- package/dist/evals/types.d.ts +4 -0
- package/dist/evals/verdicts.d.ts +15 -2
- package/dist/evals/verdicts.js +43 -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 +40 -17
- package/dist/snapshot.d.ts +2 -2
- package/dist/snapshot.js +47 -12
- 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/dist/evals/runner.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
316
|
-
|
|
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,
|
|
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
|
|
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
|
|
349
|
-
|
|
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
|
|
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
|
-
|
|
453
|
-
|
|
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
|
|
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 {
|
|
@@ -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,
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
/**
|
package/dist/evals/scenarios.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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)
|
|
@@ -84,16 +128,29 @@ async function pinsFromScenario(client, scenario, dataset) {
|
|
|
84
128
|
};
|
|
85
129
|
}
|
|
86
130
|
/**
|
|
87
|
-
* Resolves a published Scenario's immutable pins from its ID,
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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.
|
|
90
135
|
*
|
|
91
|
-
* @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.
|
|
92
138
|
*/
|
|
93
139
|
export async function resolveScenarioPins(client, selector) {
|
|
94
|
-
const parsed = parseScenarioSelector(selector);
|
|
140
|
+
const parsed = parseScenarioSelector(selector, ["scenarios", "case-conversions", "cases"]);
|
|
95
141
|
if (parsed.kind === "id") {
|
|
96
|
-
|
|
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
|
+
}
|
|
97
154
|
if (!scenario.publication)
|
|
98
155
|
throw new Error(`Scenario ${scenario.id} is a draft without published pins; publish it in Hue first`);
|
|
99
156
|
return pinsFromScenario(client, scenario, await client.getDataset(scenario.publication.datasetId));
|
|
@@ -102,8 +159,8 @@ export async function resolveScenarioPins(client, selector) {
|
|
|
102
159
|
const datasets = new Map();
|
|
103
160
|
const candidates = [];
|
|
104
161
|
for (const summary of published) {
|
|
105
|
-
const scenario = await client
|
|
106
|
-
if (!scenario
|
|
162
|
+
const scenario = await readListed(client, summary.id);
|
|
163
|
+
if (!scenario?.publication)
|
|
107
164
|
continue;
|
|
108
165
|
let dataset = datasets.get(scenario.publication.datasetId);
|
|
109
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
|
-
|
|
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
|
}
|
package/dist/evals/simulation.js
CHANGED
|
@@ -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
|
}),
|
package/dist/evals/types.d.ts
CHANGED
|
@@ -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 {
|
package/dist/evals/verdicts.d.ts
CHANGED
|
@@ -35,6 +35,11 @@ export interface VerdictResult {
|
|
|
35
35
|
explanation: string | null;
|
|
36
36
|
/** Scorer failure for `state: "error"`, otherwise `null`. */
|
|
37
37
|
error: TypedError | null;
|
|
38
|
+
/** Whether Hue recorded that the evaluator does not apply to the case; such a result counts as
|
|
39
|
+
* neither a pass nor a failure. Absent means false. */
|
|
40
|
+
notApplicable?: boolean;
|
|
41
|
+
/** What a not-applicable evaluator needs that the case lacks, such as `outcome_criteria`. */
|
|
42
|
+
requires?: string;
|
|
38
43
|
}
|
|
39
44
|
/** Outcome of {@link waitForResults}. */
|
|
40
45
|
export interface VerdictResults {
|
|
@@ -65,10 +70,15 @@ export interface CaseVerdict {
|
|
|
65
70
|
externalKey: string;
|
|
66
71
|
/** Subject scored for the case, or `null` before completion. */
|
|
67
72
|
subjectId: string | null;
|
|
68
|
-
/** `passed` and `failed` are scored verdicts; `error`, `skipped` and `pending` carry no verdict.
|
|
73
|
+
/** `passed` and `failed` are scored verdicts; `error`, `skipped` and `pending` carry no verdict.
|
|
74
|
+
* Only the evaluators that apply to the case decide it; one where none applies is an error. */
|
|
69
75
|
state: "passed" | "failed" | "error" | "skipped" | "pending";
|
|
70
|
-
/** True only for `state: "passed"`: every scored metric
|
|
76
|
+
/** True only for `state: "passed"`: every scored metric of an applicable pin passed and no
|
|
77
|
+
* applicable pin errored or is missing. */
|
|
71
78
|
passed: boolean;
|
|
79
|
+
/** Pinned scorer versions Hue recorded as not applicable to the case; always set by
|
|
80
|
+
* {@link summarizeVerdicts}. */
|
|
81
|
+
notApplicable?: string[];
|
|
72
82
|
/** Reported metrics across pinned scorers, in result order. */
|
|
73
83
|
metrics: (Metric & {
|
|
74
84
|
/** Scorer version that reported the metric. */
|
|
@@ -97,6 +107,9 @@ export interface VerdictSummary {
|
|
|
97
107
|
skipped: number;
|
|
98
108
|
/** Cases still missing a result. */
|
|
99
109
|
pending: number;
|
|
110
|
+
/** Results of evaluators that do not apply to their case, a case can have several; always
|
|
111
|
+
* set by {@link summarizeVerdicts}. */
|
|
112
|
+
notApplicable?: number;
|
|
100
113
|
};
|
|
101
114
|
}
|
|
102
115
|
/** Whether one reported metric counts as passing: an explicit `passed`, else a true boolean. */
|
package/dist/evals/verdicts.js
CHANGED
|
@@ -66,6 +66,10 @@ export async function waitForResults(client, options) {
|
|
|
66
66
|
if (!id)
|
|
67
67
|
continue;
|
|
68
68
|
const stored = await client.getResult(id);
|
|
69
|
+
// Only Hue's own flag on a skipped result marks it not applicable; the evidence alone
|
|
70
|
+
// never does, and a scored or errored result keeps its verdict whatever it carries.
|
|
71
|
+
const notApplicable = stored.state === "skipped" && stored.notApplicable === true;
|
|
72
|
+
const evidence = stored.evidence;
|
|
69
73
|
results.push({
|
|
70
74
|
id: stored.id,
|
|
71
75
|
itemId: stored.itemId,
|
|
@@ -75,6 +79,10 @@ export async function waitForResults(client, options) {
|
|
|
75
79
|
metrics: stored.state === "scored" ? stored.metrics : [],
|
|
76
80
|
explanation: stored.explanation ?? null,
|
|
77
81
|
error: stored.error ?? null,
|
|
82
|
+
notApplicable,
|
|
83
|
+
...(notApplicable && typeof evidence?.requires === "string"
|
|
84
|
+
? { requires: evidence.requires }
|
|
85
|
+
: {}),
|
|
78
86
|
});
|
|
79
87
|
}
|
|
80
88
|
return { complete, items, results };
|
|
@@ -105,11 +113,16 @@ export function summarizeVerdicts(results, options) {
|
|
|
105
113
|
const subjectId = subjectByCase.get(item.id) ?? null;
|
|
106
114
|
const own = subjectId ? results.results.filter((result) => result.subjectId === subjectId) : [];
|
|
107
115
|
const metrics = own.flatMap((result) => result.metrics.map((metric) => ({ ...metric, scorerVersionId: result.scorerVersionId })));
|
|
108
|
-
|
|
109
|
-
const
|
|
116
|
+
// An evaluator that does not apply to the case neither passes nor fails it.
|
|
117
|
+
const skippedAsInapplicable = (result) => result.state === "skipped" && result.notApplicable === true;
|
|
118
|
+
const inapplicable = own.filter(skippedAsInapplicable);
|
|
119
|
+
const applicable = own.filter((result) => !skippedAsInapplicable(result));
|
|
120
|
+
const errors = applicable.filter((result) => result.state === "error");
|
|
121
|
+
const scored = applicable.filter((result) => result.state === "scored");
|
|
110
122
|
const failing = scored.filter((result) => !result.metrics.every(metricPassed));
|
|
111
123
|
const missing = [...pins].some((pin) => !own.some((result) => result.scorerVersionId === pin));
|
|
112
|
-
const
|
|
124
|
+
const noneApplies = !missing && own.length > 0 && !applicable.length;
|
|
125
|
+
const state = errors.length || noneApplies
|
|
113
126
|
? "error"
|
|
114
127
|
: failing.length
|
|
115
128
|
? "failed"
|
|
@@ -118,23 +131,44 @@ export function summarizeVerdicts(results, options) {
|
|
|
118
131
|
: scored.length
|
|
119
132
|
? "passed"
|
|
120
133
|
: "skipped";
|
|
134
|
+
const requires = [
|
|
135
|
+
...new Set(inapplicable.flatMap((result) => (result.requires ? [result.requires] : []))),
|
|
136
|
+
];
|
|
121
137
|
return {
|
|
122
138
|
caseId: item.id,
|
|
123
139
|
externalKey: item.externalKey,
|
|
124
140
|
subjectId,
|
|
125
141
|
state,
|
|
126
142
|
passed: state === "passed",
|
|
143
|
+
notApplicable: inapplicable.map((result) => result.scorerVersionId),
|
|
127
144
|
metrics,
|
|
128
|
-
explanations:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
145
|
+
explanations: [
|
|
146
|
+
...(noneApplies
|
|
147
|
+
? [
|
|
148
|
+
`No pinned evaluator applies to this case${requires.length ? ` (they need ${requires.join(" or ")})` : ""}; pin one that grades it`,
|
|
149
|
+
]
|
|
150
|
+
: []),
|
|
151
|
+
...applicable
|
|
152
|
+
.filter((result) => result.state !== "scored" || failing.includes(result))
|
|
153
|
+
.map((result) => result.explanation)
|
|
154
|
+
.filter((explanation) => !!explanation),
|
|
155
|
+
],
|
|
132
156
|
errors: errors.map((result) => result.error?.type ?? "ScorerError"),
|
|
133
157
|
};
|
|
134
158
|
});
|
|
135
|
-
const totals = {
|
|
136
|
-
|
|
159
|
+
const totals = {
|
|
160
|
+
cases: cases.length,
|
|
161
|
+
passed: 0,
|
|
162
|
+
failed: 0,
|
|
163
|
+
error: 0,
|
|
164
|
+
skipped: 0,
|
|
165
|
+
pending: 0,
|
|
166
|
+
notApplicable: 0,
|
|
167
|
+
};
|
|
168
|
+
for (const item of cases) {
|
|
137
169
|
totals[item.state]++;
|
|
170
|
+
totals.notApplicable += item.notApplicable?.length ?? 0;
|
|
171
|
+
}
|
|
138
172
|
return { cases, totals };
|
|
139
173
|
}
|
|
140
174
|
/** Diffs the current summary against a baseline by case key. */
|