Stream Transformers: turning raw Pregel output into usable projections
Responsibilities
During each superstep, PregelLoop pushes (checkpoint_ns, mode, payload) triples into a SyncQueue via _emit (_emit:1380-1414) — these are raw "value", "update", "task", "checkpoint" events. That is the output shape of the v1 / v2 protocol: the user gets a stream of chunks. But in the v3 protocol, what users want is not chunks but "per-mode organized, single-consumer-iterable projections": run.values is a stream of complete state snapshots, run.messages is a ChatModelStream handle for LLM token streams, run.updates is a stream of node updates, and run.subgraphs is a handle for subgraph entry.
The logic that turns raw chunks into these projections lives in libs/langgraph/langgraph/stream/transformers.py (ValuesTransformer:28). Each mode has one transformer, all inheriting from the StreamTransformer abstract base class (StreamTransformer:44). They are registered by StreamMux, fed events by GraphRunStream, and each maintains its own internal StreamChannel as its projection. When you call graph.stream_events(version="v3"), what you actually get is a GraphRunStream (GraphRunStream:31); its .values / .messages / .updates / .subgraphs / .custom / .lifecycle attributes are the projection entry points of the native transformers (native attrs:78-79).
The whole transformer system is the v3 protocol's extension point — beyond the five built-ins, users can add their own transformer via Pregel.compile(stream_transformers=[...]) (stream_transformers param:783), passed to StreamMux as a factory alongside the built-ins inside _pregel_stream_v3 (factories list:3533-3544).
Design motivation
- Native vs extension projection dual track: A transformer with
_native = Truehas its projection mounted as a directrun.<key>attribute (_native:50-52); non-native ones go intorun.extensionsand are only exposed via the dict. The five built-ins are all native; user-defined ones default to extensions, and must explicitly set_native = Truefor a direct attribute. required_stream_modesreverse-derives which modes the graph emits: A transformer declares which modes it needs (required_stream_modes:88-93),_collect_stream_modestakes the union (_collect_stream_modes:398-414), and when v3 calls the underlyingstream()it passes that union asstream_mode(stream_mode=_collect_stream_modes:3549). The user does not specifystream_modedirectly — instead "pulls on demand".- Scope filtering avoids noise:
ValuesTransformer.processchecksparams["namespace"] != self._scope_list(ValuesTransformer.process:70-82) and only accepts events from its own scope; subgraph events are left to the subgraph's own mini-mux, so rootrun.valuesis not polluted by nested graphs. - Sync / async dual track with auto-detection: A transformer defaults to
supports_sync = False; as long as it overridesaprocess/afinalize/afail,transformer_requires_asyncflags it as async-only (transformer_requires_async:308-330), and registering it on a sync mux raises immediately. To support both sync and async you must explicitly setsupports_sync = True, e.g.SubgraphTransformer(supports_sync:689). SubgraphTransformergoes throughSubgraphRunStream, not raw events: When a subgraph call is discovered,_on_startedbuilds aSubgraphRunStreamwrapping the mini-mux (SubgraphTransformer:670-705). What the user gets is not an event stream but a handle object, on whichhandle.values/handle.messages/handle.subgraphscan be recursed — isomorphic to the rootruninterface.
Key files
StreamTransformer base class:44-115— Abstractinit/process/aprocess/finalize/fail+ ClassVar config.init:128-138— Returns the projection dict; keys go intoextensions, and when_native=Truealso ontorun.<key>.process:148-164— Sync lane; defaults toraise NotImplementedError; subclasses must override.aprocess:166-184— Async lane; defaults to delegating toprocess; override for async work.schedule:233-263— Anasyncio.Taskbound to the mux lifecycle — gathered onaclose, cancelled onafail.transformer_requires_async:308-330— Detects whetheraprocess/afinalize/afailare overridden, deciding whether the transformer can run on a sync mux.ValuesTransformer:28-82— Therun.valuesprojection;required_stream_modes=("values",).UpdatesTransformer:120-152— Therun.updatesprojection.MessagesTransformer:155-335— Therun.messagesprojection; packs LLM token streams intoChatModelStreamhandles and handles the v2 protocol's(payload, metadata)input.LifecycleTransformer:608-667— Therun.lifecycleprojection; emits subgraph lifecycle events back into the main log as native protocol events visible to remote SDKs.SubgraphTransformer:670-705— Therun.subgraphsprojection; buildsSubgraphRunStream+ mini-mux on subgraph discovery.GraphRunStream._pump_next:107-130— The pump: pulls one chunk, runsconvert_to_protocol_event, and feeds the mux.convert_to_protocol_event:10-32— Maps v2StreamPartfields to v3ProtocolEvent.
Data flow
The entire v3 chain starts at PregelLoop._emit, passes through SyncQueue, GraphRunStream._pump_next, convert_to_protocol_event, and finally reaches the mux. convert_to_protocol_event directly converts v2's {type, ns, data, interrupts} into a v3 ProtocolEvent:
def convert_to_protocol_event(part: StreamPart) -> ProtocolEvent:
part_dict = cast(dict[str, Any], part)
params: _ProtocolEventParams = {
"namespace": list(part_dict["ns"]),
"timestamp": int(time.time() * 1000),
"data": part_dict["data"],
}
if "interrupts" in part_dict:
params["interrupts"] = part_dict["interrupts"]
return {
"type": "event",
"method": part_dict["type"],
"params": params,
}(convert body:20-32) Note that method is exactly v2's type — values / updates / messages / custom / tasks / checkpoints / debug. This is the dispatch key inside StreamTransformer.process; for instance, ValuesTransformer only acts on events with method == "values" (method check:70-71).
The v1 / v2 protocol takes a different path inside Pregel.stream: raw chunks come straight out of the SyncQueue and through the _output function (_output:4184-4243), which decides based on whether stream_mode is a string or a list whether to yield payload, (mode, payload), or (ns, mode, payload):
if version == "v2":
...
yield {"type": mode, "ns": ns, "data": payload, "interrupts": ints}
elif stream_subgraphs and isinstance(stream_mode, list):
yield (ns, mode, payload)
elif isinstance(stream_mode, list):
yield (mode, payload)
elif stream_subgraphs:
yield (ns, payload)
else:
yield payload(_output shape branches:4220-4243) This is why stream_mode=["updates", "values"] yields triples while a single string yields a single value — the same raw chunk gets shaped differently at _output. v3 does not go through _output; it goes through GraphRunStream._pump_next → mux.push → transformer.
Boundaries and failure modes
processmust be implemented;initmust return a dict:initis an@abstractmethodby default; failing to implement it breaks startup (init abstract:127-138).processdefaults toraise NotImplementedError(process default:162-164); not overriding it blows up on the first pushed event.- Transformer order is sensitive: A transformer with
before_builtins = Trueruns first (before_builtins:94-109), but the docstring warns that it seestasksevents beforeLifecycleTransformer/SubgraphTransformerconsume them. Mutatingevent["params"]["namespace"]or theid/result/error/interruptsfields would corrupt the bookkeeping of those two — you may only observe and edit cold fields. MessagesTransformeris coupled to the v2 protocol:stream_events(version="v3")forcesversion="v2"andCONFIG_KEY_STREAM_MESSAGES_V2=Truewhen calling the underlyingstream()(_pregel_stream_v3 stream call:3545-3551), otherwise the messages projection does not get the right payload format.StreamChannelis single-consumer; iterating twice raises:run.valuescan only be consumed by oneforloop (single-consumer note:38-40); to fan-out you must callprojection.tee(n)(tee:245), at the cost of buffering.schedule(on_error="raise")turns the close path into fail:on_error="raise"makes a task exception push the mux intoafailduringaclose(on_error:254-257); the default"log"only logs without propagating — choosing wrong lets one transformer's failure take down the whole stream.SubgraphTransformer._should_trackis strictly greater than scope: It only tracks subgraphs one level below the current scope (should_track:634-636); grandchild graphs are discovered recursively throughhandle.subgraphs. If the user skipshandle.subgraphsand looks directly at rootrun.subgraphs, grandchild events will not appear there.
Summary
StreamTransformer is the extension point of the v3 streaming protocol: five built-in transformers turn raw (ns, mode, payload) into iterable typed projections, and users can add their own transformer via the stream_transformers parameter. For the overall dispatch mechanism see StreamMux; for how raw chunks are fed from PregelLoop into the SyncQueue see PregelLoop.
See official docs: LangGraph docs · README.