humanish 0.146.0 → 0.147.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 (52) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/actors/computer-use/loop/spend.js +5 -4
  3. package/dist/actors/computer-use/loop/trace.js +3 -0
  4. package/dist/actors/computer-use/loop/types.d.ts +5 -0
  5. package/dist/actors/computer-use/loop/usage.js +10 -2
  6. package/dist/actors/computer-use/openai-conversation.d.ts +7 -0
  7. package/dist/actors/computer-use/openai-conversation.js +19 -2
  8. package/dist/actors/computer-use/openai-provider.d.ts +2 -0
  9. package/dist/actors/computer-use/openai-provider.js +2 -0
  10. package/dist/actors/computer-use/openai-wire.d.ts +4 -0
  11. package/dist/actors/computer-use/openai-wire.js +9 -1
  12. package/dist/actors/contract.d.ts +5 -0
  13. package/dist/analysis/evidence.js +8 -4
  14. package/dist/analysis/image-tokens.js +9 -3
  15. package/dist/evidence/image.d.ts +2 -0
  16. package/dist/evidence/image.js +24 -3
  17. package/dist/evidence/webp-encode-worker.d.ts +1 -0
  18. package/dist/evidence/webp-encode-worker.js +31 -0
  19. package/dist/evidence/webp-transcoder.d.ts +15 -0
  20. package/dist/evidence/webp-transcoder.js +98 -0
  21. package/dist/evidence/webp.d.ts +31 -0
  22. package/dist/evidence/webp.js +94 -0
  23. package/dist/feedback/derivative-text.d.ts +7 -2
  24. package/dist/feedback/derivative-text.js +20 -1
  25. package/dist/feedback/export-bundle.js +79 -25
  26. package/dist/remote/launch.js +9 -4
  27. package/dist/routes/computer-use/participant-execution.d.ts +0 -7
  28. package/dist/routes/computer-use/participant-execution.js +6 -28
  29. package/dist/routes/computer-use/participant-frames.d.ts +32 -0
  30. package/dist/routes/computer-use/participant-frames.js +93 -0
  31. package/dist/routes/computer-use/participant-model.js +3 -0
  32. package/dist/routes/computer-use/participant-runs.js +4 -0
  33. package/dist/routes/computer-use/plan.js +5 -1
  34. package/dist/routes/computer-use/result.js +6 -0
  35. package/dist/routes/computer-use/types.d.ts +4 -0
  36. package/dist/routes/shared-world/plan.js +4 -1
  37. package/dist/run/pricing.d.ts +7 -0
  38. package/dist/run/pricing.js +58 -15
  39. package/dist/study/config.js +4 -1
  40. package/dist/study/keys.d.ts +1 -0
  41. package/dist/study/keys.js +1 -0
  42. package/dist/study/parse/actors.js +5 -0
  43. package/dist/study/plan-participants.d.ts +2 -0
  44. package/dist/study/plan-participants.js +2 -0
  45. package/dist/study/types.d.ts +7 -0
  46. package/dist/study/validation.d.ts +5 -0
  47. package/dist/study/validation.js +23 -0
  48. package/dist/verify/artifacts.js +2 -1
  49. package/docs/contracts/run-bundle.md +29 -11
  50. package/docs/contracts/schemas.md +19 -11
  51. package/docs/contracts/study-analysis.md +2 -2
  52. package/package.json +2 -1
