From 5daff1a62990ea2cf81792383be9823a9da8b656 Mon Sep 17 00:00:00 2001 From: Strands Agent <217235299+strands-agent@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:02:47 +0000 Subject: [PATCH 1/3] fix(graph): handle cancel_node gracefully without raising exception Align Graph cancel_node behavior with Swarm by not raising a RuntimeError when a node is cancelled. Instead, handle cancellation gracefully by: - Creating a NodeResult with status=FAILED for the cancelled node - Adding the node to failed_nodes set - Yielding MultiAgentNodeStopEvent for the cancelled node - Returning without exception to allow GraphResult to be yielded Also add a failed_nodes check in _execute_graph to stop execution after a cancellation, preventing downstream nodes from executing. This makes the Graph behavior consistent with Swarm cancel_node handling, where users can cleanly exit without catching RuntimeError. --- src/strands/multiagent/graph.py | 27 ++++++++++++++++++--- tests/strands/multiagent/test_graph.py | 26 ++++++++++++++++---- tests_integ/hooks/multiagent/test_cancel.py | 27 ++++++++++++++++----- 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/strands/multiagent/graph.py b/src/strands/multiagent/graph.py index 97435ad4a..49c66255c 100644 --- a/src/strands/multiagent/graph.py +++ b/src/strands/multiagent/graph.py @@ -663,11 +663,14 @@ async def _execute_graph(self, invocation_state: dict[str, Any]) -> AsyncIterato ] return + # Check if any nodes failed (including cancelled) - stop execution gracefully + if self.state.failed_nodes: + return + self._interrupt_state.deactivate() # Find newly ready nodes after batch execution - # We add all nodes in current batch as completed batch, - # because a failure would throw exception and code would not make it here + # Only nodes that completed successfully are considered for downstream execution newly_ready = self._find_newly_ready_nodes(current_batch) # Emit handoff event for batch transition if there are nodes to transition to @@ -868,7 +871,25 @@ async def _execute_node(self, node: GraphNode, invocation_state: dict[str, Any]) ) logger.debug("reason=<%s> | cancelling execution", cancel_message) yield MultiAgentNodeCancelEvent(node.node_id, cancel_message) - raise RuntimeError(cancel_message) + + # Handle cancellation gracefully (consistent with Swarm behavior) + node_result = NodeResult( + result=cancel_message, + execution_time=0, + status=Status.FAILED, + accumulated_usage=Usage(inputTokens=0, outputTokens=0, totalTokens=0), + accumulated_metrics=Metrics(latencyMs=0), + execution_count=1, + ) + + node.execution_status = Status.FAILED + node.result = node_result + node.execution_time = 0 + self.state.failed_nodes.add(node) + self.state.results[node.node_id] = node_result + + yield MultiAgentNodeStopEvent(node_id=node.node_id, node_result=node_result) + return # Build node input from satisfied dependencies node_input = self._build_node_input(node) diff --git a/tests/strands/multiagent/test_graph.py b/tests/strands/multiagent/test_graph.py index ab2d86e70..97c27dd78 100644 --- a/tests/strands/multiagent/test_graph.py +++ b/tests/strands/multiagent/test_graph.py @@ -2080,14 +2080,30 @@ def cancel_callback(event): stream = graph.stream_async("test task") tru_cancel_event = None - with pytest.raises(RuntimeError, match=cancel_message): - async for event in stream: - if event.get("type") == "multiagent_node_cancel": - tru_cancel_event = event - + tru_stop_event = None + tru_result_event = None + async for event in stream: + if event.get("type") == "multiagent_node_cancel": + tru_cancel_event = event + elif event.get("type") == "multiagent_node_stop" and event.get("node_id") == "test_agent": + tru_stop_event = event + elif event.get("type") == "multiagent_result": + tru_result_event = event + + # Verify cancel event was emitted exp_cancel_event = MultiAgentNodeCancelEvent(node_id="test_agent", message=cancel_message) assert tru_cancel_event == exp_cancel_event + # Verify stop event was emitted for cancelled node + assert tru_stop_event is not None + assert tru_stop_event["node_result"].status == Status.FAILED + assert tru_stop_event["node_result"].result == cancel_message + + # Verify result event was yielded (no exception raised) + assert tru_result_event is not None + assert tru_result_event["result"].status == Status.FAILED + + # Verify graph state tru_status = graph.state.status exp_status = Status.FAILED assert tru_status == exp_status diff --git a/tests_integ/hooks/multiagent/test_cancel.py b/tests_integ/hooks/multiagent/test_cancel.py index 9267330b7..b0e66db4f 100644 --- a/tests_integ/hooks/multiagent/test_cancel.py +++ b/tests_integ/hooks/multiagent/test_cancel.py @@ -73,16 +73,31 @@ async def test_swarm_cancel_node(swarm): @pytest.mark.asyncio async def test_graph_cancel_node(graph): tru_cancel_event = None - with pytest.raises(RuntimeError, match="test cancel"): - async for event in graph.stream_async("What is the weather"): - if event.get("type") == "multiagent_node_cancel": - tru_cancel_event = event + tru_result_event = None + async for event in graph.stream_async("What is the weather"): + if event.get("type") == "multiagent_node_cancel": + tru_cancel_event = event + elif event.get("type") == "multiagent_result": + tru_result_event = event exp_cancel_event = MultiAgentNodeCancelEvent(node_id="weather", message="test cancel") assert tru_cancel_event == exp_cancel_event - state = graph.state + # Verify result was yielded (no exception raised) + assert tru_result_event is not None + multiagent_result = tru_result_event["result"] - tru_status = state.status + tru_status = multiagent_result.status exp_status = Status.FAILED assert tru_status == exp_status + + state = graph.state + + tru_state_status = state.status + exp_state_status = Status.FAILED + assert tru_state_status == exp_state_status + + # Verify the info node was executed but weather node was cancelled + assert "info" in state.results + assert "weather" in state.results + assert state.results["weather"].status == Status.FAILED From 924a1b3e76cbe6e2a48b912c0082f6acf911c96a Mon Sep 17 00:00:00 2001 From: Strands Agent <217235299+strands-agent@users.noreply.github.com> Date: Tue, 20 Jan 2026 22:02:47 +0000 Subject: [PATCH 2/3] fix(graph): wrap cancel message in Exception to satisfy NodeResult type NodeResult.result expects AgentResult | MultiAgentResult | Exception, not a string. Wrap the cancel message in an Exception to fix the mypy type error. --- src/strands/multiagent/graph.py | 2 +- tests/strands/multiagent/test_graph.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/strands/multiagent/graph.py b/src/strands/multiagent/graph.py index 49c66255c..76a0e991b 100644 --- a/src/strands/multiagent/graph.py +++ b/src/strands/multiagent/graph.py @@ -874,7 +874,7 @@ async def _execute_node(self, node: GraphNode, invocation_state: dict[str, Any]) # Handle cancellation gracefully (consistent with Swarm behavior) node_result = NodeResult( - result=cancel_message, + result=Exception(cancel_message), execution_time=0, status=Status.FAILED, accumulated_usage=Usage(inputTokens=0, outputTokens=0, totalTokens=0), diff --git a/tests/strands/multiagent/test_graph.py b/tests/strands/multiagent/test_graph.py index 97c27dd78..20dab6804 100644 --- a/tests/strands/multiagent/test_graph.py +++ b/tests/strands/multiagent/test_graph.py @@ -2097,7 +2097,8 @@ def cancel_callback(event): # Verify stop event was emitted for cancelled node assert tru_stop_event is not None assert tru_stop_event["node_result"].status == Status.FAILED - assert tru_stop_event["node_result"].result == cancel_message + assert isinstance(tru_stop_event["node_result"].result, Exception) + assert str(tru_stop_event["node_result"].result) == cancel_message # Verify result event was yielded (no exception raised) assert tru_result_event is not None From 21d1fa323ab0a0cc77e28ddfc97bcda7c0c243f8 Mon Sep 17 00:00:00 2001 From: Strands Agent <217235299+strands-agent@users.noreply.github.com> Date: Tue, 20 Jan 2026 22:30:16 +0000 Subject: [PATCH 3/3] refactor(graph): simplify cancel_node to match Swarm pattern Simplify the cancel_node handling to match Swarm's approach: - Yield MultiAgentNodeCancelEvent - Set self.state.status = Status.FAILED - Return early (no exception) Remove unnecessary NodeResult construction and MultiAgentNodeStopEvent for cancelled nodes, as Swarm doesn't emit these either. Update the _execute_graph check to use Status.FAILED instead of failed_nodes to stop downstream execution. --- src/strands/multiagent/graph.py | 23 +++------------------ tests/strands/multiagent/test_graph.py | 9 -------- tests_integ/hooks/multiagent/test_cancel.py | 5 ++--- 3 files changed, 5 insertions(+), 32 deletions(-) diff --git a/src/strands/multiagent/graph.py b/src/strands/multiagent/graph.py index 76a0e991b..4cce2ac43 100644 --- a/src/strands/multiagent/graph.py +++ b/src/strands/multiagent/graph.py @@ -663,8 +663,8 @@ async def _execute_graph(self, invocation_state: dict[str, Any]) -> AsyncIterato ] return - # Check if any nodes failed (including cancelled) - stop execution gracefully - if self.state.failed_nodes: + # Check if execution was cancelled - stop execution gracefully + if self.state.status == Status.FAILED: return self._interrupt_state.deactivate() @@ -871,24 +871,7 @@ async def _execute_node(self, node: GraphNode, invocation_state: dict[str, Any]) ) logger.debug("reason=<%s> | cancelling execution", cancel_message) yield MultiAgentNodeCancelEvent(node.node_id, cancel_message) - - # Handle cancellation gracefully (consistent with Swarm behavior) - node_result = NodeResult( - result=Exception(cancel_message), - execution_time=0, - status=Status.FAILED, - accumulated_usage=Usage(inputTokens=0, outputTokens=0, totalTokens=0), - accumulated_metrics=Metrics(latencyMs=0), - execution_count=1, - ) - - node.execution_status = Status.FAILED - node.result = node_result - node.execution_time = 0 - self.state.failed_nodes.add(node) - self.state.results[node.node_id] = node_result - - yield MultiAgentNodeStopEvent(node_id=node.node_id, node_result=node_result) + self.state.status = Status.FAILED return # Build node input from satisfied dependencies diff --git a/tests/strands/multiagent/test_graph.py b/tests/strands/multiagent/test_graph.py index 20dab6804..0f28261c1 100644 --- a/tests/strands/multiagent/test_graph.py +++ b/tests/strands/multiagent/test_graph.py @@ -2080,13 +2080,10 @@ def cancel_callback(event): stream = graph.stream_async("test task") tru_cancel_event = None - tru_stop_event = None tru_result_event = None async for event in stream: if event.get("type") == "multiagent_node_cancel": tru_cancel_event = event - elif event.get("type") == "multiagent_node_stop" and event.get("node_id") == "test_agent": - tru_stop_event = event elif event.get("type") == "multiagent_result": tru_result_event = event @@ -2094,12 +2091,6 @@ def cancel_callback(event): exp_cancel_event = MultiAgentNodeCancelEvent(node_id="test_agent", message=cancel_message) assert tru_cancel_event == exp_cancel_event - # Verify stop event was emitted for cancelled node - assert tru_stop_event is not None - assert tru_stop_event["node_result"].status == Status.FAILED - assert isinstance(tru_stop_event["node_result"].result, Exception) - assert str(tru_stop_event["node_result"].result) == cancel_message - # Verify result event was yielded (no exception raised) assert tru_result_event is not None assert tru_result_event["result"].status == Status.FAILED diff --git a/tests_integ/hooks/multiagent/test_cancel.py b/tests_integ/hooks/multiagent/test_cancel.py index b0e66db4f..5eb547ada 100644 --- a/tests_integ/hooks/multiagent/test_cancel.py +++ b/tests_integ/hooks/multiagent/test_cancel.py @@ -97,7 +97,6 @@ async def test_graph_cancel_node(graph): exp_state_status = Status.FAILED assert tru_state_status == exp_state_status - # Verify the info node was executed but weather node was cancelled + # Verify the info node was executed but weather node was cancelled (not executed) assert "info" in state.results - assert "weather" in state.results - assert state.results["weather"].status == Status.FAILED + assert "weather" not in state.results