Skip to content

Commit c8fca66

Browse files
authored
Merge pull request #53 from aurelio-labs/feat/mulitple-activated-branches-in-router
feat: allow duplicate node names in parallel
2 parents 3f381da + fc5214b commit c8fca66

4 files changed

Lines changed: 135 additions & 8 deletions

File tree

graphai/graph.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -362,9 +362,19 @@ async def _execute_branch(
362362
callback: Callback,
363363
steps: int,
364364
stop_at_join: bool = False,
365+
branch_id: str | None = None,
365366
):
366367
"""Recursively execute a branch starting from `current_node`.
367-
When a node has multiple successors, run them concurrently and merge their outputs."""
368+
When a node has multiple successors, run them concurrently and merge their outputs.
369+
370+
Args:
371+
current_node: The node to start execution from
372+
state: The current state dict to pass to the node
373+
callback: The callback instance for events
374+
steps: Current step count for max_steps limit
375+
stop_at_join: If True, stop execution when reaching a join node
376+
branch_id: Unique identifier for this branch (used for parallel duplicate nodes)
377+
"""
368378
while True:
369379
output = await self._invoke_node(current_node, state, callback)
370380
state = {**state, **output} # merge node output into local state
@@ -396,19 +406,32 @@ async def _execute_branch(
396406
if len(next_nodes) == 1:
397407
current_node = next_nodes[0]
398408
else:
399-
# Run each branch concurrently
400-
results = await asyncio.gather(
401-
*[
409+
# Run each branch concurrently, assigning unique branch IDs
410+
# This ensures that even duplicate nodes (same node appearing multiple times)
411+
# are tracked as separate invocations
412+
branch_tasks = []
413+
for idx, n in enumerate(next_nodes):
414+
# Create unique branch ID combining parent branch, node name, and index
415+
new_branch_id = f"{branch_id or 'root'}_{n.name}_{idx}"
416+
branch_tasks.append(
402417
self._execute_branch(
403418
current_node=n,
404419
state=state.copy(),
405420
callback=callback,
406421
steps=steps + 1,
407422
stop_at_join=True, # force parallel branches to wait at JoinEdge
423+
branch_id=new_branch_id,
408424
)
409-
for n in next_nodes
410-
]
425+
)
426+
427+
# Wait for ALL branches to complete (including duplicate node invocations)
428+
logger.debug(
429+
f"Starting {len(branch_tasks)} parallel branches: "
430+
f"{[n.name for n in next_nodes]}"
411431
)
432+
results = await asyncio.gather(*branch_tasks)
433+
logger.debug(f"All {len(results)} parallel branches completed")
434+
412435
# merge states returned by each branch
413436
merged = state.copy()
414437
for res in results:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "graphai-lib"
3-
version = "0.0.10"
3+
version = "0.0.11rc1"
44
description = "Not an AI framework"
55
readme = "README.md"
66
requires-python = ">=3.10,<3.14"

tests/unit/test_graph_parallel.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,3 +370,107 @@ async def end(input: dict):
370370
assert result["a_result"] == 1
371371
assert "b_result" not in result
372372
assert "parallel_results" not in result
373+
374+
375+
@pytest.mark.asyncio
376+
async def test_router_parallel_duplicate_node_names():
377+
"""Router returning duplicate node names should execute the same node multiple times.
378+
379+
Uses different sleep durations (1s vs 3s) to verify all branches are waited for.
380+
"""
381+
import asyncio
382+
383+
call_count = {"tool_a": 0, "router": 0}
384+
sleep_times = [3, 3, 1] # Last invocation is fast, others are slow
385+
386+
@node(start=True)
387+
async def start(input: dict):
388+
return {}
389+
390+
@router(name="parallel_router")
391+
async def parallel_router(input: dict):
392+
call_count["router"] += 1
393+
if call_count["router"] > 1:
394+
return {"choice": "end"}
395+
# Return the same node name multiple times
396+
return {"choices": ["tool_a", "tool_a", "tool_a"]}
397+
398+
@node(name="tool_a")
399+
async def tool_a(input: dict):
400+
call_count["tool_a"] += 1
401+
sleep_time = sleep_times.pop()
402+
await asyncio.sleep(sleep_time)
403+
return {"a_result": call_count["tool_a"]}
404+
405+
@node(end=True)
406+
async def end(input: dict):
407+
return {"final": "done"}
408+
409+
g = Graph()
410+
g.add_node(start).add_node(parallel_router).add_node(tool_a).add_node(end)
411+
g.add_edge(start, parallel_router)
412+
g.add_edge(parallel_router, tool_a)
413+
g.add_join([tool_a], parallel_router)
414+
g.add_edge(parallel_router, end)
415+
416+
result = await g.execute({"input": {}})
417+
418+
# tool_a should have been called 3 times (once for each duplicate in choices)
419+
assert call_count["tool_a"] == 3
420+
# Router should have been called twice (first for parallel, second to continue to end)
421+
assert call_count["router"] == 2
422+
assert result["final"] == "done"
423+
424+
425+
@pytest.mark.asyncio
426+
async def test_router_parallel_mixed_duplicate_nodes():
427+
"""Router returning a mix of unique and duplicate node names."""
428+
import asyncio
429+
430+
call_count = {"tool_a": 0, "tool_b": 0, "router": 0}
431+
432+
@node(start=True)
433+
async def start(input: dict):
434+
return {}
435+
436+
@router(name="parallel_router")
437+
async def parallel_router(input: dict):
438+
call_count["router"] += 1
439+
if call_count["router"] > 1:
440+
return {"choice": "end"}
441+
# Mix of unique and duplicate nodes
442+
return {"choices": ["tool_a", "tool_b", "tool_a", "tool_b", "tool_a"]}
443+
444+
@node(name="tool_a")
445+
async def tool_a(input: dict):
446+
call_count["tool_a"] += 1
447+
await asyncio.sleep(0.01)
448+
return {"a_count": call_count["tool_a"]}
449+
450+
@node(name="tool_b")
451+
async def tool_b(input: dict):
452+
call_count["tool_b"] += 1
453+
await asyncio.sleep(0.01)
454+
return {"b_count": call_count["tool_b"]}
455+
456+
@node(end=True)
457+
async def end(input: dict):
458+
return {"final": "done"}
459+
460+
g = Graph()
461+
g.add_node(start).add_node(parallel_router).add_node(tool_a).add_node(tool_b).add_node(end)
462+
g.add_edge(start, parallel_router)
463+
g.add_edge(parallel_router, tool_a)
464+
g.add_edge(parallel_router, tool_b)
465+
g.add_join([tool_a, tool_b], parallel_router)
466+
g.add_edge(parallel_router, end)
467+
468+
result = await g.execute({"input": {}})
469+
470+
# tool_a should have been called 3 times
471+
assert call_count["tool_a"] == 3
472+
# tool_b should have been called 2 times
473+
assert call_count["tool_b"] == 2
474+
# Router called twice
475+
assert call_count["router"] == 2
476+
assert result["final"] == "done"

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)