Dynamic subgraphs?

Hi @sudokpr and @wfh

as far as I can read correctly from the source code:

  • A node function is always wrapped in a RunnableCallable
  • RunnableCallable has a built‑in rule: if your function returns another Runnable, it immediately runs that runnable with the same input and returns its output
  • Since CompiledStateGraph is a Runnable, 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 :slight_smile:

Conclusions

you’d better utilize the official approaches :smiley: