Skip to content

Commit ef5cff1

Browse files
authored
Merge pull request #236 from ai-agent-assembly/v0.1.0/AAASM-4472/fix/langgraph_subgraph_lineage
[AAASM-4472] 🐛 (langgraph): Fix subgraph-as-node lineage tracking gap
2 parents b8b4e53 + 1b9095b commit ef5cff1

2 files changed

Lines changed: 116 additions & 7 deletions

File tree

agent_assembly/adapters/langgraph/patch.py

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,58 @@ def _wrap_subgraph_spawn_node(node_map: Any, node_name: Any, node_executor: Any,
360360
return True
361361

362362

363+
def _wrap_subgraph_invoke_methods_in_place(node_name: Any, subgraph: Any, process_agent_id: str | None) -> bool:
364+
"""Wrap a compiled subgraph's own invoke/ainvoke in place for lineage.
365+
366+
AAASM-4472: used when the subgraph is reached via an already-unwrapped
367+
``PregelNode.bound`` rather than arriving bare in the node map. Unlike
368+
``_wrap_subgraph_spawn_node`` (which replaces the whole node_map entry for a
369+
bare subgraph), the outer ``PregelNode`` container here must stay intact —
370+
its channels/triggers/mapper/writers are what the Pregel runtime uses to
371+
build the executable task — so this mutates the bound Runnable's own
372+
invoke/ainvoke attributes instead. The original methods are captured before
373+
reassignment so the wrapper calls the real invoke/ainvoke rather than
374+
recursing into itself once installed.
375+
"""
376+
node_delegation_reason = f"langgraph_node:{node_name}"
377+
wrapped_any = False
378+
379+
original_invoke = getattr(subgraph, "invoke", None)
380+
if callable(original_invoke):
381+
382+
def sync_subgraph_wrapper(*args: Any, **kwargs: Any) -> Any:
383+
spawn_ctx = SpawnContext(
384+
parent_agent_id=process_agent_id or "",
385+
depth=_current_spawn_depth(),
386+
spawned_by_tool=None,
387+
delegation_reason=node_delegation_reason,
388+
)
389+
with spawn_context_scope(spawn_ctx):
390+
return original_invoke(*args, **kwargs)
391+
392+
subgraph.invoke = sync_subgraph_wrapper
393+
wrapped_any = True
394+
395+
original_ainvoke = getattr(subgraph, "ainvoke", None)
396+
if callable(original_ainvoke) and not getattr(subgraph, "_agent_assembly_ainvoke_spawned", False):
397+
398+
async def async_subgraph_wrapper(*args: Any, **kwargs: Any) -> Any:
399+
spawn_ctx = SpawnContext(
400+
parent_agent_id=process_agent_id or "",
401+
depth=_current_spawn_depth(),
402+
spawned_by_tool=None,
403+
delegation_reason=node_delegation_reason,
404+
)
405+
with spawn_context_scope(spawn_ctx):
406+
return await original_ainvoke(*args, **kwargs)
407+
408+
subgraph.ainvoke = async_subgraph_wrapper
409+
subgraph._agent_assembly_ainvoke_spawned = True
410+
wrapped_any = True
411+
412+
return wrapped_any
413+
414+
363415
def _wrap_node_invoke_methods(node_name: Any, node_executor: Any, callback_handler: Any) -> bool:
364416
"""Wrap a node executor's invoke/ainvoke methods. Return True if either was wrapped."""
365417
wrapped_any = False
@@ -384,19 +436,28 @@ def _wrap_node_entry(
384436
if _is_tool_node(node_executor):
385437
return _wrap_tool_node_subgraphs(node_executor, process_agent_id)
386438

439+
# AAASM-4434/AAASM-4472: current langgraph node-map entries are PregelNode
440+
# wrappers whose own .invoke/.ainvoke are never called by the Pregel runtime
441+
# — it dispatches through PregelNode.bound. Unwrap *before* any other
442+
# detection (including _is_compiled_subgraph) and dispatch against the
443+
# unwrapped object: checking _is_compiled_subgraph() against the outer
444+
# PregelNode itself always fails (only the wrapped object exposes `.nodes`),
445+
# so subgraph detection must run post-unwrap to work whether a subgraph
446+
# arrives bare or already PregelNode-wrapped.
447+
if _is_pregel_node_wrapper(node_executor):
448+
bound = node_executor.bound
449+
if _is_compiled_subgraph(bound):
450+
return _wrap_subgraph_invoke_methods_in_place(node_name, bound, process_agent_id)
451+
return _wrap_node_invoke_methods(node_name, bound, callback_handler)
452+
387453
if callable(node_executor):
388454
return _wrap_callable_node_executor(node_map, node_name, node_executor, callback_handler)
389455

390-
# Spawn point: node is itself a compiled subgraph — wrap for lineage.
456+
# Spawn point: node is itself a compiled subgraph, not PregelNode-wrapped —
457+
# wrap for lineage.
391458
if _is_compiled_subgraph(node_executor):
392459
return _wrap_subgraph_spawn_node(node_map, node_name, node_executor, process_agent_id)
393460

394-
# AAASM-4434: current langgraph node-map entries are PregelNode wrappers whose
395-
# own .invoke/.ainvoke are never called by the Pregel runtime — it dispatches
396-
# through PregelNode.bound. Wrap that inner Runnable instead, or hooks never fire.
397-
if _is_pregel_node_wrapper(node_executor):
398-
return _wrap_node_invoke_methods(node_name, node_executor.bound, callback_handler)
399-
400461
return _wrap_node_invoke_methods(node_name, node_executor, callback_handler)
401462

402463

test/integration/test_langgraph_real_package_smoke.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
patch as langgraph_patch_module,
3838
)
3939
from agent_assembly.adapters.langgraph.patch import LangGraphPatch # noqa: E402
40+
from agent_assembly.core.spawn import _SPAWN_CTX, SpawnContext # noqa: E402
4041

4142

4243
@pytest.fixture(autouse=True)
@@ -149,6 +150,53 @@ def test_real_stategraph_async_ainvoke_fires_node_hooks() -> None:
149150
patch.revert()
150151

151152

153+
@pytest.mark.integration
154+
def test_real_stategraph_subgraph_as_node_propagates_spawn_lineage() -> None:
155+
"""A compiled subgraph used as a parent-graph node must get spawn lineage.
156+
157+
Regresses AAASM-4472: ``_wrap_node_entry`` checked ``_is_compiled_subgraph()``
158+
against the *outer* ``PregelNode`` wrapper before unwrapping it via ``.bound``.
159+
That outer wrapper never exposes ``.nodes``, so the check failed and the code
160+
fell through to generic hook-wrapping instead of ``_wrap_subgraph_spawn_node``.
161+
Coarse hooks still fired, but ``_SPAWN_CTX`` was never set inside the
162+
subgraph's own nodes, so parent-agent lineage was lost for the standard
163+
multi-agent delegation pattern (``parent_graph.add_node("sub", compiled_subgraph)``).
164+
"""
165+
captured_spawn_ctx: list[SpawnContext | None] = []
166+
167+
def _inner_node(state: _CounterState) -> dict[str, int]:
168+
captured_spawn_ctx.append(_SPAWN_CTX.get())
169+
return {"count": state["count"] + 1}
170+
171+
sub_graph = StateGraph(_CounterState)
172+
sub_graph.add_node("inner", _inner_node)
173+
sub_graph.add_edge(START, "inner")
174+
sub_graph.add_edge("inner", END)
175+
compiled_subgraph = sub_graph.compile()
176+
177+
recorder = _EventRecorder()
178+
patch = LangGraphPatch(callback_handler=recorder, process_agent_id="parent-agent-42")
179+
assert patch.apply() is True
180+
181+
try:
182+
parent_graph = StateGraph(_CounterState)
183+
parent_graph.add_node("sub", compiled_subgraph)
184+
parent_graph.add_edge(START, "sub")
185+
parent_graph.add_edge("sub", END)
186+
compiled_parent = parent_graph.compile()
187+
188+
result = compiled_parent.invoke(_CounterState(count=0))
189+
190+
assert result == {"count": 1}
191+
assert len(captured_spawn_ctx) == 1
192+
spawn_ctx = captured_spawn_ctx[0]
193+
assert spawn_ctx is not None, "expected _SPAWN_CTX to be populated inside the subgraph's own node"
194+
assert spawn_ctx.parent_agent_id == "parent-agent-42"
195+
assert spawn_ctx.delegation_reason == "langgraph_node:sub"
196+
finally:
197+
patch.revert()
198+
199+
152200
def test_revert_restores_the_real_stategraph_compile() -> None:
153201
"""``revert()`` must restore the genuine (unpatched) ``StateGraph.compile``."""
154202
original_compile = StateGraph.compile

0 commit comments

Comments
 (0)