11from __future__ import annotations
22
33import asyncio
4+ import contextlib
45import dataclasses
56import inspect
67from collections .abc import Awaitable
@@ -226,6 +227,29 @@ def get_model_tracing_impl(
226227 return ModelTracing .ENABLED_WITHOUT_DATA
227228
228229
230+ # Helpers for cancellable tool execution
231+
232+
233+ async def _await_cancellable (awaitable ):
234+ """Await an awaitable in its own task so CancelledError interrupts promptly."""
235+ task = asyncio .create_task (awaitable )
236+ try :
237+ return await task
238+ except asyncio .CancelledError :
239+ # propagate so run.py can handle terminal cancel
240+ raise
241+
242+
243+ def _maybe_call_cancel_hook (tool_obj ) -> None :
244+ """Best-effort: call a cancel/terminate hook on the tool if present."""
245+ for name in ("cancel" , "terminate" , "stop" ):
246+ cb = getattr (tool_obj , name , None )
247+ if callable (cb ):
248+ with contextlib .suppress (Exception ):
249+ cb ()
250+ break
251+
252+
229253class RunImpl :
230254 @classmethod
231255 async def execute_tools_and_side_effects (
@@ -572,16 +596,24 @@ async def run_single_tool(
572596 if config .trace_include_sensitive_data :
573597 span_fn .span_data .input = tool_call .arguments
574598 try :
575- _ , _ , result = await asyncio .gather (
599+ # run start hooks first (don’t tie them to the cancellable task)
600+ await asyncio .gather (
576601 hooks .on_tool_start (tool_context , agent , func_tool ),
577602 (
578603 agent .hooks .on_tool_start (tool_context , agent , func_tool )
579604 if agent .hooks
580605 else _coro .noop_coroutine ()
581606 ),
582- func_tool .on_invoke_tool (tool_context , tool_call .arguments ),
583607 )
584608
609+ try :
610+ result = await _await_cancellable (
611+ func_tool .on_invoke_tool (tool_context , tool_call .arguments )
612+ )
613+ except asyncio .CancelledError :
614+ _maybe_call_cancel_hook (func_tool )
615+ raise
616+
585617 await asyncio .gather (
586618 hooks .on_tool_end (tool_context , agent , func_tool , result ),
587619 (
@@ -590,6 +622,7 @@ async def run_single_tool(
590622 else _coro .noop_coroutine ()
591623 ),
592624 )
625+
593626 except Exception as e :
594627 _error_tracing .attach_error_to_current_span (
595628 SpanError (
@@ -660,7 +693,6 @@ async def execute_computer_actions(
660693 config : RunConfig ,
661694 ) -> list [RunItem ]:
662695 results : list [RunItem ] = []
663- # Need to run these serially, because each action can affect the computer state
664696 for action in actions :
665697 acknowledged : list [ComputerCallOutputAcknowledgedSafetyCheck ] | None = None
666698 if action .tool_call .pending_safety_checks and action .computer_tool .on_safety_check :
@@ -677,24 +709,28 @@ async def execute_computer_actions(
677709 if ack :
678710 acknowledged .append (
679711 ComputerCallOutputAcknowledgedSafetyCheck (
680- id = check .id ,
681- code = check .code ,
682- message = check .message ,
712+ id = check .id , code = check .code , message = check .message
683713 )
684714 )
685715 else :
686716 raise UserError ("Computer tool safety check was not acknowledged" )
687717
688- results .append (
689- await ComputerAction .execute (
690- agent = agent ,
691- action = action ,
692- hooks = hooks ,
693- context_wrapper = context_wrapper ,
694- config = config ,
695- acknowledged_safety_checks = acknowledged ,
718+ try :
719+ item = await _await_cancellable (
720+ ComputerAction .execute (
721+ agent = agent ,
722+ action = action ,
723+ hooks = hooks ,
724+ context_wrapper = context_wrapper ,
725+ config = config ,
726+ acknowledged_safety_checks = acknowledged ,
727+ )
696728 )
697- )
729+ except asyncio .CancelledError :
730+ _maybe_call_cancel_hook (action .computer_tool )
731+ raise
732+
733+ results .append (item )
698734
699735 return results
700736
@@ -1068,16 +1104,23 @@ async def execute(
10681104 else cls ._get_screenshot_sync (action .computer_tool .computer , action .tool_call )
10691105 )
10701106
1071- _ , _ , output = await asyncio .gather (
1107+ # start hooks first
1108+ await asyncio .gather (
10721109 hooks .on_tool_start (context_wrapper , agent , action .computer_tool ),
10731110 (
10741111 agent .hooks .on_tool_start (context_wrapper , agent , action .computer_tool )
10751112 if agent .hooks
10761113 else _coro .noop_coroutine ()
10771114 ),
1078- output_func ,
10791115 )
1080-
1116+ # run the action (screenshot/etc) in a cancellable task
1117+ try :
1118+ output = await _await_cancellable (output_func )
1119+ except asyncio .CancelledError :
1120+ _maybe_call_cancel_hook (action .computer_tool )
1121+ raise
1122+
1123+ # end hooks
10811124 await asyncio .gather (
10821125 hooks .on_tool_end (context_wrapper , agent , action .computer_tool , output ),
10831126 (
@@ -1185,10 +1228,20 @@ async def execute(
11851228 data = call .tool_call ,
11861229 )
11871230 output = call .local_shell_tool .executor (request )
1188- if inspect .isawaitable (output ):
1189- result = await output
1190- else :
1191- result = output
1231+ try :
1232+ if inspect .isawaitable (output ):
1233+ result = await _await_cancellable (output )
1234+ else :
1235+ # If executor returns a sync result, just use it (can’t cancel mid-call)
1236+ result = output
1237+ except asyncio .CancelledError :
1238+ # Best-effort: if the executor or tool exposes a cancel/terminate, call it
1239+ _maybe_call_cancel_hook (call .local_shell_tool )
1240+ # If your executor returns a proc handle (common pattern), adddress it here if needed:
1241+ # with contextlib.suppress(Exception):
1242+ # proc.terminate(); await asyncio.wait_for(proc.wait(), 1.0)
1243+ # proc.kill()
1244+ raise
11921245
11931246 await asyncio .gather (
11941247 hooks .on_tool_end (context_wrapper , agent , call .local_shell_tool , result ),
@@ -1201,7 +1254,7 @@ async def execute(
12011254
12021255 return ToolCallOutputItem (
12031256 agent = agent ,
1204- output = output ,
1257+ output = result ,
12051258 raw_item = {
12061259 "type" : "local_shell_call_output" ,
12071260 "id" : call .tool_call .call_id ,
0 commit comments