@llm4ts/flow 2.23.0 → 2.25.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.
@@ -0,0 +1,240 @@
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 Schema from "effect/Schema"
6
+ import * as Stream from "effect/Stream"
7
+ import type { LlmServiceShape } from "@llm4ts/core/LlmService"
8
+ import type { LlmChunk, Message } from "@llm4ts/core/Models"
9
+ import { redactText } from "@llm4ts/core/observability/Redaction"
10
+ import { roleOr } from "./Timing.ts"
11
+
12
+ /**
13
+ * What the model was told and what it answered, per seat call — an opt-in
14
+ * record (`llm4ts run --transcript`) that `llm4ts watch` tails. It lives in
15
+ * its own files, never in the trace, so the trace and `llm4ts profile` stay
16
+ * content-free. Secrets are redacted and every field is capped.
17
+ */
18
+
19
+ const Call = Schema.TaggedStruct("Call", {
20
+ at: Schema.Number,
21
+ call: Schema.String,
22
+ role: Schema.String,
23
+ executor: Schema.optionalKey(Schema.String),
24
+ /** The system prompt, on a chat's first call only. */
25
+ system: Schema.optionalKey(Schema.String),
26
+ /** The input: a chat's newest message, or a one-shot prompt. */
27
+ input: Schema.String,
28
+ /** How many messages came before the input (a chat resends them all). */
29
+ earlier: Schema.optionalKey(Schema.Int)
30
+ })
31
+
32
+ const Reply = Schema.TaggedStruct("Reply", {
33
+ at: Schema.Number,
34
+ call: Schema.String,
35
+ text: Schema.String
36
+ })
37
+ const Tool = Schema.TaggedStruct("Tool", {
38
+ at: Schema.Number,
39
+ call: Schema.String,
40
+ tool: Schema.String,
41
+ args: Schema.String
42
+ })
43
+ const ToolResult = Schema.TaggedStruct("ToolResult", {
44
+ at: Schema.Number,
45
+ call: Schema.String,
46
+ output: Schema.String,
47
+ failed: Schema.optionalKey(Schema.Boolean)
48
+ })
49
+ const End = Schema.TaggedStruct("End", {
50
+ at: Schema.Number,
51
+ call: Schema.String,
52
+ ms: Schema.Number,
53
+ failed: Schema.optionalKey(Schema.Boolean)
54
+ })
55
+
56
+ export const TranscriptEntry = Schema.Union([Call, Reply, Tool, ToolResult, End])
57
+ export type TranscriptEntry = typeof TranscriptEntry.Type
58
+
59
+ /** Where entries go: one stream per story lane, and one for the run. */
60
+ export interface TranscriptSink {
61
+ readonly write: (lane: string | undefined, entry: TranscriptEntry) => Effect.Effect<void>
62
+ }
63
+
64
+ export interface MemoryTranscriptSink {
65
+ readonly sink: TranscriptSink
66
+ readonly entries: Effect.Effect<
67
+ ReadonlyArray<{ readonly lane: string | undefined; readonly entry: TranscriptEntry }>
68
+ >
69
+ }
70
+
71
+ export const makeMemoryTranscriptSink: Effect.Effect<MemoryTranscriptSink> = Effect.map(
72
+ Ref.make<ReadonlyArray<{ readonly lane: string | undefined; readonly entry: TranscriptEntry }>>(
73
+ []
74
+ ),
75
+ (entries) => ({
76
+ sink: { write: (lane, entry) => Ref.update(entries, (all) => [...all, { lane, entry }]) },
77
+ entries: Ref.get(entries)
78
+ })
79
+ )
80
+
81
+ const inputChars = 64_000
82
+ const partChars = 4_000
83
+
84
+ const clean = (text: string, limit: number): string => redactText(text, { maxLength: limit })
85
+
86
+ let calls = 0
87
+ const nextCall: Effect.Effect<string> = Effect.sync(() => {
88
+ calls += 1
89
+ return `call-${calls}`
90
+ })
91
+
92
+ export interface TranscriptSeatOptions {
93
+ readonly lane?: string
94
+ /** The seat's role; a caller's `withTimedRole` names the call instead. */
95
+ readonly role: string
96
+ /** Who serves the seat, read at each call (a roster hands the coder over). */
97
+ readonly executor?: Effect.Effect<string | undefined>
98
+ }
99
+
100
+ /** A seat whose every call is written to `sink`, as it happens. */
101
+ export const transcriptSeat = (
102
+ service: LlmServiceShape,
103
+ sink: TranscriptSink,
104
+ options: TranscriptSeatOptions
105
+ ): LlmServiceShape => {
106
+ const write = (entry: TranscriptEntry) => sink.write(options.lane, entry)
107
+
108
+ const opened = (
109
+ input: string,
110
+ extra: { readonly system?: string; readonly earlier?: number }
111
+ ): Effect.Effect<{ readonly call: string; readonly start: number }> =>
112
+ Effect.gen(function* () {
113
+ const call = yield* nextCall
114
+ const start = yield* Clock.currentTimeMillis
115
+ const role = yield* roleOr(options.role)
116
+ const executor = options.executor === undefined ? undefined : yield* options.executor
117
+ yield* write({
118
+ _tag: "Call",
119
+ at: start,
120
+ call,
121
+ role,
122
+ ...(executor === undefined ? {} : { executor }),
123
+ ...(extra.system === undefined ? {} : { system: clean(extra.system, inputChars) }),
124
+ input: clean(input, inputChars),
125
+ ...(extra.earlier === undefined ? {} : { earlier: extra.earlier })
126
+ })
127
+ return { call, start }
128
+ })
129
+
130
+ const closed = (call: string, start: number, failed: boolean): Effect.Effect<void> =>
131
+ Effect.flatMap(Clock.currentTimeMillis, (at) =>
132
+ write({ _tag: "End", at, call, ms: at - start, ...(failed ? { failed } : {}) })
133
+ )
134
+
135
+ const chunkEntry = (call: string, chunk: LlmChunk): Effect.Effect<void> =>
136
+ Effect.flatMap(Clock.currentTimeMillis, (at) => {
137
+ switch (chunk.metadata.event) {
138
+ case "tool_use":
139
+ return write({
140
+ _tag: "Tool",
141
+ at,
142
+ call,
143
+ tool: chunk.metadata.tool_name ?? chunk.metadata.toolName ?? "",
144
+ args: clean(chunk.metadata.tool_input ?? chunk.metadata.toolInput ?? "", partChars)
145
+ })
146
+ case "tool_result": {
147
+ const failed =
148
+ chunk.metadata.tool_failed === "true" || chunk.metadata.tool_status === "error"
149
+ return write({
150
+ _tag: "ToolResult",
151
+ at,
152
+ call,
153
+ output: clean(chunk.metadata.tool_content ?? "", partChars),
154
+ ...(failed ? { failed } : {})
155
+ })
156
+ }
157
+ default:
158
+ return chunk.delta.length === 0
159
+ ? Effect.void
160
+ : write({
161
+ _tag: "Reply",
162
+ at,
163
+ call,
164
+ text: redactText(chunk.delta, { maxLength: inputChars })
165
+ })
166
+ }
167
+ })
168
+
169
+ const recorded = <E>(
170
+ open: Effect.Effect<{ readonly call: string; readonly start: number }>,
171
+ stream: Stream.Stream<LlmChunk, E>
172
+ ): Stream.Stream<LlmChunk, E> =>
173
+ Stream.unwrap(
174
+ Effect.map(Effect.all([open, Ref.make(false)]), ([{ call, start }, failed]) =>
175
+ stream.pipe(
176
+ Stream.tap((chunk) => chunkEntry(call, chunk)),
177
+ Stream.tapError(() => Ref.set(failed, true)),
178
+ Stream.ensuring(Effect.flatMap(Ref.get(failed), (did) => closed(call, start, did)))
179
+ )
180
+ )
181
+ )
182
+
183
+ const once = <A, E, R>(input: string, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
184
+ Effect.flatMap(opened(input, {}), ({ call, start }) =>
185
+ Effect.onExit(effect, (exit) =>
186
+ Effect.andThen(
187
+ Exit.isSuccess(exit)
188
+ ? Effect.flatMap(Clock.currentTimeMillis, (at) =>
189
+ write({
190
+ _tag: "Reply",
191
+ at,
192
+ call,
193
+ text: clean(JSON.stringify(exit.value) ?? "", inputChars)
194
+ })
195
+ )
196
+ : Effect.void,
197
+ closed(call, start, Exit.isFailure(exit))
198
+ )
199
+ )
200
+ )
201
+
202
+ /** A chat's first call is told everything; a later one only what is new. */
203
+ const chatInput = (messages: ReadonlyArray<Message>) => {
204
+ const system = messages.filter((message) => message.role === "System")
205
+ const rest = messages.filter((message) => message.role !== "System")
206
+ const first = !rest.some((message) => message.role === "Assistant")
207
+ const newest = rest.at(-1)?.content ?? ""
208
+ return first
209
+ ? {
210
+ input: rest.map((message) => message.content).join("\n\n"),
211
+ extra:
212
+ system.length === 0
213
+ ? {}
214
+ : { system: system.map((message) => message.content).join("\n\n") }
215
+ }
216
+ : { input: newest, extra: { earlier: rest.length - 1 } }
217
+ }
218
+
219
+ const batched = service.scoreLabelSequence
220
+ return {
221
+ executeStream: (prompt) => recorded(opened(prompt, {}), service.executeStream(prompt)),
222
+ executeStreamWithHistory: (messages) => {
223
+ const { input, extra } = chatInput(messages)
224
+ return recorded(opened(input, extra), service.executeStreamWithHistory(messages))
225
+ },
226
+ executeWithTools: (prompt, tools) => once(prompt, service.executeWithTools(prompt, tools)),
227
+ executeStructured: (prompt, schema, jsonSchema) =>
228
+ once(prompt, service.executeStructured(prompt, schema, jsonSchema)),
229
+ executeStructuredWithUsage: (prompt, schema, jsonSchema) =>
230
+ once(prompt, service.executeStructuredWithUsage(prompt, schema, jsonSchema)),
231
+ scoreLabels: (prompt, labels) => once(prompt, service.scoreLabels(prompt, labels)),
232
+ ...(batched === undefined
233
+ ? {}
234
+ : {
235
+ scoreLabelSequence: (prompt: string, labelSets: ReadonlyArray<ReadonlyArray<string>>) =>
236
+ once(prompt, batched(prompt, labelSets))
237
+ }),
238
+ isAvailable: service.isAvailable
239
+ }
240
+ }