@@ -0,0 +1,94 @@
1
+ // Lossless WebP screenshots: the file shape verify accepts, and a bounded decode for the readers
2
+ // that need pixels. Only the simple lossless form is accepted: a RIFF container holding one VP8L
3
+ // chunk. The extended form (VP8X) is where EXIF, XMP, ICC and animation chunks live, so a frame
4
+ // in the simple form carries image data only. The VP8L bitstream itself is not proven consumed,
5
+ // so a WebP frame is never redacted evidence: a blurred frame is the PNG redactScreenshot writes.
6
+ import { readFile } from "node:fs/promises";
7
+ import { createRequire } from "node:module";
8
+ import decodeWebpImage, { init as initWebpDecoder } from "@jsquash/webp/decode.js";
9
+ const RIFF_HEADER_BYTES = 12;
10
+ const CHUNK_HEADER_BYTES = 8;
11
+ const VP8L_SIGNATURE = 0x2f;
12
+ /** The VP8L header: its signature byte, then 14-bit width and height, an alpha hint, a version. */
13
+ const VP8L_HEADER_BYTES = 5;
14
+ /**
15
+ * Why `bytes` is not a simple-format lossless WebP, or null with its dimensions when it is: RIFF
16
+ * and WEBP, then one VP8L chunk that ends the file (with its pad byte, zero, when its size is
17
+ * odd), whose header has the VP8L signature and version 0.
18
+ */
19
+ export function webpStructure(bytes) {
20
+ const failed = (error) => ({ error });
21
+ if (bytes.length < RIFF_HEADER_BYTES + CHUNK_HEADER_BYTES + VP8L_HEADER_BYTES ||
22
+ bytes.toString("latin1", 0, 4) !== "RIFF" ||
23
+ bytes.toString("latin1", 8, 12) !== "WEBP")
24
+ return failed("expected a RIFF WEBP file");
25
+ if (bytes.readUInt32LE(4) !== bytes.length - 8)
26
+ return failed("the RIFF size does not match the file size");
27
+ const chunk = bytes.toString("latin1", 12, 16);
28
+ if (chunk !== "VP8L")
29
+ return failed(`WebP evidence must be one lossless VP8L chunk, not ${/^[A-Za-z0-9 ]{4}$/.test(chunk) ? chunk.trim() : "a malformed chunk"}`);
30
+ const size = bytes.readUInt32LE(16);
31
+ const start = RIFF_HEADER_BYTES + CHUNK_HEADER_BYTES;
32
+ const end = start + size + (size % 2);
33
+ if (end !== bytes.length || (size % 2 === 1 && bytes[end - 1] !== 0))
34
+ return failed("the VP8L chunk must end the file");
35
+ if (size < VP8L_HEADER_BYTES)
36
+ return failed("the VP8L chunk has no VP8L header");
37
+ const dimensions = webpHeaderDimensions(bytes);
38
+ if (dimensions === undefined)
39
+ return failed("the VP8L header is not one this reader knows");
40
+ return { error: null, dimensions };
41
+ }
42
+ /** The bytes webpHeaderDimensions reads: the RIFF header, the VP8L chunk header, the VP8L header. */
43
+ export const WEBP_HEADER_BYTES = RIFF_HEADER_BYTES + CHUNK_HEADER_BYTES + VP8L_HEADER_BYTES;
44
+ /**
45
+ * Width and height from the first WEBP_HEADER_BYTES of a simple-format lossless WebP: a VP8L
46
+ * signature and version 0. Undefined for anything else.
47
+ */
48
+ export function webpHeaderDimensions(header) {
49
+ const start = RIFF_HEADER_BYTES + CHUNK_HEADER_BYTES;
50
+ if (header.length < WEBP_HEADER_BYTES ||
51
+ header.toString("latin1", 0, 4) !== "RIFF" ||
52
+ header.toString("latin1", 8, 16) !== "WEBPVP8L" ||
53
+ header[start] !== VP8L_SIGNATURE)
54
+ return undefined;
55
+ const bits = header.readUInt32LE(start + 1);
56
+ if (bits >>> 29 !== 0)
57
+ return undefined;
58
+ return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
59
+ }
60
+ let decoderReady;
61
+ /** Loads the decoder from the package's own wasm file: the codec's default fetches it by URL. */
62
+ function readyDecoder() {
63
+ // The codec builds its result with ImageData, which Node does not define.
64
+ const scope = globalThis;
65
+ scope.ImageData ??= class {
66
+ data;
67
+ width;
68
+ height;
69
+ constructor(data, width, height) {
70
+ this.data = data;
71
+ this.width = width;
72
+ this.height = height;
73
+ }
74
+ };
75
+ decoderReady ??= readFile(createRequire(import.meta.url).resolve("@jsquash/webp/codec/dec/webp_dec.wasm"))
76
+ .then((wasm) => WebAssembly.compile(wasm))
77
+ .then((module) => initWebpDecoder(module));
78
+ return decoderReady;
79
+ }
80
+ /**
81
+ * The RGBA pixels of a WebP frame that webpStructure accepts, whose dimensions the caller has
82
+ * checked against its pixel cap; the decoder allocates from them.
83
+ */
84
+ export async function decodeWebp(bytes) {
85
+ await readyDecoder();
86
+ const copy = new Uint8Array(bytes.length);
87
+ copy.set(bytes);
88
+ const image = await decodeWebpImage(copy.buffer);
89
+ return {
90
+ width: image.width,
91
+ height: image.height,
92
+ data: Buffer.from(image.data.buffer, image.data.byteOffset, image.data.byteLength),
93
+ };
94
+ }
@@ -5,8 +5,13 @@ interface TextFile {
5
5
  bytes: Buffer;
6
6
  }
7
7
  export declare function jsonBytes(value: unknown): Buffer;
8
- export declare function transformText(file: TextFile, imagePaths: ReadonlySet<string>): Buffer;
8
+ /**
9
+ * Each kept image's source path and the path the copy holds it at; they differ for a WebP frame,
10
+ * which the copy holds as a blurred PNG.
11
+ */
12
+ type ImagePaths = ReadonlyMap<string, string>;
13
+ export declare function transformText(file: TextFile, imagePaths: ImagePaths): Buffer;
9
14
  export declare function assertRewritableNumbers(json: string): void;
10
15
  /** The loaded source bundle as the copy records it: screenshots declared and labelled blurred. */
11
- export declare function derivedBundle(bundle: RunBundle, imagePaths: ReadonlySet<string>): RunBundle;
16
+ export declare function derivedBundle(bundle: RunBundle, imagePaths: ImagePaths): RunBundle;
12
17
  export {};
@@ -62,7 +62,10 @@ function updateScreenshotDeclarations(value, imagePaths) {
62
62
  if (typeof ref.path !== "string" || !imagePaths.has(ref.path)) {
63
63
  throw new Error("Actor screenshot reference must resolve to a PNG transformed from this run's inventory.");
64
64
  }
65
- return { ...entry, screenshotRef: { ...ref, redaction: "blurred" } };
65
+ return {
66
+ ...entry,
67
+ screenshotRef: { ...ref, path: imagePaths.get(ref.path), redaction: "blurred" },
68
+ };
66
69
  });
