@hue-run/sdk 0.20.2 → 0.20.3

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 CHANGED
@@ -42,7 +42,7 @@ project manifest are refused because managers can update ancestor locks; Python
42
42
  The generated `hue.setup.mjs` or `hue_setup.py` always selects `captureContent: false` /
43
43
  `capture_content=False`. For a supported application, setup installs the dependency and adds the
44
44
  managed import and middleware registration to the existing entrypoint; an unreferenced helper is
45
- not a completed integration. TypeScript uses `@hue-run/sdk@0.20.2`, `@opentelemetry/api@1.9.1` and
45
+ not a completed integration. TypeScript uses `@hue-run/sdk@0.20.3`, `@opentelemetry/api@1.9.1` and
46
46
  `@opentelemetry/context-async-hooks@2.11.0`; Python setup uses its separately tested package pin.
47
47
  Content capture requires an ordinary account-managed key and a later explicit application decision.
48
48
 
@@ -381,8 +381,8 @@ and Hue never receives or stores them. The command is part of the `hue` executab
381
381
  # LANGFUSE_PUBLIC_KEY=pk-lf-...
382
382
  # LANGFUSE_SECRET_KEY=sk-lf-...
383
383
  # LANGFUSE_BASE_URL=https://cloud.langfuse.com
384
- npx -y @hue-run/sdk@0.20.2 import langfuse --env-path .env.langfuse --from 2026-01-01 --dry-run
385
- npx -y @hue-run/sdk@0.20.2 import langfuse --env-path .env.langfuse --from 2026-01-01 --media
384
+ npx -y @hue-run/sdk@0.20.3 import langfuse --env-path .env.langfuse --from 2026-01-01 --dry-run
385
+ npx -y @hue-run/sdk@0.20.3 import langfuse --env-path .env.langfuse --from 2026-01-01 --media
386
386
  ```
387
387
 
388
388
  `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY` come from the environment or `--env-path`
@@ -687,7 +687,7 @@ node packages/sdk-typescript/scripts/verify-package.mjs --artifacts-dir .artifac
687
687
  # Set project to an existing supported fixture; use the same directory on resume.
688
688
  project=/absolute/path/to/supported-fixture
689
689
  node packages/sdk-typescript/scripts/verify-setup-live.mjs \
690
- --archive .artifacts/typescript/hue-run-sdk-0.20.2.tgz \
690
+ --archive .artifacts/typescript/hue-run-sdk-0.20.3.tgz \
691
691
  --origin https://STAGING_ORIGIN \
692
692
  --project "$project" --command setup \
693
693
  --evidence .context/setup-staging-before-claim.json
@@ -698,7 +698,7 @@ the private local handoff and finish the real browser claim, then reconcile the
698
698
 
699
699
  ```sh
700
700
  node packages/sdk-typescript/scripts/verify-setup-live.mjs \
701
- --archive .artifacts/typescript/hue-run-sdk-0.20.2.tgz \
701
+ --archive .artifacts/typescript/hue-run-sdk-0.20.3.tgz \
702
702
  --origin https://STAGING_ORIGIN \
703
703
  --project "$project" --command claim \
704
704
  --evidence .context/setup-staging-after-claim.json
