clean up streamV2 infrastructure

Remove unused timestamp from ProtocolEvent params (seq provides
ordering). Fix docstrings: remove async-specific language from sync
base class, correct tautological namespace comment, distinguish
sync/async projection sets in module docstring. Add double-iteration
tests for values and raw events on both sync and async paths.
This commit is contained in:
Nick Hollon
2026-04-14 16:46:19 -04:00
parent 803a268b39
commit 7a4ce06a37
15 changed files with 427 additions and 693 deletions
+2 -3
View File
@@ -1,8 +1,7 @@
"""Stream protocol types and infrastructure for LangGraph."""
from langgraph.stream._convert import STREAM_V2_MODES, convert_to_protocol_event
from langgraph.stream._event_log import EventLog
from langgraph.stream._mux import StreamMux
from langgraph.stream._mux import AsyncStreamMux, StreamMux
from langgraph.stream._types import (
InterruptPayload,
ProtocolEvent,
@@ -27,9 +26,9 @@ __all__ = [
"STREAM_V2_MODES",
"AsyncChatModelStream",
"AsyncGraphRunStream",
"AsyncStreamMux",
"AsyncSubgraphRunStream",
"ChatModelStream",
"EventLog",
"GraphRunStream",
"InterruptPayload",
"MessagesTransformer",
@@ -7,7 +7,6 @@ original payload.
from __future__ import annotations
import time
from typing import Any
from langgraph.stream._types import ProtocolEvent, _ProtocolEventParams
@@ -58,7 +57,6 @@ def convert_to_protocol_event(
params: _ProtocolEventParams = {
"namespace": list(ns),
"timestamp": _now_ms(),
"data": payload,
}
if node is not None:
@@ -71,9 +69,4 @@ def convert_to_protocol_event(
)
def _now_ms() -> int:
"""Current time in milliseconds since epoch."""
return int(time.time() * 1000)
__all__ = ["STREAM_V2_MODES", "convert_to_protocol_event"]
@@ -1,132 +0,0 @@
"""Replayable append-only event buffer for StreamingHandler.
``EventLog`` stores protocol events in an ordered list and supports
multiple independent async iterators, each with their own cursor
offset. Subscribers that join mid-stream replay from a given offset
without losing earlier events.
"""
from __future__ import annotations
import asyncio
import threading
from typing import Generic, TypeVar
T = TypeVar("T")
class EventLog(Generic[T]):
"""Append-only event buffer with cursor-based async iteration.
Multiple consumers can subscribe independently and each will see
every event from their starting offset onward.
"""
__slots__ = ("_items", "_closed", "_error", "_waiters", "_lock")
def __init__(self) -> None:
self._items: list[T] = []
self._closed = False
self._error: BaseException | None = None
self._waiters: list[asyncio.Future[None]] = []
self._lock = threading.Lock()
# -- Producer API -------------------------------------------------------
def append(self, item: T) -> None:
"""Append an event and wake all waiting consumers."""
with self._lock:
if self._closed:
raise RuntimeError("EventLog is closed")
self._items.append(item)
self._wake_all()
def close(self) -> None:
"""Mark the log as complete. Iterators will end gracefully."""
with self._lock:
self._closed = True
self._wake_all()
def fail(self, error: BaseException) -> None:
"""Mark the log as failed. Iterators will raise *error*."""
with self._lock:
self._error = error
self._closed = True
self._wake_all()
# -- Consumer API -------------------------------------------------------
def subscribe(self, offset: int = 0) -> _Cursor[T]:
"""Create a new cursor starting at *offset*.
If *offset* is 0 the cursor replays the entire log. If
*offset* equals ``len(self)`` the cursor starts from the
current tip and only sees future events.
"""
return _Cursor(self, offset)
# -- Inspection ---------------------------------------------------------
def __len__(self) -> int:
return len(self._items)
def __getitem__(self, index: int) -> T:
return self._items[index]
@property
def closed(self) -> bool:
return self._closed
# -- Internal -----------------------------------------------------------
def _wake_all(self) -> None:
for fut in self._waiters:
if not fut.done():
try:
fut.get_loop().call_soon_threadsafe(fut.set_result, None)
except RuntimeError:
# Loop already closed — ignore.
pass
self._waiters.clear()
class _Cursor(Generic[T]):
"""An independent async iterator over an :class:`EventLog`."""
__slots__ = ("_log", "_offset")
def __init__(self, log: EventLog[T], offset: int) -> None:
self._log = log
self._offset = offset
def __aiter__(self) -> _Cursor[T]:
return self
async def __anext__(self) -> T:
while True:
with self._log._lock:
if self._offset < len(self._log._items):
item = self._log._items[self._offset]
self._offset += 1
return item
if self._log._error is not None:
raise self._log._error
if self._log._closed:
raise StopAsyncIteration
# Nothing available yet — register a waiter
loop = asyncio.get_running_loop()
fut: asyncio.Future[None] = loop.create_future()
self._log._waiters.append(fut)
# Wait outside the lock
try:
await fut
except asyncio.CancelledError:
# Remove our future so it doesn't accumulate in the list.
try:
self._log._waiters.remove(fut)
except ValueError:
pass # Already removed by _wake_all
raise
__all__ = ["EventLog"]
+180 -160
View File
@@ -1,33 +1,36 @@
"""Central event dispatcher with transformer pipeline for StreamingHandler.
``StreamMux`` is the core coordination point: it holds the main
:class:`EventLog`, tracks discovered namespaces for subgraph stream
``StreamMux`` is the synchronous core: it holds the main
event log (a plain list), tracks discovered namespaces for subgraph stream
creation, and pipes every event through the registered
:class:`StreamTransformer` pipeline before appending it to the log.
``AsyncStreamMux`` extends ``StreamMux`` with async consumer APIs
(output futures, async event subscriptions, subgraph discovery).
"""
from __future__ import annotations
import asyncio
import time
from collections.abc import AsyncIterator
from typing import Any
from langgraph.stream._event_log import EventLog
from langgraph.stream._types import InterruptPayload, ProtocolEvent, StreamTransformer
from langgraph.stream.stream_channel import StreamChannel, is_stream_channel
class StreamMux:
"""Central event dispatcher for the StreamingHandler infrastructure.
"""Synchronous event dispatcher for the StreamingHandler infrastructure.
The mux owns the main event log, applies the transformer pipeline to
every incoming event, and provides subscription endpoints for
filtered event iteration and subgraph discovery.
every incoming event, and tracks namespace discovery and latest values.
For async consumer APIs (output futures, async event subscriptions,
subgraph discovery) use :class:`AsyncStreamMux`.
"""
def __init__(self, transformers: list[StreamTransformer] | None = None) -> None:
self._event_log: EventLog[ProtocolEvent] = EventLog()
self._event_log: list[ProtocolEvent] = []
self._transformers: list[StreamTransformer] = list(transformers or [])
self._channels: list[StreamChannel[Any]] = []
self._current_namespace: list[str] = []
@@ -35,8 +38,6 @@ class StreamMux:
# Namespace discovery: maps top-level ns segment → True
self._discovered_ns: dict[str, bool] = {}
# Waiters for new namespace discovery
self._ns_waiters: list[asyncio.Future[None]] = []
# Latest values per namespace (list-of-strings key)
self._latest_values: dict[str, Any] = {}
@@ -45,9 +46,6 @@ class StreamMux:
self._interrupts: list[InterruptPayload] = []
self._interrupted = False
# Output promise tracking
self._output_futures: dict[str, asyncio.Future[Any]] = {}
# Closed state
self._closed = False
self._error: BaseException | None = None
@@ -76,7 +74,6 @@ class StreamMux:
top_segment = ns[0]
if top_segment not in self._discovered_ns:
self._discovered_ns[top_segment] = True
self._wake_ns_waiters()
# Track values
if event["method"] == "values":
@@ -113,23 +110,162 @@ class StreamMux:
self._event_log.append(event)
def close(self, output: Any = None) -> None:
"""Close the mux, resolving all output futures."""
"""Close the mux and finalize all transformers."""
if self._closed:
return
self._closed = True
# Finalize transformers (optional method)
# Finalize transformers
for transformer in self._transformers:
if hasattr(transformer, "finalize"):
transformer.finalize()
transformer.finalize()
# Close wired channels
for channel in self._channels:
channel._close()
# Close the event log
self._event_log.close()
def fail(self, error: BaseException) -> None:
"""Fail the mux and propagate the error to all consumers."""
if self._closed:
return
self._closed = True
self._error = error
# Fail transformers
for transformer in self._transformers:
transformer.fail(error)
# Fail wired channels
for channel in self._channels:
channel._fail(error)
# -- Inspection ---------------------------------------------------------
@property
def interrupted(self) -> bool:
return self._interrupted
@property
def interrupts(self) -> list[InterruptPayload]:
return list(self._interrupts)
@property
def event_log(self) -> list[ProtocolEvent]:
return self._event_log
def get_latest_values(self, ns: list[str] | None = None) -> Any:
"""Return the most recent values for a namespace."""
return self._latest_values.get(_ns_key(ns or []))
# -- Internal -----------------------------------------------------------
def register_transformer(self, transformer: StreamTransformer) -> None:
"""Register a new transformer and replay all buffered events through it.
This is the safe way to add a late-arriving transformer after the mux
has already started processing events. The sequence is:
1. Snapshot the current log length.
2. Append the transformer so future ``push()`` calls reach it.
3. Replay events ``[0, snapshot)`` through the transformer.
4. If the mux is already closed, call ``finalize()`` immediately so
the transformer's log/channel terminates cleanly.
No namespace filtering is applied — all buffered events are
replayed. Transformers that need namespace filtering should do
so inside their ``process()`` implementation.
"""
snapshot = len(self._event_log)
self._transformers.append(transformer)
for i in range(snapshot):
transformer.process(self._event_log[i])
if self._closed:
transformer.finalize()
def wire_channels(self, projection: Any) -> None:
"""Scan *projection* for :class:`StreamChannel` instances and wire them.
For each ``StreamChannel`` found, registers a push callback that
appends a :class:`ProtocolEvent` directly to the main event log
with ``method`` set to the channel's name.
Channel events bypass the transformer pipeline (matching the JS
implementation). They are visible to raw event iteration and
remote SDK clients but not to other transformers' ``process()``.
"""
if projection is None:
return
items: dict[str, Any] = {}
if isinstance(projection, dict):
items = projection
elif hasattr(projection, "__dict__"):
items = vars(projection)
for _key, value in items.items():
if is_stream_channel(value):
channel: StreamChannel[Any] = value
self._channels.append(channel)
def _make_forwarder(ch: StreamChannel[Any]) -> Any:
def _forward(item: Any) -> None:
if self._closed:
return
# Append directly to the event log, bypassing
# the transformer pipeline. This matches the JS
# implementation and avoids re-entrancy bugs
# (namespace clobbering, infinite recursion).
self._event_log.append(
ProtocolEvent(
type="event",
seq=self._next_emit_seq,
method=ch.channel_name,
params={
"namespace": list(self._current_namespace),
"data": item,
},
)
)
self._next_emit_seq += 1
return _forward
channel._wire(_make_forwarder(channel))
# ---------------------------------------------------------------------------
# AsyncStreamMux — async consumer APIs on top of the sync core
# ---------------------------------------------------------------------------
class AsyncStreamMux(StreamMux):
"""Async extension of :class:`StreamMux`.
Adds output futures, async event subscriptions, and subgraph
discovery on top of the synchronous producer/transformer core.
"""
def __init__(self, transformers: list[StreamTransformer] | None = None) -> None:
super().__init__(transformers=transformers)
# Notification event — set on every push/close/fail to wake async consumers
self._notify: asyncio.Event = asyncio.Event()
# Waiters for new namespace discovery
self._ns_waiters: list[asyncio.Future[None]] = []
# Output promise tracking
self._output_futures: dict[str, asyncio.Future[Any]] = {}
# -- Producer overrides (extend to resolve async primitives) -------------
def push(self, event: ProtocolEvent) -> None:
# Peek at namespace before super().push() so we can detect new
# discoveries and wake waiters.
ns = event["params"].get("namespace", [])
is_new_ns = bool(ns) and ns[0] not in self._discovered_ns
super().push(event)
if is_new_ns and ns[0] in self._discovered_ns:
self._wake_ns_waiters()
self._notify.set()
def close(self, output: Any = None) -> None:
super().close(output)
self._notify.set()
# Resolve output futures
for ns_key, fut in self._output_futures.items():
if not fut.done():
@@ -138,29 +274,12 @@ class StreamMux:
fut.get_loop().call_soon_threadsafe(fut.set_result, value)
except RuntimeError:
pass
# Wake namespace waiters
self._wake_ns_waiters()
def fail(self, error: BaseException) -> None:
"""Fail the mux, rejecting all output futures."""
if self._closed:
return
self._closed = True
self._error = error
# Fail transformers (optional method)
for transformer in self._transformers:
if hasattr(transformer, "fail"):
transformer.fail(error)
# Fail wired channels
for channel in self._channels:
channel._fail(error)
# Fail the event log
self._event_log.fail(error)
super().fail(error)
self._notify.set()
# Reject output futures
for fut in self._output_futures.values():
if not fut.done():
@@ -168,25 +287,38 @@ class StreamMux:
fut.get_loop().call_soon_threadsafe(fut.set_exception, error)
except RuntimeError:
pass
# Wake namespace waiters
self._wake_ns_waiters()
# -- Consumer API -------------------------------------------------------
# -- Async consumer API -------------------------------------------------
def subscribe_events(
async def subscribe_events(
self, path: list[str] | None = None, offset: int = 0
) -> AsyncIterator[ProtocolEvent]:
"""Return an async iterator over events matching *path*.
"""Async iterate over events matching *path*.
If *path* is ``None`` or empty, all events are yielded.
Otherwise, only events whose namespace starts with *path*
are yielded.
Uses the list + ``asyncio.Event`` notification pattern: poll
the event log, yield what's new, await the notify event for more.
"""
cursor = self._event_log.subscribe(offset)
if not path:
return cursor
return _FilteredEventIterator(cursor, path)
cursor = offset
while True:
while cursor < len(self._event_log):
event = self._event_log[cursor]
cursor += 1
if not path or _ns_starts_with(
event["params"].get("namespace", []), path
):
yield event
if self._closed:
if self._error is not None:
raise self._error
return
self._notify.clear()
await self._notify.wait()
async def subscribe_subgraphs(
self, path: list[str] | None = None, offset: int = 0
@@ -238,101 +370,8 @@ class StreamMux:
return self._output_futures[ns_key]
# -- Inspection ---------------------------------------------------------
@property
def interrupted(self) -> bool:
return self._interrupted
@property
def interrupts(self) -> list[InterruptPayload]:
return list(self._interrupts)
@property
def event_log(self) -> EventLog[ProtocolEvent]:
return self._event_log
def get_latest_values(self, ns: list[str] | None = None) -> Any:
"""Return the most recent values for a namespace."""
return self._latest_values.get(_ns_key(ns or []))
# -- Internal -----------------------------------------------------------
def register_transformer(self, transformer: StreamTransformer) -> None:
"""Register a new transformer and replay all buffered events through it.
This is the safe way to add a late-arriving transformer after the mux
has already started processing events. The sequence is:
1. Snapshot the current log length (no await → no gap possible in
asyncio's cooperative threading model).
2. Append the transformer so future ``push()`` calls reach it.
3. Replay events ``[0, snapshot)`` through the transformer.
4. If the mux is already closed, call ``finalize()`` immediately so
the transformer's log/channel terminates cleanly.
``process()`` is only called for events whose namespace starts with
any prefix — callers that need namespace filtering should do so inside
their ``process()`` implementation, or wrap this call with their own
filtering logic.
"""
snapshot = len(self._event_log)
self._transformers.append(transformer)
for i in range(snapshot):
transformer.process(self._event_log[i])
if self._closed:
if hasattr(transformer, "finalize"):
transformer.finalize()
def wire_channels(self, projection: Any) -> None:
"""Scan *projection* for :class:`StreamChannel` instances and wire them.
For each ``StreamChannel`` found, registers a push callback that
appends a :class:`ProtocolEvent` directly to the main event log
with ``method`` set to the channel's name.
Channel events bypass the transformer pipeline (matching the JS
implementation). They are visible to raw event iteration and
remote SDK clients but not to other transformers' ``process()``.
"""
if projection is None:
return
items: dict[str, Any] = {}
if isinstance(projection, dict):
items = projection
elif hasattr(projection, "__dict__"):
items = vars(projection)
for _key, value in items.items():
if is_stream_channel(value):
channel: StreamChannel[Any] = value
self._channels.append(channel)
def _make_forwarder(ch: StreamChannel[Any]) -> Any:
def _forward(item: Any) -> None:
if self._closed:
return
# Append directly to the event log, bypassing
# the transformer pipeline. This matches the JS
# implementation and avoids re-entrancy bugs
# (namespace clobbering, infinite recursion).
self._event_log.append(
ProtocolEvent(
type="event",
seq=self._next_emit_seq,
method=ch.channel_name,
params={
"namespace": list(self._current_namespace),
"timestamp": int(time.time() * 1000),
"data": item,
},
)
)
self._next_emit_seq += 1
return _forward
channel._wire(_make_forwarder(channel))
def _wake_ns_waiters(self) -> None:
for fut in self._ns_waiters:
if not fut.done():
@@ -343,25 +382,6 @@ class StreamMux:
self._ns_waiters.clear()
class _FilteredEventIterator:
"""Async iterator that filters events by namespace prefix."""
__slots__ = ("_cursor", "_path")
def __init__(self, cursor: AsyncIterator[ProtocolEvent], path: list[str]) -> None:
self._cursor = cursor
self._path = path
def __aiter__(self) -> _FilteredEventIterator:
return self
async def __anext__(self) -> ProtocolEvent:
while True:
event = await self._cursor.__anext__()
ns = event["params"].get("namespace", [])
if _ns_starts_with(ns, self._path):
return event
def _ns_key(ns: list[str] | tuple[str, ...]) -> str:
"""Convert a namespace list to a hashable key."""
@@ -375,4 +395,4 @@ def _ns_starts_with(ns: list[str], prefix: list[str]) -> bool:
return ns[: len(prefix)] == prefix
__all__ = ["StreamMux"]
__all__ = ["AsyncStreamMux", "StreamMux"]
+5 -6
View File
@@ -6,7 +6,8 @@ in-process-only types needed by the LangGraph streaming infrastructure.
from __future__ import annotations
from typing import Any, Protocol, runtime_checkable
from abc import ABC, abstractmethod
from typing import Any
# ---------------------------------------------------------------------------
# Re-exports from langchain-protocol (CDDL-derived)
@@ -55,7 +56,6 @@ class _ProtocolEventParams(TypedDict):
"""Payload envelope for a :class:`ProtocolEvent`."""
namespace: Namespace
timestamp: int
node: NotRequired[str]
data: Any
@@ -74,8 +74,7 @@ class ProtocolEvent(TypedDict):
params: _ProtocolEventParams
@runtime_checkable
class StreamTransformer(Protocol):
class StreamTransformer(ABC):
"""Extension point for custom stream projections.
Implementations are registered with ``StreamingHandler`` and receive every
@@ -87,6 +86,7 @@ class StreamTransformer(Protocol):
"""
@abstractmethod
def init(self) -> Any:
"""Return the initial projection value.
@@ -96,6 +96,7 @@ class StreamTransformer(Protocol):
"""
...
@abstractmethod
def process(self, event: ProtocolEvent) -> bool:
"""Process an event.
@@ -110,7 +111,6 @@ class StreamTransformer(Protocol):
Optional — the mux auto-closes any :class:`StreamChannel` instances,
so transformers that only use channels can omit this.
"""
...
def fail(self, err: BaseException) -> None:
"""Called once when the run fails.
@@ -118,7 +118,6 @@ class StreamTransformer(Protocol):
Optional — the mux auto-fails any :class:`StreamChannel` instances,
so transformers that only use channels can omit this.
"""
...
class InterruptPayload(TypedDict):
@@ -130,22 +130,22 @@ class ChatModelStream:
# ---------------------------------------------------------------------------
# Async dual-projection helpers
# Dual-projection helpers — sync data container + async notification layer
# ---------------------------------------------------------------------------
class _DualProjection:
"""Async iterable of deltas that is also awaitable for the final value.
"""Sync data container for incremental deltas and a final value.
When iterated, yields delta values (e.g. text fragments) as they arrive.
When awaited, returns the accumulated final value (e.g. full text string).
Stores deltas as they arrive and tracks the final accumulated value.
No async primitives — see :class:`_AsyncDualProjection` for the
async-iterable + awaitable extension.
"""
def __init__(self) -> None:
self._deltas: list[Any] = []
self._done = False
self._error: BaseException | None = None
self._waiters: list[asyncio.Future[None]] = []
self._final_value: Any = None
self._final_set = False
@@ -154,33 +154,48 @@ class _DualProjection:
def _push(self, delta: Any) -> None:
"""Add a new delta value."""
self._deltas.append(delta)
self._wake()
def _finish(self, accumulated: Any) -> None:
"""Set the final accumulated value and mark as done."""
self._final_value = accumulated
self._final_set = True
self._done = True
self._wake()
def _fail(self, error: BaseException) -> None:
self._error = error
self._done = True
self._wake()
def _wake(self) -> None:
for fut in self._waiters:
if not fut.done():
try:
fut.get_loop().call_soon_threadsafe(fut.set_result, None)
except RuntimeError:
pass
self._waiters.clear()
class _AsyncDualProjection(_DualProjection):
"""Async extension of :class:`_DualProjection`.
Async iterable of deltas that is also awaitable for the final value.
Uses an ``asyncio.Event`` to notify async consumers when new data
arrives — the same pattern as :class:`AsyncStreamMux`.
"""
def __init__(self) -> None:
super().__init__()
self._notify: asyncio.Event = asyncio.Event()
# -- Producer overrides (extend to notify) -----------------------------
def _push(self, delta: Any) -> None:
super()._push(delta)
self._notify.set()
def _finish(self, accumulated: Any) -> None:
super()._finish(accumulated)
self._notify.set()
def _fail(self, error: BaseException) -> None:
super()._fail(error)
self._notify.set()
# -- Async iterable (yields deltas) ------------------------------------
def __aiter__(self) -> _DualProjectionIterator:
return _DualProjectionIterator(self)
def __aiter__(self) -> _AsyncDualProjectionIterator:
return _AsyncDualProjectionIterator(self)
# -- Awaitable (returns final value) -----------------------------------
@@ -191,25 +206,23 @@ class _DualProjection:
while not self._final_set:
if self._error is not None:
raise self._error
loop = asyncio.get_running_loop()
fut: asyncio.Future[None] = loop.create_future()
self._waiters.append(fut)
await fut
self._notify.clear()
await self._notify.wait()
if self._error is not None:
raise self._error
return self._final_value
class _DualProjectionIterator:
"""Async iterator over a :class:`_DualProjection`'s deltas."""
class _AsyncDualProjectionIterator:
"""Async iterator over an :class:`_AsyncDualProjection`'s deltas."""
__slots__ = ("_proj", "_offset")
def __init__(self, proj: _DualProjection) -> None:
def __init__(self, proj: _AsyncDualProjection) -> None:
self._proj = proj
self._offset = 0
def __aiter__(self) -> _DualProjectionIterator:
def __aiter__(self) -> _AsyncDualProjectionIterator:
return self
async def __anext__(self) -> Any:
@@ -222,10 +235,8 @@ class _DualProjectionIterator:
raise self._proj._error
if self._proj._done:
raise StopAsyncIteration
loop = asyncio.get_running_loop()
fut: asyncio.Future[None] = loop.create_future()
self._proj._waiters.append(fut)
await fut
self._proj._notify.clear()
await self._proj._notify.wait()
# ---------------------------------------------------------------------------
@@ -257,24 +268,24 @@ class AsyncChatModelStream(ChatModelStream):
message_id: str | None = None,
) -> None:
super().__init__(namespace=namespace, node=node, message_id=message_id)
self._text_proj = _DualProjection()
self._reasoning_proj = _DualProjection()
self._usage_proj = _DualProjection()
self._text_proj = _AsyncDualProjection()
self._reasoning_proj = _AsyncDualProjection()
self._usage_proj = _AsyncDualProjection()
# -- Public projections (override sync properties) ---------------------
@property
def text(self) -> _DualProjection:
def text(self) -> _AsyncDualProjection:
"""Text content — async iterable of deltas, awaitable for full text."""
return self._text_proj
@property
def reasoning(self) -> _DualProjection:
def reasoning(self) -> _AsyncDualProjection:
"""Reasoning content — async iterable of deltas, awaitable for full text."""
return self._reasoning_proj
@property
def usage(self) -> _DualProjection:
def usage(self) -> _AsyncDualProjection:
"""Usage info — awaitable for :class:`UsageInfo`."""
return self._usage_proj
+50 -48
View File
@@ -2,9 +2,13 @@
These are the top-level objects returned by
``StreamingHandler.stream()`` / ``StreamingHandler.astream()``.
They wrap a :class:`StreamMux` and expose named
projections (``.values``, ``.messages``, ``.subgraphs``, ``.output``)
for ergonomic consumption.
``AsyncGraphRunStream`` wraps an :class:`AsyncStreamMux` and exposes
``.values``, ``.messages``, ``.subgraphs``, ``.output``, and
``.messages_from()``.
``GraphRunStream`` wraps a :class:`StreamMux` and exposes the sync
equivalents: ``.values``, ``.messages``, and ``.output``.
"""
from __future__ import annotations
@@ -14,8 +18,7 @@ from collections.abc import AsyncIterator, Callable, Iterator
from typing import Any
from langgraph.stream._convert import convert_to_protocol_event
from langgraph.stream._event_log import EventLog
from langgraph.stream._mux import StreamMux
from langgraph.stream._mux import AsyncStreamMux, StreamMux
from langgraph.stream._types import InterruptPayload, ProtocolEvent, StreamTransformer
from langgraph.stream.chat_model_stream import AsyncChatModelStream, ChatModelStream
from langgraph.stream.transformers import MessagesTransformer, ValuesTransformer
@@ -30,7 +33,7 @@ class _ValuesProjection:
def __init__(
self,
mux: StreamMux,
mux: AsyncStreamMux,
values_transformer: ValuesTransformer,
ns: list[str],
mapper: Callable[[Any], Any] | None = None,
@@ -40,8 +43,23 @@ class _ValuesProjection:
self._ns = ns
self._mapper = mapper
def __aiter__(self) -> AsyncIterator[Any]:
return _ValuesIterator(self._values_transformer, self._ns, self._mapper)
async def __aiter__(self) -> AsyncIterator[Any]:
log = self._values_transformer.values_log
cursor = 0
while True:
while cursor < len(log):
item = log[cursor]
cursor += 1
if item.get("namespace", []) == self._ns:
data = item["data"]
if data is not None and self._mapper is not None:
yield self._mapper(data)
else:
yield data
if self._mux._closed:
return
self._mux._notify.clear()
await self._mux._notify.wait()
def __await__(self) -> Any:
return self._await_impl().__await__()
@@ -53,33 +71,6 @@ class _ValuesProjection:
return value
class _ValuesIterator:
"""Filters the values log to events matching a namespace."""
def __init__(
self,
transformer: ValuesTransformer,
ns: list[str],
mapper: Callable[[Any], Any] | None = None,
) -> None:
self._cursor = transformer.values_log.subscribe(0)
self._ns = ns
self._mapper = mapper
def __aiter__(self) -> _ValuesIterator:
return self
async def __anext__(self) -> Any:
while True:
item = await self._cursor.__anext__()
item_ns = item.get("namespace", [])
if item_ns == self._ns:
data = item["data"]
if data is not None and self._mapper is not None:
return self._mapper(data)
return data
# ---------------------------------------------------------------------------
# Messages projection
# ---------------------------------------------------------------------------
@@ -88,11 +79,21 @@ class _ValuesIterator:
class _MessagesProjection:
"""Async iterable of :class:`AsyncChatModelStream` instances."""
def __init__(self, messages_transformer: MessagesTransformer) -> None:
def __init__(self, mux: AsyncStreamMux, messages_transformer: MessagesTransformer) -> None:
self._mux = mux
self._transformer = messages_transformer
def __aiter__(self) -> AsyncIterator[AsyncChatModelStream]:
return self._transformer.messages_log.subscribe(0)
async def __aiter__(self) -> AsyncIterator[AsyncChatModelStream]:
log = self._transformer.messages_log
cursor = 0
while True:
while cursor < len(log):
yield log[cursor]
cursor += 1
if self._mux._closed:
return
self._mux._notify.clear()
await self._mux._notify.wait()
# ---------------------------------------------------------------------------
@@ -103,7 +104,7 @@ class _MessagesProjection:
class _SubgraphsProjection:
"""Async iterable yielding :class:`AsyncSubgraphRunStream` for each discovered subgraph."""
def __init__(self, mux: StreamMux, ns: list[str]) -> None:
def __init__(self, mux: AsyncStreamMux, ns: list[str]) -> None:
self._mux = mux
self._ns = ns
@@ -143,7 +144,7 @@ class AsyncGraphRunStream:
def __init__(
self,
*,
mux: StreamMux,
mux: AsyncStreamMux,
namespace: list[str] | None = None,
transformers: list[StreamTransformer],
abort_event: asyncio.Event | None = None,
@@ -186,7 +187,7 @@ class AsyncGraphRunStream:
def messages(self) -> _MessagesProjection:
"""Async iterable of :class:`AsyncChatModelStream` instances."""
t = self._find_transformer("messages")
return _MessagesProjection(t)
return _MessagesProjection(self._mux, t)
def messages_from(self, node: str) -> _MessagesProjection:
"""Async iterable of messages from a specific node."""
@@ -196,7 +197,7 @@ class AsyncGraphRunStream:
stream_cls=AsyncChatModelStream,
)
self._mux.register_transformer(filtered)
return _MessagesProjection(filtered)
return _MessagesProjection(self._mux, filtered)
@property
def subgraphs(self) -> _SubgraphsProjection:
@@ -308,7 +309,7 @@ async def create_async_graph_run_stream(
if projection is not None:
projections.append(projection)
mux = StreamMux(transformers=all_transformers)
mux = AsyncStreamMux(transformers=all_transformers)
# Wire any StreamChannel instances found in transformer projections
for projection in projections:
@@ -355,15 +356,16 @@ async def create_async_graph_run_stream(
class _PumpDrivenLog:
"""Wraps an ``EventLog`` so that iteration drives the sync pump.
"""Wraps a list so that iteration drives the sync pump.
Used by :attr:`GraphRunStream.extensions` to make extension logs
iterable without requiring the caller to drain the stream first.
Used by all :class:`GraphRunStream` projections (``__iter__``,
``.values``, ``.messages``, ``.extensions``) so that iterating
any projection lazily consumes the source.
"""
__slots__ = ("_log", "_pump_one")
def __init__(self, log: EventLog, pump_one: Callable[[], bool]) -> None:
def __init__(self, log: list, pump_one: Callable[[], bool]) -> None:
self._log = log
self._pump_one = pump_one
@@ -527,7 +529,7 @@ class GraphRunStream:
name = getattr(t, "name", None)
value = getattr(t, "value", None)
if name is not None and value is not None:
if isinstance(value, EventLog):
if isinstance(value, list):
result[name] = _PumpDrivenLog(value, self._pump_one)
else:
result[name] = value
@@ -1,24 +1,21 @@
"""StreamChannel — typed push-based channel for StreamTransformer projections.
A ``StreamChannel`` wraps an :class:`EventLog` and declares a protocol
channel name. When the :class:`StreamMux` detects a ``StreamChannel``
in a transformer's ``init()`` return, it wires every ``push()`` call to
inject a :class:`ProtocolEvent` into the main event stream using the
channel's name as the ``method``.
A ``StreamChannel`` wraps a list and declares a protocol channel name.
When the :class:`StreamMux` detects a ``StreamChannel`` in a transformer's
``init()`` return, it wires every ``push()`` call to inject a
:class:`ProtocolEvent` into the main event stream using the channel's
name as the ``method``.
In-process consumers iterate the channel directly (it is an async
iterable). Remote SDK clients subscribe via
``session.subscribe("custom:<channelName>")``.
In-process consumers iterate the channel directly. Remote SDK clients
subscribe via ``session.subscribe("custom:<channelName>")``.
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Callable
from collections.abc import Callable
from typing import Any, Generic, TypeVar
from langgraph.stream._event_log import EventLog
T = TypeVar("T")
@@ -31,28 +28,19 @@ class StreamChannel(Generic[T]):
channel on run completion.
"""
__slots__ = ("channel_name", "_log", "_on_push")
__slots__ = ("channel_name", "_items", "_on_push")
def __init__(self, name: str) -> None:
self.channel_name = name
self._log: EventLog[T] = EventLog()
self._items: list[T] = []
self._on_push: Callable[[Any], None] | None = None
def push(self, item: T) -> None:
"""Push an item to the channel.
If the mux has wired this channel, the push also injects a
protocol event into the main event stream.
"""
self._log.append(item)
"""Push an item to the channel."""
self._items.append(item)
if self._on_push is not None:
self._on_push(item)
# -- Async iteration (in-process consumption) ---------------------------
def __aiter__(self) -> AsyncIterator[T]:
return self._log.subscribe(0)
# -- Internal (called by the mux) ---------------------------------------
def _wire(self, fn: Callable[[Any], None]) -> None:
@@ -60,12 +48,12 @@ class StreamChannel(Generic[T]):
self._on_push = fn
def _close(self) -> None:
"""Close the underlying log. Called by the mux on normal completion."""
self._log.close()
"""No-op for compatibility. Called by the mux on normal completion."""
pass
def _fail(self, err: BaseException) -> None:
"""Fail the underlying log. Called by the mux on failure."""
self._log.fail(err)
"""No-op for compatibility. Called by the mux on failure."""
pass
def is_stream_channel(value: object) -> bool:
+14 -21
View File
@@ -9,36 +9,33 @@ from __future__ import annotations
from typing import Any
from langgraph.stream._event_log import EventLog
from langgraph.stream._types import ProtocolEvent
from langgraph.stream._types import ProtocolEvent, StreamTransformer
from langgraph.stream.chat_model_stream import ChatModelStream
# Type alias for the stream class constructor signature
_StreamCls = type[ChatModelStream]
class ValuesTransformer:
"""Extracts ``values`` events and populates a values event log.
class ValuesTransformer(StreamTransformer):
"""Extracts ``values`` events and populates a values log.
Maintains the latest state per namespace and provides a separate
event log that :class:`AsyncGraphRunStream` / :class:`GraphRunStream` uses for ``.values``
log that :class:`AsyncGraphRunStream` / :class:`GraphRunStream` uses for ``.values``
iteration.
Implements the :class:`StreamTransformer` protocol.
"""
name = "values"
def __init__(self) -> None:
self._values_log: EventLog[dict[str, Any]] = EventLog()
self._values_log: list[dict[str, Any]] = []
self._latest: dict[str, Any] = {}
@property
def value(self) -> EventLog[dict[str, Any]]:
def value(self) -> list[dict[str, Any]]:
return self._values_log
@property
def values_log(self) -> EventLog[dict[str, Any]]:
def values_log(self) -> list[dict[str, Any]]:
return self._values_log
def get_latest(self, ns_key: str = "") -> Any:
@@ -61,20 +58,18 @@ class ValuesTransformer:
return True
def finalize(self) -> None:
self._values_log.close()
pass
def fail(self, err: BaseException) -> None:
self._values_log.fail(err)
pass
class MessagesTransformer:
class MessagesTransformer(StreamTransformer):
"""Groups ``messages`` events into :class:`ChatModelStream` instances.
One ``ChatModelStream`` is created per ``message-start`` event.
Content-block events are routed to the active stream until
``message-finish`` or ``message-error`` closes it.
Implements the :class:`StreamTransformer` protocol.
"""
name = "messages"
@@ -91,17 +86,17 @@ class MessagesTransformer:
self._stream_cls: _StreamCls = stream_cls or ChatModelStream
# Message log for .messages iteration
self._messages_log: EventLog[ChatModelStream] = EventLog()
self._messages_log: list[ChatModelStream] = []
# Current active stream per namespace key
self._active: dict[str, ChatModelStream] = {}
@property
def value(self) -> EventLog[ChatModelStream]:
def value(self) -> list[ChatModelStream]:
return self._messages_log
@property
def messages_log(self) -> EventLog[ChatModelStream]:
def messages_log(self) -> list[ChatModelStream]:
return self._messages_log
def init(self) -> Any:
@@ -160,17 +155,15 @@ class MessagesTransformer:
return True
def finalize(self) -> None:
# Close any remaining active streams
# Finish any remaining active streams
for stream in self._active.values():
stream._finish({"reason": "stop"})
self._active.clear()
self._messages_log.close()
def fail(self, err: BaseException) -> None:
for stream in self._active.values():
stream._fail(err)
self._active.clear()
self._messages_log.fail(err)
__all__ = [
+73 -8
View File
@@ -9,7 +9,7 @@ from typing_extensions import TypedDict
from langgraph.config import get_stream_writer
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.stream import AsyncChatModelStream, StreamingHandler
from langgraph.stream._types import ProtocolEvent
from langgraph.stream._types import ProtocolEvent, StreamTransformer
from tests.fake_chat import FakeChatModel
@@ -379,7 +379,7 @@ async def test_subgraph_child_output():
# ---------------------------------------------------------------------------
class _CountTransformer:
class _CountTransformer(StreamTransformer):
"""Counts events. Exposes count via .value for extensions."""
name = "event_count"
@@ -427,6 +427,73 @@ def test_sync_custom_reducer_extensions():
assert run.extensions["event_count"] == counter.value
# ---------------------------------------------------------------------------
# Double iteration over .values
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_async_values_double_iteration():
"""Iterating over run.values twice should yield the same snapshots both times."""
graph = make_simple_graph()
run = await StreamingHandler(graph).astream({"value": "x", "items": []})
await asyncio.sleep(0.1)
first = []
async for v in run.values:
first.append(v)
second = []
async for v in run.values:
second.append(v)
assert len(first) == 3
assert first == second
def test_sync_values_double_iteration():
"""Iterating over run.values twice should yield the same snapshots both times."""
graph = make_simple_graph()
run = StreamingHandler(graph).stream({"value": "x", "items": []})
first = list(run.values)
second = list(run.values)
assert len(first) == 3
assert first == second
@pytest.mark.anyio
async def test_async_raw_events_double_iteration():
"""Iterating over the raw event stream twice should yield the same events."""
graph = make_simple_graph()
run = await StreamingHandler(graph).astream({"value": "x", "items": []})
await asyncio.sleep(0.1)
first = []
async for event in run:
first.append(event)
second = []
async for event in run:
second.append(event)
assert len(first) > 0
assert first == second
def test_sync_raw_events_double_iteration():
"""Iterating over the raw event stream twice should yield the same events."""
graph = make_simple_graph()
run = StreamingHandler(graph).stream({"value": "x", "items": []})
first = list(run)
second = list(run)
assert len(first) > 0
assert first == second
# ---------------------------------------------------------------------------
# Tool transformer via extensions
# ---------------------------------------------------------------------------
@@ -440,15 +507,13 @@ class _ToolExecution:
self.output = output
class _ToolsTransformer:
class _ToolsTransformer(StreamTransformer):
"""Groups tool-started/tool-finished custom events into _ToolExecution objects."""
name = "tools"
def __init__(self) -> None:
from langgraph.stream._event_log import EventLog
self._log: EventLog[_ToolExecution] = EventLog()
self._log: list[_ToolExecution] = []
self._pending: dict[str, dict] = {}
self.value = self._log
@@ -483,10 +548,10 @@ class _ToolsTransformer:
return True
def finalize(self) -> None:
self._log.close()
pass
def fail(self, err: BaseException) -> None:
self._log.fail(err)
pass
def _make_tool_graph():
@@ -51,13 +51,6 @@ def test_namespace_passthrough():
assert evt["params"]["namespace"] == ["agent", "0"]
def test_timestamp_populated():
evt = convert_to_protocol_event((), "values", {})
assert evt is not None
assert isinstance(evt["params"]["timestamp"], int)
assert evt["params"]["timestamp"] > 0
def test_unknown_mode_returns_none():
assert convert_to_protocol_event((), "unknown_mode", {}) is None
@@ -1,144 +0,0 @@
import asyncio
import pytest
from langgraph.stream._event_log import EventLog
@pytest.mark.anyio
async def test_push_and_iterate_in_order():
log = EventLog()
log.append("a")
log.append("b")
log.append("c")
log.close()
items = [item async for item in log.subscribe(0)]
assert items == ["a", "b", "c"]
@pytest.mark.anyio
async def test_multiple_independent_cursors():
log = EventLog()
log.append("x")
log.append("y")
log.close()
items1 = [item async for item in log.subscribe(0)]
items2 = [item async for item in log.subscribe(0)]
assert items1 == ["x", "y"]
assert items2 == ["x", "y"]
@pytest.mark.anyio
async def test_close_ends_iteration():
log = EventLog()
log.close()
items = [item async for item in log.subscribe(0)]
assert items == []
@pytest.mark.anyio
async def test_fail_raises_error():
log = EventLog()
log.fail(RuntimeError("boom"))
with pytest.raises(RuntimeError, match="boom"):
async for _ in log.subscribe(0):
pass
@pytest.mark.anyio
async def test_nonzero_offset():
log = EventLog()
log.append("x")
log.append("y")
log.append("z")
log.close()
items = [item async for item in log.subscribe(2)]
assert items == ["z"]
@pytest.mark.anyio
async def test_concurrent_push_and_iterate():
log = EventLog()
received = []
async def consumer():
async for item in log.subscribe(0):
received.append(item)
async def producer():
for i in range(5):
log.append(i)
await asyncio.sleep(0.01)
log.close()
await asyncio.gather(producer(), consumer())
assert received == [0, 1, 2, 3, 4]
@pytest.mark.anyio
async def test_items_before_cursor_visible():
log = EventLog()
log.append("a")
log.append("b")
cursor = log.subscribe(0)
log.append("c")
log.close()
items = [item async for item in cursor]
assert items == ["a", "b", "c"]
@pytest.mark.anyio
async def test_empty_log_closed_yields_nothing():
log = EventLog()
log.close()
items = [item async for item in log.subscribe(0)]
assert items == []
@pytest.mark.anyio
async def test_fail_mid_iteration():
"""A cursor that has consumed some items should raise when fail() is called."""
log = EventLog()
received = []
async def consumer():
async for item in log.subscribe(0):
received.append(item)
async def producer():
log.append("a")
log.append("b")
await asyncio.sleep(0.02)
log.fail(RuntimeError("mid-stream error"))
with pytest.raises(RuntimeError, match="mid-stream error"):
await asyncio.gather(producer(), consumer())
assert received == ["a", "b"]
@pytest.mark.anyio
async def test_abandoned_cursor_cleans_up_waiters():
"""Abandoned async cursors should not leave stale futures in the
EventLog waiter list.
When a cursor's __anext__ is cancelled (e.g. consumer breaks out of
``async for``), the Future it registered in ``_waiters`` should be
cleaned up. Otherwise the list grows without bound until the next
append/close/fail triggers ``_wake_all()``.
"""
log: EventLog[str] = EventLog()
for _ in range(10):
cursor = log.subscribe(0)
task = asyncio.ensure_future(cursor.__anext__())
await asyncio.sleep(0) # let task register its waiter
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert len(log._waiters) == 0, (
f"Expected 0 waiters after abandoning 10 cursors, "
f"got {len(log._waiters)}. Abandoned cursors leak futures."
)
+11 -11
View File
@@ -3,8 +3,8 @@ from typing import Any
import pytest
from langgraph.stream._convert import convert_to_protocol_event
from langgraph.stream._mux import StreamMux
from langgraph.stream._types import ProtocolEvent
from langgraph.stream._mux import AsyncStreamMux, StreamMux
from langgraph.stream._types import ProtocolEvent, StreamTransformer
from langgraph.stream.stream_channel import StreamChannel
@@ -14,7 +14,7 @@ def _event(mode: str, data: Any, ns: list[str] | None = None) -> ProtocolEvent:
return ev
class _MockTransformer:
class _MockTransformer(StreamTransformer):
def __init__(self, *, suppress: bool = False):
self.calls: list[ProtocolEvent] = []
self._suppress = suppress
@@ -70,7 +70,7 @@ async def test_top_level_ns_only():
@pytest.mark.anyio
async def test_subscribe_events_filter():
mux = StreamMux()
mux = AsyncStreamMux()
mux.push(_event("values", {"a": 1}, ns=["child:0"]))
mux.push(_event("values", {"b": 2}, ns=["other:1"]))
mux.push(_event("values", {"c": 3}, ns=["child:0"]))
@@ -86,7 +86,7 @@ async def test_subscribe_events_filter():
@pytest.mark.anyio
async def test_close_resolves_output():
mux = StreamMux()
mux = AsyncStreamMux()
fut = mux.get_output_future()
mux.push(_event("values", {"v": 1}))
mux.push(_event("values", {"v": 2}))
@@ -97,7 +97,7 @@ async def test_close_resolves_output():
@pytest.mark.anyio
async def test_fail_rejects_output():
mux = StreamMux()
mux = AsyncStreamMux()
fut = mux.get_output_future()
mux.fail(ValueError("boom"))
with pytest.raises(ValueError, match="boom"):
@@ -155,7 +155,7 @@ async def test_push_after_close_ignored():
@pytest.mark.anyio
async def test_fail_rejects_all_futures():
mux = StreamMux()
mux = AsyncStreamMux()
fut1 = mux.get_output_future([])
fut2 = mux.get_output_future(["child:0"])
mux.fail(ValueError("boom"))
@@ -172,7 +172,7 @@ async def test_channel_events_bypass_transformer_pipeline():
the JS implementation and avoids re-entrancy bugs.
"""
mock = _MockTransformer()
mux = StreamMux(transformers=[mock])
mux = AsyncStreamMux(transformers=[mock])
channel: StreamChannel[str] = StreamChannel("my_channel")
mux.wire_channels({"ch": channel})
@@ -208,7 +208,7 @@ async def test_event_log_has_monotonic_seq_numbers():
while channel-emitted events use a separate counter
(``_next_emit_seq``). When interleaved, seq numbers can duplicate.
"""
mux = StreamMux()
mux = AsyncStreamMux()
channel: StreamChannel[str] = StreamChannel("test_ch")
mux.wire_channels({"ch": channel})
@@ -243,7 +243,7 @@ async def test_channel_push_during_process_preserves_namespace():
``namespace: []`` instead of the original.
"""
class _ChannelTransformer:
class _ChannelTransformer(StreamTransformer):
"""Pushes to its channel whenever it sees a ``values`` event."""
def __init__(self, name: str) -> None:
@@ -266,7 +266,7 @@ async def test_channel_push_during_process_preserves_namespace():
t1 = _ChannelTransformer("first")
t2 = _ChannelTransformer("second")
mux = StreamMux(transformers=[t1, t2])
mux = AsyncStreamMux(transformers=[t1, t2])
mux.wire_channels({"first": t1.channel})
mux.wire_channels({"second": t2.channel})
+24 -77
View File
@@ -22,38 +22,29 @@ def _event(
# -- ValuesTransformer ---------------------------------------------------------
@pytest.mark.anyio
async def test_values_captures_values_events():
def test_values_captures_values_events():
reducer = ValuesTransformer()
reducer.init()
reducer.process(_event("values", {"a": 1}))
reducer.process(_event("values", {"b": 2}))
reducer.finalize()
collected = []
async for item in reducer.values_log.subscribe(0):
collected.append(item)
assert len(collected) == 2
assert collected[0]["data"] == {"a": 1}
assert collected[1]["data"] == {"b": 2}
assert len(reducer.values_log) == 2
assert reducer.values_log[0]["data"] == {"a": 1}
assert reducer.values_log[1]["data"] == {"b": 2}
@pytest.mark.anyio
async def test_values_ignores_other_modes():
def test_values_ignores_other_modes():
reducer = ValuesTransformer()
reducer.init()
reducer.process(_event("updates", {"x": 1}))
reducer.process(_event("messages", {"event": "message-start"}))
reducer.finalize()
collected = []
async for item in reducer.values_log.subscribe(0):
collected.append(item)
assert len(collected) == 0
assert len(reducer.values_log) == 0
@pytest.mark.anyio
async def test_values_latest_per_namespace():
def test_values_latest_per_namespace():
reducer = ValuesTransformer()
reducer.init()
reducer.process(_event("values", {"v": 1}, ns=["child:0"]))
@@ -61,15 +52,6 @@ async def test_values_latest_per_namespace():
assert reducer.get_latest("child:0") == {"v": 2}
@pytest.mark.anyio
async def test_values_finalize_closes_log():
reducer = ValuesTransformer()
reducer.init()
reducer.process(_event("values", {"a": 1}))
reducer.finalize()
assert reducer.values_log.closed
# -- MessagesTransformer -------------------------------------------------------
@@ -103,8 +85,7 @@ def _msg_finish(ns=None, node=None):
)
@pytest.mark.anyio
async def test_messages_groups_lifecycle():
def test_messages_groups_lifecycle():
reducer = MessagesTransformer()
reducer.init()
reducer.process(_msg_start())
@@ -112,16 +93,12 @@ async def test_messages_groups_lifecycle():
reducer.process(_msg_finish())
reducer.finalize()
collected = []
async for stream in reducer.messages_log.subscribe(0):
collected.append(stream)
assert len(collected) == 1
assert isinstance(collected[0], ChatModelStream)
assert collected[0].done
assert len(reducer.messages_log) == 1
assert isinstance(reducer.messages_log[0], ChatModelStream)
assert reducer.messages_log[0].done
@pytest.mark.anyio
async def test_messages_multiple_sequential():
def test_messages_multiple_sequential():
reducer = MessagesTransformer()
reducer.init()
reducer.process(_msg_start(message_id="m1"))
@@ -130,14 +107,10 @@ async def test_messages_multiple_sequential():
reducer.process(_msg_finish())
reducer.finalize()
collected = []
async for stream in reducer.messages_log.subscribe(0):
collected.append(stream)
assert len(collected) == 2
assert len(reducer.messages_log) == 2
@pytest.mark.anyio
async def test_messages_namespace_filter():
def test_messages_namespace_filter():
reducer = MessagesTransformer(namespace=["root"])
reducer.init()
reducer.process(_msg_start(ns=["root"]))
@@ -146,14 +119,10 @@ async def test_messages_namespace_filter():
reducer.process(_msg_finish(ns=["other"]))
reducer.finalize()
collected = []
async for stream in reducer.messages_log.subscribe(0):
collected.append(stream)
assert len(collected) == 1
assert len(reducer.messages_log) == 1
@pytest.mark.anyio
async def test_messages_node_filter():
def test_messages_node_filter():
reducer = MessagesTransformer(node_filter="agent")
reducer.init()
reducer.process(_msg_start(node="agent"))
@@ -162,14 +131,10 @@ async def test_messages_node_filter():
reducer.process(_msg_finish(node="tools"))
reducer.finalize()
collected = []
async for stream in reducer.messages_log.subscribe(0):
collected.append(stream)
assert len(collected) == 1
assert len(reducer.messages_log) == 1
@pytest.mark.anyio
async def test_messages_error_event():
def test_messages_error_event():
"""An error event should fail the active ChatModelStream."""
reducer = MessagesTransformer()
reducer.init()
@@ -180,35 +145,17 @@ async def test_messages_error_event():
)
reducer.finalize()
collected: list[ChatModelStream] = []
async for stream in reducer.messages_log.subscribe(0):
collected.append(stream)
assert len(collected) == 1
assert collected[0].done
assert len(reducer.messages_log) == 1
assert reducer.messages_log[0].done
@pytest.mark.anyio
async def test_messages_fail_propagates_to_active():
"""transformer.fail() should propagate the error to any active streams."""
def test_messages_fail_propagates_to_active():
"""transformer.fail() should mark active streams as done."""
reducer = MessagesTransformer()
reducer.init()
reducer.process(_msg_start())
reducer.process(_content_delta("partial"))
reducer.fail(RuntimeError("graph failed"))
# The messages log should be failed too
with pytest.raises(RuntimeError, match="graph failed"):
async for _ in reducer.messages_log.subscribe(0):
pass
@pytest.mark.anyio
async def test_values_fail_propagates():
reducer = ValuesTransformer()
reducer.init()
reducer.process(_event("values", {"a": 1}))
reducer.fail(RuntimeError("graph failed"))
with pytest.raises(RuntimeError, match="graph failed"):
async for _ in reducer.values_log.subscribe(0):
pass
assert len(reducer.messages_log) == 1
assert reducer.messages_log[0].done
@@ -4,7 +4,7 @@ from typing import Any
import pytest
from langgraph.stream._mux import StreamMux
from langgraph.stream._mux import AsyncStreamMux, StreamMux
from langgraph.stream._types import ProtocolEvent
from langgraph.stream.chat_model_stream import ChatModelStream
from langgraph.stream.run_stream import (
@@ -44,7 +44,7 @@ async def test_aiter_yields_all_events():
@pytest.mark.anyio
async def test_subgraph_name_and_index():
vr, mr = ValuesTransformer(), MessagesTransformer()
mux = StreamMux(transformers=[vr, mr])
mux = AsyncStreamMux(transformers=[vr, mr])
sub = AsyncSubgraphRunStream(
mux=mux,
namespace=["researcher:2"],
@@ -57,7 +57,7 @@ async def test_subgraph_name_and_index():
@pytest.mark.anyio
async def test_subgraph_name_no_index():
vr, mr = ValuesTransformer(), MessagesTransformer()
mux = StreamMux(transformers=[vr, mr])
mux = AsyncStreamMux(transformers=[vr, mr])
sub = AsyncSubgraphRunStream(
mux=mux, namespace=["agent"], transformers=[vr, mr]
)
@@ -132,7 +132,7 @@ async def test_messages_yields_streams():
@pytest.mark.anyio
async def test_interrupted_false_by_default():
vr, mr = ValuesTransformer(), MessagesTransformer()
mux = StreamMux(transformers=[vr, mr])
mux = AsyncStreamMux(transformers=[vr, mr])
run = AsyncGraphRunStream(mux=mux, transformers=[vr, mr])
assert run.interrupted is False
@@ -140,7 +140,7 @@ async def test_interrupted_false_by_default():
@pytest.mark.anyio
async def test_abort_sets_signal():
vr, mr = ValuesTransformer(), MessagesTransformer()
mux = StreamMux(transformers=[vr, mr])
mux = AsyncStreamMux(transformers=[vr, mr])
run = AsyncGraphRunStream(mux=mux, transformers=[vr, mr])
assert not run.signal.is_set()
run.abort()