Stream to a terminal or UI#
You can watch a request happen in two ways: register a dispatcher for the events you care about, or consume the stream yourself when you need to cancel or forward it.
Use callbacks for display#
Subclass AgentEventDispatcher and override only
the events you want to render. Bind it through
create_agent(..., event_dispatcher=...); client.run() still returns the
final answer.
class ConsoleEvents(AgentEventDispatcher):
async def on_reasoning_delta_event(self, event: AgentEvent) -> None:
print(f"Reasoning: {event.delta}")
async def on_tool_started_event(self, event: AgentEvent) -> None:
call = event.tool_calls[0]
print(f"Tool: {call.name} {call.arguments}")
async def on_tool_completed_event(self, event: AgentEvent) -> None:
assert event.message is not None
print(f"Result: {event.message.content}")
async def on_text_delta_event(self, event: AgentEvent) -> None:
print(event.delta)
Two rules keep the display correct:
deltais incremental, whilemessageon a completion event is complete. Appending both to the same buffer duplicates the answer.Tool arguments can arrive as incomplete fragments. Only complete tool calls are executed, and the runtime does that — your UI never runs them.
TOOL_STARTED can describe a batch: iterate event.tool_calls instead of
assuming there is one call. Completion and failure events identify the finished
call; correlate them by ID, since parallel calls can finish out of order.
Consume the stream yourself for control#
from contextlib import aclosing
async with aclosing(client.stream("Inspect the project")) as events:
async for event in events:
await send_to_ui(event)
For a simple terminal, match only the fields associated with each event:
from zett_agent.events import AgentEventType
async with aclosing(client.stream("What is 20 plus 22?")) as events:
async for event in events:
if event.type == AgentEventType.TEXT_DELTA:
print(event.delta, end="", flush=True)
elif event.type == AgentEventType.TOOL_STARTED:
for call in event.tool_calls:
print(f"\nCalling {call.name} ({call.id})")
elif event.type == AgentEventType.RUN_COMPLETED:
final_message = event.message
client must already have a model and any tools it needs. The final message
is the authoritative answer; deltas are for presentation. A provider may emit
an answer only in its final response, so a UI should reconcile the final content
instead of assuming text deltas always exist. If a dispatcher is bound, it has
already handled each event before you receive it here.
Use aclosing even when you break out of the loop early: it stops the request
and waits for cleanup. Callbacks are awaited in order, so slow callbacks apply
backpressure; hand work to a bounded application queue instead of spawning
unlimited tasks.
What you will see#
Event |
Meaning |
|---|---|
|
A fragment of the answer text. |
|
A fragment of the model’s reasoning, when the model exposes it. |
|
A requested tool call and how it finished. |
|
Partial tool arguments, for progress display only. |
|
Older context is being summarized before the next model step. |
|
One complete model response, including usage. |
|
The model did not finish normally: it was refused, cut off, interrupted, or paused for good. Follows |
|
The request finished successfully. |
|
An event your own extension emitted. |
Provider-hosted tools add SERVER_TOOL_* events; steering and internal
continuations add their own pairs. The full list is on
AgentEventType.
Track model usage#
Read usage from event.response.usage on MODEL_COMPLETED. It describes
one model step, not the whole user request. A tool round trip often produces
two or more such events, so sum their counters to measure the primary loop:
tokens = 0
async with aclosing(client.stream("Explain the result.")) as events:
async for event in events:
if event.type == AgentEventType.MODEL_COMPLETED and event.response is not None:
tokens += event.response.usage.total_tokens
total_tokens is input plus output; reasoning and cache counters are already
included and must not be added again. Missing usage is not proof of zero cost.
Separate compaction or delegated models can incur additional usage outside
these primary model events. See Observe usage and timing for recorded
timings and Connect a model provider for cache reporting.
Failures are exceptions#
RUN_COMPLETED means the request ran to its end, which is not the same as the model
finishing its answer: a refusal ends a request and still completes it. Check
MODEL_STOPPED or message.stop for that; see Know why the model stopped. Provider errors, extension errors, and
cancellation propagate as exceptions — they are never converted into a synthetic
completion event. Wrap the stream when the UI needs to show a failure, and see
Cancel and reuse an agent for cleanup and reuse.
Next: keep the conversation in Keep and restore conversations, or read Three kinds of events for the difference between UI events, internal notifications, and external input.