Skip to content

fix(langgraph): resolve 4 bugs in channels, graph branching, and pregel subgraph traversal#6944

Open
Subham Singhania (Subhamsinghania18) wants to merge 4 commits intolangchain-ai:mainfrom
Subhamsinghania18:fix/langgraph-channels-graph-pregel-bugs
Open

fix(langgraph): resolve 4 bugs in channels, graph branching, and pregel subgraph traversal#6944
Subham Singhania (Subhamsinghania18) wants to merge 4 commits intolangchain-ai:mainfrom
Subhamsinghania18:fix/langgraph-channels-graph-pregel-bugs

Conversation

@Subhamsinghania18
Copy link

@Subhamsinghania18 Subham Singhania (Subhamsinghania18) commented Feb 26, 2026

Four independent bug fixes in the core langgraph package:

  1. BinaryOperatorAggregate: Overwrite not unwrapped when initial value is MISSING

When a channel has no default constructor (self.value = MISSING), the
update() fast-path assigned the first value raw without calling
_get_overwrite(). This stored the Overwrite(x) wrapper object instead
of x, producing silently wrong graph output. It also never set
seen_overwrite, so passing two Overwrite values in the same super-step
raised no error, violating the one-Overwrite-per-super-step contract.

Closes #6909

  1. Topic.copy() crashes with AttributeError when checkpoint holds None

Topic.from_checkpoint() only guarded against MISSING. When a channel
key existed in checkpoint["channel_values"] with an explicit None (observed
with the PostgreSQL saver after partial writes), None was stored in
self.values. The subsequent call to Topic.copy() in local_read() then
crashed:

AttributeError: 'NoneType' object has no attribute 'copy'

from_checkpoint now treats None identically to MISSING. copy() has
an added defensive guard for belt-and-suspenders safety.

Closes #6791

3. KeyError('__end__') when conditional router returns END without explicit path_map entry

_finish() in BranchSpec looked up every router return value in
self.ends unconditionally. If a router returned END without an explicit
"__end__": "__end__" entry in the path map, it raised KeyError.
END is now treated as a pass-through, consistent with the no-path-map
behaviour.

Closes #6770

4. get_subgraphs() fails when node names share a common string prefix

get_subgraphs() used namespace.startswith(name) to filter nodes. If
sibling nodes share a prefix (e.g. "agent" and "agent_v2"), the shorter
name false-matched the longer namespace, stripped the wrong number of
characters, and passed a garbage string into the recursive call — returning
an empty iterator instead of the expected subgraph.

The fix uses an exact delimiter-aware check:
namespace == name or namespace.startswith(name + "|")

Closes #6924

Issue: #6909, #6791, #6770, #6924
Dependencies: None

…OperatorAggregate MISSING fast-path

When a BinaryOperatorAggregate channel has no default constructor and
self.value starts as MISSING, the update() method fast-pathed the first
value directly into self.value without inspecting it.  This had two
observable bugs:

1. An Overwrite(x) wrapper was stored verbatim, so channel.get() returned
   Overwrite(value=x) instead of x.  Graph output was silently wrong.

2. seen_overwrite was never set in the MISSING branch, so passing two
   Overwrite() values in the same super-step raised no error, violating
   the documented one-Overwrite-per-super-step contract.

The fix moves seen_overwrite initialisation before the MISSING check and
runs _get_overwrite() on the first value just like the loop does for all
subsequent values.

Closes: langchain-ai#6909
…and copy

Topic.from_checkpoint() only guarded against MISSING.  When a channel
key exists in checkpoint['channel_values'] with an explicit None value
(observed with the PostgreSQL checkpointer after partial writes), None
was stored in self.values.  The subsequent call to Topic.copy() then
crashed with:

  AttributeError: 'NoneType' object has no attribute 'copy'

This surfaced at runtime inside local_read(fresh=True), typically
triggered by a conditional edge or middleware retry, making it a hard
crash with no recovery path.

Changes:
- from_checkpoint: treat None identically to MISSING so self.values
  always defaults to the empty list defined by __init__.
- copy: add a defensive guard (self.values is not None) so even if None
  leaks in from an unexpected path the method degrades gracefully to an
  empty list rather than crashing.

Closes: langchain-ai#6791
…ath_map

When add_conditional_edges() is called with an explicit path_map, the
_finish() method blindly looked up every router return value in
self.ends.  If the router returned '__end__' (END) without a
corresponding '__end__': '__end__' entry in the map it raised:

  KeyError: '__end__'

This was inconsistent: when no path_map is supplied, returning END works
fine.  The fix special-cases r == END so that it passes through directly
without a dict lookup, matching the no-path_map behaviour and user
expectations.

Returning END without an explicit mapping is intuitive — users should not
have to enumerate the terminal condition as a dict entry just because they
supplied other mappings.

Closes: langchain-ai#6770
get_subgraphs() used str.startswith(name) to filter nodes by namespace.
If two sibling nodes share a common name prefix — e.g. 'agent' and
'agent_v2' — and are added in that order, the iteration could match the
shorter name ('agent') against a namespace meant for the longer one
('agent_v2|child'), then strip the wrong number of characters from the
namespace string, producing garbage like 'v2|child' for the recursive
call.  The recursion found nothing and the method returned an empty
iterator instead of the expected subgraph.

The fix replaces the startswith check with an exact delimiter-aware test:

  namespace == name  or  namespace.startswith(name + NS_SEP)

This ensures 'agent' only matches the namespace 'agent' or 'agent|...',
never 'agent_v2|...'.  NS_SEP is the '|' constant already used
throughout the module.

aget_subgraphs() delegates entirely to get_subgraphs(), so a single
change is sufficient.

Closes: langchain-ai#6924
# incorrectly match the longer namespace. Fixes #6924.
if namespace is not None:
if not namespace.startswith(name):
if namespace != name and not namespace.startswith(name + NS_SEP):
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note, already fixed in #6366 (which also includes a repro test case).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants