From b14a03d01f62b5f160f077f70bfbd3494c85ceee Mon Sep 17 00:00:00 2001 From: Paul Kompfner Date: Fri, 8 May 2026 10:04:14 -0400 Subject: [PATCH 1/4] fix: extend cancel_on_interruption=False regression fix to remaining realtime services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the same async-tool message routing introduced for AWSNovaSonicLLMService and OpenAIRealtimeLLMService to additional realtime LLM services where the flag's intent ("keep talking while the tool runs") is achievable: - GrokRealtimeLLMService (xAI Realtime — also benefits the deprecated Grok alias since it re-exports the xAI module) - AzureRealtimeLLMService picks up the fix transitively by inheriting from OpenAIRealtimeLLMService — no code change needed. GrokRealtimeLLMService's _process_completed_function_calls now matches the canonical pattern: skip LLMSpecificMessage, detect async-tool messages via parse_message and route them — started skipped silently, intermediate logged as an error and surfaced via push_error, final delivered through the same channel as a synchronous result. UltravoxRealtimeLLMService instead gets a one-time warning when async-tool messages appear in the context. The Ultravox API freezes the conversation during tool execution (https://docs.ultravox.ai/tools/async-tools#custom-tool-timeouts), so the flag's "keep talking while the tool runs" intent isn't achievable there — applying the same code pattern would mislead users into expecting a UX Ultravox can't deliver. Surfacing a clear warning is the right behavior until Ultravox grows true async tool support. Adds async-tool example files for Grok and Azure modeled on the existing Nova Sonic / OpenAI Realtime ones (10s simulated network delay, weather tool registered with cancel_on_interruption=False). Two services remain excluded: - GeminiLiveLLMService — the async-tool path needs deeper investigation. - InworldRealtimeLLMService — appears to have a pre-existing problem with even simple synchronous tool calling on its Realtime API (the request reaches the server fine, but response generation fails with a generic server_error). --- changelog/4447.fixed.md | 1 + .../realtime/realtime-azure-async-tool.py | 195 ++++++++++++++++++ examples/realtime/realtime-grok-async-tool.py | 179 ++++++++++++++++ src/pipecat/services/ultravox/llm.py | 31 ++- src/pipecat/services/xai/realtime/llm.py | 41 +++- 5 files changed, 445 insertions(+), 2 deletions(-) create mode 100644 changelog/4447.fixed.md create mode 100644 examples/realtime/realtime-azure-async-tool.py create mode 100644 examples/realtime/realtime-grok-async-tool.py diff --git a/changelog/4447.fixed.md b/changelog/4447.fixed.md new file mode 100644 index 000000000..bec01f4f8 --- /dev/null +++ b/changelog/4447.fixed.md @@ -0,0 +1 @@ +- Extended the `cancel_on_interruption=False` regression fix to `GrokRealtimeLLMService` and `AzureRealtimeLLMService` (the latter picks up the fix transitively by inheriting from `OpenAIRealtimeLLMService`). Same shape as the original fix for `AWSNovaSonicLLMService` and `OpenAIRealtimeLLMService`: each service now detects async-tool messages in the LLM context and routes the final result to its formal tool-result channel. Streamed intermediate results (`FunctionCallResultProperties(is_final=False)`) are not supported on these realtime services. `UltravoxRealtimeLLMService` now logs a one-time warning when async-tool messages appear in the context, since Ultravox freezes the conversation during tool execution and so the "keep talking while the tool runs" intent of `cancel_on_interruption=False` is structurally not achievable there. `GeminiLiveLLMService` and `InworldRealtimeLLMService` are excluded for now: Gemini Live's async-tool path needs deeper investigation, and Inworld appears to have a pre-existing problem with even simple tool calling on its Realtime API. diff --git a/examples/realtime/realtime-azure-async-tool.py b/examples/realtime/realtime-azure-async-tool.py new file mode 100644 index 000000000..46e561e0a --- /dev/null +++ b/examples/realtime/realtime-azure-async-tool.py @@ -0,0 +1,195 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Example: async function call with the Azure Realtime LLM service. + +The ``get_current_weather`` tool is registered with +``cancel_on_interruption=False`` and simulates a slow API call (10s sleep). +While the call is in flight the conversation continues; the result arrives +later via the async-tool mechanism and is forwarded to Azure Realtime as a +``function_call_output`` so the model can integrate it naturally into its +next turn. +""" + +import asyncio +import os +import random +from datetime import datetime + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.adapters.schemas.function_schema import FunctionSchema +from pipecat.adapters.schemas.tools_schema import ToolsSchema +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.azure.realtime.llm import AzureRealtimeLLMService +from pipecat.services.llm_service import FunctionCallParams +from pipecat.services.openai.realtime.events import ( + AudioConfiguration, + AudioInput, + InputAudioTranscription, + SessionProperties, +) +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import DailyParams +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams + +load_dotenv(override=True) + + +async def fetch_weather_from_api(params: FunctionCallParams): + # Simulate a long-running API call so we can demonstrate that the + # conversation continues while the tool is in flight. + await asyncio.sleep(10) + temperature = ( + random.randint(60, 85) + if params.arguments["format"] == "fahrenheit" + else random.randint(15, 30) + ) + await params.result_callback( + { + "conditions": "nice", + "temperature": temperature, + "location": params.arguments["location"], + "format": params.arguments["format"], + "timestamp": datetime.now().strftime("%Y%m%d_%H%M%S"), + } + ) + + +weather_function = FunctionSchema( + name="get_current_weather", + description="Get the current weather", + properties={ + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + "format": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "description": "The temperature unit to use. Infer this from the users location.", + }, + }, + required=["location", "format"], +) + +tools = ToolsSchema(standard_tools=[weather_function]) + + +system_instruction = ( + "You are a friendly assistant. The user and you will engage in a spoken " + "dialog exchanging the transcripts of a natural real-time conversation. " + "Keep your responses short, generally two or three sentences for chatty " + "scenarios. When the user asks for the weather, call get_current_weather. " + "While you wait for the result, keep chatting with the user. When the " + "result arrives, share it with the user naturally." +) + + +transport_params = { + "daily": lambda: DailyParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "twilio": lambda: FastAPIWebsocketParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "webrtc": lambda: TransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), +} + + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): + logger.info(f"Starting bot") + + llm = AzureRealtimeLLMService( + api_key=os.environ["AZURE_REALTIME_API_KEY"], + base_url=os.environ["AZURE_REALTIME_BASE_URL"], + settings=AzureRealtimeLLMService.Settings( + system_instruction=system_instruction, + session_properties=SessionProperties( + audio=AudioConfiguration( + input=AudioInput( + transcription=InputAudioTranscription(model="whisper-1"), + ) + ), + ), + ), + ) + + llm.register_function( + "get_current_weather", + fetch_weather_from_api, + cancel_on_interruption=False, + ) + + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), + user_aggregator, + llm, + transport.output(), + assistant_aggregator, + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + context.add_message( + {"role": "developer", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"Client disconnected") + await task.cancel() + + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + await runner.run(task) + + +async def bot(runner_args: RunnerArguments): + """Main bot entry point compatible with Pipecat Cloud.""" + transport = await create_transport(runner_args, transport_params) + await run_bot(transport, runner_args) + + +if __name__ == "__main__": + from pipecat.runner.run import main + + main() diff --git a/examples/realtime/realtime-grok-async-tool.py b/examples/realtime/realtime-grok-async-tool.py new file mode 100644 index 000000000..c54668fbb --- /dev/null +++ b/examples/realtime/realtime-grok-async-tool.py @@ -0,0 +1,179 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Example: async function call with the Grok Realtime LLM service. + +The ``get_current_weather`` tool is registered with +``cancel_on_interruption=False`` and simulates a slow API call (10s sleep). +While the call is in flight the conversation continues; the result arrives +later via the async-tool mechanism and is forwarded to Grok Realtime as a +``function_call_output`` so the model can integrate it naturally into its +next turn. +""" + +import asyncio +import os +import random +from datetime import datetime + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.adapters.schemas.function_schema import FunctionSchema +from pipecat.adapters.schemas.tools_schema import ToolsSchema +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.llm_service import FunctionCallParams +from pipecat.services.xai.realtime.events import SessionProperties +from pipecat.services.xai.realtime.llm import GrokRealtimeLLMService +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import DailyParams +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams + +load_dotenv(override=True) + + +async def fetch_weather_from_api(params: FunctionCallParams): + # Simulate a long-running API call so we can demonstrate that the + # conversation continues while the tool is in flight. + await asyncio.sleep(10) + temperature = ( + random.randint(60, 85) + if params.arguments["format"] == "fahrenheit" + else random.randint(15, 30) + ) + await params.result_callback( + { + "conditions": "nice", + "temperature": temperature, + "location": params.arguments["location"], + "format": params.arguments["format"], + "timestamp": datetime.now().strftime("%Y%m%d_%H%M%S"), + } + ) + + +weather_function = FunctionSchema( + name="get_current_weather", + description="Get the current weather", + properties={ + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + "format": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "description": "The temperature unit to use. Infer this from the users location.", + }, + }, + required=["location", "format"], +) + +tools = ToolsSchema(standard_tools=[weather_function]) + + +system_instruction = ( + "You are a friendly assistant. The user and you will engage in a spoken " + "dialog exchanging the transcripts of a natural real-time conversation. " + "Keep your responses short, generally two or three sentences for chatty " + "scenarios. When the user asks for the weather, call get_current_weather. " + "While you wait for the result, keep chatting with the user. When the " + "result arrives, share it with the user naturally." +) + + +# Note: Grok has built-in server-side VAD, so we don't need local VAD. +transport_params = { + "daily": lambda: DailyParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "twilio": lambda: FastAPIWebsocketParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "webrtc": lambda: TransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), +} + + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): + logger.info(f"Starting bot") + + llm = GrokRealtimeLLMService( + api_key=os.environ["XAI_API_KEY"], + settings=GrokRealtimeLLMService.Settings( + system_instruction=system_instruction, + session_properties=SessionProperties( + voice="Ara", + ), + ), + ) + + llm.register_function( + "get_current_weather", + fetch_weather_from_api, + cancel_on_interruption=False, + ) + + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair(context) + + pipeline = Pipeline( + [ + transport.input(), + user_aggregator, + llm, + transport.output(), + assistant_aggregator, + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + context.add_message( + {"role": "developer", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"Client disconnected") + await task.cancel() + + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + await runner.run(task) + + +async def bot(runner_args: RunnerArguments): + """Main bot entry point compatible with Pipecat Cloud.""" + transport = await create_transport(runner_args, transport_params) + await run_bot(transport, runner_args) + + +if __name__ == "__main__": + from pipecat.runner.run import main + + main() diff --git a/src/pipecat/services/ultravox/llm.py b/src/pipecat/services/ultravox/llm.py index 967dc7886..d11fa6a01 100644 --- a/src/pipecat/services/ultravox/llm.py +++ b/src/pipecat/services/ultravox/llm.py @@ -45,7 +45,8 @@ from pipecat.frames.frames import ( UserAudioRawFrame, VADUserStoppedSpeakingFrame, ) -from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators import async_tool_messages +from pipecat.processors.aggregators.llm_context import LLMContext, LLMSpecificMessage from pipecat.processors.frame_processor import FrameDirection from pipecat.services.llm_service import FunctionCallFromLLM, LLMService from pipecat.services.settings import NOT_GIVEN, LLMSettings, _NotGiven, assert_given @@ -218,6 +219,7 @@ class UltravoxRealtimeLLMService(LLMService): self._disconnecting = False self._bot_responding: Literal[None, "text", "voice"] = None self._last_user_id: str | None = None + self._async_tool_warning_logged: bool = False self._sample_rate = 48000 self._resampler = create_stream_resampler() @@ -413,6 +415,33 @@ class UltravoxRealtimeLLMService(LLMService): await self.push_frame(frame, direction) async def _handle_context(self, context: LLMContext): + # If the user registered a function with cancel_on_interruption=False, + # the aggregator emits async-tool-style messages into the context. We + # don't (currently) honor those on Ultravox: the Ultravox API freezes + # the conversation during tool execution + # (https://docs.ultravox.ai/tools/async-tools#custom-tool-timeouts), + # so the "keep talking while the tool runs" intent of the flag is + # structurally not achievable here. Surface a one-time warning so + # users see they're not getting what they expect. + if not self._async_tool_warning_logged: + for message in context.get_messages(): + if isinstance(message, LLMSpecificMessage): + continue + if async_tool_messages.parse_message(message) is not None: + logger.error( + f"{self}: cancel_on_interruption=False is not supported by " + f"Ultravox: the conversation freezes during tool execution, so " + f"the 'keep talking while the tool runs' intent of the flag " + f"would not be achievable anyway. Use " + f"cancel_on_interruption=True (the default) or a non-realtime " + f"LLM service if your tool needs the async semantics." + ) + await self.push_error( + error_msg="cancel_on_interruption=False is not supported by Ultravox.", + ) + self._async_tool_warning_logged = True + break + # Ultravox handles all context server-side, so the only context we may # need to handle here is new function call results. for message in reversed(context.messages): diff --git a/src/pipecat/services/xai/realtime/llm.py b/src/pipecat/services/xai/realtime/llm.py index 79ea421ed..8b8e0d94a 100644 --- a/src/pipecat/services/xai/realtime/llm.py +++ b/src/pipecat/services/xai/realtime/llm.py @@ -47,7 +47,8 @@ from pipecat.frames.frames import ( UserStoppedSpeakingFrame, ) from pipecat.metrics.metrics import LLMTokenUsage -from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators import async_tool_messages +from pipecat.processors.aggregators.llm_context import LLMContext, LLMSpecificMessage from pipecat.processors.frame_processor import FrameDirection from pipecat.services.llm_service import FunctionCallFromLLM, LLMService from pipecat.services.settings import ( @@ -913,6 +914,43 @@ class GrokRealtimeLLMService(LLMService[GrokRealtimeLLMAdapter]): sent_new_result = False for message in self._context.get_messages(): + # LLMSpecificMessages are opaque provider-specific payloads, not + # standard tool-result messages — skip them. + if isinstance(message, LLMSpecificMessage): + continue + + # Async-tool messages live alongside regular tool messages in the + # context; detect and route them before the regular logic so we + # don't try to send the async-tool envelope JSON as a tool result. + async_payload = async_tool_messages.parse_message(message) + if async_payload is not None: + if async_payload.tool_call_id in self._completed_tool_calls: + continue + if async_payload.kind == "started": + # The provider already issued the tool call and natively + # awaits a result; nothing to send for the started marker. + continue + if async_payload.kind == "intermediate": + logger.error( + f"{self}: Grok Realtime does not support streamed async " + f"tool results; dropping intermediate result for " + f"tool_call_id={async_payload.tool_call_id}. Use a " + f"non-realtime LLM service if your tool needs to " + f"stream intermediate results." + ) + await self.push_error( + error_msg="Grok Realtime does not support streamed async tool results.", + ) + continue + # kind == "final": deliver via the formal tool-result channel + # — same path as a synchronous tool result, just delayed. + if send_new_results: + sent_new_result = True + await self._send_tool_result(async_payload.tool_call_id, async_payload.result) + self._completed_tool_calls.add(async_payload.tool_call_id) + continue + + # Look for newly-completed "regular" (as opposed to async-tool) results if message.get("role") and message.get("content") != "IN_PROGRESS": tool_call_id = message.get("tool_call_id") if tool_call_id and tool_call_id not in self._completed_tool_calls: @@ -939,6 +977,7 @@ class GrokRealtimeLLMService(LLMService[GrokRealtimeLLMAdapter]): async def _send_tool_result(self, tool_call_id: str, result: str): """Send a tool call result to Grok.""" + logger.debug(f"Sending tool result to Grok Realtime for tool_call_id={tool_call_id}") item = events.ConversationItem( type="function_call_output", call_id=tool_call_id, From 2c65713c99cd32fd3b0d6885851372cd58ea69ab Mon Sep 17 00:00:00 2001 From: Paul Kompfner Date: Fri, 8 May 2026 15:45:05 -0400 Subject: [PATCH 2/4] refactor: explicit kind=='final' check in async-tool routing (Grok) Mirrors the same change applied to AWSNovaSonicLLMService and OpenAIRealtimeLLMService in #4441: replaces the implicit "final happens last" pattern in _process_completed_function_calls with an explicit `if async_payload.kind == "final":` block, plus a trailing defensive `continue` so async-tool messages with an unrecognized kind don't fall through to the regular tool-result handling block. --- src/pipecat/services/xai/realtime/llm.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/pipecat/services/xai/realtime/llm.py b/src/pipecat/services/xai/realtime/llm.py index 8b8e0d94a..7ac83d071 100644 --- a/src/pipecat/services/xai/realtime/llm.py +++ b/src/pipecat/services/xai/realtime/llm.py @@ -942,12 +942,19 @@ class GrokRealtimeLLMService(LLMService[GrokRealtimeLLMAdapter]): error_msg="Grok Realtime does not support streamed async tool results.", ) continue - # kind == "final": deliver via the formal tool-result channel - # — same path as a synchronous tool result, just delayed. - if send_new_results: - sent_new_result = True - await self._send_tool_result(async_payload.tool_call_id, async_payload.result) - self._completed_tool_calls.add(async_payload.tool_call_id) + if async_payload.kind == "final": + # Deliver via the formal tool-result channel — same path + # as a synchronous tool result, just delayed. + if send_new_results: + sent_new_result = True + await self._send_tool_result( + async_payload.tool_call_id, async_payload.result + ) + self._completed_tool_calls.add(async_payload.tool_call_id) + continue + # Defensive: any async-tool message must not fall through + # to the regular tool-result block below, even if it + # carries a kind we don't recognize. continue # Look for newly-completed "regular" (as opposed to async-tool) results From 4864eddbc7a3f722256f799aaed3c0934b5d32ac Mon Sep 17 00:00:00 2001 From: Paul Kompfner Date: Fri, 8 May 2026 16:20:40 -0400 Subject: [PATCH 3/4] feat(ultravox): support cancel_on_interruption=False via placeholder + final-as-text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the prior "log a warning and skip" approach with actual handling of async-tool messages on Ultravox. The catch with Ultravox is that its API freezes the conversation between client_tool_invocation and the matching client_tool_result — there's no "keep talking while the tool runs" channel like NON_BLOCKING on Gemini or function_call_output-without-blocking on OpenAI Realtime. So: - When the model invokes an async-registered function (cancel_on_inter ruption=False), the service immediately ships a placeholder client_tool_result that tells the model "the actual result isn't ready yet; a follow-up will arrive shortly; keep the conversation going". This unfreezes the conversation. The placeholder is sent from _handle_tool_invocation, since the started async-tool message doesn't reach the context-frame path until later. - When the real tool finishes, the final async-tool message lands in the context. _handle_context now forward-iterates and routes async-tool messages: started is a no-op (placeholder already sent), intermediate is logged-as-error and dropped (matching the other realtime services), and final is injected as user-side text via user_text_message with bracketed framing — the only mechanism Ultravox offers for adding non-tool input mid-conversation. Hoists the registry-lookup helper to LLMService as _function_is_async(name) so future services can use the same pattern without re-implementing it. Adds an async-tool example file for Ultravox modeled on the existing ones for the other realtime services. --- changelog/4447.fixed.md | 2 +- .../realtime/realtime-ultravox-async-tool.py | 186 ++++++++++++++++++ src/pipecat/services/llm_service.py | 13 ++ src/pipecat/services/ultravox/llm.py | 130 ++++++++---- 4 files changed, 296 insertions(+), 35 deletions(-) create mode 100644 examples/realtime/realtime-ultravox-async-tool.py diff --git a/changelog/4447.fixed.md b/changelog/4447.fixed.md index bec01f4f8..63ae73c86 100644 --- a/changelog/4447.fixed.md +++ b/changelog/4447.fixed.md @@ -1 +1 @@ -- Extended the `cancel_on_interruption=False` regression fix to `GrokRealtimeLLMService` and `AzureRealtimeLLMService` (the latter picks up the fix transitively by inheriting from `OpenAIRealtimeLLMService`). Same shape as the original fix for `AWSNovaSonicLLMService` and `OpenAIRealtimeLLMService`: each service now detects async-tool messages in the LLM context and routes the final result to its formal tool-result channel. Streamed intermediate results (`FunctionCallResultProperties(is_final=False)`) are not supported on these realtime services. `UltravoxRealtimeLLMService` now logs a one-time warning when async-tool messages appear in the context, since Ultravox freezes the conversation during tool execution and so the "keep talking while the tool runs" intent of `cancel_on_interruption=False` is structurally not achievable there. `GeminiLiveLLMService` and `InworldRealtimeLLMService` are excluded for now: Gemini Live's async-tool path needs deeper investigation, and Inworld appears to have a pre-existing problem with even simple tool calling on its Realtime API. +- Extended the `cancel_on_interruption=False` regression fix to `GrokRealtimeLLMService`, `AzureRealtimeLLMService`, and `UltravoxRealtimeLLMService`. Grok and Azure use the same approach as in #4441 (each service detects async-tool messages in the LLM context and routes the final result to its formal tool-result channel; Azure inherits transitively from `OpenAIRealtimeLLMService`). Ultravox needed a different approach because its API freezes the conversation between `client_tool_invocation` and the matching `client_tool_result` — for async-registered functions it now ships a placeholder `client_tool_result` immediately when the function is invoked (to unfreeze the conversation), then injects the real result as user-side text once the tool finishes. Streamed intermediate results (`FunctionCallResultProperties(is_final=False)`) are still not supported on any of these realtime services. `GeminiLiveLLMService` and `InworldRealtimeLLMService` are excluded for now: Gemini Live's async-tool path needs deeper investigation, and Inworld appears to have a pre-existing problem with even simple tool calling on its Realtime API. diff --git a/examples/realtime/realtime-ultravox-async-tool.py b/examples/realtime/realtime-ultravox-async-tool.py new file mode 100644 index 000000000..449844d5a --- /dev/null +++ b/examples/realtime/realtime-ultravox-async-tool.py @@ -0,0 +1,186 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Example: async function call with the Ultravox Realtime LLM service. + +The ``get_current_weather`` tool is registered with +``cancel_on_interruption=False`` and simulates a slow API call (10s sleep). + +Ultravox's API freezes the conversation between ``client_tool_invocation`` +and the matching ``client_tool_result``, so the service ships a placeholder +``client_tool_result`` immediately when an async-registered function is +invoked (to unfreeze the conversation). When the real tool finishes, the +actual result is injected as user-side text so the model picks it up. +""" + +import asyncio +import datetime +import os +import random + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.adapters.schemas.function_schema import FunctionSchema +from pipecat.adapters.schemas.tools_schema import ToolsSchema +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.llm_service import FunctionCallParams +from pipecat.services.ultravox.llm import OneShotInputParams, UltravoxRealtimeLLMService +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import DailyParams +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams +from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy +from pipecat.turns.user_turn_strategies import UserTurnStrategies + +load_dotenv(override=True) + + +async def fetch_weather_from_api(params: FunctionCallParams): + # Simulate a long-running API call so we can demonstrate that the + # conversation continues while the tool is in flight. + await asyncio.sleep(10) + temperature = ( + random.randint(60, 85) + if params.arguments["format"] == "fahrenheit" + else random.randint(15, 30) + ) + await params.result_callback( + { + "conditions": "nice", + "temperature": temperature, + "location": params.arguments["location"], + "format": params.arguments["format"], + "timestamp": datetime.datetime.now().strftime("%Y%m%d_%H%M%S"), + } + ) + + +weather_function = FunctionSchema( + name="get_current_weather", + description="Get the current weather", + properties={ + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + "format": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "description": "The temperature unit to use. Infer this from the users location.", + }, + }, + required=["location", "format"], +) + + +system_prompt = ( + "You are a friendly assistant. The user and you will engage in a spoken " + "dialog exchanging the transcripts of a natural real-time conversation. " + "Keep your responses short, generally two or three sentences for chatty " + "scenarios. When the user asks for the weather, call get_current_weather. " + "While you wait for the result, keep chatting with the user. When the " + "result arrives, share it with the user naturally." +) + + +transport_params = { + "daily": lambda: DailyParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "twilio": lambda: FastAPIWebsocketParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "webrtc": lambda: TransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), +} + + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): + logger.info(f"Starting bot") + + llm = UltravoxRealtimeLLMService( + params=OneShotInputParams( + api_key=os.environ["ULTRAVOX_API_KEY"], + system_prompt=system_prompt, + temperature=0.3, + max_duration=datetime.timedelta(minutes=3), + ), + one_shot_selected_tools=ToolsSchema(standard_tools=[weather_function]), + ) + + llm.register_function( + "get_current_weather", + fetch_weather_from_api, + cancel_on_interruption=False, + ) + + context = LLMContext([]) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + user_turn_strategies=UserTurnStrategies( + stop=[SpeechTimeoutUserTurnStopStrategy()], + ), + vad_analyzer=SileroVADAnalyzer(), + ), + ) + + pipeline = Pipeline( + [ + transport.input(), + user_aggregator, + llm, + transport.output(), + assistant_aggregator, + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"Client disconnected") + await task.cancel() + + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + await runner.run(task) + + +async def bot(runner_args: RunnerArguments): + """Main bot entry point compatible with Pipecat Cloud.""" + transport = await create_transport(runner_args, transport_params) + await run_bot(transport, runner_args) + + +if __name__ == "__main__": + from pipecat.runner.run import main + + main() diff --git a/src/pipecat/services/llm_service.py b/src/pipecat/services/llm_service.py index 9846a8f50..58ed54bd0 100644 --- a/src/pipecat/services/llm_service.py +++ b/src/pipecat/services/llm_service.py @@ -751,6 +751,19 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService, Generic[TAdapter] return True return function_name in self._functions.keys() + def _function_is_async(self, function_name: str) -> bool: + """Whether the named function was registered with cancel_on_interruption=False. + + Mirrors the registry-lookup pattern in :meth:`run_function_calls`: + a name-specific entry takes precedence; if there isn't one, fall + back to the ``None``-keyed catch-all entry. Returns ``False`` if + no entry matches. + """ + item = self._functions.get(function_name) + if item is None: + item = self._functions.get(None) + return item is not None and not item.cancel_on_interruption + async def run_function_calls(self, function_calls: Sequence[FunctionCallFromLLM]): """Execute a sequence of function calls from the LLM. diff --git a/src/pipecat/services/ultravox/llm.py b/src/pipecat/services/ultravox/llm.py index d11fa6a01..bceb96d06 100644 --- a/src/pipecat/services/ultravox/llm.py +++ b/src/pipecat/services/ultravox/llm.py @@ -60,6 +60,17 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +# Placeholder shipped as the client_tool_result for async-registered functions +# (cancel_on_interruption=False). Sending it immediately unfreezes the +# conversation so the model can keep talking while the real tool runs; the +# actual result is injected later as user-side text once the tool finishes. +_ASYNC_TOOL_PLACEHOLDER_RESULT = ( + "The actual result for this tool call is not yet ready. A follow-up " + "message will arrive shortly with the actual result. In the meantime, " + "keep the conversation going naturally." +) + + @dataclass class UltravoxRealtimeLLMSettings(LLMSettings): """Settings for UltravoxRealtimeLLMService. @@ -219,7 +230,11 @@ class UltravoxRealtimeLLMService(LLMService): self._disconnecting = False self._bot_responding: Literal[None, "text", "voice"] = None self._last_user_id: str | None = None - self._async_tool_warning_logged: bool = False + self._completed_tool_calls: set[str] = set() + # Tracks tool_call_ids for which we've already shipped the + # async-tool placeholder client_tool_result that unfreezes the + # conversation while the real tool runs. See _handle_tool_invocation. + self._started_placeholder_sent: set[str] = set() self._sample_rate = 48000 self._resampler = create_stream_resampler() @@ -375,6 +390,8 @@ class UltravoxRealtimeLLMService(LLMService): if self._receive_task: await self.cancel_task(self._receive_task, timeout=1.0) self._receive_task = None + self._completed_tool_calls = set() + self._started_placeholder_sent = set() async def _update_settings(self, delta: Settings): changed = await super()._update_settings(delta) @@ -415,47 +432,79 @@ class UltravoxRealtimeLLMService(LLMService): await self.push_frame(frame, direction) async def _handle_context(self, context: LLMContext): - # If the user registered a function with cancel_on_interruption=False, - # the aggregator emits async-tool-style messages into the context. We - # don't (currently) honor those on Ultravox: the Ultravox API freezes - # the conversation during tool execution - # (https://docs.ultravox.ai/tools/async-tools#custom-tool-timeouts), - # so the "keep talking while the tool runs" intent of the flag is - # structurally not achievable here. Surface a one-time warning so - # users see they're not getting what they expect. - if not self._async_tool_warning_logged: - for message in context.get_messages(): - if isinstance(message, LLMSpecificMessage): + # Ultravox handles all context server-side, so the only context we + # need to handle here is function-call results. + for message in context.get_messages(): + # LLMSpecificMessages are opaque provider-specific payloads, not + # standard tool-result messages — skip them. + if isinstance(message, LLMSpecificMessage): + continue + + # Async-tool messages live alongside regular tool messages in the + # context; detect and route them before the regular logic so we + # don't try to send the async-tool envelope JSON as a tool result. + async_payload = async_tool_messages.parse_message(message) + if async_payload is not None: + if async_payload.kind == "started": + # The placeholder client_tool_result that unfreezes the + # conversation was already shipped from + # _handle_tool_invocation when the model issued the + # call. Nothing more to do here. continue - if async_tool_messages.parse_message(message) is not None: + if async_payload.kind == "intermediate": logger.error( - f"{self}: cancel_on_interruption=False is not supported by " - f"Ultravox: the conversation freezes during tool execution, so " - f"the 'keep talking while the tool runs' intent of the flag " - f"would not be achievable anyway. Use " - f"cancel_on_interruption=True (the default) or a non-realtime " - f"LLM service if your tool needs the async semantics." + f"{self}: Ultravox does not support streamed async " + f"tool results; dropping intermediate result for " + f"tool_call_id={async_payload.tool_call_id}. Use a " + f"non-realtime LLM service if your tool needs to " + f"stream intermediate results." ) await self.push_error( - error_msg="cancel_on_interruption=False is not supported by Ultravox.", + error_msg="Ultravox does not support streamed async tool results.", ) - self._async_tool_warning_logged = True - break + continue + if async_payload.kind == "final": + if async_payload.tool_call_id in self._completed_tool_calls: + continue + # The placeholder client_tool_result has already + # "completed" the tool call from Ultravox's perspective, + # so the actual result is delivered as user-side text. + # Bracketed framing helps the model treat this as a + # tool-result update rather than fresh user input. + await self._send_user_text( + f"[Async tool result for tool_call_id=" + f"{async_payload.tool_call_id}] {async_payload.result}" + ) + self._completed_tool_calls.add(async_payload.tool_call_id) + continue + # Defensive: any async-tool message must not fall through + # to the regular tool-result block below, even if it + # carries a kind we don't recognize. + continue - # Ultravox handles all context server-side, so the only context we may - # need to handle here is new function call results. - for message in reversed(context.messages): - if message.get("role") != "tool": - break - content = message.get("content") - socket_message = { + # Look for newly-completed "regular" (as opposed to async-tool) results + if message.get("role") == "tool" and message.get("content") != "IN_PROGRESS": + tool_call_id = message.get("tool_call_id") + if tool_call_id and tool_call_id not in self._completed_tool_calls: + content = message.get("content") + result = ( + content + if isinstance(content, str) + else "".join(t.get("text") for t in content) + ) + await self._send_tool_result(tool_call_id, result) + self._completed_tool_calls.add(tool_call_id) + + async def _send_tool_result(self, tool_call_id: str, result: str): + """Send a tool call result to Ultravox.""" + logger.debug(f"Sending tool result to Ultravox for tool_call_id={tool_call_id}") + await self._send( + { "type": "client_tool_result", - "invocationId": message.get("tool_call_id"), - "result": content - if isinstance(content, str) - else "".join(t.get("text") for t in content), + "invocationId": tool_call_id, + "result": result, } - await self._send(socket_message) + ) async def _handle_vad_user_stopped_speaking(self, frame: VADUserStoppedSpeakingFrame): """Handle VAD user stopped speaking frame. @@ -596,6 +645,19 @@ class UltravoxRealtimeLLMService(LLMService): async def _handle_tool_invocation( self, tool_name: str, invocation_id: str, parameters: dict[str, Any] ): + # Ultravox freezes the conversation between client_tool_invocation + # and the matching client_tool_result. For functions registered + # with cancel_on_interruption=False the actual result won't be + # available for some time, so ship a placeholder result now to + # unfreeze the conversation. The real result will be injected + # later as user-side text from _handle_context. + if ( + self._function_is_async(tool_name) + and invocation_id not in self._started_placeholder_sent + ): + await self._send_tool_result(invocation_id, _ASYNC_TOOL_PLACEHOLDER_RESULT) + self._started_placeholder_sent.add(invocation_id) + await self.run_function_calls( [ FunctionCallFromLLM( From fb74f7714ccbd934cc9e46453717a256ee4e4b7b Mon Sep 17 00:00:00 2001 From: Paul Kompfner Date: Fri, 8 May 2026 16:35:14 -0400 Subject: [PATCH 4/4] refactor(ultravox): name async-tool result strings after the kinds they serve Renames _ASYNC_TOOL_PLACEHOLDER_RESULT to _ASYNC_TOOL_STARTED_RESULT to match the kind names from async_tool_messages, and lifts the inline "[Async tool result for tool_call_id=...] {result}" into a sibling _ASYNC_TOOL_FINAL_RESULT_TEMPLATE constant for the same reason. --- src/pipecat/services/ultravox/llm.py | 30 ++++++++++++++++++---------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/pipecat/services/ultravox/llm.py b/src/pipecat/services/ultravox/llm.py index bceb96d06..74735df6b 100644 --- a/src/pipecat/services/ultravox/llm.py +++ b/src/pipecat/services/ultravox/llm.py @@ -60,16 +60,23 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") -# Placeholder shipped as the client_tool_result for async-registered functions -# (cancel_on_interruption=False). Sending it immediately unfreezes the -# conversation so the model can keep talking while the real tool runs; the -# actual result is injected later as user-side text once the tool finishes. -_ASYNC_TOOL_PLACEHOLDER_RESULT = ( +# Result shipped as the client_tool_result when we see an async-tool +# "started" message — i.e. when an async-registered function call +# (cancel_on_interruption=False) is invoked. Sending it immediately +# unfreezes the conversation so the model can keep talking while the +# real tool runs; the actual result is injected later as user-side text +# once the tool finishes. +_ASYNC_TOOL_STARTED_RESULT = ( "The actual result for this tool call is not yet ready. A follow-up " "message will arrive shortly with the actual result. In the meantime, " "keep the conversation going naturally." ) +# Template for the user-side text we inject when the async-tool "final" +# message arrives. Bracketed framing helps the model treat this as a +# tool-result update rather than fresh user input. +_ASYNC_TOOL_FINAL_RESULT_TEMPLATE = "[Async tool result for tool_call_id={tool_call_id}] {result}" + @dataclass class UltravoxRealtimeLLMSettings(LLMSettings): @@ -468,12 +475,13 @@ class UltravoxRealtimeLLMService(LLMService): continue # The placeholder client_tool_result has already # "completed" the tool call from Ultravox's perspective, - # so the actual result is delivered as user-side text. - # Bracketed framing helps the model treat this as a - # tool-result update rather than fresh user input. + # so the actual result is delivered as user-side text + # (see _ASYNC_TOOL_FINAL_RESULT_TEMPLATE). await self._send_user_text( - f"[Async tool result for tool_call_id=" - f"{async_payload.tool_call_id}] {async_payload.result}" + _ASYNC_TOOL_FINAL_RESULT_TEMPLATE.format( + tool_call_id=async_payload.tool_call_id, + result=async_payload.result, + ) ) self._completed_tool_calls.add(async_payload.tool_call_id) continue @@ -655,7 +663,7 @@ class UltravoxRealtimeLLMService(LLMService): self._function_is_async(tool_name) and invocation_id not in self._started_placeholder_sent ): - await self._send_tool_result(invocation_id, _ASYNC_TOOL_PLACEHOLDER_RESULT) + await self._send_tool_result(invocation_id, _ASYNC_TOOL_STARTED_RESULT) self._started_placeholder_sent.add(invocation_id) await self.run_function_calls(