67
70
  }
68
71
  }
@@ -120,11 +123,27 @@ export function derivedBundle(bundle, imagePaths) {
120
123
  status: "passed",
121
124
  notes: `${bundle.redaction.notes} Exported copy: screenshots were blurred during export; the original capture is retained separately. See derivation.json.`,
122
125
  };
126
+ // Only a path whose output differs is rewritten; every other cite keeps its exact spelling.
127
+ const renamed = (cite) => {
128
+ const output = imagePaths.get(path.posix.normalize(cite));
129
+ return output !== undefined && output !== path.posix.normalize(cite) ? output : cite;
130
+ };
123
131
  for (const stream of transformed.streams) {
132
+ // The stream's latest frame, as the Observer embeds it and its UI state names it.
133
+ if (stream.embed?.kind === "screenshot" && stream.embed.url !== undefined)
134
+ stream.embed.url = renamed(stream.embed.url);
135
+ if (stream.ui?.screenshotUrl !== undefined)
136
+ stream.ui.screenshotUrl = renamed(stream.ui.screenshotUrl);
124
137
  for (const artifact of stream.artifacts) {
138
+ artifact.path = renamed(artifact.path);
125
139
  if (artifact.kind === "screenshot")
126
140
  artifact.label = artifact.label.replace(/\((raw|blurred)\)/g, "(blurred at export)");
127
141
  }
128
142
  }
143
+ for (const candidate of transformed.feedbackCandidates)
144
+ for (const evidence of candidate.evidence)
145
+ evidence.path = renamed(evidence.path);
146
+ for (const artifact of transformed.adapterArtifacts ?? [])
147
+ artifact.path = renamed(artifact.path);
129
148
  return transformed;
130
149
  }
@@ -7,7 +7,11 @@ import { tmpdir } from "node:os";
7
7
  import { renderObserver } from "../observer/render.js";
8
8
  import { inventorySandboxIds, publicSandboxView, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
9
9
  import { splitStreamTraces } from "../run/stream-trace.js";
10
+ import { PNG } from "pngjs";
11
+ import { imageDimensionsError } from "../evidence/image.js";
10
12
  import { redactScreenshot, redactText } from "../evidence/redaction.js";
13
+ import { readingsOf } from "../evidence/encoded-text.js";
14
+ import { decodeWebp, webpStructure } from "../evidence/webp.js";
11
15
  import { assertRewritableNumbers, derivedBundle, jsonBytes, transformText, } from "./derivative-text.js";
12
16
  import { streamScreenshotPaths } from "../verify/artifacts.js";
13
17
  import { runEntryLimit } from "../verify/scan-limit.js";
@@ -146,7 +150,7 @@ async function prospectivePhysicalPath(requested) {
146
150
  return path.join(await prospectivePhysicalPath(parent), path.basename(requested));
147
151
  }
148
152
  }
