as far as I can read correctly from the source code:
- A node function is always wrapped in a
RunnableCallable RunnableCallablehas a built‑in rule: if your function returns anotherRunnable, it immediately runs that runnable with the same input and returns its output- Since
CompiledStateGraphis aRunnable, returning it from the node causes the compiled subgraph to be invoked automatically, and the node’s effective output becomes the subgraph’s final state dict - The state‑update machinery then sees a normal dict and updates the parent graph’s state, which is why your example “works” even though the function literally return subgraph
Trying to answer your questions:
Is returning a compiled subgraph from a node an officially supported pattern, or should nodes always return a state-like mapping?
No - it’s not an officially supported or documented pattern.
The contract for a stateful node in StateGraph/CompiledStateGraph is:
- Return a state‑like mapping (dict of updates), or
- A Command (or list/tuple of Commands), or
- A typed/annotated state object that can be converted into updates
If a node returns some arbitrary object, the core CompiledStateGraph.attach_node logic is written to treat that as an error (INVALID_GRAPH_NODE_RETURN_VALUE). The reason your example works is that the node function is wrapped in a RunnableCallable with recurse=True, and that wrapper happens to auto‑invoke any Runnable it gets back - turning your returned CompiledStateGraph into a second‑stage call whose result is a dict, which is then accepted. That’s a side effect of the generic LangChain Runnable wrapper, not an intentional, stable LangGraph API. So for code you care about, you should still write nodes to return state updates / Commands, and use the documented subgraph patterns:
- Graph‑as‑node: builder.add_node(“subgraph”, compiled_subgraph); or
- Explicit call: out = subgraph.invoke(…); return {…state updates…}.
If it is supported, does LangGraph automatically handle synchronous vs asynchronous execution and streaming?
For the documented subgraph patterns, yes:
-
A CompiledStateGraph is a Runnable, so when you add it as a node (builder.add_node(“subgraph”, subgraph)), the PregelNode.bound runnable’s invoke/ainvoke/stream/astream are used automatically, and with subgraphs=True you get proper subgraph‑scoped streaming/events.
-
If you call subgraph.invoke / subgraph.ainvoke / subgraph.stream / subgraph.astream inside a node, the usual LangChain/LangGraph sync, async, and streaming semantics apply.
For the “return a graph from the node” trick specifically:
-
Today, RunnableCallable.invoke and ainvoke do automatically call ret.invoke(…) / ret.ainvoke(…) when your node returns a Runnable, so sync vs async is honored and any streaming the inner graph does is consumed and then forwarded through the outer node’s runnable sequence.
-
But this is an implementation detail of the RunnableCallable(recurse=True) wrapper, not a guaranteed LangGraph node contract. There’s no promise that this auto‑recursion (or its exact streaming behavior) will remain stable, and it doesn’t integrate with the explicit subgraphs=True streaming/inspection machinery.
So: supported and documented for “graph‑as‑node” and “node explicitly invokes subgraph”; not officially supported (just incidentally working) for “have a node return a compiled subgraph and rely on the wrapper to auto‑invoke it.”
Anything you can do by “returning a subgraph so it auto‑invokes” you can already do with the two official approaches (graph‑as‑node, or explicit subgraph.invoke/astream inside the node), but those approaches are clearer, typeable, and easier to integrate with checkpointers, streaming, and tooling.
That’s my judgement and opinion for now ![]()
Conclusions
you’d better utilize the official approaches ![]()