Claude
Skills
Sign in
Back

langchain-langgraph-subgraphs

Included with Lifetime
$97 forever

Compose LangGraph 1.0 subgraphs correctly — shared state key propagation, Send / Command(graph=...) dispatch, callback scoping, per-subgraph recursion budgets, and testing each subgraph in isolation. Use when building a planner + executor, a nested agent team, or a reusable subgraph library. Trigger with "langgraph subgraph", "langgraph composition", "langgraph send", "nested agents", "langgraph state propagation", "Command(graph=...)", "langgraph subgraph callbacks".

AI Agentssaaslangchainlanggraphpythonlangchain-1.0subgraphscomposition

What this skill does

# LangGraph Subgraphs and Composition (Python)

## Overview

A parent `StateGraph` invokes a compiled child subgraph as a node. The child
node writes `state["answer"] = "42"` and returns. The parent's next node reads
`state["answer"]` and gets `None`. No error, no warning, no deprecation notice —
just a silent `None` that surfaces as a wrong answer three nodes later when the
router picks the "couldn't find it" branch.

The cause is pain-catalog entry **P21**: LangGraph subgraphs run on an
**independent state schema**. Only keys declared in *both* the parent's
`TypedDict` and the child's `TypedDict` propagate across the subgraph boundary.
`answer` existed in the child schema but not the parent schema, so it was
discarded on return. The fix is to declare `answer` in both schemas (with
matching reducers, if the field is a list) or to use explicit
`Command(graph=ParentGraph, update={"answer": "42"})` to bubble it up.

The second silent failure waits one step further. Attach a tracing callback to
the parent runnable via `parent.with_config(callbacks=[tracer])` and invoke.
The tracer fires on parent nodes and never on child tool calls. This is
pain-catalog entry **P28**: LangGraph creates a fresh runtime per subgraph, so
callbacks bound at *definition time* do not inherit. The fix is to pass
callbacks at *invocation time* via `config["callbacks"]`, which does propagate.

This skill walks through the shared-state contract, three dispatch patterns
(compiled subgraph as a node, `Send` fan-out, `Command(graph=Parent)` bubble-up),
callback scoping, per-subgraph `recursion_limit` budgets, and a testing pattern
that exercises every subgraph in isolation before composition. Pin:
`langgraph 1.0.x`, `langchain-core 1.0.x`. Pain-catalog anchors: **P21, P28**,
with supporting references to P18 (reducers), P19 (stream modes on nested
graphs), and P55 (recursion budget).

A planner-executor is typically **1 parent + 2-4 subgraphs**; a hierarchical
agent team with a supervisor and N specialists is **1 parent + N subgraphs**.
Each subgraph has its own independent `recursion_limit` (default 25) — a parent
at step 20 can still invoke a child that runs 25 of its own steps.

## Prerequisites

- Python 3.10+
- `langgraph >= 1.0, < 2.0`
- `langchain-core >= 1.0, < 2.0`
- Completion of `langchain-langgraph-basics` (L25) — `StateGraph`, `TypedDict`
  state, `Annotated[list, add_messages]` reducer, `MemorySaver` checkpointing
- Test tooling: `pytest`, `langchain_core.language_models.fake_chat_models.FakeListChatModel`

## Instructions

### Step 1 — Declare the shared-state contract explicitly

The single most important decision when composing subgraphs is: **which keys
cross the boundary?** Every key that must survive the call *must* appear in
both `TypedDict` schemas with compatible types and reducers.

```python
from typing import Annotated, TypedDict
from langchain_core.messages import AnyMessage
from langgraph.graph.message import add_messages

# Keys both schemas declare -> these propagate
# Keys only in parent -> invisible to child
# Keys only in child -> discarded on return (P21)

class ParentState(TypedDict):
    # Shared with every subgraph
    messages: Annotated[list[AnyMessage], add_messages]  # P18 reducer required
    session_id: str

    # Parent-only coordination fields
    plan: list[str]
    current_step: int

class ExecutorState(TypedDict):
    # Shared with parent — must match reducer exactly (P18)
    messages: Annotated[list[AnyMessage], add_messages]
    session_id: str

    # Executor-only scratch — parent never sees these
    tool_result: dict | None
    retries: int
```

