@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/dist/Activity.d.ts +4 -3
- package/dist/Activity.d.ts.map +1 -1
- package/dist/Activity.js +48 -5
- package/dist/Activity.js.map +1 -1
- package/dist/FlowEvents.d.ts +27 -1
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +39 -1
- package/dist/FlowEvents.js.map +1 -1
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +11 -1
- package/dist/Review.js.map +1 -1
- package/dist/Roster.d.ts.map +1 -1
- package/dist/Roster.js +13 -1
- package/dist/Roster.js.map +1 -1
- package/dist/Stories.d.ts +7 -2
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +13 -7
- package/dist/Stories.js.map +1 -1
- package/dist/Timing.d.ts +7 -0
- package/dist/Timing.d.ts.map +1 -0
- package/dist/Timing.js +68 -0
- package/dist/Timing.js.map +1 -0
- package/package.json +3 -2
- package/src/Activity.ts +88 -16
- package/src/FlowEvents.ts +41 -1
- package/src/Review.ts +13 -0
- package/src/Roster.ts +17 -0
- package/src/Stories.ts +51 -22
- package/src/Timing.ts +112 -0
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
|
|
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(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
642
|
-
|
|
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
|
+
}
|