@@ -740,10 +740,10 @@ otherwise. Never hand the agent Hue-native tools (`context.tools`, `context.mcp`
740
740
  production agent lacks. Pin the SDK, so the agent's repository gains no dependency:
741
741
 
742
742
  ```sh
743
- npx -y @hue-run/sdk@0.20.2 eval --case "Refund an eligible charge" --command "node agent.js"
744
- npx -y @hue-run/sdk@0.20.2 eval --case https://app.hue.run/projects/demo/scenarios/<id> --command "python agent.py" --timeout 120 --baseline <run id>
745
- npx -y @hue-run/sdk@0.20.2 eval --set "Billing regressions" --scorer-version <id> --command "node agent.js" --save-version
746
- npx -y @hue-run/sdk@0.20.2 eval --worker --command "node agent.js" --agent-key support-agent --revision <new-agent-revision>
743
+ npx -y @hue-run/sdk@0.20.3 eval --case "Refund an eligible charge" --command "node agent.js"
744
+ npx -y @hue-run/sdk@0.20.3 eval --case https://app.hue.run/projects/demo/scenarios/<id> --command "python agent.py" --timeout 120 --baseline <run id>
745
+ npx -y @hue-run/sdk@0.20.3 eval --set "Billing regressions" --scorer-version <id> --command "node agent.js" --save-version
746
+ npx -y @hue-run/sdk@0.20.3 eval --worker --command "node agent.js" --agent-key support-agent --revision <new-agent-revision>
747
747
  ```
748
748
 
749
749
  One invocation creates one run holding every selected case; it prints the run URL
@@ -781,7 +781,7 @@ export default function runMyAgent(inputs: JsonValue, context: SimulationTargetC
781
781
  ```
782
782
 
783
783
  ```sh
784
- npx -y @hue-run/sdk@0.20.2 eval --case "Refund an eligible charge" ./hue-agent.ts --content
784
+ npx -y @hue-run/sdk@0.20.3 eval --case "Refund an eligible charge" ./hue-agent.ts --content
785
785
  ```
786
786
 
787
787
  `--scenario` remains an alias for `--case` for existing scripts. Pass one selection flag.
package/EVALUATIONS.md CHANGED
@@ -362,7 +362,9 @@ Python SDK's `run_experiment` and `rescore` hand pinned input files to targets a
362
362
  generated files and the outbound worker are TypeScript-only.
363
363
 
364
364
  Since TypeScript 0.13.2, the `hue eval --worker --command "…"` wrapper supplies both callbacks, so one
365
- command worker accepts direct and world cases, including mixed eval sets. It keeps the launch
365
+ command worker accepts direct and world cases, including mixed eval sets. Since 0.20.3, a one-shot
366
+ `hue eval --set` over such a set runs it as one `mixed` run: each case takes its own kind's path
367
+ under one experiment and its own checkpoint identity (`--mode simulation` over it is refused). It keeps the launch
366
368
  directory for relative application entry points; private case inputs/output still use
367
369
  `HUE_CASE_*`. An adapter-file worker opts into the direct callback with `--capability direct:v1`
368
370
  and must handle `context.mode === "direct"`. Restart an existing environment-only command
package/README.md CHANGED
@@ -536,8 +536,8 @@ the Langfuse project's own keys (`LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, a
536
536
  `HUE_API_KEY`:
537
537
 
538
538
  ```sh
539
- npx -y @hue-run/sdk@0.20.2 import langfuse --env-path .env.langfuse --from 2026-01-01 --dry-run
540
- npx -y @hue-run/sdk@0.20.2 import langfuse --env-path .env.langfuse --from 2026-01-01 --media
539
+ npx -y @hue-run/sdk@0.20.3 import langfuse --env-path .env.langfuse --from 2026-01-01 --dry-run
540
+ npx -y @hue-run/sdk@0.20.3 import langfuse --env-path .env.langfuse --from 2026-01-01 --media
541
541
  ```
542
542
 
543
543
  - It reads Langfuse Cloud and self-hosted v4 through the Observations API v2, and self-hosted v3
package/dist/cli/eval.js CHANGED
@@ -20,7 +20,8 @@ import { describeTelemetryIssues, runExperiment, TargetCancelledError, TELEMETRY
20
20
  import { HueExportError } from "../transport.js";
21
21
  import { matchByName, parseScenarioSelector, resolveEvalSetPins, resolveScenarioPins, } from "../evals/scenarios.js";
22
22
  import { collectDirectOutputs, stageDirectCase } from "./eval-direct.js";
23
- import { runSimulation, } from "../evals/simulation.js";
23
+ import { runEnvironmentTarget } from "../evals/environment-target.js";
24
+ import { runSimulation, simulationTargetContext, } from "../evals/simulation.js";
24
25
  import { collectExperimentVerdicts, compareVerdicts, metricPassed, } from "../evals/verdicts.js";
25
26
  import { DEFAULT_ENV_FILE, envFileArgument, envFileOptions } from "./env-file.js";
26
27
  const USAGE = `Usage: hue eval [adapter-file] [options]
@@ -35,7 +36,9 @@ Selection (exactly one, not used with --worker):
35
36
  --dataset-version <id> Frozen dataset version; requires --scorer or --scorer-version
36
37
  --scorer <slug|name|id> Evaluator to pin at its latest published version (repeatable)
37
38
  --scorer-version <id> Scorer version to pin (repeatable)
38
- --mode <auto|direct|simulation> Case kind; auto picks direct for sets whose cases pin no world
39
+ --mode <auto|direct|simulation> Case kind; auto picks direct for sets whose cases pin no
40
+ world and runs a set mixing both kinds as one run, each
41
+ case through its own kind's path
39
42
 
40
43
  Agent (exactly one):
41
44
  <adapter-file> Module exporting default or runMyAgent(inputs, context)
@@ -979,9 +982,11 @@ function parseMode(value) {
979
982
  return value;
980
983
  throw new UsageError("--mode must be auto, direct or simulation");
981
984
  }
982
- /** The selection's cases, and whether the run is direct: when `--mode` leaves it open, direct
983
- * means nothing pins a simulated world, not a Scenario and no case of the version. A simulation
984
- * run cannot execute a case that pins no world, so a selection mixing both kinds is refused. */
985
+ /** The selection's cases and how they run. When `--mode` leaves it open, a version mixing cases
986
+ * that pin a world with cases that pin none runs as one mixed run; direct means nothing pins a
987
+ * simulated world, not a Scenario and no case of the version; anything else is a simulation. A
988
+ * simulation run cannot execute a case that pins no world, so `--mode simulation` over such
989
+ * cases is refused. */
985
990
  async function inspectSelection(client, pins, mode) {
986
991
  let cases = 0;
987
992
  let worldCases = 0;
@@ -992,14 +997,18 @@ async function inspectSelection(client, pins, mode) {
992
997
  worldCases += page.items.filter((item) => item.environmentVersionId).length;
993
998
  after = page.nextCursor ?? undefined;
994
999
  } while (after);
995
- const direct = mode === "auto"
996
- ? !pins.scenarioId && !pins.environmentVersionId && worldCases === 0
997
- : mode === "direct";
998
1000
  const answerOnly = cases - worldCases;
999
- const refusal = !direct && answerOnly > 0
1000
- ? `${answerOnly} of the ${cases} ${cases === 1 ? "case pins" : "cases pin"} no world, so a simulation run would fail ${answerOnly === 1 ? "it" : "them"} before the agent starts. Put the answer-only cases in their own eval set${mode === "simulation" ? ", or drop --mode simulation" : ""}.`
1001
+ const kind = mode !== "auto"
1002
+ ? mode
1003
+ : worldCases > 0 && answerOnly > 0
1004
+ ? "mixed"
1005
+ : worldCases === 0 && !pins.scenarioId && !pins.environmentVersionId
1006
+ ? "direct"
1007
+ : "simulation";
1008
+ const refusal = kind === "simulation" && answerOnly > 0
1009
+ ? `${answerOnly} of the ${cases} ${cases === 1 ? "case pins" : "cases pin"} no world, so a simulation run would fail ${answerOnly === 1 ? "it" : "them"} before the agent starts. ${mode === "simulation" ? "Drop --mode simulation: --mode auto runs the world cases and the answer-only cases together as one run" : "Put the answer-only cases in their own eval set"}.`
1001
1010
  : undefined;
1002
- return { direct, cases, worldCases, ...(refusal ? { refusal } : {}) };
1011
+ return { kind, cases, worldCases, ...(refusal ? { refusal } : {}) };
1003
1012
  }
1004
1013
  function toJson(verdicts, runUrl, baseline, extra = {}, telemetry = []) {
1005
1014
  const notAccepted = new Map(telemetry.map((entry) => [entry.caseId, entry.issues]));
@@ -1255,7 +1264,9 @@ async function runOnce(values, connection, agents, agent, hue, output, signal) {
1255
1264
  }
1256
1265
  const runName = values.name ?? defaultRunName(agent);
1257
1266
  const project = await client.checkConnection();
1258
- const checkpointDirectory = await prepareCheckpointDirectory(values["checkpoint-dir"], agent.key, project.id, selection.direct ? "direct" : "simulation");
1267
+ // Each kind of run has its own checkpoint directory and identity: a mixed run never resumes
1268
+ // from, or is resumed by, a direct or simulation run's checkpoint.
1269
+ const checkpointDirectory = await prepareCheckpointDirectory(values["checkpoint-dir"], agent.key, project.id, selection.kind);
1259
1270
  const judging = values.judges
1260
1271
  ? {
1261
1272
  store: await CheckpointStore.acquire(checkpointPath(checkpointDirectory, ["judge-dispatch", "judge checkpoint"]), { kind: "cli-judge-dispatch", projectId: project.id, baseUrl: connection.baseUrl }),
@@ -1267,7 +1278,7 @@ async function runOnce(values, connection, agents, agent, hue, output, signal) {
1267
1278
  source: {
1268
1279
  directory: checkpointDirectory,
1269
1280
  identity: {
1270
- kind: selection.direct ? "direct" : "simulation",
1281
+ kind: selection.kind,
1271
1282
  projectId: project.id,
1272
1283
  baseUrl: connection.baseUrl,
1273
1284
  },
@@ -1283,8 +1294,9 @@ async function runOnce(values, connection, agents, agent, hue, output, signal) {
1283
1294
  await completeJudgeHandoff(client, judging, pending);
1284
1295
  return await reportVerdicts(client, values, pending.run, baselineId, output, signal, judging);
1285
1296
  }
1286
- if (selection.direct)
1287
- return await runDirect({
1297
+ if (selection.kind !== "simulation")
1298
+ return await runExperimentAttempt({
1299
+ kind: selection.kind,
1288
1300
  client,
1289
1301
  values,
1290
1302
  pins,
@@ -1298,6 +1310,7 @@ async function runOnce(values, connection, agents, agent, hue, output, signal) {
1298
1310
  concurrency,
1299
1311
  baselineId,
1300
1312
  judging,
1313
+ checkpointDirectory,
1301
1314
  }, connection);
1302
1315
  const environmentClient = createEnvironmentClient(connection);
1303
1316
  const caseKeys = new Map();
@@ -1393,8 +1406,11 @@ async function checkRun(client, values, pins, selection, agent, output) {
1393
1406
  return 1;
1394
1407
  }
1395
1408
  }
1396
- const { direct, cases, worldCases, refusal } = selection;
1409
+ const { kind, cases, worldCases, refusal } = selection;
1397
1410
  const unsaved = !pins.saved && !values["save-version"];
1411
+ // What each kind of run gives its cases: a direct run gives no case a world, a simulation
1412
+ // run gives every case one (and is refused over cases that pin none), a mixed run gives one
1413
+ // to each case that pins one.
1398
1414
  const check = {
1399
1415
  ok: !unsaved && !refusal,
1400
1416
  project: { id: project.id, name: project.name },
@@ -1406,8 +1422,9 @@ async function checkRun(client, values, pins, selection, agent, output) {
1406
1422
  cases,
1407
1423
  evaluators: pins.scorerVersionIds.length,
1408
1424
  },
1409
- mode: direct ? "direct" : "simulation",
1410
- worldCases: direct ? 0 : worldCases,
1425
+ mode: kind,
1426
+ worldCases: kind === "direct" ? 0 : worldCases,
1427
+ directCases: kind === "direct" ? cases : cases - worldCases,
1411
1428
  agent: { key: agent.key, revision: agent.revision, concurrency: values.concurrency ?? "1" },
1412
1429
  };
1413
1430
  if (values.json)
@@ -1415,9 +1432,12 @@ async function checkRun(client, values, pins, selection, agent, output) {
1415
1432
  else {
1416
1433
  output.log(`Project: ${project.name} (${project.id})`);
1417
1434
  output.log(`Selection: ${check.selection.kind === "case" ? "case" : "eval set"} "${pins.name}", ${cases} ${cases === 1 ? "case" : "cases"}, ${pins.scorerVersionIds.length} ${pins.scorerVersionIds.length === 1 ? "evaluator" : "evaluators"}`);
1418
- output.log(direct
1435
+ const worlds = `${worldCases} ${worldCases === 1 ? "case gets" : "cases get"} a fresh world, reached through the HUE_SIM_*_URL mirrors and HUE_WORLD_TOKEN`;
1436
+ output.log(kind === "direct"
1419
1437
  ? "Mode: direct; the agent answers without a simulated world or app mirrors"
1420
- : `Mode: simulation; ${worldCases} ${worldCases === 1 ? "case gets" : "cases get"} a fresh world, reached through the HUE_SIM_*_URL mirrors and HUE_WORLD_TOKEN`);
1438
+ : kind === "simulation"
1439
+ ? `Mode: simulation; ${worlds}`
1440
+ : `Mode: mixed; ${worlds}, and ${check.directCases} ${check.directCases === 1 ? "case answers" : "cases answer"} without one`);
1421
1441
  output.log(`Agent: ${agent.key} @ ${agent.revision}`);
1422
1442
  }
1423
1443
  if (refusal) {
@@ -1432,21 +1452,26 @@ async function checkRun(client, values, pins, selection, agent, output) {
1432
1452
  return 0;
1433
1453
  }
1434
1454
  /**
1435
- * Direct cases: one ordinary experiment through `runExperiment`. The agent receives the case's
1436
- * pinned files and returns generated documents; code evaluators pinned to the run stay deferred
1437
- * for Hue's executor (`deferUnboundLocalScorers`), so no evaluator source runs on this machine.
1438
- * The attempt is checkpointed as in simulation mode: rerunning the same selection resumes the
1439
- * saved experiment without invoking the agent again for its finished cases.
1455
+ * One ordinary experiment through `runExperiment`, for a direct run and for a mixed one. A
1456
+ * direct run's agent receives the case's pinned files and returns generated documents. A mixed
1457
+ * run routes each case by its pin: a case pinning no world goes to the direct adapter, a case
1458
+ * pinning one gets a fresh world through the environment target and the simulation adapter, as
1459
+ * a simulation run gives it. Code evaluators pinned to the run stay deferred for Hue's executor
1460
+ * (`deferUnboundLocalScorers`), so no evaluator source runs on this machine. The attempt is
1461
+ * checkpointed as in simulation mode, under the run kind's own identity: rerunning the same
1462
+ * selection resumes the saved experiment without invoking the agent again for its finished
1463
+ * cases.
1440
1464
  */
1441
- async function runDirect(run, connection) {
1442
- const { client, values, pins, output, signal } = run;
1465
+ async function runExperimentAttempt(run, connection) {
1466
+ const { client, kind, values, pins, output, signal } = run;
1443
1467
  const config = { agentKey: run.agent.key, agentRevision: run.agent.revision };
1444
- const checkpointDirectory = await prepareCheckpointDirectory(values["checkpoint-dir"], run.agent.key, run.projectId, "direct");
1445
- const store = await CheckpointStore.acquire(checkpointDirectory, {
1446
- kind: "direct",
1468
+ const store = await CheckpointStore.acquire(run.checkpointDirectory, {
1469
+ kind,
1447
1470
  projectId: run.projectId,
1448
1471
  baseUrl: connection.baseUrl,
1449
1472
  });
1473
+ // Only a mixed run creates worlds; its world cases reach Hue through the environment client.
1474
+ const environmentClient = kind === "mixed" ? createEnvironmentClient(connection) : undefined;
1450
1475
  let experimentId = "";
1451
1476
  let runUrl = "";
1452
1477
  let report;
@@ -1459,7 +1484,7 @@ async function runDirect(run, connection) {
1459
1484
  });
1460
1485
  let attempt = await store.read("active-attempt");
1461
1486
  if (attempt && attempt.stage !== "completed" && attempt.selectionDigest !== selectionDigest)
1462
- throw new Error("Recover the unfinished direct run before running a changed selection: run the unchanged command again to finish it, or pass another --checkpoint-dir");
1487
+ throw new Error(`Recover the unfinished ${kind} run before running a changed selection: run the unchanged command again to finish it, or pass another --checkpoint-dir`);
1463
1488
  if (!attempt || attempt.stage === "completed") {
1464
1489
  attempt = { selectionDigest, idempotencyKey: randomUUID(), stage: "preparing" };
1465
1490
  await store.write("active-attempt", attempt);
@@ -1496,10 +1521,41 @@ async function runDirect(run, connection) {
1496
1521
  scorers: [],
1497
1522
  deferUnboundLocalScorers: true,
1498
1523
  environmentEvidence: "when_pinned",
1524
+ // A world case that a process which died here left running runs again in a fresh world;
1525
+ // an answer-only case it left running stays uncertain, since its agent may still be
1526
+ // acting. A direct run gives no case a world, so none of its cases reruns.
1527
+ ...(environmentClient
1528
+ ? { rerunCrashedCases: (item) => Boolean(item.environmentVersionId) }
1529
+ : {}),
1499
1530
  async target(inputs, context) {
1500
1531
  if (signal.aborted)
1501
1532
  throw new TargetCancelledError();
1502
- output.log(`[${context.item.externalKey}] agent started`);
1533
+ const label = context.item.externalKey;
1534
+ if (environmentClient && context.item.environmentVersionId)
1535
+ return runEnvironmentTarget({
1536
+ client,
1537
+ environmentClient,
1538
+ hue: run.hue,
1539
+ inputs,
1540
+ context,
1541
+ agentRevision: run.agent.revision,
1542
+ // The CLI adapts to whatever the deployment serves; the library warning is for
1543
+ // code that still reads the legacy capability itself.
1544
+ deprecationWarnings: false,
1545
+ signal,
1546
+ onProgress(event) {
1547
+ if (event.type === "world_created")
1548
+ output.log(`[${label}] world created`);
1549
+ else if (event.type === "target_started")
1550
+ output.log(`[${label}] agent started`);
1551
+ else if (event.type === "world_sealed")
1552
+ output.log(`[${label}] world sealed`);
1553
+ else if (event.type === "attempt_prepared")
1554
+ output.log(`[${label}] attempt prepared: ${event.status}`);
1555
+ },
1556
+ target: (targetInputs, targetContext) => run.agents.simulation(structuredClone(targetInputs), simulationTargetContext(targetContext)),
1557
+ });
1558
+ output.log(`[${label}] agent started`);
1503
1559
  const result = await run.agents.direct(inputs, {
1504
1560
  mode: "direct",
1505
1561
  config: structuredClone(context.config),
@@ -1522,7 +1578,7 @@ async function runDirect(run, connection) {
1522
1578
  experimentId,
1523
1579
  subjectIds: report.subjectIds,
1524
1580
  runUrl,
1525
- extra: { mode: "direct", deferredScorerVersionIds: report.deferredScorerVersionIds },
1581
+ extra: { mode: kind, deferredScorerVersionIds: report.deferredScorerVersionIds },
1526
1582
  telemetryNotAccepted: report.telemetryNotAccepted,
1527
1583
  }, { kind: "attempt" });
1528
1584
  attempt.stage = "completed";
@@ -1538,7 +1594,7 @@ async function runDirect(run, connection) {
1538
1594
  experimentId,
1539
1595
  subjectIds: report.subjectIds,
1540
1596
  runUrl,
1541
- extra: { mode: "direct", deferredScorerVersionIds: report.deferredScorerVersionIds },
1597
+ extra: { mode: kind, deferredScorerVersionIds: report.deferredScorerVersionIds },
1542
1598
  telemetryNotAccepted: report.telemetryNotAccepted,
1543
1599
  }, run.baselineId, output, signal, run.judging);
1544
1600
  }
@@ -112,8 +112,11 @@ export interface RunExperimentOptions extends RunnerOptions {
112
112
  signal?: AbortSignal;
113
113
  /** Rerun, as a new attempt, a case that a process which died on this machine left running.
114
114
  * Only for targets that give every attempt a fresh world: elsewhere the dead run's agent may
115
- * still be acting, so such a case stays uncertain. */
116
- rerunCrashedCases?: boolean;
115
+ * still be acting, so such a case stays uncertain. A predicate decides per frozen case, for a
116
+ * target that gives a fresh world only to the cases that pin one: those rerun, the others stay
117
+ * uncertain. The crash is recorded in the case's checkpoint before the case is read, so a read
118
+ * that fails leaves the decision to the next invocation. */
119
+ rerunCrashedCases?: boolean | ((item: ExperimentCase) => boolean);
117
120
  /** Experiment to run; its dataset version must be frozen. */
118
121
  experimentId: string;
119
122
  /** Whether each case waits for acknowledged trace export or explicitly omits evidence. Required. */
@@ -442,6 +442,24 @@ export async function runExperiment(options) {
442
442
  .map((version) => version.id),
443
443
  };
444
444
  try {
445
+ // A run that reclaimed a dead process's lock first records that a crash sweep is due, then
446
+ // marks every case that process left running as crashed, before anything else happens and
447
+ // under the same release as the rest. The record outlives this run: a sweep that stops part
448
+ // way (a write the disk refuses) is finished by the next run, which holds no reclaimed lock
449
+ // but reads the record, so each case's recovery decision survives, and a failure further on
450
+ // (a case read Hue refuses stops the run) leaves it to the next run the same way.
451
+ const sweep = "crash-sweep";
452
+ if (store.reclaimed)
453
+ await store.write(sweep, { pending: true });
454
+ if ((await store.read(sweep))?.pending) {
455
+ for (const item of items) {
456
+ const file = `case-${uuid(item.id)}`;
457
+ const checkpoint = await store.read(file).catch(() => undefined);
458
+ if (checkpoint?.stage === "running" && !checkpoint.crashed)
459
+ await store.write(file, { ...checkpoint, crashed: true });
460
+ }
461
+ await store.write(sweep, { pending: false });
462
+ }
445
463
  const filesRoot = resolve(options.filesDirectory ?? join(options.checkpointDirectory, "files"));
446
464
  const sanitize = (message) => message.slice(0, 4000).toWellFormed().replaceAll("\u0000", "");
447
465
  // A target stopped at its world's deadline is a `TargetTimeout`, told apart from an error the
@@ -539,10 +557,18 @@ export async function runExperiment(options) {
539
557
  files.keepOutputs = checkpoint?.stage === "uploading";
540
558
  if (checkpoint && "attempt" in checkpoint)
541
559
  files.directory = worldDirectoryOf(item.id, checkpoint.attempt);
560
+ // The frozen case, read once per case: the crash decision below and the start need it.
561
+ let frozen;
562
+ const frozenCaseOf = async () => (frozen ??= await options.client.getExperimentCase(experiment.id, item.id));
542
563
  let retryOf;
543
- // A case left running by a process that died here is as interrupted as one the caller
544
- // stopped; a fresh world isolates it from anything the dead run's agent still does.
545
- if (checkpoint?.stage === "running" && store.reclaimed && options.rerunCrashedCases) {
564
+ // A case left running by a process that died here (recorded above, before anything was
565
+ // read) is as interrupted as one the caller stopped; a fresh world isolates it from
566
+ // anything the dead run's agent still does.
567
+ if (checkpoint?.stage === "running" &&
568
+ checkpoint.crashed &&
569
+ options.rerunCrashedCases &&
570
+ (typeof options.rerunCrashedCases !== "function" ||
571
+ options.rerunCrashedCases(structuredClone(await frozenCaseOf())))) {
546
572
  checkpoint = { ...checkpoint, stage: "interrupted" };
547
573
  await store.write(file, checkpoint);
548
574
  }
@@ -572,7 +598,7 @@ export async function runExperiment(options) {
572
598
  // second invocation. Metadata-only mode discarded the output, so its outcome is lost.
573
599
  if (checkpoint.hasOutput && checkpoint.output === undefined)
574
600
  throw new UncertainExecutionError(item.id, checkpoint.executionId);
575
- const frozenCase = await options.client.getExperimentCase(experiment.id, item.id);
601
+ const frozenCase = await frozenCaseOf();
576
602
  checkpoint = await prepare(file, checkpoint, frozenCase, caseDirectoryOf(frozenCase), checkpoint.output);
577
603
  files.keepOutputs = false;
578
604
  }
@@ -581,7 +607,7 @@ export async function runExperiment(options) {
581
607
  throw new TargetCancelledError();
582
608
  if (item.execution && item.execution.id !== retryOf)
583
609
  throw new UncertainExecutionError(item.id, item.execution.id);
584
- const frozenCase = await options.client.getExperimentCase(experiment.id, item.id);
610
+ const frozenCase = await frozenCaseOf();
585
611
  if (frozenCase.datasetVersionId !== version.id)
586
612
  throw new Error("Case is not from the pinned dataset version");
587
613
  // Validate before creating a remote execution. SDK/input failures are not
@@ -4,6 +4,7 @@ import type { EnvironmentTool } from "../world/tools.js";
4
4
  import type { EnvironmentIdentity, PublishableEnvironmentDefinition, WorldHandoff } from "../world/types.js";
5
5
  import { type EvaluationClient } from "./client.js";
6
6
  import type { ActualAgentManifestInputV2, AttemptConnectionBundleV2, RequestedAttemptProviderV2 } from "./attempt.js";
7
+ import { type EnvironmentTargetContext } from "./environment-target.js";
7
8
  import { type RunnerReport, type TelemetryNotAccepted } from "./runner.js";
8
9
  import type { ExperimentCase, Identity, JsonValue, LocalFile, LocalScorer, ScorerDefinition, SimulationMcpCapability, TargetResult } from "./types.js";
9
10
  /** Repository-authored scorer identity and its public definition or local binding. */
@@ -222,6 +223,9 @@ export interface SimulationReport extends RunnerReport {
222
223
  * @deprecated Use {@link SimulationDefinition}.
223
224
  */
224
225
  export type SimulationScenario = SimulationDefinition;
226
+ /** The candidate-visible context of a world case, cloned from the environment target's: the
227
+ * frozen case's identity alone, never its expected outcome, metadata or source pins. */
228
+ export declare function simulationTargetContext(context: EnvironmentTargetContext): SimulationTargetContext;
225
229
  /** Run an existing agent callback against one fresh hosted world per case. The helper
226
230
  * owns immutable resolution, execution linkage, finalization, scoring and resumable uploads.
227
231
  */
@@ -344,6 +344,29 @@ async function resolveExperiment(options, definition, idempotencyKey) {
344
344
  };
345
345
  }
346
346
  let scenarioDeprecationWarned = false;
347
+ /** The candidate-visible context of a world case, cloned from the environment target's: the
348
+ * frozen case's identity alone, never its expected outcome, metadata or source pins. */
349
+ export function simulationTargetContext(context) {
350
+ return {
351
+ config: structuredClone(context.config),
352
+ item: { id: context.item.id, externalKey: context.item.externalKey },
353
+ executionId: context.executionId,
354
+ environmentRunId: context.environmentRunId,
355
+ tools: context.tools,
356
+ ...(context.world ? { world: structuredClone(context.world) } : {}),
357
+ ...(context.mcp
358
+ ? {
359
+ mcp: { url: context.mcp.url, token: context.mcp.token, expiresAt: context.mcp.expiresAt },
360
+ }
361
+ : {}),
362
+ ...(context.connectionBundle
363
+ ? { connectionBundle: structuredClone(context.connectionBundle) }
364
+ : {}),
365
+ files: structuredClone(context.files),
366
+ outputDirectory: context.outputDirectory,
367
+ signal: context.signal,
368
+ };
369
+ }
347
370
  /** Run an existing agent callback against one fresh hosted world per case. The helper
348
371
  * owns immutable resolution, execution linkage, finalization, scoring and resumable uploads.
349
372
  */
@@ -462,32 +485,7 @@ export async function runSimulation(options) {
462
485
  executionId: context.executionId,
463
486
  caseId: context.item.id,
464
487
  }),
465
- target: (targetInputs, targetContext) => options.target(structuredClone(targetInputs), {
466
- config: structuredClone(targetContext.config),
467
- item: {
468
- id: targetContext.item.id,
469
- externalKey: targetContext.item.externalKey,
470
- },
471
- executionId: targetContext.executionId,
472
- environmentRunId: targetContext.environmentRunId,
473
- tools: targetContext.tools,
474
- ...(targetContext.world ? { world: structuredClone(targetContext.world) } : {}),
475
- ...(targetContext.mcp
476
- ? {
477
- mcp: {
478
- url: targetContext.mcp.url,
479
- token: targetContext.mcp.token,
480
- expiresAt: targetContext.mcp.expiresAt,
481
- },
482
- }
483
- : {}),
484
- ...(targetContext.connectionBundle
485
- ? { connectionBundle: structuredClone(targetContext.connectionBundle) }
486
- : {}),
487
- files: structuredClone(targetContext.files),
488
- outputDirectory: targetContext.outputDirectory,
489
- signal: targetContext.signal,
490
- }),
488
+ target: (targetInputs, targetContext) => options.target(structuredClone(targetInputs), simulationTargetContext(targetContext)),
491
489
  }),
492
490
  });
493
491
  const complete = { ...report, experimentId, runUrl };
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Package version shared by the instrumentation scope and the export User-Agent. */
2
- export declare const sdkVersion = "0.20.2";
2
+ export declare const sdkVersion = "0.20.3";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/write-version.mjs from package.json; do not edit by hand.
2
2
  /** Package version shared by the instrumentation scope and the export User-Agent. */
3
- export const sdkVersion = "0.20.2";
3
+ export const sdkVersion = "0.20.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hue-run/sdk",
3
- "version": "0.20.2",
3
+ "version": "0.20.3",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "publishConfig": {