Skip to content

Commit 2c5660f

Browse files
authored
Merge pull request #1491 from Steve-Dusty/feat/graph-workflow-on-node-complete
feat: add on_node_complete streaming callback to GraphWorkflow
2 parents a2242c0 + aedec71 commit 2c5660f

3 files changed

Lines changed: 252 additions & 1 deletion

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""
2+
GraphWorkflow Token Streaming Example
3+
4+
Demonstrates real token-by-token streaming from multiple agents.
5+
You'll see tokens appear in the terminal as each agent generates
6+
them, with color-coded labels showing which agent is "speaking".
7+
8+
Architecture:
9+
Coordinator (Layer 0)
10+
-> Market-Analyst (Layer 1, parallel)
11+
-> Tech-Analyst (Layer 1, parallel)
12+
-> Risk-Analyst (Layer 1, parallel)
13+
-> Synthesizer (Layer 2)
14+
"""
15+
16+
import sys
17+
import threading
18+
import time
19+
20+
from swarms.structs.agent import Agent
21+
from swarms.structs.graph_workflow import GraphWorkflow
22+
23+
# ANSI colors for each agent
24+
COLORS = {
25+
"Coordinator": "\033[96m", # cyan
26+
"Market-Analyst": "\033[93m", # yellow
27+
"Tech-Analyst": "\033[92m", # green
28+
"Risk-Analyst": "\033[91m", # red
29+
"Synthesizer": "\033[95m", # magenta
30+
}
31+
RESET = "\033[0m"
32+
BOLD = "\033[1m"
33+
34+
# Lock to avoid garbled output from parallel agents
35+
print_lock = threading.Lock()
36+
37+
38+
def create_agent(name: str, description: str) -> Agent:
39+
return Agent(
40+
agent_name=name,
41+
agent_description=description,
42+
system_prompt=f"You are {name}. {description} Keep your response to 2-3 sentences.",
43+
model_name="gpt-5.4",
44+
max_loops=1,
45+
verbose=False,
46+
print_on=False,
47+
streaming_on=True,
48+
)
49+
50+
51+
def main():
52+
# -- Build agents --
53+
coordinator = create_agent(
54+
"Coordinator",
55+
"You coordinate analysis tasks. Briefly outline what each team member should focus on.",
56+
)
57+
market_analyst = create_agent(
58+
"Market-Analyst",
59+
"You analyse market trends and competitive landscape.",
60+
)
61+
tech_analyst = create_agent(
62+
"Tech-Analyst",
63+
"You evaluate technical feasibility and architecture.",
64+
)
65+
risk_analyst = create_agent(
66+
"Risk-Analyst",
67+
"You identify risks and propose mitigations.",
68+
)
69+
synthesizer = create_agent(
70+
"Synthesizer",
71+
"You synthesize inputs from multiple analysts into a concise executive summary.",
72+
)
73+
74+
# -- Build workflow --
75+
workflow = GraphWorkflow(name="Streaming-Demo")
76+
for agent in [coordinator, market_analyst, tech_analyst, risk_analyst, synthesizer]:
77+
workflow.add_node(agent)
78+
79+
workflow.add_edges_from_source(
80+
"Coordinator",
81+
["Market-Analyst", "Tech-Analyst", "Risk-Analyst"],
82+
)
83+
workflow.add_edges_to_target(
84+
["Market-Analyst", "Tech-Analyst", "Risk-Analyst"],
85+
"Synthesizer",
86+
)
87+
88+
# -- Token-by-token streaming callback --
89+
# Track which agents have printed their header
90+
active_agents = {}
91+
92+
def on_token(node_id: str, token: str) -> None:
93+
color = COLORS.get(node_id, "")
94+
with print_lock:
95+
if node_id not in active_agents:
96+
active_agents[node_id] = True
97+
sys.stdout.write(f"\n{color}{BOLD}[{node_id}]{RESET}{color} ")
98+
sys.stdout.write(f"{color}{token}{RESET}")
99+
sys.stdout.flush()
100+
101+
def on_complete(node_id: str, output) -> None:
102+
with print_lock:
103+
sys.stdout.write("\n")
104+
sys.stdout.flush()
105+
# Clear so next run of same agent gets a new header
106+
active_agents.pop(node_id, None)
107+
108+
# -- Run --
109+
task = "Evaluate the feasibility of launching an AI-powered personal finance assistant."
110+
111+
print(f"{BOLD}{'=' * 60}")
112+
print(" GraphWorkflow Token Streaming Demo")
113+
print(f"{'=' * 60}{RESET}")
114+
print(f"\n Task: {task}\n")
115+
116+
start = time.time()
117+
result = workflow.run(
118+
task,
119+
streaming_callback=on_token,
120+
on_node_complete=on_complete,
121+
)
122+
elapsed = time.time() - start
123+
124+
print(f"\n{BOLD}{'=' * 60}")
125+
print(f" Done in {elapsed:.1f}s | Agents: {list(result.keys())}")
126+
print(f"{'=' * 60}{RESET}")
127+
128+
129+
if __name__ == "__main__":
130+
main()

swarms/structs/graph_workflow.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from enum import Enum
1111
from typing import (
1212
Any,
13+
Callable,
1314
Dict,
1415
Iterator,
1516
List,
@@ -741,9 +742,13 @@ def __init__(
741742
verbose: bool = False,
742743
backend: str = "networkx",
743744
checkpoint_dir: Optional[str] = None,
745+
on_node_complete: Optional[
746+
Callable[[str, Any], None]
747+
] = None,
744748
):
745749
self.id = id
746750
self.verbose = verbose
751+
self.on_node_complete = on_node_complete
747752

748753
if self.verbose:
749754
logger.info("Initializing GraphWorkflow")
@@ -1713,6 +1718,12 @@ def run(
17131718
self,
17141719
task: Optional[str] = None,
17151720
img: Optional[str] = None,
1721+
on_node_complete: Optional[
1722+
Callable[[str, Any], None]
1723+
] = None,
1724+
streaming_callback: Optional[
1725+
Callable[[str, str], None]
1726+
] = None,
17161727
*args: Any,
17171728
**kwargs: Any,
17181729
) -> Dict[str, Any]:
@@ -1726,6 +1737,14 @@ def run(
17261737
Args:
17271738
task (Optional[str]): Task to execute. Uses self.task if not provided.
17281739
img (Optional[str]): Optional image path for multimodal tasks.
1740+
on_node_complete (Optional[Callable[[str, Any], None]]): Callback
1741+
fired immediately when each agent finishes, before the layer
1742+
completes. Receives ``(node_id, output)``. A callback passed
1743+
here takes precedence over the instance-level callback set in
1744+
``__init__``.
1745+
streaming_callback (Optional[Callable[[str, str], None]]): Callback
1746+
fired for every token as agents generate output in real-time.
1747+
Receives ``(node_id, token)``.
17291748
*args: Additional positional arguments.
17301749
**kwargs: Additional keyword arguments.
17311750
@@ -1736,6 +1755,11 @@ def run(
17361755
keyed as ``{node_id}_loop_{loop_number}`` plus the final
17371756
loop's results under the plain ``node_id`` keys.
17381757
"""
1758+
# Resolve callbacks: run-level overrides instance-level
1759+
_on_node_complete = (
1760+
on_node_complete or self.on_node_complete
1761+
)
1762+
_streaming_callback = streaming_callback
17391763
run_start_time = time.time()
17401764

