From 772fb57090c8aa6eb4c9e07511e5c94cba0a2efb Mon Sep 17 00:00:00 2001 From: filipi87 Date: Thu, 9 Apr 2026 10:29:23 -0300 Subject: [PATCH 1/8] Enable async tool cancellation feature. --- src/pipecat/adapters/base_llm_adapter.py | 36 ++++++ src/pipecat/services/llm_service.py | 115 +++++++++++++++++-- src/pipecat/utils/async_tool_cancellation.py | 49 ++++++++ 3 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 src/pipecat/utils/async_tool_cancellation.py diff --git a/src/pipecat/adapters/base_llm_adapter.py b/src/pipecat/adapters/base_llm_adapter.py index 3e8037629..7d210d1f5 100644 --- a/src/pipecat/adapters/base_llm_adapter.py +++ b/src/pipecat/adapters/base_llm_adapter.py @@ -15,6 +15,7 @@ from typing import Any, Dict, Generic, List, Optional, TypeVar from loguru import logger +from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema from pipecat.processors.aggregators.llm_context import ( LLMContext, @@ -48,6 +49,20 @@ class BaseLLMAdapter(ABC, Generic[TLLMInvocationParams]): def __init__(self): """Initialize the adapter.""" self._warned_system_instruction = False + self._builtin_tools: List[FunctionSchema] = [] + + @property + def builtin_tools(self) -> List[FunctionSchema]: + """Built-in tools automatically merged into every inference request. + + Mixins (e.g. ``AsyncToolCancellationLLMServiceMixin``) append their + tool schemas here so that the tools are injected transparently without + the user having to add them to their ``ToolsSchema``. + + Returns: + Mutable list of ``FunctionSchema`` instances. + """ + return self._builtin_tools @property @abstractmethod @@ -122,6 +137,9 @@ class BaseLLMAdapter(ABC, Generic[TLLMInvocationParams]): def from_standard_tools(self, tools: Any) -> List[Any] | NotGiven: """Convert tools from standard format to provider format. + Built-in tools are automatically merged into the schema before conversion so that every + inference request receives them without the user having to declare them explicitly. + Args: tools: Tools in standard format or provider-specific format. @@ -129,8 +147,26 @@ class BaseLLMAdapter(ABC, Generic[TLLMInvocationParams]): List of tools converted to provider format, or original tools if not in standard format. """ + if self._builtin_tools: + if isinstance(tools, ToolsSchema): + tools = ToolsSchema( + standard_tools=tools.standard_tools + self._builtin_tools, + custom_tools=tools.custom_tools, + ) + else: + # User supplied tools in a legacy/provider-specific format; + # we cannot safely merge — build a schema from builtins only. + if tools is not None: + logger.warning( + "Built-in tools could not be merged because the supplied tools are not" + " a ToolsSchema instance. Only built-in tools will be sent." + ) + tools = ToolsSchema(standard_tools=self._builtin_tools) + if isinstance(tools, ToolsSchema): logger.debug(f"Retrieving the tools using the adapter: {type(self)}") + tool_names = [tool.name for tool in tools.standard_tools] + logger.debug(f"Tool names: {tool_names}") return self.to_provider_tools_format(tools) # Fallback to return the same tools in case they are not in a standard format return tools diff --git a/src/pipecat/services/llm_service.py b/src/pipecat/services/llm_service.py index 03fcb115f..127b9d60e 100644 --- a/src/pipecat/services/llm_service.py +++ b/src/pipecat/services/llm_service.py @@ -16,6 +16,7 @@ from typing import ( Awaitable, Callable, Dict, + List, Mapping, Optional, Protocol, @@ -60,6 +61,11 @@ from pipecat.services.ai_service import AIService from pipecat.services.settings import LLMSettings from pipecat.services.websocket_service import WebsocketService from pipecat.turns.user_turn_completion_mixin import UserTurnCompletionLLMServiceMixin +from pipecat.utils.async_tool_cancellation import ( + ASYNC_TOOL_CANCELLATION_INSTRUCTIONS, + CANCEL_ASYNC_TOOL_NAME, + CANCEL_ASYNC_TOOL_SCHEMA, +) from pipecat.utils.context.llm_context_summarization import ( DEFAULT_SUMMARIZATION_TIMEOUT, LLMContextSummarizationUtil, @@ -230,6 +236,7 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): self._group_parallel_tools = group_parallel_tools self._function_call_timeout_secs = function_call_timeout_secs self._filter_incomplete_user_turns: bool = False + self._async_cancellation_enabled: bool = False self._base_system_instruction: Optional[str] = None self._adapter = self.adapter_class() self._functions: Dict[Optional[str], FunctionCallRegistryItem] = {} @@ -291,6 +298,8 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): await super().start(frame) if not self._run_in_parallel: await self._create_sequential_runner_task() + if self._has_async_functions(): + self._setup_async_tool_cancellation() async def stop(self, frame: EndFrame): """Stop the LLM service. @@ -315,17 +324,20 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): await self._cancel_summary_task() def _compose_system_instruction(self): - """Compose system_instruction by appending turn completion instructions. + """Compose system_instruction from the base and all active addon instructions. Combines the base system instruction with turn completion instructions - and writes the result to ``self._settings.system_instruction``. + (when enabled) and async tool cancellation instructions (when enabled), + writing the result to ``self._settings.system_instruction``. """ base = self._base_system_instruction - completion_instructions = self._user_turn_completion_config.completion_instructions - if base: - self._settings.system_instruction = f"{base}\n\n{completion_instructions}" - else: - self._settings.system_instruction = completion_instructions + parts = [base] if base else [] + if self._filter_incomplete_user_turns: + parts.append(self._user_turn_completion_config.completion_instructions) + if self._async_cancellation_enabled: + parts.append(ASYNC_TOOL_CANCELLATION_INSTRUCTIONS) + composed = "\n\n".join(p for p in parts if p) + self._settings.system_instruction = composed or None async def _update_settings(self, delta: LLMSettings) -> dict[str, Any]: """Apply a settings delta, handling turn-completion fields. @@ -361,10 +373,10 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): if ( "system_instruction" in changed - and self._filter_incomplete_user_turns + and (self._filter_incomplete_user_turns or self._async_cancellation_enabled) and "filter_incomplete_user_turns" not in changed ): - # system_instruction changed while turn completion is active. + # system_instruction changed while composition is active. # Treat the new value as the new base and recompose. self._base_system_instruction = self._settings.system_instruction self._compose_system_instruction() @@ -849,6 +861,91 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): if timeout_task and not timeout_task.done(): await self.cancel_task(timeout_task) + def _has_async_functions(self) -> bool: + """Return True if at least one non-builtin async function is registered.""" + return any( + not item.cancel_on_interruption + for name, item in self._functions.items() + if name != CANCEL_ASYNC_TOOL_NAME + ) + + def _setup_async_tool_cancellation(self): + """Enable async tool cancellation. + + Saves the base system instruction, recomposes to include cancellation + instructions, registers the built-in ``cancel_async_tool_call`` handler, + and injects its schema into the adapter's built-in tool list. + """ + logger.debug(f"{self}: Enabling async tool cancellation") + + self._async_cancellation_enabled = True + + if self._base_system_instruction is None: + self._base_system_instruction = self._settings.system_instruction + + self._compose_system_instruction() + + if not any(t.name == CANCEL_ASYNC_TOOL_NAME for t in self._adapter.builtin_tools): + self._adapter.builtin_tools.append(CANCEL_ASYNC_TOOL_SCHEMA) + + if CANCEL_ASYNC_TOOL_NAME not in self._functions: + self._functions[CANCEL_ASYNC_TOOL_NAME] = FunctionCallRegistryItem( + function_name=CANCEL_ASYNC_TOOL_NAME, + handler=self._cancel_async_tool_call_handler, + cancel_on_interruption=True, + ) + + async def _cancel_async_tool_call_handler(self, params: "FunctionCallParams"): + """Handle a ``cancel_async_tool_call`` invocation from the LLM. + + Args: + params: Function call parameters containing ``tool_call_id`` to cancel. + """ + logger.info("_cancel_async_tool_call_handler invoked!") + + tool_call_id: Optional[str] = params.arguments.get("tool_call_id") + if not tool_call_id: + logger.warning(f"{self} cancel_async_tool_call called with no tool_call_id") + await params.result_callback({"cancelled": None}) + return + + await self._cancel_function_calls_by_tool_call_id(tool_call_id) + await params.result_callback( + {"cancelled": tool_call_id}, + properties=FunctionCallResultProperties(run_llm=True), + ) + + async def _cancel_function_calls_by_tool_call_id(self, tool_call_id: str): + """Cancel in-progress function call tasks by their tool_call_id. + + Args: + tool_call_id: tool_call_id to cancel. + """ + cancelled_tasks = set() + for task, runner_item in self._function_call_tasks.items(): + if runner_item.tool_call_id == tool_call_id: + name = runner_item.function_name + tool_call_id = runner_item.tool_call_id + + logger.debug( + f"{self} Cancelling async function call [{name}:{tool_call_id}] " + "by LLM request..." + ) + + if task: + task.remove_done_callback(self._function_call_task_finished) + await self.cancel_task(task) + cancelled_tasks.add(task) + + await self.broadcast_frame( + FunctionCallCancelFrame, function_name=name, tool_call_id=tool_call_id + ) + + logger.debug(f"{self} Async function call [{name}:{tool_call_id}] cancelled") + + for task in cancelled_tasks: + self._function_call_task_finished(task) + async def _cancel_function_call(self, function_name: Optional[str]): cancelled_tasks = set() for task, runner_item in self._function_call_tasks.items(): diff --git a/src/pipecat/utils/async_tool_cancellation.py b/src/pipecat/utils/async_tool_cancellation.py new file mode 100644 index 000000000..e00741508 --- /dev/null +++ b/src/pipecat/utils/async_tool_cancellation.py @@ -0,0 +1,49 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Constants for the built-in async tool cancellation feature. + +When an ``LLMService`` has functions registered with +``cancel_on_interruption=False`` (async tools), it automatically injects the +``cancel_async_tool_call`` tool and the instructions below into every inference +request so the LLM can cancel stale in-progress calls. +""" + +from pipecat.adapters.schemas.function_schema import FunctionSchema + +CANCEL_ASYNC_TOOL_NAME = "cancel_async_tool_call" + +ASYNC_TOOL_CANCELLATION_INSTRUCTIONS = """ASYNC TOOL CANCELLATION: +Some tool calls run asynchronously in the background. When one starts, a tool response \ +is added to the conversation whose content is a JSON object with \ +"type": "tool", "status": "started", and a "tool_call_id" field containing the \ +exact ID of that call (e.g. {"type": "tool", "status": "started", "tool_call_id": "..."}). + +If the user changes topic, explicitly says they no longer need the result, or the pending \ +result would clearly be stale, call cancel_async_tool_call. \ +To find the correct tool_call_id: locate the most recent tool response in the conversation \ +whose content has "status": "started" and whose call has NOT already been cancelled, \ +then copy the "tool_call_id" value from that content exactly as-is. \ +Never invent or guess a tool_call_id.""" + +CANCEL_ASYNC_TOOL_SCHEMA = FunctionSchema( + name=CANCEL_ASYNC_TOOL_NAME, + description=( + "Cancel a single async tool call that is no longer needed. " + "Use this when the user changes topic, indicates a pending result is " + "no longer relevant, or when processing the result would produce a " + "stale or confusing response. " + "The tool_call_id must be the exact 'id' value from the assistant's " + "tool call which we wish to cancel, visible in the conversation history." + ), + properties={ + "tool_call_id": { + "type": "string", + "description": ("The exact id of the async call to cancel."), + } + }, + required=["tool_call_id"], +) From 5cf90cba9807ae0519c2a8e577dc521930731350 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Thu, 9 Apr 2026 17:11:04 -0300 Subject: [PATCH 2/8] Addressing PR review comments. --- src/pipecat/adapters/base_llm_adapter.py | 39 ++++++++------ src/pipecat/services/llm_service.py | 54 ++++++++++++++++---- src/pipecat/utils/async_tool_cancellation.py | 16 +++--- 3 files changed, 75 insertions(+), 34 deletions(-) diff --git a/src/pipecat/adapters/base_llm_adapter.py b/src/pipecat/adapters/base_llm_adapter.py index 7d210d1f5..b1080d197 100644 --- a/src/pipecat/adapters/base_llm_adapter.py +++ b/src/pipecat/adapters/base_llm_adapter.py @@ -10,6 +10,7 @@ This module provides the abstract base class for implementing LLM provider-speci adapters that handle tool format conversion and standardization. """ +import warnings from abc import ABC, abstractmethod from typing import Any, Dict, Generic, List, Optional, TypeVar @@ -49,18 +50,19 @@ class BaseLLMAdapter(ABC, Generic[TLLMInvocationParams]): def __init__(self): """Initialize the adapter.""" self._warned_system_instruction = False - self._builtin_tools: List[FunctionSchema] = [] + self._builtin_tools: Dict[str, FunctionSchema] = {} @property - def builtin_tools(self) -> List[FunctionSchema]: + def builtin_tools(self) -> Dict[str, FunctionSchema]: """Built-in tools automatically merged into every inference request. - Mixins (e.g. ``AsyncToolCancellationLLMServiceMixin``) append their - tool schemas here so that the tools are injected transparently without - the user having to add them to their ``ToolsSchema``. + Keyed by tool name for O(1) lookup, insertion, and removal. The + service injects tools here so they are sent transparently on every + inference request without the user having to add them to their + ``ToolsSchema``. Returns: - Mutable list of ``FunctionSchema`` instances. + Mutable dict mapping tool name to ``FunctionSchema``. """ return self._builtin_tools @@ -150,23 +152,28 @@ class BaseLLMAdapter(ABC, Generic[TLLMInvocationParams]): if self._builtin_tools: if isinstance(tools, ToolsSchema): tools = ToolsSchema( - standard_tools=tools.standard_tools + self._builtin_tools, + standard_tools=tools.standard_tools + list(self._builtin_tools.values()), custom_tools=tools.custom_tools, ) else: - # User supplied tools in a legacy/provider-specific format; - # we cannot safely merge — build a schema from builtins only. + # User supplied tools in a legacy/provider-specific format. + # Built-in tools cannot be safely merged, so they will not be injected. + # Migrate to ToolsSchema to enable built-in tool support; use custom_tools + # as an escape hatch for any provider-specific tools that don't fit the + # standard schema. if tools is not None: - logger.warning( - "Built-in tools could not be merged because the supplied tools are not" - " a ToolsSchema instance. Only built-in tools will be sent." + warnings.warn( + "Built-in tools (e.g. async tool cancellation) could not be injected " + "because the supplied tools are not a ToolsSchema instance. " + "Migrate to ToolsSchema to enable built-in tool support. " + "Use ToolsSchema(custom_tools=...) as an escape hatch for any " + "provider-specific tools that don't fit the standard schema.", + DeprecationWarning, + stacklevel=2, ) - tools = ToolsSchema(standard_tools=self._builtin_tools) + # Fall through and return the original tools unchanged. if isinstance(tools, ToolsSchema): - logger.debug(f"Retrieving the tools using the adapter: {type(self)}") - tool_names = [tool.name for tool in tools.standard_tools] - logger.debug(f"Tool names: {tool_names}") return self.to_provider_tools_format(tools) # Fallback to return the same tools in case they are not in a standard format return tools diff --git a/src/pipecat/services/llm_service.py b/src/pipecat/services/llm_service.py index 127b9d60e..3602e8eda 100644 --- a/src/pipecat/services/llm_service.py +++ b/src/pipecat/services/llm_service.py @@ -207,6 +207,7 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): run_in_parallel: bool = True, group_parallel_tools: bool = True, function_call_timeout_secs: Optional[float] = None, + enable_async_tool_cancellation: bool = False, settings: Optional[LLMSettings] = None, **kwargs, ): @@ -221,6 +222,10 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): arrives. Defaults to True. function_call_timeout_secs: Optional timeout in seconds for deferred function calls. + enable_async_tool_cancellation: When True and at least one async function + (``cancel_on_interruption=False``) is registered, automatically injects + the ``cancel_async_tool_call`` built-in tool and its system instructions + so the LLM can cancel stale in-progress calls. Defaults to False. settings: The runtime-updatable settings for the LLM service. **kwargs: Additional arguments passed to the parent AIService. @@ -235,8 +240,9 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): self._run_in_parallel = run_in_parallel self._group_parallel_tools = group_parallel_tools self._function_call_timeout_secs = function_call_timeout_secs + self._enable_async_tool_cancellation: bool = enable_async_tool_cancellation self._filter_incomplete_user_turns: bool = False - self._async_cancellation_enabled: bool = False + self._async_tool_cancellation_enabled: bool = False self._base_system_instruction: Optional[str] = None self._adapter = self.adapter_class() self._functions: Dict[Optional[str], FunctionCallRegistryItem] = {} @@ -298,7 +304,7 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): await super().start(frame) if not self._run_in_parallel: await self._create_sequential_runner_task() - if self._has_async_functions(): + if self._enable_async_tool_cancellation and self._has_async_tools(): self._setup_async_tool_cancellation() async def stop(self, frame: EndFrame): @@ -334,7 +340,7 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): parts = [base] if base else [] if self._filter_incomplete_user_turns: parts.append(self._user_turn_completion_config.completion_instructions) - if self._async_cancellation_enabled: + if self._async_tool_cancellation_enabled: parts.append(ASYNC_TOOL_CANCELLATION_INSTRUCTIONS) composed = "\n\n".join(p for p in parts if p) self._settings.system_instruction = composed or None @@ -373,7 +379,7 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): if ( "system_instruction" in changed - and (self._filter_incomplete_user_turns or self._async_cancellation_enabled) + and (self._filter_incomplete_user_turns or self._async_tool_cancellation_enabled) and "filter_incomplete_user_turns" not in changed ): # system_instruction changed while composition is active. @@ -588,6 +594,11 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): ``function_call_timeout_secs`` for this specific function. Defaults to None, which uses the global timeout. """ + if function_name == CANCEL_ASYNC_TOOL_NAME: + raise ValueError( + f"'{CANCEL_ASYNC_TOOL_NAME}' is a reserved built-in tool name and cannot be " + "registered by user code." + ) # Registering a function with the function_name set to None will run # that handler for all functions self._functions[function_name] = FunctionCallRegistryItem( @@ -622,6 +633,11 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): None, which uses the global timeout. """ wrapper = DirectFunctionWrapper(handler) + if wrapper.name == CANCEL_ASYNC_TOOL_NAME: + raise ValueError( + f"'{CANCEL_ASYNC_TOOL_NAME}' is a reserved built-in tool name and cannot be " + "registered by user code." + ) self._functions[wrapper.name] = FunctionCallRegistryItem( function_name=wrapper.name, handler=wrapper, @@ -636,6 +652,8 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): function_name: The name of the function handler to remove. """ del self._functions[function_name] + if self._async_tool_cancellation_enabled and not self._has_async_tools(): + self._teardown_async_tool_cancellation() def unregister_direct_function(self, handler: Any): """Remove a registered direct function handler. @@ -646,6 +664,8 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): wrapper = DirectFunctionWrapper(handler) del self._functions[wrapper.name] # Note: no need to remove start callback here, as direct functions don't support start callbacks. + if self._async_tool_cancellation_enabled and not self._has_async_tools(): + self._teardown_async_tool_cancellation() def has_function(self, function_name: str): """Check if a function handler is registered. @@ -861,8 +881,8 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): if timeout_task and not timeout_task.done(): await self.cancel_task(timeout_task) - def _has_async_functions(self) -> bool: - """Return True if at least one non-builtin async function is registered.""" + def _has_async_tools(self) -> bool: + """Return True if at least one non-builtin async tool is registered.""" return any( not item.cancel_on_interruption for name, item in self._functions.items() @@ -874,19 +894,18 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): Saves the base system instruction, recomposes to include cancellation instructions, registers the built-in ``cancel_async_tool_call`` handler, - and injects its schema into the adapter's built-in tool list. + and injects its schema into the adapter's built-in tool dict. """ logger.debug(f"{self}: Enabling async tool cancellation") - self._async_cancellation_enabled = True + self._async_tool_cancellation_enabled = True if self._base_system_instruction is None: self._base_system_instruction = self._settings.system_instruction self._compose_system_instruction() - if not any(t.name == CANCEL_ASYNC_TOOL_NAME for t in self._adapter.builtin_tools): - self._adapter.builtin_tools.append(CANCEL_ASYNC_TOOL_SCHEMA) + self._adapter.builtin_tools[CANCEL_ASYNC_TOOL_NAME] = CANCEL_ASYNC_TOOL_SCHEMA if CANCEL_ASYNC_TOOL_NAME not in self._functions: self._functions[CANCEL_ASYNC_TOOL_NAME] = FunctionCallRegistryItem( @@ -895,13 +914,26 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): cancel_on_interruption=True, ) + def _teardown_async_tool_cancellation(self): + """Disable async tool cancellation. + + Removes the built-in ``cancel_async_tool_call`` handler and its schema, + recomposes the system instruction without cancellation instructions. + """ + logger.debug(f"{self}: Disabling async tool cancellation") + + self._async_tool_cancellation_enabled = False + self._adapter.builtin_tools.pop(CANCEL_ASYNC_TOOL_NAME, None) + self._functions.pop(CANCEL_ASYNC_TOOL_NAME, None) + self._compose_system_instruction() + async def _cancel_async_tool_call_handler(self, params: "FunctionCallParams"): """Handle a ``cancel_async_tool_call`` invocation from the LLM. Args: params: Function call parameters containing ``tool_call_id`` to cancel. """ - logger.info("_cancel_async_tool_call_handler invoked!") + logger.debug(f"{self}: cancel_async_tool_call invoked") tool_call_id: Optional[str] = params.arguments.get("tool_call_id") if not tool_call_id: diff --git a/src/pipecat/utils/async_tool_cancellation.py b/src/pipecat/utils/async_tool_cancellation.py index e00741508..c32dc1a29 100644 --- a/src/pipecat/utils/async_tool_cancellation.py +++ b/src/pipecat/utils/async_tool_cancellation.py @@ -19,30 +19,32 @@ CANCEL_ASYNC_TOOL_NAME = "cancel_async_tool_call" ASYNC_TOOL_CANCELLATION_INSTRUCTIONS = """ASYNC TOOL CANCELLATION: Some tool calls run asynchronously in the background. When one starts, a tool response \ is added to the conversation whose content is a JSON object with \ -"type": "tool", "status": "started", and a "tool_call_id" field containing the \ -exact ID of that call (e.g. {"type": "tool", "status": "started", "tool_call_id": "..."}). +"type": "async_tool", "status": "running", and a "tool_call_id" field containing the \ +exact ID of that call (e.g. {"type": "async_tool", "status": "running", "tool_call_id": "..."}). If the user changes topic, explicitly says they no longer need the result, or the pending \ result would clearly be stale, call cancel_async_tool_call. \ To find the correct tool_call_id: locate the most recent tool response in the conversation \ -whose content has "status": "started" and whose call has NOT already been cancelled, \ +whose content has "status": "running" and whose call has NOT already been cancelled, \ then copy the "tool_call_id" value from that content exactly as-is. \ Never invent or guess a tool_call_id.""" CANCEL_ASYNC_TOOL_SCHEMA = FunctionSchema( name=CANCEL_ASYNC_TOOL_NAME, description=( - "Cancel a single async tool call that is no longer needed. " + "Cancel a single async tool call whose results are no longer needed. " "Use this when the user changes topic, indicates a pending result is " "no longer relevant, or when processing the result would produce a " "stale or confusing response. " - "The tool_call_id must be the exact 'id' value from the assistant's " - "tool call which we wish to cancel, visible in the conversation history." + "The tool_call_id must be copied exactly from the 'tool_call_id' field " + "in the async tool's 'running' response visible in the conversation history." ), properties={ "tool_call_id": { "type": "string", - "description": ("The exact id of the async call to cancel."), + "description": ( + "The exact tool_call_id from the async tool's 'running' response to cancel." + ), } }, required=["tool_call_id"], From 2dd11702292860b00226f18ed5e0de60c26eae84 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Thu, 9 Apr 2026 17:26:51 -0300 Subject: [PATCH 3/8] Updating the Anthropic stream example to allow cancel the location tracking. --- .../function-calling-anthropic-async-stream.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/examples/function-calling/function-calling-anthropic-async-stream.py b/examples/function-calling/function-calling-anthropic-async-stream.py index 953855da0..e96a56442 100644 --- a/examples/function-calling/function-calling-anthropic-async-stream.py +++ b/examples/function-calling/function-calling-anthropic-async-stream.py @@ -118,6 +118,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = AnthropicLLMService( api_key=os.getenv("ANTHROPIC_API_KEY"), + enable_async_tool_cancellation=True, settings=AnthropicLLMService.Settings( system_instruction=( "You are a helpful assistant in a voice conversation. " @@ -139,10 +140,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): timeout_secs=30, ) - @llm.event_handler("on_function_calls_started") - async def on_function_calls_started(service, function_calls): - await tts.queue_frame(TTSSpeakFrame("Sure, tracking your location now.")) - location_function = FunctionSchema( name="track_current_location", description="Start tracking the user's current GPS location, reporting position updates until the user reaches their destination.", From 346c585290dab773e3152461da1f2c5aca5ade05 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Fri, 10 Apr 2026 07:31:51 -0300 Subject: [PATCH 4/8] Enabling the option to cancel the tools for all the async examples. --- examples/function-calling/function-calling-anthropic-async.py | 1 + .../function-calling/function-calling-google-async-stream.py | 1 + examples/function-calling/function-calling-google-async.py | 1 + .../function-calling/function-calling-openai-async-stream.py | 1 + examples/function-calling/function-calling-openai-async.py | 1 + .../function-calling-openai-responses-async-stream.py | 1 + .../function-calling/function-calling-openai-responses-async.py | 1 + 7 files changed, 7 insertions(+) diff --git a/examples/function-calling/function-calling-anthropic-async.py b/examples/function-calling/function-calling-anthropic-async.py index 979ebcd59..b727cbd13 100644 --- a/examples/function-calling/function-calling-anthropic-async.py +++ b/examples/function-calling/function-calling-anthropic-async.py @@ -77,6 +77,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = AnthropicLLMService( api_key=os.getenv("ANTHROPIC_API_KEY"), + enable_async_tool_cancellation=True, settings=AnthropicLLMService.Settings( system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), diff --git a/examples/function-calling/function-calling-google-async-stream.py b/examples/function-calling/function-calling-google-async-stream.py index 0a2a1e831..c8d0f87db 100644 --- a/examples/function-calling/function-calling-google-async-stream.py +++ b/examples/function-calling/function-calling-google-async-stream.py @@ -118,6 +118,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = GoogleLLMService( api_key=os.getenv("GOOGLE_API_KEY"), + enable_async_tool_cancellation=True, settings=GoogleLLMService.Settings( system_instruction=( "You are a helpful assistant in a voice conversation. " diff --git a/examples/function-calling/function-calling-google-async.py b/examples/function-calling/function-calling-google-async.py index 1b717d4f1..6892d4abd 100644 --- a/examples/function-calling/function-calling-google-async.py +++ b/examples/function-calling/function-calling-google-async.py @@ -128,6 +128,7 @@ indicate you should use the get_image tool are: llm = GoogleLLMService( api_key=os.getenv("GOOGLE_API_KEY"), + enable_async_tool_cancellation=True, settings=GoogleLLMService.Settings( system_instruction=system_prompt, ), diff --git a/examples/function-calling/function-calling-openai-async-stream.py b/examples/function-calling/function-calling-openai-async-stream.py index 5b4412489..1a3a6317b 100644 --- a/examples/function-calling/function-calling-openai-async-stream.py +++ b/examples/function-calling/function-calling-openai-async-stream.py @@ -118,6 +118,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = OpenAILLMService( api_key=os.getenv("OPENAI_API_KEY"), + enable_async_tool_cancellation=True, settings=OpenAILLMService.Settings( system_instruction=( "You are a helpful assistant in a voice conversation. " diff --git a/examples/function-calling/function-calling-openai-async.py b/examples/function-calling/function-calling-openai-async.py index b5c5b83ac..7a7bd5ddd 100644 --- a/examples/function-calling/function-calling-openai-async.py +++ b/examples/function-calling/function-calling-openai-async.py @@ -87,6 +87,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = OpenAILLMService( api_key=os.getenv("OPENAI_API_KEY"), + enable_async_tool_cancellation=True, settings=OpenAILLMService.Settings( system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), diff --git a/examples/function-calling/function-calling-openai-responses-async-stream.py b/examples/function-calling/function-calling-openai-responses-async-stream.py index 0862e4813..5bdde5cb3 100644 --- a/examples/function-calling/function-calling-openai-responses-async-stream.py +++ b/examples/function-calling/function-calling-openai-responses-async-stream.py @@ -118,6 +118,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = OpenAIResponsesLLMService( api_key=os.getenv("OPENAI_API_KEY"), + enable_async_tool_cancellation=True, settings=OpenAIResponsesLLMService.Settings( system_instruction=( "You are a helpful assistant in a voice conversation. " diff --git a/examples/function-calling/function-calling-openai-responses-async.py b/examples/function-calling/function-calling-openai-responses-async.py index ba984bc04..16b21f212 100644 --- a/examples/function-calling/function-calling-openai-responses-async.py +++ b/examples/function-calling/function-calling-openai-responses-async.py @@ -77,6 +77,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = OpenAIResponsesLLMService( api_key=os.getenv("OPENAI_API_KEY"), + enable_async_tool_cancellation=True, settings=OpenAIResponsesLLMService.Settings( system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), From 1ca094dad7cbceb1ca1fa58aa87d46f35b44ac89 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Fri, 10 Apr 2026 07:40:52 -0300 Subject: [PATCH 5/8] Not invoking on_function_calls_started for the cancel function, and creating on_function_calls_cancelled --- src/pipecat/services/llm_service.py | 31 +++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/pipecat/services/llm_service.py b/src/pipecat/services/llm_service.py index 3602e8eda..b1ca29346 100644 --- a/src/pipecat/services/llm_service.py +++ b/src/pipecat/services/llm_service.py @@ -183,7 +183,10 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): - on_completion_timeout: Called when an LLM completion timeout occurs - on_function_calls_started: Called when function calls are received and - execution is about to start + execution is about to start. Built-in tools (e.g. ``cancel_async_tool_call``) + are excluded from this event. + - on_function_calls_cancelled: Called after one or more async tool calls are + cancelled. Example:: @@ -194,6 +197,10 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): @task.event_handler("on_function_calls_started") async def on_function_calls_started(service, function_calls): logger.info(f"Starting {len(function_calls)} function calls") + + @task.event_handler("on_function_calls_cancelled") + async def on_function_calls_cancelled(service, cancelled): + logger.info(f"Cancelled {len(cancelled)} async function calls") """ _settings: LLMSettings @@ -252,6 +259,7 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): self._summary_task: Optional[asyncio.Task] = None self._register_event_handler("on_function_calls_started") + self._register_event_handler("on_function_calls_cancelled") self._register_event_handler("on_completion_timeout") def get_llm_adapter(self) -> BaseLLMAdapter: @@ -693,9 +701,14 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): if len(function_calls) == 0: return - await self._call_event_handler("on_function_calls_started", function_calls) - - await self.broadcast_frame(FunctionCallsStartedFrame, function_calls=function_calls) + # Exclude the built-in cancel tool — it's an internal mechanism and + # should not be surfaced to user-facing event handlers or frames. + user_visible_calls = [ + fc for fc in function_calls if fc.function_name != CANCEL_ASYNC_TOOL_NAME + ] + if user_visible_calls: + await self._call_event_handler("on_function_calls_started", user_visible_calls) + await self.broadcast_frame(FunctionCallsStartedFrame, function_calls=user_visible_calls) # When group_parallel_tools is True all calls share a group_id so the # aggregator triggers the LLM exactly once after the last one completes. @@ -954,6 +967,7 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): tool_call_id: tool_call_id to cancel. """ cancelled_tasks = set() + cancelled_items = [] for task, runner_item in self._function_call_tasks.items(): if runner_item.tool_call_id == tool_call_id: name = runner_item.function_name @@ -973,13 +987,18 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): FunctionCallCancelFrame, function_name=name, tool_call_id=tool_call_id ) + cancelled_items.append(runner_item) logger.debug(f"{self} Async function call [{name}:{tool_call_id}] cancelled") for task in cancelled_tasks: self._function_call_task_finished(task) + if cancelled_items: + await self._call_event_handler("on_function_calls_cancelled", cancelled_items) + async def _cancel_function_call(self, function_name: Optional[str]): cancelled_tasks = set() + cancelled_items = [] for task, runner_item in self._function_call_tasks.items(): if runner_item.registry_item.function_name == function_name: name = runner_item.function_name @@ -999,12 +1018,16 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): FunctionCallCancelFrame, function_name=name, tool_call_id=tool_call_id ) + cancelled_items.append(runner_item) logger.debug(f"{self} Function call [{name}:{tool_call_id}] has been cancelled") # Remove all cancelled tasks from our set. for task in cancelled_tasks: self._function_call_task_finished(task) + if cancelled_items: + await self._call_event_handler("on_function_calls_cancelled", cancelled_items) + def _function_call_task_finished(self, task: asyncio.Task): if task in self._function_call_tasks: del self._function_call_tasks[task] From 891f00cb5fe79eb3ab5ad074df7a154f45927d5c Mon Sep 17 00:00:00 2001 From: filipi87 Date: Fri, 10 Apr 2026 07:45:20 -0300 Subject: [PATCH 6/8] Using the on_function_calls_cancelled inside the examples. --- .../function-calling-anthropic-async-stream.py | 5 +++++ .../function-calling/function-calling-anthropic-async.py | 5 +++++ .../function-calling/function-calling-google-async-stream.py | 5 +++++ examples/function-calling/function-calling-google-async.py | 5 +++++ .../function-calling/function-calling-openai-async-stream.py | 5 +++++ examples/function-calling/function-calling-openai-async.py | 5 +++++ .../function-calling-openai-responses-async-stream.py | 5 +++++ .../function-calling-openai-responses-async.py | 5 +++++ 8 files changed, 40 insertions(+) diff --git a/examples/function-calling/function-calling-anthropic-async-stream.py b/examples/function-calling/function-calling-anthropic-async-stream.py index e96a56442..8dc62481a 100644 --- a/examples/function-calling/function-calling-anthropic-async-stream.py +++ b/examples/function-calling/function-calling-anthropic-async-stream.py @@ -140,6 +140,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): timeout_secs=30, ) + @llm.event_handler("on_function_calls_cancelled") + async def on_function_calls_cancelled(service, cancelled): + for item in cancelled: + logger.info(f"Function call cancelled: {item.function_name} [{item.tool_call_id}]") + location_function = FunctionSchema( name="track_current_location", description="Start tracking the user's current GPS location, reporting position updates until the user reaches their destination.", diff --git a/examples/function-calling/function-calling-anthropic-async.py b/examples/function-calling/function-calling-anthropic-async.py index b727cbd13..62baaaa1b 100644 --- a/examples/function-calling/function-calling-anthropic-async.py +++ b/examples/function-calling/function-calling-anthropic-async.py @@ -93,6 +93,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation) + @llm.event_handler("on_function_calls_cancelled") + async def on_function_calls_cancelled(service, cancelled): + for item in cancelled: + logger.info(f"Function call cancelled: {item.function_name} [{item.tool_call_id}]") + weather_function = FunctionSchema( name="get_current_weather", description="Get the current weather", diff --git a/examples/function-calling/function-calling-google-async-stream.py b/examples/function-calling/function-calling-google-async-stream.py index c8d0f87db..63c83318e 100644 --- a/examples/function-calling/function-calling-google-async-stream.py +++ b/examples/function-calling/function-calling-google-async-stream.py @@ -144,6 +144,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_function_calls_started(service, function_calls): await tts.queue_frame(TTSSpeakFrame("Sure, tracking your location now.")) + @llm.event_handler("on_function_calls_cancelled") + async def on_function_calls_cancelled(service, cancelled): + for item in cancelled: + logger.info(f"Function call cancelled: {item.function_name} [{item.tool_call_id}]") + location_function = FunctionSchema( name="track_current_location", description="Start tracking the user's current GPS location, reporting position updates until the user reaches their destination.", diff --git a/examples/function-calling/function-calling-google-async.py b/examples/function-calling/function-calling-google-async.py index 6892d4abd..89bae24d4 100644 --- a/examples/function-calling/function-calling-google-async.py +++ b/examples/function-calling/function-calling-google-async.py @@ -141,6 +141,11 @@ indicate you should use the get_image tool are: async def on_function_calls_started(service, function_calls): await tts.queue_frame(TTSSpeakFrame("Let me check on that.")) + @llm.event_handler("on_function_calls_cancelled") + async def on_function_calls_cancelled(service, cancelled): + for item in cancelled: + logger.info(f"Function call cancelled: {item.function_name} [{item.tool_call_id}]") + weather_function = FunctionSchema( name="get_weather", description="Get the current weather", diff --git a/examples/function-calling/function-calling-openai-async-stream.py b/examples/function-calling/function-calling-openai-async-stream.py index 1a3a6317b..fd2caaddf 100644 --- a/examples/function-calling/function-calling-openai-async-stream.py +++ b/examples/function-calling/function-calling-openai-async-stream.py @@ -144,6 +144,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_function_calls_started(service, function_calls): await tts.queue_frame(TTSSpeakFrame("Sure, tracking your location now.")) + @llm.event_handler("on_function_calls_cancelled") + async def on_function_calls_cancelled(service, cancelled): + for item in cancelled: + logger.info(f"Function call cancelled: {item.function_name} [{item.tool_call_id}]") + location_function = FunctionSchema( name="track_current_location", description="Start tracking the user's current GPS location, reporting position updates until the user reaches their destination.", diff --git a/examples/function-calling/function-calling-openai-async.py b/examples/function-calling/function-calling-openai-async.py index 7a7bd5ddd..50efa25e9 100644 --- a/examples/function-calling/function-calling-openai-async.py +++ b/examples/function-calling/function-calling-openai-async.py @@ -107,6 +107,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_function_calls_started(service, function_calls): await tts.queue_frame(TTSSpeakFrame("Let me check on that.")) + @llm.event_handler("on_function_calls_cancelled") + async def on_function_calls_cancelled(service, cancelled): + for item in cancelled: + logger.info(f"Function call cancelled: {item.function_name} [{item.tool_call_id}]") + weather_function = FunctionSchema( name="get_current_weather", description="Get the current weather", diff --git a/examples/function-calling/function-calling-openai-responses-async-stream.py b/examples/function-calling/function-calling-openai-responses-async-stream.py index 5bdde5cb3..093e0d47b 100644 --- a/examples/function-calling/function-calling-openai-responses-async-stream.py +++ b/examples/function-calling/function-calling-openai-responses-async-stream.py @@ -144,6 +144,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_function_calls_started(service, function_calls): await tts.queue_frame(TTSSpeakFrame("Sure, tracking your location now.")) + @llm.event_handler("on_function_calls_cancelled") + async def on_function_calls_cancelled(service, cancelled): + for item in cancelled: + logger.info(f"Function call cancelled: {item.function_name} [{item.tool_call_id}]") + location_function = FunctionSchema( name="track_current_location", description="Track the device's current GPS location during a road trip, reporting position updates as the vehicle moves through cities until it reaches the final destination.", diff --git a/examples/function-calling/function-calling-openai-responses-async.py b/examples/function-calling/function-calling-openai-responses-async.py index 16b21f212..8e6fb3fa9 100644 --- a/examples/function-calling/function-calling-openai-responses-async.py +++ b/examples/function-calling/function-calling-openai-responses-async.py @@ -105,6 +105,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # matching, forcing a full context resend. await tts.queue_frame(TTSSpeakFrame("Let me check on that.", append_to_context=False)) + @llm.event_handler("on_function_calls_cancelled") + async def on_function_calls_cancelled(service, cancelled): + for item in cancelled: + logger.info(f"Function call cancelled: {item.function_name} [{item.tool_call_id}]") + weather_function = FunctionSchema( name="get_current_weather", description="Get the current weather", From 8cce25d2d290e7886e84e9fc39cc4c0bbfb999c4 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Fri, 10 Apr 2026 08:25:50 -0300 Subject: [PATCH 7/8] Fixing openai examples. --- .../function-calling/function-calling-openai-async-stream.py | 3 +++ examples/function-calling/function-calling-openai-async.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/examples/function-calling/function-calling-openai-async-stream.py b/examples/function-calling/function-calling-openai-async-stream.py index fd2caaddf..b91a0eac1 100644 --- a/examples/function-calling/function-calling-openai-async-stream.py +++ b/examples/function-calling/function-calling-openai-async-stream.py @@ -187,6 +187,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. + context.add_message( + {"role": "developer", "content": "Please introduce yourself to the user."} + ) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/function-calling/function-calling-openai-async.py b/examples/function-calling/function-calling-openai-async.py index 50efa25e9..79a4ccad0 100644 --- a/examples/function-calling/function-calling-openai-async.py +++ b/examples/function-calling/function-calling-openai-async.py @@ -171,6 +171,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. + context.add_message( + {"role": "developer", "content": "Please introduce yourself to the user."} + ) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") From c54216706502bd095872c76a0fb93982057e7d6e Mon Sep 17 00:00:00 2001 From: filipi87 Date: Fri, 10 Apr 2026 15:06:39 -0300 Subject: [PATCH 8/8] Refactored on_function_calls_cancelled to use FunctionCallFromLLM. --- ...function-calling-anthropic-async-stream.py | 4 ++-- .../function-calling-anthropic-async.py | 4 ++-- .../function-calling-google-async-stream.py | 4 ++-- .../function-calling-google-async.py | 4 ++-- .../function-calling-openai-async-stream.py | 4 ++-- .../function-calling-openai-async.py | 4 ++-- ...n-calling-openai-responses-async-stream.py | 4 ++-- ...function-calling-openai-responses-async.py | 4 ++-- src/pipecat/services/llm_service.py | 24 +++++++++++++++---- 9 files changed, 35 insertions(+), 21 deletions(-) diff --git a/examples/function-calling/function-calling-anthropic-async-stream.py b/examples/function-calling/function-calling-anthropic-async-stream.py index 8dc62481a..52069af68 100644 --- a/examples/function-calling/function-calling-anthropic-async-stream.py +++ b/examples/function-calling/function-calling-anthropic-async-stream.py @@ -141,8 +141,8 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) @llm.event_handler("on_function_calls_cancelled") - async def on_function_calls_cancelled(service, cancelled): - for item in cancelled: + async def on_function_calls_cancelled(service, function_calls): + for item in function_calls: logger.info(f"Function call cancelled: {item.function_name} [{item.tool_call_id}]") location_function = FunctionSchema( diff --git a/examples/function-calling/function-calling-anthropic-async.py b/examples/function-calling/function-calling-anthropic-async.py index 62baaaa1b..2c5fb9402 100644 --- a/examples/function-calling/function-calling-anthropic-async.py +++ b/examples/function-calling/function-calling-anthropic-async.py @@ -94,8 +94,8 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation) @llm.event_handler("on_function_calls_cancelled") - async def on_function_calls_cancelled(service, cancelled): - for item in cancelled: + async def on_function_calls_cancelled(service, function_calls): + for item in function_calls: logger.info(f"Function call cancelled: {item.function_name} [{item.tool_call_id}]") weather_function = FunctionSchema( diff --git a/examples/function-calling/function-calling-google-async-stream.py b/examples/function-calling/function-calling-google-async-stream.py index 63c83318e..503880ca6 100644 --- a/examples/function-calling/function-calling-google-async-stream.py +++ b/examples/function-calling/function-calling-google-async-stream.py @@ -145,8 +145,8 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): await tts.queue_frame(TTSSpeakFrame("Sure, tracking your location now.")) @llm.event_handler("on_function_calls_cancelled") - async def on_function_calls_cancelled(service, cancelled): - for item in cancelled: + async def on_function_calls_cancelled(service, function_calls): + for item in function_calls: logger.info(f"Function call cancelled: {item.function_name} [{item.tool_call_id}]") location_function = FunctionSchema( diff --git a/examples/function-calling/function-calling-google-async.py b/examples/function-calling/function-calling-google-async.py index 89bae24d4..dbc86d664 100644 --- a/examples/function-calling/function-calling-google-async.py +++ b/examples/function-calling/function-calling-google-async.py @@ -142,8 +142,8 @@ indicate you should use the get_image tool are: await tts.queue_frame(TTSSpeakFrame("Let me check on that.")) @llm.event_handler("on_function_calls_cancelled") - async def on_function_calls_cancelled(service, cancelled): - for item in cancelled: + async def on_function_calls_cancelled(service, function_calls): + for item in function_calls: logger.info(f"Function call cancelled: {item.function_name} [{item.tool_call_id}]") weather_function = FunctionSchema( diff --git a/examples/function-calling/function-calling-openai-async-stream.py b/examples/function-calling/function-calling-openai-async-stream.py index b91a0eac1..a60a5198f 100644 --- a/examples/function-calling/function-calling-openai-async-stream.py +++ b/examples/function-calling/function-calling-openai-async-stream.py @@ -145,8 +145,8 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): await tts.queue_frame(TTSSpeakFrame("Sure, tracking your location now.")) @llm.event_handler("on_function_calls_cancelled") - async def on_function_calls_cancelled(service, cancelled): - for item in cancelled: + async def on_function_calls_cancelled(service, function_calls): + for item in function_calls: logger.info(f"Function call cancelled: {item.function_name} [{item.tool_call_id}]") location_function = FunctionSchema( diff --git a/examples/function-calling/function-calling-openai-async.py b/examples/function-calling/function-calling-openai-async.py index 79a4ccad0..31c932d5f 100644 --- a/examples/function-calling/function-calling-openai-async.py +++ b/examples/function-calling/function-calling-openai-async.py @@ -108,8 +108,8 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): await tts.queue_frame(TTSSpeakFrame("Let me check on that.")) @llm.event_handler("on_function_calls_cancelled") - async def on_function_calls_cancelled(service, cancelled): - for item in cancelled: + async def on_function_calls_cancelled(service, function_calls): + for item in function_calls: logger.info(f"Function call cancelled: {item.function_name} [{item.tool_call_id}]") weather_function = FunctionSchema( diff --git a/examples/function-calling/function-calling-openai-responses-async-stream.py b/examples/function-calling/function-calling-openai-responses-async-stream.py index 093e0d47b..745c18ae1 100644 --- a/examples/function-calling/function-calling-openai-responses-async-stream.py +++ b/examples/function-calling/function-calling-openai-responses-async-stream.py @@ -145,8 +145,8 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): await tts.queue_frame(TTSSpeakFrame("Sure, tracking your location now.")) @llm.event_handler("on_function_calls_cancelled") - async def on_function_calls_cancelled(service, cancelled): - for item in cancelled: + async def on_function_calls_cancelled(service, function_calls): + for item in function_calls: logger.info(f"Function call cancelled: {item.function_name} [{item.tool_call_id}]") location_function = FunctionSchema( diff --git a/examples/function-calling/function-calling-openai-responses-async.py b/examples/function-calling/function-calling-openai-responses-async.py index 8e6fb3fa9..368f41fe1 100644 --- a/examples/function-calling/function-calling-openai-responses-async.py +++ b/examples/function-calling/function-calling-openai-responses-async.py @@ -106,8 +106,8 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): await tts.queue_frame(TTSSpeakFrame("Let me check on that.", append_to_context=False)) @llm.event_handler("on_function_calls_cancelled") - async def on_function_calls_cancelled(service, cancelled): - for item in cancelled: + async def on_function_calls_cancelled(service, function_calls): + for item in function_calls: logger.info(f"Function call cancelled: {item.function_name} [{item.tool_call_id}]") weather_function = FunctionSchema( diff --git a/src/pipecat/services/llm_service.py b/src/pipecat/services/llm_service.py index b1ca29346..036818370 100644 --- a/src/pipecat/services/llm_service.py +++ b/src/pipecat/services/llm_service.py @@ -195,12 +195,12 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): logger.warning("LLM completion timed out") @task.event_handler("on_function_calls_started") - async def on_function_calls_started(service, function_calls): + async def on_function_calls_started(service, function_calls: List[FunctionCallFromLLM]): logger.info(f"Starting {len(function_calls)} function calls") @task.event_handler("on_function_calls_cancelled") - async def on_function_calls_cancelled(service, cancelled): - logger.info(f"Cancelled {len(cancelled)} async function calls") + async def on_function_calls_cancelled(service, function_calls: List[FunctionCallFromLLM]): + logger.info(f"Cancelled {len(function_calls)} function calls") """ _settings: LLMSettings @@ -987,7 +987,14 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): FunctionCallCancelFrame, function_name=name, tool_call_id=tool_call_id ) - cancelled_items.append(runner_item) + cancelled_items.append( + FunctionCallFromLLM( + function_name=runner_item.function_name, + tool_call_id=runner_item.tool_call_id, + arguments=runner_item.arguments, + context=runner_item.context, + ) + ) logger.debug(f"{self} Async function call [{name}:{tool_call_id}] cancelled") for task in cancelled_tasks: @@ -1018,7 +1025,14 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): FunctionCallCancelFrame, function_name=name, tool_call_id=tool_call_id ) - cancelled_items.append(runner_item) + cancelled_items.append( + FunctionCallFromLLM( + function_name=runner_item.function_name, + tool_call_id=runner_item.tool_call_id, + arguments=runner_item.arguments, + context=runner_item.context, + ) + ) logger.debug(f"{self} Function call [{name}:{tool_call_id}] has been cancelled") # Remove all cancelled tasks from our set.