@llm4ts/runner 2.8.0 → 2.9.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/Connectors.d.ts +2 -0
- package/dist/Connectors.d.ts.map +1 -1
- package/dist/Connectors.js +26 -24
- package/dist/Connectors.js.map +1 -1
- package/dist/ExecutorRoster.d.ts +54 -0
- package/dist/ExecutorRoster.d.ts.map +1 -0
- package/dist/ExecutorRoster.js +206 -0
- package/dist/ExecutorRoster.js.map +1 -0
- package/dist/FlowRunner.d.ts +14 -4
- package/dist/FlowRunner.d.ts.map +1 -1
- package/dist/FlowRunner.js +201 -12
- package/dist/FlowRunner.js.map +1 -1
- package/dist/Terminal.d.ts +27 -1
- package/dist/Terminal.d.ts.map +1 -1
- package/dist/Terminal.js +152 -30
- package/dist/Terminal.js.map +1 -1
- package/package.json +4 -3
- package/src/Connectors.ts +27 -24
- package/src/ExecutorRoster.ts +318 -0
- package/src/FlowRunner.ts +306 -14
- package/src/Terminal.ts +209 -31
package/src/Terminal.ts
CHANGED
|
@@ -85,6 +85,11 @@ export const terminalSafe = (value: string): string => {
|
|
|
85
85
|
|
|
86
86
|
const Ansi = Object.freeze({
|
|
87
87
|
reset: "\u001b[0m",
|
|
88
|
+
cyan: "\u001b[36m",
|
|
89
|
+
blue: "\u001b[34m",
|
|
90
|
+
magenta: "\u001b[35m",
|
|
91
|
+
yellow: "\u001b[33m",
|
|
92
|
+
greenBold: "\u001b[1;32m",
|
|
88
93
|
boldMagenta: "\u001b[1;35m",
|
|
89
94
|
green: "\u001b[32m",
|
|
90
95
|
red: "\u001b[31m",
|
|
@@ -102,8 +107,12 @@ export interface TerminalPalette {
|
|
|
102
107
|
readonly assistant: (text: string) => string
|
|
103
108
|
readonly toolCall: (tool: string, args: string) => string
|
|
104
109
|
readonly status: (frame: string, label: string) => string
|
|
110
|
+
/** A lane's tag, in the lane's own colour (`index` picks it). */
|
|
111
|
+
readonly lane: (index: number, text: string) => string
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
const laneColours = [Ansi.cyan, Ansi.yellow, Ansi.blue, Ansi.greenBold, Ansi.magenta]
|
|
115
|
+
|
|
107
116
|
export const makeTerminalPalette = (enabled: boolean): TerminalPalette => {
|
|
108
117
|
const paint = (code: string, text: string): string =>
|
|
109
118
|
enabled ? `${code}${text}${Ansi.reset}` : text
|
|
@@ -119,7 +128,8 @@ export const makeTerminalPalette = (enabled: boolean): TerminalPalette => {
|
|
|
119
128
|
const head = paint(Ansi.yellowBold, `● ${tool}`)
|
|
120
129
|
return args.length === 0 ? head : `${head} ${paint(Ansi.darkGray, `(${args})`)}`
|
|
121
130
|
},
|
|
122
|
-
status: (frame, label) => `${paint(Ansi.boldMagenta, frame)} ${label}
|
|
131
|
+
status: (frame, label) => `${paint(Ansi.boldMagenta, frame)} ${label}`,
|
|
132
|
+
lane: (index, text) => paint(laneColours[index % laneColours.length] ?? Ansi.cyan, text)
|
|
123
133
|
}
|
|
124
134
|
}
|
|
125
135
|
|
|
@@ -222,9 +232,16 @@ const clearLine = "\r\u001b[2K"
|
|
|
222
232
|
|
|
223
233
|
export interface TerminalOutput {
|
|
224
234
|
readonly isTTY?: boolean
|
|
235
|
+
readonly columns?: number
|
|
225
236
|
readonly write: (text: string) => unknown
|
|
226
237
|
}
|
|
227
238
|
|
|
239
|
+
/** Cuts a plain status line to the terminal's width, so a wrapped line never breaks the redraw. */
|
|
240
|
+
export const fitToWidth = (line: string, columns: number | undefined): string =>
|
|
241
|
+
columns === undefined || columns < 8 || line.length <= columns - 2
|
|
242
|
+
? line
|
|
243
|
+
: `${line.slice(0, columns - 3)}…`
|
|
244
|
+
|
|
228
245
|
export const terminalSupportsColor = (
|
|
229
246
|
output: TerminalOutput,
|
|
230
247
|
environment: Readonly<Record<string, string | undefined>>
|
|
@@ -235,13 +252,20 @@ export const makeLiveTerminalSurface = Effect.fn("@llm4ts/runner/Terminal.makeLi
|
|
|
235
252
|
process.stdout.write(text)
|
|
236
253
|
},
|
|
237
254
|
palette: TerminalPalette = makeTerminalPalette(true),
|
|
238
|
-
tick: Duration.Input = "100 millis"
|
|
255
|
+
tick: Duration.Input = "100 millis",
|
|
256
|
+
columns: () => number | undefined = () => process.stdout.columns
|
|
239
257
|
): Effect.fn.Return<TerminalSurface, never, Scope.Scope> {
|
|
240
258
|
const lock = yield* Semaphore.make(1)
|
|
241
259
|
const status = yield* Ref.make<string | undefined>(undefined)
|
|
242
260
|
const frame = yield* Ref.make(0)
|
|
243
261
|
const suspended = yield* Ref.make(false)
|
|
262
|
+
// How many lines the status block took when last drawn: a multi-line
|
|
263
|
+
// block (one row per concurrent story) is cleared line by line upwards.
|
|
264
|
+
const drawn = yield* Ref.make(0)
|
|
244
265
|
const emit = (text: string): Effect.Effect<void> => Effect.sync(() => write(text))
|
|
266
|
+
const clearStatus = Effect.flatMap(Ref.getAndSet(drawn, 0), (lines) =>
|
|
267
|
+
emit(lines <= 1 ? clearLine : `${clearLine}${`\u001b[1A${clearLine}`.repeat(lines - 1)}`)
|
|
268
|
+
)
|
|
245
269
|
const drawStatus = Effect.gen(function* () {
|
|
246
270
|
if (yield* Ref.get(suspended)) {
|
|
247
271
|
return
|
|
@@ -249,24 +273,34 @@ export const makeLiveTerminalSurface = Effect.fn("@llm4ts/runner/Terminal.makeLi
|
|
|
249
273
|
const label = yield* Ref.get(status)
|
|
250
274
|
if (label !== undefined) {
|
|
251
275
|
const currentFrame = yield* Ref.get(frame)
|
|
276
|
+
const width = columns()
|
|
277
|
+
const rows = label.split("\n")
|
|
252
278
|
yield* emit(
|
|
253
|
-
|
|
279
|
+
rows
|
|
280
|
+
.map((row, index) =>
|
|
281
|
+
palette.status(
|
|
282
|
+
index === 0 ? (spinnerFrames[currentFrame % spinnerFrames.length] ?? "·") : " ",
|
|
283
|
+
fitToWidth(row, width === undefined ? undefined : width - 2)
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
.join("\n")
|
|
254
287
|
)
|
|
288
|
+
yield* Ref.set(drawn, rows.length)
|
|
255
289
|
}
|
|
256
290
|
})
|
|
257
291
|
const surface: TerminalSurface = {
|
|
258
292
|
palette,
|
|
259
293
|
log: (line) =>
|
|
260
294
|
lock.withPermit(
|
|
261
|
-
|
|
295
|
+
clearStatus.pipe(Effect.andThen(emit(`${line}\n`)), Effect.andThen(drawStatus))
|
|
262
296
|
),
|
|
263
297
|
setStatus: (label) =>
|
|
264
298
|
lock.withPermit(
|
|
265
|
-
Ref.set(status, label)
|
|
299
|
+
clearStatus.pipe(Effect.andThen(Ref.set(status, label)), Effect.andThen(drawStatus))
|
|
266
300
|
),
|
|
267
301
|
suspend: (effect) =>
|
|
268
302
|
lock
|
|
269
|
-
.withPermit(Ref.set(suspended, true).pipe(Effect.andThen(
|
|
303
|
+
.withPermit(Ref.set(suspended, true).pipe(Effect.andThen(clearStatus)))
|
|
270
304
|
.pipe(
|
|
271
305
|
Effect.andThen(effect),
|
|
272
306
|
Effect.ensuring(
|
|
@@ -276,11 +310,11 @@ export const makeLiveTerminalSurface = Effect.fn("@llm4ts/runner/Terminal.makeLi
|
|
|
276
310
|
}
|
|
277
311
|
yield* Effect.sleep(tick).pipe(
|
|
278
312
|
Effect.andThen(Ref.update(frame, (current) => current + 1)),
|
|
279
|
-
Effect.andThen(lock.withPermit(drawStatus)),
|
|
313
|
+
Effect.andThen(lock.withPermit(clearStatus.pipe(Effect.andThen(drawStatus)))),
|
|
280
314
|
Effect.forever,
|
|
281
315
|
Effect.forkScoped
|
|
282
316
|
)
|
|
283
|
-
yield* Effect.addFinalizer(() => lock.withPermit(
|
|
317
|
+
yield* Effect.addFinalizer(() => lock.withPermit(clearStatus))
|
|
284
318
|
return surface
|
|
285
319
|
})
|
|
286
320
|
|
|
@@ -292,7 +326,7 @@ export const makeTerminalSurface = (
|
|
|
292
326
|
output.write(text)
|
|
293
327
|
}
|
|
294
328
|
return terminalSupportsColor(output, environment)
|
|
295
|
-
? makeLiveTerminalSurface(write)
|
|
329
|
+
? makeLiveTerminalSurface(write, makeTerminalPalette(true), "100 millis", () => output.columns)
|
|
296
330
|
: Effect.succeed(makePlainTerminalSurface((line) => write(`${line}\n`)))
|
|
297
331
|
}
|
|
298
332
|
|
|
@@ -316,6 +350,75 @@ interface OpenStage {
|
|
|
316
350
|
readonly startedAt: number
|
|
317
351
|
}
|
|
318
352
|
|
|
353
|
+
/** The lane an event belongs to (a concurrent story), if it carries one. */
|
|
354
|
+
export const laneOfEvent = (
|
|
355
|
+
event: FlowEvent
|
|
356
|
+
): { readonly lane: string; readonly executor?: string } | undefined => {
|
|
357
|
+
switch (event._tag) {
|
|
358
|
+
case "StageStarted":
|
|
359
|
+
case "StageCompleted":
|
|
360
|
+
case "StageFailed":
|
|
361
|
+
case "Info":
|
|
362
|
+
case "ToolUse":
|
|
363
|
+
case "AssistantMessage":
|
|
364
|
+
case "TokensUsed":
|
|
365
|
+
return event.lane === undefined
|
|
366
|
+
? undefined
|
|
367
|
+
: {
|
|
368
|
+
lane: event.lane,
|
|
369
|
+
...(event.executor === undefined ? {} : { executor: event.executor })
|
|
370
|
+
}
|
|
371
|
+
default:
|
|
372
|
+
return undefined
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** `bonifici-list · lemonade-deepseek` — how a lane is named on screen. */
|
|
377
|
+
export const laneLabel = (lane: string, executor: string | undefined): string =>
|
|
378
|
+
executor === undefined ? lane : `${lane} · ${executor}`
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Whether a laned event is printed as its own line. With several stories
|
|
382
|
+
* running, tool calls are counted on each story's status row instead;
|
|
383
|
+
* `Verbose` prints them all, tagged.
|
|
384
|
+
*/
|
|
385
|
+
export const rendersLanedEvent = (verbosity: Verbosity, event: FlowEvent): boolean =>
|
|
386
|
+
event._tag === "ToolUse"
|
|
387
|
+
? verbosity === "Verbose" || verbosity === "Debug"
|
|
388
|
+
: rendersEvent(verbosity, event)
|
|
389
|
+
|
|
390
|
+
interface LaneState {
|
|
391
|
+
readonly index: number
|
|
392
|
+
depth: number
|
|
393
|
+
stages: ReadonlyArray<OpenStage>
|
|
394
|
+
tools: number
|
|
395
|
+
executor: string | undefined
|
|
396
|
+
readonly startedAt: number
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** One status row per active lane, the run's own stage (if any) first. */
|
|
400
|
+
export const statusBlock = (
|
|
401
|
+
globalStage: string | undefined,
|
|
402
|
+
lanes: ReadonlyArray<{
|
|
403
|
+
readonly lane: string
|
|
404
|
+
readonly executor: string | undefined
|
|
405
|
+
readonly stage: string | undefined
|
|
406
|
+
readonly elapsedMs: number
|
|
407
|
+
readonly tools: number
|
|
408
|
+
}>
|
|
409
|
+
): string | undefined => {
|
|
410
|
+
const rows = lanes
|
|
411
|
+
.filter((lane) => lane.stage !== undefined)
|
|
412
|
+
.map(
|
|
413
|
+
(lane) =>
|
|
414
|
+
`${laneLabel(lane.lane, lane.executor)} · ${lane.stage ?? ""} · ${formatDurationMs(lane.elapsedMs)}${
|
|
415
|
+
lane.tools === 0 ? "" : ` · ${lane.tools} tool call${lane.tools === 1 ? "" : "s"}`
|
|
416
|
+
}`
|
|
417
|
+
)
|
|
418
|
+
const block = [...(globalStage === undefined ? [] : [globalStage]), ...rows]
|
|
419
|
+
return block.length === 0 ? undefined : block.join("\n")
|
|
420
|
+
}
|
|
421
|
+
|
|
319
422
|
export const consumeTerminalEvents = Effect.fn("@llm4ts/runner/Terminal.consume")(function* (
|
|
320
423
|
events: FlowEventHub,
|
|
321
424
|
surface: TerminalSurface,
|
|
@@ -324,6 +427,10 @@ export const consumeTerminalEvents = Effect.fn("@llm4ts/runner/Terminal.consume"
|
|
|
324
427
|
): Effect.fn.Return<TerminalConsumer, never, Scope.Scope> {
|
|
325
428
|
const depth = yield* Ref.make(0)
|
|
326
429
|
const stages = yield* Ref.make<ReadonlyArray<OpenStage>>([])
|
|
430
|
+
// Concurrent stories keep their own stage stacks: one shared stack
|
|
431
|
+
// interleaves their stages, closes the wrong one, and shows whichever
|
|
432
|
+
// story started last as the only thing running.
|
|
433
|
+
const lanes = new Map<string, LaneState>()
|
|
327
434
|
const consumed = yield* Ref.make(0)
|
|
328
435
|
const statsRef = yield* Ref.make<TerminalRunStats>({ stagesCompleted: 0, stagesFailed: 0 })
|
|
329
436
|
const subscription = yield* events.subscribe
|
|
@@ -334,9 +441,97 @@ export const consumeTerminalEvents = Effect.fn("@llm4ts/runner/Terminal.consume"
|
|
|
334
441
|
Effect.map((now) => `${palette.dim(new Date(now).toISOString().slice(11, 19))} `)
|
|
335
442
|
)
|
|
336
443
|
: Effect.succeed("")
|
|
444
|
+
|
|
445
|
+
const refreshStatus = Effect.gen(function* () {
|
|
446
|
+
const now = yield* Clock.currentTimeMillis
|
|
447
|
+
const global = (yield* Ref.get(stages)).at(-1)?.name
|
|
448
|
+
yield* surface.setStatus(
|
|
449
|
+
statusBlock(
|
|
450
|
+
global,
|
|
451
|
+
[...lanes.entries()].map(([lane, state]) => ({
|
|
452
|
+
lane,
|
|
453
|
+
executor: state.executor,
|
|
454
|
+
stage: state.stages.at(-1)?.name,
|
|
455
|
+
elapsedMs: now - state.startedAt,
|
|
456
|
+
tools: state.tools
|
|
457
|
+
}))
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
const countStage = (event: FlowEvent): Effect.Effect<void> =>
|
|
463
|
+
event._tag === "StageCompleted"
|
|
464
|
+
? Ref.update(statsRef, (current) => ({
|
|
465
|
+
...current,
|
|
466
|
+
stagesCompleted: current.stagesCompleted + 1
|
|
467
|
+
}))
|
|
468
|
+
: event._tag === "StageFailed"
|
|
469
|
+
? Ref.update(statsRef, (current) => ({
|
|
470
|
+
...current,
|
|
471
|
+
stagesFailed: current.stagesFailed + 1
|
|
472
|
+
}))
|
|
473
|
+
: Effect.void
|
|
474
|
+
|
|
475
|
+
const withDuration = (event: FlowEvent, line: string, closed: OpenStage | undefined) =>
|
|
476
|
+
Effect.gen(function* () {
|
|
477
|
+
if (
|
|
478
|
+
closed === undefined ||
|
|
479
|
+
(event._tag !== "StageCompleted" && event._tag !== "StageFailed")
|
|
480
|
+
) {
|
|
481
|
+
return line
|
|
482
|
+
}
|
|
483
|
+
const now = yield* Clock.currentTimeMillis
|
|
484
|
+
return `${line} ${palette.dim(`(${formatDurationMs(now - closed.startedAt)})`)}`
|
|
485
|
+
})
|
|
486
|
+
|
|
337
487
|
yield* Stream.fromSubscription(subscription).pipe(
|
|
338
488
|
Stream.runForEach((event) =>
|
|
339
489
|
Effect.gen(function* () {
|
|
490
|
+
const tagged = laneOfEvent(event)
|
|
491
|
+
if (tagged !== undefined) {
|
|
492
|
+
const now = yield* Clock.currentTimeMillis
|
|
493
|
+
const state =
|
|
494
|
+
lanes.get(tagged.lane) ??
|
|
495
|
+
({
|
|
496
|
+
index: lanes.size,
|
|
497
|
+
depth: 0,
|
|
498
|
+
stages: [],
|
|
499
|
+
tools: 0,
|
|
500
|
+
executor: undefined,
|
|
501
|
+
startedAt: now
|
|
502
|
+
} satisfies LaneState)
|
|
503
|
+
lanes.set(tagged.lane, state)
|
|
504
|
+
if (tagged.executor !== undefined) {
|
|
505
|
+
state.executor = tagged.executor
|
|
506
|
+
}
|
|
507
|
+
let closed: OpenStage | undefined
|
|
508
|
+
if (closesChild(event)) {
|
|
509
|
+
state.depth = Math.max(0, state.depth - 1)
|
|
510
|
+
}
|
|
511
|
+
const lineDepth = state.depth
|
|
512
|
+
if (event._tag === "StageStarted") {
|
|
513
|
+
state.depth += 1
|
|
514
|
+
state.stages = [...state.stages, { name: terminalSafe(event.stage), startedAt: now }]
|
|
515
|
+
} else if (closesChild(event)) {
|
|
516
|
+
closed = state.stages.at(-1)
|
|
517
|
+
state.stages = state.stages.slice(0, -1)
|
|
518
|
+
yield* countStage(event)
|
|
519
|
+
} else if (event._tag === "ToolUse") {
|
|
520
|
+
state.tools += 1
|
|
521
|
+
}
|
|
522
|
+
if (state.stages.length === 0 && closesChild(event)) {
|
|
523
|
+
lanes.delete(tagged.lane)
|
|
524
|
+
}
|
|
525
|
+
if (rendersLanedEvent(verbosity, event)) {
|
|
526
|
+
const tag = palette.lane(state.index, `[${laneLabel(tagged.lane, state.executor)}]`)
|
|
527
|
+
const line = yield* withDuration(event, terminalLine(event, palette), closed)
|
|
528
|
+
const prefix = yield* timestampPrefix
|
|
529
|
+
yield* surface.log(`${prefix}${tag} ${indentBlock(lineDepth, line)}`)
|
|
530
|
+
}
|
|
531
|
+
yield* refreshStatus
|
|
532
|
+
yield* Ref.update(consumed, (count) => count + 1)
|
|
533
|
+
return
|
|
534
|
+
}
|
|
340
535
|
const currentDepth = closesChild(event)
|
|
341
536
|
? yield* Ref.updateAndGet(depth, (value) => Math.max(0, value - 1))
|
|
342
537
|
: yield* Ref.get(depth)
|
|
@@ -346,32 +541,15 @@ export const consumeTerminalEvents = Effect.fn("@llm4ts/runner/Terminal.consume"
|
|
|
346
541
|
const active = terminalSafe(event.stage)
|
|
347
542
|
const startedAt = yield* Clock.currentTimeMillis
|
|
348
543
|
yield* Ref.update(stages, (current) => [...current, { name: active, startedAt }])
|
|
349
|
-
yield*
|
|
544
|
+
yield* refreshStatus
|
|
350
545
|
} else if (closesChild(event)) {
|
|
351
546
|
closedStage = (yield* Ref.get(stages)).at(-1)
|
|
352
|
-
|
|
353
|
-
yield*
|
|
354
|
-
|
|
355
|
-
yield* Ref.update(statsRef, (current) => ({
|
|
356
|
-
...current,
|
|
357
|
-
stagesCompleted: current.stagesCompleted + 1
|
|
358
|
-
}))
|
|
359
|
-
} else if (event._tag === "StageFailed") {
|
|
360
|
-
yield* Ref.update(statsRef, (current) => ({
|
|
361
|
-
...current,
|
|
362
|
-
stagesFailed: current.stagesFailed + 1
|
|
363
|
-
}))
|
|
364
|
-
}
|
|
547
|
+
yield* Ref.update(stages, (current) => current.slice(0, -1))
|
|
548
|
+
yield* refreshStatus
|
|
549
|
+
yield* countStage(event)
|
|
365
550
|
}
|
|
366
551
|
if (rendersEvent(verbosity, event)) {
|
|
367
|
-
|
|
368
|
-
if (
|
|
369
|
-
closedStage !== undefined &&
|
|
370
|
-
(event._tag === "StageCompleted" || event._tag === "StageFailed")
|
|
371
|
-
) {
|
|
372
|
-
const now = yield* Clock.currentTimeMillis
|
|
373
|
-
line = `${line} ${palette.dim(`(${formatDurationMs(now - closedStage.startedAt)})`)}`
|
|
374
|
-
}
|
|
552
|
+
const line = yield* withDuration(event, terminalLine(event, palette), closedStage)
|
|
375
553
|
const prefix = yield* timestampPrefix
|
|
376
554
|
yield* surface.log(`${prefix}${indentBlock(currentDepth, line)}`)
|
|
377
555
|
}
|