@hue-run/sdk 0.15.2 → 0.17.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.
@@ -213,6 +213,10 @@ async function allPages(page, maximum = 5000) {
213
213
  * world that cannot be sealed), telemetry not accepted by the one transport the cases share, or
214
214
  * a target outcome left uncertain because its world could not be sealed or read back. Starting
215
215
  * more cases into it only piles up the same failure; the run is resumed later instead. */
216
+ /** A new attempt replacing an interrupted one, which Hue still holds as started. */
217
+ function retry(previousExecutionId) {
218
+ return previousExecutionId ? { previousExecutionId, allowUncertainRetry: true } : {};
219
+ }
216
220
  function systemic(error) {
217
221
  // A saved outcome still waiting for its trace (`TraceExportUnacknowledgedError`) is not
218
222
  // systemic: the flush failed on an earlier attempt, telemetry may have recovered since, and
@@ -230,6 +234,7 @@ async function pool(items, concurrency, execute) {
230
234
  let position = 0;
231
235
  const failures = [];
232
236
  let stop = false;
237
+ let cancelled;
233
238
  await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
234
239
  while (position < items.length && !stop) {
235
240
  const item = items[position++];
@@ -237,12 +242,20 @@ async function pool(items, concurrency, execute) {
237
242
  await execute(item);
238
243
  }
239
244
  catch (error) {
245
+ // The caller's stop: start nothing more, and report the stop once the cases in flight settle.
246
+ if (error instanceof TargetCancelledError) {
247
+ cancelled = error;
248
+ stop = true;
249
+ continue;
250
+ }
240
251
  failures.push(error);
241
252
  if (systemic(error))
242
253
  stop = true;
243
254
  }
244
255
  }
245
256
  }));
257
+ if (cancelled)
258
+ throw cancelled;
246
259
  if (failures.length === 1)
247
260
  throw failures[0];
248
261
  if (failures.length) {
@@ -498,17 +511,19 @@ export async function runExperiment(options) {
498
511
  scores,
499
512
  exportState: "pending",
500
513
  ...(saved.trace ? { trace: saved.trace } : {}),
514
+ ...(saved.attempt ? { attempt: saved.attempt } : {}),
501
515
  };
502
516
  await store.write(file, prepared);
503
517
  return prepared;
504
518
  }
505
519
  // A case pinned to a world keeps its files apart and removes them when it ends: the agent's
506
520
  // copies of its inputs, its work, the evaluator's downloads and the staged outputs.
