@llm4ts/runner 2.21.1 → 2.22.1
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/dist/AgentTree.d.ts +32 -0
- package/dist/AgentTree.d.ts.map +1 -1
- package/dist/AgentTree.js +94 -10
- package/dist/AgentTree.js.map +1 -1
- package/dist/AgentTreeSurface.d.ts +1 -1
- package/dist/AgentTreeSurface.d.ts.map +1 -1
- package/dist/AgentTreeSurface.js +5 -2
- package/dist/AgentTreeSurface.js.map +1 -1
- package/dist/FlowRunner.d.ts.map +1 -1
- package/dist/FlowRunner.js +10 -7
- package/dist/FlowRunner.js.map +1 -1
- package/dist/Profile.d.ts +166 -0
- package/dist/Profile.d.ts.map +1 -0
- package/dist/Profile.js +497 -0
- package/dist/Profile.js.map +1 -0
- package/dist/Terminal.d.ts.map +1 -1
- package/dist/Terminal.js +2 -0
- package/dist/Terminal.js.map +1 -1
- package/dist/Watch.d.ts +15 -0
- package/dist/Watch.d.ts.map +1 -1
- package/dist/Watch.js +19 -6
- package/dist/Watch.js.map +1 -1
- package/package.json +4 -3
- package/src/AgentTree.ts +136 -10
- package/src/AgentTreeSurface.ts +6 -2
- package/src/FlowRunner.ts +19 -11
- package/src/Profile.ts +645 -0
- package/src/Terminal.ts +2 -0
- package/src/Watch.ts +36 -10
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))
|