149
- function assertFeedbackReferences(bundle, source) {
153
+ function assertFeedbackReferences(bundle, source, imagePaths) {
150
154
  const retained = new Set(source.files.filter((file) => omittedReason(file.path) === undefined).map((file) => file.path));
151
155
  retained.add("observer/index.html");
152
156
  retained.add("observer/observer-data.json");
@@ -154,14 +158,16 @@ function assertFeedbackReferences(bundle, source) {
154
158
  for (const evidence of candidate.evidence) {
155
159
  if (!retained.has(evidence.path))
156
160
  throw new Error("Feedback evidence must resolve to a retained or regenerated artifact in the derivative.");
157
- if (evidence.kind === "screenshot" && path.extname(evidence.path).toLowerCase() !== ".png") {
161
+ if (evidence.kind === "screenshot" &&
162
+ path.extname(imagePaths.get(evidence.path) ?? evidence.path).toLowerCase() !== ".png") {
158
163
  throw new Error("Feedback screenshot evidence must resolve to a transformed PNG.");
159
164
  }
160
165
  }
161
166
  }
162
167
  }
163
- /** PNG paths that run.json cites outside actor traces: feedback, adapter and stream artifacts. */
164
- function citedPngPaths(bundle) {
168
+ const IMAGE_EXTENSIONS = new Set([".png", ".webp"]);
169
+ /** Image paths that run.json cites outside actor traces: feedback, adapter and stream artifacts. */
170
+ function citedImagePaths(bundle) {
165
171
  const cited = [
166
172
  ...bundle.feedbackCandidates.flatMap((candidate) => candidate.evidence.map(({ path }) => path)),
167
173
  ...(bundle.adapterArtifacts ?? []).map((artifact) => artifact.path),
@@ -169,25 +175,38 @@ function citedPngPaths(bundle) {
169
175
  ];
170
176
  return new Set(cited
171
177
  .map((cite) => path.posix.normalize(cite))
172
- .filter((cite) => path.extname(cite).toLowerCase() === ".png"));
178
+ .filter((cite) => IMAGE_EXTENSIONS.has(path.extname(cite).toLowerCase())));
173
179
  }
174
180
  /**
175
- * The PNGs the derivative keeps. verify cannot read an image, so only one an actor trace registers
176
- * as a stream screenshot can be share_ready. A PNG nothing cites is dropped; a PNG that other
177
- * evidence cites refuses the export, since dropping it would change that evidence.
181
+ * The images the derivative keeps, each with the path the copy holds it at: a PNG at its own path,
182
+ * a WebP frame at the same path ending .png, since a blurred frame is a PNG. verify cannot read an
183
+ * image, so only one an actor trace registers as a stream screenshot can be share_ready. An image
184
+ * nothing cites is dropped; one that other evidence cites refuses the export, since dropping it
185
+ * would change that evidence.
178
186
  */
179
- function keptPngPaths(source, bundle) {
187
+ function keptImagePaths(source, bundle) {
180
188
  const streamShots = streamScreenshotPaths(bundle);
181
- const cited = citedPngPaths(bundle);
182
- const kept = new Set();
189
+ const cited = citedImagePaths(bundle);
190
+ const present = new Set(source.files.map((file) => file.path));
191
+ const kept = new Map();
192
+ const outputs = new Set();
183
193
  for (const file of source.files) {
184
- if (path.extname(file.path).toLowerCase() !== ".png")
194
+ const extension = path.extname(file.path).toLowerCase();
195
+ if (!IMAGE_EXTENSIONS.has(extension))
185
196
  continue;
186
- // A file that only claims to be a PNG may conceal a payload; refuse it rather than drop it.
187
- if (!file.bytes.subarray(0, 8).equals(PNG_MAGIC))
197
+ // A file that only claims to be an image may conceal a payload; refuse it rather than drop it.
198
+ if (extension === ".png" && !file.bytes.subarray(0, 8).equals(PNG_MAGIC))
188
199
  throw new Error("PNG artifact has invalid signature.");
200
+ if (extension === ".webp" && webpStructure(file.bytes).error !== null)
201
+ throw new Error(`WebP artifact ${file.path} is not a lossless WebP frame.`);
189
202
  if (streamShots.has(file.path)) {
190
- kept.add(file.path);
203
+ const output = extension === ".webp" ? `${file.path.slice(0, -extension.length)}.png` : file.path;
204
+ if (output !== file.path && present.has(output))
205
+ throw new Error(`${file.path} would be blurred to ${output}, which the run already holds; export refuses to overwrite it.`);
206
+ if (outputs.has(output))
207
+ throw new Error(`Two frames would be exported as ${output}.`);
208
+ outputs.add(output);
209
+ kept.set(file.path, output);
191
210
  }
192
211
  else if (cited.has(file.path)) {
193
212
  throw new Error(`Evidence cites ${file.path}, an image that is not a stream screenshot; verify cannot read it, so no export of this run can be share_ready.`);
@@ -195,9 +214,21 @@ function keptPngPaths(source, bundle) {
195
214
  }
196
215
  return kept;
197
216
  }
217
+ /** A WebP frame as the PNG redactScreenshot reads, after the same pixel cap a PNG meets. */
218
+ async function webpAsPng(bytes) {
219
+ const structure = webpStructure(bytes);
220
+ const refusal = structure.error ??
221
+ imageDimensionsError("WebP", structure.dimensions.width, structure.dimensions.height);
222
+ if (refusal !== null)
223
+ throw new Error(`WebP artifact could not be decoded safely: ${refusal}.`);
224
+ const image = await decodeWebp(bytes);
225
+ const png = new PNG({ width: image.width, height: image.height });
226
+ image.data.copy(png.data);
227
+ return PNG.sync.write(png);
228
+ }
198
229
  async function writeDerivative(source, stagePaths, bundle) {
199
230
  const entries = [];
200
- const imagePaths = keptPngPaths(source, bundle);
231
+ const imagePaths = keptImagePaths(source, bundle);
201
232
  const sandboxIds = inventorySandboxIds(source.files);
202
233
  // run.json and each trace file come from one rewritten bundle, so each trace's index binds the
203
234
  // bytes written here. A v1 source is written as v2.
@@ -206,11 +237,25 @@ async function writeDerivative(source, stagePaths, bundle) {
206
237
  ["run.json", jsonBytes(derived.bundle)],
207
238
  ...derived.files.map((file) => [file.path, file.bytes]),
208
239
  ]);
240
+ // A file that still names a WebP frame would cite a frame the copy lacks. Its file name is
241
+ // matched in every reading verify decodes (escapes, percent-encoding), so an alias such as
242
+ // `screenshots/./frame.webp` or `%66rame.webp` is caught too.
243
+ const renamedNames = [...imagePaths]
244
+ .filter(([from, to]) => from !== to)
245
+ .map(([from]) => path.posix.basename(from).toLowerCase());
246
+ const assertNoRenamedSource = (relative, bytes) => {
247
+ if (renamedNames.length === 0)
248
+ return;
249
+ const readings = readingsOf(bytes.toString("utf8")).map((reading) => reading.toLowerCase());
250
+ const named = renamedNames.find((name) => readings.some((reading) => reading.includes(name)));
251
+ if (named !== undefined)
252
+ throw new Error(`${relative} names ${named}, a WebP frame the copy holds as a blurred PNG; export cannot rewrite that reference.`);
253
+ };
209
254
  let images = 0;
210
255
  for (const file of source.files) {
211
- const isPng = path.extname(file.path).toLowerCase() === ".png";
256
+ const isImage = IMAGE_EXTENSIONS.has(path.extname(file.path).toLowerCase());
212
257
  const reason = omittedReason(file.path) ??
213
- (isPng && !imagePaths.has(file.path)
258
+ (isImage && !imagePaths.has(file.path)
214
259
  ? "unreferenced image: nothing cites it and verify cannot read it"
215
260
  : undefined);
216
261
  if (reason) {
@@ -220,10 +265,11 @@ async function writeDerivative(source, stagePaths, bundle) {
220
265
  const extension = path.extname(file.path).toLowerCase();
221
266
  let bytes;
222
267
  let action;
223
- if (extension === ".png") {
224
- if (!file.bytes.subarray(0, 8).equals(PNG_MAGIC))
268
+ const outputPath = imagePaths.get(file.path) ?? file.path;
269
+ if (extension === ".png" || extension === ".webp") {
270
+ if (extension === ".png" && !file.bytes.subarray(0, 8).equals(PNG_MAGIC))
225
271
  throw new Error("PNG artifact has invalid signature.");
226
- const redacted = redactScreenshot(file.bytes);
272
+ const redacted = redactScreenshot(extension === ".webp" ? await webpAsPng(file.bytes) : file.bytes);
227
273
  if (!redacted.decoded)
228
274
  throw new Error("PNG artifact could not be decoded safely; export refuses a placeholder.");
229
275
  bytes = redacted.buffer;
@@ -232,7 +278,7 @@ async function writeDerivative(source, stagePaths, bundle) {
232
278
  }
233
279
  else {
234
280
  if (!TEXT_EXTENSIONS.has(extension))
235
- throw new Error(`Unsupported artifact format ${extension || "(no extension)"}; bundle export supports PNG and UTF-8 text only.`);
281
+ throw new Error(`Unsupported artifact format ${extension || "(no extension)"}; bundle export supports PNG, lossless WebP frames and UTF-8 text only.`);
236
282
  bytes = transformText(file, imagePaths);
237
283
  const rewritten = derivedFiles.get(file.path);
238
284
  if (rewritten !== undefined) {
@@ -241,16 +287,24 @@ async function writeDerivative(source, stagePaths, bundle) {
241
287
  derivedFiles.delete(file.path);
242
288
  }
243
289
  bytes = scrubSandboxIdBytes(file.path, bytes, sandboxIds);
290
+ assertNoRenamedSource(file.path, bytes);
244
291
  action = bytes.equals(file.bytes) ? "copied" : "updated";
245
292
  }
246
- await writeContainedOutputFile(stagePaths, file.path, bytes);
247
- entries.push({ path: file.path, sourceSha256: file.sha256, outputSha256: hash(bytes), action });
293
+ await writeContainedOutputFile(stagePaths, outputPath, bytes);
294
+ entries.push({
295
+ path: file.path,
296
+ ...(outputPath === file.path ? {} : { outputPath }),
297
+ sourceSha256: file.sha256,
298
+ outputSha256: hash(bytes),
299
+ action,
300
+ });
248
301
  }
249
302
  // A v1 source stream with no actor trace file gets one; its content came from run.json.
250
303
  const runJson = source.files.find((file) => file.path === "run.json");
251
304
  for (const [relative, bytes] of derivedFiles) {
252
305
  if (relative === "run.json" || runJson === undefined)
253
306
  continue;
307
+ assertNoRenamedSource(relative, bytes);
254
308
  await writeContainedOutputFile(stagePaths, relative, bytes);
255
309
  entries.push({
256
310
  path: relative,
@@ -419,7 +473,7 @@ async function verifyFrozenSource(workspace, source, runId) {
419
473
  if (bundle.runId !== runId)
420
474
  throw new Error("Source run identity does not match its directory.");
421
475
  assertFinished(bundle);
422
- assertFeedbackReferences(bundle, source);
476
+ assertFeedbackReferences(bundle, source, keptImagePaths(source, bundle));
423
477
  await removeOwnedDirectory(rawSnapshot, true);
424
478
  workspace.rawSnapshot = undefined;
425
479
  return { bundle };
@@ -8,7 +8,7 @@ import { tmpdir } from "node:os";
8
8
  import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { promisify } from "node:util";
11
- import { readContainedRegularFile, writeContainedOutputFile } from "../run/contained-output.js";
11
+ import { readContainedRegularFile, removeContainedOutputFile, writeContainedOutputFile, } from "../run/contained-output.js";
12
12
  import { createRunArtifactPaths } from "../run/paths.js";
13
13
  import { reclaimRunSandboxes } from "../run/reclaim.js";
14
14
  import { pointLatestRunAt } from "../run/run.js";
@@ -141,7 +141,7 @@ export async function followRemoteRun(args) {
141
141
  // Evidence already copied while the run went on, by path, with its size then.
142
142
  const copied = new Map();
143
143
  const copy = async () => {
144
- const { files, alreadyHere } = await copyRunBack(runner, paths, runId);
144
+ const { files, alreadyHere } = await copyRunBack(runner, paths, runId, copied);
145
145
  if (alreadyHere > 0)
146
146
  say(`${alreadyHere} of its ${files} files were already here.`);
147
147
  return files;
@@ -255,9 +255,10 @@ async function readRun(runner, runId, seen, signals) {
255
255
  /**
256
256
  * Copy every file of the runner's run directory into this machine's run directory, each checked
257
257
  * against the digest the runner listed. The sandbox journal keeps this machine's lines (the
258
- * runner's own receipt) and adds the runner's (its participants').
258
+ * runner's own receipt) and adds the runner's (its participants'). A file copied while the run went
259
+ * on that the runner no longer has, a frame's PNG replaced by its WebP, is removed here too.
259
260
  */
260
- async function copyRunBack(runner, paths, runId) {
261
+ async function copyRunBack(runner, paths, runId, copiedWhileRunning) {
261
262
  const files = await listRemoteRun(runner, runId);
262
263
  let alreadyHere = 0;
263
264
  const wanted = [];
@@ -278,6 +279,10 @@ async function copyRunBack(runner, paths, runId) {
278
279
  const mine = local.status === "read" ? Buffer.from(local.bytes).toString("utf8") : "";
279
280
  await writeContainedOutputFile(paths, file.path, joinJournals(mine, Buffer.from(bytes).toString("utf8")), "utf8");
280
281
  });
282
+ const listed = new Set(files.map((file) => file.path));
283
+ for (const file of copiedWhileRunning.keys())
284
+ if (!listed.has(file))
285
+ await removeContainedOutputFile(paths, file);
281
286
  await pointLatestRunAt(paths, runId);
282
287
  return { files: files.length, alreadyHere };
283
288
  }
@@ -1,12 +1,5 @@
1
- import { type PreparedOutputRoot } from "../../run/contained-output.js";
2
1
  import type { CuaParticipantDeps, CuaParticipantPlan, CuaParticipantResult, DesktopParticipantRun, ParticipantRunOutcome } from "./types.js";
3
2
  import type { RunSubjectProvenance } from "../../run/bundle.js";
4
- /** Build a participant's writeScreenshot closure: writes under screenshots/<screenshotDir>/ and records
5
- * the relative path the trace references (screenshots/<name> at N=1; screenshots/<laneId>/<name>
6
- * at N>1). */
7
- export declare function makeParticipantWriteScreenshot(artifactRoot: PreparedOutputRoot, spec: {
8
- screenshotDir: string;
9
- }, screenshots: string[]): (name: string, bytes: Buffer) => Promise<string>;
10
3
  /** Run one participant against a prepared desktop. The adapter owns provisioning, final
11
4
  * evidence and cleanup; this runner owns the model, trace and participant outcome. */
12
5
  export declare function runCuaParticipant(spec: DesktopParticipantRun, deps: CuaParticipantDeps): Promise<ParticipantRunOutcome>;
@@ -2,36 +2,12 @@ import { scheduledParticipantDeps } from "./participant-runs.js";
2
2
  import { judgeParticipantRecords } from "../../run/judge.js";
3
3
  import { createE2BParticipantDesktop } from "./e2b-desktop/desktop.js";
4
4
  import { createInProcessDesktop } from "./in-process-desktop.js";
5
- import path from "node:path";
6
5
  import { cuaParticipantDiagnostics } from "./diagnostics.js";
7
6
  import { pastEndReason, runOnSchedule } from "../../study/arrivals.js";
8
- import { assertScreenshotEvidence, stripPngMetadataChunks } from "../../evidence/image.js";
9
7
  import { redactText, toErrorMessage } from "../../evidence/redaction.js";
10
- import { assertSafeOutputPathSegment, writeContainedOutputFile, } from "../../run/contained-output.js";
8
+ import { participantFrames } from "./participant-frames.js";
11
9
  import { participantOutcomeOk, participantFactsOf } from "./participant-facts.js";
12
10
  import { closeParticipantModel, judgeParticipantSession, participantSessionOptions, recordParticipantTrace, startParticipantModel, } from "./participant-model.js";
13
- /** Build a participant's writeScreenshot closure: writes under screenshots/<screenshotDir>/ and records
14
- * the relative path the trace references (screenshots/<name> at N=1; screenshots/<laneId>/<name>
15
- * at N>1). */
16
- export function makeParticipantWriteScreenshot(artifactRoot, spec, screenshots) {
17
- if (spec.screenshotDir) {
18
- assertSafeOutputPathSegment(spec.screenshotDir, "Screenshot participant id");
19
- }
20
- const dirParts = spec.screenshotDir ? ["screenshots", spec.screenshotDir] : ["screenshots"];
21
- const relPrefix = spec.screenshotDir
22
- ? path.posix.join("screenshots", spec.screenshotDir)
23
- : "screenshots";
24
- return async (name, bytes) => {
25
- assertSafeOutputPathSegment(name, "Screenshot name");
26
- const rel = path.posix.join(relPrefix, name);
27
- // Evidence holds image data only, whatever the executor's frames carried.
28
- const frame = stripPngMetadataChunks(bytes);
29
- await assertScreenshotEvidence(rel, frame);
30
- await writeContainedOutputFile(artifactRoot, path.join(...dirParts, name), frame);
31
- screenshots.push(rel);
32
- return rel;
33
- };
34
- }
35
11
  /** A blocked participant outcome: the pipeline gate or the study budget skipped it. */
36
12
  function skippedOutcome(spec, reason, arrival) {
37
13
  return {
@@ -65,8 +41,8 @@ export async function runCuaParticipant(spec, deps) {
65
41
  return skippedOutcome(spec, pastEnd);
66
42
  let model = {};
67
43
  const warnings = [];
68
- const screenshots = [];
69
- const writeScreenshot = makeParticipantWriteScreenshot(deps.artifactRoot, spec, screenshots);
44
+ const frames = participantFrames(deps.artifactRoot, spec, { blurred: deps.redactScreenshots });
45
+ const writeScreenshot = frames.write;
70
46
  let session;
71
47
  let sessionError;
72
48
  let providerCleanupError;
@@ -112,6 +88,7 @@ export async function runCuaParticipant(spec, deps) {
112
88
  else
113
89
  sessionError = desktopFailure;
114
90
  }
91
+ const replacedFrames = await frames.finish(session?.trace);
115
92
  if (session)
116
93
  await recordParticipantTrace(spec, deps, session, warnings);
117
94
  const { noEngagement, selfReportedBlocker, reportedFriction } = judgeParticipantSession(session, deps, warnings);
@@ -129,7 +106,8 @@ export async function runCuaParticipant(spec, deps) {
129
106
  ...(providerPolicyError === undefined ? {} : { providerPolicyError }),
130
107
  ...desktopEvidence,
131
108
  killed: released,
132
- screenshots,
109
+ screenshots: frames.screenshots,
110
+ replacedFrames,
133
111
  warnings,
134
112
  noEngagement,
135
113
  selfReportedBlocker,
@@ -0,0 +1,32 @@
1
+ import type { ActorTrace } from "../../actors/contract.js";
2
+ import { type WebpTranscoder } from "../../evidence/webp-transcoder.js";
3
+ import { type PreparedOutputRoot } from "../../run/contained-output.js";
4
+ /** Build a participant's writeScreenshot closure: writes under screenshots/<screenshotDir>/ and records
5
+ * the relative path the trace references (screenshots/<name> at N=1; screenshots/<laneId>/<name>
6
+ * at N>1). */
7
+ export declare function makeParticipantWriteScreenshot(artifactRoot: PreparedOutputRoot, spec: {
8
+ screenshotDir: string;
9
+ }, screenshots: string[]): (name: string, bytes: Buffer) => Promise<string>;
10
+ export interface ParticipantFrames {
11
+ /** The session's writeScreenshot: writes the PNG, then queues its WebP. */
12
+ write(name: string, bytes: Buffer): Promise<string>;
13
+ /** Every frame written, by the path its trace references names. */
14
+ readonly screenshots: string[];
15
+ /**
16
+ * Waits up to FINISH_WAIT_MS for the frames still queued, then points `trace` and `screenshots`
17
+ * at each frame's WebP. Returns the PNGs those WebP replace, which the run removes once its final
18
+ * bundle is written: until then the live trace in run.json still names them.
19
+ */
20
+ finish(trace: ActorTrace | undefined): Promise<string[]>;
21
+ }
22
+ /**
23
+ * `blurred` is the study's policies.redactScreenshots: its frames are the redactor's PNG
24
+ * thumbnails, which stay PNG, since only that shape counts as blurred evidence.
25
+ */
26
+ export declare function participantFrames(artifactRoot: PreparedOutputRoot, spec: {
27
+ screenshotDir: string;
28
+ }, options: {
29
+ blurred: boolean;
30
+ transcoder?: WebpTranscoder;
31
+ finishWaitMs?: number;
32
+ }): ParticipantFrames;
@@ -0,0 +1,93 @@
1
+ // A computer-use participant's screenshots: each frame is written and referenced as PNG during the
2
+ // session, as the live Observer and a mid-run copy read it, and queued for a lossless WebP copy.
3
+ // When the session ends, each frame whose WebP is written is referenced by its WebP instead and
4
+ // its PNG removed, so at no point does a reference name a file that is not there.
5
+ import path from "node:path";
6
+ import { assertScreenshotEvidence, stripPngMetadataChunks } from "../../evidence/image.js";
7
+ import { webpTranscoder } from "../../evidence/webp-transcoder.js";
8
+ import { assertSafeOutputPathSegment, writeContainedOutputFile, } from "../../run/contained-output.js";
9
+ /**
10
+ * How long a finished session waits for its frames still queued. Encoding takes about 330 ms a
11
+ * frame, so this covers a short backlog; a frame not done by then stays PNG.
12
+ */
13
+ const FINISH_WAIT_MS = 5_000;
14
+ /** Build a participant's writeScreenshot closure: writes under screenshots/<screenshotDir>/ and records
15
+ * the relative path the trace references (screenshots/<name> at N=1; screenshots/<laneId>/<name>
16
+ * at N>1). */
17
+ export function makeParticipantWriteScreenshot(artifactRoot, spec, screenshots) {
18
+ if (spec.screenshotDir) {
19
+ assertSafeOutputPathSegment(spec.screenshotDir, "Screenshot participant id");
20
+ }
21
+ const dirParts = spec.screenshotDir ? ["screenshots", spec.screenshotDir] : ["screenshots"];
22
+ const relPrefix = spec.screenshotDir
23
+ ? path.posix.join("screenshots", spec.screenshotDir)
24
+ : "screenshots";
25
+ return async (name, bytes) => {
26
+ assertSafeOutputPathSegment(name, "Screenshot name");
27
+ const rel = path.posix.join(relPrefix, name);
28
+ // Evidence holds image data only, whatever the executor's frames carried.
29
+ const frame = stripPngMetadataChunks(bytes);
30
+ await assertScreenshotEvidence(rel, frame);
31
+ await writeContainedOutputFile(artifactRoot, path.join(...dirParts, name), frame);
32
+ screenshots.push(rel);
33
+ return rel;
34
+ };
35
+ }
36
+ /**
37
+ * `blurred` is the study's policies.redactScreenshots: its frames are the redactor's PNG
38
+ * thumbnails, which stay PNG, since only that shape counts as blurred evidence.
39
+ */
40
+ export function participantFrames(artifactRoot, spec, options) {
41
+ const transcoder = options.blurred ? undefined : (options.transcoder ?? webpTranscoder());
42
+ const finishWaitMs = options.finishWaitMs ?? FINISH_WAIT_MS;
43
+ const screenshots = [];
44
+ const writePng = makeParticipantWriteScreenshot(artifactRoot, spec, screenshots);
45
+ const queued = [];
46
+ /** Each frame whose WebP is written: its PNG path and its WebP path. */
47
+ const done = new Map();
48
+ // Once the session's references are settled, a late WebP is not written: nothing would name it.
49
+ let finished = false;
50
+ return {
51
+ screenshots,
52
+ async write(name, bytes) {
53
+ const rel = await writePng(name, bytes);
54
+ const webpRel = rel.replace(/\.png$/, ".webp");
55
+ const job = webpRel === rel ? undefined : transcoder?.transcode(bytes);
56
+ // A frame the worker cannot take, or fails to encode or write, stays PNG.
57
+ if (job !== undefined)
58
+ queued.push({
59
+ done: job.webp
60
+ .then(async (webp) => {
61
+ if (finished)
62
+ return;
63
+ await writeContainedOutputFile(artifactRoot, webpRel, webp);
64
+ done.set(rel, webpRel);
65
+ })
66
+ .catch(() => undefined),
67
+ abandon: job.abandon,
68
+ });
69
+ return rel;
70
+ },
71
+ async finish(trace) {
72
+ let timer;
73
+ await Promise.race([
74
+ Promise.all(queued.map((job) => job.done)),
75
+ new Promise((resolve) => (timer = setTimeout(resolve, finishWaitMs))),
76
+ ]);
77
+ clearTimeout(timer);
78
+ finished = true;
79
+ const replaced = new Map(done);
80
+ if (trace !== undefined)
81
+ trace.items = trace.items.map((item) => {
82
+ const webp = item.screenshotRef && replaced.get(item.screenshotRef.path);
83
+ return webp === undefined || item.screenshotRef === undefined
84
+ ? item
85
+ : { ...item, screenshotRef: { ...item.screenshotRef, path: webp } };
86
+ });
87
+ screenshots.splice(0, screenshots.length, ...screenshots.map((rel) => replaced.get(rel) ?? rel));
88
+ for (const job of queued)
89
+ job.abandon();
90
+ return [...replaced.keys()];
91
+ },
92
+ };
93
+ }
@@ -118,6 +118,9 @@ export function participantSessionOptions(spec, deps, ready, provider, writeScre
118
118
  ...(spec.planned.limits.maxOutputTokens === undefined
119
119
  ? {}
120
120
  : { maxOutputTokens: spec.planned.limits.maxOutputTokens }),
121
+ ...(spec.planned.limits.serviceTier === undefined
122
+ ? {}
123
+ : { serviceTier: spec.planned.limits.serviceTier }),
121
124
  },
122
125
  ...(maxUsd === undefined
123
126
  ? {}
@@ -152,6 +152,9 @@ function planEntry(spec) {
152
152
  ...(spec.planned.limits.maxOutputTokens === undefined
153
153
  ? {}
154
154
  : { maxOutputTokens: spec.planned.limits.maxOutputTokens }),
155
+ ...(spec.planned.limits.serviceTier === undefined
156
+ ? {}
157
+ : { serviceTier: spec.planned.limits.serviceTier }),
155
158
  ...(spec.planned.targetUrl === undefined
156
159
  ? {}
157
160
  : { targetDigest: digestUrl(spec.planned.targetUrl) }),
@@ -179,6 +182,7 @@ export function formatParticipantPlanEntry(entry) {
179
182
  entry.surface ? `surface=${entry.surface}` : undefined,
180
183
  entry.caseGroup ? `case=${entry.caseGroup}` : undefined,
181
184
  entry.reasoningEffort ? `effort=${entry.reasoningEffort}` : undefined,
185
+ entry.serviceTier ? `tier=${entry.serviceTier}` : undefined,
182
186
  entry.startAfterMs === undefined ? undefined : `start=+${formatDuration(entry.startAfterMs)}`,
183
187
  entry.endAfterMs === undefined ? undefined : `end=+${formatDuration(entry.endAfterMs)}`,
184
188
  entry.maxSessionMs === undefined ? undefined : `cap=${formatDuration(entry.maxSessionMs)}`,