1616 Awaitable ,
1717 Callable ,
1818 Dict ,
19+ List ,
1920 Mapping ,
2021 Optional ,
2122 Protocol ,
6061from pipecat .services .settings import LLMSettings
6162from pipecat .services .websocket_service import WebsocketService
6263from pipecat .turns .user_turn_completion_mixin import UserTurnCompletionLLMServiceMixin
64+ from pipecat .utils .async_tool_cancellation import (
65+ ASYNC_TOOL_CANCELLATION_INSTRUCTIONS ,
66+ CANCEL_ASYNC_TOOL_NAME ,
67+ CANCEL_ASYNC_TOOL_SCHEMA ,
68+ )
6369from pipecat .utils .context .llm_context_summarization import (
6470 DEFAULT_SUMMARIZATION_TIMEOUT ,
6571 LLMContextSummarizationUtil ,
@@ -230,6 +236,7 @@ def __init__(
230236 self ._group_parallel_tools = group_parallel_tools
231237 self ._function_call_timeout_secs = function_call_timeout_secs
232238 self ._filter_incomplete_user_turns : bool = False
239+ self ._async_cancellation_enabled : bool = False
233240 self ._base_system_instruction : Optional [str ] = None
234241 self ._adapter = self .adapter_class ()
235242 self ._functions : Dict [Optional [str ], FunctionCallRegistryItem ] = {}
@@ -291,6 +298,8 @@ async def start(self, frame: StartFrame):
291298 await super ().start (frame )
292299 if not self ._run_in_parallel :
293300 await self ._create_sequential_runner_task ()
301+ if self ._has_async_functions ():
302+ self ._setup_async_tool_cancellation ()
294303
295304 async def stop (self , frame : EndFrame ):
296305 """Stop the LLM service.
@@ -315,17 +324,20 @@ async def cancel(self, frame: CancelFrame):
315324 await self ._cancel_summary_task ()
316325
317326 def _compose_system_instruction (self ):
318- """Compose system_instruction by appending turn completion instructions.
327+ """Compose system_instruction from the base and all active addon instructions.
319328
320329 Combines the base system instruction with turn completion instructions
321- and writes the result to ``self._settings.system_instruction``.
330+ (when enabled) and async tool cancellation instructions (when enabled),
331+ writing the result to ``self._settings.system_instruction``.
322332 """
323333 base = self ._base_system_instruction
324- completion_instructions = self ._user_turn_completion_config .completion_instructions
325- if base :
326- self ._settings .system_instruction = f"{ base } \n \n { completion_instructions } "
327- else :
328- self ._settings .system_instruction = completion_instructions
334+ parts = [base ] if base else []
335+ if self ._filter_incomplete_user_turns :
336+ parts .append (self ._user_turn_completion_config .completion_instructions )
337+ if self ._async_cancellation_enabled :
338+ parts .append (ASYNC_TOOL_CANCELLATION_INSTRUCTIONS )
339+ composed = "\n \n " .join (p for p in parts if p )
340+ self ._settings .system_instruction = composed or None
329341
330342 async def _update_settings (self , delta : LLMSettings ) -> dict [str , Any ]:
331343 """Apply a settings delta, handling turn-completion fields.
@@ -361,10 +373,10 @@ async def _update_settings(self, delta: LLMSettings) -> dict[str, Any]:
361373
362374 if (
363375 "system_instruction" in changed
364- and self ._filter_incomplete_user_turns
376+ and ( self ._filter_incomplete_user_turns or self . _async_cancellation_enabled )
365377 and "filter_incomplete_user_turns" not in changed
366378 ):
367- # system_instruction changed while turn completion is active.
379+ # system_instruction changed while composition is active.
368380 # Treat the new value as the new base and recompose.
369381 self ._base_system_instruction = self ._settings .system_instruction
370382 self ._compose_system_instruction ()
@@ -849,6 +861,91 @@ async def timeout_handler():
849861 if timeout_task and not timeout_task .done ():
850862 await self .cancel_task (timeout_task )
851863
864+ def _has_async_functions (self ) -> bool :
865+ """Return True if at least one non-builtin async function is registered."""
866+ return any (
867+ not item .cancel_on_interruption
868+ for name , item in self ._functions .items ()
869+ if name != CANCEL_ASYNC_TOOL_NAME
870+ )
871+
872+ def _setup_async_tool_cancellation (self ):
873+ """Enable async tool cancellation.
874+
875+ Saves the base system instruction, recomposes to include cancellation
876+ instructions, registers the built-in ``cancel_async_tool_call`` handler,
877+ and injects its schema into the adapter's built-in tool list.
878+ """
879+ logger .debug (f"{ self } : Enabling async tool cancellation" )
880+
881+ self ._async_cancellation_enabled = True
882+
883+ if self ._base_system_instruction is None :
884+ self ._base_system_instruction = self ._settings .system_instruction
885+
886+ self ._compose_system_instruction ()
887+
888+ if not any (t .name == CANCEL_ASYNC_TOOL_NAME for t in self ._adapter .builtin_tools ):
889+ self ._adapter .builtin_tools .append (CANCEL_ASYNC_TOOL_SCHEMA )
890+
891+ if CANCEL_ASYNC_TOOL_NAME not in self ._functions :
892+ self ._functions [CANCEL_ASYNC_TOOL_NAME ] = FunctionCallRegistryItem (
893+ function_name = CANCEL_ASYNC_TOOL_NAME ,
894+ handler = self ._cancel_async_tool_call_handler ,
895+ cancel_on_interruption = True ,
896+ )
897+
898+ async def _cancel_async_tool_call_handler (self , params : "FunctionCallParams" ):
899+ """Handle a ``cancel_async_tool_call`` invocation from the LLM.
900+
901+ Args:
902+ params: Function call parameters containing ``tool_call_id`` to cancel.
903+ """
904+ logger .info ("_cancel_async_tool_call_handler invoked!" )
905+
906+ tool_call_id : Optional [str ] = params .arguments .get ("tool_call_id" )
907+ if not tool_call_id :
908+ logger .warning (f"{ self } cancel_async_tool_call called with no tool_call_id" )
909+ await params .result_callback ({"cancelled" : None })
910+ return
911+
912+ await self ._cancel_function_calls_by_tool_call_id (tool_call_id )
913+ await params .result_callback (
914+ {"cancelled" : tool_call_id },
915+ properties = FunctionCallResultProperties (run_llm = True ),
916+ )
917+
918+ async def _cancel_function_calls_by_tool_call_id (self , tool_call_id : str ):
919+ """Cancel in-progress function call tasks by their tool_call_id.
920+
921+ Args:
922+ tool_call_id: tool_call_id to cancel.
923+ """
924+ cancelled_tasks = set ()
925+ for task , runner_item in self ._function_call_tasks .items ():
926+ if runner_item .tool_call_id == tool_call_id :
927+ name = runner_item .function_name
928+ tool_call_id = runner_item .tool_call_id
929+
930+ logger .debug (
931+ f"{ self } Cancelling async function call [{ name } :{ tool_call_id } ] "
932+ "by LLM request..."
933+ )
934+
935+ if task :
936+ task .remove_done_callback (self ._function_call_task_finished )
937+ await self .cancel_task (task )
938+ cancelled_tasks .add (task )
939+
940+ await self .broadcast_frame (
941+ FunctionCallCancelFrame , function_name = name , tool_call_id = tool_call_id
942+ )
943+
944+ logger .debug (f"{ self } Async function call [{ name } :{ tool_call_id } ] cancelled" )
945+
946+ for task in cancelled_tasks :
947+ self ._function_call_task_finished (task )
948+
852949 async def _cancel_function_call (self , function_name : Optional [str ]):
853950 cancelled_tasks = set ()
854951 for task , runner_item in self ._function_call_tasks .items ():
0 commit comments