@llm4ts/runner 2.22.1 → 2.23.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.
package/src/Profile.ts CHANGED
@@ -47,10 +47,22 @@ export const TurnStats = Schema.Struct({
47
47
  lastPrompt: Schema.optionalKey(Schema.Int),
48
48
  maxPrompt: Schema.optionalKey(Schema.Int),
49
49
  /** The last prompt is more than three times the first. */
50
- promptGrowth: Schema.Boolean
50
+ promptGrowth: Schema.Boolean,
51
+ /** Tool calls per finished turn: each is a model round trip. */
52
+ avgSteps: Schema.Number,
53
+ /** The model's own time per round trip: a turn's time less its tools, over its steps + 1. */
54
+ avgStepMs: Ms
51
55
  })
52
56
  export type TurnStats = typeof TurnStats.Type
53
57
 
58
+ /** A stretch of a story's time no `Timed` event covers, named by the events around it. */
59
+ export const Gap = Schema.Struct({
60
+ ms: Ms,
61
+ after: Schema.String,
62
+ before: Schema.String
63
+ })
64
+ export type Gap = typeof Gap.Type
65
+
54
66
  export const StoryProfile = Schema.Struct({
55
67
  id: Schema.String,
56
68
  executor: Schema.optionalKey(Schema.String),
@@ -59,7 +71,15 @@ export const StoryProfile = Schema.Struct({
59
71
  /** From the plan being ready to this story starting: dependencies and free coder slots. */
60
72
  queuedMs: Schema.optionalKey(Ms),
61
73
  time: ProfileTime,
62
- turns: TurnStats
74
+ turns: TurnStats,
75
+ /** Its largest untimed stretches, 30 seconds or more: where "unaccounted" sits. */
76
+ gaps: Schema.Array(Gap),
77
+ /**
78
+ * A coder turn the trace shows working (tool calls after its last finished
79
+ * call) but not ended: a run still going. Counted as model time so far.
80
+ */
81
+ openTurnMs: Ms,
82
+ openTurnTools: Schema.Int
63
83
  })
64
84
  export type StoryProfile = typeof StoryProfile.Type
65
85
 
@@ -102,7 +122,11 @@ export const ProfileReport = Schema.Struct({
102
122
  models: Schema.Array(ModelRow),
103
123
  gates: Schema.Array(GateRow),
104
124
  tools: Schema.Array(CountRow),
125
+ /** Coder tool time by kind of work: explore, edit, test, build, install, git, other. */
126
+ toolCategories: Schema.Array(CountRow),
105
127
  waits: Schema.Array(CountRow),
128
+ /** Timed on the run's events, in no story: the run's own planning, or a seat no story owns. */
129
+ outside: Schema.Array(CountRow),
106
130
  findings: Schema.Array(Finding)
107
131
  })
108
132
  export type ProfileReport = typeof ProfileReport.Type
@@ -128,6 +152,11 @@ interface Lane {
128
152
  /** The last turn boundary: any lane event but a tool call inside a turn. */
129
153
  last: number
130
154
  skipped: boolean
155
+ /** Every event on the lane, described without its content, for the gaps. */
156
+ readonly moments: Array<{ readonly at: number; readonly what: string }>
157
+ /** Since when a coder turn has been working without ending, and its tool calls. */
158
+ openSince: number | undefined
159
+ openTools: number
131
160
  }
132
161
 
133
162
  interface Lease {
@@ -166,6 +195,93 @@ const groupBy = <A>(items: ReadonlyArray<A>, key: (item: A) => string): Map<stri
166
195
  const laneOf = (event: FlowEvent): string | undefined =>
167
196
  "lane" in event && typeof event.lane === "string" ? event.lane : undefined
168
197
 
198
+ /** An event as the gaps name it: its kind, never its content. */
199
+ const describe = (event: FlowEvent): string => {
200
+ switch (event._tag) {
201
+ case "StageStarted":
202
+ return "a stage start"
203
+ case "StageCompleted":
204
+ return "a stage end"
205
+ case "StageFailed":
206
+ return "a stage failure"
207
+ case "ToolUse":
208
+ return "a coder tool call"
209
+ case "TokensUsed":
210
+ return "a token report"
211
+ case "Info":
212
+ return "a log note"
213
+ case "ReviewFindings":
214
+ return "a review round"
215
+ case "StoryJudged":
216
+ return "a judge verdict"
217
+ case "AssistantMessage":
218
+ return "an assistant message"
219
+ case "CapabilityUsed":
220
+ case "CapabilityDenied":
221
+ case "CapabilityUnenforceable":
222
+ return "a capability check"
223
+ case "ExecutorLeased":
224
+ case "ExecutorReleased":
225
+ case "ExecutorExcluded":
226
+ case "ExecutorResumed":
227
+ case "ExecutorHandedOver":
228
+ return "a roster change"
229
+ case "Timed":
230
+ switch (event.kind) {
231
+ case "model":
232
+ return `the end of a ${event.label} call`
233
+ case "tool":
234
+ return `the end of tool ${event.label}`
235
+ case "gate":
236
+ return `the end of gate ${event.label}`
237
+ case "wait":
238
+ return `the end of a wait for the ${event.label}`
239
+ default:
240
+ return `the end of ${event.label}`
241
+ }
242
+ default:
243
+ return "an event"
244
+ }
245
+ }
246
+
247
+ const gapThresholdMs = 30_000
248
+
249
+ /** The parts of each step between two lane events that no timed interval covers, largest first. */
250
+ const gapsOf = (lane: Lane): ReadonlyArray<Gap> => {
251
+ const covered = [
252
+ ...lane.timed.map((item) => [item.at - item.event.ms, item.at] as const),
253
+ // A turn still open is model time, not a gap.
254
+ ...(lane.openSince === undefined || lane.end !== undefined
255
+ ? []
256
+ : [[lane.openSince, Number.POSITIVE_INFINITY] as const])
257
+ ].sort((left, right) => left[0] - right[0])
258
+ const uncovered = (from: number, to: number): number => {
259
+ let free = 0
260
+ let cursor = from
261
+ for (const [start, end] of covered) {
262
+ if (end <= cursor || start >= to) {
263
+ continue
264
+ }
265
+ free += Math.max(0, start - cursor)
266
+ cursor = Math.max(cursor, end)
267
+ }
268
+ return free + Math.max(0, to - cursor)
269
+ }
270
+ const moments = [...lane.moments].sort((left, right) => left.at - right.at)
271
+ return moments
272
+ .slice(1)
273
+ .flatMap((next, index) => {
274
+ const previous = moments[index]
275
+ if (previous === undefined) {
276
+ return []
277
+ }
278
+ const ms = uncovered(previous.at, next.at)
279
+ return ms >= gapThresholdMs ? [{ ms, after: previous.what, before: next.what }] : []
280
+ })
281
+ .sort((left, right) => right.ms - left.ms)
282
+ .slice(0, 3)
283
+ }
284
+
169
285
  /** The roster role a seat label stands for, to find its lease. */
170
286
  const leaseRole = (label: string): string => (label === "reasoning" ? "reviewer" : label)
171
287
 
@@ -200,7 +316,10 @@ export const profileOf = (inputs: ReadonlyArray<TreeInput>): ProfileReport => {
200
316
  prompts: [],
201
317
  estimatedCalls: [],
202
318
  last: at,
203
- skipped: false
319
+ skipped: false,
320
+ moments: [{ at, what: describe(event) }],
321
+ openSince: undefined,
322
+ openTools: 0
204
323
  })
205
324
  continue
206
325
  }
@@ -219,8 +338,20 @@ export const profileOf = (inputs: ReadonlyArray<TreeInput>): ProfileReport => {
219
338
  case "Timed": {
220
339
  allTimed.push({ at, event })
221
340
  lane?.timed.push({ at, event })
341
+ if (lane !== undefined && event.kind === "model" && event.label === "coder") {
342
+ lane.openSince = undefined
343
+ lane.openTools = 0
344
+ }
222
345
  break
223
346
  }
347
+ case "ToolUse":
348
+ if (lane !== undefined) {
349
+ // A tool call opens a turn until a coder call ends: it began at
350
+ // the last turn boundary.
351
+ lane.openSince = lane.openSince ?? lane.last
352
+ lane.openTools += 1
353
+ }
354
+ break
224
355
  case "TokensUsed":
225
356
  if (lane !== undefined) {
226
357
  if (event.agent === "coder") {
@@ -241,6 +372,7 @@ export const profileOf = (inputs: ReadonlyArray<TreeInput>): ProfileReport => {
241
372
  break
242
373
  }
243
374
  if (lane !== undefined) {
375
+ lane.moments.push({ at, what: describe(event) })
244
376
  lane.executor = lane.executor ?? ("executor" in event ? event.executor : undefined)
245
377
  if (!withinTurn.has(event._tag)) {
246
378
  lane.last = at
@@ -324,7 +456,30 @@ export const profileOf = (inputs: ReadonlyArray<TreeInput>): ProfileReport => {
324
456
  ms: sum(items.map((item) => item.event.ms))
325
457
  }))
326
458
  const tools = counted("tool")
459
+ const toolCategories = [
460
+ ...groupBy(
461
+ allTimed.filter((item) => item.event.kind === "tool" && item.event.category !== undefined),
462
+ (item) => item.event.category ?? ""
463
+ ).entries()
464
+ ]
465
+ .map(([label, items]) => ({
466
+ label,
467
+ count: items.length,
468
+ ms: sum(items.map((item) => item.event.ms))
469
+ }))
470
+ .sort((left, right) => right.ms - left.ms)
327
471
  const waits = counted("wait")
472
+ const steps = stepStats(estimated ? [] : [...lanes.values()].flatMap(turnSteps))
473
+ const outside = [
474
+ ...groupBy(
475
+ allTimed.filter((item) => item.event.lane === undefined),
476
+ (item) => item.event.label
477
+ ).entries()
478
+ ].map(([label, items]) => ({
479
+ label,
480
+ count: items.length,
481
+ ms: sum(items.map((item) => item.event.ms))
482
+ }))
328
483
  const merges = [...counted("merge"), ...counted("git")]
329
484
 
330
485
  const totals = stories.reduce<ProfileTime>(
@@ -349,11 +504,49 @@ export const profileOf = (inputs: ReadonlyArray<TreeInput>): ProfileReport => {
349
504
  models,
350
505
  gates,
351
506
  tools,
507
+ toolCategories,
352
508
  waits,
353
- findings: findingsOf({ stories, models, gates, tools, waits, merges, storyTime, estimated })
509
+ outside,
510
+ findings: findingsOf({
511
+ steps,
512
+ toolCategories,
513
+ stories,
514
+ models,
515
+ gates,
516
+ tools,
517
+ waits,
518
+ merges,
519
+ storyTime,
520
+ estimated
521
+ })
354
522
  }
355
523
  }
356
524
 
525
+ const stepStats = (
526
+ turns: ReadonlyArray<{ readonly steps: number; readonly stepMs: number }>
527
+ ): { readonly avgSteps: number; readonly avgStepMs: number } => ({
528
+ avgSteps: average(turns.map((turn) => turn.steps)) ?? 0,
529
+ avgStepMs: average(turns.map((turn) => turn.stepMs)) ?? 0
530
+ })
531
+
532
+ /** Each finished coder turn's steps (its tool calls) and model time per step. */
533
+ const turnSteps = (
534
+ lane: Lane
535
+ ): ReadonlyArray<{ readonly steps: number; readonly stepMs: number }> => {
536
+ const tools = lane.timed.filter((item) => item.event.kind === "tool")
537
+ return lane.timed
538
+ .filter((item) => item.event.kind === "model" && item.event.label === "coder")
539
+ .map((turn) => {
540
+ const start = turn.at - turn.event.ms
541
+ const inside = tools.filter((tool) => tool.at > start && tool.at <= turn.at)
542
+ const toolMs = sum(inside.map((tool) => tool.event.ms))
543
+ return {
544
+ steps: inside.length,
545
+ stepMs: Math.max(0, turn.event.ms - toolMs) / (inside.length + 1)
546
+ }
547
+ })
548
+ }
549
+
357
550
  const storyProfile = (
358
551
  lane: Lane,
359
552
  lastAt: number,
@@ -367,7 +560,11 @@ const storyProfile = (
367
560
  .filter((item) => item.event.kind === "model" && item.event.label === "coder")
368
561
  .map((item) => item.event.ms)
369
562
  const tools = of("tool")
370
- const model = estimated ? sum(lane.estimatedCalls) : Math.max(0, of("model") - tools)
563
+ // Only a story still running has a turn in progress; in a finished one an
564
+ // unclosed turn is a timing that never came, left to the gaps.
565
+ const open = !estimated && lane.end === undefined && lane.openSince !== undefined
566
+ const openTurnMs = open && lane.openSince !== undefined ? lastAt - lane.openSince : 0
567
+ const model = estimated ? sum(lane.estimatedCalls) : Math.max(0, of("model") + openTurnMs - tools)
371
568
  const gates = of("gate")
372
569
  const merge = of("merge") + of("git")
373
570
  const waiting = of("wait")
@@ -376,6 +573,9 @@ const storyProfile = (
376
573
  const lastPrompt = lane.prompts.at(-1)
377
574
  const maxPrompt = lane.prompts.length === 0 ? undefined : Math.max(...lane.prompts)
378
575
  return {
576
+ gaps: estimated ? [] : gapsOf(lane),
577
+ openTurnMs,
578
+ openTurnTools: open ? lane.openTools : 0,
379
579
  id: lane.id,
380
580
  ...(lane.executor === undefined ? {} : { executor: lane.executor }),
381
581
  status: lane.status,
@@ -396,6 +596,7 @@ const storyProfile = (
396
596
  ...(firstPrompt === undefined ? {} : { firstPrompt }),
397
597
  ...(lastPrompt === undefined ? {} : { lastPrompt }),
398
598
  ...(maxPrompt === undefined ? {} : { maxPrompt }),
599
+ ...stepStats(estimated ? [] : turnSteps(lane)),
399
600
  promptGrowth:
400
601
  firstPrompt !== undefined &&
401
602
  lastPrompt !== undefined &&
@@ -420,11 +621,16 @@ export const duration = (ms: number): string => {
420
621
  return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`
421
622
  }
422
623
 
624
+ const stepsText = (steps: number): string =>
625
+ `${Number.isInteger(steps) ? steps : steps.toFixed(1)} step${steps === 1 ? "" : "s"}`
626
+
423
627
  const share = (ms: number, of: number): string => (of <= 0 ? "" : `${Math.round((ms / of) * 100)}%`)
424
628
 
425
629
  const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? "" : "s"}`
426
630
 
427
631
  const findingsOf = (facts: {
632
+ readonly steps: { readonly avgSteps: number; readonly avgStepMs: number }
633
+ readonly toolCategories: ReadonlyArray<CountRow>
428
634
  readonly stories: ReadonlyArray<StoryProfile>
429
635
  readonly models: ReadonlyArray<ModelRow>
430
636
  readonly gates: ReadonlyArray<GateRow>
@@ -445,7 +651,11 @@ const findingsOf = (facts: {
445
651
  ms,
446
652
  text:
447
653
  role === "coder"
448
- ? `coder turns: ${calls}, ${ofStories(ms)}, avg ${duration(ms / calls)}${first}`
654
+ ? `coder turns: ${calls}, ${ofStories(ms)}, avg ${duration(ms / calls)}${first}${
655
+ facts.steps.avgSteps > 0
656
+ ? `, ${stepsText(facts.steps.avgSteps)} per turn, ${duration(facts.steps.avgStepMs)} of model per step`
657
+ : ""
658
+ }`
449
659
  : `${role} calls: ${calls}, ${ofStories(ms)}, avg ${duration(ms / calls)}${first}`
450
660
  }
451
661
  })
@@ -457,10 +667,16 @@ const findingsOf = (facts: {
457
667
  row.failed === 0 ? "" : `, ${row.failed} failed`
458
668
  }`
459
669
  })),
460
- ...facts.tools.map((row) => ({
461
- ms: row.ms,
462
- text: `coder tool \`${row.label}\`: ${plural(row.count, "call")}, ${ofStories(row.ms)}`
463
- })),
670
+ // By kind of work when the trace has it (2.23), else by tool name.
671
+ ...(facts.toolCategories.length > 0
672
+ ? facts.toolCategories.map((row) => ({
673
+ ms: row.ms,
674
+ text: `coder tools, ${row.label}: ${plural(row.count, "call")}, ${ofStories(row.ms)}`
675
+ }))
676
+ : facts.tools.map((row) => ({
677
+ ms: row.ms,
678
+ text: `coder tool \`${row.label}\`: ${plural(row.count, "call")}, ${ofStories(row.ms)}`
679
+ }))),
464
680
  ...facts.waits.map((row) => ({
465
681
  ms: row.ms,
466
682
  text: `waiting for the ${row.label}: ${row.count}×, ${ofStories(row.ms)}`
@@ -470,6 +686,14 @@ const findingsOf = (facts: {
470
686
  text: `${row.label}: ${row.count}×, ${ofStories(row.ms)}`
471
687
  }))
472
688
  ]
689
+ for (const story of facts.stories) {
690
+ if (story.openTurnMs >= 1_000) {
691
+ candidates.push({
692
+ ms: story.openTurnMs,
693
+ text: `${story.id}: a coder turn still running for ${duration(story.openTurnMs)} (${plural(story.openTurnTools, "tool call")} so far)`
694
+ })
695
+ }
696
+ }
473
697
  const queued = sum(facts.stories.map((story) => story.queuedMs ?? 0))
474
698
  if (queued >= 1_000) {
475
699
  candidates.push({
@@ -519,7 +743,7 @@ export const renderProfile = (report: ProfileReport): string => {
519
743
  [report.estimated ? "model + tools + gates (estimated)" : "model", report.totals.model],
520
744
  ["coder tools", report.totals.tools],
521
745
  ["gates", report.totals.gates],
522
- ["merge", report.totals.merge],
746
+ ["git + merge", report.totals.merge],
523
747
  ["waiting", report.totals.waiting],
524
748
  ["unaccounted", report.totals.unaccounted]
525
749
  ]
@@ -551,11 +775,13 @@ export const renderProfile = (report: ProfileReport): string => {
551
775
  "model",
552
776
  "tools",
553
777
  "gates",
554
- "merge",
778
+ "git+merge",
555
779
  "wait",
556
780
  "other",
557
781
  "turns",
558
782
  "avg turn",
783
+ "steps/turn",
784
+ "model/step",
559
785
  "prompt ×"
560
786
  ],
561
787
  report.stories.map((story) => [
@@ -571,6 +797,8 @@ export const renderProfile = (report: ProfileReport): string => {
571
797
  duration(story.time.unaccounted),
572
798
  String(story.turns.count),
573
799
  duration(story.turns.avgMs),
800
+ story.turns.avgSteps === 0 ? "" : story.turns.avgSteps.toFixed(1),
801
+ story.turns.avgSteps === 0 ? "" : duration(story.turns.avgStepMs),
574
802
  story.turns.firstPrompt !== undefined &&
575
803
  story.turns.lastPrompt !== undefined &&
576
804
  story.turns.firstPrompt > 0
@@ -581,6 +809,27 @@ export const renderProfile = (report: ProfileReport): string => {
581
809
  ...(report.skipped.length === 0
582
810
  ? []
583
811
  : [` skipped (merged on an earlier run): ${report.skipped.join(", ")}`]),
812
+ ...(report.stories.every((story) => story.gaps.length === 0)
813
+ ? []
814
+ : [
815
+ "",
816
+ "Unaccounted, largest gaps (no timing covers them)",
817
+ ...report.stories.flatMap((story) =>
818
+ story.gaps.map(
819
+ (gap) => ` ${story.id}: ${duration(gap.ms)} between ${gap.after} and ${gap.before}`
820
+ )
821
+ )
822
+ ]),
823
+ ...(report.outside.length === 0
824
+ ? []
825
+ : [
826
+ "",
827
+ "Timed outside any story (the run's own planning, or a seat no story owns)",
828
+ ...table(
829
+ ["label", "count", "total"],
830
+ report.outside.map((row) => [row.label, String(row.count), duration(row.ms)])
831
+ )
832
+ ]),
584
833
  ...(report.models.length === 0
585
834
  ? []
586
835
  : [
@@ -624,6 +873,21 @@ export const renderProfile = (report: ProfileReport): string => {
624
873
  String(row.failed)
625
874
  ])
626
875
  )
876
+ ]),
877
+ ...(report.toolCategories.length === 0
878
+ ? []
879
+ : [
880
+ "",
881
+ "Coder tools by kind",
882
+ ...table(
883
+ ["kind", "calls", "total", "avg"],
884
+ report.toolCategories.map((row) => [
885
+ row.label,
886
+ String(row.count),
887
+ duration(row.ms),
888
+ duration(row.ms / row.count)
889
+ ])
890
+ )
627
891
  ])
628
892
  ].join("\n")
629
893
  }