@llm4ts/runner 3.3.0 → 4.0.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 (130) hide show
  1. package/README.md +1 -1
  2. package/dist/AgentTree.d.ts +2 -3
  3. package/dist/AgentTree.d.ts.map +1 -1
  4. package/dist/AgentTree.js +8 -20
  5. package/dist/AgentTree.js.map +1 -1
  6. package/dist/AgentTreeSurface.d.ts +1 -1
  7. package/dist/AgentTreeSurface.d.ts.map +1 -1
  8. package/dist/AgentTreeSurface.js +2 -5
  9. package/dist/AgentTreeSurface.js.map +1 -1
  10. package/dist/Cli.d.ts.map +1 -1
  11. package/dist/Cli.js +2 -1
  12. package/dist/Cli.js.map +1 -1
  13. package/dist/Connectors.d.ts +27 -2
  14. package/dist/Connectors.d.ts.map +1 -1
  15. package/dist/Connectors.js +76 -89
  16. package/dist/Connectors.js.map +1 -1
  17. package/dist/Doctor.d.ts.map +1 -1
  18. package/dist/Doctor.js +8 -11
  19. package/dist/Doctor.js.map +1 -1
  20. package/dist/EventPresentation.d.ts +35 -0
  21. package/dist/EventPresentation.d.ts.map +1 -0
  22. package/dist/EventPresentation.js +132 -0
  23. package/dist/EventPresentation.js.map +1 -0
  24. package/dist/ExecutorRoster.d.ts.map +1 -1
  25. package/dist/ExecutorRoster.js +4 -6
  26. package/dist/ExecutorRoster.js.map +1 -1
  27. package/dist/FlowArgs.d.ts +1 -1
  28. package/dist/FlowArgs.d.ts.map +1 -1
  29. package/dist/FlowArgs.js +4 -2
  30. package/dist/FlowArgs.js.map +1 -1
  31. package/dist/FlowRunner.d.ts +13 -4
  32. package/dist/FlowRunner.d.ts.map +1 -1
  33. package/dist/FlowRunner.js +50 -39
  34. package/dist/FlowRunner.js.map +1 -1
  35. package/dist/NodeGeminiCliExecutor.d.ts.map +1 -1
  36. package/dist/NodeGeminiCliExecutor.js +7 -4
  37. package/dist/NodeGeminiCliExecutor.js.map +1 -1
  38. package/dist/NodeGeminiModelBridge.d.ts.map +1 -1
  39. package/dist/NodeGeminiModelBridge.js +4 -1
  40. package/dist/NodeGeminiModelBridge.js.map +1 -1
  41. package/dist/NodeHttpClient.d.ts.map +1 -1
  42. package/dist/NodeHttpClient.js +1 -0
  43. package/dist/NodeHttpClient.js.map +1 -1
  44. package/dist/Otel.d.ts.map +1 -1
  45. package/dist/Otel.js +5 -5
  46. package/dist/Otel.js.map +1 -1
  47. package/dist/Package.d.ts.map +1 -1
  48. package/dist/Package.js +1 -0
  49. package/dist/Package.js.map +1 -1
  50. package/dist/Packs.d.ts.map +1 -1
  51. package/dist/Packs.js +2 -1
  52. package/dist/Packs.js.map +1 -1
  53. package/dist/PiModels.d.ts.map +1 -1
  54. package/dist/PiModels.js +6 -9
  55. package/dist/PiModels.js.map +1 -1
  56. package/dist/Terminal.d.ts +13 -11
  57. package/dist/Terminal.d.ts.map +1 -1
  58. package/dist/Terminal.js +24 -80
  59. package/dist/Terminal.js.map +1 -1
  60. package/dist/Tools.d.ts.map +1 -1
  61. package/dist/Tools.js +2 -1
  62. package/dist/Tools.js.map +1 -1
  63. package/dist/Transcripts.d.ts +1 -1
  64. package/dist/Transcripts.d.ts.map +1 -1
  65. package/dist/Transcripts.js +3 -2
  66. package/dist/Transcripts.js.map +1 -1
  67. package/dist/WindowsCommand.d.ts.map +1 -1
  68. package/dist/WindowsCommand.js +1 -0
  69. package/dist/WindowsCommand.js.map +1 -1
  70. package/dist/index.d.ts +1 -2
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +1 -2
  73. package/dist/index.js.map +1 -1
  74. package/package.json +9 -21
  75. package/src/AgentTree.ts +16 -25
  76. package/src/AgentTreeSurface.ts +6 -11
  77. package/src/Cli.ts +2 -1
  78. package/src/Connectors.ts +131 -107
  79. package/src/Doctor.ts +10 -12
  80. package/src/EventPresentation.ts +167 -0
  81. package/src/ExecutorRoster.ts +4 -6
  82. package/src/FlowArgs.ts +7 -2
  83. package/src/FlowRunner.ts +66 -60
  84. package/src/NodeGeminiCliExecutor.ts +8 -4
  85. package/src/NodeGeminiModelBridge.ts +5 -1
  86. package/src/NodeHttpClient.ts +1 -0
  87. package/src/Otel.ts +5 -6
  88. package/src/Package.ts +1 -0
  89. package/src/Packs.ts +2 -1
  90. package/src/PiModels.ts +6 -10
  91. package/src/Terminal.ts +104 -165
  92. package/src/Tools.ts +2 -1
  93. package/src/Transcripts.ts +3 -2
  94. package/src/WindowsCommand.ts +1 -0
  95. package/src/index.ts +3 -2
  96. package/dist/Costs.d.ts +0 -52
  97. package/dist/Costs.d.ts.map +0 -1
  98. package/dist/Costs.js +0 -95
  99. package/dist/Costs.js.map +0 -1
  100. package/dist/McpStdio.d.ts +0 -7
  101. package/dist/McpStdio.d.ts.map +0 -1
  102. package/dist/McpStdio.js +0 -19
  103. package/dist/McpStdio.js.map +0 -1
  104. package/dist/Profile.d.ts +0 -309
  105. package/dist/Profile.d.ts.map +0 -1
  106. package/dist/Profile.js +0 -967
  107. package/dist/Profile.js.map +0 -1
  108. package/dist/ReviewFingerprint.d.ts +0 -2
  109. package/dist/ReviewFingerprint.d.ts.map +0 -1
  110. package/dist/ReviewFingerprint.js +0 -13
  111. package/dist/ReviewFingerprint.js.map +0 -1
  112. package/dist/RunReaders.d.ts +0 -69
  113. package/dist/RunReaders.d.ts.map +0 -1
  114. package/dist/RunReaders.js +0 -283
  115. package/dist/RunReaders.js.map +0 -1
  116. package/dist/StoryReaders.d.ts +0 -16
  117. package/dist/StoryReaders.d.ts.map +0 -1
  118. package/dist/StoryReaders.js +0 -107
  119. package/dist/StoryReaders.js.map +0 -1
  120. package/dist/Watch.d.ts +0 -68
  121. package/dist/Watch.d.ts.map +0 -1
  122. package/dist/Watch.js +0 -261
  123. package/dist/Watch.js.map +0 -1
  124. package/src/Costs.ts +0 -148
  125. package/src/McpStdio.ts +0 -41
  126. package/src/Profile.ts +0 -1213
  127. package/src/ReviewFingerprint.ts +0 -13
  128. package/src/RunReaders.ts +0 -419
  129. package/src/StoryReaders.ts +0 -157
  130. package/src/Watch.ts +0 -446
