@llm4ts/flow 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.
Files changed (56) hide show
  1. package/dist/Approval.d.ts +1 -1
  2. package/dist/Approval.d.ts.map +1 -1
  3. package/dist/Artifacts.d.ts +2 -2
  4. package/dist/Artifacts.d.ts.map +1 -1
  5. package/dist/BenchReport.d.ts +2 -2
  6. package/dist/BenchReport.d.ts.map +1 -1
  7. package/dist/CostLedger.d.ts +2 -2
  8. package/dist/CostLedger.d.ts.map +1 -1
  9. package/dist/Equiv.d.ts +3 -3
  10. package/dist/Equiv.d.ts.map +1 -1
  11. package/dist/Flow.d.ts +1 -1
  12. package/dist/Flow.d.ts.map +1 -1
  13. package/dist/FlowContext.d.ts +15 -1
  14. package/dist/FlowContext.d.ts.map +1 -1
  15. package/dist/FlowContext.js.map +1 -1
  16. package/dist/FlowError.d.ts +24 -1
  17. package/dist/FlowError.d.ts.map +1 -1
  18. package/dist/FlowError.js +29 -0
  19. package/dist/FlowError.js.map +1 -1
  20. package/dist/FlowEvents.d.ts +45 -0
  21. package/dist/FlowEvents.d.ts.map +1 -1
  22. package/dist/FlowEvents.js +87 -7
  23. package/dist/FlowEvents.js.map +1 -1
  24. package/dist/Judgment.d.ts +2 -2
  25. package/dist/Judgment.d.ts.map +1 -1
  26. package/dist/Persistence.d.ts +2 -2
  27. package/dist/Persistence.d.ts.map +1 -1
  28. package/dist/PlanExecution.d.ts +1 -1
  29. package/dist/PlanExecution.d.ts.map +1 -1
  30. package/dist/ProgramJudge.d.ts +1 -1
  31. package/dist/ProgramJudge.d.ts.map +1 -1
  32. package/dist/Replay.d.ts +2 -2
  33. package/dist/Replay.d.ts.map +1 -1
  34. package/dist/Review.d.ts +3 -3
  35. package/dist/Review.d.ts.map +1 -1
  36. package/dist/ReviewCache.d.ts +1 -1
  37. package/dist/ReviewCache.d.ts.map +1 -1
  38. package/dist/Roster.d.ts +164 -0
  39. package/dist/Roster.d.ts.map +1 -0
  40. package/dist/Roster.js +583 -0
  41. package/dist/Roster.js.map +1 -0
  42. package/dist/RosterSeats.d.ts +71 -0
  43. package/dist/RosterSeats.d.ts.map +1 -0
  44. package/dist/RosterSeats.js +167 -0
  45. package/dist/RosterSeats.js.map +1 -0
  46. package/dist/Stories.d.ts +9 -5
  47. package/dist/Stories.d.ts.map +1 -1
  48. package/dist/Stories.js +95 -24
  49. package/dist/Stories.js.map +1 -1
  50. package/package.json +4 -2
  51. package/src/FlowContext.ts +19 -1
  52. package/src/FlowError.ts +32 -0
  53. package/src/FlowEvents.ts +108 -7
  54. package/src/Roster.ts +849 -0
  55. package/src/RosterSeats.ts +360 -0
  56. package/src/Stories.ts +129 -28
