Emit LLM stream lifecycle events (#26971)

This commit is contained in:
Kit Langton 2026-05-11 21:31:48 -04:00 committed by GitHub
commit 8030a6c187
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 560 additions and 196 deletions

View file

@ -0,0 +1,88 @@
import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema"
export interface State {
readonly stepStarted: boolean
readonly text: ReadonlySet<string>
readonly reasoning: ReadonlySet<string>
}
export const initial = (): State => ({ stepStarted: false, text: new Set(), reasoning: new Set() })
export const stepStart = (state: State, events: LLMEvent[]): State => {
if (state.stepStarted) return state
events.push(LLMEvent.stepStart({ index: 0 }))
return { ...state, stepStarted: true }
}
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
const stepped = stepStart(state, events)
if (stepped.text.has(id)) {
events.push(LLMEvent.textDelta({ id, text }))
return stepped
}
events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
return { ...stepped, text: new Set([...stepped.text, id]) }
}
export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
const stepped = stepStart(state, events)
if (stepped.reasoning.has(id)) {
events.push(LLMEvent.reasoningDelta({ id, text }))
return stepped
}
events.push(LLMEvent.reasoningStart({ id }), LLMEvent.reasoningDelta({ id, text }))
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
}
export const reasoningEnd = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
): State => {
if (!state.reasoning.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
const reasoning = new Set(stepped.reasoning)
reasoning.delete(id)
return { ...stepped, reasoning }
}
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
if (!state.text.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.textEnd({ id, providerMetadata }))
const text = new Set(stepped.text)
text.delete(id)
return { ...stepped, text }
}
const closeOpenBlocks = (state: State, events: LLMEvent[]): State => {
for (const id of state.reasoning) events.push(LLMEvent.reasoningEnd({ id }))
for (const id of state.text) events.push(LLMEvent.textEnd({ id }))
return { ...state, text: new Set(), reasoning: new Set() }
}
export const finish = (
state: State,
events: LLMEvent[],
input: {
readonly reason: FinishReason
readonly usage?: Usage
readonly providerMetadata?: ProviderMetadata
},
): State => {
const stepped = closeOpenBlocks(stepStart(state, events), events)
events.push(
LLMEvent.stepFinish({
index: 0,
reason: input.reason,
usage: input.usage,
providerMetadata: input.providerMetadata,
}),
LLMEvent.requestFinish(input),
)
return { ...stepped, stepStarted: false }
}
export * as Lifecycle from "./lifecycle"

View file

@ -1,5 +1,5 @@
import { Effect } from "effect"
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema"
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number
@ -27,13 +27,13 @@ export type State<K extends StreamKey> = Partial<Record<K, PendingTool>>
/**
* Result of adding argument text to one pending tool call. It returns both the
* next `tools` state and the updated `tool` because parsers often need the
* current id/name immediately. `event` is present only when new text arrived;
* metadata-only deltas update identity without emitting `tool-input-delta`.
* current id/name immediately. `events` contains lifecycle and delta events
* produced by the append; metadata-only deltas update identity without output.
*/
export interface AppendOutcome<K extends StreamKey> {
readonly tools: State<K>
readonly tool: PendingTool
readonly event?: ToolInputDelta
readonly events: ReadonlyArray<LLMEvent>
}
/** Create empty accumulator state for one provider stream. */
@ -49,7 +49,14 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
return next
}
const inputDelta = (tool: PendingTool, text: string): ToolInputDelta =>
const inputStart = (tool: PendingTool) =>
LLMEvent.toolInputStart({
id: tool.id,
name: tool.name,
providerMetadata: tool.providerMetadata,
})
const inputDelta = (tool: PendingTool, text: string) =>
LLMEvent.toolInputDelta({
id: tool.id,
name: tool.name,
@ -76,11 +83,16 @@ const appendTool = <K extends StreamKey>(
key: K,
tool: PendingTool,
text: string,
): AppendOutcome<K> => ({
tools: withTool(tools, key, tool),
tool,
event: text.length === 0 ? undefined : inputDelta(tool, text),
})
): AppendOutcome<K> => {
const events: LLMEvent[] = []
if (!tools[key]) events.push(inputStart(tool))
if (text.length > 0) events.push(inputDelta(tool, text))
return {
tools: withTool(tools, key, tool),
tool,
events,
}
}
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError =>
result instanceof LLMError
@ -121,7 +133,8 @@ export const appendOrStart = <K extends StreamKey>(
providerExecuted: current?.providerExecuted,
providerMetadata: current?.providerMetadata,
}
if (current && delta.text.length === 0 && current.id === id && current.name === name) return { tools, tool: current }
if (current && delta.text.length === 0 && current.id === id && current.name === name)
return { tools, tool: current, events: [] }
return appendTool(tools, key, tool, delta.text)
}
@ -139,7 +152,7 @@ export const appendExisting = <K extends StreamKey>(
): AppendOutcome<K> | LLMError => {
const current = tools[key]
if (!current) return eventError(route, missingToolMessage)
if (text.length === 0) return { tools, tool: current }
if (text.length === 0) return { tools, tool: current, events: [] }
return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text)
}
@ -152,7 +165,13 @@ export const finish = <K extends StreamKey>(route: string, tools: State<K>, key:
Effect.gen(function* () {
const tool = tools[key]
if (!tool) return { tools }
return { tools: withoutTool(tools, key), event: yield* toolCall(route, tool) }
return {
tools: withoutTool(tools, key),
events: [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
yield* toolCall(route, tool),
],
}
})
/**
@ -164,7 +183,13 @@ export const finishWithInput = <K extends StreamKey>(route: string, tools: State
Effect.gen(function* () {
const tool = tools[key]
if (!tool) return { tools }
return { tools: withoutTool(tools, key), event: yield* toolCall(route, tool, input) }
return {
tools: withoutTool(tools, key),
events: [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
yield* toolCall(route, tool, input),
],
}
})
/**
@ -179,7 +204,14 @@ export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =
)
return {
tools: empty<K>(),
events: yield* Effect.forEach(pending, (tool) => toolCall(route, tool)),
events: yield* Effect.forEach(pending, (tool) =>
toolCall(route, tool).pipe(
Effect.map((call) => [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
call,
]),
),
).pipe(Effect.map((events) => events.flat())),
}
})