Compare commits

...
Author SHA1 Message Date
Eugene Yurtsev febf241b30 x 2025-11-07 11:50:47 -05:00
2 changed files with 81 additions and 9 deletions
+9 -9
View File
@@ -9,7 +9,7 @@ from collections.abc import Awaitable, Callable, Sequence
from dataclasses import replace
from typing import Any
from langgraph._internal._config import patch_configurable
from langgraph._internal._config import patch_configurable, recast_checkpoint_ns
from langgraph._internal._constants import (
CONF,
CONFIG_KEY_CHECKPOINT_NS,
@@ -43,16 +43,16 @@ def run_with_retry(
except ParentCommand as exc:
ns: str = config[CONF][CONFIG_KEY_CHECKPOINT_NS]
cmd = exc.args[0]
if cmd.graph in (ns, task.name):
if cmd.graph in (recast_checkpoint_ns(ns), task.name):
# this command is for the current graph, handle it
for w in task.writers:
w.invoke(cmd, config)
break
elif cmd.graph == Command.PARENT:
# this command is for the parent graph, assign it to the parent
parts = ns.split(NS_SEP)
if parts[-1].isdigit():
parts.pop()
# normalize namespace by removing task IDs
recast_ns = recast_checkpoint_ns(ns)
parts = recast_ns.split(NS_SEP)
parent_ns = NS_SEP.join(parts[:-1])
exc.args = (replace(cmd, graph=parent_ns),)
# bubble up
@@ -138,16 +138,16 @@ async def arun_with_retry(
except ParentCommand as exc:
ns: str = config[CONF][CONFIG_KEY_CHECKPOINT_NS]
cmd = exc.args[0]
if cmd.graph in (ns, task.name):
if cmd.graph in (recast_checkpoint_ns(ns), task.name):
# this command is for the current graph, handle it
for w in task.writers:
w.invoke(cmd, config)
break
elif cmd.graph == Command.PARENT:
# this command is for the parent graph, assign it to the parent
parts = ns.split(NS_SEP)
if parts[-1].isdigit():
parts.pop()
# normalize namespace by removing task IDs
recast_ns = recast_checkpoint_ns(ns)
parts = recast_ns.split(NS_SEP)
parent_ns = NS_SEP.join(parts[:-1])
exc.args = (replace(cmd, graph=parent_ns),)
# bubble up
+72
View File
@@ -7911,6 +7911,78 @@ def test_parent_command_goto(
}
@pytest.mark.parametrize("subgraph_persist", [True, False])
def test_parent_command_goto_deeply_nested(
sync_checkpointer: BaseCheckpointSaver, subgraph_persist: bool
) -> None:
"""Test Command.PARENT with goto in deeply nested graphs (3+ levels).
This tests the fix for issue #6409 where Command.PARENT with goto
would fail in graphs with 3 or more levels of nesting due to
namespace comparison issues.
"""
class State(TypedDict):
messages: Annotated[list[str], operator.add]
# Level 3 (deepest): sub_sub_graph
def sub_sub_node(state):
"""Returns Command.PARENT to jump to grandparent's node."""
return Command(
graph=Command.PARENT,
goto="sub_node_3",
update={"messages": ["sub_sub_node"]},
)
sub_sub_builder = StateGraph(State)
sub_sub_builder.add_node("sub_sub_node", sub_sub_node)
sub_sub_builder.add_edge(START, "sub_sub_node")
sub_sub_graph = sub_sub_builder.compile(checkpointer=subgraph_persist)
# Level 2 (middle): sub_graph
def sub_node_1(state):
return {"messages": ["sub_node_1"]}
def sub_node_3(state):
"""Target node for Command.PARENT goto."""
return {"messages": ["sub_node_3"]}
sub_builder = StateGraph(State)
sub_builder.add_node("sub_node_1", sub_node_1)
sub_builder.add_node("sub_node_2", sub_sub_graph)
sub_builder.add_node("sub_node_3", sub_node_3)
sub_builder.add_edge(START, "sub_node_1")
sub_builder.add_edge("sub_node_1", "sub_node_2")
sub_graph = sub_builder.compile(checkpointer=subgraph_persist)
# Level 1 (top): main_graph
def main_node_1(state):
return {"messages": ["main_node_1"]}
main_builder = StateGraph(State)
main_builder.add_node("main_node_1", main_node_1)
main_builder.add_node("main_node_2", sub_graph)
main_builder.add_edge(START, "main_node_1")
main_builder.add_edge("main_node_1", "main_node_2")
main_graph = main_builder.compile(sync_checkpointer, name="main")
config = {"configurable": {"thread_id": 1}}
result = main_graph.invoke(input={"messages": ["start"]}, config=config)
# Verify the execution order includes all expected nodes.
# Note: When subgraphs have persistent checkpointers, parent state
# is passed down, which may cause message duplication at subgraph boundaries.
# The key assertion is that:
# 1. All expected messages appear in order
# 2. sub_node_3 executed (proving Command.PARENT goto worked)
expected_messages = ["main_node_1", "sub_node_1", "sub_sub_node", "sub_node_3"]
assert all(msg in result["messages"] for msg in expected_messages), (
f"Expected all messages {expected_messages} to be in result {result['messages']}"
)
# Verify sub_node_3 executed last (the Command.PARENT goto target)
assert result["messages"][-1] == "sub_node_3"
@pytest.mark.parametrize("with_timeout", [True, False])
def test_timeout_with_parent_command(
sync_checkpointer: BaseCheckpointSaver, with_timeout: bool