@llm4ts/flow 2.7.2 → 2.9.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.
Files changed (75) 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/Chat.d.ts.map +1 -1
  8. package/dist/Chat.js +35 -4
  9. package/dist/Chat.js.map +1 -1
  10. package/dist/CostLedger.d.ts +2 -2
  11. package/dist/CostLedger.d.ts.map +1 -1
  12. package/dist/Equiv.d.ts +3 -3
  13. package/dist/Equiv.d.ts.map +1 -1
  14. package/dist/Flow.d.ts +1 -1
  15. package/dist/Flow.d.ts.map +1 -1
  16. package/dist/FlowContext.d.ts +15 -1
  17. package/dist/FlowContext.d.ts.map +1 -1
  18. package/dist/FlowContext.js.map +1 -1
  19. package/dist/FlowError.d.ts +40 -1
  20. package/dist/FlowError.d.ts.map +1 -1
  21. package/dist/FlowError.js +58 -3
  22. package/dist/FlowError.js.map +1 -1
  23. package/dist/GitTool.d.ts +26 -2
  24. package/dist/GitTool.d.ts.map +1 -1
  25. package/dist/GitTool.js +54 -3
  26. package/dist/GitTool.js.map +1 -1
  27. package/dist/Judgment.d.ts +2 -2
  28. package/dist/Judgment.d.ts.map +1 -1
  29. package/dist/Perimeter.d.ts +22 -1
  30. package/dist/Perimeter.d.ts.map +1 -1
  31. package/dist/Perimeter.js +57 -1
  32. package/dist/Perimeter.js.map +1 -1
  33. package/dist/Persistence.d.ts +2 -2
  34. package/dist/Persistence.d.ts.map +1 -1
  35. package/dist/PlanExecution.d.ts +1 -1
  36. package/dist/PlanExecution.d.ts.map +1 -1
  37. package/dist/ProgramJudge.d.ts +1 -1
  38. package/dist/ProgramJudge.d.ts.map +1 -1
  39. package/dist/Replay.d.ts +2 -2
  40. package/dist/Replay.d.ts.map +1 -1
  41. package/dist/Review.d.ts +3 -3
  42. package/dist/Review.d.ts.map +1 -1
  43. package/dist/ReviewCache.d.ts +1 -1
  44. package/dist/ReviewCache.d.ts.map +1 -1
  45. package/dist/Roster.d.ts +163 -0
  46. package/dist/Roster.d.ts.map +1 -0
  47. package/dist/Roster.js +560 -0
  48. package/dist/Roster.js.map +1 -0
  49. package/dist/RosterSeats.d.ts +71 -0
  50. package/dist/RosterSeats.d.ts.map +1 -0
  51. package/dist/RosterSeats.js +144 -0
  52. package/dist/RosterSeats.js.map +1 -0
  53. package/dist/Stories.d.ts +54 -8
  54. package/dist/Stories.d.ts.map +1 -1
  55. package/dist/Stories.js +413 -51
  56. package/dist/Stories.js.map +1 -1
  57. package/dist/StoryPlan.d.ts +15 -0
  58. package/dist/StoryPlan.d.ts.map +1 -1
  59. package/dist/StoryPlan.js +58 -0
  60. package/dist/StoryPlan.js.map +1 -1
  61. package/dist/TransientRetry.d.ts +9 -0
  62. package/dist/TransientRetry.d.ts.map +1 -1
  63. package/dist/TransientRetry.js +43 -8
  64. package/dist/TransientRetry.js.map +1 -1
  65. package/package.json +4 -2
  66. package/src/Chat.ts +45 -6
  67. package/src/FlowContext.ts +19 -1
  68. package/src/FlowError.ts +65 -3
  69. package/src/GitTool.ts +99 -5
  70. package/src/Perimeter.ts +85 -1
  71. package/src/Roster.ts +824 -0
  72. package/src/RosterSeats.ts +306 -0
  73. package/src/Stories.ts +588 -88
  74. package/src/StoryPlan.ts +73 -0
  75. package/src/TransientRetry.ts +97 -8
