@llm4ts/flow 2.22.1 → 2.23.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.
package/src/Stories.ts CHANGED
@@ -14,6 +14,7 @@ import type * as Scope from "effect/Scope"
14
14
  import * as Semaphore from "effect/Semaphore"
15
15
  import * as Stream from "effect/Stream"
16
16
  import type { LlmServiceShape } from "@llm4ts/core/LlmService"
17
+ import { cap } from "./Context.ts"
17
18
  import type { LlmChunk, TokenUsage } from "@llm4ts/core/Models"
18
19
  import type { LlmError } from "@llm4ts/core/Errors"
19
20
  import { BoardItem, type BoardSyncShape } from "./BoardSync.ts"
@@ -37,7 +38,7 @@ import {
37
38
  withLane,
38
39
  type FlowEventsShape
39
40
  } from "./FlowEvents.ts"
40
- import { timeEffect } from "./Timing.ts"
41
+ import { timeEffect, withTimedRole } from "./Timing.ts"
41
42
  import { statusPaths, type GitToolShape } from "./GitTool.ts"
42
43
  import {
43
44
  checkPerimeter,
@@ -294,6 +295,71 @@ export const perimeterRules = (story: Story, where?: StoryWhereabouts): string =
294
295
  "- Everything you provide must be implemented completely: other stories depend on it."
295
296
  ].join("\n")
296
297
 
298
+ /**
299
+ * The gates run after every task (typecheck, lint, tests, perimeter), so a
300
+ * coder that runs them itself spends minutes on work the flow repeats.
301
+ */
302
+ export const gateRules = [
303
+ "After each task the flow runs the project's gates on your worktree (typecheck, lint, the",
304
+ "tests) and hands any failure back to you as a finding. Do not run the full test suite,",
305
+ "typecheck or lint yourself. If you need to check your work, run only the one test file",
306
+ "that covers your change."
307
+ ].join("\n")
308
+
309
+ const defaultContextChars = 40_000
310
+ const fileChars = 8_000
311
+
312
+ /**
313
+ * The code a story starts from, for its coder's system prompt: the shared
314
+ * read-only files it builds on, then the files it owns, each capped, until
315
+ * `budget` runs out; the rest by path only. Reading them up front saves the
316
+ * `ls`/`find`/`cat` round trips the coder would otherwise spend on them.
317
+ */
318
+ export const startingCodeOf = (
319
+ story: Story,
320
+ git: GitToolShape,
321
+ files: PlainFileStoreShape,
322
+ worktree: string,
323
+ budget: number
324
+ ): Effect.Effect<string | undefined, FlowError> =>
325
+ Effect.gen(function* () {
326
+ if (budget <= 0) {
327
+ return undefined
328
+ }
329
+ const shared = yield* git.listFiles(story.sharedReadOnly)
330
+ const owned = yield* git.listFiles(story.owned)
331
+ if (shared.length + owned.length === 0) {
332
+ return undefined
333
+ }
334
+ let left = budget
335
+ const leftOut: Array<string> = []
336
+ const section = (title: string, paths: ReadonlyArray<string>) =>
337
+ Effect.gen(function* () {
338
+ const parts: Array<string> = []
339
+ for (const path of paths) {
340
+ const text = yield* Effect.orElseSucceed(
341
+ files.read(join(worktree, path)),
342
+ () => undefined
343
+ )
344
+ if (text === undefined || left <= 0) {
345
+ leftOut.push(path)
346
+ continue
347
+ }
348
+ const shown = cap(text, Math.min(fileChars, left)).text
349
+ left -= shown.length
350
+ parts.push(`### ${path}\n\`\`\`\n${shown}\n\`\`\``)
351
+ }
352
+ return parts.length === 0 ? [] : [title, ...parts]
353
+ })
354
+ return [
355
+ "## The code you start from",
356
+ "Read this before exploring: it is the current content of the files below.",
357
+ ...(yield* section("Shared, read-only — use these as they are:", shared)),
358
+ ...(yield* section("Yours — the story's owned files so far:", owned)),
359
+ ...(leftOut.length === 0 ? [] : [`Not shown (over the budget): ${leftOut.join(", ")}`])
360
+ ].join("\n\n")
361
+ })
362
+
297
363
  export const storyPrompt = (story: Story): string =>
298
364
  [`Story: ${story.title}`, "", story.description.trim()].join("\n")
