@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/FlowRunner.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path"
|
|
2
2
|
import * as Clock from "effect/Clock"
|
|
3
3
|
import * as Effect from "effect/Effect"
|
|
4
|
+
import * as Ref from "effect/Ref"
|
|
4
5
|
import type * as Scope from "effect/Scope"
|
|
5
6
|
import type { ConnectorConfig } from "@llm4ts/core/ConnectorConfig"
|
|
6
7
|
import { CliConnectorConfig, defaultReasoningConfig } from "@llm4ts/core/ConnectorConfig"
|
|
@@ -13,6 +14,7 @@ import { makeTypeSafeJudgment } from "@llm4ts/core/judgment/TypeSafeJudgment"
|
|
|
13
14
|
import * as Redacted from "effect/Redacted"
|
|
14
15
|
import type { LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
15
16
|
import type { ConnectorCapabilities, TokenUsage } from "@llm4ts/core/Models"
|
|
17
|
+
import { ConnectorCapabilities as ConnectorCapabilitiesClass } from "@llm4ts/core/Models"
|
|
16
18
|
import type { ProcessExecutorShape } from "@llm4ts/core/ProcessExecutor"
|
|
17
19
|
import type { TemporaryFilesShape } from "@llm4ts/core/TemporaryFiles"
|
|
18
20
|
import type { GeminiCliExecutorShape } from "@llm4ts/core/providers/GeminiCliProvider"
|
|
@@ -35,7 +37,12 @@ import {
|
|
|
35
37
|
type CostBudget
|
|
36
38
|
} from "@llm4ts/flow/CostLedger"
|
|
37
39
|
import { estimatedUsageOptionsFromEnv, makeEstimatedUsageMeter } from "@llm4ts/flow/EstimatedUsage"
|
|
38
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
FlowLlmError,
|
|
42
|
+
RosterInvalid,
|
|
43
|
+
describeFlowError,
|
|
44
|
+
type FlowError
|
|
45
|
+
} from "@llm4ts/flow/FlowError"
|
|
39
46
|
import {
|
|
40
47
|
defaultDrainTimeout,
|
|
41
48
|
FlowEvents,
|
|
@@ -43,15 +50,39 @@ import {
|
|
|
43
50
|
Info,
|
|
44
51
|
TokensUsed,
|
|
45
52
|
makeFlowEventHub,
|
|
46
|
-
|
|
53
|
+
withLane,
|
|
54
|
+
type FlowEventHub,
|
|
55
|
+
type FlowEventsShape
|
|
47
56
|
} from "@llm4ts/flow/FlowEvents"
|
|
48
|
-
import { FlowContext, type FlowContextShape } from "@llm4ts/flow/FlowContext"
|
|
57
|
+
import { FlowContext, type ContextOptions, type FlowContextShape } from "@llm4ts/flow/FlowContext"
|
|
49
58
|
import { makeFlowRecorder } from "@llm4ts/flow/FlowRecorder"
|
|
50
59
|
import { makeJudgmentLog, type JudgmentLogShape } from "@llm4ts/flow/JudgmentLog"
|
|
51
60
|
import { makeGitHubTool } from "@llm4ts/flow/GitHubTool"
|
|
52
61
|
import { makeGitTool } from "@llm4ts/flow/GitTool"
|
|
53
62
|
import type { PlainFileStoreShape } from "@llm4ts/flow/Persistence"
|
|
54
63
|
import { makeTransientRetry } from "@llm4ts/flow/TransientRetry"
|
|
64
|
+
import {
|
|
65
|
+
Exclusion,
|
|
66
|
+
makeRoster,
|
|
67
|
+
makeRosterStateStore,
|
|
68
|
+
priorityOf,
|
|
69
|
+
rolesOf,
|
|
70
|
+
type ExecutorSpec,
|
|
71
|
+
type Role,
|
|
72
|
+
type RosterDocument,
|
|
73
|
+
type RosterShape
|
|
74
|
+
} from "@llm4ts/flow/Roster"
|
|
75
|
+
import {
|
|
76
|
+
makeHeldCoder,
|
|
77
|
+
rosterSeat,
|
|
78
|
+
type HeldCoder,
|
|
79
|
+
type RosterSeat,
|
|
80
|
+
type RosterView,
|
|
81
|
+
type SeatSource
|
|
82
|
+
} from "@llm4ts/flow/RosterSeats"
|
|
83
|
+
import * as Semaphore from "effect/Semaphore"
|
|
84
|
+
import * as ScopeModule from "effect/Scope"
|
|
85
|
+
import { executorConfig, httpProbe, loadRosterDocument, rosterStatePath } from "./ExecutorRoster.ts"
|
|
55
86
|
import { nodeHttpClient } from "./NodeHttpClient.ts"
|
|
56
87
|
import { nodeGeminiCliExecutor } from "./NodeGeminiCliExecutor.ts"
|
|
57
88
|
import { nodePlainFileStore } from "./NodePlainFileStore.ts"
|
|
@@ -158,10 +189,19 @@ export interface FlowRunnerOptions {
|
|
|
158
189
|
* nothing (see the connector capability matrix).
|
|
159
190
|
*/
|
|
160
191
|
readonly budget?: CostBudget
|
|
192
|
+
/**
|
|
193
|
+
* The executor roster (ADR 0019): every seat is then served from it and
|
|
194
|
+
* the coder/reasoning/reviewer configs above are ignored. Omitted, the
|
|
195
|
+
* runner loads one (`LLM4TS_ROSTER`, the user's and the repository's
|
|
196
|
+
* roster files); `"none"` runs with one executor per seat.
|
|
197
|
+
*/
|
|
198
|
+
readonly roster?: RosterDocument | "none"
|
|
161
199
|
}
|
|
162
200
|
|
|
163
201
|
export interface FlowRunnerBundle {
|
|
164
202
|
readonly context: FlowContextShape
|
|
203
|
+
/** The executor roster in force (ADR 0019), when the run has one. */
|
|
204
|
+
readonly roster?: RosterDocument
|
|
165
205
|
readonly events: FlowEventHub
|
|
166
206
|
/** The run's cost tracker, already subscribed to `events`. */
|
|
167
207
|
readonly tracker: CostTracker
|
|
@@ -407,15 +447,16 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
|
|
|
407
447
|
readonly reviewers: ReadonlyArray<LlmServiceShape>
|
|
408
448
|
readonly judgment: JudgmentShape
|
|
409
449
|
},
|
|
410
|
-
rebind: boolean
|
|
450
|
+
rebind: boolean,
|
|
451
|
+
laneEvents: FlowEventsShape = events
|
|
411
452
|
): FlowContextShape =>
|
|
412
453
|
FlowContext.of({
|
|
413
454
|
reasoning: seats.reasoning,
|
|
414
455
|
coder: seats.coder,
|
|
415
456
|
judgment: seats.judgment,
|
|
416
|
-
git: makeGitTool(dependencies.process, workDir,
|
|
417
|
-
hosting: makeGitHubTool(dependencies.process, workDir,
|
|
418
|
-
events,
|
|
457
|
+
git: makeGitTool(dependencies.process, workDir, laneEvents),
|
|
458
|
+
hosting: makeGitHubTool(dependencies.process, workDir, laneEvents),
|
|
459
|
+
events: laneEvents,
|
|
419
460
|
reviewers: seats.reviewers,
|
|
420
461
|
coderCapabilities: seats.coder.capabilities,
|
|
421
462
|
userPrompt: options.userPrompt,
|
|
@@ -425,11 +466,253 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
|
|
|
425
466
|
// `contextFor` of its own — nothing in the design nests worktrees.
|
|
426
467
|
...(rebind
|
|
427
468
|
? {
|
|
428
|
-
contextFor: (directory: string) =>
|
|
429
|
-
Effect.map(seatsFor(directory), (rebound) =>
|
|
469
|
+
contextFor: (directory: string, contextOptions?: ContextOptions) =>
|
|
470
|
+
Effect.map(seatsFor(directory), (rebound) =>
|
|
471
|
+
contextAt(
|
|
472
|
+
directory,
|
|
473
|
+
rebound,
|
|
474
|
+
false,
|
|
475
|
+
contextOptions?.label === undefined
|
|
476
|
+
? events
|
|
477
|
+
: withLane(events, { lane: contextOptions.label, workDir: directory })
|
|
478
|
+
)
|
|
479
|
+
)
|
|
430
480
|
}
|
|
431
481
|
: {})
|
|
432
482
|
})
|
|
483
|
+
/**
|
|
484
|
+
* One look at every executor before the first lease: a health URL that
|
|
485
|
+
* does not answer waits in the round for its engine; a harness that cannot
|
|
486
|
+
* start (not installed, not signed in) sits the run out.
|
|
487
|
+
*/
|
|
488
|
+
const probeExecutors = (
|
|
489
|
+
roster: RosterShape,
|
|
490
|
+
seatFor: (
|
|
491
|
+
executor: ExecutorSpec,
|
|
492
|
+
role: Role,
|
|
493
|
+
workDir: string
|
|
494
|
+
) => Effect.Effect<RosterSeat, FlowError>,
|
|
495
|
+
workDir: string
|
|
496
|
+
): Effect.Effect<void> =>
|
|
497
|
+
Effect.forEach(
|
|
498
|
+
roster.executors,
|
|
499
|
+
(executor) =>
|
|
500
|
+
Effect.gen(function* () {
|
|
501
|
+
if (executor.health !== undefined) {
|
|
502
|
+
if (!(yield* httpProbe(executor.health))) {
|
|
503
|
+
yield* roster.exclude(
|
|
504
|
+
Exclusion.make({
|
|
505
|
+
id: executor.id,
|
|
506
|
+
kind: "health",
|
|
507
|
+
until: yield* Clock.currentTimeMillis,
|
|
508
|
+
reason: `its health check ${executor.health} does not answer`
|
|
509
|
+
})
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
const role = rolesOf(executor)[0] ?? "coder"
|
|
515
|
+
const seat = yield* Effect.result(seatFor(executor, role, workDir))
|
|
516
|
+
if (seat._tag === "Failure") {
|
|
517
|
+
yield* roster.exclude(
|
|
518
|
+
Exclusion.make({
|
|
519
|
+
id: executor.id,
|
|
520
|
+
kind: "run",
|
|
521
|
+
reason: `could not start: ${describeFlowError(seat.failure)}`
|
|
522
|
+
})
|
|
523
|
+
)
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
if (!(yield* seat.success.isAvailable)) {
|
|
527
|
+
yield* roster.exclude(
|
|
528
|
+
Exclusion.make({
|
|
529
|
+
id: executor.id,
|
|
530
|
+
kind: "run",
|
|
531
|
+
reason: `its harness '${executor.harness}' is not available (not installed, or not signed in)`
|
|
532
|
+
})
|
|
533
|
+
)
|
|
534
|
+
}
|
|
535
|
+
}),
|
|
536
|
+
{ discard: true, concurrency: "unbounded" }
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Every seat from the roster (ADR 0019): a lazily held coder at the root,
|
|
541
|
+
* an eagerly held one per `contextFor` (a story waits there for a slot),
|
|
542
|
+
* and reasoning seats leased per call, independent of the coder.
|
|
543
|
+
*/
|
|
544
|
+
const rosterContext = Effect.fn("@llm4ts/runner/FlowRunner.rosterContext")(function* (
|
|
545
|
+
document: RosterDocument
|
|
546
|
+
): Effect.fn.Return<FlowContextShape, FlowError, Scope.Scope> {
|
|
547
|
+
const runScope = yield* Effect.scope
|
|
548
|
+
const roster = yield* makeRoster({
|
|
549
|
+
executors: document.executors,
|
|
550
|
+
events,
|
|
551
|
+
state: makeRosterStateStore(dependencies.files, rosterStatePath(runEnvironment)),
|
|
552
|
+
probe: httpProbe
|
|
553
|
+
})
|
|
554
|
+
const seatCache = new Map<string, RosterSeat>()
|
|
555
|
+
const cacheLock = yield* Semaphore.make(1)
|
|
556
|
+
const seatFor = (
|
|
557
|
+
executor: ExecutorSpec,
|
|
558
|
+
role: Role,
|
|
559
|
+
workDir: string
|
|
560
|
+
): Effect.Effect<RosterSeat, FlowError> =>
|
|
561
|
+
cacheLock.withPermit(
|
|
562
|
+
Effect.gen(function* () {
|
|
563
|
+
const readOnly = role !== "coder"
|
|
564
|
+
const key = `${executor.id}\u0000${readOnly ? "ro" : "rw"}\u0000${workDir}`
|
|
565
|
+
const cached = seatCache.get(key)
|
|
566
|
+
if (cached !== undefined) {
|
|
567
|
+
return cached
|
|
568
|
+
}
|
|
569
|
+
const configuration = executorConfig(executor, readOnly, runEnvironment)
|
|
570
|
+
if (configuration === undefined) {
|
|
571
|
+
return yield* RosterInvalid.make({
|
|
572
|
+
violations: [`executor '${executor.id}' has an unknown harness '${executor.harness}'`]
|
|
573
|
+
})
|
|
574
|
+
}
|
|
575
|
+
const seat: RosterSeat = yield* resolveSeat(
|
|
576
|
+
prepareConnector(bridged(configuration), workDir, runEnvironment)
|
|
577
|
+
).pipe(ScopeModule.provide(runScope))
|
|
578
|
+
seatCache.set(key, seat)
|
|
579
|
+
return seat
|
|
580
|
+
})
|
|
581
|
+
)
|
|
582
|
+
const source: SeatSource = { seatFor }
|
|
583
|
+
yield* probeExecutors(roster, seatFor, options.workDir)
|
|
584
|
+
|
|
585
|
+
const judgmentConfig =
|
|
586
|
+
options.judgment ??
|
|
587
|
+
document.executors
|
|
588
|
+
.filter((spec) => rolesOf(spec).includes("judge"))
|
|
589
|
+
.flatMap((spec) => {
|
|
590
|
+
const configuration = executorConfig(spec, true, runEnvironment)
|
|
591
|
+
return configuration === undefined ? [] : [configuration]
|
|
592
|
+
})[0] ??
|
|
593
|
+
reasoning
|
|
594
|
+
const coderCapabilities = (workDir: string, held: HeldCoder) =>
|
|
595
|
+
Effect.gen(function* () {
|
|
596
|
+
const current = yield* held.lease
|
|
597
|
+
const first =
|
|
598
|
+
current ??
|
|
599
|
+
[...document.executors]
|
|
600
|
+
.filter((spec) => rolesOf(spec).includes("coder"))
|
|
601
|
+
.sort((left, right) => priorityOf(left, "coder") - priorityOf(right, "coder"))[0]
|
|
602
|
+
if (first === undefined) {
|
|
603
|
+
return ConnectorCapabilitiesClass.make({})
|
|
604
|
+
}
|
|
605
|
+
return yield* Effect.map(Effect.result(seatFor(first, "coder", workDir)), (result) =>
|
|
606
|
+
result._tag === "Success"
|
|
607
|
+
? result.success.capabilities
|
|
608
|
+
: ConnectorCapabilitiesClass.make({})
|
|
609
|
+
)
|
|
610
|
+
})
|
|
611
|
+
const viewFor = (
|
|
612
|
+
held: HeldCoder,
|
|
613
|
+
workDir: string,
|
|
614
|
+
label: string,
|
|
615
|
+
laneEvents: FlowEventsShape
|
|
616
|
+
): RosterView => ({
|
|
617
|
+
forRole: (role) =>
|
|
618
|
+
rosterSeat(roster, source, role, workDir, {
|
|
619
|
+
events: laneEvents,
|
|
620
|
+
avoid: Effect.map(held.executor, (id) => (id === undefined ? [] : [id])),
|
|
621
|
+
borrow: held.lease,
|
|
622
|
+
label
|
|
623
|
+
}),
|
|
624
|
+
available: roster.available,
|
|
625
|
+
slots: roster.slots,
|
|
626
|
+
executor: held.executor,
|
|
627
|
+
history: held.history
|
|
628
|
+
})
|
|
629
|
+
const contextIn = (
|
|
630
|
+
workDir: string,
|
|
631
|
+
held: HeldCoder,
|
|
632
|
+
label: string,
|
|
633
|
+
rebind: boolean,
|
|
634
|
+
laneEvents: FlowEventsShape
|
|
635
|
+
): Effect.Effect<FlowContextShape, FlowError> =>
|
|
636
|
+
Effect.gen(function* () {
|
|
637
|
+
const view = viewFor(held, workDir, label, laneEvents)
|
|
638
|
+
const judgmentSeat =
|
|
639
|
+
options.judgment === undefined
|
|
640
|
+
? view.forRole("judge")
|
|
641
|
+
: yield* resolveSeat(
|
|
642
|
+
prepareConnector(bridged(options.judgment), workDir, runEnvironment)
|
|
643
|
+
).pipe(ScopeModule.provide(runScope))
|
|
644
|
+
const judgment = yield* judgmentFor(judgmentSeat, judgmentConfig, runEnvironment)
|
|
645
|
+
return FlowContext.of({
|
|
646
|
+
reasoning: view.forRole(rebind ? "planner" : "reviewer"),
|
|
647
|
+
coder: held.service,
|
|
648
|
+
judgment,
|
|
649
|
+
git: makeGitTool(dependencies.process, workDir, laneEvents),
|
|
650
|
+
hosting: makeGitHubTool(dependencies.process, workDir, laneEvents),
|
|
651
|
+
events: laneEvents,
|
|
652
|
+
reviewers: [view.forRole("reviewer")],
|
|
653
|
+
coderCapabilities: yield* coderCapabilities(workDir, held),
|
|
654
|
+
userPrompt: options.userPrompt,
|
|
655
|
+
workDir,
|
|
656
|
+
workspace: options.workspace,
|
|
657
|
+
roster: view,
|
|
658
|
+
...(rebind
|
|
659
|
+
? {
|
|
660
|
+
contextFor: (directory: string, contextOptions?: ContextOptions) =>
|
|
661
|
+
Effect.gen(function* () {
|
|
662
|
+
const label = contextOptions?.label ?? directory
|
|
663
|
+
// The lane's executor is read at every event, so a
|
|
664
|
+
// handover shows on the next line it prints.
|
|
665
|
+
const executorRef = yield* Ref.make<Effect.Effect<string | undefined>>(
|
|
666
|
+
Effect.succeed(undefined)
|
|
667
|
+
)
|
|
668
|
+
const laneEvents = withLane(events, {
|
|
669
|
+
lane: label,
|
|
670
|
+
executor: Effect.flatten(Ref.get(executorRef)),
|
|
671
|
+
workDir: directory
|
|
672
|
+
})
|
|
673
|
+
const storyCoder = yield* makeHeldCoder(roster, source, directory, {
|
|
674
|
+
events: laneEvents,
|
|
675
|
+
eager: true,
|
|
676
|
+
label,
|
|
677
|
+
...(contextOptions?.prefer === undefined
|
|
678
|
+
? {}
|
|
679
|
+
: { prefer: contextOptions.prefer })
|
|
680
|
+
})
|
|
681
|
+
yield* Ref.set(executorRef, storyCoder.executor)
|
|
682
|
+
return yield* contextIn(directory, storyCoder, label, false, laneEvents)
|
|
683
|
+
})
|
|
684
|
+
}
|
|
685
|
+
: {})
|
|
686
|
+
})
|
|
687
|
+
})
|
|
688
|
+
const rootCoder = yield* makeHeldCoder(roster, source, options.workDir, {
|
|
689
|
+
events,
|
|
690
|
+
eager: false,
|
|
691
|
+
label: "the run"
|
|
692
|
+
})
|
|
693
|
+
return yield* contextIn(options.workDir, rootCoder, "the run", true, events)
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
const rosterDocument =
|
|
697
|
+
options.roster === "none"
|
|
698
|
+
? undefined
|
|
699
|
+
: (options.roster ??
|
|
700
|
+
(yield* loadRosterDocument({
|
|
701
|
+
files: dependencies.files,
|
|
702
|
+
environment: runEnvironment,
|
|
703
|
+
workDir: options.workDir
|
|
704
|
+
})))
|
|
705
|
+
if (rosterDocument !== undefined) {
|
|
706
|
+
const context = yield* rosterContext(rosterDocument)
|
|
707
|
+
return {
|
|
708
|
+
events,
|
|
709
|
+
tracker,
|
|
710
|
+
runId,
|
|
711
|
+
roster: rosterDocument,
|
|
712
|
+
...(judgmentLog === undefined ? {} : { judgmentLog }),
|
|
713
|
+
context
|
|
714
|
+
}
|
|
715
|
+
}
|
|
433
716
|
const seats = yield* seatsFor(options.workDir)
|
|
434
717
|
return {
|
|
435
718
|
events,
|
|
@@ -440,6 +723,10 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
|
|
|
440
723
|
}
|
|
441
724
|
})
|
|
442
725
|
|
|
726
|
+
/** `claude (coder·reviewer·judge ×3)` — how the run header names an executor. */
|
|
727
|
+
const describeExecutor = (spec: ExecutorSpec): string =>
|
|
728
|
+
`${spec.id} (${rolesOf(spec).join("·")} ×${spec.slots ?? 1})`
|
|
729
|
+
|
|
443
730
|
export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle")(function* <
|
|
444
731
|
A,
|
|
445
732
|
E,
|
|
@@ -468,11 +755,16 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
|
|
|
468
755
|
})
|
|
469
756
|
if (verbosity !== "Quiet") {
|
|
470
757
|
const reviewers = options.reviewers ?? []
|
|
471
|
-
const seats =
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
758
|
+
const seats =
|
|
759
|
+
bundle.roster === undefined
|
|
760
|
+
? [
|
|
761
|
+
`coder ${describeSeat(options.coder)}`,
|
|
762
|
+
`reasoning ${describeSeat(defaultReasoningConfig(options.coder, options.reasoning))}`,
|
|
763
|
+
...(reviewers.length === 0
|
|
764
|
+
? []
|
|
765
|
+
: [`reviewers ${reviewers.map(describeSeat).join(", ")}`])
|
|
766
|
+
].join(" · ")
|
|
767
|
+
: `executors ${bundle.roster.executors.map(describeExecutor).join(", ")} (roster in force: the flow's own seat choices are not used)`
|
|
476
768
|
yield* surface.log(palette.info(`${seats} · ${options.workDir}`))
|
|
477
769
|
if (tracePath !== undefined) {
|
|
478
770
|
yield* surface.log(
|