StreamMux: the event dispatcher for multi-mode streaming
Responsibilities
In the LangGraph v3 streaming protocol, calling graph.stream_events(version="v3") no longer returns a string of raw (namespace, mode, payload) tuples. Instead you get a GraphRunStream object exposing projections like run.values / run.messages / run.updates / run.subgraphs / run.lifecycle (GraphRunStream:31-49). Splitting those raw events by mode into different projections, and letting the user pull from them with an ordinary for loop, is the job of StreamMux — the event dispatch center. The code lives in libs/langgraph/langgraph/stream/_mux.py (StreamMux:26).
StreamMux itself implements no mode-specific logic. It does three things: register transformers, push events, and close. On push, it calls each StreamTransformer.process(event) in registration order (push:269-296); each transformer decides whether to drop the event into its own projection channel. The mux then appends the event to _events, the main log — a complete audit copy of the raw stream. The projections owned by transformers are StreamChannel instances; named channels are also auto-forwarded by the mux into the main log, so a transformer's side effects show up both in its own channel and in the main log.
StreamMux is also the foundation for nested subgraphs. When a subgraph is discovered, SubgraphTransformer calls mux._make_child(scope) (_make_child:193-225) to build a mini-mux for the subgraph. That mini-mux shares the same set of factories as the parent (so transformer instances are per-scope), and it inherits the parent mux's pump binding — when the root pump moves, every subgraph's events advance one step.
Design motivation
- Caller-driven pump, no background thread: The
GraphRunStreamiterator IS the pump (_pump_next:107-130). When the user doesfor event in run.values, each iteration triggers_pump_nextwhich pulls one raw event and feeds it to the mux; no background thread is spawned, and memory usage is bounded by the consumption rate.bind_pumpregisters this pump function with the mux (bind_pump:162-180) and is automatically inherited by mini-mux children via_make_child. - Transformers run in registration order, serially:
pushdoesfor transformer in self._transformers: if not transformer.process(event): keep = False(push loop:288-292). Earlier transformers' side effects happen first and are visible to later ones; a transformer returningFalseonly suppresses the event from the main log — earlier transformers still consumed it. - Native vs extension projection split: A transformer with
_native = Truehas its projection mounted as a direct attribute likerun.valuesonGraphRunStream(native attrs:78-79); non-native ones only go intorun.extensions. Thenative_keysset (native_keys:109) is the "direct attribute" whitelist the v3 protocol exposes to SDKs. before_builtinsgives content-rewriting transformers right of way: Transformers doing PII filtering or content moderation must see the raw text fields before built-in transformers likeMessagesTransformer(before_builtins:94-109). At registration the mux re-partitions into two lanes bybefore_builtins = True(partition:131-148); order within each lane is preserved.seqis only assigned at the root mux:_assign_seqdefaults toTrue, and child muxes passFalsevia_make_child(_assign_seq:215-220). This way subgraph events forwarded directly to the root log do not have their envelope rewritten, keepingseqmonotonic and aligned to the root's write order.
Key files
StreamMux class:26-46— Central scheduler attributes and docstring.StreamMux.__init__:48-148— Accepts atransformerslist orfactories, and registers them in two lanes split bybefore_builtins._make_child:193-225— Builds a mini-mux when a subgraph is discovered, inheriting the pump binding._register:227-267— Callstransformer.init()to obtain the projection dict, checks for key conflicts, and wires namedStreamChannels.push:269-296— Sync path: callsprocessin order, then decides whether to enter the main log.apush:351-378— Async path: serially awaitsaprocessso later transformers see earlier async results.close:298-324— Sync close; calls every transformer'sfinalize, and guarantees all channels close even on error.aclose:380-404— Async close; firstgathers all scheduled tasks, thenafinalize._collect_stream_modes:398-414— Reverse-derives which modes to request from the graph based on therequired_stream_modesof registered transformers._pregel_stream_v3:3533-3558— Sync v3 entry; instantiates StreamMux + GraphRunStream and feeds the mux's stream_mode back into the underlyingstream().
Data flow
The core of StreamMux.push is just a few lines — walk the transformers in registration order, and if the event survives, append it to the main log:
def push(self, event: ProtocolEvent) -> None:
keep = True
for transformer in self._transformers:
if not transformer.process(event):
keep = False
if keep:
if self._assign_seq:
self._seq += 1
event["seq"] = self._seq
self._events.push(event)(push body:288-296) It looks plain, but transformer.process may push into its own StreamChannel, and named channels are auto-wired into the main log by the mux (_register:227-267). So a single raw values event may simultaneously land in run.values via ValuesTransformer and be transformed by a user-defined transformer and pushed back into the main log. Events walk a serial chain across transformers, with order strictly preserved.
Boundaries and failure modes
- Async transformers raise immediately under sync mode:
_registercallstransformer_requires_asyncto check (requires_async check:234-239). If a transformer overridesaprocessbut does not setsupports_sync=True, registering it on a sync mux raises an error advisingastream()instead ofstream(). - Projection key conflicts raise
ValueErrordirectly:_registerchecksset(projection) & set(self.extensions)(conflict check:246-256); the error lists the conflicting keys and which transformer owns them. Custom transformers cannot reuse built-in projection key names. _make_childrejects pre-built transformers: A mux constructed with thetransformers=argument cannot build child muxes (factory required:209-214), because pre-built instances cannot be re-initialized in a fresh scope.SubgraphTransformeronly runs on a mux built withfactories=.- Finalize errors do not block other cleanup:
closecollects the first exception viafirst_errorand keeps closing the remaining transformers and channels, then re-raises (close error handling:312-324), ensuring no resource leak. bind_pumpmust be called before channels are read:GraphRunStream.__init__'swire_pump=Truedefault runs_wire_request_more(_wire_request_more:80-92), and a child mux inherits the parent binding through_make_child. But if you construct a mux by hand and forgetbind_pump,StreamChannel.__iter__'s_request_morewill forever beNone(_request_more:191-192), and iteration ends immediately.schedule(coro)only works in async mode:StreamTransformer.schedulerequires an event loop (schedule:233-263). The muxgathers all scheduled tasks duringacloseand onlyafinalizes once they finish; callingschedulein sync mode fails.
Summary
StreamMux is the hub of the v3 streaming protocol: it implements no mode logic itself, only dispatches transformers in order, builds child muxes per scope, and collects both raw events and transformer projections into the main log. For details on how each mode turns events into user-friendly shapes, see stream/transformers; for how raw events are fed in from PregelLoop, see the Pregel engine.
See official docs: LangGraph docs · README.