17411765
if task is not None:
@@ -1904,12 +1928,21 @@ def run(
19041928
# Submit all tasks
19051929
for node_id, agent, prompt in layer_data:
19061930
try:
1931+
# Build per-agent kwargs, injecting
1932+
# streaming_callback if provided.
1933+
submit_kwargs = dict(kwargs)
1934+
if _streaming_callback is not None:
1935+
_nid = node_id # capture for closure
1936+
submit_kwargs["streaming_callback"] = (
1937+
lambda token, _nid=_nid: _streaming_callback(_nid, token)
1938+
)
1939+
19071940
future = executor.submit(
19081941
agent.run,
19091942
prompt,
19101943
img,
19111944
*args,
1912-
**kwargs,
1945+
**submit_kwargs,
19131946
)
19141947
future_to_data[future] = (
19151948
node_id,
@@ -1982,6 +2015,15 @@ def run(
19822015
f"Error adding output to conversation for agent {agent_name}: {e}"
19832016
)
19842017

2018+
# Fire the on_node_complete callback
2019+
if _on_node_complete is not None:
2020+
try:
2021+
_on_node_complete(node_id, output)
2022+
except Exception as e:
2023+
logger.exception(
2024+
f"Error in on_node_complete callback for {agent_name}: {e}"
2025+
)
2026+
19852027
layer_execution_time = (
19862028
time.time() - layer_start_time
19872029
)

tests/structs/test_graph_workflow.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,5 +931,84 @@ def test_graph_workflow_single_loop_backward_compatible():
931931
), "Single-loop results should not contain loop-suffixed keys"
932932

933933

934+
def test_graph_workflow_on_node_complete_callback_via_run():
935+
"""Test that on_node_complete callback fires for each agent when passed
936+
to run() (fixes #1482)."""
937+
agent1 = create_test_agent("Agent1", "Entry agent")
938+
agent2 = create_test_agent("Agent2", "End agent")
939+
940+
workflow = GraphWorkflow(name="Callback-Run-Test")
941+
workflow.add_node(agent1)
942+
workflow.add_node(agent2)
943+
workflow.add_edge(agent1, agent2)
944+
945+
completed = []
946+
947+
def on_complete(node_id, output):
948+
completed.append((node_id, output))
949+
950+
result = workflow.run(
951+
"Test callback via run",
952+
on_node_complete=on_complete,
953+
)
954+
assert result is not None
955+
assert len(completed) == 2
956+
957+
completed_ids = [nid for nid, _ in completed]
958+
assert "Agent1" in completed_ids
959+
assert "Agent2" in completed_ids
960+
961+
# Outputs in callback should match the returned results
962+
for node_id, output in completed:
963+
assert result[node_id] == output
964+
965+
966+
def test_graph_workflow_on_node_complete_callback_via_init():
967+
"""Test that on_node_complete callback works when set at __init__ level."""
968+
agent1 = create_test_agent("Agent1", "Entry agent")
969+
agent2 = create_test_agent("Agent2", "End agent")
970+
971+
completed = []
972+
973+
def on_complete(node_id, output):
974+
completed.append(node_id)
975+
976+
workflow = GraphWorkflow(
977+
name="Callback-Init-Test",
978+
on_node_complete=on_complete,
979+
)
980+
workflow.add_node(agent1)
981+
workflow.add_node(agent2)
982+
workflow.add_edge(agent1, agent2)
983+
984+
result = workflow.run("Test callback via init")
985+
assert result is not None
986+
assert "Agent1" in completed
987+
assert "Agent2" in completed
988+
989+
990+
def test_graph_workflow_on_node_complete_run_overrides_init():
991+
"""Test that a callback passed to run() takes precedence over __init__."""
992+
agent1 = create_test_agent("Agent1", "Solo agent")
993+
994+
init_calls = []
995+
run_calls = []
996+
997+
workflow = GraphWorkflow(
998+
name="Callback-Override-Test",
999+
on_node_complete=lambda nid, out: init_calls.append(nid),
1000+
)
1001+
workflow.add_node(agent1)
1002+
1003+
workflow.run(
1004+
"Test override",
1005+
on_node_complete=lambda nid, out: run_calls.append(nid),
1006+
)
1007+
1008+
# Only the run-level callback should have been called
1009+
assert len(run_calls) == 1
1010+
assert len(init_calls) == 0
1011+
1012+
9341013
if __name__ == "__main__":
9351014
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)