Level 3: Streaming and Observability¶
Stream agent responses token-by-token and observe every step of the execution pipeline. This level covers the three streaming APIs, the ExtractionFilter, and the callback system.
Prerequisites¶
- Completed Level 2
- A working agent with memory (Postgres or in-memory)
Three Streaming APIs¶
SymfonicAgent provides three streaming methods, each at a different abstraction level:
| Method | Yields | Best for |
|---|---|---|
stream_text() |
str |
Chat UI -- just the clean text |
stream_typed() |
StreamEvent subclasses |
Rich UIs -- text, tool calls, thinking, activation |
stream() |
StreamChunk |
Low-level -- raw event wrappers with event_type field |
All three methods share the same signature:
async for chunk in agent.stream_text(
query,
scope=scope,
callbacks=[...], # optional per-call callbacks
session_id="abc", # optional session tracking
history=[...], # optional prior messages
):
...
stream_text() -- Simplest Streaming¶
For chat UIs that just need clean text, stream_text() yields only
str values. All lifecycle events, tool calls, and extraction blocks
are silently consumed (consolidation still runs).
import asyncio
from symfonic.agent.config import FrameworkConfig
from symfonic.agent.engine import SymfonicAgent
from symfonic.agent.types import FrameworkTenantScope
from symfonic.core.providers import AnthropicProvider
async def main():
agent = SymfonicAgent(
model_provider=AnthropicProvider(),
config=FrameworkConfig(auto_hydrate=False, auto_consolidate=False),
)
scope = FrameworkTenantScope(tenant_id="demo")
print("Agent: ", end="", flush=True)
async for text in agent.stream_text("Explain dependency injection in 3 sentences.", scope=scope):
print(text, end="", flush=True)
print()
asyncio.run(main())
Output streams character-by-character as the LLM generates tokens.
stream_typed() -- Rich Event Stream¶
For UIs that show thinking indicators, tool call progress, or graph
activation animations, stream_typed() yields typed event objects:
import asyncio
from symfonic.agent.config import FrameworkConfig
from symfonic.agent.engine import SymfonicAgent
from symfonic.agent.types import FrameworkTenantScope
from symfonic.core.contracts.types import (
ActivationEvent,
TextDeltaEvent,
ThinkingDeltaEvent,
ToolCallStartEvent,
ToolResultEvent,
UsageEvent,
)
from symfonic.core.providers import AnthropicProvider
async def main():
agent = SymfonicAgent(
model_provider=AnthropicProvider(),
config=FrameworkConfig(auto_hydrate=False, auto_consolidate=False),
)
scope = FrameworkTenantScope(tenant_id="demo")
async for event in agent.stream_typed("What tools do you have?", scope=scope):
match event:
case TextDeltaEvent(text=t):
print(t, end="", flush=True)
case ThinkingDeltaEvent(text=t):
print(f"[thinking] {t}")
case ToolCallStartEvent(tool_name=name):
print(f"\n[tool] Calling {name}...")
case ToolResultEvent(result=r):
print(f"[tool] Result: {r[:100]}")
case ActivationEvent(node_label=label, score=s):
print(f"[memory] Activated: {label} (score={s:.2f})")
case UsageEvent(input_tokens=inp, output_tokens=out):
print(f"\n[usage] {inp} in / {out} out")
case _:
pass # MessageStartEvent, MessageEndEvent, ExtensionEvent, etc.
print()
asyncio.run(main())
The full event type hierarchy:
| Event Type | Fields | When |
|---|---|---|
TextDeltaEvent |
text: str |
Each LLM output token |
ThinkingDeltaEvent |
text: str |
Extended thinking / chain-of-thought |
ToolCallStartEvent |
tool_name, tool_call_id |
LLM decides to call a tool |
ToolCallDeltaEvent |
tool_call_id, args_delta |
Incremental tool arguments |
ToolResultEvent |
tool_call_id, result |
Tool execution completes |
ActivationEvent |
node_id, node_label, score, layer |
Memory node activated via spreading activation |
MessageStartEvent |
message_id, model |
New LLM message begins |
MessageEndEvent |
message_id, stop_reason |
LLM message ends |
UsageEvent |
input_tokens, output_tokens |
Token usage report |
ExtensionEvent |
type, payload |
Vendor-specific or custom events |
ExtractionFilter: Automatic Block Stripping¶
When enable_hms_prompt=True, the LLM emits <GRAPH_OPERATIONS> blocks
inline with its response text. These blocks contain memory extraction
instructions and must not be shown to the user.
The ExtractionFilter is a stateful streaming filter that handles this
automatically. It works correctly even when tags are split across multiple
chunks:
from symfonic.core.streaming.extraction_filter import ExtractionFilter
filt = ExtractionFilter()
# Simulate chunked LLM output with an embedded extraction block
chunks = [
"Here is my answer. ",
"<GRAPH_OPERA", # partial tag -- buffered
'TIONS>[{"type":"create_node","label":"SOUL"}]</GRAPH_OPERATIONS>',
" Hope that helps!",
]
for chunk in chunks:
clean = filt.feed(chunk)
if clean:
print(clean, end="")
remainder = filt.flush()
if remainder:
print(remainder, end="")
print()
print(f"Extracted ops: {filt.get_extracted_ops()}")
Output:
Both stream_typed() and stream_text() apply the ExtractionFilter
internally -- you never see extraction blocks in the output.
CallbackHandler: Prompt Auditing¶
Attach callbacks to any run() or stream() call for LLM-level
observability. The CallbackHandler protocol defines 7 lifecycle hooks:
import asyncio
from symfonic.agent.config import FrameworkConfig
from symfonic.agent.engine import SymfonicAgent
from symfonic.agent.types import FrameworkTenantScope
from symfonic.core.callbacks.protocol import CallbackHandler
from symfonic.core.providers import AnthropicProvider
class PromptAuditor:
"""Logs every LLM prompt and response for compliance.
As of v7.4.3, every LLM call site is wired -- this includes the
metacognition critic, the always-on context-window summariser, the
LLM extraction service, the procedural router, and more. The
``LLMEndEvent.node_name`` discriminator identifies which subsystem
issued the call so cost / latency can be attributed correctly:
* ``"react"`` -- main agent reasoning loop
* ``"metacognition_critic"`` -- post-draft reflection pass
* ``"metacognition_skill_gate"`` -- skill-promotion gating critic
* ``"context_window_summariser"`` -- conversation compaction (always-on)
* ``"insight_extractor"`` -- self-reflection insight extraction
* ``"entity_extractor_llm"`` -- Phase 11 entity extraction
* ``"consolidation_extractor"`` -- structured-ops extraction
* ``"procedural_router"`` -- LLM-backed tool router
"""
async def on_agent_start(self, event) -> None:
print(f"[agent] Started run_id={event.run_id}")
async def on_agent_end(self, event) -> None:
print(f"[agent] Finished run_id={event.run_id}")
async def on_node_start(self, event) -> None:
print(f" [node] -> {event.node_name}")
async def on_node_end(self, event) -> None:
print(f" [node] <- {event.node_name} ({event.duration_ms:.0f}ms)")
async def on_node_error(self, event) -> None:
print(f" [node] ERROR {event.node_name}: {event.error}")
async def on_llm_start(self, event) -> None:
print(f" [llm] Sending {len(event.messages)} messages to {event.model}")
async def on_llm_end(self, event) -> None:
# v7.4.3: event.node_name discriminates between react/critic/compactor/etc.
# Defaults to "" for handlers that don't care; non-empty for everything
# emitted by symfonic's instrumented call sites.
source = event.node_name or "(legacy)"
print(f" [llm:{source}] Response: {event.usage} tokens")
async def main():
agent = SymfonicAgent(
model_provider=AnthropicProvider(),
config=FrameworkConfig(auto_hydrate=False, auto_consolidate=False),
)
scope = FrameworkTenantScope(tenant_id="demo")
# Attach to a single call
response = await agent.run(
"Summarize the benefits of DI.",
scope=scope,
callbacks=[PromptAuditor()],
)
print(f"\nResponse: {response.final_response[:100]}...")
asyncio.run(main())
Multiple callbacks can be attached -- they run in parallel via
CallbackManager.
Optional Hooks (v6.1.7+ / v7.0.1+)¶
Several callback hooks are optional: the dispatcher only invokes them
when the handler defines the method, and emitters call
CallbackManager.has_hook("...") to skip event construction entirely when
nothing listens. This preserves the zero-cost invariant for legacy
handlers.
on_llm_pre_call (v6.1.7) — wire-accurate prompt logging¶
LLMStartEvent fires BEFORE system-message consolidation — useful for
debugging hydration. LLMPreCallEvent fires AFTER consolidation and tool
binding, BEFORE the provider call — useful for gateway trace events,
compliance capture, and replay fixtures.
from symfonic.core.contracts.callbacks import LLMPreCallEvent
class PromptAuditor:
async def on_llm_pre_call(self, event: LLMPreCallEvent) -> None:
# event.messages: post-consolidation list the provider sees
# event.tools: resolved tool descriptors (name, description, schema)
# event.invocation_params: provider-native kwargs (temperature, etc.)
print(f"[wire] model={event.model} tools={len(event.tools)}")
Fields: model, messages: list[BaseMessage], tools: list[dict],
run_id: str, invocation_params: dict. LLMPreCallEvent.messages
contains the single leading SystemMessage (when any system content is
present) followed by the non-system messages in order.
on_tool_routing_decision (v7.0.1) — wire-level tool narrowing¶
Fires when FrameworkConfig.tool_routing_mode != "off". Tells you which
tools would (observe) or will (enforce) bind on this turn.
from symfonic.agent.triage.events import ToolRoutingDecisionEvent
class RoutingAuditor:
async def on_tool_routing_decision(self, event: ToolRoutingDecisionEvent) -> None:
print(
f"[routing] {event.mode} intent={event.intent_label} "
f"total={event.total_tools} narrowed={len(event.narrowed_tool_names)} "
f"saved_tokens~{event.saved_tokens_estimate}"
)
Use observe mode to collect telemetry before flipping to enforce —
this is the recommended adoption path.
on_intent_classified (v7.0) — intent filter verdict¶
Fires as an ExtensionEvent(type="intent_classified") when
intent_filter_mode != "off". Subscribe via the generic ExtensionEvent
dispatch pattern if your handler supports one.
on_fabrication_detected (v7.0) — per-finding telemetry¶
Fires as an ExtensionEvent(type="fabrication_detected") when the
fabrication detector finds a hit in observe, revise, or refuse
mode. In refuse mode an ExtensionEvent(type="fabrication_blocked")
also fires when the draft is replaced with a safe envelope.
FrameworkObservabilityHook: System-Level Metrics¶
For framework-level events (hydration latency, consolidation ops, routing,
token budget), use FrameworkObservabilityHook:
from symfonic.agent.hooks import LoggingFrameworkHook
from symfonic.agent.config import FrameworkConfig
# LoggingFrameworkHook logs all framework events via Python logging
agent = SymfonicAgent(
model_provider=provider,
config=FrameworkConfig(observability_hook=LoggingFrameworkHook()),
)
The FrameworkObservabilityHook protocol defines these events:
| Hook Method | Fires When |
|---|---|
on_hydration_complete |
Memory context injected into system prompt |
on_consolidation_complete |
Memory extraction committed to graph |
on_routing_complete |
Lazy tool routing filters tools for query |
on_token_budget_computed |
Token savings calculated from tool filtering |
Streaming with FastAPI SSE¶
Combine stream_typed() with FastAPI's StreamingResponse for
Server-Sent Events:
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from symfonic.core.contracts.types import TextDeltaEvent
import json
app = FastAPI()
async def sse_generator(query: str, tenant_id: str):
scope = FrameworkTenantScope(tenant_id=tenant_id)
async for event in agent.stream_typed(query, scope=scope):
event_type = type(event).__name__
data = json.dumps(event.model_dump())
yield f"event: {event_type}\ndata: {data}\n\n"
@app.post("/stream")
async def stream_endpoint(query: str, tenant_id: str):
return StreamingResponse(
sse_generator(query, tenant_id),
media_type="text/event-stream",
)
Or use the built-in router which handles SSE automatically:
from symfonic.agent.fastapi.router import create_agent_router
router = create_agent_router(agent, prefix="/api/v1")
app.include_router(router)
# SSE endpoint available at POST /api/v1/stream/typed
Next Steps¶
Your agent streams responses and you can observe every step. Level 4 shows how to inject domain-specific behavior with plugins.
Next: Plugins & Domains
See also: the Feature Catalog Observability cluster for the full event taxonomy and which release introduced each hook.