@llm4ts/runner 2.21.0 → 2.22.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 ADDED
@@ -0,0 +1,645 @@
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 { treeInputsOfTrace, type TreeInput } from "./AgentTree.ts"
7
+ import { formatCount } from "./Terminal.ts"
8
+ import {
9
+ resolveTraceTarget,
10
+ type TraceChoice,
11
+ type TraceSources,
12
+ type WatchTargetMissing
13
+ } from "./Watch.ts"
14
+
15
+ /**
16
+ * `llm4ts profile`: where a run's time went. A pure fold over the
17
+ * same inputs as the agent tree: per story, its wall time split into model,
18
+ * the coder's tools, gates, merge and waiting, the rest left as unaccounted;
19
+ * per role and executor the model calls, per command the gates; and the
20
+ * biggest sinks ranked in plain words.
21
+ *
22
+ * Content-free by design, so a report can leave a customer's server: story
23
+ * ids, roles, executor ids, tool names, gate commands as configured, counts
24
+ * and durations — never a prompt, a reply, an argument or an output.
25
+ *
26
+ * A trace from before the `Timed` event (2.22) is estimated: a model call is
27
+ * the gap before its token report in the same lane, and the report says so.
28
+ */
29
+
30
+ const Ms = Schema.Number
31
+
32
+ export const ProfileTime = Schema.Struct({
33
+ model: Ms,
34
+ tools: Ms,
35
+ gates: Ms,
36
+ merge: Ms,
37
+ waiting: Ms,
38
+ unaccounted: Ms
39
+ })
40
+ export type ProfileTime = typeof ProfileTime.Type
41
+
42
+ export const TurnStats = Schema.Struct({
43
+ count: Schema.Int,
44
+ avgMs: Ms,
45
+ maxMs: Ms,
46
+ firstPrompt: Schema.optionalKey(Schema.Int),
47
+ lastPrompt: Schema.optionalKey(Schema.Int),
48
+ maxPrompt: Schema.optionalKey(Schema.Int),
49
+ /** The last prompt is more than three times the first. */
50
+ promptGrowth: Schema.Boolean
51
+ })
52
+ export type TurnStats = typeof TurnStats.Type
53
+
54
+ export const StoryProfile = Schema.Struct({
55
+ id: Schema.String,
56
+ executor: Schema.optionalKey(Schema.String),
57
+ status: Schema.Literals(["done", "failed", "running"]),
58
+ wallMs: Ms,
59
+ /** From the plan being ready to this story starting: dependencies and free coder slots. */
60
+ queuedMs: Schema.optionalKey(Ms),
61
+ time: ProfileTime,
62
+ turns: TurnStats
63
+ })
64
+ export type StoryProfile = typeof StoryProfile.Type
65
+
66
+ export const ModelRow = Schema.Struct({
67
+ role: Schema.String,
68
+ executor: Schema.optionalKey(Schema.String),
69
+ calls: Schema.Int,
70
+ ms: Ms,
71
+ avgMs: Ms,
72
+ avgFirstMs: Schema.optionalKey(Ms),
73
+ /** As the backend reported them. */
74
+ apiMs: Schema.optionalKey(Ms),
75
+ toolMs: Schema.optionalKey(Ms)
76
+ })
77
+ export type ModelRow = typeof ModelRow.Type
78
+
79
+ export const GateRow = Schema.Struct({
80
+ command: Schema.String,
81
+ runs: Schema.Int,
82
+ ms: Ms,
83
+ avgMs: Ms,
84
+ failed: Schema.Int
85
+ })
86
+ export type GateRow = typeof GateRow.Type
87
+
88
+ export const CountRow = Schema.Struct({ label: Schema.String, count: Schema.Int, ms: Ms })
89
+ export type CountRow = typeof CountRow.Type
90
+
91
+ export const Finding = Schema.Struct({ text: Schema.String, ms: Schema.optionalKey(Ms) })
92
+ export type Finding = typeof Finding.Type
93
+
94
+ export const ProfileReport = Schema.Struct({
95
+ /** No `Timed` events: model time is estimated from gaps, the rest is unknown. */
96
+ estimated: Schema.Boolean,
97
+ wallMs: Ms,
98
+ stories: Schema.Array(StoryProfile),
99
+ /** Stories merged on an earlier run: this run only skipped them. */
100
+ skipped: Schema.Array(Schema.String),
101
+ totals: ProfileTime,
102
+ models: Schema.Array(ModelRow),
103
+ gates: Schema.Array(GateRow),
104
+ tools: Schema.Array(CountRow),
105
+ waits: Schema.Array(CountRow),
106
+ findings: Schema.Array(Finding)
107
+ })
108
+ export type ProfileReport = typeof ProfileReport.Type
109
+
110
+ // ── fold ────────────────────────────────────────────────────────────────────
111
+
112
+ interface TimedAt {
113
+ readonly at: number
114
+ readonly event: Timed
115
+ }
116
+
117
+ interface Lane {
118
+ readonly id: string
119
+ start: number
120
+ end: number | undefined
121
+ status: "done" | "failed" | "running"
122
+ executor: string | undefined
123
+ readonly timed: Array<TimedAt>
124
+ /** Coder prompt sizes, in order. */
125
+ readonly prompts: Array<number>
126
+ /** Estimated mode: the gaps before each token report. */
127
+ readonly estimatedCalls: Array<number>
128
+ /** The last turn boundary: any lane event but a tool call inside a turn. */
129
+ last: number
130
+ skipped: boolean
131
+ }
132
+
133
+ interface Lease {
134
+ readonly at: number
135
+ readonly executor: string
136
+ readonly role: string
137
+ readonly label: string | undefined
138
+ }
139
+
140
+ const storyStage = /^story (\S+)$/u
141
+
142
+ /** Events a coder turn emits while it runs: not where a model call starts. */
143
+ const withinTurn: ReadonlySet<FlowEvent["_tag"]> = new Set([
144
+ "ToolUse",
145
+ "UsageProgress",
146
+ "CapabilityUsed",
147
+ "AssistantMessage"
148
+ ])
149
+
150
+ const sum = (values: ReadonlyArray<number>): number =>
151
+ values.reduce((total, value) => total + value, 0)
152
+
153
+ const average = (values: ReadonlyArray<number>): number | undefined =>
154
+ values.length === 0 ? undefined : sum(values) / values.length
155
+
156
+ const groupBy = <A>(items: ReadonlyArray<A>, key: (item: A) => string): Map<string, Array<A>> => {
157
+ const groups = new Map<string, Array<A>>()
158
+ for (const item of items) {
159
+ const group = groups.get(key(item)) ?? []
160
+ group.push(item)
161
+ groups.set(key(item), group)
162
+ }
163
+ return groups
164
+ }
165
+
166
+ const laneOf = (event: FlowEvent): string | undefined =>
167
+ "lane" in event && typeof event.lane === "string" ? event.lane : undefined
168
+
169
+ /** The roster role a seat label stands for, to find its lease. */
170
+ const leaseRole = (label: string): string => (label === "reasoning" ? "reviewer" : label)
171
+
172
+ export const profileOf = (inputs: ReadonlyArray<TreeInput>): ProfileReport => {
173
+ const lanes = new Map<string, Lane>()
174
+ const leases: Array<Lease> = []
175
+ const allTimed: Array<TimedAt> = []
176
+ let first: number | undefined
177
+ let lastAt = 0
178
+ let planReady: number | undefined
179
+
180
+ for (const input of inputs) {
181
+ first = first ?? input.at
182
+ lastAt = Math.max(lastAt, input.at)
183
+ if (input._tag !== "Event") {
184
+ continue
185
+ }
186
+ const { event, at } = input
187
+ const laneId = laneOf(event)
188
+ const lane = laneId === undefined ? undefined : lanes.get(laneId)
189
+ switch (event._tag) {
190
+ case "StageStarted": {
191
+ const story = storyStage.exec(event.stage)?.[1]
192
+ if (story !== undefined && event.lane === story) {
193
+ lanes.set(story, {
194
+ id: story,
195
+ start: at,
196
+ end: undefined,
197
+ status: "running",
198
+ executor: event.executor,
199
+ timed: [],
200
+ prompts: [],
201
+ estimatedCalls: [],
202
+ last: at,
203
+ skipped: false
204
+ })
205
+ continue
206
+ }
207
+ break
208
+ }
209
+ case "StageCompleted":
210
+ case "StageFailed":
211
+ if (event.stage === "story plan" && event.lane === undefined) {
212
+ planReady = at
213
+ }
214
+ if (lane !== undefined && event.stage === `story ${lane.id}`) {
215
+ lane.end = at
216
+ lane.status = event._tag === "StageFailed" ? "failed" : "done"
217
+ }
218
+ break
219
+ case "Timed": {
220
+ allTimed.push({ at, event })
221
+ lane?.timed.push({ at, event })
222
+ break
223
+ }
224
+ case "TokensUsed":
225
+ if (lane !== undefined) {
226
+ if (event.agent === "coder") {
227
+ lane.prompts.push(event.usage.prompt)
228
+ }
229
+ lane.estimatedCalls.push(at - lane.last)
230
+ }
231
+ break
232
+ case "ExecutorLeased":
233
+ leases.push({ at, executor: event.executor, role: event.role, label: event.label })
234
+ break
235
+ case "Info":
236
+ if (lane !== undefined && event.message.startsWith(`story ${lane.id}: already merged`)) {
237
+ lane.skipped = true
238
+ }
239
+ break
240
+ default:
241
+ break
242
+ }
243
+ if (lane !== undefined) {
244
+ lane.executor = lane.executor ?? ("executor" in event ? event.executor : undefined)
245
+ if (!withinTurn.has(event._tag)) {
246
+ lane.last = at
247
+ }
248
+ }
249
+ }
250
+
251
+ const estimated = allTimed.length === 0
252
+ const stories = [...lanes.values()]
253
+ .filter((lane) => !lane.skipped)
254
+ .map((lane) => storyProfile(lane, lastAt, planReady, estimated))
255
+ const skipped = [...lanes.values()].filter((lane) => lane.skipped).map((lane) => lane.id)
256
+
257
+ const executorOf = (item: TimedAt): string | undefined => {
258
+ if (item.event.executor !== undefined) {
259
+ return item.event.executor
260
+ }
261
+ const role = leaseRole(item.event.label)
262
+ const lease = [...leases]
263
+ .reverse()
264
+ .find(
265
+ (candidate) =>
266
+ candidate.at <= item.at && candidate.role === role && candidate.label === item.event.lane
267
+ )
268
+ return lease?.executor
269
+ }
270
+
271
+ const modelCalls = allTimed.filter((item) => item.event.kind === "model")
272
+ const models = [
273
+ ...groupBy(modelCalls, (item) => `${item.event.label}\u0000${executorOf(item) ?? ""}`).values()
274
+ ].map((calls): ModelRow => {
275
+ const sample = calls[0]
276
+ const executor = sample === undefined ? undefined : executorOf(sample)
277
+ const ms = sum(calls.map((item) => item.event.ms))
278
+ const firsts = calls.flatMap((item) =>
279
+ item.event.firstMs === undefined ? [] : [item.event.firstMs]
280
+ )
281
+ const apis = calls.flatMap((item) => (item.event.apiMs === undefined ? [] : [item.event.apiMs]))
282
+ const toolsReported = calls.flatMap((item) =>
283
+ item.event.toolMs === undefined ? [] : [item.event.toolMs]
284
+ )
285
+ const avgFirstMs = average(firsts)
286
+ return {
287
+ role: sample?.event.label ?? "",
288
+ ...(executor === undefined ? {} : { executor }),
289
+ calls: calls.length,
290
+ ms,
291
+ avgMs: ms / calls.length,
292
+ ...(avgFirstMs === undefined ? {} : { avgFirstMs }),
293
+ ...(apis.length === 0 ? {} : { apiMs: sum(apis) }),
294
+ ...(toolsReported.length === 0 ? {} : { toolMs: sum(toolsReported) })
295
+ }
296
+ })
297
+
298
+ const gates = [
299
+ ...groupBy(
300
+ allTimed.filter((item) => item.event.kind === "gate"),
301
+ (item) => item.event.label
302
+ ).entries()
303
+ ].map(([command, runs]): GateRow => {
304
+ const ms = sum(runs.map((item) => item.event.ms))
305
+ return {
306
+ command,
307
+ runs: runs.length,
308
+ ms,
309
+ avgMs: ms / runs.length,
310
+ failed: runs.filter((item) => (item.event.exitCode ?? 0) !== 0 || item.event.failed === true)
311
+ .length
312
+ }
313
+ })
314
+
315
+ const counted = (kind: Timed["kind"]): ReadonlyArray<CountRow> =>
316
+ [
317
+ ...groupBy(
318
+ allTimed.filter((item) => item.event.kind === kind),
319
+ (item) => item.event.label
320
+ ).entries()
321
+ ].map(([label, items]) => ({
322
+ label,
323
+ count: items.length,
324
+ ms: sum(items.map((item) => item.event.ms))
325
+ }))
326
+ const tools = counted("tool")
327
+ const waits = counted("wait")
328
+ const merges = [...counted("merge"), ...counted("git")]
329
+
330
+ const totals = stories.reduce<ProfileTime>(
331
+ (total, story) => ({
332
+ model: total.model + story.time.model,
333
+ tools: total.tools + story.time.tools,
334
+ gates: total.gates + story.time.gates,
335
+ merge: total.merge + story.time.merge,
336
+ waiting: total.waiting + story.time.waiting,
337
+ unaccounted: total.unaccounted + story.time.unaccounted
338
+ }),
339
+ { model: 0, tools: 0, gates: 0, merge: 0, waiting: 0, unaccounted: 0 }
340
+ )
341
+ const storyTime = sum(stories.map((story) => story.wallMs))
342
+
343
+ return {
344
+ estimated,
345
+ wallMs: first === undefined ? 0 : lastAt - first,
346
+ stories,
347
+ skipped,
348
+ totals,
349
+ models,
350
+ gates,
351
+ tools,
352
+ waits,
353
+ findings: findingsOf({ stories, models, gates, tools, waits, merges, storyTime, estimated })
354
+ }
355
+ }
356
+
357
+ const storyProfile = (
358
+ lane: Lane,
359
+ lastAt: number,
360
+ planReady: number | undefined,
361
+ estimated: boolean
362
+ ): StoryProfile => {
363
+ const wallMs = (lane.end ?? lastAt) - lane.start
364
+ const of = (kind: Timed["kind"]) =>
365
+ sum(lane.timed.filter((item) => item.event.kind === kind).map((item) => item.event.ms))
366
+ const coderCalls = lane.timed
367
+ .filter((item) => item.event.kind === "model" && item.event.label === "coder")
368
+ .map((item) => item.event.ms)
369
+ const tools = of("tool")
370
+ const model = estimated ? sum(lane.estimatedCalls) : Math.max(0, of("model") - tools)
371
+ const gates = of("gate")
372
+ const merge = of("merge") + of("git")
373
+ const waiting = of("wait")
374
+ const turnTimes = estimated ? lane.estimatedCalls : coderCalls
375
+ const firstPrompt = lane.prompts[0]
376
+ const lastPrompt = lane.prompts.at(-1)
377
+ const maxPrompt = lane.prompts.length === 0 ? undefined : Math.max(...lane.prompts)
378
+ return {
379
+ id: lane.id,
380
+ ...(lane.executor === undefined ? {} : { executor: lane.executor }),
381
+ status: lane.status,
382
+ wallMs,
383
+ ...(planReady === undefined ? {} : { queuedMs: Math.max(0, lane.start - planReady) }),
384
+ time: {
385
+ model,
386
+ tools,
387
+ gates,
388
+ merge,
389
+ waiting,
390
+ unaccounted: Math.max(0, wallMs - model - tools - gates - merge - waiting)
391
+ },
392
+ turns: {
393
+ count: turnTimes.length,
394
+ avgMs: average(turnTimes) ?? 0,
395
+ maxMs: turnTimes.length === 0 ? 0 : Math.max(...turnTimes),
396
+ ...(firstPrompt === undefined ? {} : { firstPrompt }),
397
+ ...(lastPrompt === undefined ? {} : { lastPrompt }),
398
+ ...(maxPrompt === undefined ? {} : { maxPrompt }),
399
+ promptGrowth:
400
+ firstPrompt !== undefined &&
401
+ lastPrompt !== undefined &&
402
+ firstPrompt > 0 &&
403
+ lastPrompt > 3 * firstPrompt
404
+ }
405
+ }
406
+ }
407
+
408
+ // ── words ───────────────────────────────────────────────────────────────────
409
+
410
+ /** `20s`, `4m00s`, `1h02m`. */
411
+ export const duration = (ms: number): string => {
412
+ const seconds = Math.round(ms / 1_000)
413
+ if (seconds < 60) {
414
+ return `${seconds}s`
415
+ }
416
+ const minutes = Math.floor(seconds / 60)
417
+ if (minutes < 60) {
418
+ return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`
419
+ }
420
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`
421
+ }
422
+
423
+ const share = (ms: number, of: number): string => (of <= 0 ? "" : `${Math.round((ms / of) * 100)}%`)
424
+
425
+ const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? "" : "s"}`
426
+
427
+ const findingsOf = (facts: {
428
+ readonly stories: ReadonlyArray<StoryProfile>
429
+ readonly models: ReadonlyArray<ModelRow>
430
+ readonly gates: ReadonlyArray<GateRow>
431
+ readonly tools: ReadonlyArray<CountRow>
432
+ readonly waits: ReadonlyArray<CountRow>
433
+ readonly merges: ReadonlyArray<CountRow>
434
+ readonly storyTime: number
435
+ readonly estimated: boolean
436
+ }): ReadonlyArray<Finding> => {
437
+ const ofStories = (ms: number) => `${duration(ms)} (${share(ms, facts.storyTime)} of story time)`
438
+ const byRole = [...groupBy(facts.models, (row) => row.role).entries()].map(([role, rows]) => {
439
+ const calls = sum(rows.map((row) => row.calls))
440
+ const ms = sum(rows.map((row) => row.ms))
441
+ const firsts = rows.flatMap((row) => (row.avgFirstMs === undefined ? [] : [row.avgFirstMs]))
442
+ const firstMs = average(firsts)
443
+ const first = firstMs === undefined ? "" : `, first output after ${duration(firstMs)}`
444
+ return {
445
+ ms,
446
+ text:
447
+ role === "coder"
448
+ ? `coder turns: ${calls}, ${ofStories(ms)}, avg ${duration(ms / calls)}${first}`
449
+ : `${role} calls: ${calls}, ${ofStories(ms)}, avg ${duration(ms / calls)}${first}`
450
+ }
451
+ })
452
+ const candidates: Array<{ readonly ms: number; readonly text: string }> = [
453
+ ...byRole,
454
+ ...facts.gates.map((row) => ({
455
+ ms: row.ms,
456
+ text: `gate \`${row.command}\`: ${plural(row.runs, "run")}, ${ofStories(row.ms)}${
457
+ row.failed === 0 ? "" : `, ${row.failed} failed`
458
+ }`
459
+ })),
460
+ ...facts.tools.map((row) => ({
461
+ ms: row.ms,
462
+ text: `coder tool \`${row.label}\`: ${plural(row.count, "call")}, ${ofStories(row.ms)}`
463
+ })),
464
+ ...facts.waits.map((row) => ({
465
+ ms: row.ms,
466
+ text: `waiting for the ${row.label}: ${row.count}×, ${ofStories(row.ms)}`
467
+ })),
468
+ ...facts.merges.map((row) => ({
469
+ ms: row.ms,
470
+ text: `${row.label}: ${row.count}×, ${ofStories(row.ms)}`
471
+ }))
472
+ ]
473
+ const queued = sum(facts.stories.map((story) => story.queuedMs ?? 0))
474
+ if (queued >= 1_000) {
475
+ candidates.push({
476
+ ms: queued,
477
+ text: `stories queued before starting (dependencies, free coder slots): ${duration(queued)} in all`
478
+ })
479
+ }
480
+ const estimated = facts.estimated
481
+ ? facts.stories.map((story) => ({
482
+ ms: story.time.model,
483
+ text: `${story.id}: model calls (estimated) ${duration(story.time.model)} of ${duration(story.wallMs)}`
484
+ }))
485
+ : []
486
+ const ranked = [...candidates, ...estimated]
487
+ .filter((candidate) => candidate.ms >= 1_000)
488
+ .sort((left, right) => right.ms - left.ms)
489
+ .slice(0, 3)
490
+ const growth = facts.stories.flatMap((story) =>
491
+ story.turns.promptGrowth &&
492
+ story.turns.firstPrompt !== undefined &&
493
+ story.turns.lastPrompt !== undefined
494
+ ? [
495
+ {
496
+ 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`
497
+ }
498
+ ]
499
+ : []
500
+ )
501
+ return [...ranked, ...growth]
502
+ }
503
+
504
+ const table = (
505
+ header: ReadonlyArray<string>,
506
+ rows: ReadonlyArray<ReadonlyArray<string>>
507
+ ): Array<string> => {
508
+ const widths = header.map((title, column) =>
509
+ Math.max(title.length, ...rows.map((row) => (row[column] ?? "").length))
510
+ )
511
+ const line = (cells: ReadonlyArray<string>) =>
512
+ ` ${cells.map((cell, column) => (column === 0 ? cell.padEnd(widths[column] ?? 0) : cell.padStart(widths[column] ?? 0))).join(" ")}`.trimEnd()
513
+ return [line(header), ...rows.map(line)]
514
+ }
515
+
516
+ export const renderProfile = (report: ProfileReport): string => {
517
+ const storyTime = sum(report.stories.map((story) => story.wallMs))
518
+ const categories: ReadonlyArray<readonly [string, number]> = [
519
+ [report.estimated ? "model + tools + gates (estimated)" : "model", report.totals.model],
520
+ ["coder tools", report.totals.tools],
521
+ ["gates", report.totals.gates],
522
+ ["merge", report.totals.merge],
523
+ ["waiting", report.totals.waiting],
524
+ ["unaccounted", report.totals.unaccounted]
525
+ ]
526
+ return [
527
+ `llm4ts profile · ${duration(report.wallMs)} wall · ${report.stories.length} ${report.stories.length === 1 ? "story" : "stories"}${
528
+ report.estimated
529
+ ? " · estimated: this trace has no timings (before 2.22), model time is read from gaps"
530
+ : ""
531
+ }`,
532
+ "",
533
+ "Where the time goes",
534
+ ...(report.findings.length === 0
535
+ ? [" nothing timed yet"]
536
+ : report.findings.map((finding, index) => ` ${index + 1}. ${finding.text}`)),
537
+ "",
538
+ "Story time",
539
+ ...table(
540
+ ["category", "time", "share"],
541
+ categories.map(([name, ms]) => [name, duration(ms), share(ms, storyTime)])
542
+ ),
543
+ "",
544
+ "Stories",
545
+ ...table(
546
+ [
547
+ "story",
548
+ "status",
549
+ "wall",
550
+ "queued",
551
+ "model",
552
+ "tools",
553
+ "gates",
554
+ "merge",
555
+ "wait",
556
+ "other",
557
+ "turns",
558
+ "avg turn",
559
+ "prompt ×"
560
+ ],
561
+ report.stories.map((story) => [
562
+ story.id,
563
+ story.status,
564
+ duration(story.wallMs),
565
+ story.queuedMs === undefined ? "" : duration(story.queuedMs),
566
+ duration(story.time.model),
567
+ duration(story.time.tools),
568
+ duration(story.time.gates),
569
+ duration(story.time.merge),
570
+ duration(story.time.waiting),
571
+ duration(story.time.unaccounted),
572
+ String(story.turns.count),
573
+ duration(story.turns.avgMs),
574
+ story.turns.firstPrompt !== undefined &&
575
+ story.turns.lastPrompt !== undefined &&
576
+ story.turns.firstPrompt > 0
577
+ ? `${(story.turns.lastPrompt / story.turns.firstPrompt).toFixed(1)}×`
578
+ : ""
579
+ ])
580
+ ),
581
+ ...(report.skipped.length === 0
582
+ ? []
583
+ : [` skipped (merged on an earlier run): ${report.skipped.join(", ")}`]),
584
+ ...(report.models.length === 0
585
+ ? []
586
+ : [
587
+ "",
588
+ "Model calls",
589
+ ...table(
590
+ [
591
+ "role",
592
+ "executor",
593
+ "calls",
594
+ "total",
595
+ "avg",
596
+ "first output",
597
+ "api (reported)",
598
+ "tools (reported)"
599
+ ],
600
+ report.models.map((row) => [
601
+ row.role,
602
+ row.executor ?? "",
603
+ String(row.calls),
604
+ duration(row.ms),
605
+ duration(row.avgMs),
606
+ row.avgFirstMs === undefined ? "" : duration(row.avgFirstMs),
607
+ row.apiMs === undefined ? "" : duration(row.apiMs),
608
+ row.toolMs === undefined ? "" : duration(row.toolMs)
609
+ ])
610
+ )
611
+ ]),
612
+ ...(report.gates.length === 0
613
+ ? []
614
+ : [
615
+ "",
616
+ "Gates",
617
+ ...table(
618
+ ["command", "runs", "total", "avg", "failed"],
619
+ report.gates.map((row) => [
620
+ row.command,
621
+ String(row.runs),
622
+ duration(row.ms),
623
+ duration(row.avgMs),
624
+ String(row.failed)
625
+ ])
626
+ )
627
+ ])
628
+ ].join("\n")
629
+ }
630
+
631
+ export interface ProfileOptions extends TraceChoice {
632
+ readonly json?: boolean
633
+ }
634
+
635
+ const encodeReport = Schema.encodeSync(Schema.fromJsonString(ProfileReport))
636
+
637
+ /** The report for a trace, an epic's latest run, or the newest trace: text, or JSON for comparing runs. */
638
+ export const makeProfileProgram = Effect.fn("@llm4ts/runner/Profile.make")(function* (
639
+ options: ProfileOptions,
640
+ sources: TraceSources
641
+ ): Effect.fn.Return<string, FlowError | WatchTargetMissing> {
642
+ const target = yield* resolveTraceTarget(options, sources)
643
+ const report = profileOf(treeInputsOfTrace(yield* readTrace(sources.files, target.tracePath)))
644
+ return options.json === true ? encodeReport(report) : renderProfile(report)
645
+ })
package/src/Terminal.ts CHANGED
@@ -42,6 +42,7 @@ export const rendersEvent = (verbosity: Verbosity, event: FlowEvent): boolean =>
42
42
  case "UsageProgress":
43
43
  case "ExecutorReleased":
44
44
  case "StoryJudged":
45
+ case "Timed":
45
46
  return false
46
47
  case "StageStarted":
47
48
  case "StageCompleted":
@@ -242,6 +243,7 @@ export const terminalLine = (
242
243
  case "BlackboardRun":
243
244
  case "UsageProgress":
244
245
  case "StoryJudged":
246
+ case "Timed":
245
247
  return ""
246
248
  case "StageStarted":
247
249
  return palette.stageStart(safe(event.stage))