Flat, pattern-matchable event tuples the loop emits to :on_event.
Event shape inspired by ash_ai's ToolLoop.stream/2 — one tuple per
logical event, exhaustive, easy to match on in LiveView handlers, OTel
emitters, and cost trackers. Same events feed the OpenTelemetry span
emitter (landing in PR 4).
Events:
{:content, text}— partial or full assistant text.{:thinking, text}— partial or full model reasoning/thinking content (Anthropicthinkingblocks, OpenAIreasoningcontent). Distinct from:contentso hosts can display it separately (e.g. in a side pane).{:tool_call, ToolCall.t()}— model requested a tool call.{:tool_result, ToolResult.t()}— tool produced a result (or error).{:tool_ui, %{tool_call_id:, kind:, payload:}}— structured UI payload for hosts that render rich previews (diffs, file contents, process output). Only emitted when a tool returns the{:ok, %{llm:, ui:}}shape; the LLM-facing string still flows through:tool_result.{:iteration, n}— a new iteration is starting.{:compaction, %{before:, after:, reason:}}— context compacted.{:subagent_spawn, %{id:, prompt:}}— a sub-agent started.{:subagent_result, %{id:, text:}}— sub-agent returned.{:conclusion, %{iteration:, text:, source:}}— the iteration's one-line conclusion (seeExAthena.Conclusions).sourceis:stated(model used the CONCLUSION marker),:tail(final paragraph fallback), or:derived(synthesized from tool activity). Hosts render these as a progress timeline.{:queue_wait, %{provider:, status:, waited_ms?}}— the loop's next provider call is blocked waiting for a request-queue slot (status: :waiting), or just obtained one after a wait (status: :acquired, withwaited_ms). Only emitted when the call actually blocks — a free slot produces no events. Hosts use this to render a "waiting on GPU" state for runs queued behind other loops.{:usage, usage_map}— partial usage report from the provider.{:structured_retry, %{attempt:, error:}}— extract_structured retry.{:error, reason}— non-fatal warning (the loop continues).{:submitted, deliverable}— the model called thefinishtool to declare completion.deliverableis the payload passed to the tool (nilwhen no deliverable was provided). Emitted just before{:done, Result}whenfinish_reasonis:submitted.{:done, Result.t()}— terminal event. Always the last event emitted; the Result carries the finish_reason.
Summary
Functions
Emit an event via the supplied callback (nil is a no-op).
Build a {callback, counters} pair that translates provider-side
ExAthena.Streaming.Event structs into host-side Loop event tuples.
Build an :on_wait callback for ExAthena.RequestQueue.with_slot/3 that
translates queue-wait notifications into {:queue_wait, …} loop events.
Types
@type t() :: {:content, String.t()} | {:thinking, String.t()} | {:tool_call, ExAthena.Messages.ToolCall.t()} | {:tool_result, ExAthena.Messages.ToolResult.t()} | {:tool_ui, %{tool_call_id: String.t(), kind: atom(), payload: map()}} | {:iteration, non_neg_integer()} | {:compaction, %{before: integer(), after: integer(), reason: term()}} | {:subagent_spawn, %{id: term(), prompt: String.t()}} | {:subagent_result, %{id: term(), text: String.t()}} | {:conclusion, %{ iteration: non_neg_integer(), text: String.t(), source: :stated | :tail | :derived }} | {:queue_wait, %{ :provider => atom(), :status => :waiting | :acquired, optional(:waited_ms) => non_neg_integer() }} | {:usage, map()} | {:structured_retry, %{attempt: non_neg_integer(), error: term()}} | {:error, term()} | {:submitted, term()} | {:done, ExAthena.Result.t()}
Functions
Emit an event via the supplied callback (nil is a no-op).
@spec provider_stream_callback((t() -> term()) | nil) :: {(ExAthena.Streaming.Event.t() -> :ok), :counters.counters_ref()}
Build a {callback, counters} pair that translates provider-side
ExAthena.Streaming.Event structs into host-side Loop event tuples.
Providers speak %Streaming.Event{} structs; host UIs speak flat Loop
tuples like {:content, text} and {:thinking, text}. This callback
bridges the two so modes can wire a provider stream directly to an
on_event callback without manually pattern-matching every event type.
The returned counters (an :counters table with 2 slots) track how many
text/thinking deltas have been forwarded:
- Slot 1 — text delta count
- Slot 2 — thinking delta count
Modes use these counters to suppress their end-of-turn full-text emission when streaming deltas have already been delivered to the host, avoiding duplicate content.
Mapping
:text_deltawith binary data → increments slot 1, emits{:content, t}:thinking_deltawith binary data → increments slot 2, emits{:thinking, t}:tool_call_endwhenforward_tool_events: true→ emits{:tool_call, data}:tool_resultwhenforward_tool_events: true→ emits{:tool_result, data}- All other events (
:start,:stop,:usage,:error,:tool_call_start,:tool_call_delta, and tool events when forwarding is off) →:ok, nothing emitted, counters untouched
:usage is intentionally dropped here because modes emit it once from the
final Response struct, which has complete accounting. :tool_call_start
and :tool_call_delta are dropped because hosts receive complete tool
events via :tool_call.
Options
:forward_tool_events(boolean, defaultfalse) — whentrue, forwards:tool_call_endand:tool_resultevents to the host callback.:filter_tool_fences(boolean, defaultfalse) — whentrue, text deltas are run throughExAthena.Loop.FenceFilterso~~~tool_callfenced blocks (the TextTagged protocol used by non-native-tool-call providers) never reach the host as visible{:content, _}text; the loop surfaces the parsed calls as{:tool_call, _}events instead.{:content, visible}is emitted only when the filtered text is non-empty, but counter slot 1 still increments on every raw text delta — the end-of-turn full-text suppression must stay armed because the fullresponse.textmay also contain fences. On:stopthe filter is flushed and any held tail is emitted as{:content, tail}(:stopitself remains untranslated). Filter state lives in the process dictionary under a per-callback unique key; this relies on the provider invoking the callback synchronously from a single process (mock, req_llm, and claude_code all reduce the stream in-process).:thinking_deltais never filtered.
Examples
parent = self()
on_event = fn event -> send(parent, {:evt, event}) end
{cb, counters} = Events.provider_stream_callback(on_event)
cb.(%Streaming.Event{type: :text_delta, data: "hello"})
# => {:content, "hello"} emitted; :counters.get(counters, 1) == 1
@spec provider_stream_callback( (t() -> term()) | nil, keyword() ) :: {(ExAthena.Streaming.Event.t() -> :ok), :counters.counters_ref()}
@spec queue_wait_emitter((t() -> term()) | nil, atom() | nil) :: (:waiting | {:acquired, non_neg_integer()} -> :ok) | nil
Build an :on_wait callback for ExAthena.RequestQueue.with_slot/3 that
translates queue-wait notifications into {:queue_wait, …} loop events.
Returns nil when there is no on_event callback (with_slot treats a nil
on_wait as "don't notify").