humanish 0.127.1 → 0.129.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 (48) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/CONTEXT.md +2 -2
  3. package/README.md +1 -1
  4. package/dist/actors/computer-use/loop/output-limit.js +3 -0
  5. package/dist/actors/computer-use/loop/types.d.ts +16 -10
  6. package/dist/actors/computer-use/loop.js +6 -1
  7. package/dist/analysis/evidence.js +1 -4
  8. package/dist/analysis/validation.js +1 -1
  9. package/dist/cli/commands/study-format.js +2 -1
  10. package/dist/feedback/export.js +3 -2
  11. package/dist/observer/data.d.ts +13 -6
  12. package/dist/observer/data.js +40 -10
  13. package/dist/observer/feed-cursors.d.ts +3 -1
  14. package/dist/observer/feed-cursors.js +5 -2
  15. package/dist/observer/run-routes.js +40 -7
  16. package/dist/observer-app.html +8 -8
  17. package/dist/routes/computer-use/fanout-bundle.js +4 -7
  18. package/dist/routes/computer-use/participant-model.js +1 -1
  19. package/dist/routes/shared-world/bundle-records.js +6 -1
  20. package/dist/routes/shared-world/bundle.d.ts +8 -0
  21. package/dist/routes/shared-world/bundle.js +33 -13
  22. package/dist/routes/shared-world/participant-specs.d.ts +2 -1
  23. package/dist/routes/shared-world/participant-specs.js +5 -2
  24. package/dist/routes/shared-world/result.js +13 -7
  25. package/dist/routes/shared-world/types.d.ts +2 -0
  26. package/dist/run/bundle.d.ts +7 -4
  27. package/dist/run/bundle.js +10 -4
  28. package/dist/run/detail.js +8 -2
  29. package/dist/run/locate.d.ts +2 -2
  30. package/dist/run/locate.js +2 -2
  31. package/dist/run/outcomes.d.ts +7 -0
  32. package/dist/run/outcomes.js +14 -0
  33. package/dist/run/run.js +11 -3
  34. package/dist/run/stream-trace.d.ts +41 -14
  35. package/dist/run/stream-trace.js +195 -42
  36. package/dist/run/streams.d.ts +4 -1
  37. package/dist/study/bundle-estimate.js +17 -17
  38. package/dist/verify/costs.d.ts +7 -3
  39. package/dist/verify/costs.js +27 -12
  40. package/dist/verify/verify.js +14 -8
  41. package/docs/architecture/github-feedback-loop.md +1 -1
  42. package/docs/architecture/observer.md +32 -10
  43. package/docs/contracts/README.md +1 -1
  44. package/docs/contracts/run-bundle.md +102 -18
  45. package/docs/contracts/schemas.md +55 -45
  46. package/docs/contracts/study-analysis.md +1 -1
  47. package/docs/decisions/0002-observer-is-one-self-contained-file.md +1 -1
  48. package/package.json +1 -1
@@ -1,25 +1,37 @@
1
1
  // Where a participant's trace lives. A v1 run.json holds every stream's trace items and screenshot
2
2
  // entries inline. From v2 the run writer moves them into one file per stream and leaves an index
3
3
  // in run.json, so run.json stays a summary whatever the length of the sessions. From v3 a running
4
- // participant's items so far are in a live file run.json indexes too (src/run/live-items.ts).
5
- // Every reader that needs the items loads them here, which accepts every version and checks each
6
- // file against its index.
4
+ // participant's items so far are in a live file run.json indexes too (src/run/live-items.ts). From
5
+ // v4 the per-request lists (REQUEST_LISTS) are only in the trace file, and a running participant
6
+ // carries its totals and the estimate priced from its rows. Every reader that needs the items or
7
+ // the lists loads them here, which accepts every version and checks each file against its index.
7
8
  import { createHash } from "node:crypto";
8
- import { RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V2_SCHEMA, } from "./bundle.js";
9
+ import { RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V2_SCHEMA, RUN_BUNDLE_V3_SCHEMA, } from "./bundle.js";
9
10
  import { isRunBundle } from "./bundle-shape.js";
