From 55a317b575b1f1ee62185aa05b24cfe9eea6c9b0 Mon Sep 17 00:00:00 2001 From: Patrick Gray Date: Wed, 1 Oct 2025 17:19:46 -0400 Subject: [PATCH 1/9] hooks - before tool call event - cancel tool --- src/strands/hooks/events.py | 5 ++- src/strands/tools/executors/_executor.py | 20 ++++++++++- .../strands/tools/executors/test_executor.py | 33 +++++++++++++++++++ tests_integ/tools/executors/conftest.py | 15 +++++++++ .../tools/executors/test_concurrent.py | 12 +++++++ .../tools/executors/test_sequential.py | 12 +++++++ 6 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 tests_integ/tools/executors/conftest.py diff --git a/src/strands/hooks/events.py b/src/strands/hooks/events.py index b3b2014f3..cb426a4fc 100644 --- a/src/strands/hooks/events.py +++ b/src/strands/hooks/events.py @@ -97,14 +97,17 @@ class BeforeToolCallEvent(HookEvent): to change which tool gets executed. This may be None if tool lookup failed. tool_use: The tool parameters that will be passed to selected_tool. invocation_state: Keyword arguments that will be passed to the tool. + cancel: A user defined message that when set, will cancel the tool call. + The message will be placed into a tool result with an error status. """ selected_tool: Optional[AgentTool] tool_use: ToolUse invocation_state: dict[str, Any] + cancel: Optional[str] = None def _can_write(self, name: str) -> bool: - return name in ["selected_tool", "tool_use"] + return name in ["cancel", "selected_tool", "tool_use"] @dataclass diff --git a/src/strands/tools/executors/_executor.py b/src/strands/tools/executors/_executor.py index 2a75c48f2..5f605f65a 100644 --- a/src/strands/tools/executors/_executor.py +++ b/src/strands/tools/executors/_executor.py @@ -81,6 +81,24 @@ async def _stream( ) ) + if before_event.cancel: + after_event = agent.hooks.invoke_callbacks( + AfterToolCallEvent( + agent=agent, + tool_use=tool_use, + invocation_state=invocation_state, + result={ + "toolUseId": str(tool_use.get("toolUseId")), + "status": "error", + "content": [{"text": before_event.cancel}], + }, + selected_tool=None, + ) + ) + yield ToolResultEvent(after_event.result) + tool_results.append(after_event.result) + return + try: selected_tool = before_event.selected_tool tool_use = before_event.tool_use @@ -123,7 +141,7 @@ async def _stream( # so that we don't needlessly yield ToolStreamEvents for non-generator callbacks. # In which case, as soon as we get a ToolResultEvent we're done and for ToolStreamEvent # we yield it directly; all other cases (non-sdk AgentTools), we wrap events in - # ToolStreamEvent and the last even is just the result + # ToolStreamEvent and the last event is just the result. if isinstance(event, ToolResultEvent): # below the last "event" must point to the tool_result diff --git a/tests/strands/tools/executors/test_executor.py b/tests/strands/tools/executors/test_executor.py index 3bbedb477..e34dfa317 100644 --- a/tests/strands/tools/executors/test_executor.py +++ b/tests/strands/tools/executors/test_executor.py @@ -31,6 +31,15 @@ def tracer(): yield mock_get_tracer.return_value +@pytest.fixture +def cancel_hook(agent): + def callback(event): + event.cancel = "Tool execution cancelled by user" + return event + + return callback + + @pytest.mark.asyncio async def test_executor_stream_yields_result( executor, agent, tool_results, invocation_state, hook_events, weather_tool, alist @@ -215,3 +224,27 @@ async def test_executor_stream_with_trace( cycle_trace.add_child.assert_called_once() assert isinstance(cycle_trace.add_child.call_args[0][0], Trace) + + +@pytest.mark.asyncio +async def test_executor_stream_cancel(executor, agent, cancel_hook, tool_results, invocation_state, alist): + agent.hooks.add_callback(BeforeToolCallEvent, cancel_hook) + tool_use: ToolUse = {"name": "weather_tool", "toolUseId": "1", "input": {}} + + stream = executor._stream(agent, tool_use, tool_results, invocation_state) + + tru_events = await alist(stream) + exp_events = [ + ToolResultEvent( + { + "toolUseId": "1", + "status": "error", + "content": [{"text": "Tool execution cancelled by user"}], + }, + ), + ] + assert tru_events == exp_events + + tru_results = tool_results + exp_results = [exp_events[-1].tool_result] + assert tru_results == exp_results diff --git a/tests_integ/tools/executors/conftest.py b/tests_integ/tools/executors/conftest.py new file mode 100644 index 000000000..8a7216b81 --- /dev/null +++ b/tests_integ/tools/executors/conftest.py @@ -0,0 +1,15 @@ +import pytest + +from strands.hooks import BeforeToolCallEvent, HookProvider + + +@pytest.fixture +def cancel_hook(): + class Hook(HookProvider): + def register_hooks(self, registry): + registry.add_callback(BeforeToolCallEvent, self.cancel) + + def cancel(self, event): + event.cancel = "cancelled tool call" + + return Hook() diff --git a/tests_integ/tools/executors/test_concurrent.py b/tests_integ/tools/executors/test_concurrent.py index 27dd468e0..0156b5967 100644 --- a/tests_integ/tools/executors/test_concurrent.py +++ b/tests_integ/tools/executors/test_concurrent.py @@ -1,4 +1,5 @@ import asyncio +import json import pytest @@ -59,3 +60,14 @@ async def test_agent_invoke_async_tool_executor(agent, tool_events): {"name": "time_tool", "event": "end"}, ] assert tru_events == exp_events + + +@pytest.mark.asyncio +async def test_agent_invoke_async_tool_executor_cancelled(cancel_hook, tool_executor, time_tool, tool_events): + agent = Agent(tools=[time_tool], tool_executor=tool_executor, hooks=[cancel_hook]) + + await agent.invoke_async("What is the time in New York?") + messages = json.dumps(agent.messages) + + assert len(tool_events) == 0 + assert "cancelled tool call" in messages diff --git a/tests_integ/tools/executors/test_sequential.py b/tests_integ/tools/executors/test_sequential.py index 82fc51a59..4f71458e4 100644 --- a/tests_integ/tools/executors/test_sequential.py +++ b/tests_integ/tools/executors/test_sequential.py @@ -1,4 +1,5 @@ import asyncio +import json import pytest @@ -59,3 +60,14 @@ async def test_agent_invoke_async_tool_executor(agent, tool_events): {"name": "weather_tool", "event": "end"}, ] assert tru_events == exp_events + + +@pytest.mark.asyncio +async def test_agent_invoke_async_tool_executor_cancelled(cancel_hook, tool_executor, time_tool, tool_events): + agent = Agent(tools=[time_tool], tool_executor=tool_executor, hooks=[cancel_hook]) + + await agent.invoke_async("What is the time in New York?") + messages = json.dumps(agent.messages) + + assert len(tool_events) == 0 + assert "cancelled tool call" in messages From da3f5d7ec9349828a10c0e99af000831d5ad81c1 Mon Sep 17 00:00:00 2001 From: Patrick Gray Date: Fri, 3 Oct 2025 11:29:09 -0400 Subject: [PATCH 2/9] address feedback --- src/strands/hooks/events.py | 9 +++++---- src/strands/tools/executors/_executor.py | 11 +++++++++-- tests/strands/tools/executors/test_executor.py | 16 ++++++++++++---- tests_integ/tools/executors/conftest.py | 2 +- 4 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/strands/hooks/events.py b/src/strands/hooks/events.py index cb426a4fc..1fc7ebee0 100644 --- a/src/strands/hooks/events.py +++ b/src/strands/hooks/events.py @@ -97,17 +97,18 @@ class BeforeToolCallEvent(HookEvent): to change which tool gets executed. This may be None if tool lookup failed. tool_use: The tool parameters that will be passed to selected_tool. invocation_state: Keyword arguments that will be passed to the tool. - cancel: A user defined message that when set, will cancel the tool call. - The message will be placed into a tool result with an error status. + cancel_tool: A user defined message that when set, will cancel the tool call. + The message will be placed into a tool result with an error status. Alternatively, customers can set the + field to `True` and Strands will populate a default cancel message. """ selected_tool: Optional[AgentTool] tool_use: ToolUse invocation_state: dict[str, Any] - cancel: Optional[str] = None + cancel_tool: bool | str = False def _can_write(self, name: str) -> bool: - return name in ["cancel", "selected_tool", "tool_use"] + return name in ["cancel_tool", "selected_tool", "tool_use"] @dataclass diff --git a/src/strands/tools/executors/_executor.py b/src/strands/tools/executors/_executor.py index 5f605f65a..226c41e7e 100644 --- a/src/strands/tools/executors/_executor.py +++ b/src/strands/tools/executors/_executor.py @@ -81,7 +81,7 @@ async def _stream( ) ) - if before_event.cancel: + if before_event.cancel_tool: after_event = agent.hooks.invoke_callbacks( AfterToolCallEvent( agent=agent, @@ -90,7 +90,14 @@ async def _stream( result={ "toolUseId": str(tool_use.get("toolUseId")), "status": "error", - "content": [{"text": before_event.cancel}], + "content": [ + { + "text": ( + before_event.cancel_tool if isinstance(before_event.cancel_tool, str) + else "tool cancelled by user" + ), + }, + ], }, selected_tool=None, ) diff --git a/tests/strands/tools/executors/test_executor.py b/tests/strands/tools/executors/test_executor.py index e34dfa317..a957c764b 100644 --- a/tests/strands/tools/executors/test_executor.py +++ b/tests/strands/tools/executors/test_executor.py @@ -32,9 +32,9 @@ def tracer(): @pytest.fixture -def cancel_hook(agent): +def cancel_hook(request): def callback(event): - event.cancel = "Tool execution cancelled by user" + event.cancel_tool = request.param return event return callback @@ -226,8 +226,16 @@ async def test_executor_stream_with_trace( assert isinstance(cycle_trace.add_child.call_args[0][0], Trace) +@pytest.mark.parametrize( + ("cancel_hook", "result_text"), + [ + (True, "tool cancelled by user"), + ("user cancel message", "user cancel message") + ], + indirect="cancel_hook", +) @pytest.mark.asyncio -async def test_executor_stream_cancel(executor, agent, cancel_hook, tool_results, invocation_state, alist): +async def test_executor_stream_cancel(cancel_hook, result_text, executor, agent, tool_results, invocation_state, alist): agent.hooks.add_callback(BeforeToolCallEvent, cancel_hook) tool_use: ToolUse = {"name": "weather_tool", "toolUseId": "1", "input": {}} @@ -239,7 +247,7 @@ async def test_executor_stream_cancel(executor, agent, cancel_hook, tool_results { "toolUseId": "1", "status": "error", - "content": [{"text": "Tool execution cancelled by user"}], + "content": [{"text": result_text}], }, ), ] diff --git a/tests_integ/tools/executors/conftest.py b/tests_integ/tools/executors/conftest.py index 8a7216b81..c8e7fed95 100644 --- a/tests_integ/tools/executors/conftest.py +++ b/tests_integ/tools/executors/conftest.py @@ -10,6 +10,6 @@ def register_hooks(self, registry): registry.add_callback(BeforeToolCallEvent, self.cancel) def cancel(self, event): - event.cancel = "cancelled tool call" + event.cancel_tool = "cancelled tool call" return Hook() From e77ea64ae2ddf455e863298695bd1ec0efe75e93 Mon Sep 17 00:00:00 2001 From: Patrick Gray Date: Fri, 3 Oct 2025 11:31:02 -0400 Subject: [PATCH 3/9] lint --- src/strands/tools/executors/_executor.py | 3 ++- tests/strands/tools/executors/test_executor.py | 7 ++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/strands/tools/executors/_executor.py b/src/strands/tools/executors/_executor.py index 226c41e7e..e9259633d 100644 --- a/src/strands/tools/executors/_executor.py +++ b/src/strands/tools/executors/_executor.py @@ -93,7 +93,8 @@ async def _stream( "content": [ { "text": ( - before_event.cancel_tool if isinstance(before_event.cancel_tool, str) + before_event.cancel_tool + if isinstance(before_event.cancel_tool, str) else "tool cancelled by user" ), }, diff --git a/tests/strands/tools/executors/test_executor.py b/tests/strands/tools/executors/test_executor.py index a957c764b..bc01d3139 100644 --- a/tests/strands/tools/executors/test_executor.py +++ b/tests/strands/tools/executors/test_executor.py @@ -228,11 +228,8 @@ async def test_executor_stream_with_trace( @pytest.mark.parametrize( ("cancel_hook", "result_text"), - [ - (True, "tool cancelled by user"), - ("user cancel message", "user cancel message") - ], - indirect="cancel_hook", + [(True, "tool cancelled by user"), ("user cancel message", "user cancel message")], + indirect=["cancel_hook"], ) @pytest.mark.asyncio async def test_executor_stream_cancel(cancel_hook, result_text, executor, agent, tool_results, invocation_state, alist): From a6d1230780e91c040548d91c0b0bb5b6077ae12d Mon Sep 17 00:00:00 2001 From: Patrick Gray Date: Fri, 3 Oct 2025 14:32:19 -0400 Subject: [PATCH 4/9] set up tool result separately --- src/strands/tools/executors/_executor.py | 27 ++++++++++++------------ 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/strands/tools/executors/_executor.py b/src/strands/tools/executors/_executor.py index e9259633d..f6e6f79b7 100644 --- a/src/strands/tools/executors/_executor.py +++ b/src/strands/tools/executors/_executor.py @@ -82,25 +82,26 @@ async def _stream( ) if before_event.cancel_tool: + cancel_result: ToolResult = { + "toolUseId": str(tool_use.get("toolUseId")), + "status": "error", + "content": [ + { + "text": ( + before_event.cancel_tool + if isinstance(before_event.cancel_tool, str) + else "tool cancelled by user" + ), + }, + ], + } after_event = agent.hooks.invoke_callbacks( AfterToolCallEvent( agent=agent, tool_use=tool_use, invocation_state=invocation_state, - result={ - "toolUseId": str(tool_use.get("toolUseId")), - "status": "error", - "content": [ - { - "text": ( - before_event.cancel_tool - if isinstance(before_event.cancel_tool, str) - else "tool cancelled by user" - ), - }, - ], - }, selected_tool=None, + result=cancel_result, ) ) yield ToolResultEvent(after_event.result) From c7f3eec4649c9302538dc9017477c30ed6090677 Mon Sep 17 00:00:00 2001 From: Patrick Gray Date: Fri, 3 Oct 2025 14:39:24 -0400 Subject: [PATCH 5/9] update cancel tool description --- src/strands/hooks/events.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/strands/hooks/events.py b/src/strands/hooks/events.py index 1fc7ebee0..d8969a0a9 100644 --- a/src/strands/hooks/events.py +++ b/src/strands/hooks/events.py @@ -98,8 +98,8 @@ class BeforeToolCallEvent(HookEvent): tool_use: The tool parameters that will be passed to selected_tool. invocation_state: Keyword arguments that will be passed to the tool. cancel_tool: A user defined message that when set, will cancel the tool call. - The message will be placed into a tool result with an error status. Alternatively, customers can set the - field to `True` and Strands will populate a default cancel message. + The message will be placed into a tool result with an error status. If set to `True`, Strands will cancel + the tool call and use a default cancel message. """ selected_tool: Optional[AgentTool] From a8c573a42345247457784c9b1077704c343523ba Mon Sep 17 00:00:00 2001 From: Patrick Gray Date: Fri, 3 Oct 2025 15:02:55 -0400 Subject: [PATCH 6/9] tests - remove cancel hook fixture --- .../strands/tools/executors/test_executor.py | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/tests/strands/tools/executors/test_executor.py b/tests/strands/tools/executors/test_executor.py index bc01d3139..83505b3a5 100644 --- a/tests/strands/tools/executors/test_executor.py +++ b/tests/strands/tools/executors/test_executor.py @@ -31,15 +31,6 @@ def tracer(): yield mock_get_tracer.return_value -@pytest.fixture -def cancel_hook(request): - def callback(event): - event.cancel_tool = request.param - return event - - return callback - - @pytest.mark.asyncio async def test_executor_stream_yields_result( executor, agent, tool_results, invocation_state, hook_events, weather_tool, alist @@ -227,13 +218,16 @@ async def test_executor_stream_with_trace( @pytest.mark.parametrize( - ("cancel_hook", "result_text"), + ("cancel_tool", "result_text"), [(True, "tool cancelled by user"), ("user cancel message", "user cancel message")], - indirect=["cancel_hook"], ) @pytest.mark.asyncio -async def test_executor_stream_cancel(cancel_hook, result_text, executor, agent, tool_results, invocation_state, alist): - agent.hooks.add_callback(BeforeToolCallEvent, cancel_hook) +async def test_executor_stream_cancel(cancel_tool, result_text, executor, agent, tool_results, invocation_state, alist): + def cancel_callback(event): + event.cancel_tool = cancel_tool + return event + + agent.hooks.add_callback(BeforeToolCallEvent, cancel_callback) tool_use: ToolUse = {"name": "weather_tool", "toolUseId": "1", "input": {}} stream = executor._stream(agent, tool_use, tool_results, invocation_state) From 617f50a22e40cd92416bbb92d846e2ad1b690311 Mon Sep 17 00:00:00 2001 From: Patrick Gray Date: Fri, 3 Oct 2025 15:11:25 -0400 Subject: [PATCH 7/9] set tool cancel flag in after event --- src/strands/hooks/events.py | 2 ++ src/strands/tools/executors/_executor.py | 14 +++++--------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/strands/hooks/events.py b/src/strands/hooks/events.py index d8969a0a9..aaa07f8da 100644 --- a/src/strands/hooks/events.py +++ b/src/strands/hooks/events.py @@ -128,6 +128,7 @@ class AfterToolCallEvent(HookEvent): invocation_state: Keyword arguments that were passed to the tool result: The result of the tool invocation. Either a ToolResult on success or an Exception if the tool execution failed. + tool_cancelled: Flag indicating whether the tool call was cancelled by the user. """ selected_tool: Optional[AgentTool] @@ -135,6 +136,7 @@ class AfterToolCallEvent(HookEvent): invocation_state: dict[str, Any] result: ToolResult exception: Optional[Exception] = None + tool_cancelled: bool = False def _can_write(self, name: str) -> bool: return name == "result" diff --git a/src/strands/tools/executors/_executor.py b/src/strands/tools/executors/_executor.py index f6e6f79b7..16912837b 100644 --- a/src/strands/tools/executors/_executor.py +++ b/src/strands/tools/executors/_executor.py @@ -82,18 +82,13 @@ async def _stream( ) if before_event.cancel_tool: + cancel_tool = ( + before_event.cancel_tool if isinstance(before_event.cancel_tool, str) else "tool cancelled by user" + ) cancel_result: ToolResult = { "toolUseId": str(tool_use.get("toolUseId")), "status": "error", - "content": [ - { - "text": ( - before_event.cancel_tool - if isinstance(before_event.cancel_tool, str) - else "tool cancelled by user" - ), - }, - ], + "content": [{"text": cancel_tool}], } after_event = agent.hooks.invoke_callbacks( AfterToolCallEvent( @@ -102,6 +97,7 @@ async def _stream( invocation_state=invocation_state, selected_tool=None, result=cancel_result, + tool_cancelled=True, ) ) yield ToolResultEvent(after_event.result) From 6689325276cef6b26e3ce57ce93bccb172ce69de Mon Sep 17 00:00:00 2001 From: Patrick Gray Date: Fri, 3 Oct 2025 15:29:54 -0400 Subject: [PATCH 8/9] emit tool cancel event --- src/strands/tools/executors/_executor.py | 8 ++++--- src/strands/types/_events.py | 23 +++++++++++++++++++ .../strands/tools/executors/test_executor.py | 11 +++++---- .../tools/executors/test_concurrent.py | 12 ++++++---- .../tools/executors/test_sequential.py | 12 ++++++---- 5 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/strands/tools/executors/_executor.py b/src/strands/tools/executors/_executor.py index 16912837b..722ff0d6b 100644 --- a/src/strands/tools/executors/_executor.py +++ b/src/strands/tools/executors/_executor.py @@ -14,7 +14,7 @@ from ...hooks import AfterToolCallEvent, BeforeToolCallEvent from ...telemetry.metrics import Trace from ...telemetry.tracer import get_tracer -from ...types._events import ToolResultEvent, ToolStreamEvent, TypedEvent +from ...types._events import ToolCancelEvent, ToolResultEvent, ToolStreamEvent, TypedEvent from ...types.content import Message from ...types.tools import ToolChoice, ToolChoiceAuto, ToolConfig, ToolResult, ToolUse @@ -82,13 +82,15 @@ async def _stream( ) if before_event.cancel_tool: - cancel_tool = ( + cancel_message = ( before_event.cancel_tool if isinstance(before_event.cancel_tool, str) else "tool cancelled by user" ) + yield ToolCancelEvent(tool_use, cancel_message) + cancel_result: ToolResult = { "toolUseId": str(tool_use.get("toolUseId")), "status": "error", - "content": [{"text": cancel_tool}], + "content": [{"text": cancel_message}], } after_event = agent.hooks.invoke_callbacks( AfterToolCallEvent( diff --git a/src/strands/types/_events.py b/src/strands/types/_events.py index 3d0f1d0f0..e20bf658a 100644 --- a/src/strands/types/_events.py +++ b/src/strands/types/_events.py @@ -298,6 +298,29 @@ def tool_use_id(self) -> str: return cast(str, cast(ToolUse, cast(dict, self.get("tool_stream_event")).get("tool_use")).get("toolUseId")) +class ToolCancelEvent(TypedEvent): + """Event emitted when a user cancels a tool call from their BeforeToolCallEvent hook.""" + + def __init__(self, tool_use: ToolUse, message: str) -> None: + """Initialize with tool streaming data. + + Args: + tool_use: Information about the tool being cancelled + message: The tool cancellation message + """ + super().__init__({"tool_cancel_event": {"tool_use": tool_use, "message": message}}) + + @property + def tool_use_id(self) -> str: + """The id of the tool cancelled.""" + return cast(str, cast(ToolUse, cast(dict, self.get("tool_cancelled_event")).get("tool_use")).get("toolUseId")) + + @property + def message(self) -> str: + """The tool cancellation message.""" + return cast(str, self["message"]) + + class ModelMessageEvent(TypedEvent): """Event emitted when the model invocation has completed. diff --git a/tests/strands/tools/executors/test_executor.py b/tests/strands/tools/executors/test_executor.py index 83505b3a5..2a0a44e10 100644 --- a/tests/strands/tools/executors/test_executor.py +++ b/tests/strands/tools/executors/test_executor.py @@ -7,7 +7,7 @@ from strands.hooks import AfterToolCallEvent, BeforeToolCallEvent from strands.telemetry.metrics import Trace from strands.tools.executors._executor import ToolExecutor -from strands.types._events import ToolResultEvent, ToolStreamEvent +from strands.types._events import ToolCancelEvent, ToolResultEvent, ToolStreamEvent from strands.types.tools import ToolUse @@ -218,11 +218,13 @@ async def test_executor_stream_with_trace( @pytest.mark.parametrize( - ("cancel_tool", "result_text"), + ("cancel_tool", "cancel_message"), [(True, "tool cancelled by user"), ("user cancel message", "user cancel message")], ) @pytest.mark.asyncio -async def test_executor_stream_cancel(cancel_tool, result_text, executor, agent, tool_results, invocation_state, alist): +async def test_executor_stream_cancel( + cancel_tool, cancel_message, executor, agent, tool_results, invocation_state, alist +): def cancel_callback(event): event.cancel_tool = cancel_tool return event @@ -234,11 +236,12 @@ def cancel_callback(event): tru_events = await alist(stream) exp_events = [ + ToolCancelEvent(tool_use, cancel_message), ToolResultEvent( { "toolUseId": "1", "status": "error", - "content": [{"text": result_text}], + "content": [{"text": cancel_message}], }, ), ] diff --git a/tests_integ/tools/executors/test_concurrent.py b/tests_integ/tools/executors/test_concurrent.py index 0156b5967..48653af9c 100644 --- a/tests_integ/tools/executors/test_concurrent.py +++ b/tests_integ/tools/executors/test_concurrent.py @@ -63,11 +63,15 @@ async def test_agent_invoke_async_tool_executor(agent, tool_events): @pytest.mark.asyncio -async def test_agent_invoke_async_tool_executor_cancelled(cancel_hook, tool_executor, time_tool, tool_events): +async def test_agent_stream_async_tool_executor_cancelled(cancel_hook, tool_executor, time_tool, tool_events): agent = Agent(tools=[time_tool], tool_executor=tool_executor, hooks=[cancel_hook]) - await agent.invoke_async("What is the time in New York?") - messages = json.dumps(agent.messages) + exp_message = "cancelled tool call" + tru_message = "" + async for event in agent.stream_async("What is the time in New York?"): + if "tool_cancel_event" in event: + tru_message = event["tool_cancel_event"]["message"] + assert tru_message == exp_message assert len(tool_events) == 0 - assert "cancelled tool call" in messages + assert exp_message in json.dumps(agent.messages) diff --git a/tests_integ/tools/executors/test_sequential.py b/tests_integ/tools/executors/test_sequential.py index 4f71458e4..d959222d4 100644 --- a/tests_integ/tools/executors/test_sequential.py +++ b/tests_integ/tools/executors/test_sequential.py @@ -63,11 +63,15 @@ async def test_agent_invoke_async_tool_executor(agent, tool_events): @pytest.mark.asyncio -async def test_agent_invoke_async_tool_executor_cancelled(cancel_hook, tool_executor, time_tool, tool_events): +async def test_agent_stream_async_tool_executor_cancelled(cancel_hook, tool_executor, time_tool, tool_events): agent = Agent(tools=[time_tool], tool_executor=tool_executor, hooks=[cancel_hook]) - await agent.invoke_async("What is the time in New York?") - messages = json.dumps(agent.messages) + exp_message = "cancelled tool call" + tru_message = "" + async for event in agent.stream_async("What is the time in New York?"): + if "tool_cancel_event" in event: + tru_message = event["tool_cancel_event"]["message"] + assert tru_message == exp_message assert len(tool_events) == 0 - assert "cancelled tool call" in messages + assert exp_message in json.dumps(agent.messages) From e3f7e7b03551fc1c1ed5bc8c2b349420376b140e Mon Sep 17 00:00:00 2001 From: Patrick Gray Date: Fri, 3 Oct 2025 17:05:58 -0400 Subject: [PATCH 9/9] cancel message in after event --- src/strands/hooks/events.py | 4 ++-- src/strands/tools/executors/_executor.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/strands/hooks/events.py b/src/strands/hooks/events.py index aaa07f8da..8f611e4e2 100644 --- a/src/strands/hooks/events.py +++ b/src/strands/hooks/events.py @@ -128,7 +128,7 @@ class AfterToolCallEvent(HookEvent): invocation_state: Keyword arguments that were passed to the tool result: The result of the tool invocation. Either a ToolResult on success or an Exception if the tool execution failed. - tool_cancelled: Flag indicating whether the tool call was cancelled by the user. + cancel_message: The cancellation message if the user cancelled the tool call. """ selected_tool: Optional[AgentTool] @@ -136,7 +136,7 @@ class AfterToolCallEvent(HookEvent): invocation_state: dict[str, Any] result: ToolResult exception: Optional[Exception] = None - tool_cancelled: bool = False + cancel_message: str | None = None def _can_write(self, name: str) -> bool: return name == "result" diff --git a/src/strands/tools/executors/_executor.py b/src/strands/tools/executors/_executor.py index 722ff0d6b..f78861f81 100644 --- a/src/strands/tools/executors/_executor.py +++ b/src/strands/tools/executors/_executor.py @@ -99,7 +99,7 @@ async def _stream( invocation_state=invocation_state, selected_tool=None, result=cancel_result, - tool_cancelled=True, + cancel_message=cancel_message, ) ) yield ToolResultEvent(after_event.result)