Streaming Events¶
The symfonic agent exposes three streaming HTTP endpoints, each serving a different trade-off between verbosity and ease of consumption. This page is the reference for the event taxonomy, ordering guarantees, error semantics, and client-side consumption patterns.
Endpoints¶
| Endpoint | Payload | Best for |
|---|---|---|
POST /api/v1/stream |
StreamChunk (loose dict) |
existing consumers, plug-and-play UI |
POST /api/v1/stream/typed |
StreamEvent (typed Pydantic) |
structured UIs, graph animation |
Both endpoints return text/event-stream on success. Auth, budget, and
rate-limit failures return JSON (not SSE) with the matching
4xx/429/401 status code — clients must distinguish at the HTTP
layer before starting the SSE parser.
Event Taxonomy (typed stream)¶
All events are frozen Pydantic models defined in
symfonic.core.contracts.types. The wire format:
| Event | Fires | Payload fields |
|---|---|---|
ActivationEvent |
once per activated memory node, BEFORE text | node_id, node_label, score, layer, source_node_id |
ThinkingDeltaEvent |
during reasoning (extended thinking) | text |
TextDeltaEvent |
each cleaned text chunk from the LLM | text |
ToolCallStartEvent |
LLM decides to call a tool | tool_name, tool_call_id |
ToolResultEvent |
tool returned a result | tool_call_id, result |
MemoryExtractedEvent |
once, after all text deltas | ops: list[dict] |
ConsolidationScheduledEvent |
once, penultimate | report, graph_mutations |
GraphNodeCreatedEvent |
during consolidation (synchronous path only) | node_id, label, layer |
GraphEdgeCreatedEvent |
during consolidation (synchronous path only) | source, target, relationship |
ResponseCompleteEvent |
terminal event on success | text, duration_ms |
error |
terminal event on failure | detail |
Ordering Guarantees¶
For a successful request the first-occurrence order is:
- Zero or more
ActivationEvent - Zero or more
ThinkingDeltaEventinterleaved withTextDeltaEvent - Zero or more
TextDeltaEvent,ToolCallStartEvent,ToolResultEvent - At most one
MemoryExtractedEvent(emitted only when ops were parsed) - Exactly one
ConsolidationScheduledEvent - Zero or more
GraphNodeCreatedEvent/GraphEdgeCreatedEvent(synchronous consolidation path only) - Exactly one
ResponseCompleteEvent
Guarantees enforced by the Gate 4 integration tests in
tests/agent/fastapi/test_sse_event_order.py:
- activation precedes text —
ActivationEvent.first_index < TextDeltaEvent.first_index - memory extraction after text, before consolidation —
TextDeltaEvent.last < MemoryExtractedEvent < ConsolidationScheduledEvent - consolidation is a singleton — exactly one per stream
- stream ends with
ResponseCompleteEventORerror— never both, never neither
Not guaranteed:
- Absolute timing between events.
TextDeltaEvents may arrive with any pacing (typically 5–50ms apart for Anthropic; bursty otherwise). - Graph node/edge events in the Celery path. When consolidation runs on a worker queue, the graph events arrive out-of-band via a separate notification bus and are NOT multiplexed into the same SSE response.
Error Semantics¶
| Scenario | HTTP | Body |
|---|---|---|
Missing Authorization |
401 | JSON {"detail": "..."} |
Missing X-Tenant-ID |
401 | JSON |
| Forged tenant (not a member) | 403 | JSON |
| Budget exceeded | 429 | JSON with Retry-After header |
| Rate limited | 429 | JSON with Retry-After header |
| Runtime failure after stream opens | 200 + event: error |
SSE |
The critical invariant: 4xx errors arrive before the SSE handshake.
Once the server has written text/event-stream headers, it will only
close the stream by emitting event: error\ndata: {...}\n\n.
Client Consumption¶
JavaScript (EventSource / fetch)¶
const resp = await fetch('/api/v1/stream/typed', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'X-Tenant-ID': tenantId,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, tenant_id: tenantId }),
});
if (resp.status !== 200) {
// 4xx/5xx — parse JSON body, surface to user
const err = await resp.json();
throw new Error(err.detail);
}
const reader = resp.body.getReader();
// ... parse SSE frames using a proper SSE parser (e.g. `eventsource-parser`).
Python (httpx)¶
async with httpx.AsyncClient() as c:
async with c.stream(
"POST", "/api/v1/stream/typed",
headers={"Authorization": f"Bearer {token}", "X-Tenant-ID": tenant},
json={"query": query, "tenant_id": tenant},
) as r:
if r.status_code != 200:
body = await r.aread()
raise RuntimeError(body)
async for line in r.aiter_lines():
... # parse SSE frames
A wire-compliant Python parser ships with the test suite at
tests/agent/fastapi/sse_test_harness.py (parse_sse_stream) and can
be reused outside tests.
Known Gaps (as of v5.5.0)¶
[semantic]/[episodic]/[procedural]citation tags may appear in mid-streamTextDeltaEventpayloads. They are stripped fromResponseCompleteEvent.textand from/chatresponses but not from intermediate deltas. A follow-upCitationFilter(parallel to the existingExtractionFilter) will fix this.Last-Event-IDreconnect is NOT supported. Every POST starts a new stream from the beginning.