507
- const worldDirectoryOf = (itemId) => join(filesRoot, `world-case-${uuid(itemId)}`);
521
+ // A retry gets its own, so an agent a dead run left behind cannot write into it.
522
+ const worldDirectoryOf = (itemId, attempt) => join(filesRoot, `world-case-${uuid(itemId)}${attempt ? `-${uuid(attempt)}` : ""}`);
508
523
  const runCase = async (item, files) => {
509
524
  const file = `case-${uuid(item.id)}`;
510
- const worldDirectory = worldDirectoryOf(item.id);
511
- const caseDirectoryOf = (frozen) => frozen.environmentVersionId ? worldDirectory : join(filesRoot, `case-${uuid(item.id)}`);
525
+ const directDirectory = join(filesRoot, `case-${uuid(item.id)}`);
526
+ const caseDirectoryOf = (frozen) => frozen.environmentVersionId ? files.directory : directDirectory;
512
527
  let checkpoint;
513
528
  try {
514
529
  checkpoint = await store.read(file);
@@ -521,6 +536,24 @@ export async function runExperiment(options) {
521
536
  throw error;
522
537
  }
523
538
  files.keepOutputs = checkpoint?.stage === "uploading";
539
+ if (checkpoint && "attempt" in checkpoint)
540
+ files.directory = worldDirectoryOf(item.id, checkpoint.attempt);
541
+ let retryOf;
542
+ // A case left running by a process that died here is as interrupted as one the caller
543
+ // stopped; a fresh world isolates it from anything the dead run's agent still does.
544
+ if (checkpoint?.stage === "running" && store.reclaimed && options.rerunCrashedCases) {
545
+ checkpoint = { ...checkpoint, stage: "interrupted" };
546
+ await store.write(file, checkpoint);
547
+ }
548
+ if (checkpoint?.stage === "interrupted") {
549
+ retryOf = checkpoint.executionId;
550
+ checkpoint = undefined;
551
+ // A direct case's directory goes too, so the retry finds no input copy or output of the
552
+ // stopped attempt.
553
+ await removeCaseFiles(files.directory);
554
+ await removeCaseFiles(directDirectory);
555
+ files.directory = worldDirectoryOf(item.id, retryOf);
556
+ }
524
557
  if (checkpoint && checkpoint.stage !== "prepared" && checkpoint.stage !== "uploading") {
525
558
  if (checkpoint.stage === "serialization_failed")
526
559
  throw new OutcomeSerializationError(checkpoint.executionId);
@@ -528,6 +561,7 @@ export async function runExperiment(options) {
528
561
  ? await options.client.startExecution(experiment.id, item.id, {
529
562
  idempotencyKey: checkpoint.startKey,
530
563
  traceExternalId: checkpoint.traceExternalId,
564
+ ...retry(checkpoint.previousExecutionId),
531
565
  })
532
566
  : await options.client.getExecution(checkpoint.executionId);
533
567
  throw new UncertainExecutionError(item.id, execution.id);
@@ -542,7 +576,9 @@ export async function runExperiment(options) {
542
576
  files.keepOutputs = false;
543
577
  }
544
578
  if (!checkpoint) {
545
- if (item.execution)
579
+ if (options.signal?.aborted)
580
+ throw new TargetCancelledError();
581
+ if (item.execution && item.execution.id !== retryOf)
546
582
  throw new UncertainExecutionError(item.id, item.execution.id);
547
583
  const frozenCase = await options.client.getExperimentCase(experiment.id, item.id);
548
584
  if (frozenCase.datasetVersionId !== version.id)
@@ -574,13 +610,16 @@ export async function runExperiment(options) {
574
610
  stage: "starting",
575
611
  startKey: randomUUID(),
576
612
  traceExternalId: span.traceId,
613
+ ...(retryOf ? { previousExecutionId: retryOf } : {}),
577
614
  };
578
615
  await store.write(file, start);
579
616
  const execution = await options.client.startExecution(experiment.id, item.id, {
580
617
  idempotencyKey: start.startKey,
581
618
  traceExternalId: span.traceId,
619
+ ...retry(retryOf),
582
620
  });
583
- await store.write(file, { stage: "running", executionId: execution.id });
621
+ const attempt = retryOf ? { attempt: retryOf } : {};
622
+ await store.write(file, { stage: "running", executionId: execution.id, ...attempt });
584
623
  let state = "succeeded";
585
624
  let output;
586
625
  let generated;
@@ -604,6 +643,17 @@ export async function runExperiment(options) {
604
643
  catch (error) {
605
644
  if (error instanceof TargetOutcomeUncertainError)
606
645
  throw error;
646
+ // A stop the caller asked for is no outcome: the execution stays open for the
647
+ // resumed run to replace instead of being scored as cancelled.
648
+ if (error instanceof TargetCancelledError && options.signal?.aborted) {
649
+ options.hue.recordError(span.span, error);
650
+ await store.write(file, {
651
+ stage: "interrupted",
652
+ executionId: execution.id,
653
+ ...attempt,
654
+ });
655
+ throw error;
656
+ }
607
657
  state = error instanceof TargetCancelledError ? "cancelled" : "error";
608
658
  targetError = error;
609
659
  options.hue.recordError(span.span, error);
@@ -659,6 +709,7 @@ export async function runExperiment(options) {
659
709
  ...(options.persistResultContent && output !== undefined ? { output } : {}),
660
710
  ...(state === "error" ? { error: errorPayload(targetError) } : {}),
661
711
  files: staged,
712
+ ...attempt,
662
713
  };
663
714
  // Without persisted result content a restart cannot reconstruct the outcome; the
664
715
  // saved stage then reports the execution as uncertain instead of guessing.
@@ -838,21 +889,23 @@ export async function runExperiment(options) {
838
889
  // A world case's directory goes however the case ends, a forced exit included. An
839
890
  // interrupted upload keeps only the staged outputs it resumes from; inputs are downloaded
840
891
  // anew for scoring. A direct case has no such directory, so nothing is removed for it.
841
- const worldDirectory = worldDirectoryOf(item.id);
842
- const files = { keepOutputs: true };
843
- const untrack = onForcedExit(() => releaseWorldFilesSync(worldDirectory, files.keepOutputs));
892
+ const files = { keepOutputs: true, directory: worldDirectoryOf(item.id) };
893
+ const untrack = onForcedExit(() => releaseWorldFilesSync(files.directory, files.keepOutputs));
844
894
  try {
845
895
  await runCase(item, files);
846
- await releaseWorldFiles(worldDirectory, false);
896
+ await releaseWorldFiles(files.directory, false);
847
897
  }
848
898
  catch (error) {
849
- await releaseWorldFiles(worldDirectory, files.keepOutputs);
899
+ await releaseWorldFiles(files.directory, files.keepOutputs);
850
900
  throw error;
851
901
  }
852
902
  finally {
853
903
  untrack();
854
904
  }
855
905
  });
906
+ // A stop that came while the last cases saved their outcomes still leaves the run open.
907
+ if (options.signal?.aborted)
908
+ throw new TargetCancelledError();
856
909
  let finish = await store.read("finish");
857
910
  if (!finish) {
858
911
  finish = { key: randomUUID() };
@@ -1,4 +1,4 @@
1
- import { z } from "zod";
1
+ import { z } from "zod/v4";
2
2
  import { json, digest } from "./json.js";
3
3
  const metricName = z
4
4
  .string()
@@ -391,7 +391,7 @@ export async function runSimulation(options) {
391
391
  attempt.stage !== "completed" &&
392
392
  attempt.scenarioDigest !== scenarioDigest &&
393
393
  attempt.scenarioDigest !== previousDigest)
394
- throw new Error("Recover the unfinished simulation before running a changed scenario or agent revision");
394
+ throw new Error("Recover the unfinished simulation before running a changed scenario or agent revision: run the unchanged one again to finish it, or use another checkpoint directory");
395
395
  if (!attempt || attempt.stage === "completed") {
396
396
  attempt = {
397
397
  scenarioDigest,
@@ -417,12 +417,14 @@ export async function runSimulation(options) {
417
417
  ];
418
418
  }
419
419
  const experimentId = checkpointSegment(attempt.experimentId, "experiment id");
420
- const runUrl = new URL(`/experiments/${experimentId}`, options.client.baseUrl).toString();
420
+ const runUrl = new URL(`/runs/${experimentId}`, options.client.baseUrl).toString();
421
421
  await options.onProgress?.({ type: "run_created", experimentId, runUrl });
422
422
  const requested = requestedConfiguration
423
423
  ? pinRequestedAttemptV2(requestedConfiguration, (await options.client.getExperiment(experimentId)).config)
424
424
  : undefined;
425
425
  const report = await runExperiment({
426
+ signal: options.signal,
427
+ rerunCrashedCases: true,
426
428
  client: options.client,
427
429
  hue: options.hue,
428
430
  experimentId,
@@ -1,6 +1,6 @@
1
- /** Inline file content larger than this many UTF-8 bytes is exported as its digest instead. */
1
+ /** Text media above this decoded byte limit is offloaded; binary media always uses a digest. */
2
2
  export declare const INLINE_FILE_LIMIT: number;
3
- /** The longest message text, in UTF-8 bytes, inspected for inline files. */
3
+ /** The longest message text, in UTF-16 code units, inspected for inline files. */
4
4
  export declare const MAX_INLINE_FILE_TEXT: number;
5
5
  /** Message text one record inspects for inline files in all, in UTF-16 code units. */
6
6
  export declare const INLINE_FILE_TEXT_PER_RECORD: number;
@@ -20,38 +20,47 @@ export interface InlineFileBytes {
20
20
  * `data:` URL whose data does not decode is taken as UTF-8 too.
21
21
  */
22
22
  export declare function inlineFileBytes(content: string): InlineFileBytes;
23
- /** The key holding a part's inline content: GenAI `blob` parts and AI SDK 6 `file` parts. */
24
- export declare function contentKey(part: Record<string, unknown>): "content" | "data" | undefined;
25
- /**
26
- * Replaces inline files longer than {@link INLINE_FILE_LIMIT} in a recorded message attribute
27
- * with their SHA-256 and byte size, so a span that inlines a large file exports the file's
28
- * identity instead of being rejected for its size. The part keeps its other fields (`type`,
29
- * `mime_type`, `modality`, `mediaType`, …). Other attributes, shorter messages and values that
30
- * are not JSON are returned unchanged.
31
- */
32
- export declare function hashInlineFiles(key: string, value: unknown,
33
- /** Counts the files replaced by their digest, when given. */
34
- count?: {
35
- files: number;
36
- }): unknown;
37
- /** One inline file part found in a parsed message: the part object, the key holding its inline
38
- * content, that content, and the part's content as a JSON Pointer (RFC 6901) into the message. */
23
+ /** A part and the exact leaf that contains its bytes. Nested provider metadata stays in place. */
39
24
  export interface InlineFilePart {
40
25
  part: Record<string, unknown>;
41
- key: "content" | "data";
26
+ owner: Record<string, unknown>;
27
+ key: string;
42
28
  content: string;
43
29
  pointer: string;
30
+ partPointer: string;
31
+ contentType?: string;
32
+ name?: string;
33
+ base64?: boolean;
44
34
  }
45
- /**
46
- * The inline file parts of a parsed message whose content text is longer than
47
- * {@link INLINE_FILE_LIMIT} UTF-8 bytes, in document order: the caller decodes each and keeps the
48
- * ones whose file bytes are over the limit. The parts are the parsed value's own objects, so the
49
- * caller may replace their content in place.
50
- */
35
+ export declare function isMediaName(value: unknown): value is string;
36
+ /** Whether the receiver can preview these bytes as text. */
37
+ export declare function isTextMediaType(contentType: string): boolean;
38
+ /** Cheap shared prefilter before bounded parsing of span message attributes. */
39
+ export declare function mayInlineFiles(key: string, value: unknown): boolean;
40
+ /** A printable MIME type with optional parameters, accepted by the upload contract. */
41
+ export declare function isMediaContentType(value: unknown): value is string;
42
+ /** Detect the content pointer, MIME, name, and encoding contract of a provider media part. */
43
+ export declare function contentPath(part: Record<string, unknown>, pointer?: string): InlineFilePart | undefined;
44
+ /** Inline media in document order. The caller applies the decoded text threshold. */
51
45
  export declare function largeInlineFileParts(value: unknown): InlineFilePart[];
52
- /** The digest an inline file part is exported as when its bytes are not uploaded: the part's
53
- * other fields with the file's SHA-256 and byte size, as {@link hashInlineFiles} writes it. */
54
- export declare function digestPart(part: Record<string, unknown>, key: "content" | "data", digest: {
46
+ export declare function mediaBytes(file: InlineFilePart): InlineFileBytes;
47
+ export declare function mediaContentType(file: InlineFilePart, decoded: InlineFileBytes): string;
48
+ export interface FileDigest {
55
49
  sha256: string;
56
50
  size: number;
57
- }): void;
51
+ }
52
+ /** Bounded payload identity memo, shared by admission and export, cleared at shutdown. */
53
+ export declare class MediaDigestMemo {
54
+ private entries;
55
+ private units;
56
+ clear(): void;
57
+ digest(file: InlineFilePart, decoded?: InlineFileBytes, traceId?: string): FileDigest;
58
+ bytes(bytes: Uint8Array, traceId: string): FileDigest;
59
+ }
60
+ /** Digest placement removes only the leaf and adds identity to the provider part. */
61
+ export declare function digestPart(file: InlineFilePart, digest: FileDigest): void;
62
+ export declare function hashInlineFiles(key: string, value: unknown, count?: {
63
+ files: number;
64
+ }, memo?: MediaDigestMemo, traceId?: string): unknown;
65
+ /** Nested message attributes retain small media and digest larger media without upload state. */
66
+ export declare function hashNestedInlineFiles(key: string, value: unknown): unknown;