humanish 0.127.0 → 0.128.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 (42) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/CONTEXT.md +2 -2
  3. package/README.md +1 -1
  4. package/dist/actors/closing-report.d.ts +8 -1
  5. package/dist/actors/closing-report.js +4 -1
  6. package/dist/actors/codex/restricted-participant-policy.js +2 -5
  7. package/dist/actors/computer-use/loop/debrief.js +6 -5
  8. package/dist/actors/computer-use/loop/ending.d.ts +5 -4
  9. package/dist/actors/computer-use/loop/ending.js +5 -4
  10. package/dist/actors/computer-use/loop/output-limit.js +3 -0
  11. package/dist/actors/computer-use/loop/types.d.ts +16 -10
  12. package/dist/actors/computer-use/loop.js +8 -3
  13. package/dist/actors/contract.d.ts +10 -6
  14. package/dist/cli/commands/study-format.js +13 -2
  15. package/dist/feedback/export.js +3 -2
  16. package/dist/observer/data.d.ts +13 -6
  17. package/dist/observer/data.js +40 -10
  18. package/dist/observer-app.html +8 -8
  19. package/dist/routes/computer-use/fanout-bundle.js +25 -6
  20. package/dist/routes/computer-use/participant-model.js +1 -1
  21. package/dist/run/bundle.d.ts +7 -4
  22. package/dist/run/bundle.js +10 -4
  23. package/dist/run/detail.js +8 -2
  24. package/dist/run/locate.d.ts +2 -2
  25. package/dist/run/locate.js +2 -2
  26. package/dist/run/run.js +11 -3
  27. package/dist/run/stream-trace.d.ts +17 -7
  28. package/dist/run/stream-trace.js +135 -29
  29. package/dist/run/streams.d.ts +4 -1
  30. package/dist/study/bundle-estimate.js +17 -17
  31. package/dist/verify/costs.d.ts +7 -3
  32. package/dist/verify/costs.js +27 -12
  33. package/dist/verify/verify.js +14 -8
  34. package/docs/architecture/actor-contract.md +7 -3
  35. package/docs/architecture/github-feedback-loop.md +1 -1
  36. package/docs/architecture/observer.md +19 -10
  37. package/docs/contracts/README.md +1 -1
  38. package/docs/contracts/run-bundle.md +93 -15
  39. package/docs/contracts/schemas.md +55 -45
  40. package/docs/contracts/study-analysis.md +1 -1
  41. package/docs/decisions/0002-observer-is-one-self-contained-file.md +1 -1
  42. package/package.json +1 -1
@@ -37,14 +37,18 @@ function fanoutPlanEvents(args) {
37
37
  }
38
38
  /**
39
39
  * The run's summary in plain sentences: who took part, what happened to them with the denominator
40
- * attached, and that the gaps list the participants who did not pass.
40
+ * attached, and that the gaps list the participants who did not pass. A participant the run
41
+ * skipped before its session started never used the app, so it is counted in a sentence of its
42
+ * own.
41
43
  */