@@ -0,0 +1,360 @@
1
+ // Seats over the executor roster (ADR 0019). A flow keeps calling an
2
+ // `LlmServiceShape`; behind it, a reasoning seat leases an executor for each
3
+ // call, and a coder seat holds one executor for its context and hands over
4
+ // to the next when that one is taken out of the round. The runner supplies
5
+ // `seatFor`, which turns an executor into a connector rooted in a directory.
6
+ import * as Effect from "effect/Effect"
7
+ import * as Ref from "effect/Ref"
8
+ import * as Scope from "effect/Scope"
9
+ import * as Semaphore from "effect/Semaphore"
10
+ import * as Stream from "effect/Stream"
11
+ import { ConfigError, type LlmError } from "@llm4ts/core/Errors"
12
+ import type { LlmServiceShape } from "@llm4ts/core/LlmService"
13
+ import { Message, type ConnectorCapabilities, type LlmChunk } from "@llm4ts/core/Models"
14
+ import { describeFlowError, type FlowError } from "./FlowError.ts"
15
+ import { Info, type FlowEventsShape } from "./FlowEvents.ts"
16
+ import {
17
+ describeExclusion,
18
+ hasRole,
19
+ type ExecutorSpec,
20
+ type Lease,
21
+ type Role,
22
+ type RosterShape
23
+ } from "./Roster.ts"
24
+
25
+ /** An executor's connector, as the runner resolved it for a role and a directory. */
26
+ export type RosterSeat = LlmServiceShape & { readonly capabilities: ConnectorCapabilities }
27
+
28
+ export interface SeatSource {
29
+ readonly seatFor: (
30
+ executor: ExecutorSpec,
31
+ role: Role,
32
+ workDir: string
33
+ ) => Effect.Effect<RosterSeat, FlowError>
34
+ }
35
+
36
+ /** What a flow context knows about the roster behind its seats. */
37
+ export interface RosterView {
38
+ /** A seat for `role`, leased per call (independent of this context's coder where possible). */
39
+ readonly forRole: (role: Role) => LlmServiceShape
40
+ /** Free slots for `role` right now. */
41
+ readonly available: (role: Role) => Effect.Effect<number>
42
+ /** Configured slots for `role`. */
43
+ readonly slots: (role: Role) => number
44
+ /** The executor holding this context's coder, once leased. */
45
+ readonly executor: Effect.Effect<string | undefined>
46
+ /** Every executor that held this context's coder, in order (handovers). */
47
+ readonly history: Effect.Effect<ReadonlyArray<string>>
48
+ }
49
+
50
+ /** A roster failure seen through an `LlmServiceShape`, whose failures are `LlmError`s. */
51
+ const asLlmError = (error: FlowError): LlmError =>
52
+ ConfigError.make({ message: describeFlowError(error) })
53
+
54
+ const say = (events: FlowEventsShape, message: string): Effect.Effect<void> =>
55
+ events.publish(Info.make({ message: `roster: ${message}` }))
56
+
57
+ /** Shared plumbing: every service method as "run this on some seat". */
58
+ interface SeatRunner {
59
+ readonly run: <A, R>(
60
+ use: (seat: RosterSeat, note: string | undefined) => Effect.Effect<A, LlmError, R>
61
+ ) => Effect.Effect<A, LlmError, R>
62
+ readonly stream: (
63
+ use: (seat: RosterSeat, note: string | undefined) => Stream.Stream<LlmChunk, LlmError>
64
+ ) => Stream.Stream<LlmChunk, LlmError>
65
+ readonly isAvailable: Effect.Effect<boolean>
66
+ }
67
+
68
+ const withNote = (note: string | undefined, prompt: string): string =>
69
+ note === undefined ? prompt : `${note}\n\n${prompt}`
70
+
71
+ const withNoteOnLast = (
72
+ note: string | undefined,
73
+ messages: ReadonlyArray<Message>
74
+ ): ReadonlyArray<Message> => {
75
+ const last = messages.at(-1)
76
+ if (note === undefined || last === undefined) {
77
+ return messages
78
+ }
79
+ return [
80
+ ...messages.slice(0, -1),
81
+ Message.make({ ...last, content: withNote(note, last.content) })
82
+ ]
83
+ }
84
+
85
+ const serviceOver = (runner: SeatRunner): LlmServiceShape => ({
86
+ executeStream: (prompt) =>
87
+ runner.stream((seat, note) => seat.executeStream(withNote(note, prompt))),
88
+ executeStreamWithHistory: (messages) =>
89
+ runner.stream((seat, note) => seat.executeStreamWithHistory(withNoteOnLast(note, messages))),
90
+ executeWithTools: (prompt, tools) =>
91
+ runner.run((seat, note) => seat.executeWithTools(withNote(note, prompt), tools)),
92
+ executeStructured: (prompt, schema, jsonSchema) =>
93
+ runner.run((seat, note) => seat.executeStructured(withNote(note, prompt), schema, jsonSchema)),
94
+ executeStructuredWithUsage: (prompt, schema, jsonSchema) =>
95
+ runner.run((seat, note) =>
96
+ seat.executeStructuredWithUsage(withNote(note, prompt), schema, jsonSchema)
97
+ ),
98
+ scoreLabels: (prompt, labels) => runner.run((seat) => seat.scoreLabels(prompt, labels)),
99
+ isAvailable: runner.isAvailable
100
+ })
101
+
102
+ // ---- Per-call seats ----------------------------------------------------------------
103
+
104
+ export interface RosterSeatOptions {
105
+ readonly events: FlowEventsShape
106
+ /** Executors this seat must not use: the context's coder, for independence. */
107
+ readonly avoid?: Effect.Effect<ReadonlyArray<string>>
108
+ /** Used without a slot when nobody outside `avoid` can ever serve the role. */
109
+ readonly borrow?: Effect.Effect<ExecutorSpec | undefined>
110
+ /** Who is asking, for the events. */
111
+ readonly label?: string
112
+ }
113
+
114
+ /**
115
+ * A seat for `role` that leases an executor for each call and releases it
116
+ * when the call (or its stream) ends. A failure is reported to the roster,
117
+ * which excludes the executor when the failure says it is out of quota or
118
+ * down; the call itself still fails — a per-call seat has nothing to hand
119
+ * over, and the retry layer below has already had its turn.
120
+ */
121
+ export const rosterSeat = (
122
+ roster: RosterShape,
123
+ source: SeatSource,
124
+ role: Role,
125
+ workDir: string,
126
+ options: RosterSeatOptions
127
+ ): LlmServiceShape => {
128
+ const acquire = (
129
+ alsoAvoid: ReadonlyArray<string>
130
+ ): Effect.Effect<ExecutorSpec, LlmError, Scope.Scope> =>
131
+ Effect.gen(function* () {
132
+ const avoid = [...(options.avoid === undefined ? [] : yield* options.avoid), ...alsoAvoid]
133
+ if (avoid.length > 0 && !(yield* roster.canEverServe(role, avoid))) {
134
+ const borrowed = options.borrow === undefined ? undefined : yield* options.borrow
135
+ if (borrowed !== undefined && hasRole(borrowed, role)) {
136
+ yield* say(
137
+ options.events,
138
+ `${borrowed.id} takes ${role}${options.label === undefined ? "" : ` for ${options.label}`} on its own coder's slot — not independent (no other executor can take ${role})`
139
+ )
140
+ return borrowed
141
+ }
142
+ }
143
+ const lease = yield* roster
144
+ .lease(role, { avoid, ...(options.label === undefined ? {} : { label: options.label }) })
145
+ .pipe(Effect.mapError(asLlmError))
146
+ return lease.executor
147
+ })
148
+
149
+ const seatOf = (executor: ExecutorSpec): Effect.Effect<RosterSeat, LlmError> =>
150
+ source.seatFor(executor, role, workDir).pipe(Effect.mapError(asLlmError))
151
+
152
+ /**
153
+ * After a failure that took `executor` out of the round, whether another
154
+ * executor can still take the call: the call moves once, never twice — a
155
+ * second failure is the call's own, not the executor's.
156
+ */
157
+ const retryElsewhere = (
158
+ executor: ExecutorSpec,
159
+ error: LlmError,
160
+ tried: ReadonlyArray<string>
161
+ ): Effect.Effect<boolean> =>
162
+ Effect.gen(function* () {
163
+ const exclusion = yield* roster.report(executor.id, error)
164
+ if (exclusion === undefined || tried.length > 0) {
165
+ return false
166
+ }
167
+ const avoid = options.avoid === undefined ? [] : yield* options.avoid
168
+ const other = yield* roster.canEverServe(role, [...avoid, executor.id])
169
+ if (other) {
170
+ yield* say(
171
+ options.events,
172
+ `${role}${options.label === undefined ? "" : ` for ${options.label}`} moves off ${executor.id} (${describeExclusion(exclusion)})`
173
+ )
174
+ }
175
+ return other
176
+ })
177
+
178
+ const runOn = <A, R>(
179
+ use: (seat: RosterSeat, note: string | undefined) => Effect.Effect<A, LlmError, R>,
180
+ tried: ReadonlyArray<string>
181
+ ): Effect.Effect<A, LlmError, R> =>
182
+ // The slot is held for the call and released before any retry.
183
+ Effect.scoped(
184
+ Effect.gen(function* () {
185
+ const executor = yield* acquire(tried)
186
+ const seat = yield* seatOf(executor)
187
+ const outcome = yield* Effect.result(use(seat, undefined))
188
+ return { executor, outcome }
189
+ })
190
+ ).pipe(
191
+ Effect.flatMap(({ executor, outcome }) =>
192
+ outcome._tag === "Success"
193
+ ? Effect.succeed(outcome.success)
194
+ : Effect.flatMap(retryElsewhere(executor, outcome.failure, tried), (again) =>
195
+ again ? runOn(use, [...tried, executor.id]) : Effect.fail(outcome.failure)
196
+ )
197
+ )
198
+ )
199
+
200
+ const streamOn = (
201
+ use: (seat: RosterSeat, note: string | undefined) => Stream.Stream<LlmChunk, LlmError>,
202
+ tried: ReadonlyArray<string>
203
+ ): Stream.Stream<LlmChunk, LlmError> =>
204
+ Stream.unwrap(
205
+ Effect.gen(function* () {
206
+ const executor = yield* acquire(tried)
207
+ const seat = yield* seatOf(executor)
208
+ return use(seat, undefined).pipe(
209
+ Stream.catchIf(
210
+ () => true,
211
+ (error) =>
212
+ Stream.unwrap(
213
+ Effect.map(retryElsewhere(executor, error, tried), (again) =>
214
+ again ? streamOn(use, [...tried, executor.id]) : Stream.fail(error)
215
+ )
216
+ )
217
+ )
218
+ )
219
+ })
220
+ )
221
+
222
+ return serviceOver({
223
+ run: (use) => runOn(use, []),
224
+ stream: (use) => streamOn(use, []),
225
+ isAvailable: roster.canEverServe(role)
226
+ })
227
+ }
228
+
229
+ // ---- The held coder ------------------------------------------------------------------
230
+
231
+ export interface HeldCoderOptions {
232
+ readonly events: FlowEventsShape
233
+ /** Lease now (a story waiting for capacity), or on the first call (a flow's root coder). */
234
+ readonly eager: boolean
235
+ /** The executor this context held before, taken first while free. */
236
+ readonly prefer?: string
237
+ /** Handovers before a failure surfaces. Default 2. */
238
+ readonly maxHandovers?: number
239
+ readonly label?: string
240
+ }
241
+
242
+ export interface HeldCoder {
243
+ readonly service: LlmServiceShape
244
+ readonly executor: Effect.Effect<string | undefined>
245
+ readonly lease: Effect.Effect<ExecutorSpec | undefined>
246
+ readonly history: Effect.Effect<ReadonlyArray<string>>
247
+ }
248
+
249
+ export const takeoverNote = (from: string, reason: string): string =>
250
+ [
251
+ `(You are taking over this work from another coding agent ('${from}'), which had to stop:`,
252
+ `${reason}. Its changes so far are in the working tree: inspect them (git status, git diff)`,
253
+ "before continuing, and carry on from there.)"
254
+ ].join(" ")
255
+
256
+ interface Held {
257
+ readonly lease: Lease
258
+ readonly seat: RosterSeat
259
+ }
260
+
261
+ /**
262
+ * The coder of one context (a story, or a flow's root): one executor held
263
+ * for the context's lifetime. When a call fails in a way that takes that
264
+ * executor out of the round (a usage limit, a dead serving engine), the
265
+ * lease is released, the next coder leased, and the call repeated there
266
+ * with a takeover note — the chat history the flow replays, the task
267
+ * checkpoint and the working tree carry the work across.
268
+ */
269
+ export const makeHeldCoder = Effect.fn("@llm4ts/flow/RosterSeats.heldCoder")(function* (
270
+ roster: RosterShape,
271
+ source: SeatSource,
272
+ workDir: string,
273
+ options: HeldCoderOptions
274
+ ): Effect.fn.Return<HeldCoder, FlowError, Scope.Scope> {
275
+ const scope = yield* Effect.scope
276
+ const current = yield* Ref.make<Held | undefined>(undefined)
277
+ const history = yield* Ref.make<ReadonlyArray<string>>([])
278
+ const handovers = yield* Ref.make(0)
279
+ const prefer = yield* Ref.make(options.prefer)
280
+ const gate = yield* Semaphore.make(1)
281
+ const maxHandovers = options.maxHandovers ?? 2
282
+
283
+ const acquire: Effect.Effect<Held, FlowError> = gate.withPermit(
284
+ Effect.gen(function* () {
285
+ const held = yield* Ref.get(current)
286
+ if (held !== undefined) {
287
+ return held
288
+ }
289
+ const preferred = yield* Ref.get(prefer)
290
+ const lease = yield* roster
291
+ .lease("coder", {
292
+ ...(preferred === undefined ? {} : { prefer: preferred }),
293
+ ...(options.label === undefined ? {} : { label: options.label })
294
+ })
295
+ .pipe(Scope.provide(scope))
296
+ const seat = yield* source.seatFor(lease.executor, "coder", workDir)
297
+ const next = { lease, seat }
298
+ yield* Ref.set(current, next)
299
+ yield* Ref.update(history, (ids) =>
300
+ ids.at(-1) === lease.executor.id ? ids : [...ids, lease.executor.id]
301
+ )
302
+ return next
303
+ })
304
+ )
305
+
306
+ if (options.eager) {
307
+ yield* acquire
308
+ }
309
+
310
+ /** Decides whether a failure hands over; returns the takeover note, or fails with the error. */
311
+ const handover = (held: Held, error: LlmError): Effect.Effect<string, LlmError> =>
312
+ Effect.gen(function* () {
313
+ const exclusion = yield* roster.report(held.lease.executor.id, error)
314
+ if (exclusion === undefined || (yield* Ref.get(handovers)) >= maxHandovers) {
315
+ return yield* Effect.fail(error)
316
+ }
317
+ yield* Ref.update(handovers, (count) => count + 1)
318
+ yield* Ref.set(prefer, undefined)
319
+ yield* held.lease.release
320
+ yield* Ref.set(current, undefined)
321
+ yield* say(
322
+ options.events,
323
+ `${options.label ?? workDir}: handing the coder over from ${held.lease.executor.id} (${describeExclusion(exclusion)})`
324
+ )
325
+ return takeoverNote(held.lease.executor.id, exclusion.reason)
326
+ })
327
+
328
+ const run = <A, R>(
329
+ use: (seat: RosterSeat, note: string | undefined) => Effect.Effect<A, LlmError, R>,
330
+ note?: string
331
+ ): Effect.Effect<A, LlmError, R> =>
332
+ Effect.gen(function* () {
333
+ const held = yield* acquire.pipe(Effect.mapError(asLlmError))
334
+ return yield* use(held.seat, note).pipe(
335
+ Effect.catch((error) => Effect.flatMap(handover(held, error), (next) => run(use, next)))
336
+ )
337
+ })
338
+
339
+ const stream = (
340
+ use: (seat: RosterSeat, note: string | undefined) => Stream.Stream<LlmChunk, LlmError>,
341
+ note?: string
342
+ ): Stream.Stream<LlmChunk, LlmError> =>
343
+ Stream.unwrap(
344
+ Effect.map(acquire.pipe(Effect.mapError(asLlmError)), (held) =>
345
+ use(held.seat, note).pipe(
346
+ Stream.catchIf(
347
+ () => true,
348
+ (error) => Stream.unwrap(Effect.map(handover(held, error), (next) => stream(use, next)))
349
+ )
350
+ )
351
+ )
352
+ )
353
+
354
+ return {
355
+ service: serviceOver({ run, stream, isAvailable: roster.canEverServe("coder") }),
356
+ executor: Effect.map(Ref.get(current), (held) => held?.lease.executor.id),
357
+ lease: Effect.map(Ref.get(current), (held) => held?.lease.executor),
358
+ history: Ref.get(history)
359
+ }
360
+ })