If `messages` on the child used a different reducer (or no reducer), list
updates would silently replace instead of append on one side of the boundary
(P18). The `messages` + `session_id` pair is the propagation contract. Everything
else is private to its owner.

See [State Contract](references/state-contract.md) for the full
state-propagation matrix and the "subset rule" for schema inheritance.

### Step 2 — Pick the dispatch pattern

Three ways a parent can invoke a subgraph, and each solves a different problem.

**A. Compiled subgraph as a node** — Simplest. Subgraph runs, returns a state
update, parent continues.

```python
from langgraph.graph import StateGraph, END

executor_graph = (
    StateGraph(ExecutorState)
    .add_node("run_tool", run_tool_node)
    .add_node("summarize", summarize_node)
    .add_edge("run_tool", "summarize")
    .add_edge("summarize", END)
    .set_entry_point("run_tool")
    .compile()
)

parent_graph = (
    StateGraph(ParentState)
    .add_node("plan", planner_node)
    .add_node("execute", executor_graph)   # compiled subgraph as a node
    .add_node("finalize", finalize_node)
    .add_edge("plan", "execute")
    .add_edge("execute", "finalize")
    .set_entry_point("plan")
    .compile()
)
```

Only `messages` and `session_id` cross the boundary in either direction (from
Step 1). `tool_result` stays inside the child; `plan` stays inside the parent.

**B. `Send(graph, state)` for fan-out** — One parent step spawns N parallel
subgraph invocations, each with a different slice of state.

```python
from langgraph.types import Send

def dispatch_specialists(state: ParentState) -> list[Send]:
    return [
        Send("specialist_graph", {"messages": state["messages"],
                                   "session_id": state["session_id"],
                                   "topic": topic})
        for topic in state["plan"]
    ]
```

Use `Send` when the number of subgraph calls depends on runtime state.
Reducers on shared keys merge the parallel results.

**C. `Command(graph=ParentGraph, update=...)` to bubble up** — A subgraph node
jumps control back to the parent with an explicit state update, skipping the
rest of the subgraph.

```python
from langgraph.types import Command

def specialist_early_exit(state: ExecutorState) -> Command:
    if state.get("tool_result") and state["tool_result"].get("done"):
        return Command(
            graph=Command.PARENT,
            update={"messages": [AIMessage("done")]},
            goto="finalize",
        )
    return {"retries": state.get("retries", 0) + 1}
```

`Command(graph=Command.PARENT)` is the explicit opposite of P21 — it forces a
field up to the parent scope regardless of schema overlap.

See [Dispatch Patterns](references/dispatch-patterns.md) for the full decision
tree (inline function vs subgraph-as-node vs `Send` vs `Command` vs separate
service) and a sizing guide.

### Step 3 — Scope callbacks at invocation time, not definition time

```python
# WRONG — callbacks bind at definition time and do NOT propagate to subgraphs (P28)
traced_parent = parent_graph.with_config(callbacks=[tracer])
traced_parent.invoke({"messages": [HumanMessage("...")], "session_id": "s1"})
# tracer fires on parent nodes only. Child tool calls are invisible.

# RIGHT — callbacks pass via config at invocation time, propagating into every subgraph
parent_graph.invoke(
    {"messages": [HumanMessage("...")], "session_id": "s1"},
    config={
        "configurable": {"thread_id": "s1"},
        "callbacks": [tracer],
    },
)
# tracer fires on parent nodes AND every child tool, LLM, and chain event.
```

Every production invocation path — API handler, batch worker, test harness —
should pass callbacks via `config["callbacks"]`. Lint for
`with_config(callbacks=` on compiled graphs in CI and flag it.

See [Callback Scoping](references/callback-scoping.md) for the debugging
playbook when a callback "should be firing but isn't."

### Step 4 — Budget recursion per subgraph

LangGraph's `recursion_limit` (default **25** supersteps) is **per-graph, not
global**. A parent graph at superstep 20 invoking a subgraph resets the counter
inside that subgraph to zero. Pros: one runaway subgraph cannot starve the
parent. Cons: adding subgraphs does not reduce your global budget — a poorly
bounded specialist can still rack up 25 of its own step

Related in AI Agents