@llm4ts/flow 2.7.2 → 2.8.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 (61) 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/FlowError.d.ts +17 -1
  17. package/dist/FlowError.d.ts.map +1 -1
  18. package/dist/FlowError.js +29 -3
  19. package/dist/FlowError.js.map +1 -1
  20. package/dist/GitTool.d.ts +26 -2
  21. package/dist/GitTool.d.ts.map +1 -1
  22. package/dist/GitTool.js +54 -3
  23. package/dist/GitTool.js.map +1 -1
  24. package/dist/Judgment.d.ts +2 -2
  25. package/dist/Judgment.d.ts.map +1 -1
  26. package/dist/Perimeter.d.ts +22 -1
  27. package/dist/Perimeter.d.ts.map +1 -1
  28. package/dist/Perimeter.js +57 -1
  29. package/dist/Perimeter.js.map +1 -1
  30. package/dist/Persistence.d.ts +2 -2
  31. package/dist/Persistence.d.ts.map +1 -1
  32. package/dist/PlanExecution.d.ts +1 -1
  33. package/dist/PlanExecution.d.ts.map +1 -1
  34. package/dist/ProgramJudge.d.ts +1 -1
  35. package/dist/ProgramJudge.d.ts.map +1 -1
  36. package/dist/Replay.d.ts +2 -2
  37. package/dist/Replay.d.ts.map +1 -1
  38. package/dist/Review.d.ts +3 -3
  39. package/dist/Review.d.ts.map +1 -1
  40. package/dist/ReviewCache.d.ts +1 -1
  41. package/dist/ReviewCache.d.ts.map +1 -1
  42. package/dist/Stories.d.ts +47 -5
  43. package/dist/Stories.d.ts.map +1 -1
  44. package/dist/Stories.js +347 -45
  45. package/dist/Stories.js.map +1 -1
  46. package/dist/StoryPlan.d.ts +15 -0
  47. package/dist/StoryPlan.d.ts.map +1 -1
  48. package/dist/StoryPlan.js +58 -0
  49. package/dist/StoryPlan.js.map +1 -1
  50. package/dist/TransientRetry.d.ts +9 -0
  51. package/dist/TransientRetry.d.ts.map +1 -1
  52. package/dist/TransientRetry.js +43 -8
  53. package/dist/TransientRetry.js.map +1 -1
  54. package/package.json +2 -2
  55. package/src/Chat.ts +45 -6
  56. package/src/FlowError.ts +33 -3
  57. package/src/GitTool.ts +99 -5
  58. package/src/Perimeter.ts +85 -1
  59. package/src/Stories.ts +495 -78
  60. package/src/StoryPlan.ts +73 -0
  61. package/src/TransientRetry.ts +97 -8
package/src/StoryPlan.ts CHANGED
@@ -216,6 +216,79 @@ export const dependentsOf = (plan: StoryPlan, id: string): ReadonlyArray<string>
216
216
  .filter((candidate) => candidate !== id && blocked.has(candidate))
217
217
  }
218
218
 