42
44
  function fanoutSummary(args, facts) {
43
45
  const count = args.specs.length;
44
46
  const people = plural(count, "participant");
45
47
  const rerun = args.rerun === undefined ? "" : `Rerun from ${args.rerun.sourceRunId}: `;
48
+ // The live summary is written once, before any participant starts, so it names the plan: by
49
+ // the time it is read a participant may still be waiting, or skipped.
46
50
  if (args.inProgress === true) {
47
- return `${people} ${count === 1 ? "is" : "are"} each using their own copy of the app. The run is still going, so nothing here is final.`;
51
+ return `${people} ${count === 1 ? "is planned, in its own copy" : "are planned, each in their own copy"} of the app. The run is still going, so nothing here is final.`;
48
52
  }
49
53
  if (args.dryRun) {
50
54
  return `${rerun}Dry run: ${people} would each use their own copy of ${subjectName(publicSafeAppUrlLabel(args.appUrl), args.aggregateSubject)}. No desktops were launched and $0 was spent.`;
@@ -52,7 +56,20 @@ function fanoutSummary(args, facts) {
52
56
  const tasks = facts.tasks === undefined ? "" : ` Tasks: ${facts.tasks}.`;
53
57
  // A host suspension comes first: it may explain everything after it.
54
58
  const host = args.hostSuspension === undefined ? "" : `${args.hostSuspension.summary} `;
55
- return `${host}${rerun}${people} took part, each in their own copy of the app: ${facts.outcomes}${gapsListClause(count - facts.passed, count)}.${tasks}`;
59
+ const tookPart = count - facts.skipped;
60
+ const outcomes = facts.outcomes ?? `0/${tookPart} finished a session`;
61
+ const gaps = gapsListClause(tookPart - facts.passed, tookPart);
62
+ const sentences = [];
63
+ // A run where every participant was skipped has no one to describe in this sentence.
64
+ if (tookPart > 0)
65
+ sentences.push(`${plural(tookPart, "participant")} took part, ${tookPart === 1 ? "in its own copy" : "each in their own copy"} of the app: ${outcomes}${gaps}.`);
66
+ // The gaps are pointed at once: here only when the sentence before did not.
67
+ const why = gaps === "" ? "; the gaps say why" : "";
68
+ if (facts.skipped === 1)
69
+ sentences.push(`1 participant was skipped before its session started${why}.`);
70
+ if (facts.skipped > 1)
71
+ sentences.push(`${facts.skipped} participants were skipped before their sessions started${why}.`);
72
+ return `${host}${rerun}${sentences.join(" ")}${tasks}`;
56
73
  }
57
74
  /**
58
75
  * The review's gaps: one line for the participants a host suspension likely failed, then a line
@@ -75,6 +92,7 @@ function fanoutReview(args, streams, judgment) {
75
92
  // The judge's verdict: live fan-out must prove every participant (judgeParticipants).
76
93
  const verdict = args.verdict;
77
94
  const passedParticipants = judgment.participants.filter((participant) => participant.judgedStatus === "passed").length;
95
+ const skippedParticipants = (outcomes ?? []).filter((outcome) => outcome?.skippedReason !== undefined).length;
78
96
  // What happened to the participants, with the denominator attached. The verdict above has to
79
97
  // collapse the run to one word; this does not (docs/principles/three-roles.md).
80
98
  const terminalOutcomes = (outcomes ?? []).filter((outcome) => outcome?.session?.status !== undefined);
@@ -103,9 +121,10 @@ function fanoutReview(args, streams, judgment) {
103
121
  // sandbox, so the summary carries its digest, which verify accepts in a shared run.
104
122
  summary: fanoutSummary(args, {
105
123
  passed: passedParticipants,
106
- outcomes: participants === undefined
107
- ? `0/${args.specs.length} finished a session`
108
- : formatParticipantOutcomes(participants, participantEndings),
124
+ skipped: skippedParticipants,
125
+ ...(participants === undefined
126
+ ? {}
127
+ : { outcomes: formatParticipantOutcomes(participants, participantEndings) }),
109
128
  ...(runTasks === undefined ? {} : { tasks: formatRunTaskFunnel(runTasks) }),
110
129
  }),
111
130
  gaps: args.inProgress === true
@@ -153,7 +153,7 @@ export function participantSessionOptions(spec, deps, ready, provider, writeScre
153
153
  const crossed = deps.runBudget.crossed();
154
154
  return crossed === undefined
155
155
  ? null
156
- : `study budget reached: ${crossed}; this lane stops here and sibling lanes stop at their next turn`;
156
+ : `study budget reached: ${crossed}; the study's participants stop before their next model request`;
157
157
  },
158
158
  }),
159
159
  ...(deps.onObservedUrl === undefined ? {} : { onObservedUrl: deps.onObservedUrl }),
@@ -7,14 +7,17 @@ import type { RunInterruptSignal } from "./status.js";
7
7
  import type { RunSimulationStatus, RunStream, RunStreamKind } from "./streams.js";
8
8
  /**
9
9
  * The run bundle version this release writes: each stream's trace, and each running participant's
10
- * items so far, are files run.json indexes.
10
+ * items so far, are files run.json indexes, and each participant's per-request lists are only in
11
+ * its trace file (REQUEST_LISTS in src/run/stream-trace.ts).
11
12
  */
12
- export declare const RUN_BUNDLE_SCHEMA = "humanish.run-bundle.v3";
13
+ export declare const RUN_BUNDLE_SCHEMA = "humanish.run-bundle.v4";
14
+ /** The version 0.125.0 and 0.126.0 wrote, with the per-request lists in run.json too. It is still read. */
15
+ export declare const RUN_BUNDLE_V3_SCHEMA = "humanish.run-bundle.v3";
13
16
  /** The version 0.123.0 and 0.124.0 wrote, with a running participant's items inline. It is still read. */
14
17
  export declare const RUN_BUNDLE_V2_SCHEMA = "humanish.run-bundle.v2";
15
18
  /** The version 0.122.0 and earlier wrote, with every trace inline. It is still read. */
16
19
  export declare const RUN_BUNDLE_V1_SCHEMA = "humanish.run-bundle.v1";
17
- /** Whether `value` names a run bundle version this release reads: v3, v2 or v1. */
20
+ /** Whether `value` names a run bundle version this release reads: v4, v3, v2 or v1. */
18
21
  export declare function isReadRunBundleSchema(value: unknown): value is RunBundle["schema"];
19
22
  /** The run bundle file, relative to the run directory. */
20
23
  export declare const RUN_BUNDLE_FILE = "run.json";
@@ -238,7 +241,7 @@ export interface RunBundle {
238
241
  restrictions: ["real-communications"];
239
242
  };
240
243
  commsReceiving?: CommsReceivingEvidence;
241
- schema: typeof RUN_BUNDLE_SCHEMA | typeof RUN_BUNDLE_V2_SCHEMA | typeof RUN_BUNDLE_V1_SCHEMA;
244
+ schema: typeof RUN_BUNDLE_SCHEMA | typeof RUN_BUNDLE_V3_SCHEMA | typeof RUN_BUNDLE_V2_SCHEMA | typeof RUN_BUNDLE_V1_SCHEMA;
242
245
  runId: string;
243
246
  mode: "dry-run" | "live";
244
247
  simCount: number;
@@ -3,16 +3,22 @@ import { captureGitState } from "./git-state.js";
3
3
  import { studyFields } from "./study-provenance.js";
4
4
  /**
5
5
  * The run bundle version this release writes: each stream's trace, and each running participant's
6
- * items so far, are files run.json indexes.
6
+ * items so far, are files run.json indexes, and each participant's per-request lists are only in
7
+ * its trace file (REQUEST_LISTS in src/run/stream-trace.ts).
7
8
  */
8
- export const RUN_BUNDLE_SCHEMA = "humanish.run-bundle.v3";
9
+ export const RUN_BUNDLE_SCHEMA = "humanish.run-bundle.v4";
10
+ /** The version 0.125.0 and 0.126.0 wrote, with the per-request lists in run.json too. It is still read. */
11
+ export const RUN_BUNDLE_V3_SCHEMA = "humanish.run-bundle.v3";
9
12
  /** The version 0.123.0 and 0.124.0 wrote, with a running participant's items inline. It is still read. */
10
13
  export const RUN_BUNDLE_V2_SCHEMA = "humanish.run-bundle.v2";
11
14
  /** The version 0.122.0 and earlier wrote, with every trace inline. It is still read. */
12
15
  export const RUN_BUNDLE_V1_SCHEMA = "humanish.run-bundle.v1";
13
- /** Whether `value` names a run bundle version this release reads: v3, v2 or v1. */
16
+ /** Whether `value` names a run bundle version this release reads: v4, v3, v2 or v1. */
14
17
  export function isReadRunBundleSchema(value) {
15
- return (value === RUN_BUNDLE_SCHEMA || value === RUN_BUNDLE_V2_SCHEMA || value === RUN_BUNDLE_V1_SCHEMA);
18
+ return (value === RUN_BUNDLE_SCHEMA ||
19
+ value === RUN_BUNDLE_V3_SCHEMA ||
20
+ value === RUN_BUNDLE_V2_SCHEMA ||
21
+ value === RUN_BUNDLE_V1_SCHEMA);
16
22
  }
17
23
  /** The run bundle file, relative to the run directory. */
18
24
  export const RUN_BUNDLE_FILE = "run.json";
@@ -72,7 +72,7 @@ function participantFrom(stream, index, savedPersonaId) {
72
72
  ...(typeof trace.counts?.turns === "number" ? { turns: trace.counts.turns } : {}),
73
73
  ...(typeof trace.counts?.actions === "number" ? { actions: trace.counts.actions } : {}),
74
74
  ...(thoughts > 0 ? { thoughts } : {}),
75
- ...costOf(trace),
75
+ ...costOf(trace, stream.traceUnavailable === undefined),
76
76
  ...(stream.traceUnavailable === undefined
77
77
  ? {}
78
78
  : { evidenceUnavailable: stream.traceUnavailable }),
@@ -85,13 +85,19 @@ function participantFrom(stream, index, savedPersonaId) {
85
85
  * model it prices at, so the same estimator gives a figure mid-run instead of the screen reporting
86
86
  * the cost as unknown until the moment the run ends. That is the half of a run where knowing what
87
87
  * it is costing actually changes what you do.
88
+ *
89
+ * Without `rowsLoaded` (the loader refused the participant's trace) only a recorded figure is
90
+ * shown: long-context pricing needs each request's input size, and the totals alone can price
91
+ * under half of what the rows give.
88
92
  */
89
- function costOf(trace) {
93
+ function costOf(trace, rowsLoaded) {
90
94
  if (trace.executionProfile?.billing === "account-unknown")
91
95
  return { estimatedCostUsd: null };
92
96
  const recorded = trace.estimatedCost?.estimatedCostUsd;
93
97
  if (recorded !== undefined)
94
98
  return { estimatedCostUsd: recorded };
99
+ if (!rowsLoaded)
100
+ return {};
95
101
  const usage = trace.tokenUsage;
96
102
  const model = trace.ids?.model;
97
103
  if (usage === undefined || typeof model !== "string")
@@ -44,8 +44,8 @@ export declare function readSafeRunArtifactJson(runPaths: PreparedRunArtifactPat
44
44
  * A run's bundle as `humanish verify` reads it, for a reader outside humanish: run.json of run
45
45
  * `runInput` (a run id, or `latest`) under `cwdInput`'s `.humanish/runs/`, with every participant's
46
46
  * trace file and, while the run is in progress, live file joined, so each stream has its
47
- * `actor.items`, its screenshot entries and its `liveActor.items` as a v1 run.json held them. It
48
- * reads run bundles v1, v2 and v3. Each file must match the index run.json records for it (path,
47
+ * `actor.items`, its per-request lists, its screenshot entries and its `liveActor.items` as a v1
48
+ * run.json held them. It reads run bundles v1, v2, v3 and v4. Each file must match the index run.json records for it (path,
49
49
  * size, sha256, item count); one that does not throws an Error named `StreamTraceRefusedError`
50
50
  * that names the participant. Resolves to null when there is no such run, or its run.json is not
51
51
  * a run bundle. `bundlePath` is run.json relative to `cwdInput`, `runDir` the run folder.
@@ -105,8 +105,8 @@ export async function readSafeRunArtifactJson(runPaths, relativePath) {
105
105
  * A run's bundle as `humanish verify` reads it, for a reader outside humanish: run.json of run
106
106
  * `runInput` (a run id, or `latest`) under `cwdInput`'s `.humanish/runs/`, with every participant's
107
107
  * trace file and, while the run is in progress, live file joined, so each stream has its
108
- * `actor.items`, its screenshot entries and its `liveActor.items` as a v1 run.json held them. It
109
- * reads run bundles v1, v2 and v3. Each file must match the index run.json records for it (path,
108
+ * `actor.items`, its per-request lists, its screenshot entries and its `liveActor.items` as a v1
109
+ * run.json held them. It reads run bundles v1, v2, v3 and v4. Each file must match the index run.json records for it (path,
110
110
  * size, sha256, item count); one that does not throws an Error named `StreamTraceRefusedError`
111
111
  * that names the participant. Resolves to null when there is no such run, or its run.json is not
112
112
  * a run bundle. `bundlePath` is run.json relative to `cwdInput`, `runDir` the run folder.
package/dist/run/run.js CHANGED
@@ -122,8 +122,9 @@ function observerTarget(options) {
122
122
  * run.json for `bundle`, written after every live file and trace file it indexes, then the removal
123
123
  * of the live files it no longer names. Returns the bundle the review and the projections read:
124
124
  * raw sandbox ids scrubbed, each finished trace inline, each running participant's items as the
125
- * index of its live file, and the live files' warning among the events. Live items are scrubbed
126
- * as they are appended, so the scrub of the bundle no longer walks them.
125
+ * index of its live file with the estimate run.json records for it, and the live files' warning
126
+ * among the events. Live items are scrubbed as they are appended, so the scrub of the bundle no
127
+ * longer walks them.
127
128
  */
128
129
  async function writeRunJson(paths, liveItems, bundle) {
129
130
  const sandboxIds = await readRunSandboxIds(paths);
@@ -138,7 +139,14 @@ async function writeRunJson(paths, liveItems, bundle) {
138
139
  await writeContainedOutputFile(paths, file.path, file.bytes);
139
140
  await writeContainedOutputFile(paths, RUN_BUNDLE_FILE, json(split.bundle), "utf8");
140
141
  await live.published();
141
- return publicBundle;
142
+ // The split prices each running participant from the rows it leaves out of run.json.
143
+ const streams = publicBundle.streams.map((stream, index) => {
144
+ const estimate = split.bundle.streams[index]?.liveActor?.estimatedCost;
145
+ return stream.liveActor === undefined || estimate === undefined
146
+ ? stream
147
+ : { ...stream, liveActor: { ...stream.liveActor, estimatedCost: estimate } };
148
+ });
149
+ return { ...publicBundle, streams };
142
150
  }
143
151
  function runPublisher(args) {
144
152
  const { options, runId, createdAt, paths, runStatus, now, admit, participantsRan } = args;
@@ -7,12 +7,15 @@ export declare class StreamTraceRefusedError extends Error {
7
7
  readonly streamId: string;
8
8
  constructor(streamId: string, detail: string);
9
9
  }
10
+ /** `trace` without the per-request lists; every other field as it is. */
11
+ export declare function withoutRequestLists<T extends object>(trace: T): T;
10
12
  /**
11
13
  * The run.json the run writes, and the trace files it writes first. Each stream with a finished
12
- * actor keeps that actor without `items`, its artifacts other than screenshots, and an index of its
13
- * trace file; the file is the actor trace with the stream's screenshot entries as `artifacts`. A
14
- * stream already split (its actor read back from a v2 run.json without its items) keeps its index,
15
- * since its file is on disk.
14
+ * actor keeps that actor without `items` and the per-request lists, its artifacts other than
15
+ * screenshots, and an index of its trace file; the file is the whole actor trace with the stream's
16
+ * screenshot entries as `artifacts`. A stream already split (its actor read back from run.json
17
+ * without its items) keeps its index, since its file is on disk, and loses any lists an older
18
+ * run.json held beside it. A running participant's live actor is written by liveSummary.
16
19
  */
17
20
  export declare function splitStreamTraces(bundle: RunBundle): {
18
21
  bundle: RunBundle;
@@ -54,6 +57,12 @@ type LiveAfter = (index: LiveItemsIndex) => LiveItemsCursor | undefined;
54
57
  * actor, two participants indexing one file) are loadStreamTraces'.
55
58
  */
56
59
  export declare function loadStreamTrace(runPaths: PreparedOutputRoot, stream: RunStream, maxBytes?: number): Promise<RunStream>;
60
+ /**
61
+ * Whether a run.json of `schema` holds the per-request lists (REQUEST_LISTS) the writer kept in
62
+ * it: v1 to v3, and a schema WRITER_RULES does not name, which is read as v1. From v4 they are
63
+ * only in the trace file, so a running participant's `liveActor` holds none.
64
+ */
65
+ export declare const keepsRequestListsInRunJson: (schema: string) => boolean;
57
66
  interface LoadedTraces {
58
67
  bundle: RunBundle;
59
68
  refused: StreamTraceRefusedError[];
@@ -85,9 +94,10 @@ export declare function loadStreamTraces(source: PreparedOutputRoot | TraceFileR
85
94
  }): Promise<LoadedTraces>;
86
95
  /**
87
96
  * A loaded bundle as a reader that shows a run presents it. A refused stream keeps run.json's
88
- * summary, shows none of the items run.json or a file supplied for it (its actor's items, its
89
- * in-progress items, its screenshot entries), and carries `traceUnavailable` with the reason; a
90
- * warn event on its timeline says the same.
97
+ * summary, shows none of the items or per-request lists run.json or a file supplied for it (its
98
+ * actor's items and lists, its in-progress items, its screenshot entries), and carries
99
+ * `traceUnavailable` with the reason; a warn event on its timeline says the same. A reader that
100
+ * shows the run then never prices or checks rows the loader refused.
91
101
  */
92
102
  export declare function displayedTraces(loaded: {
93
103
  bundle: RunBundle;
@@ -1,14 +1,16 @@
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
16
  /** A trace that does not match its index, or cannot be read. The message names the stream. */
@@ -43,12 +45,64 @@ function tracePath(stream, index, claimed) {
43
45
  !POSITION_TRACE_FILE.test(artifact.path))?.path;
44
46
  return own !== undefined && !claimed.has(traceFileKey(own)) ? own : `actors/${index + 1}.json`;
45
47
  }
48
+ /**
49
+ * A participant's lists that gain one entry per model request, as paths into its actor trace. From
50
+ * run bundle v4 run.json leaves them out of `actor` and `liveActor`, and the trace file alone holds
51
+ * them; the Observer's data leaves them out too.
52
+ */
53
+ const REQUEST_LISTS = ["tokenUsage.turns", "conversation.requests", "providerRequests"];
54
+ /** The value at `list`'s path in `trace`, when the object holding it is there. */
55
+ function listIn(trace, list) {
56
+ const [first, second] = list.split(".");
57
+ if (second === undefined)
58
+ return trace[first];
59
+ const parent = trace[first];
60
+ return isRecord(parent) ? parent[second] : undefined;
61
+ }
62
+ /** `trace` without the per-request lists; every other field as it is. */
63
+ export function withoutRequestLists(trace) {
64
+ const copy = { ...trace };
65
+ for (const list of REQUEST_LISTS) {
66
+ const [first, second] = list.split(".");
67
+ const parent = copy[first];
68
+ if (second === undefined)
69
+ delete copy[first];
70
+ else if (isRecord(parent) && second in parent) {
71
+ const { [second]: _list, ...rest } = parent;
72
+ copy[first] = rest;
73
+ }
74
+ }
75
+ return copy;
76
+ }
77
+ /** The first per-request list `trace` holds, if any. */
78
+ function heldRequestList(trace) {
79
+ return REQUEST_LISTS.find((list) => listIn(trace, list) !== undefined);
80
+ }
81
+ /**
82
+ * run.json's live actor: without the per-request lists, and with the estimate priced from the
83
+ * rows it leaves out. Long-context pricing needs each request's input size, so a figure priced
84
+ * from the totals alone could be under half the real one. A live actor read back from run.json
85
+ * has no rows and keeps the estimate they gave.
86
+ */
87
+ function liveSummary(live) {
88
+ const summary = withoutRequestLists(live);
89
+ const model = live.ids?.model;
90
+ if (live.tokenUsage === undefined || typeof model !== "string")
91
+ return summary;
92
+ if (live.tokenUsage.turns === undefined && live.estimatedCost !== undefined)
93
+ return summary;
94
+ return {
95
+ ...summary,
96
+ estimatedCost: estimateActorCostForExecution(live.tokenUsage, model, live.executionProfile),
97
+ };
98
+ }
46
99
  /**
47
100
  * 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.
101
+ * actor keeps that actor without `items` and the per-request lists, its artifacts other than
102
+ * screenshots, and an index of its trace file; the file is the whole actor trace with the stream's
103
+ * screenshot entries as `artifacts`. A stream already split (its actor read back from run.json
104
+ * without its items) keeps its index, since its file is on disk, and loses any lists an older
105
+ * run.json held beside it. A running participant's live actor is written by liveSummary.
52
106
  */
53
107
  export function splitStreamTraces(bundle) {
54
108
  const files = [];
@@ -56,10 +110,15 @@ export function splitStreamTraces(bundle) {
56
110
  for (const stream of bundle.streams)
57
111
  if (stream.trace)
58
112
  claimed.add(traceFileKey(stream.trace.path));
59
- const streams = bundle.streams.map((stream, index) => {
113
+ const streams = bundle.streams.map((written, index) => {
114
+ const stream = written.liveActor === undefined
115
+ ? written
116
+ : { ...written, liveActor: liveSummary(written.liveActor) };
60
117
  const actor = stream.actor;
61
- if (actor === undefined || !Array.isArray(actor.items))
118
+ if (actor === undefined)
62
119
  return stream;
120
+ if (!Array.isArray(actor.items))
121
+ return { ...stream, actor: withoutRequestLists(actor) };
63
122
  const { trace: _previous, ...rest } = stream;
64
123
  const filePath = tracePath(rest, index, claimed);
65
124
  // Only a stream already split that moved position can hold this stream's position name.
@@ -72,8 +131,8 @@ export function splitStreamTraces(bundle) {
72
131
  const { items, ...summary } = actor;
73
132
  return {
74
133
  ...rest,
75
- // run.json's actor is the summary: every field of the trace except its items.
76
- actor: summary,
134
+ // run.json's actor is the summary: every field of the trace except its items and lists.
135
+ actor: withoutRequestLists(summary),
77
136
  artifacts: stream.artifacts.filter((artifact) => !isScreenshot(artifact)),
78
137
  trace: {
79
138
  path: filePath,
@@ -125,13 +184,38 @@ function joinTrace(stream, bytes) {
125
184
  refuse(`${index.path} lists an artifact that is not a screenshot entry`);
126
185
  if (value.items.length !== index.items || artifacts.length !== index.screenshots)
127
186
  refuse(`${index.path} holds ${value.items.length} items and ${artifacts.length} screenshots and run.json records ${index.items} and ${index.screenshots}`);
187
+ const actor = joinRequestLists(stream.actor, value, index.path, refuse);
128
188
  const { trace: _index, ...rest } = stream;
129
189
  return {
130
190
  ...rest,
131
- actor: { ...stream.actor, items: value.items },
191
+ actor: { ...actor, items: value.items },
132
192
  artifacts: [...stream.artifacts, ...artifacts],
133
193
  };
134
194
  }
195
+ /**
196
+ * run.json's actor with each per-request list its trace file holds, whatever the bundle's
197
+ * version: before v4 the writer wrote run.json's lists and the file's from one actor, so they are
198
+ * equal. A nested list goes into run.json's `tokenUsage` or `conversation` when run.json has it.
199
+ */
200
+ function joinRequestLists(summary, file, filePath, refuse) {
201
+ if (isRecord(file.conversation) && !Array.isArray(file.conversation.requests))
202
+ refuse(`${filePath} records a conversation without its requests list`);
203
+ const joined = { ...summary };
204
+ for (const list of REQUEST_LISTS) {
205
+ const value = listIn(file, list);
206
+ if (value === undefined)
207
+ continue;
208
+ if (!Array.isArray(value))
209
+ refuse(`${filePath} holds ${list} as something other than a list`);
210
+ const [first, second] = list.split(".");
211
+ const parent = joined[first];
212
+ if (second === undefined)
213
+ joined[first] = value;
214
+ else if (isRecord(parent))
215
+ joined[first] = { ...parent, [second]: value };
216
+ }
217
+ return joined;
218
+ }
135
219
  /** Refuses an index the shape check would refuse, naming a file that is not a trace file name. */
136
220
  function assertIndex(stream) {
137
221
  const index = stream.trace;
@@ -242,16 +326,30 @@ async function readStreamTrace(read, stream, maxBytes, liveAfter) {
242
326
  export async function loadStreamTrace(runPaths, stream, maxBytes = RUN_ARTIFACT_MAX_BYTES) {
243
327
  return (await readStreamTrace(containedReader(runPaths), stream, maxBytes)).stream;
244
328
  }
329
+ /**
330
+ * What each version's writer never puts in run.json, so a run.json of that version that holds it
331
+ * did not come from the writer. From v2 the writer indexes every actor on every write, mid-run
332
+ * flushes included, and the final write replaces `liveActor`. From v3 a running participant's
333
+ * items are in a live file (humanish.live-actor.v2). From v4 the per-request lists are only in
334
+ * the trace file. A run.json of v1, or of no version this table names, is read as v1 is.
335
+ */
336
+ const WRITER_RULES = new Map([
337
+ [RUN_BUNDLE_V2_SCHEMA, { liveFiles: false, listsOut: false }],
338
+ [RUN_BUNDLE_V3_SCHEMA, { liveFiles: true, listsOut: false }],
339
+ [RUN_BUNDLE_SCHEMA, { liveFiles: true, listsOut: true }],
340
+ ]);
341
+ /**
342
+ * Whether a run.json of `schema` holds the per-request lists (REQUEST_LISTS) the writer kept in
343
+ * it: v1 to v3, and a schema WRITER_RULES does not name, which is read as v1. From v4 they are
344
+ * only in the trace file, so a running participant's `liveActor` holds none.
345
+ */
346
+ export const keepsRequestListsInRunJson = (schema) => WRITER_RULES.get(schema)?.listsOut !== true;
245
347
  /**
246
348
  * Why a stream of this bundle cannot be read, before any file is. A trace file belongs to one
247
349
  * 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.
350
+ * case-insensitive file system compares them, is refused in every version. The other rules are
351
+ * WRITER_RULES': a v2 actor without an index lost its trace file, and a run.json whose outcome says
352
+ * the run finished never carries `liveActor`.
255
353
  */
256
354
  function bundleRefusal(bundle, stream, owners) {
257
355
  const shared = isRecord(stream.trace)
@@ -259,16 +357,23 @@ function bundleRefusal(bundle, stream, owners) {
259
357
  : undefined;
260
358
  if (shared !== undefined && shared.length > 1)
261
359
  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)
360
+ const rules = WRITER_RULES.get(String(bundle.schema));
361
+ if (rules === undefined)
263
362
  return undefined;
363
+ const version = String(bundle.schema).replace("humanish.run-bundle.", "");
264
364
  if (stream.actor !== undefined && stream.trace === undefined)
265
365
  return "run.json holds its actor and names no trace file";
266
366
  if (stream.liveActor !== undefined && bundle.outcome?.state === "finished")
267
367
  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";
368
+ if (rules.liveFiles && stream.liveActor !== undefined && isInlineLiveActor(stream.liveActor))
369
+ return `run.json ${version} holds in-progress items inline, which the writer keeps in a live file`;
370
+ if (rules.listsOut)
371
+ for (const field of ["actor", "liveActor"]) {
372
+ const trace = stream[field];
373
+ const list = isRecord(trace) ? heldRequestList(trace) : undefined;
374
+ if (list !== undefined)
375
+ return `run.json ${version} holds ${list} in its ${field}, which the writer keeps only in the trace file`;
376
+ }
272
377
  return undefined;
273
378
  }
274
379
  /**
@@ -348,9 +453,10 @@ async function loadEveryStream(read, bundle, maxBytes, liveAfter) {
348
453
  }
349
454
  /**
350
455
  * 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.
456
+ * summary, shows none of the items or per-request lists run.json or a file supplied for it (its
457
+ * actor's items and lists, its in-progress items, its screenshot entries), and carries
458
+ * `traceUnavailable` with the reason; a warn event on its timeline says the same. A reader that
459
+ * shows the run then never prices or checks rows the loader refused.
354
460
  */
355
461
  export function displayedTraces(loaded) {
356
462
  const reasons = new Map(loaded.refused.map((error) => [error.streamId, error.message]));
@@ -363,7 +469,7 @@ export function displayedTraces(loaded) {
363
469
  const { liveActor: _live, trace: _index, actor, ...rest } = stream;
364
470
  return {
365
471
  ...rest,
366
- ...(actor === undefined ? {} : { actor: { ...actor, items: [] } }),
472
+ ...(actor === undefined ? {} : { actor: { ...withoutRequestLists(actor), items: [] } }),
367
473
  artifacts: stream.artifacts.filter((artifact) => !isScreenshot(artifact)),
368
474
  traceUnavailable: reason,
369
475
  };
@@ -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[];