@llm4ts/flow 2.21.0 → 2.22.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/Activity.ts CHANGED
@@ -1,9 +1,10 @@
1
+ import * as Clock from "effect/Clock"
1
2
  import * as Effect from "effect/Effect"
2
3
  import * as Ref from "effect/Ref"
3
4
  import * as Stream from "effect/Stream"
4
5
  import type { LlmError } from "@llm4ts/core/Errors"
5
- import type { LlmChunk } from "@llm4ts/core/Models"
6
- import { ToolUse, UsageProgress, type FlowEventsShape } from "./FlowEvents.ts"
6
+ import { LlmChunk } from "@llm4ts/core/Models"
7
+ import { Timed, ToolUse, UsageProgress, type FlowEventsShape } from "./FlowEvents.ts"
7
8
 
8
9
  /**
9
10
  * Live agent activity.
@@ -122,6 +123,62 @@ const nextCall: Effect.Effect<string> = Effect.sync(() => {
122
123
  * tool call it carries. Wrap a connector stream with this before `collect` so
123
124
  * the run reports what the agent is doing while it is doing it.
124
125
  */
126
+ interface OpenTool {
127
+ readonly id: string | undefined
128
+ readonly tool: string
129
+ readonly at: number
130
+ }
131
+
132
+ const toolIdOf = (chunk: LlmChunk): string | undefined => {
133
+ const id = chunk.metadata.tool_id ?? chunk.metadata.toolId
134
+ return id === undefined || id.length === 0 ? undefined : id
135
+ }
136
+
137
+ /**
138
+ * The `Timed{kind:"tool"}` a tool's end closes: the open call with
139
+ * the same id, or the oldest open one when the harness sends no ids. A
140
+ * harness that reports a tool only at its end (an older codex) gets its
141
+ * `ToolUse` published then, untimed, as before.
142
+ */
143
+ const toolEnded = (
144
+ events: FlowEventsShape,
145
+ open: Ref.Ref<ReadonlyArray<OpenTool>>,
146
+ chunk: LlmChunk
147
+ ): Effect.Effect<void> =>
148
+ Effect.gen(function* () {
149
+ const id = toolIdOf(chunk)
150
+ const started = yield* Ref.modify(open, (calls) => {
151
+ const index = id === undefined ? 0 : calls.findIndex((call) => call.id === id)
152
+ const found = index < 0 ? undefined : calls[index]
153
+ return [
154
+ found,
155
+ found === undefined ? calls : [...calls.slice(0, index), ...calls.slice(index + 1)]
156
+ ] as const
157
+ })
158
+ if (started === undefined) {
159
+ const late = toolUseFrom(
160
+ LlmChunk.make({ delta: "", metadata: { ...chunk.metadata, event: "tool_use" } })
161
+ )
162
+ return late === undefined ? undefined : yield* events.publish(late)
163
+ }
164
+ const now = yield* Clock.currentTimeMillis
165
+ const failed = chunk.metadata.tool_failed === "true" || chunk.metadata.tool_status === "error"
166
+ yield* events.publish(
167
+ Timed.make({
168
+ kind: "tool",
169
+ label: started.tool,
170
+ ms: now - started.at,
171
+ ...(failed ? { failed: true } : {})
172
+ })
173
+ )
174
+ })
175
+
176
+ /**
177
+ * Republishes the stream unchanged, publishing a `ToolUse` event for every
178
+ * tool call it carries, and a `Timed` one when the call ends. Wrap a
179
+ * connector stream with this before `collect` so the run reports what the
180
+ * agent is doing while it is doing it.
181
+ */
125
182
  export const withToolActivity = <R>(
126
183
  events: FlowEventsShape,
127
184
  stream: Stream.Stream<LlmChunk, LlmError, R>
@@ -130,22 +187,37 @@ export const withToolActivity = <R>(
130
187
  // display-only progress, so a long agent turn shows its tokens growing; a
131
188
  // call that reported any closes with `done` so the display drops it.
132
189
  Stream.unwrap(
133
- Effect.map(Effect.all([nextCall, Ref.make(false)]), ([call, reported]) =>
134
- Stream.tap(stream, (chunk) => {
135
- const event = toolUseFrom(chunk)
136
- const tool = event === undefined ? Effect.void : events.publish(event)
137
- return chunk.usage === undefined
138
- ? tool
139
- : tool.pipe(
140
- Effect.andThen(events.publish(UsageProgress.make({ call, usage: chunk.usage }))),
141
- Effect.andThen(Ref.set(reported, true))
190
+ Effect.map(
191
+ Effect.all([nextCall, Ref.make(false), Ref.make<ReadonlyArray<OpenTool>>([])]),
192
+ ([call, reported, open]) =>
193
+ Stream.tap(stream, (chunk) => {
194
+ const event = toolUseFrom(chunk)
195
+ const tool =
196
+ event === undefined
197
+ ? chunk.metadata.event === "tool_result"
198
+ ? toolEnded(events, open, chunk)
199
+ : Effect.void
200
+ : Effect.andThen(
201
+ events.publish(event),
202
+ Effect.flatMap(Clock.currentTimeMillis, (at) =>
203
+ Ref.update(open, (calls) => [
204
+ ...calls,
205
+ { id: toolIdOf(chunk), tool: event.tool, at }
206
+ ])
207
+ )
208
+ )
209
+ return chunk.usage === undefined
210
+ ? tool
211
+ : tool.pipe(
212
+ Effect.andThen(events.publish(UsageProgress.make({ call, usage: chunk.usage }))),
213
+ Effect.andThen(Ref.set(reported, true))
214
+ )
215
+ }).pipe(
216
+ Stream.ensuring(
217
+ Effect.flatMap(Ref.get(reported), (any) =>
218
+ any ? events.publish(UsageProgress.make({ call, done: true })) : Effect.void
142
219
  )
143
- }).pipe(
144
- Stream.ensuring(
145
- Effect.flatMap(Ref.get(reported), (any) =>
146
- any ? events.publish(UsageProgress.make({ call, done: true })) : Effect.void
147
220
  )
148
221
  )
149
- )
150
222
  )
151
223
  )
package/src/FlowEvents.ts CHANGED
@@ -181,6 +181,32 @@ export class BlackboardRun extends Schema.TaggedClass<BlackboardRun>()("Blackboa
181
181
  result: RunResult
182
182
  }) {}
183
183
 
184
+ /**
185
+ * How long something took, published when it ends: a model call,
186
+ * a coder's tool, a gate command, git or a merge, a wait, a process start.
187
+ * `llm4ts profile` and the agent tree add these up; nothing else depends on
188
+ * them, and none carries content — only names, numbers and an exit code.
189
+ */
190
+ export const TimedKind = Schema.Literals(["model", "tool", "gate", "git", "merge", "wait", "spawn"])
191
+ export type TimedKind = typeof TimedKind.Type
192
+
193
+ export class Timed extends Schema.TaggedClass<Timed>()("Timed", {
194
+ kind: TimedKind,
195
+ /** What was timed: the seat's role, the tool, the gate command as configured, the wait. */
196
+ label: Schema.String,
197
+ ms: Schema.Number,
198
+ /** A model call: when its first output came. */
199
+ firstMs: Schema.optionalKey(Schema.Number),
200
+ /** As the backend itself reported them (Gemini's stats): model API time and tool time. */
201
+ apiMs: Schema.optionalKey(Schema.Number),
202
+ toolMs: Schema.optionalKey(Schema.Number),
203
+ exitCode: Schema.optionalKey(Schema.Int),
204
+ /** It ended in a failure (the time was still spent). */
205
+ failed: Schema.optionalKey(Schema.Boolean),
206
+ lane: Schema.optionalKey(Schema.String),
207
+ executor: Schema.optionalKey(Schema.String)
208
+ }) {}
209
+
184
210
  /** One rubric dimension of a story verdict: its score out of `max`. */
185
211
  export const JudgedDimension = Schema.Struct({
186
212
  id: Schema.String,
@@ -303,7 +329,8 @@ export const FlowEvent = Schema.Union([
303
329
  ExecutorExcluded,
304
330
  ExecutorResumed,
305
331
  ExecutorHandedOver,
306
- StoryJudged
332
+ StoryJudged,
333
+ Timed
307
334
  ])
308
335
  export type FlowEvent = typeof FlowEvent.Type
309
336
 
@@ -395,6 +422,19 @@ const stamped = (
395
422
  ...(event.model === undefined ? {} : { model: event.model }),
396
423
  ...tags
397
424
  })
425
+ // The lane's executor is its coder's: it names a coder call or tool, not
426
+ // a reviewer's or judge's call made on another executor.
427
+ case "Timed":
428
+ return event.lane !== undefined
429
+ ? event
430
+ : Timed.make({
431
+ ...event,
432
+ lane,
433
+ ...(executor !== undefined &&
434
+ ((event.kind === "model" && event.label === "coder") || event.kind === "tool")
435
+ ? { executor }
436
+ : {})
437
+ })
398
438
  default:
399
439
  return event
400
440
  }
package/src/Review.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as Clock from "effect/Clock"
1
2
  import * as Effect from "effect/Effect"
2
3
  import * as Schema from "effect/Schema"
3
4
  import type { LlmError } from "@llm4ts/core/Errors"
@@ -14,6 +15,7 @@ import {
14
15
  ReviewFinding,
15
16
  ReviewFindings,
16
17
  publishJudgmentObserved,
18
+ Timed,
17
19
  type FlowEventsShape
18
20
  } from "./FlowEvents.ts"
19
21
  import { Reviewer } from "./Reviewer.ts"
@@ -260,6 +262,7 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
260
262
  if (executable === undefined) {
261
263
  return ReviewResult.make({ issues: [], summary: "" })
262
264
  }
265
+ const started = yield* Clock.currentTimeMillis
263
266
  const result = yield* guarded(
264
267
  Capabilities.Exec(executable),
265
268
  `lint: ${command.join(" ")}`,
@@ -273,6 +276,16 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
273
276
  )
274
277
  )
275
278
  )
279
+ // The command as configured and its exit code: never its output.
280
+ yield* events.publish(
281
+ Timed.make({
282
+ kind: "gate",
283
+ label: command.join(" "),
284
+ ms: (yield* Clock.currentTimeMillis) - started,
285
+ exitCode: result.exitCode,
286
+ ...(result.exitCode === 0 ? {} : { failed: true })
287
+ })
288
+ )
276
289
  if (result.exitCode === 0) {
277
290
  return ReviewResult.make({ issues: [], summary: "lint passed" })
278
291
  }
package/src/Roster.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  ExecutorReleased,
21
21
  ExecutorResumed,
22
22
  Info,
23
+ Timed,
23
24
  type FlowEventsShape
24
25
  } from "./FlowEvents.ts"
25
26
  import { loadVersioned, saveVersioned, type PlainFileStoreShape } from "./Persistence.ts"
@@ -710,9 +711,24 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
710
711
  Effect.gen(function* () {
711
712
  const avoid = leaseOptions.avoid ?? []
712
713
  let announced = false
714
+ const asked = yield* Clock.currentTimeMillis
715
+ // A lease that had to wait says how long, for the story it is for.
716
+ const waited: Effect.Effect<void> = Effect.flatMap(Clock.currentTimeMillis, (now) =>
717
+ announced
718
+ ? events.publish(
719
+ Timed.make({
720
+ kind: "wait",
721
+ label: `roster ${role}`,
722
+ ms: now - asked,
723
+ ...(leaseOptions.label === undefined ? {} : { lane: leaseOptions.label })
724
+ })
725
+ )
726
+ : Effect.void
727
+ )
713
728
  while (true) {
714
729
  const spec = yield* attempt(role, leaseOptions)
715
730
  if (spec !== undefined) {
731
+ yield* waited
716
732
  return yield* leaseOf(spec, role, leaseOptions.label)
717
733
  }
718
734
  if (!(yield* canEverServe(role, avoid))) {
@@ -740,6 +756,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
740
756
  yield* probeDue
741
757
  const recovered = yield* attempt(role, leaseOptions)
742
758
  if (recovered !== undefined) {
759
+ yield* waited
743
760
  return yield* leaseOf(recovered, role, leaseOptions.label)
744
761
  }
745
762
  const probing = yield* lock.withPermit(
package/src/Stories.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // merges back only after its judge and perimeter checks pass, and the
5
5
  // target's gates run on the epic branch after every merge. Scheduling,
6
6
  // gating, resume and failure policy live here; seats come from `contextFor`.
7
+ import * as Clock from "effect/Clock"
7
8
  import * as Effect from "effect/Effect"
8
9
  import * as Exit from "effect/Exit"
9
10
  import * as Queue from "effect/Queue"
@@ -28,7 +29,15 @@ import {
28
29
  StoryFailed,
29
30
  type FlowError
30
31
  } from "./FlowError.ts"
31
- import { Info, JudgedDimension, StoryJudged, withLane, type FlowEventsShape } from "./FlowEvents.ts"
32
+ import {
33
+ Info,
34
+ JudgedDimension,
35
+ StoryJudged,
36
+ Timed,
37
+ withLane,
38
+ type FlowEventsShape
39
+ } from "./FlowEvents.ts"
40
+ import { timeEffect } from "./Timing.ts"
32
41
  import { statusPaths, type GitToolShape } from "./GitTool.ts"
33
42
  import {
34
43
  checkPerimeter,
@@ -464,7 +473,7 @@ export interface StoriesOptions {
464
473
  * installed dependencies, so the gates cannot run there without this.
465
474
  * Runs on every start and resume; a failure fails the story.
466
475
  */
467
- readonly setup?: (workDir: string) => Effect.Effect<void, FlowError>
476
+ readonly setup?: (workDir: string, events?: FlowEventsShape) => Effect.Effect<void, FlowError>
468
477
  /**
469
478
  * When setup fails, give the story's coder ONE turn to make the worktree
470
479
  * ready (install, warm a cache, generate a client), then run setup again as
@@ -473,7 +482,14 @@ export interface StoriesOptions {
473
482
  */
474
483
  readonly setupAgent?: boolean
475
484
  /** The target's gates, run in a worktree per task and on the epic checkout after each merge. */
476
- readonly gates: (workDir: string) => Effect.Effect<ReviewResult, FlowError>
485
+ /**
486
+ * The target's gates in a directory. `events` is the story's lane, so a
487
+ * gate's timing names its story; a gate may ignore it.
488
+ */
489
+ readonly gates: (
490
+ workDir: string,
491
+ events?: FlowEventsShape
492
+ ) => Effect.Effect<ReviewResult, FlowError>
477
493
  /** Story-level judge over the branch's diff against the epic branch; omit to skip. */
478
494
  readonly judge?: (
479
495
  story: Story,
@@ -624,24 +640,37 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
624
640
 
625
641
  /** Merge the story branch into the epic branch and re-gate the epic head, one story at a time. */
626
642
  const integrate = (story: Story, branch: string): Effect.Effect<void, FlowError> =>
627
- mergeLock.withPermit(
628
- Effect.gen(function* () {
629
- yield* epicCheckoutClean(story)
630
- const checkpoint = yield* context.git.checkpoint
631
- yield* context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`)
632
- const gate = yield* options.gates(context.workDir)
633
- if (!gate.isClean) {
634
- // Never leave a red epic head for the next story to inherit.
635
- yield* context.git.rollback(checkpoint)
636
- return yield* failed(
637
- story,
638
- `epic gates failed after merging; merge undone:\n${issueLines(gate)}`
643
+ Effect.flatMap(Clock.currentTimeMillis, (requested) =>
644
+ mergeLock.withPermit(
645
+ Effect.gen(function* () {
646
+ const lane = laneOf(story)
647
+ // Merges are one at a time: how long this story queued for its turn.
648
+ const turn = yield* Clock.currentTimeMillis
649
+ yield* lane.publish(
650
+ Timed.make({ kind: "wait", label: "merge lock", ms: turn - requested })
639
651
  )
640
- }
641
- yield* laneOf(story).publish(
642
- Info.make({ message: `story ${story.id}: merged into ${epicBranch}` })
643
- )
644
- })
652
+ yield* epicCheckoutClean(story)
653
+ const checkpoint = yield* context.git.checkpoint
654
+ yield* timeEffect(
655
+ lane,
656
+ context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`),
657
+ (ms, failed) =>
658
+ Timed.make({ kind: "merge", label: "merge", ms, ...(failed ? { failed } : {}) })
659
+ )
660
+ const gate = yield* options.gates(context.workDir, lane)
661
+ if (!gate.isClean) {
662
+ // Never leave a red epic head for the next story to inherit.
663
+ yield* context.git.rollback(checkpoint)
664
+ return yield* failed(
665
+ story,
666
+ `epic gates failed after merging; merge undone:\n${issueLines(gate)}`
667
+ )
668
+ }
669
+ yield* laneOf(story).publish(
670
+ Info.make({ message: `story ${story.id}: merged into ${epicBranch}` })
671
+ )
672
+ })
673
+ )
645
674
  )
646
675
 
647
676
  /** The worktree and branch for a story, honouring the hash-guarded resume rules. */
@@ -794,7 +823,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
794
823
  // Catch up before setup: the epic may have changed the manifest too.
795
824
  yield* catchUp(story, git)
796
825
  if (options.setup !== undefined) {
797
- const setup = options.setup(state.worktree)
826
+ const setup = options.setup(state.worktree, laneOf(story))
798
827
  yield* stage(
799
828
  laneOf(story),
800
829
  `story ${story.id}: setup`,
@@ -835,7 +864,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
835
864
  // The target's gates plus the perimeter: a stray path is a gate failure
836
865
  // the task review loop hands back to the coder before anything commits.
837
866
  const gates: Effect.Effect<ReviewResult, FlowError> = Effect.gen(function* () {
838
- const target = yield* options.gates(state.worktree)
867
+ const target = yield* options.gates(state.worktree, laneOf(story))
839
868
  const changed = yield* perimeterNow(story, git)
840
869
  return combined(target, perimeterGate([...changed.sharedReadOnly, ...changed.outside], story))
841
870
  })
package/src/Timing.ts ADDED
@@ -0,0 +1,112 @@
1
+ import * as Clock from "effect/Clock"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Exit from "effect/Exit"
4
+ import * as Ref from "effect/Ref"
5
+ import * as Stream from "effect/Stream"
6
+ import type { LlmServiceShape } from "@llm4ts/core/LlmService"
7
+ import type { LlmChunk } from "@llm4ts/core/Models"
8
+ import { Timed, type FlowEventsShape } from "./FlowEvents.ts"
9
+
10
+ /**
11
+ * Where a run's time goes. `timedSeat` decorates a seat so every
12
+ * call publishes a `Timed{kind:"model"}` when it ends — its wall time, when
13
+ * its first output came, and the API and tool time the backend reported, if
14
+ * it did. The events carry the seat's role, never the prompt or the reply.
15
+ */
16
+
17
+ const reportedMs = (chunk: LlmChunk, key: string): number | undefined => {
18
+ const raw = chunk.metadata[key]
19
+ const value = raw === undefined ? Number.NaN : Number(raw)
20
+ return Number.isFinite(value) ? value : undefined
21
+ }
22
+
23
+ /** Times `effect`, publishing what `timed` makes of its duration whether it succeeds or fails. */
24
+ export const timeEffect = <A, E, R>(
25
+ events: FlowEventsShape,
26
+ effect: Effect.Effect<A, E, R>,
27
+ timed: (ms: number, failed: boolean) => Timed
28
+ ): Effect.Effect<A, E, R> =>
29
+ Effect.flatMap(Clock.currentTimeMillis, (start) =>
30
+ Effect.onExit(effect, (exit) =>
31
+ Effect.flatMap(Clock.currentTimeMillis, (end) =>
32
+ events.publish(timed(end - start, Exit.isFailure(exit)))
33
+ )
34
+ )
35
+ )
36
+
37
+ const timedStream = <E>(
38
+ events: FlowEventsShape,
39
+ label: string,
40
+ stream: Stream.Stream<LlmChunk, E>
41
+ ): Stream.Stream<LlmChunk, E> =>
42
+ Stream.unwrap(
43
+ Effect.gen(function* () {
44
+ const start = yield* Clock.currentTimeMillis
45
+ const first = yield* Ref.make<number | undefined>(undefined)
46
+ const reported = yield* Ref.make<{ apiMs?: number; toolMs?: number }>({})
47
+ const failed = yield* Ref.make(false)
48
+ return stream.pipe(
49
+ Stream.tap((chunk) =>
50
+ Effect.gen(function* () {
51
+ const now = yield* Clock.currentTimeMillis
52
+ yield* Ref.update(first, (at) => at ?? now - start)
53
+ const apiMs = reportedMs(chunk, "api_ms")
54
+ const toolMs = reportedMs(chunk, "tools_ms")
55
+ if (apiMs !== undefined || toolMs !== undefined) {
56
+ yield* Ref.update(reported, (current) => ({
57
+ ...current,
58
+ ...(apiMs === undefined ? {} : { apiMs }),
59
+ ...(toolMs === undefined ? {} : { toolMs })
60
+ }))
61
+ }
62
+ })
63
+ ),
64
+ Stream.tapError(() => Ref.set(failed, true)),
65
+ Stream.ensuring(
66
+ Effect.gen(function* () {
67
+ const end = yield* Clock.currentTimeMillis
68
+ const firstMs = yield* Ref.get(first)
69
+ yield* events.publish(
70
+ Timed.make({
71
+ kind: "model",
72
+ label,
73
+ ms: end - start,
74
+ ...(firstMs === undefined ? {} : { firstMs }),
75
+ ...(yield* Ref.get(reported)),
76
+ ...((yield* Ref.get(failed)) ? { failed: true } : {})
77
+ })
78
+ )
79
+ })
80
+ )
81
+ )
82
+ })
83
+ )
84
+
85
+ export const timedSeat = (
86
+ service: LlmServiceShape,
87
+ events: FlowEventsShape,
88
+ label: string
89
+ ): LlmServiceShape => {
90
+ const model = (ms: number, failed: boolean): Timed =>
91
+ Timed.make({ kind: "model", label, ms, ...(failed ? { failed: true } : {}) })
92
+ const batched = service.scoreLabelSequence
93
+ return {
94
+ executeStream: (prompt) => timedStream(events, label, service.executeStream(prompt)),
95
+ executeStreamWithHistory: (messages) =>
96
+ timedStream(events, label, service.executeStreamWithHistory(messages)),
97
+ executeWithTools: (prompt, tools) =>
98
+ timeEffect(events, service.executeWithTools(prompt, tools), model),
99
+ executeStructured: (prompt, schema, jsonSchema) =>
100
+ timeEffect(events, service.executeStructured(prompt, schema, jsonSchema), model),
101
+ executeStructuredWithUsage: (prompt, schema, jsonSchema) =>
102
+ timeEffect(events, service.executeStructuredWithUsage(prompt, schema, jsonSchema), model),
103
+ scoreLabels: (prompt, labels) => timeEffect(events, service.scoreLabels(prompt, labels), model),
104
+ ...(batched === undefined
105
+ ? {}
106
+ : {
107
+ scoreLabelSequence: (prompt: string, labelSets: ReadonlyArray<ReadonlyArray<string>>) =>
108
+ timeEffect(events, batched(prompt, labelSets), model)
109
+ }),
110
+ isAvailable: service.isAvailable
111
+ }
112
+ }