feat(ultravox): support cancel_on_interruption=False via placeholder + final-as-text

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.
This commit is contained in:
Paul Kompfner
2026-05-08 16:20:40 -04:00
parent 2c65713c99
commit 4864eddbc7
4 changed files with 296 additions and 35 deletions

View File

@@ -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.

View File

@@ -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()

View File

@@ -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.

View File

@@ -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(