10
11
  import { readContainedPrefix, readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
11
12
  import { isLiveItemsIndex, LIVE_ACTOR_SCHEMA, LIVE_ACTOR_V1_SCHEMA, liveItemsAfter, liveItemsFromBytes, } from "./live-items.js";
13
+ import { estimateActorCostForExecution } from "./pricing.js";
12
14
  import { isRunStreamArtifact, isStreamTraceIndex, isStreamTracePath } from "./stream-shape.js";
13
15
  import { isRecord } from "./type-guards.js";
14
- /** A trace that does not match its index, or cannot be read. The message names the stream. */
16
+ /**
17
+ * A trace that does not match its index, or cannot be read. The message names the stream, and ends
18
+ * with `advice`: by default, restore the run folder from a copy.
19
+ */
15
20
  export class StreamTraceRefusedError extends Error {
16
21
  streamId;
17
- constructor(streamId, detail) {
18
- super(`Participant ${streamId}'s trace was not read: ${detail}. A trace file must stay as the run wrote it; restore the run folder from a copy.`);
22
+ constructor(streamId, detail, advice = "A trace file must stay as the run wrote it; restore the run folder from a copy.") {
23
+ super(`Participant ${streamId}'s trace was not read: ${detail}. ${advice}`);
19
24
  this.streamId = streamId;
20
25
  this.name = "StreamTraceRefusedError";
21
26
  }
22
27
  }
28
+ /** Why the loader reads no trace or live file whose index records more than `maxBytes`. */
29
+ const pastLimitDetail = (index, maxBytes) => `run.json records ${index.path} at ${index.bytes} bytes, more than the ${maxBytes} bytes humanish reads of one file`;
30
+ /**
31
+ * The refusal of a trace or live file whose index records more than `maxBytes`. The loader reads
32
+ * no file past the limit, so a copy of the file is refused too.
33
+ */
34
+ const tooLarge = (streamId, index, maxBytes) => new StreamTraceRefusedError(streamId, pastLimitDetail(index, maxBytes), "Restoring a copy does not help; open the file with another tool, such as jq, to read its items.");
23
35
  const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
24
36
  const isScreenshot = (artifact) => artifact.kind === "screenshot";
25
37
  /**
@@ -43,12 +55,64 @@ function tracePath(stream, index, claimed) {
43
55
  !POSITION_TRACE_FILE.test(artifact.path))?.path;
44
56
  return own !== undefined && !claimed.has(traceFileKey(own)) ? own : `actors/${index + 1}.json`;
45
57
  }
58
+ /**
59
+ * A participant's lists that gain one entry per model request, as paths into its actor trace. From
60
+ * run bundle v4 run.json leaves them out of `actor` and `liveActor`, and the trace file alone holds
61
+ * them; the Observer's data leaves them out too.
62
+ */
63
+ const REQUEST_LISTS = ["tokenUsage.turns", "conversation.requests", "providerRequests"];
64
+ /** The value at `list`'s path in `trace`, when the object holding it is there. */
65
+ function listIn(trace, list) {
66
+ const [first, second] = list.split(".");
67
+ if (second === undefined)
68
+ return trace[first];
69
+ const parent = trace[first];
70
+ return isRecord(parent) ? parent[second] : undefined;
71
+ }
72
+ /** `trace` without the per-request lists; every other field as it is. */
73
+ export function withoutRequestLists(trace) {
74
+ const copy = { ...trace };
75
+ for (const list of REQUEST_LISTS) {
76
+ const [first, second] = list.split(".");
77
+ const parent = copy[first];
78
+ if (second === undefined)
79
+ delete copy[first];
80
+ else if (isRecord(parent) && second in parent) {
81
+ const { [second]: _list, ...rest } = parent;
82
+ copy[first] = rest;
83
+ }
84
+ }
85
+ return copy;
86
+ }
87
+ /** The first per-request list `trace` holds, if any. */
88
+ function heldRequestList(trace) {
89
+ return REQUEST_LISTS.find((list) => listIn(trace, list) !== undefined);
90
+ }
91
+ /**
92
+ * run.json's live actor: without the per-request lists, and with the estimate priced from the
93
+ * rows it leaves out. Long-context pricing needs each request's input size, so a figure priced
94
+ * from the totals alone could be under half the real one. A live actor read back from run.json
95
+ * has no rows and keeps the estimate they gave.
96
+ */
97
+ function liveSummary(live) {
98
+ const summary = withoutRequestLists(live);
99
+ const model = live.ids?.model;
100
+ if (live.tokenUsage === undefined || typeof model !== "string")
101
+ return summary;
102
+ if (live.tokenUsage.turns === undefined && live.estimatedCost !== undefined)
103
+ return summary;
104
+ return {
105
+ ...summary,
106
+ estimatedCost: estimateActorCostForExecution(live.tokenUsage, model, live.executionProfile),
107
+ };
108
+ }
46
109
  /**
47
110
  * The run.json the run writes, and the trace files it writes first. Each stream with a finished
48
- * actor keeps that actor without `items`, its artifacts other than screenshots, and an index of its
49
- * trace file; the file is the actor trace with the stream's screenshot entries as `artifacts`. A
50
- * stream already split (its actor read back from a v2 run.json without its items) keeps its index,
51
- * since its file is on disk.
111
+ * actor keeps that actor without `items` and the per-request lists, its artifacts other than
112
+ * screenshots, and an index of its trace file; the file is the whole actor trace with the stream's
113
+ * screenshot entries as `artifacts`. A stream already split (its actor read back from run.json
114
+ * without its items) keeps its index, since its file is on disk, and loses any lists an older
115
+ * run.json held beside it. A running participant's live actor is written by liveSummary.
52
116
  */
53
117
  export function splitStreamTraces(bundle) {
54
118
  const files = [];
@@ -56,10 +120,15 @@ export function splitStreamTraces(bundle) {
56
120
  for (const stream of bundle.streams)
57
121
  if (stream.trace)
58
122
  claimed.add(traceFileKey(stream.trace.path));
59
- const streams = bundle.streams.map((stream, index) => {
123
+ const streams = bundle.streams.map((written, index) => {
124
+ const stream = written.liveActor === undefined
125
+ ? written
126
+ : { ...written, liveActor: liveSummary(written.liveActor) };
60
127
  const actor = stream.actor;
61
- if (actor === undefined || !Array.isArray(actor.items))
128
+ if (actor === undefined)
62
129
  return stream;
130
+ if (!Array.isArray(actor.items))
131
+ return { ...stream, actor: withoutRequestLists(actor) };
63
132
  const { trace: _previous, ...rest } = stream;
64
133
  const filePath = tracePath(rest, index, claimed);
65
134
  // Only a stream already split that moved position can hold this stream's position name.
@@ -72,8 +141,8 @@ export function splitStreamTraces(bundle) {
72
141
  const { items, ...summary } = actor;
73
142
  return {
74
143
  ...rest,
75
- // run.json's actor is the summary: every field of the trace except its items.
76
- actor: summary,
144
+ // run.json's actor is the summary: every field of the trace except its items and lists.
145
+ actor: withoutRequestLists(summary),
77
146
  artifacts: stream.artifacts.filter((artifact) => !isScreenshot(artifact)),
78
147
  trace: {
79
148
  path: filePath,
@@ -125,13 +194,38 @@ function joinTrace(stream, bytes) {
125
194
  refuse(`${index.path} lists an artifact that is not a screenshot entry`);
126
195
  if (value.items.length !== index.items || artifacts.length !== index.screenshots)
127
196
  refuse(`${index.path} holds ${value.items.length} items and ${artifacts.length} screenshots and run.json records ${index.items} and ${index.screenshots}`);
197
+ const actor = joinRequestLists(stream.actor, value, index.path, refuse);
128
198
  const { trace: _index, ...rest } = stream;
129
199
  return {
130
200
  ...rest,
131
- actor: { ...stream.actor, items: value.items },
201
+ actor: { ...actor, items: value.items },
132
202
  artifacts: [...stream.artifacts, ...artifacts],
133
203
  };
134
204
  }
205
+ /**
206
+ * run.json's actor with each per-request list its trace file holds, whatever the bundle's
207
+ * version: before v4 the writer wrote run.json's lists and the file's from one actor, so they are
208
+ * equal. A nested list goes into run.json's `tokenUsage` or `conversation` when run.json has it.
209
+ */
210
+ function joinRequestLists(summary, file, filePath, refuse) {
211
+ if (isRecord(file.conversation) && !Array.isArray(file.conversation.requests))
212
+ refuse(`${filePath} records a conversation without its requests list`);
213
+ const joined = { ...summary };
214
+ for (const list of REQUEST_LISTS) {
215
+ const value = listIn(file, list);
216
+ if (value === undefined)
217
+ continue;
218
+ if (!Array.isArray(value))
219
+ refuse(`${filePath} holds ${list} as something other than a list`);
220
+ const [first, second] = list.split(".");
221
+ const parent = joined[first];
222
+ if (second === undefined)
223
+ joined[first] = value;
224
+ else if (isRecord(parent))
225
+ joined[first] = { ...parent, [second]: value };
226
+ }
227
+ return joined;
228
+ }
135
229
  /** Refuses an index the shape check would refuse, naming a file that is not a trace file name. */
136
230
  function assertIndex(stream) {
137
231
  const index = stream.trace;
@@ -166,10 +260,22 @@ async function readLiveItems(read, stream, maxBytes, liveAfter) {
166
260
  return refuse("run.json records a malformed live items index");
167
261
  if (live.items !== undefined)
168
262
  refuse("run.json holds in-progress items beside their live file");
169
- if (index.bytes > maxBytes)
170
- refuse(`${index.path} is indexed at ${index.bytes} bytes, more than the ${maxBytes} read`);
171
263
  const { itemsFile: _index, ...summary } = live;
172
264
  const after = liveAfter?.(index);
265
+ if (index.bytes > maxBytes) {
266
+ if (after === undefined || !cursorFits(index, after))
267
+ throw tooLarge(stream.id, index, maxBytes);
268
+ // The caller keeps the prefix it holds, read when the file was under the limit.
269
+ return {
270
+ stream: { ...stream, liveActor: { ...summary, items: [] } },
271
+ read: {
272
+ streamId: stream.id,
273
+ index,
274
+ after,
275
+ stopped: `Participant ${stream.id}'s items past ${after.items} were not read: ${pastLimitDetail(index, maxBytes)}. The items read before stay shown; open the file with another tool, such as jq, to read the rest.`,
276
+ },
277
+ };
278
+ }
173
279
  const tail = after === undefined ? undefined : await readLiveTail(read, stream, index, after);
174
280
  if (tail !== undefined)
175
281
  return {
@@ -190,6 +296,15 @@ async function readLiveItems(read, stream, maxBytes, liveAfter) {
190
296
  read: { streamId: stream.id, index },
191
297
  };
192
298
  }
299
+ /**
300
+ * Whether `after` can be a prefix of the file `index` describes: some bytes, no more items or bytes
301
+ * than the index, and at its end in both counts or in neither. Whether it ends on a line end is
302
+ * checked only when the tail is read.
303
+ */
304
+ const cursorFits = (index, after) => after.bytes > 0 &&
305
+ after.items <= index.items &&
306
+ after.bytes <= index.bytes &&
307
+ (after.items === index.items) === (after.bytes === index.bytes);
193
308
  /**
194
309
  * The items of the live file `index` describes past `after`, and the bytes they were read from;
195
310
  * undefined when the cursor does not fit the index or the file's lines, so the caller reads the
@@ -197,10 +312,7 @@ async function readLiveItems(read, stream, maxBytes, liveAfter) {
197
312
  * LiveFileUnreadableError, as a whole read does.
198
313
  */
199
314
  async function readLiveTail(read, stream, index, after) {
200
- if (after.bytes === 0 ||
201
- after.items > index.items ||
202
- after.bytes > index.bytes ||
203
- (after.items === index.items) !== (after.bytes === index.bytes))
315
+ if (!cursorFits(index, after))
204
316
  return undefined;
205
317
  if (after.bytes === index.bytes)
206
318
  return { items: [], bytes: Buffer.alloc(0) };
@@ -220,6 +332,8 @@ async function readStreamTrace(read, stream, maxBytes, liveAfter) {
220
332
  let joined = stream;
221
333
  if (stream.trace !== undefined) {
222
334
  assertIndex(stream);
335
+ if (stream.trace.bytes > maxBytes)
336
+ throw tooLarge(stream.id, stream.trace, maxBytes);
223
337
  const file = await read(stream.trace.path, maxBytes);
224
338
  if ("refused" in file)
225
339
  throw new StreamTraceRefusedError(stream.id, file.refused);
@@ -236,22 +350,37 @@ async function readStreamTrace(read, stream, maxBytes, liveAfter) {
236
350
  * stream without an index is returned as it is (a v1 stream, or one with no actor). An indexed
237
351
  * stream's file must have a trace file name (isStreamTracePath); it is read under `maxBytes` and
238
352
  * checked against the index's byte count, sha256, item count and screenshot count, and any
239
- * difference throws StreamTraceRefusedError. The rules that need the whole bundle (an unindexed v2
353
+ * difference throws StreamTraceRefusedError. An index that records more than `maxBytes` throws
354
+ * before the file is read. The rules that need the whole bundle (an unindexed v2
240
355
  * actor, two participants indexing one file) are loadStreamTraces'.
241
356
  */
242
357
  export async function loadStreamTrace(runPaths, stream, maxBytes = RUN_ARTIFACT_MAX_BYTES) {
243
358
  return (await readStreamTrace(containedReader(runPaths), stream, maxBytes)).stream;
244
359
  }
360
+ /**
361
+ * What each version's writer never puts in run.json, so a run.json of that version that holds it
362
+ * did not come from the writer. From v2 the writer indexes every actor on every write, mid-run
363
+ * flushes included, and the final write replaces `liveActor`. From v3 a running participant's
364
+ * items are in a live file (humanish.live-actor.v2). From v4 the per-request lists are only in
365
+ * the trace file. A run.json of v1, or of no version this table names, is read as v1 is.
366
+ */
367
+ const WRITER_RULES = new Map([
368
+ [RUN_BUNDLE_V2_SCHEMA, { liveFiles: false, listsOut: false }],
369
+ [RUN_BUNDLE_V3_SCHEMA, { liveFiles: true, listsOut: false }],
370
+ [RUN_BUNDLE_SCHEMA, { liveFiles: true, listsOut: true }],
371
+ ]);
372
+ /**
373
+ * Whether a run.json of `schema` holds the per-request lists (REQUEST_LISTS) the writer kept in
374
+ * it: v1 to v3, and a schema WRITER_RULES does not name, which is read as v1. From v4 they are
375
+ * only in the trace file, so a running participant's `liveActor` holds none.
376
+ */
377
+ export const keepsRequestListsInRunJson = (schema) => WRITER_RULES.get(schema)?.listsOut !== true;
245
378
  /**
246
379
  * Why a stream of this bundle cannot be read, before any file is. A trace file belongs to one
247
380
  * participant, so every stream indexing a path another stream indexes, compared as a
248
- * case-insensitive file system compares them, is refused. In v2 the writer
249
- * indexes every actor on every write, mid-run flushes included, so a v2 actor without an index lost
250
- * its trace file, and inline items never come from the writer. A run still writing carries its
251
- * participants' items so far as `liveActor`: inline in v2 (humanish.live-actor.v1), in a live file
252
- * from v3 (humanish.live-actor.v2), so a v3 run.json with inline live items did not come from the
253
- * writer. A run.json whose outcome says the run finished never carries `liveActor`, since the
254
- * final write replaces it.
381
+ * case-insensitive file system compares them, is refused in every version. The other rules are
382
+ * WRITER_RULES': a v2 actor without an index lost its trace file, and a run.json whose outcome says
383
+ * the run finished never carries `liveActor`.
255
384
  */
256
385
  function bundleRefusal(bundle, stream, owners) {
257
386
  const shared = isRecord(stream.trace)
@@ -259,16 +388,23 @@ function bundleRefusal(bundle, stream, owners) {
259
388
  : undefined;
260
389
  if (shared !== undefined && shared.length > 1)
261
390
  return `run.json indexes ${String(stream.trace.path)} for participants ${shared.join(", ")} (paths that differ only in case name one file on some file systems), and a trace file belongs to one participant`;
262
- if (bundle.schema !== RUN_BUNDLE_SCHEMA && bundle.schema !== RUN_BUNDLE_V2_SCHEMA)
391
+ const rules = WRITER_RULES.get(String(bundle.schema));
392
+ if (rules === undefined)
263
393
  return undefined;
394
+ const version = String(bundle.schema).replace("humanish.run-bundle.", "");
264
395
  if (stream.actor !== undefined && stream.trace === undefined)
265
396
  return "run.json holds its actor and names no trace file";
266
397
  if (stream.liveActor !== undefined && bundle.outcome?.state === "finished")
267
398
  return "run.json records a finished run and still holds in-progress items";
268
- if (bundle.schema === RUN_BUNDLE_SCHEMA &&
269
- stream.liveActor !== undefined &&
270
- isInlineLiveActor(stream.liveActor))
271
- return "run.json v3 holds in-progress items inline, which the writer keeps in a live file";
399
+ if (rules.liveFiles && stream.liveActor !== undefined && isInlineLiveActor(stream.liveActor))
400
+ return `run.json ${version} holds in-progress items inline, which the writer keeps in a live file`;
401
+ if (rules.listsOut)
402
+ for (const field of ["actor", "liveActor"]) {
403
+ const trace = stream[field];
404
+ const list = isRecord(trace) ? heldRequestList(trace) : undefined;
405
+ if (list !== undefined)
406
+ return `run.json ${version} holds ${list} in its ${field}, which the writer keeps only in the trace file`;
407
+ }
272
408
  return undefined;
273
409
  }
274
410
  /**
@@ -282,7 +418,9 @@ function bundleRefusal(bundle, stream, owners) {
282
418
  * `liveAfter` names, for a live file's index, the prefix the caller holds already (the Observer
283
419
  * feed's cursor). The loader then reads and joins only the items past it, checked by liveItemsAfter
284
420
  * without the indexed sha256, and `live` records the cursor; a cursor past the index or off the
285
- * file's lines gets the whole prefix, checked as always.
421
+ * file's lines gets the whole prefix, checked as always. A live file whose index records more than
422
+ * `maxBytes` is not read: for a cursor that fits the index the stream gets no items past it and
423
+ * `live` records `stopped`, and without one the stream is refused.
286
424
  *
287
425
  * A live file that cannot be read may have been removed by the writer after `bundle` was read: it
288
426
  * removes a superseded generation once run.json names the next one, and every live file once the
@@ -348,13 +486,16 @@ async function loadEveryStream(read, bundle, maxBytes, liveAfter) {
348
486
  }
349
487
  /**
350
488
  * A loaded bundle as a reader that shows a run presents it. A refused stream keeps run.json's
351
- * summary, shows none of the items run.json or a file supplied for it (its actor's items, its
352
- * in-progress items, its screenshot entries), and carries `traceUnavailable` with the reason; a
353
- * warn event on its timeline says the same.
489
+ * summary, shows none of the items or per-request lists run.json or a file supplied for it (its
490
+ * actor's items and lists, its in-progress items, its screenshot entries), and carries
491
+ * `traceUnavailable` with the reason; a warn event on its timeline says the same. A reader that
492
+ * shows the run then never prices or checks rows the loader refused. A live file the loader
493
+ * stopped reading (LiveItemsRead's `stopped`) gives its stream a `live-items.not-read` warn event.
354
494
  */
355
495
  export function displayedTraces(loaded) {
356
496
  const reasons = new Map(loaded.refused.map((error) => [error.streamId, error.message]));
357
- if (reasons.size === 0)
497
+ const stopped = (loaded.live ?? []).filter((read) => read.stopped !== undefined);
498
+ if (reasons.size === 0 && stopped.length === 0)
358
499
  return loaded.bundle;
359
500
  const streams = loaded.bundle.streams.map((stream) => {
360
501
  const reason = reasons.get(stream.id);
@@ -363,7 +504,7 @@ export function displayedTraces(loaded) {
363
504
  const { liveActor: _live, trace: _index, actor, ...rest } = stream;
364
505
  return {
365
506
  ...rest,
366
- ...(actor === undefined ? {} : { actor: { ...actor, items: [] } }),
507
+ ...(actor === undefined ? {} : { actor: { ...withoutRequestLists(actor), items: [] } }),
367
508
  artifacts: stream.artifacts.filter((artifact) => !isScreenshot(artifact)),
368
509
  traceUnavailable: reason,
369
510
  };
@@ -378,7 +519,19 @@ export function displayedTraces(loaded) {
378
519
  message: reasons.get(stream.id),
379
520
  streamId: stream.id,
380
521
  }));
381
- return { ...loaded.bundle, streams, events: [...loaded.bundle.events, ...events] };
522
+ const notRead = stopped.map((read) => ({
523
+ id: `event-live-items-not-read-${read.streamId}`,
524
+ at: loaded.bundle.streams.find((stream) => stream.id === read.streamId)?.updatedAt ?? "",
525
+ level: "warn",
526
+ type: "live-items.not-read",
527
+ message: read.stopped,
528
+ streamId: read.streamId,
529
+ }));
530
+ return {
531
+ ...loaded.bundle,
532
+ streams,
533
+ events: [...loaded.bundle.events, ...events, ...notRead],
534
+ };
382
535
  }
383
536
  /** The bundle with every stream loaded; the first refusal throws. */
384
537
  export async function loadRunTraces(source, bundle, maxBytes) {
@@ -289,7 +289,10 @@ export interface RunStream {
289
289
  * completionReason, or completedAt, and this shape cannot claim them. Present only on
290
290
  * `inProgress` bundles; the final write replaces it with the real `actor` and never
291
291
  * carries it. A v3 run.json holds `humanish.live-actor.v2` with `itemsFile` in place of `items`;
292
- * the trace loader (src/run/stream-trace.ts) reads the file and puts the items back.
292
+ * the trace loader (src/run/stream-trace.ts) reads the file and puts the items back. A v4
293
+ * run.json's live actor also leaves out `tokenUsage.turns` and `providerRequests`, which are
294
+ * written only in the trace when the participant finishes, and carries the `estimatedCost` the
295
+ * run writer priced from those rows.
293
296
  */
294
297
  liveActor?: {
295
298
  schema: "humanish.live-actor.v1" | "humanish.live-actor.v2";
@@ -4,11 +4,14 @@
4
4
  // RUN_ARTIFACT_MAX_BYTES, and the public-safety scan visits at most runEntryLimit(participants)
5
5
  // files and folders.
6
6
  //
7
- // observer/index.html carries every participant's items gzipped, so with busy participants it can
8
- // pass verify's 32 MiB before run.json passes analysis's 16 MiB: at these rates, 17 participants
9
- // working 8 hours. observer-data.json carries the same per-request lists as run.json and was within
10
- // 7 % of its size on every live run measured, so it passes 32 MiB only after run.json passes
11
- // 16 MiB, and the estimate does not check it.
7
+ // observer/index.html carries every participant's items gzipped, so with busy participants it
8
+ // passes verify's 32 MiB long before run.json passes analysis's 16 MiB: at these rates, 20
9
+ // participants working 8 hours. From run bundle v4 run.json grows by participant, not by model
10
+ // request: each participant's per-request lists are only in its trace. run.json can still pass
11
+ // 16 MiB, since a study's mission is not capped and each participant's stream copies it.
12
+ // observer-data.json leaves the lists out too and was 0.94 to 1.12 of run.json's size on the live
13
+ // runs measured, so it passes 32 MiB only after run.json passes 16 MiB, and the estimate does not
14
+ // check it.
12
15
  import { EVIDENCE_LIMITS } from "../analysis/analysis-limits.js";
13
16
  import { RUN_ARTIFACT_MAX_BYTES } from "../run/contained-output.js";
14
17
  import { formatDuration } from "../run/projection.js";
@@ -20,19 +23,16 @@ import { runEntryLimit } from "../verify/scan-limit.js";
20
23
  // own files. Pace, session length and screenshots: the live computer-use runs kept on the
21
24
  // maintainer's machine. A trace's rate a turn on the public site/public/runs/lobby-0927 run
22
25
  // (8 participants, 20 to 78 turns) was 1,579 bytes plus 91 for the page, under the live fit.
26
+ // The run.json and page data rates were measured again on those runs written as v4.
23
27
  const RATES = Object.freeze({
24
28
  /** run.json bytes for the run itself: its header, study, review and cost. Live fit: 4,381. */
25
29
  runBytes: 4_400,
26
30
  /**
27
- * run.json bytes for each participant before its turns: its stream, actor summary and closing
28
- * report request. Live fit: 16,239, plus one request.
31
+ * run.json bytes for each participant: its stream, actor summary and closing report request.
32
+ * Live fit on v3: 16,239. Written as v4, the kept runs of one participant held 20,571 to 23,160
33
+ * bytes and those of two 30,647 to 38,948, beside 20,900 and 37,400 at these rates.
29
34
  */
30
35
  participantBytes: 16_500,
31
- /**
32
- * run.json bytes a model turn adds: the request's entries in `tokenUsage.turns` and
33
- * `conversation.requests`. Live: 30,623 bytes over 137 requests, 223.5 each.
34
- */
35
- turnRunBytes: 225,
36
36
  /** A trace file's bytes before its turns: the actor summary. Live fit: 5,378. */
37
37
  participantTraceBytes: 5_400,
38
38
  /**
@@ -63,10 +63,10 @@ const RATES = Object.freeze({
63
63
  /** observer/index.html's app before any run data: 1,199,831 bytes on the dry runs. */
64
64
  observerAppBytes: 1_200_000,
65
65
  /**
66
- * The page's compact copy of observer-data.json, as a share of run.json: 0.64 to 0.81 whole on
67
- * the live runs, 0.49 for a participant's part, and 0.42 for the per-request entries.
66
+ * The page's compact copy of observer-data.json, as a share of run.json: 0.71 to 0.84 on the
67
+ * live runs written as v4, where neither carries the per-request lists.
68
68
  */
69
- observerPageDataShare: 0.5,
69
+ observerPageDataShare: 0.85,
70
70
  /**
71
71
  * The page's gzipped base64 items, as a share of all the traces' bytes: 0.087 to 0.114 on the
72
72
  * live runs of two participants, 0.097 for the busiest participant alone (about 475 bytes a
@@ -124,7 +124,7 @@ export function estimateBundle(participants, sessionMs) {
124
124
  largestTrace = Math.max(largestTrace, trace);
125
125
  }
126
126
  const count = participants.length;
127
- const runFileBytes = RATES.runBytes + count * RATES.participantBytes + turns * RATES.turnRunBytes;
127
+ const runFileBytes = RATES.runBytes + count * RATES.participantBytes;
128
128
  const screenshots = count + turns + frames;
129
129
  const observerPageBytes = Math.round(RATES.observerAppBytes +
130
130
  RATES.observerPageDataShare * runFileBytes +
@@ -204,7 +204,7 @@ export function bundleLimitReason(estimate) {
204
204
  : `${changes.slice(0, -1).join(", ")}, or ${changes.at(-1)}`;
205
205
  return [
206
206
  ...passed,
207
- `The estimate counts ${counted.join(" and ")}, at a model turn every ${formatDuration(RATES.secondsPerTurn * 1000)} that adds about ${RATES.turnRunBytes} bytes to run.json and up to ${(RATES.turnTraceBytes / 1000).toFixed(1)} KB to the participant's trace.`,
207
+ `The estimate counts ${counted.join(" and ")}, at a model turn every ${formatDuration(RATES.secondsPerTurn * 1000)} that adds up to ${(RATES.turnTraceBytes / 1000).toFixed(1)} KB to the participant's trace.`,
208
208
  "The run goes ahead.",
209
209
  `To stay under ${passed.length === 1 ? "the limit" : "the limits"}, ${advice}.`,
210
210
  ].join(" ");
@@ -24,7 +24,11 @@ export declare function contradictsAccountBilling(streams: readonly {
24
24
  * a null estimate, or a participant without estimatedCost is fine. A non-null figure must carry its
25
25
  * ratesAsOf date + source; a number total must equal round6(sum of only the non-null lines) and a
26
26
  * null line may never be coerced to 0. A null estimate must be declared (a reason + null
27
- * ratesAsOf), mirroring the terminal no-spend proof's null-discipline. Account-billed participants
28
- * also have their execution profile and provider request receipts checked here.
27
+ * ratesAsOf), mirroring the terminal no-spend proof's null-discipline. A running participant's
28
+ * estimate is checked the same way. Account-billed participants also have their execution profile
29
+ * and provider request receipts checked here. From v4 a running participant's receipts are written
30
+ * only in its trace when it finishes, so its live actor holds none. The receipts of a stream in
31
+ * `refused` (its trace the loader refused) are not checked: from v4 they are only in that trace,
32
+ * and verify reports the refusal.
29
33
  */
30
- export declare function costAndReceiptFindings(bundle: RunBundle): string[];
34
+ export declare function costAndReceiptFindings(bundle: RunBundle, refused: ReadonlySet<string>): string[];
@@ -2,6 +2,7 @@
2
2
  // totals match their known lines, and account-billed participants never acquire a price.
3
3
  import { validActorExecutionProfile, validActorProviderRequests } from "../actors/contract.js";
4
4
  import { round6 } from "../run/pricing.js";
5
+ import { keepsRequestListsInRunJson } from "../run/stream-trace.js";
5
6
  /** Account-billed participants must never acquire a price through an aggregate or ambiguous model line. */
6
7
  export function contradictsAccountBilling(streams, cost) {
7
8
  const account = (stream) => (stream.liveActor?.executionProfile ?? stream.actor?.executionProfile)?.billing ===
@@ -28,18 +29,23 @@ export function contradictsAccountBilling(streams, cost) {
28
29
  * a null estimate, or a participant without estimatedCost is fine. A non-null figure must carry its
29
30
  * ratesAsOf date + source; a number total must equal round6(sum of only the non-null lines) and a
30
31
  * null line may never be coerced to 0. A null estimate must be declared (a reason + null
31
- * ratesAsOf), mirroring the terminal no-spend proof's null-discipline. Account-billed participants
32
- * also have their execution profile and provider request receipts checked here.
32
+ * ratesAsOf), mirroring the terminal no-spend proof's null-discipline. A running participant's
33
+ * estimate is checked the same way. Account-billed participants also have their execution profile
34
+ * and provider request receipts checked here. From v4 a running participant's receipts are written
35
+ * only in its trace when it finishes, so its live actor holds none. The receipts of a stream in
36
+ * `refused` (its trace the loader refused) are not checked: from v4 they are only in that trace,
37
+ * and verify reports the refusal.
33
38
  */
34
- export function costAndReceiptFindings(bundle) {
39
+ export function costAndReceiptFindings(bundle, refused) {
35
40
  const findings = [];
36
41
  if (contradictsAccountBilling(bundle.streams, bundle.cost))
37
42
  findings.push("Run cost lines contradict account billing identity");
38
43
  findings.push(...costSummaryFindings(bundle.cost));
44
+ const liveReceipts = keepsRequestListsInRunJson(bundle.schema);
39
45
  for (const stream of bundle.streams) {
40
- for (const actor of [stream.actor, stream.liveActor]) {
41
- findings.push(...executionReceiptFindings(actor));
42
- }
46
+ const loaded = !refused.has(stream.id);
47
+ findings.push(...executionReceiptFindings(stream.actor, loaded));
48
+ findings.push(...executionReceiptFindings(stream.liveActor, liveReceipts && loaded));
43
49
  findings.push(...participantEstimateFindings(stream));
44
50
  }
45
51
  return findings;
@@ -81,14 +87,17 @@ function costSummaryFindings(cost) {
81
87
  }
82
88
  return findings;
83
89
  }
84
- /** An account-billed participant's execution profile and provider request receipts. */
85
- function executionReceiptFindings(actor) {
90
+ /**
91
+ * An account-billed participant's execution profile and, when `receipts` says the trace holds
92
+ * them, its provider request receipts.
93
+ */
94
+ function executionReceiptFindings(actor, receipts) {
86
95
  const findings = [];
87
96
  if (actor?.executionProfile === undefined)
88
97
  return findings;
89
98
  if (!validActorExecutionProfile(actor.executionProfile))
90
99
  findings.push("Invalid actor execution profile");
91
- if (!validActorProviderRequests(actor.providerRequests))
100
+ if (receipts && !validActorProviderRequests(actor.providerRequests))
92
101
  findings.push("Invalid account participant request receipts");
93
102
  if (actor.historyTurnsOmitted !== undefined &&
94
103
  (!Number.isSafeInteger(actor.historyTurnsOmitted) || actor.historyTurnsOmitted < 0))
@@ -99,14 +108,20 @@ function executionReceiptFindings(actor) {
99
108
  findings.push("Account participant dollars must remain unknown");
100
109
  return findings;
101
110
  }
111
+ /** The labeling of a participant's estimate: its finished actor's, and its live actor's while it runs. */
102
112
  function participantEstimateFindings(stream) {
113
+ const participantLabel = stream.laneId ?? stream.id;
114
+ return [
115
+ ...estimateFindings(stream.actor?.estimatedCost, participantLabel, "actor"),
116
+ ...estimateFindings(stream.liveActor?.estimatedCost, participantLabel, "live actor"),
117
+ ];
118
+ }
119
+ function estimateFindings(estimate, participantLabel, field) {
103
120
  const findings = [];
104
- const estimate = stream.actor?.estimatedCost;
105
121
  if (!estimate)
106
122
  return findings;
107
- const participantLabel = stream.laneId ?? stream.id;
108
123
  if (estimate.schema !== "humanish.actor-estimated-cost.v1") {
109
- findings.push(`participant ${participantLabel} actor estimatedCost schema is ${String(estimate.schema)}, expected humanish.actor-estimated-cost.v1`);
124
+ findings.push(`participant ${participantLabel} ${field} estimatedCost schema is ${String(estimate.schema)}, expected humanish.actor-estimated-cost.v1`);
110
125
  }
111
126
  if (estimate.estimatedCostUsd !== null) {
112
127
  if (typeof estimate.ratesAsOf !== "string" || estimate.ratesAsOf.length === 0) {
@@ -5,7 +5,7 @@ import { analysisSharingProblems } from "../analysis/sharing.js";
5
5
  import { containsSensitive, REDACTED_SANDBOX_ID } from "../evidence/redaction.js";
6
6
  import { keyedSandboxIds, readRunSandboxIds } from "../run/sandbox-ids.js";
7
7
  import { validatePreparedRunArtifactPaths } from "../run/paths.js";
8
- import { isReadRunBundleSchema, RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V1_SCHEMA, RUN_BUNDLE_V2_SCHEMA, } from "../run/bundle.js";
8
+ import { isReadRunBundleSchema, RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V1_SCHEMA, RUN_BUNDLE_V2_SCHEMA, RUN_BUNDLE_V3_SCHEMA, } from "../run/bundle.js";
9
9
  import { ContainedReadRefusedError, refusalText } from "../run/contained-output.js";
10
10
  import { isCleanupResult, isRunBundle } from "../run/bundle-shape.js";
11
11
  import { loadStreamTraces } from "../run/stream-trace.js";
@@ -139,7 +139,7 @@ export async function verifyResolvedRun(cwd, runInput, runPaths) {
139
139
  ? [`missing local evidence artifacts: ${missingEvidenceArtifacts.join(", ")}`]
140
140
  : []),
141
141
  ]));
142
- checks.push(...(await evidenceChecks(runPaths, bundle, cleanupRead)));
142
+ checks.push(...(await evidenceChecks(runPaths, bundle, cleanupRead, traces.refusedStreams)));
143
143
  const recordingOk = checks.every((check) => check.ok);
144
144
  if (derivedPublicSafetyFindings.length > 0)
145
145
  checks.push({
@@ -225,12 +225,13 @@ function evidenceArtifactsCheck(shaped, problems) {
225
225
  }
226
226
  /**
227
227
  * run.json with each stream's trace loaded, one file at a time, and the refusal of each trace that
228
- * does not match its index. A run.json that fails the shape guard is returned as it is.
228
+ * does not match its index, with the ids of those streams. A run.json that fails the shape guard
229
+ * is returned as it is.
229
230
  */
230
231
  async function loadTraces(runPaths, stored) {
231
232
  const scanned = { sensitive: [], opaque: [] };
232
233
  if (!isRunBundle(stored))
233
- return { bundle: stored, refusedTraces: [], ...scanned, pages: [] };
234
+ return { bundle: stored, refusedTraces: [], refusedStreams: new Set(), ...scanned, pages: [] };
234
235
  // Each trace is source evidence: scanned by the bytes it was joined from, apart from the
235
236
  // directory walk and any path exemption the walk makes.
236
237
  const { bundle, refused } = await loadStreamTraces(runPaths, stored, {
@@ -243,6 +244,7 @@ async function loadTraces(runPaths, stored) {
243
244
  return {
244
245
  bundle,
245
246
  refusedTraces: refused.map((error) => error.message),
247
+ refusedStreams: new Set(refused.map((error) => error.streamId)),
246
248
  ...scanned,
247
249
  pages: pageFindings(bundle),
248
250
  };
@@ -312,7 +314,7 @@ function bundlePresenceChecks(bundleRead, review) {
312
314
  ? `run.json declares ${String(schema)}`
313
315
  : record === undefined
314
316
  ? "no readable run.json, so no schema"
315
- : `run.json declares ${typeof schema === "string" ? schema : "no schema"}; verify reads ${RUN_BUNDLE_SCHEMA}, ${RUN_BUNDLE_V2_SCHEMA} and ${RUN_BUNDLE_V1_SCHEMA}`,
317
+ : `run.json declares ${typeof schema === "string" ? schema : "no schema"}; verify reads ${RUN_BUNDLE_SCHEMA}, ${RUN_BUNDLE_V3_SCHEMA}, ${RUN_BUNDLE_V2_SCHEMA} and ${RUN_BUNDLE_V1_SCHEMA}`,
316
318
  },
317
319
  {
318
320
  name: "run bundle shape",
@@ -378,8 +380,12 @@ function findingsCheck(name, findings, okMessage, failLabel) {
378
380
  * check has no findings and stays ok; the shape check is the failure.
379
381
  */
380
382
  const SHAPE_UNCHECKED = "not checked, because run.json failed the shape check";
381
- /** The bundle-content checks, in report order. A bundle that fails its shape guard has no findings. */
382
- async function evidenceChecks(runPaths, bundle, cleanupRead) {
383
+ /**
384
+ * The bundle-content checks, in report order. A bundle that fails its shape guard has no findings.
385
+ * `refusedStreams` names the streams whose traces the loader refused, which the evidence-artifacts
386
+ * check reports.
387
+ */
388
+ async function evidenceChecks(runPaths, bundle, cleanupRead, refusedStreams) {
383
389
  const cleanupJson = runJsonValue(cleanupRead);
384
390
  const valid = isRunBundle(bundle) ? bundle : null;
385
391
  const pass = (message) => (valid ? message : SHAPE_UNCHECKED);
@@ -409,7 +415,7 @@ async function evidenceChecks(runPaths, bundle, cleanupRead) {
409
415
  // Absence passes (fail-open on display); a claimed dollar figure without its ratesAsOf date +
410
416
  // source, or a total that does not match its known lines, fails. Magnitude is never inspected:
411
417
  // a correctly-labeled huge estimate still passes.
412
- findingsCheck("cost estimate labeling", valid ? costAndReceiptFindings(valid) : [], pass("every cost estimate carries its ratesAsOf date and source and the total matches its lines, or the run claims no cost"), "cost labeling findings"),
418
+ findingsCheck("cost estimate labeling", valid ? costAndReceiptFindings(valid, refusedStreams) : [], pass("every cost estimate carries its ratesAsOf date and source and the total matches its lines, or the run claims no cost"), "cost labeling findings"),
413
419
  ];
414
420
  return checks;
415
421
  }