@@ -0,0 +1,306 @@
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: Effect.Effect<ExecutorSpec, LlmError, Scope.Scope> = Effect.gen(function* () {
129
+ const avoid = options.avoid === undefined ? [] : yield* options.avoid
130
+ if (avoid.length > 0 && !(yield* roster.canEverServe(role, avoid))) {
131
+ const borrowed = options.borrow === undefined ? undefined : yield* options.borrow
132
+ if (borrowed !== undefined && hasRole(borrowed, role)) {
133
+ yield* say(
134
+ options.events,
135
+ `${borrowed.id} takes ${role}${options.label === undefined ? "" : ` for ${options.label}`} on its own coder's slot — not independent (no other executor can take ${role})`
136
+ )
137
+ return borrowed
138
+ }
139
+ }
140
+ const lease = yield* roster
141
+ .lease(role, { avoid, ...(options.label === undefined ? {} : { label: options.label }) })
142
+ .pipe(Effect.mapError(asLlmError))
143
+ return lease.executor
144
+ })
145
+
146
+ const seatOf = (executor: ExecutorSpec): Effect.Effect<RosterSeat, LlmError> =>
147
+ source.seatFor(executor, role, workDir).pipe(Effect.mapError(asLlmError))
148
+
149
+ const reportOn =
150
+ (executor: ExecutorSpec) =>
151
+ (error: LlmError): Effect.Effect<void> =>
152
+ Effect.asVoid(roster.report(executor.id, error))
153
+
154
+ return serviceOver({
155
+ run: (use) =>
156
+ Effect.scoped(
157
+ Effect.gen(function* () {
158
+ const executor = yield* acquire
159
+ const seat = yield* seatOf(executor)
160
+ return yield* use(seat, undefined).pipe(Effect.tapError(reportOn(executor)))
161
+ })
162
+ ),
163
+ stream: (use) =>
164
+ Stream.unwrap(
165
+ Effect.gen(function* () {
166
+ const executor = yield* acquire
167
+ const seat = yield* seatOf(executor)
168
+ return use(seat, undefined).pipe(Stream.tapError(reportOn(executor)))
169
+ })
170
+ ),
171
+ isAvailable: roster.canEverServe(role)
172
+ })
173
+ }
174
+
175
+ // ---- The held coder ------------------------------------------------------------------
176
+
177
+ export interface HeldCoderOptions {
178
+ readonly events: FlowEventsShape
179
+ /** Lease now (a story waiting for capacity), or on the first call (a flow's root coder). */
180
+ readonly eager: boolean
181
+ /** The executor this context held before, taken first while free. */
182
+ readonly prefer?: string
183
+ /** Handovers before a failure surfaces. Default 2. */
184
+ readonly maxHandovers?: number
185
+ readonly label?: string
186
+ }
187
+
188
+ export interface HeldCoder {
189
+ readonly service: LlmServiceShape
190
+ readonly executor: Effect.Effect<string | undefined>
191
+ readonly lease: Effect.Effect<ExecutorSpec | undefined>
192
+ readonly history: Effect.Effect<ReadonlyArray<string>>
193
+ }
194
+
195
+ export const takeoverNote = (from: string, reason: string): string =>
196
+ [
197
+ `(You are taking over this work from another coding agent ('${from}'), which had to stop:`,
198
+ `${reason}. Its changes so far are in the working tree: inspect them (git status, git diff)`,
199
+ "before continuing, and carry on from there.)"
200
+ ].join(" ")
201
+
202
+ interface Held {
203
+ readonly lease: Lease
204
+ readonly seat: RosterSeat
205
+ }
206
+
207
+ /**
208
+ * The coder of one context (a story, or a flow's root): one executor held
209
+ * for the context's lifetime. When a call fails in a way that takes that
210
+ * executor out of the round (a usage limit, a dead serving engine), the
211
+ * lease is released, the next coder leased, and the call repeated there
212
+ * with a takeover note — the chat history the flow replays, the task
213
+ * checkpoint and the working tree carry the work across.
214
+ */
215
+ export const makeHeldCoder = Effect.fn("@llm4ts/flow/RosterSeats.heldCoder")(function* (
216
+ roster: RosterShape,
217
+ source: SeatSource,
218
+ workDir: string,
219
+ options: HeldCoderOptions
220
+ ): Effect.fn.Return<HeldCoder, FlowError, Scope.Scope> {
221
+ const scope = yield* Effect.scope
222
+ const current = yield* Ref.make<Held | undefined>(undefined)
223
+ const history = yield* Ref.make<ReadonlyArray<string>>([])
224
+ const handovers = yield* Ref.make(0)
225
+ const prefer = yield* Ref.make(options.prefer)
226
+ const gate = yield* Semaphore.make(1)
227
+ const maxHandovers = options.maxHandovers ?? 2
228
+
229
+ const acquire: Effect.Effect<Held, FlowError> = gate.withPermit(
230
+ Effect.gen(function* () {
231
+ const held = yield* Ref.get(current)
232
+ if (held !== undefined) {
233
+ return held
234
+ }
235
+ const preferred = yield* Ref.get(prefer)
236
+ const lease = yield* roster
237
+ .lease("coder", {
238
+ ...(preferred === undefined ? {} : { prefer: preferred }),
239
+ ...(options.label === undefined ? {} : { label: options.label })
240
+ })
241
+ .pipe(Scope.provide(scope))
242
+ const seat = yield* source.seatFor(lease.executor, "coder", workDir)
243
+ const next = { lease, seat }
244
+ yield* Ref.set(current, next)
245
+ yield* Ref.update(history, (ids) =>
246
+ ids.at(-1) === lease.executor.id ? ids : [...ids, lease.executor.id]
247
+ )
248
+ return next
249
+ })
250
+ )
251
+
252
+ if (options.eager) {
253
+ yield* acquire
254
+ }
255
+
256
+ /** Decides whether a failure hands over; returns the takeover note, or fails with the error. */
257
+ const handover = (held: Held, error: LlmError): Effect.Effect<string, LlmError> =>
258
+ Effect.gen(function* () {
259
+ const exclusion = yield* roster.report(held.lease.executor.id, error)
260
+ if (exclusion === undefined || (yield* Ref.get(handovers)) >= maxHandovers) {
261
+ return yield* Effect.fail(error)
262
+ }
263
+ yield* Ref.update(handovers, (count) => count + 1)
264
+ yield* Ref.set(prefer, undefined)
265
+ yield* held.lease.release
266
+ yield* Ref.set(current, undefined)
267
+ yield* say(
268
+ options.events,
269
+ `${options.label ?? workDir}: handing the coder over from ${held.lease.executor.id} (${describeExclusion(exclusion)})`
270
+ )
271
+ return takeoverNote(held.lease.executor.id, exclusion.reason)
272
+ })
273
+
274
+ const run = <A, R>(
275
+ use: (seat: RosterSeat, note: string | undefined) => Effect.Effect<A, LlmError, R>,
276
+ note?: string
277
+ ): Effect.Effect<A, LlmError, R> =>
278
+ Effect.gen(function* () {
279
+ const held = yield* acquire.pipe(Effect.mapError(asLlmError))
280
+ return yield* use(held.seat, note).pipe(
281
+ Effect.catch((error) => Effect.flatMap(handover(held, error), (next) => run(use, next)))
282
+ )
283
+ })
284
+
285
+ const stream = (
286
+ use: (seat: RosterSeat, note: string | undefined) => Stream.Stream<LlmChunk, LlmError>,
287
+ note?: string
288
+ ): Stream.Stream<LlmChunk, LlmError> =>
289
+ Stream.unwrap(
290
+ Effect.map(acquire.pipe(Effect.mapError(asLlmError)), (held) =>
291
+ use(held.seat, note).pipe(
292
+ Stream.catchIf(
293
+ () => true,
294
+ (error) => Stream.unwrap(Effect.map(handover(held, error), (next) => stream(use, next)))
295
+ )
296
+ )
297
+ )
298
+ )
299
+
300
+ return {
301
+ service: serviceOver({ run, stream, isAvailable: roster.canEverServe("coder") }),
302
+ executor: Effect.map(Ref.get(current), (held) => held?.lease.executor.id),
303
+ lease: Effect.map(Ref.get(current), (held) => held?.lease.executor),
304
+ history: Ref.get(history)
305
+ }
306
+ })