Packages

Elixir framework for building production-ready AI agents and LLM applications: type-safe schemas with JSON Schema generation, tool calling, streaming, multi-agent coordination, guardrails, and distributed fault-tolerant sessions, with first-class Anthropic Claude support.

Current section

Files

Jump to
normandy lib normandy components stream_processor.ex
Raw

lib/normandy/components/stream_processor.ex

defmodule Normandy.Components.StreamProcessor do
@moduledoc """
Utilities for processing streaming LLM responses.
Provides functions to accumulate streaming events into complete messages,
handle text deltas, and build final responses from event streams.
"""
alias Normandy.Components.StreamEvent
@doc """
Accumulates text deltas from a stream of events.
Returns a stream that emits accumulated text chunks.
## Example
stream
|> parse_stream_events()
|> accumulate_text()
|> Enum.each(&IO.write/1)
"""
@spec accumulate_text(Enumerable.t()) :: Enumerable.t()
def accumulate_text(event_stream) do
event_stream
|> Stream.filter(&is_text_delta?/1)
|> Stream.map(&extract_text_delta/1)
|> Stream.reject(&is_nil/1)
end
@doc """
Builds a complete message from all stream events.
Accumulates all events and constructs a final response structure
similar to non-streaming responses.
## Example
events = Enum.to_list(stream)
final_message = StreamProcessor.build_final_message(events)
"""
@spec build_final_message([StreamEvent.t() | map()]) :: map()
def build_final_message(events) when is_list(events) do
Enum.reduce(events, %{content: [], usage: %{}, stop_reason: nil}, fn event, acc ->
process_event(event, acc)
end)
end
@doc """
Invokes a callback for each event type.
Callbacks receive `(event_type, data)` and should return `:ok` or `{:error, reason}`.
## Example
callback = fn
:text_delta, text -> IO.write(text)
:tool_use, tool -> IO.puts("Tool: \#{tool.name}")
_, _ -> :ok
end
StreamProcessor.process_with_callback(stream, callback)
"""
@spec process_with_callback(Enumerable.t(), function()) :: {:ok, map()} | {:error, term()}
def process_with_callback(event_stream, callback) when is_function(callback, 2) do
try do
events =
event_stream
|> Enum.map(fn event ->
invoke_callback(event, callback)
event
end)
final_message = build_final_message(events)
{:ok, final_message}
rescue
error -> {:error, error}
end
end
# Private functions
defp is_text_delta?(%StreamEvent{type: :content_block_delta, delta: %{"type" => "text_delta"}}),
do: true
defp is_text_delta?(%{type: "content_block_delta", delta: %{"type" => "text_delta"}}), do: true
defp is_text_delta?(_), do: false
defp extract_text_delta(%StreamEvent{delta: %{"text" => text}}), do: text
defp extract_text_delta(%{delta: %{"text" => text}}), do: text
defp extract_text_delta(_), do: nil
defp process_event(%{type: "message_start", message: message}, acc) do
Map.merge(acc, %{
id: message["id"],
model: message["model"],
role: message["role"]
})
end
defp process_event(%{type: "content_block_start", content_block: block, index: index}, acc) do
content = acc.content || []
# Initialize content block at index
new_content = List.insert_at(content, index, block)
%{acc | content: new_content}
end
defp process_event(%{type: "content_block_delta", delta: delta, index: index}, acc) do
content = acc.content || []
updated_content =
case delta do
%{"type" => "text_delta", "text" => text} ->
append_text_delta(content, index, text)
%{"type" => "input_json_delta", "partial_json" => json} ->
append_json_delta(content, index, json)
_ ->
content
end
%{acc | content: updated_content}
end
defp process_event(%{type: "message_delta", delta: delta, usage: usage}, acc) do
acc
|> maybe_set_stop_reason(delta["stop_reason"])
|> maybe_update_usage(usage)
end
defp process_event(%{type: "message_stop"}, acc), do: acc
defp process_event(_, acc), do: acc
defp append_text_delta(content, index, text) do
List.update_at(content, index, fn
%{"type" => "text", "text" => existing} = block ->
%{block | "text" => existing <> text}
block ->
block
end)
end
defp append_json_delta(content, index, partial_json) do
List.update_at(content, index, fn
%{"type" => "tool_use", "input" => existing} = block when is_binary(existing) ->
%{block | "input" => existing <> partial_json}
%{"type" => "tool_use"} = block ->
Map.put(block, "input", partial_json)
block ->
block
end)
end
defp maybe_set_stop_reason(acc, nil), do: acc
defp maybe_set_stop_reason(acc, reason), do: Map.put(acc, :stop_reason, reason)
defp maybe_update_usage(acc, nil), do: acc
defp maybe_update_usage(acc, usage) do
existing_usage = acc.usage || %{}
updated_usage = Map.merge(existing_usage, usage)
Map.put(acc, :usage, updated_usage)
end
defp invoke_callback(
%{type: "content_block_delta", delta: %{"type" => "text_delta", "text" => text}},
callback
) do
callback.(:text_delta, text)
end
defp invoke_callback(
%{type: "content_block_start", content_block: %{"type" => "tool_use"} = tool},
callback
) do
callback.(:tool_use_start, tool)
end
defp invoke_callback(
%{type: "content_block_delta", delta: %{"type" => "thinking_delta", "thinking" => text}},
callback
) do
callback.(:thinking_delta, text)
end
defp invoke_callback(%{type: "message_start", message: message}, callback) do
callback.(:message_start, message)
end
defp invoke_callback(%{type: "message_stop"}, callback) do
callback.(:message_stop, %{})
end
# Routes synthetic guardrail_violation events through the callback.
# Expected event shape: `%{type: "guardrail_violation", stage: :output,
# mode: :accumulate | :incremental, violations: [...]}`. The callback
# receives `{:guardrail_violation, %{stage:, mode:, violations:}}` with
# `:type` stripped. BaseAgent's streaming guardrail paths currently fire
# the callback directly; this clause exists for callers that route a
# synthetic event list through `process_with_callback/2`.
defp invoke_callback(%{type: "guardrail_violation"} = event, callback) do
callback.(:guardrail_violation, Map.delete(event, :type))
end
defp invoke_callback(_, _callback), do: :ok
end