219
+ /** Every story `id` waits for, directly or through another dependency. */
220
+ export const dependenciesOf = (plan: StoryPlan, id: string): ReadonlyArray<string> => {
221
+ const needed = new Set<string>()
222
+ const pending = [...(plan.story(id)?.dependsOn ?? [])]
223
+ while (pending.length > 0) {
224
+ const next = pending.pop()
225
+ if (next === undefined || needed.has(next)) {
226
+ continue
227
+ }
228
+ needed.add(next)
229
+ pending.push(...(plan.story(next)?.dependsOn ?? []))
230
+ }
231
+ return plan.stories.map((story) => story.id).filter((candidate) => needed.has(candidate))
232
+ }
233
+
234
+ /** How `other` stands to `story` in the plan's order. */
235
+ export type StoryRelation = "self" | "dependency" | "dependent" | "parallel"
236
+
237
+ export const relationOf = (plan: StoryPlan, story: Story, other: Story): StoryRelation =>
238
+ other.id === story.id
239
+ ? "self"
240
+ : dependenciesOf(plan, story.id).includes(other.id)
241
+ ? "dependency"
242
+ : dependentsOf(plan, story.id).includes(other.id)
243
+ ? "dependent"
244
+ : "parallel"
245
+
246
+ /** The story that owns `path`, if any (owned sets are disjoint). */
247
+ export const ownerOf = (plan: StoryPlan, path: string): Story | undefined =>
248
+ plan.stories.find((story) => story.owned.some((prefix) => pathWithin(path, prefix)))
249
+
250
+ const fileLike = /\.[A-Za-z0-9]{1,5}$/
251
+
252
+ const basename = (path: string): string => normalizePath(path).split("/").at(-1) ?? ""
253
+
254
+ /**
255
+ * The repository paths a piece of prose names: tokens that start under one
256
+ * of the plan's top-level directories (`src/App.tsx`), plus a bare file name
257
+ * that is exactly the file name of an owned path (`App.tsx`), which resolves
258
+ * to that path. Prose paths are guesses, never proofs — callers use them to
259
+ * ask again or to explain, not to fail.
260
+ */
261
+ export const pathsNamedIn = (plan: StoryPlan, text: string): ReadonlyArray<string> => {
262
+ const known = plan.stories.flatMap((story) => [...story.owned, ...story.sharedReadOnly])
263
+ const roots = new Set(
264
+ known.map((path) => normalizePath(path).split("/")[0] ?? "").filter((root) => root.length > 0)
265
+ )
266
+ const ownedFiles = plan.stories.flatMap((story) =>
267
+ story.owned.filter((path) => fileLike.test(normalizePath(path)))
268
+ )
269
+ const found: Array<string> = []
270
+ for (const raw of text.match(/[A-Za-z0-9_.@/\\-]+/g) ?? []) {
271
+ const token = normalizePath(raw.replace(/[.,:;]+$/, ""))
272
+ if (token.length === 0 || token.includes("://")) {
273
+ continue
274
+ }
275
+ if (token.includes("/")) {
276
+ if (roots.has(token.split("/")[0] ?? "")) {
277
+ found.push(token)
278
+ }
279
+ continue
280
+ }
281
+ if (fileLike.test(token)) {
282
+ for (const path of ownedFiles) {
283
+ if (basename(path) === token) {
284
+ found.push(normalizePath(path))
285
+ }
286
+ }
287
+ }
288
+ }
289
+ return [...new Set(found)]
290
+ }
291
+
219
292
  /** Stable over the story entry's content — a changed entry means a fresh branch. */
220
293
  export const storyHash = (story: Story): string =>
