@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/dist/Activity.d.ts +7 -0
- package/dist/Activity.d.ts.map +1 -1
- package/dist/Activity.js +42 -1
- package/dist/Activity.js.map +1 -1
- package/dist/FlowEvents.d.ts +2 -0
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +2 -0
- package/dist/FlowEvents.js.map +1 -1
- package/dist/GitTool.d.ts +3 -1
- package/dist/GitTool.d.ts.map +1 -1
- package/dist/GitTool.js +9 -1
- package/dist/GitTool.js.map +1 -1
- package/dist/Stories.d.ts +20 -0
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +62 -6
- package/dist/Stories.js.map +1 -1
- package/dist/Timing.d.ts +6 -1
- package/dist/Timing.d.ts.map +1 -1
- package/dist/Timing.js +25 -3
- package/dist/Timing.js.map +1 -1
- package/package.json +2 -2
- package/src/Activity.ts +54 -1
- package/src/FlowEvents.ts +2 -0
- package/src/GitTool.ts +25 -2
- package/src/Stories.ts +95 -6
- package/src/Timing.ts +36 -6
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*
|
|
925
|
-
|
|
926
|
-
.
|
|
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*
|
|
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
|
+
})
|