Skip to content

fix(langgraph): add compile-time detection of detached nodes#6751

Open
Saakshi Gupta (saakshigupta2002) wants to merge 3 commits intolangchain-ai:mainfrom
saakshigupta2002:fix/6735-detect-detached-nodes
Open

fix(langgraph): add compile-time detection of detached nodes#6751
Saakshi Gupta (saakshigupta2002) wants to merge 3 commits intolangchain-ai:mainfrom
saakshigupta2002:fix/6735-detect-detached-nodes

Conversation

@saakshigupta2002
Copy link
Copy Markdown

@saakshigupta2002 Saakshi Gupta (saakshigupta2002) commented Feb 5, 2026

Pull Request: Compile-time Detection of Detached Nodes

Summary

This PR introduces optional validation to detect and handle detached nodes during graph compilation. Detached nodes are either:

  • Unreachable nodes: Nodes that are not reachable from START
  • Dead-end nodes: Nodes that have no path to END

These scenarios often indicate bugs in graph construction that are currently silently ignored, leading to hard-to-debug issues in production.

Changes

New Parameter: on_detached_nodes

Added a new on_detached_nodes parameter to StateGraph.compile() with three modes:

Mode Behavior
"warn" (default) Log a warning but compile successfully
"raise" Raise a ValueError and fail compilation
"ignore" Disable detection (useful for dynamic routing)

Implementation Details

Detection is implemented in CompiledStateGraph._check_detached_nodes(), which leverages the existing compiled channel/writer infrastructure rather than building a separate graph representation.

After attach_node, attach_edge, and attach_branch populate the channel system, the method:

  1. Scans all nodes' writers to collect every channel that is written to via ChannelWriteEntry and ChannelWriteTupleEntry.static declarations
  2. Detects unreachable nodes by checking if any writer targets the node's branch:to:{name} trigger channel — if not, nothing routes to this node
  3. Detects dead-end nodes by checking if a node has any outgoing connection: writers to branch:to:* channels, explicit edges to END in the builder, conditional branches, or Command destinations declared in static

This approach is idiomatic to LangGraph's internal architecture and avoids duplicating connectivity information that the channel system already captures.

Example Usage

from langgraph.graph import StateGraph, START, END

class State(TypedDict):
    messages: list[str]

graph = StateGraph(State)
graph.add_node("process", process_fn)
graph.add_node("orphan", orphan_fn)  # Forgot to connect this!
graph.add_edge(START, "process")
graph.add_edge("process", END)

# Default behavior: warns about detached nodes but compiles
compiled = graph.compile()  # Logs: "Found unreachable node(s): ['orphan']"

# Strict mode: fails compilation
compiled = graph.compile(on_detached_nodes="raise")  # Raises ValueError

# For dynamic routing: disable detection
compiled = graph.compile(on_detached_nodes="ignore")  # No warnings

Important Notes for Dynamic Routing

For nodes that use Command objects or conditional edges without explicit mappings, the destinations cannot be detected at compile time. In such cases:

  1. Use the destinations parameter when adding nodes:

    graph.add_node(
        "router",
        router_fn,
        destinations={"target_a": "Label A", "target_b": "Label B"}
    )
  2. Or set on_detached_nodes="ignore" to disable validation.

Test Plan

Added comprehensive test coverage for:

  • Single unreachable node detection
  • Multiple unreachable nodes detection
  • Sink node without path to END detection
  • Warning mode logs warning but compiles
  • Ignore mode suppresses warnings
  • Conditional edges with explicit mapping
  • Conditional edges without mapping (dynamic routing)
  • Command destinations (dict format)
  • Command destinations (tuple format)
  • Multi-source waiting edges
  • Diamond pattern with conditional routing
  • Cyclic graphs with conditional exit
  • Minimal single node graph
  • Command with goto=END
  • Parallel branches all reaching END
  • Mixed unreachable and sink nodes

Backwards Compatibility

This change is fully backwards compatible:

  • Default behavior is "warn", which only logs warnings
  • Existing code continues to work without modification
  • No breaking changes to the API

Related Issue

Fixes: #6735


Files Changed

File Changes
libs/langgraph/langgraph/graph/state.py Added _check_detached_nodes() to CompiledStateGraph, added on_detached_nodes parameter to compile()
libs/langgraph/tests/test_pregel.py Added 16 test cases

This commit introduces optional validation to detect and handle detached
nodes (unreachable nodes and dead-end nodes) during graph compilation.

Changes:
- Add `on_detached_nodes` parameter to `StateGraph.compile()` with three modes:
  - "warn" (default): Log a warning but compile successfully
  - "raise": Raise a ValueError and fail compilation
  - "ignore": Disable detection for dynamic routing scenarios

- Add new methods to StateGraph for graph analysis:
  - `_adjacency`: Build adjacency list from edges, branches, and Command destinations
  - `_reverse_adjacency`: Reverse adjacency for predecessor lookups
  - `_nodes_reachable_from_start()`: BFS from START to find reachable nodes
  - `_unreachable_nodes()`: Find nodes not reachable from START
  - `_sink_nodes()`: Find nodes with no outgoing edges
  - `_dead_end_nodes()`: Find sink nodes that aren't END

- Add comprehensive test coverage for all scenarios

Fixes: langchain-ai#6735
@hinthornw
Copy link
Copy Markdown
Collaborator

It should be enough to check the channels if you wanted

@saakshigupta2002
Copy link
Copy Markdown
Author

It should be enough to check the channels if you wanted

Hey, thanks for taking a look! :) That's a great point! I hadn't considered using the channels directly for this. Since the branch:to:{node} channels already capture the connectivity after edges and branches are attached, checking which channels have no writers (unreachable) and which nodes don't write to any outgoing channel (dead ends) would be way cleaner than building a separate adjacency list and doing BFS on top of it.

I'll rework the implementation to move the detection into the compiled graph stage where the channel info is available. Should keep things a lot simpler and more in line with how the rest of the codebase works. Will push an update soon!

Rework the detached node detection to leverage the existing compiled
channel/writer infrastructure instead of building a separate adjacency
list with BFS traversal.

Changes:
- Move detection from StateGraph.validate() to
  CompiledStateGraph._check_detached_nodes(), which runs after all
  attach_node/attach_edge/attach_branch calls when channel info is
  fully populated
- Unreachable nodes: check which branch:to:{node} channels have no
  writers from ChannelWriteEntry or ChannelWriteTupleEntry.static
- Dead-end nodes: check if a node writes to any branch:to:* channel,
  has edges to END in builder.edges, or has conditional branches
- Remove the old _adjacency, _reverse_adjacency, BFS reachability,
  _unreachable_nodes, _sink_nodes, _dead_end_nodes methods and their
  deque/cached_property imports from StateGraph
- Revert validate() to its original signature (no on_detached_nodes)

The external API (compile(on_detached_nodes=...)) is unchanged.
@mdrxy Mason Daugherty (mdrxy) added the bypass-issue-check Maintainer override: skip issue-link enforcement label Mar 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bypass-issue-check Maintainer override: skip issue-link enforcement external

Projects

None yet

Development

Successfully merging this pull request may close these issues.

request: Compile-time validation of nodes that aren't statically linked

3 participants