package/src/Profile.ts DELETED
@@ -1,1213 +0,0 @@
1
- import * as Effect from "effect/Effect"
2
- import * as Schema from "effect/Schema"
3
- import type { FlowError } from "@llm4ts/flow/FlowError"
4
- import type { FlowEvent, Timed } from "@llm4ts/flow/FlowEvents"
5
- import { readTrace } from "@llm4ts/flow/Replay"
6
- import { duration, treeInputsOfTrace, type TreeInput } from "./AgentTree.ts"
7
-
8
- export { duration }
9
- import { formatCount } from "./Terminal.ts"
10
- import {
11
- resolveTraceTarget,
12
- WatchTargetMissing,
13
- type TraceChoice,
14
- type TraceSources
15
- } from "./Watch.ts"
16
-
17
- /**
18
- * `llm4ts profile`: where a run's time went. A pure fold over the
19
- * same inputs as the agent tree: per story, its wall time split into model,
20
- * the coder's tools, gates, merge and waiting, the rest left as unaccounted;
21
- * per role and executor the model calls, per command the gates; and the
22
- * biggest sinks ranked in plain words.
23
- *
24
- * Content-free by design, so a report can leave a customer's server: story
25
- * ids, roles, executor ids, tool names, gate commands as configured, counts
26
- * and durations — never a prompt, a reply, an argument or an output.
27
- *
28
- * A trace from before the `Timed` event (2.22) is estimated: a model call is
29
- * the gap before its token report in the same lane, and the report says so.
30
- */
31
-
32
- const Ms = Schema.Number
33
-
34
- export const ProfileTime = Schema.Struct({
35
- model: Ms,
36
- tools: Ms,
37
- gates: Ms,
38
- merge: Ms,
39
- waiting: Ms,
40
- unaccounted: Ms
41
- })
42
- export type ProfileTime = typeof ProfileTime.Type
43
-
44
- export const TurnStats = Schema.Struct({
45
- count: Schema.Int,
46
- avgMs: Ms,
47
- maxMs: Ms,
48
- firstPrompt: Schema.optionalKey(Schema.Int),
49
- lastPrompt: Schema.optionalKey(Schema.Int),
50
- maxPrompt: Schema.optionalKey(Schema.Int),
51
- /** The last prompt is more than three times the first. */
52
- promptGrowth: Schema.Boolean,
53
- /** Tool calls per finished turn: each is a model round trip. */
54
- avgSteps: Schema.Number,
55
- /** The model's own time per round trip: a turn's time less its tools, over its steps + 1. */
56
- avgStepMs: Ms
57
- })
58
- export type TurnStats = typeof TurnStats.Type
59
-
60
- /** A stretch of a story's time no `Timed` event covers, named by the events around it. */
61
- export const Gap = Schema.Struct({
62
- ms: Ms,
63
- after: Schema.String,
64
- before: Schema.String
65
- })
66
- export type Gap = typeof Gap.Type
67
-
68
- /**
69
- * The coder's tool calls by kind of work (build, install and git count as
70
- * other). `script` is the ad-hoc Python/Node one-liners, heredocs and scratch
71
- * files it improvised; profiles saved before it existed decode it as 0.
72
- */
73
- export const ToolCalls = Schema.Struct({
74
- explore: Schema.Int,
75
- edit: Schema.Int,
76
- test: Schema.Int,
77
- script: Schema.Int.pipe(Schema.withDecodingDefaultKey(Effect.succeed(0))),
78
- other: Schema.Int
79
- })
80
- export type ToolCalls = typeof ToolCalls.Type
81
-
82
- export const StoryProfile = Schema.Struct({
83
- id: Schema.String,
84
- executor: Schema.optionalKey(Schema.String),
85
- status: Schema.Literals(["done", "failed", "running"]),
86
- wallMs: Ms,
87
- /** From the plan being ready to this story starting: dependencies and free coder slots. */
88
- queuedMs: Schema.optionalKey(Ms),
89
- time: ProfileTime,
90
- turns: TurnStats,
91
- /** Its largest untimed stretches, 30 seconds or more: where "unaccounted" sits. */
92
- gaps: Schema.Array(Gap),
93
- /**
94
- * A coder turn the trace shows working (tool calls after its last finished
95
- * call) but not ended: a run still going. Counted as model time so far.
96
- */
97
- openTurnMs: Ms,
98
- openTurnTools: Schema.Int,
99
- toolCalls: ToolCalls,
100
- /** Task stages the coder ran on this lane: the tasks it gave itself, plus any revision. */
101
- tasks: Schema.Int,
102
- /** From the coder's first task (after setup and planning) to its first edit; absent when it never edited. */
103
- firstEditMs: Schema.optionalKey(Ms),
104
- /** Explore calls (ls, find, grep, read) made before that first edit. */
105
- exploreBeforeEdit: Schema.Int,
106
- /** Commands the coder's Findings claimed to have run that no tool call carried (ADR 0027). */
107
- unverifiedClaims: Schema.Int,
108
- /** Tasks whose Findings said `confidence: low`. */
109
- lowConfidenceTasks: Schema.Int,
110
- /** The largest diff its reviewers read in one round, in lines (ADR 0044). */
111
- reviewDiffLines: Schema.optionalKey(Schema.Int)
112
- })
113
- export type StoryProfile = typeof StoryProfile.Type
114
-
115
- export const ModelRow = Schema.Struct({
116
- role: Schema.String,
117
- executor: Schema.optionalKey(Schema.String),
118
- calls: Schema.Int,
119
- ms: Ms,
120
- avgMs: Ms,
121
- avgFirstMs: Schema.optionalKey(Ms),
122
- /** As the backend reported them. */
123
- apiMs: Schema.optionalKey(Ms),
124
- toolMs: Schema.optionalKey(Ms)
125
- })
126
- export type ModelRow = typeof ModelRow.Type
127
-
128
- export const GateRow = Schema.Struct({
129
- command: Schema.String,
130
- runs: Schema.Int,
131
- ms: Ms,
132
- avgMs: Ms,
133
- failed: Schema.Int
134
- })
135
- export type GateRow = typeof GateRow.Type
136
-
137
- export const CountRow = Schema.Struct({ label: Schema.String, count: Schema.Int, ms: Ms })
138
- export type CountRow = typeof CountRow.Type
139
-
140
- export const Finding = Schema.Struct({ text: Schema.String, ms: Schema.optionalKey(Ms) })
141
- export type Finding = typeof Finding.Type
142
-
143
- export const ProfileReport = Schema.Struct({
144
- /** No `Timed` events: model time is estimated from gaps, the rest is unknown. */
145
- estimated: Schema.Boolean,
146
- wallMs: Ms,
147
- stories: Schema.Array(StoryProfile),
148
- /** Stories merged on an earlier run: this run only skipped them. */
149
- skipped: Schema.Array(Schema.String),
150
- totals: ProfileTime,
151
- models: Schema.Array(ModelRow),
152
- gates: Schema.Array(GateRow),
153
- tools: Schema.Array(CountRow),
154
- /** Coder tool time by kind of work: explore, edit, test, script, build, install, git, other. */
155
- toolCategories: Schema.Array(CountRow),
156
- waits: Schema.Array(CountRow),
157
- /** Timed on the run's events, in no story: the run's own planning, or a seat no story owns. */
158
- outside: Schema.Array(CountRow),
159
- findings: Schema.Array(Finding)
160
- })
161
- export type ProfileReport = typeof ProfileReport.Type
162
-
163
- // ── fold ────────────────────────────────────────────────────────────────────
164
-
165
- interface TimedAt {
166
- readonly at: number
167
- readonly event: Timed
168
- }
169
-
170
- interface Lane {
171
- readonly id: string
172
- start: number
173
- end: number | undefined
174
- status: "done" | "failed" | "running"
175
- executor: string | undefined
176
- readonly timed: Array<TimedAt>
177
- /** Coder prompt sizes, in order. */
178
- readonly prompts: Array<number>
179
- /** Estimated mode: the gaps before each token report. */
180
- readonly estimatedCalls: Array<number>
181
- /** The last turn boundary: any lane event but a tool call inside a turn. */
182
- last: number
183
- skipped: boolean
184
- /** Every event on the lane, described without its content, for the gaps. */
185
- readonly moments: Array<{ readonly at: number; readonly what: string }>
186
- /** Since when a coder turn has been working without ending, and its tool calls. */
187
- openSince: number | undefined
188
- openTools: number
189
- tasks: number
190
- /** When the coder's first task began: setup and task planning come before it. */
191
- firstTask: number | undefined
192
- toolCalls: { explore: number; edit: number; test: number; script: number; other: number }
193
- firstEdit: number | undefined
194
- exploreBeforeEdit: number
195
- unverifiedClaims: number
196
- lowConfidenceTasks: number
197
- reviewDiffLines: number | undefined
198
- }
199
-
200
- interface Lease {
201
- readonly at: number
202
- readonly executor: string
203
- readonly role: string
204
- readonly label: string | undefined
205
- }
206
-
207
- const storyStage = /^story (\S+)$/u
208
-
209
- /** Events a coder turn emits while it runs: not where a model call starts. */
210
- const withinTurn: ReadonlySet<FlowEvent["_tag"]> = new Set([
211
- "ToolUse",
212
- "UsageProgress",
213
- "CapabilityUsed",
214
- "AssistantMessage"
215
- ])
216
-
217
- const sum = (values: ReadonlyArray<number>): number =>
218
- values.reduce((total, value) => total + value, 0)
219
-
220
- const average = (values: ReadonlyArray<number>): number | undefined =>
221
- values.length === 0 ? undefined : sum(values) / values.length
222
-
223
- const groupBy = <A>(items: ReadonlyArray<A>, key: (item: A) => string): Map<string, Array<A>> => {
224
- const groups = new Map<string, Array<A>>()
225
- for (const item of items) {
226
- const group = groups.get(key(item)) ?? []
227
- group.push(item)
228
- groups.set(key(item), group)
229
- }
230
- return groups
231
- }
232
-
233
- const laneOf = (event: FlowEvent): string | undefined =>
234
- "lane" in event && typeof event.lane === "string" ? event.lane : undefined
235
-
236
- /** An event as the gaps name it: its kind, never its content. */
237
- const describe = (event: FlowEvent): string => {
238
- switch (event._tag) {
239
- case "StageStarted":
240
- return "a stage start"
241
- case "StageCompleted":
242
- return "a stage end"
243
- case "StageFailed":
244
- return "a stage failure"
245
- case "ToolUse":
246
- return "a coder tool call"
247
- case "TokensUsed":
248
- return "a token report"
249
- case "Info":
250
- return "a log note"
251
- case "ReviewFindings":
252
- return "a review round"
253
- case "StoryJudged":
254
- return "a judge verdict"
255
- case "AssistantMessage":
256
- return "an assistant message"
257
- case "CapabilityUsed":
258
- case "CapabilityDenied":
259
- case "CapabilityUnenforceable":
260
- return "a capability check"
261
- case "EvidenceChecked":
262
- return "an evidence check"
263
- case "ReviewFindingDemoted":
264
- return "a review finding demoted"
265
- case "ExecutorLeased":
266
- case "ExecutorReleased":
267
- case "ExecutorExcluded":
268
- case "ExecutorResumed":
269
- case "ExecutorHandedOver":
270
- return "a roster change"
271
- case "Timed":
272
- switch (event.kind) {
273
- case "model":
274
- return `the end of a ${event.label} call`
275
- case "tool":
276
- return `the end of tool ${event.label}`
277
- case "gate":
278
- return `the end of gate ${event.label}`
279
- case "wait":
280
- return `the end of a wait for the ${event.label}`
281
- default:
282
- return `the end of ${event.label}`
283
- }
284
- case "Began":
285
- return event.kind === "model"
286
- ? `the start of a ${event.label} call`
287
- : `the start of ${event.kind} ${event.label}`
288
- default:
289
- return "an event"
290
- }
291
- }
292
-
293
- const gapThresholdMs = 30_000
294
-
295
- /** The parts of each step between two lane events that no timed interval covers, largest first. */
296
- const gapsOf = (lane: Lane): ReadonlyArray<Gap> => {
297
- const covered = [
298
- ...lane.timed.map((item) => [item.at - item.event.ms, item.at] as const),
299
- // A turn still open is model time, not a gap.
300
- ...(lane.openSince === undefined || lane.end !== undefined
301
- ? []
302
- : [[lane.openSince, Number.POSITIVE_INFINITY] as const])
303
- ].sort((left, right) => left[0] - right[0])
304
- const uncovered = (from: number, to: number): number => {
305
- let free = 0
306
- let cursor = from
307
- for (const [start, end] of covered) {
308
- if (end <= cursor || start >= to) {
309
- continue
310
- }
311
- free += Math.max(0, start - cursor)
312
- cursor = Math.max(cursor, end)
313
- }
314
- return free + Math.max(0, to - cursor)
315
- }
316
- const moments = [...lane.moments].sort((left, right) => left.at - right.at)
317
- return moments
318
- .slice(1)
319
- .flatMap((next, index) => {
320
- const previous = moments[index]
321
- if (previous === undefined) {
322
- return []
323
- }
324
- const ms = uncovered(previous.at, next.at)
325
- return ms >= gapThresholdMs ? [{ ms, after: previous.what, before: next.what }] : []
326
- })
327
- .sort((left, right) => right.ms - left.ms)
328
- .slice(0, 3)
329
- }
330
-
331
- /** The roster role a seat label stands for, to find its lease. */
332
- const leaseRole = (label: string): string => (label === "reasoning" ? "reviewer" : label)
333
-
334
- export const profileOf = (inputs: ReadonlyArray<TreeInput>): ProfileReport => {
335
- const lanes = new Map<string, Lane>()
336
- const leases: Array<Lease> = []
337
- const allTimed: Array<TimedAt> = []
338
- let first: number | undefined
339
- let lastAt = 0
340
- let planReady: number | undefined
341
-
342
- for (const input of inputs) {
343
- first = first ?? input.at
344
- lastAt = Math.max(lastAt, input.at)
345
- if (input._tag !== "Event") {
346
- continue
347
- }
348
- const { event, at } = input
349
- const laneId = laneOf(event)
350
- const lane = laneId === undefined ? undefined : lanes.get(laneId)
351
- switch (event._tag) {
352
- case "StageStarted": {
353
- const story = storyStage.exec(event.stage)?.[1]
354
- if (story !== undefined && event.lane === story) {
355
- lanes.set(story, {
356
- id: story,
357
- start: at,
358
- end: undefined,
359
- status: "running",
360
- executor: event.executor,
361
- timed: [],
362
- prompts: [],
363
- estimatedCalls: [],
364
- last: at,
365
- skipped: false,
366
- moments: [{ at, what: describe(event) }],
367
- openSince: undefined,
368
- openTools: 0,
369
- tasks: 0,
370
- firstTask: undefined,
371
- toolCalls: { explore: 0, edit: 0, test: 0, script: 0, other: 0 },
372
- firstEdit: undefined,
373
- exploreBeforeEdit: 0,
374
- unverifiedClaims: 0,
375
- lowConfidenceTasks: 0,
376
- reviewDiffLines: undefined
377
- })
378
- continue
379
- }
380
- if (
381
- lane !== undefined &&
382
- !storyStage.test(event.stage) &&
383
- !event.stage.startsWith(`story ${lane.id}:`)
384
- ) {
385
- lane.tasks += 1
386
- lane.firstTask = lane.firstTask ?? at
387
- }
388
- break
389
- }
390
- case "StageCompleted":
391
- case "StageFailed":
392
- if (event.stage === "story plan" && event.lane === undefined) {
393
- planReady = at
394
- }
395
- if (lane !== undefined && event.stage === `story ${lane.id}`) {
396
- lane.end = at
397
- lane.status = event._tag === "StageFailed" ? "failed" : "done"
398
- }
399
- break
400
- case "Timed": {
401
- allTimed.push({ at, event })
402
- lane?.timed.push({ at, event })
403
- if (lane !== undefined && event.kind === "tool" && event.category !== undefined) {
404
- const kind =
405
- event.category === "explore" ||
406
- event.category === "edit" ||
407
- event.category === "test" ||
408
- event.category === "script"
409
- ? event.category
410
- : "other"
411
- lane.toolCalls[kind] += 1
412
- if (kind === "edit" && lane.firstEdit === undefined) {
413
- lane.firstEdit = at - (lane.firstTask ?? lane.start)
414
- lane.exploreBeforeEdit = lane.toolCalls.explore
415
- }
416
- }
417
- if (lane !== undefined && event.kind === "model" && event.label === "coder") {
418
- lane.openSince = undefined
419
- lane.openTools = 0
420
- }
421
- break
422
- }
423
- case "ToolUse":
424
- if (lane !== undefined) {
425
- // A tool call opens a turn until a coder call ends: it began at
426
- // the last turn boundary.
427
- lane.openSince = lane.openSince ?? lane.last
428
- lane.openTools += 1
429
- }
430
- break
431
- case "TokensUsed":
432
- if (lane !== undefined) {
433
- if (event.agent === "coder") {
434
- lane.prompts.push(event.usage.prompt)
435
- }
436
- lane.estimatedCalls.push(at - lane.last)
437
- }
438
- break
439
- case "ExecutorLeased":
440
- leases.push({ at, executor: event.executor, role: event.role, label: event.label })
441
- break
442
- case "Info":
443
- if (lane !== undefined && event.message.startsWith(`story ${lane.id}: already merged`)) {
444
- lane.skipped = true
445
- }
446
- break
447
- case "ReviewFindings":
448
- if (lane !== undefined && event.diffLines !== undefined) {
449
- lane.reviewDiffLines = Math.max(lane.reviewDiffLines ?? 0, event.diffLines)
450
- }
451
- break
452
- case "EvidenceChecked":
453
- if (lane !== undefined) {
454
- lane.unverifiedClaims += event.unverified
455
- if (event.confidence === "low") {
456
- lane.lowConfidenceTasks += 1
457
- }
458
- }
459
- break
460
- default:
461
- break
462
- }
463
- if (lane !== undefined) {
464
- lane.moments.push({ at, what: describe(event) })
465
- lane.executor = lane.executor ?? ("executor" in event ? event.executor : undefined)
466
- if (!withinTurn.has(event._tag)) {
467
- lane.last = at
468
- }
469
- }
470
- }
471
-
472
- const estimated = allTimed.length === 0
473
- const stories = [...lanes.values()]
474
- .filter((lane) => !lane.skipped)
475
- .map((lane) => storyProfile(lane, lastAt, planReady, estimated))
476
- const skipped = [...lanes.values()].filter((lane) => lane.skipped).map((lane) => lane.id)
477
-
478
- const executorOf = (item: TimedAt): string | undefined => {
479
- if (item.event.executor !== undefined) {
480
- return item.event.executor
481
- }
482
- const role = leaseRole(item.event.label)
483
- const lease = [...leases]
484
- .reverse()
485
- .find(
486
- (candidate) =>
487
- candidate.at <= item.at && candidate.role === role && candidate.label === item.event.lane
488
- )
489
- return lease?.executor
490
- }
491
-
492
- const modelCalls = allTimed.filter((item) => item.event.kind === "model")
493
- const models = [
494
- ...groupBy(modelCalls, (item) => `${item.event.label}\u0000${executorOf(item) ?? ""}`).values()
495
- ].map((calls): ModelRow => {
496
- const sample = calls[0]
497
- const executor = sample === undefined ? undefined : executorOf(sample)
498
- const ms = sum(calls.map((item) => item.event.ms))
499
- const firsts = calls.flatMap((item) =>
500
- item.event.firstMs === undefined ? [] : [item.event.firstMs]
501
- )
502
- const apis = calls.flatMap((item) => (item.event.apiMs === undefined ? [] : [item.event.apiMs]))
503
- const toolsReported = calls.flatMap((item) =>
504
- item.event.toolMs === undefined ? [] : [item.event.toolMs]
505
- )
506
- const avgFirstMs = average(firsts)
507
- return {
508
- role: sample?.event.label ?? "",
509
- ...(executor === undefined ? {} : { executor }),
510
- calls: calls.length,
511
- ms,
512
- avgMs: ms / calls.length,
513
- ...(avgFirstMs === undefined ? {} : { avgFirstMs }),
514
- ...(apis.length === 0 ? {} : { apiMs: sum(apis) }),
515
- ...(toolsReported.length === 0 ? {} : { toolMs: sum(toolsReported) })
516
- }
517
- })
518
-
519
- const gates = [
520
- ...groupBy(
521
- allTimed.filter((item) => item.event.kind === "gate"),
522
- (item) => item.event.label
523
- ).entries()
524
- ].map(([command, runs]): GateRow => {
525
- const ms = sum(runs.map((item) => item.event.ms))
526
- return {
527
- command,
528
- runs: runs.length,
529
- ms,
530
- avgMs: ms / runs.length,
531
- failed: runs.filter((item) => (item.event.exitCode ?? 0) !== 0 || item.event.failed === true)
532
- .length
533
- }
534
- })
535
-
536
- const counted = (kind: Timed["kind"]): ReadonlyArray<CountRow> =>
537
- [
538
- ...groupBy(
539
- allTimed.filter((item) => item.event.kind === kind),
540
- (item) => item.event.label
541
- ).entries()
542
- ].map(([label, items]) => ({
543
- label,
544
- count: items.length,
545
- ms: sum(items.map((item) => item.event.ms))
546
- }))
547
- const tools = counted("tool")
548
- const toolCategories = [
549
- ...groupBy(
550
- allTimed.filter((item) => item.event.kind === "tool" && item.event.category !== undefined),
551
- (item) => item.event.category ?? ""
552
- ).entries()
553
- ]
554
- .map(([label, items]) => ({
555
- label,
556
- count: items.length,
557
- ms: sum(items.map((item) => item.event.ms))
558
- }))
559
- .sort((left, right) => right.ms - left.ms)
560
- const waits = counted("wait")
561
- const steps = stepStats(estimated ? [] : [...lanes.values()].flatMap(turnSteps))
562
- const outside = [
563
- ...groupBy(
564
- allTimed.filter((item) => item.event.lane === undefined),
565
- (item) => item.event.label
566
- ).entries()
567
- ].map(([label, items]) => ({
568
- label,
569
- count: items.length,
570
- ms: sum(items.map((item) => item.event.ms))
571
- }))
572
- const merges = [...counted("merge"), ...counted("git")]
573
-
574
- const totals = stories.reduce<ProfileTime>(
575
- (total, story) => ({
576
- model: total.model + story.time.model,
577
- tools: total.tools + story.time.tools,
578
- gates: total.gates + story.time.gates,
579
- merge: total.merge + story.time.merge,
580
- waiting: total.waiting + story.time.waiting,
581
- unaccounted: total.unaccounted + story.time.unaccounted
582
- }),
583
- { model: 0, tools: 0, gates: 0, merge: 0, waiting: 0, unaccounted: 0 }
584
- )
585
- const storyTime = sum(stories.map((story) => story.wallMs))
586
-
587
- return {
588
- estimated,
589
- wallMs: first === undefined ? 0 : lastAt - first,
590
- stories,
591
- skipped,
592
- totals,
593
- models,
594
- gates,
595
- tools,
596
- toolCategories,
597
- waits,
598
- outside,
599
- findings: findingsOf({
600
- steps,
601
- toolCategories,
602
- stories,
603
- models,
604
- gates,
605
- tools,
606
- waits,
607
- merges,
608
- storyTime,
609
- estimated
610
- })
611
- }
612
- }
613
-
614
- const stepStats = (
615
- turns: ReadonlyArray<{ readonly steps: number; readonly stepMs: number }>
616
- ): { readonly avgSteps: number; readonly avgStepMs: number } => ({
617
- avgSteps: average(turns.map((turn) => turn.steps)) ?? 0,
618
- avgStepMs: average(turns.map((turn) => turn.stepMs)) ?? 0
619
- })
620
-
621
- /** Each finished coder turn's steps (its tool calls) and model time per step. */
622
- const turnSteps = (
623
- lane: Lane
624
- ): ReadonlyArray<{ readonly steps: number; readonly stepMs: number }> => {
625
- const tools = lane.timed.filter((item) => item.event.kind === "tool")
626
- return lane.timed
627
- .filter((item) => item.event.kind === "model" && item.event.label === "coder")
628
- .map((turn) => {
629
- const start = turn.at - turn.event.ms
630
- const inside = tools.filter((tool) => tool.at > start && tool.at <= turn.at)
631
- const toolMs = sum(inside.map((tool) => tool.event.ms))
632
- return {
633
- steps: inside.length,
634
- stepMs: Math.max(0, turn.event.ms - toolMs) / (inside.length + 1)
635
- }
636
- })
637
- }
638
-
639
- const storyProfile = (
640
- lane: Lane,
641
- lastAt: number,
642
- planReady: number | undefined,
643
- estimated: boolean
644
- ): StoryProfile => {
645
- const wallMs = (lane.end ?? lastAt) - lane.start
646
- const of = (kind: Timed["kind"]) =>
647
- sum(lane.timed.filter((item) => item.event.kind === kind).map((item) => item.event.ms))
648
- const coderCalls = lane.timed
649
- .filter((item) => item.event.kind === "model" && item.event.label === "coder")
650
- .map((item) => item.event.ms)
651
- const tools = of("tool")
652
- // Only a story still running has a turn in progress; in a finished one an
653
- // unclosed turn is a timing that never came, left to the gaps.
654
- const open = !estimated && lane.end === undefined && lane.openSince !== undefined
655
- const openTurnMs = open && lane.openSince !== undefined ? lastAt - lane.openSince : 0
656
- const model = estimated ? sum(lane.estimatedCalls) : Math.max(0, of("model") + openTurnMs - tools)
657
- const gates = of("gate")
658
- const merge = of("merge") + of("git")
659
- const waiting = of("wait")
660
- const turnTimes = estimated ? lane.estimatedCalls : coderCalls
661
- const firstPrompt = lane.prompts[0]
662
- const lastPrompt = lane.prompts.at(-1)
663
- const maxPrompt = lane.prompts.length === 0 ? undefined : Math.max(...lane.prompts)
664
- return {
665
- gaps: estimated ? [] : gapsOf(lane),
666
- openTurnMs,
667
- openTurnTools: open ? lane.openTools : 0,
668
- toolCalls: { ...lane.toolCalls },
669
- tasks: lane.tasks,
670
- ...(lane.firstEdit === undefined ? {} : { firstEditMs: lane.firstEdit }),
671
- exploreBeforeEdit: lane.exploreBeforeEdit,
672
- unverifiedClaims: lane.unverifiedClaims,
673
- lowConfidenceTasks: lane.lowConfidenceTasks,
674
- ...(lane.reviewDiffLines === undefined ? {} : { reviewDiffLines: lane.reviewDiffLines }),
675
- id: lane.id,
676
- ...(lane.executor === undefined ? {} : { executor: lane.executor }),
677
- status: lane.status,
678
- wallMs,
679
- ...(planReady === undefined ? {} : { queuedMs: Math.max(0, lane.start - planReady) }),
680
- time: {
681
- model,
682
- tools,
683
- gates,
684
- merge,
685
- waiting,
686
- unaccounted: Math.max(0, wallMs - model - tools - gates - merge - waiting)
687
- },
688
- turns: {
689
- count: turnTimes.length,
690
- avgMs: average(turnTimes) ?? 0,
691
- maxMs: turnTimes.length === 0 ? 0 : Math.max(...turnTimes),
692
- ...(firstPrompt === undefined ? {} : { firstPrompt }),
693
- ...(lastPrompt === undefined ? {} : { lastPrompt }),
694
- ...(maxPrompt === undefined ? {} : { maxPrompt }),
695
- ...stepStats(estimated ? [] : turnSteps(lane)),
696
- promptGrowth:
697
- firstPrompt !== undefined &&
698
- lastPrompt !== undefined &&
699
- firstPrompt > 0 &&
700
- lastPrompt > 3 * firstPrompt
701
- }
702
- }
703
- }
704
-
705
- // ── words ───────────────────────────────────────────────────────────────────
706
-
707
- const stepsText = (steps: number): string =>
708
- `${Number.isInteger(steps) ? steps : steps.toFixed(1)} step${steps === 1 ? "" : "s"}`
709
-
710
- const share = (ms: number, of: number): string => (of <= 0 ? "" : `${Math.round((ms / of) * 100)}%`)
711
-
712
- const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? "" : "s"}`
713
-
714
- const findingsOf = (facts: {
715
- readonly steps: { readonly avgSteps: number; readonly avgStepMs: number }
716
- readonly toolCategories: ReadonlyArray<CountRow>
717
- readonly stories: ReadonlyArray<StoryProfile>
718
- readonly models: ReadonlyArray<ModelRow>
719
- readonly gates: ReadonlyArray<GateRow>
720
- readonly tools: ReadonlyArray<CountRow>
721
- readonly waits: ReadonlyArray<CountRow>
722
- readonly merges: ReadonlyArray<CountRow>
723
- readonly storyTime: number
724
- readonly estimated: boolean
725
- }): ReadonlyArray<Finding> => {
726
- const ofStories = (ms: number) => `${duration(ms)} (${share(ms, facts.storyTime)} of story time)`
727
- const byRole = [...groupBy(facts.models, (row) => row.role).entries()].map(([role, rows]) => {
728
- const calls = sum(rows.map((row) => row.calls))
729
- const ms = sum(rows.map((row) => row.ms))
730
- const firsts = rows.flatMap((row) => (row.avgFirstMs === undefined ? [] : [row.avgFirstMs]))
731
- const firstMs = average(firsts)
732
- const first = firstMs === undefined ? "" : `, first output after ${duration(firstMs)}`
733
- return {
734
- ms,
735
- text:
736
- role === "coder"
737
- ? `coder turns: ${calls}, ${ofStories(ms)}, avg ${duration(ms / calls)}${first}${
738
- facts.steps.avgSteps > 0
739
- ? `, ${stepsText(facts.steps.avgSteps)} per turn, ${duration(facts.steps.avgStepMs)} of model per step`
740
- : ""
741
- }`
742
- : `${role} calls: ${calls}, ${ofStories(ms)}, avg ${duration(ms / calls)}${first}`
743
- }
744
- })
745
- const candidates: Array<{ readonly ms: number; readonly text: string }> = [
746
- ...byRole,
747
- ...facts.gates.map((row) => ({
748
- ms: row.ms,
749
- text: `gate \`${row.command}\`: ${plural(row.runs, "run")}, ${ofStories(row.ms)}${
750
- row.failed === 0 ? "" : `, ${row.failed} failed`
751
- }`
752
- })),
753
- // By kind of work when the trace has it (2.23), else by tool name.
754
- ...(facts.toolCategories.length > 0
755
- ? facts.toolCategories.map((row) => ({
756
- ms: row.ms,
757
- text: `coder tools, ${row.label}: ${plural(row.count, "call")}, ${ofStories(row.ms)}`
758
- }))
759
- : facts.tools.map((row) => ({
760
- ms: row.ms,
761
- text: `coder tool \`${row.label}\`: ${plural(row.count, "call")}, ${ofStories(row.ms)}`
762
- }))),
763
- ...facts.waits.map((row) => ({
764
- ms: row.ms,
765
- text: `waiting for the ${row.label}: ${row.count}×, ${ofStories(row.ms)}`
766
- })),
767
- ...facts.merges.map((row) => ({
768
- ms: row.ms,
769
- text: `${row.label}: ${row.count}×, ${ofStories(row.ms)}`
770
- }))
771
- ]
772
- for (const story of facts.stories) {
773
- if (story.openTurnMs >= 1_000) {
774
- candidates.push({
775
- ms: story.openTurnMs,
776
- text: `${story.id}: a coder turn still running for ${duration(story.openTurnMs)} (${plural(story.openTurnTools, "tool call")} so far)`
777
- })
778
- }
779
- }
780
- for (const story of facts.stories) {
781
- const wandered =
782
- story.firstEditMs !== undefined &&
783
- story.wallMs >= 60_000 &&
784
- (story.exploreBeforeEdit >= 15 ||
785
- (story.exploreBeforeEdit >= 5 && story.firstEditMs >= story.wallMs / 4))
786
- if (wandered && story.firstEditMs !== undefined) {
787
- candidates.push({
788
- ms: story.firstEditMs,
789
- text: `${story.id}: ${plural(story.exploreBeforeEdit, "explore call")} and ${duration(story.firstEditMs)} before the coder's first edit — it found its way instead of being told where to go`
790
- })
791
- }
792
- }
793
- const queued = sum(facts.stories.map((story) => story.queuedMs ?? 0))
794
- if (queued >= 1_000) {
795
- candidates.push({
796
- ms: queued,
797
- text: `stories queued before starting (dependencies, free coder slots): ${duration(queued)} in all`
798
- })
799
- }
800
- const estimated = facts.estimated
801
- ? facts.stories.map((story) => ({
802
- ms: story.time.model,
803
- text: `${story.id}: model calls (estimated) ${duration(story.time.model)} of ${duration(story.wallMs)}`
804
- }))
805
- : []
806
- const ranked = [...candidates, ...estimated]
807
- .filter((candidate) => candidate.ms >= 1_000)
808
- .sort((left, right) => right.ms - left.ms)
809
- .slice(0, 3)
810
- const fabricated = facts.stories.flatMap((story) =>
811
- story.unverifiedClaims > 0
812
- ? [
813
- {
814
- text: `${story.id}: the coder claimed ${plural(story.unverifiedClaims, "verification command")} that never ran — its account of its own testing is not evidence`
815
- }
816
- ]
817
- : []
818
- )
819
- const improvised = facts.stories.flatMap((story) =>
820
- story.toolCalls.script >= 3
821
- ? [
822
- {
823
- text: `${story.id}: ${story.toolCalls.script} ad-hoc scripts — a pack tool preset (llm4ts tool) would make this repeatable`
824
- }
825
- ]
826
- : []
827
- )
828
- const growth = facts.stories.flatMap((story) =>
829
- story.turns.promptGrowth &&
830
- story.turns.firstPrompt !== undefined &&
831
- story.turns.lastPrompt !== undefined
832
- ? [
833
- {
834
- text: `${story.id}: coder prompts grew ${(story.turns.lastPrompt / story.turns.firstPrompt).toFixed(1)}× (${formatCount(story.turns.firstPrompt)} → ${formatCount(story.turns.lastPrompt)} tokens): every turn resends the history`
835
- }
836
- ]
837
- : []
838
- )
839
- return [...ranked, ...fabricated, ...improvised, ...growth]
840
- }
841
-
842
- const table = (
843
- header: ReadonlyArray<string>,
844
- rows: ReadonlyArray<ReadonlyArray<string>>
845
- ): Array<string> => {
846
- const widths = header.map((title, column) =>
847
- Math.max(title.length, ...rows.map((row) => (row[column] ?? "").length))
848
- )
849
- const line = (cells: ReadonlyArray<string>) =>
850
- ` ${cells.map((cell, column) => (column === 0 ? cell.padEnd(widths[column] ?? 0) : cell.padStart(widths[column] ?? 0))).join(" ")}`.trimEnd()
851
- return [line(header), ...rows.map(line)]
852
- }
853
-
854
- export const renderProfile = (report: ProfileReport): string => {
855
- const storyTime = sum(report.stories.map((story) => story.wallMs))
856
- const categories: ReadonlyArray<readonly [string, number]> = [
857
- [report.estimated ? "model + tools + gates (estimated)" : "model", report.totals.model],
858
- ["coder tools", report.totals.tools],
859
- ["gates", report.totals.gates],
860
- ["git + merge", report.totals.merge],
861
- ["waiting", report.totals.waiting],
862
- ["unaccounted", report.totals.unaccounted]
863
- ]
864
- return [
865
- `llm4ts profile · ${duration(report.wallMs)} wall · ${report.stories.length} ${report.stories.length === 1 ? "story" : "stories"}${
866
- report.estimated
867
- ? " · estimated: this trace has no timings (before 2.22), model time is read from gaps"
868
- : ""
869
- }`,
870
- "",
871
- "Where the time goes",
872
- ...(report.findings.length === 0
873
- ? [" nothing timed yet"]
874
- : report.findings.map((finding, index) => ` ${index + 1}. ${finding.text}`)),
875
- "",
876
- "Story time",
877
- ...table(
878
- ["category", "time", "share"],
879
- categories.map(([name, ms]) => [name, duration(ms), share(ms, storyTime)])
880
- ),
881
- "",
882
- "Stories",
883
- ...table(
884
- [
885
- "story",
886
- "status",
887
- "wall",
888
- "queued",
889
- "model",
890
- "tools",
891
- "gates",
892
- "git+merge",
893
- "wait",
894
- "other",
895
- "turns",
896
- "avg turn",
897
- "steps/turn",
898
- "model/step",
899
- "prompt ×"
900
- ],
901
- report.stories.map((story) => [
902
- story.id,
903
- story.status,
904
- duration(story.wallMs),
905
- story.queuedMs === undefined ? "" : duration(story.queuedMs),
906
- duration(story.time.model),
907
- duration(story.time.tools),
908
- duration(story.time.gates),
909
- duration(story.time.merge),
910
- duration(story.time.waiting),
911
- duration(story.time.unaccounted),
912
- String(story.turns.count),
913
- duration(story.turns.avgMs),
914
- story.turns.avgSteps === 0 ? "" : story.turns.avgSteps.toFixed(1),
915
- story.turns.avgSteps === 0 ? "" : duration(story.turns.avgStepMs),
916
- story.turns.firstPrompt !== undefined &&
917
- story.turns.lastPrompt !== undefined &&
918
- story.turns.firstPrompt > 0
919
- ? `${(story.turns.lastPrompt / story.turns.firstPrompt).toFixed(1)}×`
920
- : ""
921
- ])
922
- ),
923
- ...(report.stories.every(
924
- (story) =>
925
- story.toolCalls.explore +
926
- story.toolCalls.edit +
927
- story.toolCalls.test +
928
- story.toolCalls.script +
929
- story.toolCalls.other ===
930
- 0
931
- )
932
- ? []
933
- : [
934
- "",
935
- "Coder work per story",
936
- ...table(
937
- [
938
- "story",
939
- "tasks",
940
- "explore",
941
- "edit",
942
- "test",
943
- "script",
944
- "other",
945
- "before 1st edit",
946
- "1st edit at"
947
- ],
948
- report.stories.map((story) => [
949
- story.id,
950
- String(story.tasks),
951
- String(story.toolCalls.explore),
952
- String(story.toolCalls.edit),
953
- String(story.toolCalls.test),
954
- String(story.toolCalls.script),
955
- String(story.toolCalls.other),
956
- story.firstEditMs === undefined ? "" : `${story.exploreBeforeEdit} explore`,
957
- story.firstEditMs === undefined ? "never" : duration(story.firstEditMs)
958
- ])
959
- )
960
- ]),
961
- ...(report.stories.every((story) => story.reviewDiffLines === undefined)
962
- ? []
963
- : [
964
- "",
965
- "Largest review diff per story",
966
- ...report.stories.flatMap((story) =>
967
- story.reviewDiffLines === undefined
968
- ? []
969
- : [` ${story.id}: review diff ${story.reviewDiffLines} lines`]
970
- )
971
- ]),
972
- ...(report.skipped.length === 0
973
- ? []
974
- : [` skipped (merged on an earlier run): ${report.skipped.join(", ")}`]),
975
- ...(report.stories.every((story) => story.gaps.length === 0)
976
- ? []
977
- : [
978
- "",
979
- "Unaccounted, largest gaps (no timing covers them)",
980
- ...report.stories.flatMap((story) =>
981
- story.gaps.map(
982
- (gap) => ` ${story.id}: ${duration(gap.ms)} between ${gap.after} and ${gap.before}`
983
- )
984
- )
985
- ]),
986
- ...(report.outside.length === 0
987
- ? []
988
- : [
989
- "",
990
- "Timed outside any story (the run's own planning, or a seat no story owns)",
991
- ...table(
992
- ["label", "count", "total"],
993
- report.outside.map((row) => [row.label, String(row.count), duration(row.ms)])
994
- )
995
- ]),
996
- ...(report.models.length === 0
997
- ? []
998
- : [
999
- "",
1000
- "Model calls",
1001
- ...table(
1002
- [
1003
- "role",
1004
- "executor",
1005
- "calls",
1006
- "total",
1007
- "avg",
1008
- "first output",
1009
- "api (reported)",
1010
- "tools (reported)"
1011
- ],
1012
- report.models.map((row) => [
1013
- row.role,
1014
- row.executor ?? "",
1015
- String(row.calls),
1016
- duration(row.ms),
1017
- duration(row.avgMs),
1018
- row.avgFirstMs === undefined ? "" : duration(row.avgFirstMs),
1019
- row.apiMs === undefined ? "" : duration(row.apiMs),
1020
- row.toolMs === undefined ? "" : duration(row.toolMs)
1021
- ])
1022
- )
1023
- ]),
1024
- ...(report.gates.length === 0
1025
- ? []
1026
- : [
1027
- "",
1028
- "Gates",
1029
- ...table(
1030
- ["command", "runs", "total", "avg", "failed"],
1031
- report.gates.map((row) => [
1032
- row.command,
1033
- String(row.runs),
1034
- duration(row.ms),
1035
- duration(row.avgMs),
1036
- String(row.failed)
1037
- ])
1038
- )
1039
- ]),
1040
- ...(report.toolCategories.length === 0
1041
- ? []
1042
- : [
1043
- "",
1044
- "Coder tools by kind",
1045
- ...table(
1046
- ["kind", "calls", "total", "avg"],
1047
- report.toolCategories.map((row) => [
1048
- row.label,
1049
- String(row.count),
1050
- duration(row.ms),
1051
- duration(row.ms / row.count)
1052
- ])
1053
- )
1054
- ])
1055
- ].join("\n")
1056
- }
1057
-
1058
- // ── delta ───────────────────────────────────────────────────────────────────
1059
-
1060
- /** One line of a before/after comparison (ADR 0029): a count or a duration. */
1061
- export const DeltaRow = Schema.Struct({
1062
- label: Schema.String,
1063
- before: Schema.Number,
1064
- after: Schema.Number,
1065
- unit: Schema.Literals(["count", "ms"]),
1066
- /** Which direction is better, for the arrow; `none` for neutral facts. */
1067
- better: Schema.Literals(["lower", "higher", "none"])
1068
- })
1069
- export type DeltaRow = typeof DeltaRow.Type
1070
-
1071
- export const ProfileDelta = Schema.Struct({
1072
- rows: Schema.Array(DeltaRow)
1073
- })
1074
- export type ProfileDelta = typeof ProfileDelta.Type
1075
-
1076
- const roleCalls = (report: ProfileReport, role: string): number =>
1077
- sum(report.models.filter((row) => row.role === role).map((row) => row.calls))
1078
-
1079
- /**
1080
- * The harness-evals comparison (`docs/harness-evals/README.md`), in its
1081
- * order: how much the coders explored before editing, how often review and
1082
- * judgment were called, what the gates said, how honest the claims were,
1083
- * then where the time went. Tokens and cost are `llm4ts costs`' business.
1084
- */
1085
- export const profileDelta = (before: ProfileReport, after: ProfileReport): ProfileDelta => {
1086
- const count = (
1087
- label: string,
1088
- pick: (report: ProfileReport) => number,
1089
- better: DeltaRow["better"] = "lower"
1090
- ): DeltaRow => ({
1091
- label,
1092
- before: pick(before),
1093
- after: pick(after),
1094
- unit: "count",
1095
- better
1096
- })
1097
- const time = (label: string, pick: (report: ProfileReport) => number): DeltaRow => ({
1098
- label,
1099
- before: pick(before),
1100
- after: pick(after),
1101
- unit: "ms",
1102
- better: "lower"
1103
- })
1104
- return {
1105
- rows: [
1106
- count("stories done", (r) => r.stories.filter((s) => s.status === "done").length, "higher"),
1107
- count("stories failed", (r) => r.stories.filter((s) => s.status === "failed").length),
1108
- count("explore calls before the first edit", (r) =>
1109
- sum(r.stories.map((s) => s.exploreBeforeEdit))
1110
- ),
1111
- count("coder turns", (r) => sum(r.stories.map((s) => s.turns.count))),
1112
- count("coder edits", (r) => sum(r.stories.map((s) => s.toolCalls.edit)), "none"),
1113
- count("coder test runs", (r) => sum(r.stories.map((s) => s.toolCalls.test)), "none"),
1114
- count("coder scripts", (r) => sum(r.stories.map((s) => s.toolCalls.script))),
1115
- count("reviewer calls", (r) => roleCalls(r, "reviewer")),
1116
- count("judge calls", (r) => roleCalls(r, "judge")),
1117
- count("gate runs", (r) => sum(r.gates.map((g) => g.runs))),
1118
- count("gate failures", (r) => sum(r.gates.map((g) => g.failed))),
1119
- count("fabricated verification claims", (r) => sum(r.stories.map((s) => s.unverifiedClaims))),
1120
- count("low-confidence tasks", (r) => sum(r.stories.map((s) => s.lowConfidenceTasks))),
1121
- count(
1122
- "stories whose prompt grew over 3×",
1123
- (r) => r.stories.filter((s) => s.turns.promptGrowth).length
1124
- ),
1125
- time("wall", (r) => r.wallMs),
1126
- time("model time", (r) => r.totals.model),
1127
- time("coder tool time", (r) => r.totals.tools),
1128
- time("gate time", (r) => r.totals.gates),
1129
- time("waiting", (r) => r.totals.waiting)
1130
- ]
1131
- }
1132
- }
1133
-
1134
- const deltaCell = (row: DeltaRow): string => {
1135
- const diff = row.after - row.before
1136
- if (diff === 0) {
1137
- return "="
1138
- }
1139
- const shown = row.unit === "ms" ? duration(Math.abs(diff)) : String(Math.abs(diff))
1140
- const sign = diff > 0 ? "+" : "-"
1141
- const verdict =
1142
- row.better === "none" ? "" : diff < 0 === (row.better === "lower") ? " better" : " worse"
1143
- const percent = row.before === 0 ? "" : ` (${Math.round((diff / row.before) * 100)}%)`
1144
- return `${sign}${shown}${percent}${verdict}`
1145
- }
1146
-
1147
- export const renderProfileDelta = (
1148
- delta: ProfileDelta,
1149
- labels: { readonly before: string; readonly after: string }
1150
- ): string =>
1151
- [
1152
- `llm4ts profile · ${labels.before} → ${labels.after}`,
1153
- "",
1154
- ...table(
1155
- ["measure", "before", "after", "change"],
1156
- delta.rows.map((row) => [
1157
- row.label,
1158
- row.unit === "ms" ? duration(row.before) : String(row.before),
1159
- row.unit === "ms" ? duration(row.after) : String(row.after),
1160
- deltaCell(row)
1161
- ])
1162
- ),
1163
- "",
1164
- "* tokens and cost per role: llm4ts costs --repo <repo>"
1165
- ].join("\n")
1166
-
1167
- export interface ProfileOptions extends TraceChoice {
1168
- readonly json?: boolean
1169
- /** A trace, or a `--json` report saved earlier, to compare the profiled run against (ADR 0029). */
1170
- readonly against?: string
1171
- }
1172
-
1173
- const encodeReport = Schema.encodeSync(Schema.fromJsonString(ProfileReport))
1174
- const decodeReport = Schema.decodeUnknownOption(Schema.fromJsonString(ProfileReport))
1175
- const encodeDelta = Schema.encodeSync(Schema.fromJsonString(ProfileDelta))
1176
-
1177
- const reportAt = Effect.fn("@llm4ts/runner/Profile.reportAt")(function* (
1178
- files: TraceSources["files"],
1179
- path: string
1180
- ): Effect.fn.Return<ProfileReport, FlowError | WatchTargetMissing> {
1181
- if (path.endsWith(".json")) {
1182
- const text = yield* files.read(path)
1183
- const decoded = text === undefined ? undefined : decodeReport(text)
1184
- if (decoded === undefined || decoded._tag === "None") {
1185
- return yield* WatchTargetMissing.make({
1186
- message: `${path} is not a profile report (write one with llm4ts profile --json)`
1187
- })
1188
- }
1189
- return decoded.value
1190
- }
1191
- return profileOf(treeInputsOfTrace(yield* readTrace(files, path)))
1192
- })
1193
-
1194
- /**
1195
- * The report for a trace, an epic's latest run, or the newest trace: text,
1196
- * or JSON for comparing runs; with `against`, the delta from that earlier
1197
- * run to this one.
1198
- */
1199
- export const makeProfileProgram = Effect.fn("@llm4ts/runner/Profile.make")(function* (
1200
- options: ProfileOptions,
1201
- sources: TraceSources
1202
- ): Effect.fn.Return<string, FlowError | WatchTargetMissing> {
1203
- const target = yield* resolveTraceTarget(options, sources)
1204
- const report = profileOf(treeInputsOfTrace(yield* readTrace(sources.files, target.tracePath)))
1205
- if (options.against !== undefined) {
1206
- const before = yield* reportAt(sources.files, options.against)
1207
- const delta = profileDelta(before, report)
1208
- return options.json === true
1209
- ? encodeDelta(delta)
1210
- : renderProfileDelta(delta, { before: options.against, after: target.tracePath })
1211
- }
1212
- return options.json === true ? encodeReport(report) : renderProfile(report)
1213
- })