299
365
 
@@ -507,6 +573,13 @@ export interface StoriesOptions {
507
573
  * story's plan (coder, review, gates, commit). Default 2: one revision.
508
574
  */
509
575
  readonly judgeRounds?: number
576
+ /**
577
+ * How much of the code a story starts from goes into its coder's system
578
+ * prompt, in characters: the shared read-only files it uses, then its own.
579
+ * Each file read up front is a model round trip the coder does not spend
580
+ * exploring. Default 40 000; 0 leaves it out.
581
+ */
582
+ readonly contextChars?: number
510
583
  /** Extra system context per story (house rules, shared read-only excerpts). */
511
584
  readonly system?: (story: Story) => Effect.Effect<string, FlowError>
512
585
  /** How a story's task plan is produced. Default: the story's own coder plans it. */
@@ -828,6 +901,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
828
901
  const extra = options.system === undefined ? undefined : yield* options.system(story)
829
902
  const system = [
830
903
  perimeterRules(story, { plan, worktree: state.worktree, epicCheckout: context.workDir }),
904
+ gateRules,
831
905
  extra
832
906
  ]
833
907
  .filter((part): part is string => part !== undefined && part.trim().length > 0)
@@ -921,9 +995,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
921
995
  }
922
996
  let rebuttal = blockedRebuttal(plan, story, need)
923
997
  if (rebuttal === undefined && options.verifyBlocked !== undefined) {
924
- const verdict = yield* options
925
- .verifyBlocked(story, need, state.worktree, watchedSeats)
926
- .pipe(Effect.catch(() => Effect.succeed(undefined)))
998
+ const verdict = yield* withTimedRole(
999
+ "verifier",
1000
+ options.verifyBlocked(story, need, state.worktree, watchedSeats)
1001
+ ).pipe(Effect.catch(() => Effect.succeed(undefined)))
927
1002
  rebuttal = verdict === undefined || verdict.real ? undefined : verdict.reason
928
1003
  }
929
1004
  if (rebuttal === undefined) {
@@ -994,6 +1069,17 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
994
1069
  })
995
1070
 
996
1071
  const planTasks = options.planTasks ?? defaultPlanTasks
1072
+ // Taken after catch-up and setup: the code as the first task finds it.
1073
+ const startingCode = yield* startingCodeOf(
1074
+ story,
1075
+ git,
1076
+ files,
1077
+ state.worktree,
1078
+ options.contextChars ?? defaultContextChars
1079
+ )
1080
+ const taskSystem = [system, startingCode]
1081
+ .filter((part): part is string => part !== undefined)
1082
+ .join("\n\n")
997
1083
  const implementTasks = implementPlanFlow(storyContext, {
998
1084
  store: makePlanStore(files),
999
1085
  planPath: planPath(story),
@@ -1004,7 +1090,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1004
1090
  prompt,
1005
1091
  laneOf(story)
1006
1092
  ),
1007
- system,
1093
+ system: taskSystem,
1008
1094
  chatPerTask: true,
1009
1095
  checkoutBranch: false,
1010
1096
  lint: gates,
@@ -1078,7 +1164,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1078
1164
  ],
1079
1165
  summary: `judge:${story.id}`
1080
1166
  })
