Compare commits

...
Author SHA1 Message Date
Richard Scarrott e5e37b5d70 fix(langgraph): match whole namespace segments in get_subgraphs (#9615)
Follows up [this review
comment](https://github.com/langchain-ai/langgraph/pull/9614#discussion_r4493053567)
on #9614.

## Summary

`get_subgraphs(namespace=...)` matched node names by string prefix and
reassigned `namespace` inside the loop. It now matches whole segments.

```diff
 for name, node in self.nodes
-  skip unless ns.startswith(name)
+  skip unless ns == name or ns.startswith(name + NS_SEP)
-  ns = ns[len(name) + 1:]   # every later node sees this
-  recurse with ns
+  recurse with ns[len(name) + 1:]
```

`_subgraph_for_namespace` filters with `namespace=` again, and #9614's
workaround comment is gone.

**Behaviour change.** With `namespace=` and `recurse=True`,
`get_subgraphs` and `aget_subgraphs` yield only the subgraph at that
namespace. Take a `research` node whose subgraph has a `review` node,
plus top-level `research_review` and `review` nodes:

| `namespace=` | before | after |
| ------------------ | ---------------------------------------- |
------------------ |
| `research_review` | `research\|review`, the parent's `review` |
`research_review` |
| `research\|review` | `research\|review`, the parent's `review` |
`research\|review` |

The Agent Server's `GET
/assistants/{id}/subgraphs/{namespace}?recurse=true` is the only other
caller that passes a namespace, and its response changes the same way.

JS `getSubgraphs` has the same prefix match but doesn't reassign, so for
`research_review` it lists `research|review` before the right graph. JS
state reads compare exact names and are unaffected. JS should get the
same fix in a follow-up.

## Evidence

New sync and async tests:

- `test_get_subgraphs_matches_whole_namespace_segments` checks that each
namespace yields only its own graph. It fails on `main`.
- `test_nested_namespace_update_raises_despite_top_level_namesake`
checks that `update_state` on `research|review` raises when `research`
declares no `review`, instead of writing through the parent's `review`.
It fails if only `get_subgraphs` is reverted.

`make format lint test` passes.

## Merge Danger

**Door:** two-way

**Blast Radius:** small. Callers that pass `namespace=` get fewer
results, and each result they lose was at the wrong namespace.
2026-11-06 16:08:12 +00:00
3 changed files with 170 additions and 17 deletions

No files matched your search

+16 -15
View File
@@ -905,14 +905,10 @@ class Pregel(
if not checkpoint_ns or CONFIG_KEY_CHECKPOINTER in config[CONF]:
return None
recast = recast_checkpoint_ns(checkpoint_ns)
# Walk every subgraph rather than filter by `namespace=`: that filter
# matches by prefix, so for `research_review` it can yield
# `research|review` and skip a declared `research_review`.
for name, pregel in self.get_subgraphs(recurse=True):
if name == recast:
return pregel, patch_configurable(
config, {CONFIG_KEY_CHECKPOINTER: checkpointer}
)
for _, pregel in self.get_subgraphs(namespace=recast, recurse=True):
return pregel, patch_configurable(
config, {CONFIG_KEY_CHECKPOINTER: checkpointer}
)
if for_update:
raise ValueError(f"Subgraph {recast} not found")
return None
@@ -1161,7 +1157,9 @@ class Pregel(
"""Get the subgraphs of the graph.
Args:
namespace: The namespace to filter the subgraphs by.
namespace: Yield only the subgraph at this exact namespace, such
as `parent|child` (without task IDs). A nested namespace needs
`recurse=True`.
recurse: Whether to recurse into the subgraphs.
If `False`, only the immediate subgraphs will be returned.
@@ -1169,9 +1167,9 @@ class Pregel(
An iterator of the `(namespace, subgraph)` pairs.
"""
for name, node in self.nodes.items():
# filter by prefix
# filter by the namespace's first segment, matched whole
if namespace is not None:
if not namespace.startswith(name):
if namespace != name and not namespace.startswith(name + NS_SEP):
continue
# find the subgraph, if any
@@ -1185,12 +1183,13 @@ class Pregel(
if namespace is None:
yield name, graph
if recurse and isinstance(graph, Pregel):
if namespace is not None:
namespace = namespace[len(name) + 1 :]
child_namespace = (
None if namespace is None else namespace[len(name) + 1 :]
)
yield from (
(f"{name}{NS_SEP}{n}", s)
for n, s in graph.get_subgraphs(
namespace=namespace, recurse=recurse
namespace=child_namespace, recurse=recurse
)
)
@@ -1200,7 +1199,9 @@ class Pregel(
"""Get the subgraphs of the graph.
Args:
namespace: The namespace to filter the subgraphs by.
namespace: Yield only the subgraph at this exact namespace, such
as `parent|child` (without task IDs). A nested namespace needs
`recurse=True`.
recurse: Whether to recurse into the subgraphs.
If `False`, only the immediate subgraphs will be returned.
@@ -8,7 +8,8 @@ Covers three checkpointer settings for subgraph state:
- checkpointer=True: "stateful" — state accumulates across invocations on the same thread id
Also covers reading a subgraph's namespace from the parent graph, including
one where no subgraph is declared, such as a subagent a tool invokes.
one where no subgraph is declared, such as a subagent a tool invokes, and
finding the subgraph a namespace names.
"""
from typing import Annotated, Any
@@ -916,3 +917,77 @@ def test_prefix_named_namespaces_resolve_to_their_own_graph(
else:
# The parent's channels have no `notes` or `verdict`.
assert values("research_review") == {"messages": ["check it", "review done"]}
def _prefix_named_subgraphs() -> tuple[Any, dict[str, Any]]:
"""A parent whose subgraph namespaces start with one another's names:
`research` has a `review` subgraph, and the parent declares
`research_review` and `review` too. Returns the parent and each subgraph,
keyed by its namespace."""
graphs = {
"research|review": _notes_graph("research's review"),
"research_review": _notes_graph("research_review"),
"review": _notes_graph("review"),
}
graphs["research"] = (
StateGraph(NotesState)
.add_node("review", graphs["research|review"])
.add_edge(START, "review")
.compile()
)
parent = (
StateGraph(NotesState)
.add_node("research", graphs["research"])
.add_node("research_review", graphs["research_review"])
.add_node("review", graphs["review"])
.add_edge(START, "research")
.add_edge("research", "research_review")
.add_edge("research_review", "review")
.compile()
)
return parent, graphs
@pytest.mark.parametrize(
"namespace", ["research", "research|review", "research_review", "review"]
)
def test_get_subgraphs_matches_whole_namespace_segments(namespace: str) -> None:
"""`get_subgraphs(namespace=...)` yields only the subgraph at that
namespace. `research_review` isn't `research` then `review`, and
`research|review` doesn't also find the parent's own `review`."""
parent, graphs = _prefix_named_subgraphs()
assert list(parent.get_subgraphs(namespace=namespace, recurse=True)) == [
(namespace, graphs[namespace])
]
def test_nested_namespace_update_raises_despite_top_level_namesake(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""`research|review` names a `review` inside `research`'s subgraph, which
declares no subgraph there. An update to it raises rather than writing
through the parent's own `review`."""
research = (
StateGraph(NotesState)
.add_node("review", lambda state: {})
.add_edge(START, "review")
.compile()
)
parent = (
StateGraph(NotesState)
.add_node("research", research)
.add_node("review", _notes_graph("review"))
.add_edge(START, "research")
.add_edge("research", "review")
.compile(checkpointer=sync_checkpointer)
)
config: RunnableConfig = {
"configurable": {
"thread_id": str(uuid4()),
"checkpoint_ns": "research:a|review:b",
}
}
with pytest.raises(ValueError, match=r"Subgraph research\|review not found"):
parent.update_state(config, {"notes": "edit"})
@@ -8,7 +8,8 @@ Covers three checkpointer settings for subgraph state:
- checkpointer=True: "stateful" — state accumulates across invocations on the same thread id
Also covers reading a subgraph's namespace from the parent graph, including
one where no subgraph is declared, such as a subagent a tool invokes.
one where no subgraph is declared, such as a subagent a tool invokes, and
finding the subgraph a namespace names.
"""
import sys
@@ -949,3 +950,79 @@ async def test_prefix_named_namespaces_resolve_to_their_own_graph_async(
assert await values("research_review") == {
"messages": ["check it", "review done"]
}
def _prefix_named_subgraphs() -> tuple[Any, dict[str, Any]]:
"""A parent whose subgraph namespaces start with one another's names:
`research` has a `review` subgraph, and the parent declares
`research_review` and `review` too. Returns the parent and each subgraph,
keyed by its namespace."""
graphs = {
"research|review": _notes_graph("research's review"),
"research_review": _notes_graph("research_review"),
"review": _notes_graph("review"),
}
graphs["research"] = (
StateGraph(NotesState)
.add_node("review", graphs["research|review"])
.add_edge(START, "review")
.compile()
)
parent = (
StateGraph(NotesState)
.add_node("research", graphs["research"])
.add_node("research_review", graphs["research_review"])
.add_node("review", graphs["review"])
.add_edge(START, "research")
.add_edge("research", "research_review")
.add_edge("research_review", "review")
.compile()
)
return parent, graphs
@pytest.mark.parametrize(
"namespace", ["research", "research|review", "research_review", "review"]
)
async def test_get_subgraphs_matches_whole_namespace_segments_async(
namespace: str,
) -> None:
"""`aget_subgraphs(namespace=...)` yields only the subgraph at that
namespace. `research_review` isn't `research` then `review`, and
`research|review` doesn't also find the parent's own `review`."""
parent, graphs = _prefix_named_subgraphs()
assert [
s async for s in parent.aget_subgraphs(namespace=namespace, recurse=True)
] == [(namespace, graphs[namespace])]
async def test_nested_namespace_update_raises_despite_top_level_namesake_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""`research|review` names a `review` inside `research`'s subgraph, which
declares no subgraph there. An update to it raises rather than writing
through the parent's own `review`."""
research = (
StateGraph(NotesState)
.add_node("review", lambda state: {})
.add_edge(START, "review")
.compile()
)
parent = (
StateGraph(NotesState)
.add_node("research", research)
.add_node("review", _notes_graph("review"))
.add_edge(START, "research")
.add_edge("research", "review")
.compile(checkpointer=async_checkpointer)
)
config: RunnableConfig = {
"configurable": {
"thread_id": str(uuid4()),
"checkpoint_ns": "research:a|review:b",
}
}
with pytest.raises(ValueError, match=r"Subgraph research\|review not found"):
await parent.aupdate_state(config, {"notes": "edit"})