Streaming Executor
On this page
Bridges arbitrary streaming RAG systems / handlers with a router pipeline. Provides StreamingRouterAdapter (normalizes any streaming-capable object into a uniform async token generator) and RouterStreamExecutor (a drop-in streaming extension for "Router-like" objects, with an event-based streaming API on top of plain token streaming).
[!NOTE] Designed for a broader family of routers, not exclusively HierarchicalRouter This module's docstrings and duck-typed
_get_handlerlogic (checking for.route()or.process(), then unwrapping.handler/.best.handler/ dict"handler"keys) suggest it was designed to sit in front of a general "Router-like" interface, not specificallyHierarchicalRouter. See the compatibility warning underRouterStreamExecutor._get_handlerbelow before wiring this directly toHierarchicalRouter.route().
StreamEventType
class StreamEventType:
ROUTE_SELECTED = "route_selected"
RETRIEVAL_DONE = "retrieval_done"
TOKEN = "token"
DONE = "done"
ERROR = "error"
Plain string constants (not an Enum) used as RouterStreamEvent.type values.
[!NOTE] RETRIEVAL_DONE is declared but never emitted
StreamEventType.RETRIEVAL_DONEis defined but not observed to be used anywhere else in the analyzed source —RouterStreamExecutoronly ever yieldsROUTE_SELECTED,TOKEN,DONE, andERRORevents. It appears reserved for future use or for RAG systems that report retrieval progress through some other integration point not present in this module.
RouterStreamEvent
@dataclass
class RouterStreamEvent:
type: str
data: Any
latency: float = 0.0
Typed event yielded by RouterStreamExecutor.astream_events / stream_events.
Fields
Properties
is_token -> bool
Returns self.type == StreamEventType.TOKEN.
Magic methods
__str__(self) -> str
Returns self.data if is_token, else "" — lets you do str(event) to get just the text content, silently discarding metadata events. Note this only works cleanly for TOKEN events; for non-token events it returns an empty string rather than a string representation of the metadata.
Related APIs: RouterStreamExecutor.astream_events
StreamingRouterAdapter
class StreamingRouterAdapter:
def __init__(self, rag_system: Any, route_name: str = "default", **llm_kwargs: Any): ...
Wraps an arbitrary RAG system or handler object so its output can be consumed as a uniform async generator of string tokens, regardless of which streaming/generation method the underlying object actually exposes.
Constructor
Methods
astream(self, query: str, **kw: Any) -> AsyncIterator[str] (async generator)
Tries the wrapped object's methods in this priority order, using the first one that exists and produces output:
astream(query, **merged)— if it returns a native async generator, yield each token viastr(token). If it returns a coroutine instead,awaitit; if the awaited result is itself an async generator, iterate that; otherwise yield the awaited result as a singlestr(...)chunk.agenerate(query, **merged)— awaited to get a full string, then split on whitespace and yielded word-by-word (each with a trailing space appended), withawait asyncio.sleep(0)between words to yield control back to the event loop. This simulates streaming over a non-streaming async method.stream(query, **merged)— a synchronous generator, iterated directly (blocking the event loop per-iteration, mitigated only by anasyncio.sleep(0)yield-point after each token — this is not offloaded to a thread, unlike thegeneratefallback below).generate(query, **merged)— a synchronous, non-streaming method, run viaawait asyncio.to_thread(rag.generate, query, **merged)(so it doesn't block the event loop), then split into words and yielded the same way asagenerate.
Raises: RuntimeError — if none of the four methods exist on rag_system (f"StreamingRouterAdapter: RAG system '{type(rag).__name__}' has no stream/generate method.").
[!WARNING] Priority-3
stream()is not thread-offloaded Unlike the priority-4generate()fallback (which usesasyncio.to_thread), the priority-3 synchronousstream()path iterates the generator directly on the event loop thread, with only a bareasyncio.sleep(0)between tokens. If the wrapped object'sstream()implementation does blocking I/O per-token (e.g. a blocking HTTP streaming client), this can stall the event loop despite the cooperative yield points.
Word-splitting fallback loses original whitespace/punctuation spacing: the agenerate/generate fallback paths reconstruct "streaming" by str(text).split() + " " — multi-space runs, newlines, and other whitespace in the original text are collapsed to single spaces in the yielded output.
Examples
1. Basic — wrapping a system with native async streaming:
class MyRAG:
async def astream(self, query, **kw):
for word in ["Hello", "from", "RAG"]:
yield word + " "
adapter = StreamingRouterAdapter(MyRAG())
async for token in adapter.astream("hi"):
print(token, end="")
2. Wrapping a synchronous, non-streaming system:
class SimpleRAG:
def generate(self, query, **kw):
return "The answer is 42."
adapter = StreamingRouterAdapter(SimpleRAG())
async for token in adapter.astream("what is the answer?"):
print(token, end="") # "The " "answer " "is " "42. "
3. Error handling — unsupported object:
adapter = StreamingRouterAdapter(object())
try:
async for _ in adapter.astream("test"):
pass
except RuntimeError as e:
print(e)
Related APIs: RouterStreamExecutor
RouterStreamExecutor
class RouterStreamExecutor:
def __init__(self, router: Any, fallback_rag: Optional[Any] = None, default_top_k: int = 1): ...
Drop-in streaming extension for any "Router-like" object — calls the router to select a handler, then streams tokens from it, optionally emitting structured progress events along the way.
Constructor
Internal handler resolution
_get_handler(self, query: str) -> Optional[Any]
Algorithm:
- If
self.routerhas arouteattribute, callself.router.route(query); elif it hasprocess, callself.router.process(query); else returnself.fallback_ragdirectly. - If the result is
None, returnself.fallback_rag. - If the result is a
dict, returnresult.get("handler") or self.fallback_rag. - Elif the result has a
.handlerattribute, returnresult.handler. - Elif the result has a
.bestattribute, returngetattr(result.best, "handler", self.fallback_rag). - Else return
self.fallback_rag. - Any exception during the above is caught, logged as a warning (
"RouterStreamExecutor: routing failed (%s) — using fallback."), andself.fallback_ragis returned.
[!WARNING] HierarchicalRouter.route() does not expose
.handleror.bestHierarchicalRouter.route(request)returns aRoutingResult, whose attributes areresponse,trace,matched(plus propertiescontent,success,route_name,group_name,confidence,total_time_ms) — it has neither a.handlerattribute nor a.bestattribute in the analyzed source ofcore/result.py. Additionally,HierarchicalRouter.route(request)expects aHandlerRequest, not a raw query string, while_get_handlercallsself.router.route(query)with a raw string. Based on static analysis alone, wiringRouterStreamExecutordirectly to aHierarchicalRouterinstance would callroute(query)with the wrong argument type and, even if that were fixed, would not find a.handler/.bestattribute on the returnedRoutingResult— it would silently fall through toself.fallback_rag(orNoneif no fallback was set) on every call, calling_get_handler'sexcept Exceptionbranch to catch theTypeErrorfrom the argument mismatch. If you want to driveRouterStreamExecutorfrom aHierarchicalRouter, consider wrapping it in a thin adapter object exposing aroute(query) -> object with .handlerinterface, or pass a pre-builtStreamingRouterAdapterasfallback_ragand rely on that instead. This is stated as an observation from static analysis, not a claim about the module authors' intent.
Async streaming methods
astream(self, query: str, **kw: Any) -> AsyncIterator[str]
Resolves a handler via _get_handler. If None, logs a warning and yields the single string "⚠️ No handler available for this query." then returns. Otherwise delegates token-by-token to _astream_from_handler.
_astream_from_handler(self, handler, query, **kw) -> AsyncIterator[str] (internal)
If handler is already a StreamingRouterAdapter, stream from it directly. Otherwise, wrap it in a new StreamingRouterAdapter(handler) on the fly and stream from that — meaning any object satisfying StreamingRouterAdapter's duck-typed method requirements (astream/agenerate/stream/generate) can be returned by your router without pre-wrapping.
astream_events(self, query: str, **kw: Any) -> AsyncIterator[RouterStreamEvent]
Richer streaming that emits a full event sequence:
- Resolve the handler; immediately yield a
ROUTE_SELECTEDevent withdata={"route": route_name, "query_preview": query[:60]}, whereroute_nameisgetattr(handler, "route_name", type(handler).__name__ if handler else "none"). - If
handler is None, yield anERRORevent ("No handler found") and return — no further events. - Otherwise, stream tokens via
_astream_from_handler, yielding oneTOKENevent per token. If streaming raises, log it, yield oneERRORevent with the exception message, and return (noDONEevent follows an error). - On successful completion of the token stream, yield a final
DONEevent withdata={"route": route_name, "total_latency": ...}.
Every event's latency field is round(time.perf_counter() - t0, 4) measured from the start of the call.
Synchronous convenience methods
stream(self, query: str, **kw: Any) -> Iterator[str]
Runs astream to completion via asyncio.run(...), collecting all tokens into a list first, then yields them from that list — this is not truly incremental for the caller; all tokens are generated before the first one is yielded back to synchronous code.
[!WARNING]
asyncio.runcannot be called from inside a running event loop Per the class docstring: callingstream()(orstream_events()/generate()) from code already running inside an active asyncio event loop will raise aRuntimeError(asyncio.run() cannot be called from a running event loop). Useastream/astream_events/ageneratedirectly in async contexts instead.
stream_events(self, query: str, **kw: Any) -> Iterator[RouterStreamEvent]
Synchronous variant of astream_events, with the same "collect everything via asyncio.run, then yield from a list" pattern and the same running-event-loop caveat.
Convenience: full-answer generation
agenerate(self, query: str, **kw: Any) -> str (async)
Streams via astream and concatenates all yielded tokens ("".join(parts)) into a single string.
generate(self, query: str, **kw: Any) -> str
Synchronous variant: asyncio.run(self.agenerate(query, **kw)).
Examples
1. Basic — using RouterStreamExecutor with a pre-built StreamingRouterAdapter as the router surface:
from zerda.router.execution.streaming_executor import RouterStreamExecutor, StreamingRouterAdapter
class SimpleRouter:
"""Minimal object satisfying RouterStreamExecutor's duck-typed interface."""
def __init__(self, adapter):
self._adapter = adapter
def route(self, query):
class _Result:
handler = self._adapter
return _Result()
adapter = StreamingRouterAdapter(my_rag_system)
executor = RouterStreamExecutor(SimpleRouter(adapter))
for token in executor.stream("Explain RAG"):
print(token, end="", flush=True)
2. Async token streaming:
async for token in executor.astream("Explain RAG"):
print(token, end="", flush=True)
3. Event-based streaming for a UI:
async for event in executor.astream_events("Explain RAG"):
if event.type == "route_selected":
print(f"[routed to {event.data['route']}]")
elif event.is_token:
print(event.data, end="", flush=True)
elif event.type == "done":
print(f"\n[done in {event.data['total_latency']}s]")
elif event.type == "error":
print(f"\n[error: {event.data['message']}]")
4. Error handling — no handler resolvable:
executor = RouterStreamExecutor(router=object()) # no route()/process()
for token in executor.stream("anything"):
print(token) # "⚠️ No handler available for this query."
5. Full-answer, non-streaming consumption:
answer = executor.generate("Summarize the document")
print(answer)
Best practices
- Confirm your router object's
route()/process()return shape matches one of_get_handler's unwrapping cases (dict["handler"],.handler,.best.handler) before relying onRouterStreamExecutor— see theHierarchicalRoutercompatibility warning above. - Prefer
astream_eventsoverastreamwhenever the UI needs to show routing state (which route was picked) before or during token output. - Always use the
astream*/agenerateasync methods when already inside an event loop (e.g. inside a FastAPI async endpoint); reservestream/stream_events/generatefor purely synchronous call sites.
Related APIs: StreamingRouterAdapter, HierarchicalRouter.stream/stream_async