feat(langgraph): add streaming transformer infrastructure and tests

Introduces the StreamingHandler, StreamMux, EventLog, StreamChannel,
and StreamTransformer abstractions for ergonomic streaming projections
over compiled graphs. Includes ValuesTransformer and MessagesTransformer
as built-in native projections, plus support for user-defined custom
transformers.
This commit is contained in:
Nick Hollon
2026-04-15 18:12:13 -04:00
parent 7fa49bd550
commit 0076da9008
10 changed files with 1877 additions and 0 deletions
@@ -0,0 +1,21 @@
"""Streaming infrastructure for LangGraph.
Provides a ``StreamingHandler`` that wraps a compiled graph and exposes
ergonomic streaming projections through a transformer pipeline.
"""
from langgraph.stream._event_log import EventLog
from langgraph.stream._types import ProtocolEvent, StreamTransformer
from langgraph.stream.run_stream import AsyncGraphRunStream, GraphRunStream
from langgraph.stream.stream_channel import StreamChannel
from langgraph.stream.streaming_handler import StreamingHandler
__all__ = [
"AsyncGraphRunStream",
"EventLog",
"GraphRunStream",
"ProtocolEvent",
"StreamChannel",
"StreamTransformer",
"StreamingHandler",
]
@@ -0,0 +1,24 @@
from __future__ import annotations
from typing import Any
from langgraph.stream._types import ProtocolEvent, _ProtocolEventParams
def convert_to_protocol_event(part: dict[str, Any]) -> ProtocolEvent:
"""Convert a v2 StreamPart dict to a ProtocolEvent.
Expects a dict with keys ``type``, ``ns``, ``data``, and optionally
``interrupts`` (present on values events).
"""
params: _ProtocolEventParams = {
"namespace": list(part["ns"]),
"data": part["data"],
}
if "interrupts" in part:
params["interrupts"] = part["interrupts"]
return {
"type": "event",
"method": part["type"],
"params": params,
}
@@ -0,0 +1,138 @@
from __future__ import annotations
import asyncio
import threading
from collections.abc import AsyncIterator, Iterator
from typing import Generic, TypeVar
T = TypeVar("T")
class EventLog(Generic[T]):
"""Append-only buffer with multi-cursor sync and async iteration.
Supports multiple independent consumers iterating the same log
concurrently. Each call to ``__iter__`` or ``__aiter__`` creates a
new cursor starting from the beginning.
A given instance should be used in either sync or async mode — the
two notification paths are independent and do not interfere, but
mixing them on one instance is not tested.
Producer API (thread-safe):
push(item) — append an item, notify all waiting cursors
close() — mark the log as done
fail(err) — mark the log as errored
Consumer API:
__iter__() — new sync cursor (blocks via threading.Condition)
__aiter__() — new async cursor (awaits via asyncio.Future)
"""
def __init__(self) -> None:
self._items: list[T] = []
self._closed = False
self._error: BaseException | None = None
# Sync notification
self._lock = threading.Lock()
self._cond = threading.Condition(self._lock)
# Async notification — futures created lazily by async cursors
self._async_waiters: list[asyncio.Future[None]] = []
# ------------------------------------------------------------------
# Producer API
# ------------------------------------------------------------------
def push(self, item: T) -> None:
"""Append *item* and wake all waiting cursors."""
with self._lock:
if self._closed:
raise RuntimeError("Cannot push to a closed EventLog")
self._items.append(item)
self._cond.notify_all()
self._wake_async()
def close(self) -> None:
"""Mark the log as complete — open cursors will finish cleanly."""
with self._lock:
self._closed = True
self._cond.notify_all()
self._wake_async()
def fail(self, err: BaseException) -> None:
"""Mark the log as errored — open cursors will raise *err*."""
with self._lock:
self._error = err
self._closed = True
self._cond.notify_all()
self._wake_async()
# ------------------------------------------------------------------
# Sync iteration
# ------------------------------------------------------------------
def __iter__(self) -> Iterator[T]:
"""Return a new independent sync cursor over the log."""
return self._sync_cursor()
def _sync_cursor(self) -> Iterator[T]:
cursor = 0
while True:
with self._lock:
# Wait until data is available or the log is done.
while cursor >= len(self._items) and not self._closed:
self._cond.wait()
# Yield available items before raising errors, matching
# the async cursor's behavior.
if cursor < len(self._items):
item = self._items[cursor]
cursor += 1
elif self._error is not None:
raise self._error
else:
# closed and no more items
return
yield item
# ------------------------------------------------------------------
# Async iteration
# ------------------------------------------------------------------
def __aiter__(self) -> AsyncIterator[T]:
"""Return a new independent async cursor over the log."""
return self._async_cursor()
async def _async_cursor(self) -> AsyncIterator[T]:
cursor = 0
while True:
if cursor < len(self._items):
yield self._items[cursor]
cursor += 1
elif self._closed:
if self._error is not None:
raise self._error
return
else:
# Wait for notification from push/close/fail.
loop = asyncio.get_running_loop()
fut: asyncio.Future[None] = loop.create_future()
self._async_waiters.append(fut)
await fut
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _wake_async(self) -> None:
"""Resolve all pending async futures (safe from any thread)."""
waiters = self._async_waiters
if not waiters:
return
self._async_waiters = []
for fut in waiters:
if not fut.done():
try:
fut.get_loop().call_soon_threadsafe(fut.set_result, None)
except RuntimeError:
# Event loop already closed — nothing to notify.
pass
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from langgraph.stream._event_log import EventLog
from langgraph.stream._types import ProtocolEvent, StreamTransformer
from langgraph.stream.stream_channel import StreamChannel
class StreamMux:
"""Central event dispatcher for the streaming infrastructure.
Owns the main ``EventLog[ProtocolEvent]`` and routes events through
a transformer pipeline. StreamChannels discovered in transformer
projections are auto-wired so that every ``push()`` also injects a
``ProtocolEvent`` into the main log.
"""
def __init__(self) -> None:
self._events: EventLog[ProtocolEvent] = EventLog()
self._transformers: list[StreamTransformer] = []
self._channels: list[StreamChannel[Any]] = []
self._seq = 0
def register(self, transformer: StreamTransformer) -> dict[str, Any]:
"""Register a transformer and return its projection dict.
Calls ``transformer.init()``, stores the transformer for event
processing, and returns the projection. StreamChannels in the
projection are auto-wired.
"""
projection = transformer.init()
if not isinstance(projection, dict):
raise TypeError(
f"StreamTransformer.init() must return a dict, "
f"got {type(projection).__name__}"
)
self._transformers.append(transformer)
self._wire_channels(projection)
return projection
def push(self, event: ProtocolEvent) -> None:
"""Route *event* through all transformers, then append to the main log.
Each transformer's ``process()`` is called in registration order.
If any transformer returns ``False``, the event is suppressed
from the main log (but transformers that already saw it keep
their side-effects).
Seq is assigned right before an event enters the main log, not
before the transformer pipeline runs. This ensures that events
auto-forwarded from StreamChannels during ``process()`` get
earlier seq numbers than the original event, preserving
monotonic ordering in the log.
"""
keep = True
for transformer in self._transformers:
if not transformer.process(event):
keep = False
if keep:
self._seq += 1
event["seq"] = self._seq
self._events.push(event)
def close(self) -> None:
"""Finalize all transformers, close all channels and the main log.
If any transformer's ``finalize()`` raises, the remaining
transformers, channels, and the main log are still closed.
The first error is re-raised after cleanup completes.
"""
first_error: BaseException | None = None
for transformer in self._transformers:
try:
transformer.finalize()
except BaseException as e:
if first_error is None:
first_error = e
for ch in self._channels:
ch._close()
self._events.close()
if first_error is not None:
raise first_error
def fail(self, err: BaseException) -> None:
"""Fail all transformers, channels, and the main log.
If any transformer's ``fail()`` raises, the remaining
transformers, channels, and the main log are still failed.
"""
for transformer in self._transformers:
try:
transformer.fail(err)
except BaseException:
pass
for ch in self._channels:
ch._fail(err)
self._events.fail(err)
# ------------------------------------------------------------------
# StreamChannel auto-wiring
# ------------------------------------------------------------------
def _wire_channels(self, projection: dict[str, Any]) -> None:
"""Find StreamChannel instances in *projection* and wire them."""
for value in projection.values():
if isinstance(value, StreamChannel):
self._channels.append(value)
channel_name = value.name
def _make_forward(name: str) -> Callable[[Any], None]:
def _forward(item: Any) -> None:
self._forward(name, item)
return _forward
value._wire(_make_forward(channel_name))
def _forward(self, channel_name: str, item: Any) -> None:
"""Inject a ProtocolEvent for a StreamChannel push.
Forwarded events bypass the transformer pipeline to avoid
infinite recursion (a transformer that pushes to a channel
during ``process()`` would re-trigger itself). These events
are visible in the main event log but are not passed through
transformers' ``process()`` methods.
"""
self._seq += 1
event: ProtocolEvent = {
"type": "event",
"seq": self._seq,
"method": f"custom:{channel_name}",
"params": {
"namespace": [],
"data": item,
},
}
self._events.push(event)
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Literal
from typing_extensions import NotRequired, TypedDict
class _ProtocolEventParams(TypedDict):
"""Parameters for a protocol event."""
namespace: list[str]
data: Any
interrupts: NotRequired[tuple[Any, ...]]
class ProtocolEvent(TypedDict):
"""A protocol event emitted by the streaming infrastructure.
Wraps a raw stream part (values, messages, custom, etc.) in a uniform
envelope with a monotonic sequence number assigned by the StreamMux.
"""
type: Literal["event"]
seq: NotRequired[int]
method: str # StreamMode value: "values", "messages", "custom", etc.
params: _ProtocolEventParams
class StreamTransformer(ABC):
"""Extension point for custom stream projections.
Transformers observe protocol events flowing through the StreamMux and
build typed derived projections (EventLogs, StreamChannels, promises, etc.).
Set `_native = True` on a transformer to have its projection keys
exposed as direct attributes on the run stream (in addition to
appearing in `run.extensions`).
Subclasses must implement `init` and `process`. The `finalize` and
`fail` hooks are optional — the default implementations are no-ops.
StreamChannel instances are auto-closed/failed by the mux regardless.
"""
@abstractmethod
def init(self) -> Any:
"""Return the projection dict.
Keys become entries in `run.extensions`. If the transformer has
`_native = True`, keys are also set as direct attributes on the
run stream.
StreamChannel instances in the return value are automatically
wired by the StreamMux for protocol event auto-forwarding.
"""
...
@abstractmethod
def process(self, event: ProtocolEvent) -> bool:
"""Process a protocol event.
Called for every event before it is appended to the main event log.
Return False to suppress the event from the main log.
"""
...
def finalize(self) -> None:
"""Called when the run ends normally.
Override to close EventLogs, resolve promises, or perform other
teardown. StreamChannel instances are auto-closed by the mux.
"""
def fail(self, err: BaseException) -> None:
"""Called when the run ends with an error.
Override to fail EventLogs, reject promises, or perform other
teardown. StreamChannel instances are auto-failed by the mux.
"""
@@ -0,0 +1,119 @@
from __future__ import annotations
import asyncio
import threading
from collections.abc import AsyncIterator, Iterator
from typing import Any
from langgraph.stream._mux import StreamMux
from langgraph.stream._types import ProtocolEvent
from langgraph.stream.transformers import ValuesTransformer
class GraphRunStream:
"""Sync run stream with transformer-driven projections.
All transformer projections live in ``extensions``. Native transformer
projections (those with ``_native = True``) are also set as direct
attributes on this instance (e.g. ``run.values``, ``run.messages``).
Iterating the run stream directly yields raw ``ProtocolEvent`` objects
from the mux's main event log.
"""
def __init__(
self,
mux: StreamMux,
extensions: dict[str, Any],
values_transformer: ValuesTransformer,
pump_thread: threading.Thread,
) -> None:
self._mux = mux
self.extensions = extensions
self._values_transformer = values_transformer
self._pump_thread = pump_thread
@property
def output(self) -> dict[str, Any] | None:
"""Block until the run completes and return the final state."""
self._pump_thread.join()
if self._values_transformer._log._error is not None:
raise self._values_transformer._log._error
return self._values_transformer._latest
@property
def interrupted(self) -> bool:
"""Block until the run completes, then return whether it was interrupted."""
self._pump_thread.join()
return self._values_transformer._interrupted
@property
def interrupts(self) -> list[Any]:
"""Block until the run completes, then return interrupt payloads."""
self._pump_thread.join()
return self._values_transformer._interrupts
def __iter__(self) -> Iterator[ProtocolEvent]:
"""Iterate all protocol events from the mux's main event log."""
return iter(self._mux._events)
class AsyncGraphRunStream:
"""Async run stream with transformer-driven projections.
All transformer projections live in ``extensions``. Native transformer
projections (those with ``_native = True``) are also set as direct
attributes on this instance (e.g. ``run.values``, ``run.messages``).
Async-iterating the run stream yields raw ``ProtocolEvent`` objects
from the mux's main event log.
"""
def __init__(
self,
mux: StreamMux,
extensions: dict[str, Any],
values_transformer: ValuesTransformer,
pump_task: asyncio.Task[None],
) -> None:
self._mux = mux
self.extensions = extensions
self._values_transformer = values_transformer
self._pump_task = pump_task
@property
def output(self) -> Any:
"""Return an awaitable that resolves to the final state.
Usage::
output = await run.output
"""
return self._get_output()
async def _get_output(self) -> dict[str, Any] | None:
try:
await self._pump_task
except BaseException:
pass
if self._values_transformer._log._error is not None:
raise self._values_transformer._log._error
return self._values_transformer._latest
@property
def interrupted(self) -> bool:
"""Whether the run was interrupted.
Only meaningful after the run has completed (after consuming the
stream or awaiting ``output``).
"""
return self._values_transformer._interrupted
@property
def interrupts(self) -> list[Any]:
"""Interrupt payloads, populated when interrupted is True."""
return self._values_transformer._interrupts
def __aiter__(self) -> AsyncIterator[ProtocolEvent]:
"""Iterate all protocol events from the mux's main event log."""
return self._mux._events.__aiter__()
@@ -0,0 +1,63 @@
from __future__ import annotations
from collections.abc import AsyncIterator, Callable, Iterator
from typing import Generic, TypeVar
from langgraph.stream._event_log import EventLog
T = TypeVar("T")
class StreamChannel(Generic[T]):
"""A named projection channel with optional protocol auto-forwarding.
Wraps an `EventLog[T]` and declares a protocol channel name. When the
`StreamMux` detects a `StreamChannel` in a transformer's ``init()``
return value, it automatically wires every ``push()`` to inject a
`ProtocolEvent` into the main event stream using the channel's name
as the ``method``.
In-process consumers iterate the channel directly (``for item in ch``
or ``async for item in ch``). Remote SDK clients subscribe via
``session.subscribe("custom:<channelName>")``.
Lifecycle (``_close`` / ``_fail``) is managed by the mux — transformers
using only StreamChannels don't need ``finalize`` / ``fail`` hooks.
"""
def __init__(self, name: str) -> None:
self.name = name
self._log: EventLog[T] = EventLog()
self._wire_fn: Callable[[T], None] | None = None
def push(self, item: T) -> None:
"""Append *item* to the log and auto-forward if wired."""
self._log.push(item)
if self._wire_fn is not None:
self._wire_fn(item)
# ------------------------------------------------------------------
# Mux lifecycle hooks (not called by transformers directly)
# ------------------------------------------------------------------
def _wire(self, fn: Callable[[T], None]) -> None:
"""Install the auto-forward callback (called by StreamMux)."""
self._wire_fn = fn
def _close(self) -> None:
"""Close the underlying log (called by StreamMux on run end)."""
self._log.close()
def _fail(self, err: BaseException) -> None:
"""Fail the underlying log (called by StreamMux on run error)."""
self._log.fail(err)
# ------------------------------------------------------------------
# Iteration — delegates to the inner EventLog (multi-cursor)
# ------------------------------------------------------------------
def __iter__(self) -> Iterator[T]:
return iter(self._log)
def __aiter__(self) -> AsyncIterator[T]:
return self._log.__aiter__()
@@ -0,0 +1,161 @@
from __future__ import annotations
import asyncio
import threading
from collections.abc import Sequence
from typing import Any
from langchain_core.runnables import RunnableConfig
from langgraph.stream._convert import convert_to_protocol_event
from langgraph.stream._mux import StreamMux
from langgraph.stream._types import StreamTransformer
from langgraph.stream.run_stream import AsyncGraphRunStream, GraphRunStream
from langgraph.stream.transformers import MessagesTransformer, ValuesTransformer
from langgraph.types import All, StreamMode
# All stream modes to request from the graph.
STREAM_V2_MODES: list[StreamMode] = [
"values",
"updates",
"messages",
"custom",
"checkpoints",
"tasks",
"debug",
]
class StreamingHandler:
"""Wraps a compiled graph and provides ergonomic streaming projections.
Usage::
handler = StreamingHandler(graph)
# Sync
run = handler.stream(input_data)
for state in run.values:
print(state)
output = run.output
# Async
run = await handler.astream(input_data)
async for state in run.values:
print(state)
output = await run.output
"""
def __init__(self, graph: Any) -> None:
self._graph = graph
def stream(
self,
input: Any,
config: RunnableConfig | None = None,
*,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
transformers: list[StreamTransformer] | None = None,
) -> GraphRunStream:
"""Start a sync streaming run.
Returns a `GraphRunStream` immediately. A background daemon thread
pumps events from the graph into the transformer pipeline.
"""
mux, extensions, native_keys, values_t = self._setup(transformers)
def pump() -> None:
try:
for part in self._graph.stream(
input,
config,
stream_mode=STREAM_V2_MODES,
subgraphs=True,
version="v2",
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
):
mux.push(convert_to_protocol_event(part))
mux.close()
except BaseException as e:
mux.fail(e)
thread = threading.Thread(target=pump, daemon=True)
thread.start()
run = GraphRunStream(mux, extensions, values_t, thread)
for key in native_keys:
setattr(run, key, extensions[key])
return run
async def astream(
self,
input: Any,
config: RunnableConfig | None = None,
*,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
transformers: list[StreamTransformer] | None = None,
) -> AsyncGraphRunStream:
"""Start an async streaming run.
Returns an `AsyncGraphRunStream` immediately. A background asyncio
task pumps events from the graph into the transformer pipeline.
"""
mux, extensions, native_keys, values_t = self._setup(transformers)
async def pump() -> None:
try:
async for part in self._graph.astream(
input,
config,
stream_mode=STREAM_V2_MODES,
subgraphs=True,
version="v2",
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
):
mux.push(convert_to_protocol_event(part))
mux.close()
except BaseException as e:
mux.fail(e)
task = asyncio.create_task(pump())
run = AsyncGraphRunStream(mux, extensions, values_t, task)
for key in native_keys:
setattr(run, key, extensions[key])
return run
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
@staticmethod
def _setup(
user_transformers: list[StreamTransformer] | None,
) -> tuple[StreamMux, dict[str, Any], set[str], ValuesTransformer]:
"""Create the mux, register all transformers.
Returns (mux, extensions, native_keys, values_transformer).
"""
mux = StreamMux()
values_t = ValuesTransformer()
messages_t = MessagesTransformer()
all_transformers: list[StreamTransformer] = [values_t, messages_t]
if user_transformers:
all_transformers.extend(user_transformers)
extensions: dict[str, Any] = {}
native_keys: set[str] = set()
for t in all_transformers:
projection = mux.register(t)
extensions.update(projection)
if getattr(t, "_native", False):
native_keys.update(projection.keys())
return mux, extensions, native_keys, values_t
@@ -0,0 +1,82 @@
from __future__ import annotations
from typing import Any
from langgraph.stream._event_log import EventLog
from langgraph.stream._types import ProtocolEvent, StreamTransformer
class ValuesTransformer(StreamTransformer):
"""Captures values events and projects them into an iterable of state snapshots.
Native transformer — projection keys are exposed as direct attributes
on the run stream (e.g. ``run.values``).
"""
_native = True
def __init__(self) -> None:
self._log: EventLog[dict[str, Any]] = EventLog()
self._latest: dict[str, Any] | None = None
self._interrupted = False
self._interrupts: list[Any] = []
def init(self) -> dict[str, Any]:
return {"values": self._log}
def process(self, event: ProtocolEvent) -> bool:
if event["method"] != "values":
return True
params = event["params"]
# Only capture root namespace events
if params["namespace"]:
return True
self._latest = params["data"]
self._log.push(params["data"])
interrupts = params.get("interrupts", ())
if interrupts:
self._interrupted = True
self._interrupts.extend(interrupts)
return True
def finalize(self) -> None:
self._log.close()
def fail(self, err: BaseException) -> None:
self._log.fail(err)
class MessagesTransformer(StreamTransformer):
"""Captures messages events and passes through raw (chunk, metadata) tuples.
This is the same shape as today's ``stream_mode="messages"`` output.
A follow-on PR will replace this with a richer transformer that
produces ChatModelStream objects using the protocol handler.
Native transformer — projection keys are exposed as direct attributes
on the run stream (e.g. ``run.messages``).
"""
_native = True
def __init__(self) -> None:
self._log: EventLog[tuple[Any, dict[str, Any]]] = EventLog()
def init(self) -> dict[str, Any]:
return {"messages": self._log}
def process(self, event: ProtocolEvent) -> bool:
if event["method"] != "messages":
return True
params = event["params"]
# Only capture root namespace events
if params["namespace"]:
return True
self._log.push(params["data"])
return True
def finalize(self) -> None:
self._log.close()
def fail(self, err: BaseException) -> None:
self._log.fail(err)
File diff suppressed because it is too large Load Diff