1081
- : yield* judge(story, subject, watchedSeats, empty ? "code" : "diff")
1167
+ : yield* withTimedRole(
1168
+ "judge",
1169
+ judge(story, subject, watchedSeats, empty ? "code" : "diff")
1170
+ )
1082
1171
  yield* laneOf(story).publish(
1083
1172
  StoryJudged.make({
1084
1173
  lane: story.id,
package/src/Timing.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import * as Clock from "effect/Clock"
2
+ import * as Context from "effect/Context"
2
3
  import * as Effect from "effect/Effect"
3
4
  import * as Exit from "effect/Exit"
4
5
  import * as Ref from "effect/Ref"
5
6
  import * as Stream from "effect/Stream"
7
+ import type { JudgmentShape } from "@llm4ts/core/judgment/Judgment"
6
8
  import type { LlmServiceShape } from "@llm4ts/core/LlmService"
7
9
  import type { LlmChunk } from "@llm4ts/core/Models"
8
10
  import { Timed, type FlowEventsShape } from "./FlowEvents.ts"
@@ -14,6 +16,19 @@ import { Timed, type FlowEventsShape } from "./FlowEvents.ts"
14
16
  * it did. The events carry the seat's role, never the prompt or the reply.
15
17
  */
16
18
 
19
+ /**
20
+ * The role a call is made for, when it is not the seat's own: one seat
21
+ * serves the reviewer, the story judge and the BLOCKED_ON verifier when no
22
+ * roster splits them, and the report should still tell them apart.
23
+ */
24
+ const TimedRole = Context.Reference<string | undefined>("@llm4ts/flow/Timing/TimedRole", {
25
+ defaultValue: () => undefined
26
+ })
27
+
28
+ /** Times the seat calls `effect` makes under `role` instead of the seat's own label. */
29
+ export const withTimedRole = <A, E, R>(role: string, effect: Effect.Effect<A, E, R>) =>
30
+ Effect.provideService(effect, TimedRole, role)
31
+
17
32
  const reportedMs = (chunk: LlmChunk, key: string): number | undefined => {
18
33
  const raw = chunk.metadata[key]
19
34
  const value = raw === undefined ? Number.NaN : Number(raw)
@@ -24,12 +39,12 @@ const reportedMs = (chunk: LlmChunk, key: string): number | undefined => {
24
39
  export const timeEffect = <A, E, R>(
25
40
  events: FlowEventsShape,
26
41
  effect: Effect.Effect<A, E, R>,
27
- timed: (ms: number, failed: boolean) => Timed
42
+ timed: (ms: number, failed: boolean, role: string | undefined) => Timed
28
43
  ): Effect.Effect<A, E, R> =>
29
- Effect.flatMap(Clock.currentTimeMillis, (start) =>
44
+ Effect.flatMap(Effect.all([Clock.currentTimeMillis, TimedRole]), ([start, role]) =>
30
45
  Effect.onExit(effect, (exit) =>
31
46
  Effect.flatMap(Clock.currentTimeMillis, (end) =>
32
- events.publish(timed(end - start, Exit.isFailure(exit)))
47
+ events.publish(timed(end - start, Exit.isFailure(exit), role))
33
48
  )
34
49
  )
35
50
  )
@@ -42,6 +57,7 @@ const timedStream = <E>(
42
57
  Stream.unwrap(
43
58
  Effect.gen(function* () {
44
59
  const start = yield* Clock.currentTimeMillis
60
+ const role = yield* TimedRole
45
61
  const first = yield* Ref.make<number | undefined>(undefined)
46
62
  const reported = yield* Ref.make<{ apiMs?: number; toolMs?: number }>({})
47
63
  const failed = yield* Ref.make(false)
@@ -69,7 +85,7 @@ const timedStream = <E>(
69
85
  yield* events.publish(
70
86
  Timed.make({
71
87
  kind: "model",
72
- label,
88
+ label: role ?? label,
73
89
  ms: end - start,
74
90
  ...(firstMs === undefined ? {} : { firstMs }),
75
91
  ...(yield* Ref.get(reported)),
@@ -87,8 +103,8 @@ export const timedSeat = (
87
103
  events: FlowEventsShape,
88
104
  label: string
89
105
  ): LlmServiceShape => {
90
- const model = (ms: number, failed: boolean): Timed =>
91
- Timed.make({ kind: "model", label, ms, ...(failed ? { failed: true } : {}) })
106
+ const model = (ms: number, failed: boolean, role: string | undefined): Timed =>
107
+ Timed.make({ kind: "model", label: role ?? label, ms, ...(failed ? { failed: true } : {}) })
92
108
  const batched = service.scoreLabelSequence
93
109
  return {
94
110
  executeStream: (prompt) => timedStream(events, label, service.executeStream(prompt)),
@@ -110,3 +126,17 @@ export const timedSeat = (
110
126
  isAvailable: service.isAvailable
111
127
  }
112
128
  }
129
+
130
+ /** Typed judgments (ADR 0017) timed as `judgment` calls, under the caller's role if it named one. */
131
+ export const timedJudgment = (judgment: JudgmentShape, events: FlowEventsShape): JudgmentShape => ({
132
+ ...judgment,
133
+ judge: (input) =>
134
+ timeEffect(events, judgment.judge(input), (ms, failed, role) =>
135
+ Timed.make({
136
+ kind: "model",
137
+ label: role ?? "judgment",
138
+ ms,
139
+ ...(failed ? { failed: true } : {})
140
+ })
141
+ )
142
+ })