221
294
  stableHash(
@@ -25,7 +25,27 @@ const transientSignals: ReadonlyArray<string> = [
25
25
  " 500",
26
26
  " 502",
27
27
  " 503",
28
- " 504"
28
+ " 504",
29
+ // A local single-model server (LM Studio) under parallel load: a request
30
+ // queued behind another generation is cut off ("terminated"), or a stream
31
+ // dies mid-way. Both recover on a prompt retry.
32
+ "terminated",
33
+ "stream disconnected"
34
+ ]
35
+
36
+ // The serving engine itself is down: a local server restarting after a GPU
37
+ // backend crash, or unable to (re)load its model. It comes back, but in
38
+ // minutes, not seconds — a one-second backoff spends every retry while the
39
+ // engine is still recovering, so these get their own, slower budget.
40
+ const outageSignals: ReadonlyArray<string> = [
41
+ "engine is recovering",
42
+ "engine_recovering",
43
+ "backend is unhealthy",
44
+ "runtime_unavailable",
45
+ "server is shutting down",
46
+ "server_shutdown",
47
+ "failed to load model",
48
+ "engine protocol startup was aborted"
29
49
  ]
30
50
 
31
51
  // An "empty response" is ambiguous: gemini returns one both for a random mid-stream
@@ -55,6 +75,8 @@ const deterministic4xxSignals: ReadonlyArray<string> = [
55
75
  // message string when no typed cause survived. Two copies of this list would drift.
56
76
  const contextOverflowSignals: ReadonlyArray<string> = [
57
77
  "exceeds the maximum number of tokens",
78
+ "exceeds the context window",
79
+ "context_length_exceeded",
58
80
  "input token count exceeds",
59
81
  "context length exceeded",
60
82
  "maximum context length",
@@ -88,6 +110,12 @@ export const isContextOverflowMessage = (message: string): boolean =>
88
110
  export const isContextOverflow = (error: LlmError): boolean =>
89
111
  error._tag === "ProviderError" && isContextOverflowMessage(error.message)
90
112
 
113
+ /** The serving engine is down or restarting (see `outageSignals`). */
114
+ export const isOutageMessage = (message: string): boolean => includesSignal(message, outageSignals)
115
+
116
+ export const isOutage = (error: LlmError): boolean =>
117
+ error._tag === "ProviderError" && !isContextOverflow(error) && isOutageMessage(error.message)
118
+
91
119
  export const isTransient = (error: LlmError): boolean => {
92
120
  switch (error._tag) {
93
121
  case "TimeoutError":
@@ -96,6 +124,7 @@ export const isTransient = (error: LlmError): boolean => {
96
124
  case "ProviderError":
97
125
  return (
98
126
  !isFlakyStream(error) &&
127
+ !isOutage(error) &&
99
128
  !isDeterministic4xx(error.message) &&
100
129
  includesSignal(error.message, transientSignals)
101
130
  )
@@ -127,6 +156,12 @@ export interface TransientRetryOptions {
127
156
  * time, so this budget is small (default 2) and independent of the others.
128
157
  */
129
158
  readonly parseRetries?: number
159
+ /** Retries while the serving engine is down (`isOutage`). Default 5. */
160
+ readonly outageRetries?: number
161
+ /** First outage wait, doubled per attempt up to `outageMaxDelay`. Default 15 seconds. */
162
+ readonly outageDelay?: Duration.Input
163
+ /** Default 2 minutes: five attempts wait about six minutes in all. */
164
+ readonly outageMaxDelay?: Duration.Input
130
165
  }
131
166
 
132
167
  interface ResolvedTransientRetryOptions {
@@ -135,6 +170,9 @@ interface ResolvedTransientRetryOptions {
135
170
  readonly flakyRetries: number
136
171
  readonly flakyDelay: Duration.Duration
137
172
  readonly parseRetries: number
173
+ readonly outageRetries: number
174
+ readonly outageDelay: Duration.Duration
175
+ readonly outageMaxDelay: Duration.Duration
138
176
  }
139
177
 
140
178
  const resolveOptions = (options: TransientRetryOptions): ResolvedTransientRetryOptions => ({
@@ -142,9 +180,18 @@ const resolveOptions = (options: TransientRetryOptions): ResolvedTransientRetryO
142
180
  baseDelay: Duration.fromInputUnsafe(options.baseDelay ?? "1 second"),
143
181
  flakyRetries: options.flakyRetries ?? 6,
144
182
  flakyDelay: Duration.fromInputUnsafe(options.flakyDelay ?? "1 second"),
145
- parseRetries: options.parseRetries ?? 2
183
+ parseRetries: options.parseRetries ?? 2,
184
+ outageRetries: options.outageRetries ?? 5,
185
+ outageDelay: Duration.fromInputUnsafe(options.outageDelay ?? "15 seconds"),
186
+ outageMaxDelay: Duration.fromInputUnsafe(options.outageMaxDelay ?? "2 minutes")
146
187
  })
147
188
 
189
+ const outageBackoff = (
190
+ options: ResolvedTransientRetryOptions,
191
+ attempt: number
192
+ ): Duration.Duration =>
193
+ Duration.min(Duration.times(options.outageDelay, 2 ** attempt), options.outageMaxDelay)
194
+
148
195
  /** A structured call whose text came back but did not decode as the requested JSON. */
149
196
  export const isStructuredParseFailure = (error: LlmError): boolean => error._tag === "ParseError"
150
197
 
@@ -203,11 +250,30 @@ const retryEffect = <A, R>(
203
250
  options: ResolvedTransientRetryOptions,
204
251
  events: FlowEvents["Service"]
205
252
  ): Effect.Effect<A, LlmError, R> => {
206
- const loop = (transientAttempt: number, flakyAttempt: number): Effect.Effect<A, LlmError, R> =>
253
+ const loop = (
254
+ transientAttempt: number,
255
+ flakyAttempt: number,
256
+ outageAttempt = 0
257
+ ): Effect.Effect<A, LlmError, R> =>
207
258
  Effect.catchIf(
208
259
  effect,
209
260
  () => true,
210
261
  (error) => {
262
+ if (isOutage(error) && outageAttempt < options.outageRetries) {
263
+ return waitForRetry(
264
+ events,
265
+ `serving engine down (${what}) — waiting for it to recover`,
266
+ outageAttempt,
267
+ options.outageRetries,
268
+ error,
269
+ outageBackoff(options, outageAttempt)
270
+ ).pipe(
271
+ Effect.andThen(
272
+ Effect.suspend(() => loop(transientAttempt, flakyAttempt, outageAttempt + 1))
273
+ )
274
+ )
275
+ }
276
+
211
277
  if (isFlakyStream(error) && flakyAttempt < options.flakyRetries) {
212
278
  return waitForRetry(
213
279
  events,
@@ -216,7 +282,11 @@ const retryEffect = <A, R>(
216
282
  options.flakyRetries,
217
283
  error,
218
284
  options.flakyDelay
219
- ).pipe(Effect.andThen(Effect.suspend(() => loop(transientAttempt, flakyAttempt + 1))))
285
+ ).pipe(
286
+ Effect.andThen(
287
+ Effect.suspend(() => loop(transientAttempt, flakyAttempt + 1, outageAttempt))
288
+ )
289
+ )
220
290
  }
221
291
 
222
292
  if (isTransient(error) && transientAttempt < options.maxRetries) {
@@ -227,7 +297,11 @@ const retryEffect = <A, R>(
227
297
  options.maxRetries,
228
298
  error,
229
299
  transientDelay(error, backoff(options.baseDelay, transientAttempt))
230
- ).pipe(Effect.andThen(Effect.suspend(() => loop(transientAttempt + 1, flakyAttempt))))
300
+ ).pipe(
301
+ Effect.andThen(
302
+ Effect.suspend(() => loop(transientAttempt + 1, flakyAttempt, outageAttempt))
303
+ )
304
+ )
231
305
  }
232
306
 
233
307
  return Effect.fail(error)
@@ -251,12 +325,27 @@ const retryStream = <R>(
251
325
 
252
326
  const loop = (
253
327
  transientAttempt: number,
254
- flakyAttempt: number
328
+ flakyAttempt: number,
329
+ outageAttempt = 0
255
330
  ): Stream.Stream<LlmChunk, LlmError, R> =>
256
331
  Stream.catchIf(
257
332
  stream,
258
333
  () => true,
259
334
  (error) => {
335
+ if (isOutage(error) && outageAttempt < options.outageRetries) {
336
+ return waitThenRetry(
337
+ waitForRetry(
338
+ events,
339
+ `serving engine down (${what}) — waiting for it to recover`,
340
+ outageAttempt,
341
+ options.outageRetries,
342
+ error,
343
+ outageBackoff(options, outageAttempt)
344
+ ),
345
+ () => loop(transientAttempt, flakyAttempt, outageAttempt + 1)
346
+ )
347
+ }
348
+
260
349
  if (isFlakyStream(error) && flakyAttempt < options.flakyRetries) {
261
350
  return waitThenRetry(
262
351
  waitForRetry(
@@ -267,7 +356,7 @@ const retryStream = <R>(
267
356
  error,
268
357
  options.flakyDelay
269
358
  ),
270
- () => loop(transientAttempt, flakyAttempt + 1)
359
+ () => loop(transientAttempt, flakyAttempt + 1, outageAttempt)
271
360
  )
272
361
  }
273
362
 
@@ -281,7 +370,7 @@ const retryStream = <R>(
281
370
  error,
282
371
  transientDelay(error, backoff(options.baseDelay, transientAttempt))
283
372
  ),
284
- () => loop(transientAttempt + 1, flakyAttempt)
373
+ () => loop(transientAttempt + 1, flakyAttempt, outageAttempt)
285
374
  )
286
375
  }
287
376