Merge pull request #4447 from pipecat-ai/pk/realtime-async-tool-support-followup
fix: extend cancel_on_interruption=False regression fix to remaining realtime services
This commit is contained in:
1
changelog/4447.fixed.md
Normal file
1
changelog/4447.fixed.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- 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.
|
||||||
195
examples/realtime/realtime-azure-async-tool.py
Normal file
195
examples/realtime/realtime-azure-async-tool.py
Normal file
@@ -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()
|
||||||
179
examples/realtime/realtime-grok-async-tool.py
Normal file
179
examples/realtime/realtime-grok-async-tool.py
Normal file
@@ -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()
|
||||||
186
examples/realtime/realtime-ultravox-async-tool.py
Normal file
186
examples/realtime/realtime-ultravox-async-tool.py
Normal 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()
|
||||||
@@ -751,6 +751,19 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService, Generic[TAdapter]
|
|||||||
return True
|
return True
|
||||||
return function_name in self._functions.keys()
|
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]):
|
async def run_function_calls(self, function_calls: Sequence[FunctionCallFromLLM]):
|
||||||
"""Execute a sequence of function calls from the LLM.
|
"""Execute a sequence of function calls from the LLM.
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ from pipecat.frames.frames import (
|
|||||||
UserAudioRawFrame,
|
UserAudioRawFrame,
|
||||||
VADUserStoppedSpeakingFrame,
|
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.processors.frame_processor import FrameDirection
|
||||||
from pipecat.services.llm_service import FunctionCallFromLLM, LLMService
|
from pipecat.services.llm_service import FunctionCallFromLLM, LLMService
|
||||||
from pipecat.services.settings import NOT_GIVEN, LLMSettings, _NotGiven, assert_given
|
from pipecat.services.settings import NOT_GIVEN, LLMSettings, _NotGiven, assert_given
|
||||||
@@ -59,6 +60,24 @@ except ModuleNotFoundError as e:
|
|||||||
raise Exception(f"Missing module: {e}")
|
raise Exception(f"Missing module: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
@dataclass
|
||||||
class UltravoxRealtimeLLMSettings(LLMSettings):
|
class UltravoxRealtimeLLMSettings(LLMSettings):
|
||||||
"""Settings for UltravoxRealtimeLLMService.
|
"""Settings for UltravoxRealtimeLLMService.
|
||||||
@@ -218,6 +237,11 @@ class UltravoxRealtimeLLMService(LLMService):
|
|||||||
self._disconnecting = False
|
self._disconnecting = False
|
||||||
self._bot_responding: Literal[None, "text", "voice"] = None
|
self._bot_responding: Literal[None, "text", "voice"] = None
|
||||||
self._last_user_id: str | None = None
|
self._last_user_id: str | None = None
|
||||||
|
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._sample_rate = 48000
|
||||||
self._resampler = create_stream_resampler()
|
self._resampler = create_stream_resampler()
|
||||||
@@ -373,6 +397,8 @@ class UltravoxRealtimeLLMService(LLMService):
|
|||||||
if self._receive_task:
|
if self._receive_task:
|
||||||
await self.cancel_task(self._receive_task, timeout=1.0)
|
await self.cancel_task(self._receive_task, timeout=1.0)
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
|
self._completed_tool_calls = set()
|
||||||
|
self._started_placeholder_sent = set()
|
||||||
|
|
||||||
async def _update_settings(self, delta: Settings):
|
async def _update_settings(self, delta: Settings):
|
||||||
changed = await super()._update_settings(delta)
|
changed = await super()._update_settings(delta)
|
||||||
@@ -413,20 +439,80 @@ class UltravoxRealtimeLLMService(LLMService):
|
|||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
async def _handle_context(self, context: LLMContext):
|
async def _handle_context(self, context: LLMContext):
|
||||||
# Ultravox handles all context server-side, so the only context we may
|
# Ultravox handles all context server-side, so the only context we
|
||||||
# need to handle here is new function call results.
|
# need to handle here is function-call results.
|
||||||
for message in reversed(context.messages):
|
for message in context.get_messages():
|
||||||
if message.get("role") != "tool":
|
# LLMSpecificMessages are opaque provider-specific payloads, not
|
||||||
break
|
# standard tool-result messages — skip them.
|
||||||
content = message.get("content")
|
if isinstance(message, LLMSpecificMessage):
|
||||||
socket_message = {
|
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_payload.kind == "intermediate":
|
||||||
|
logger.error(
|
||||||
|
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="Ultravox does not support streamed async tool results.",
|
||||||
|
)
|
||||||
|
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
|
||||||
|
# (see _ASYNC_TOOL_FINAL_RESULT_TEMPLATE).
|
||||||
|
await self._send_user_text(
|
||||||
|
_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
|
||||||
|
# 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
|
||||||
|
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",
|
"type": "client_tool_result",
|
||||||
"invocationId": message.get("tool_call_id"),
|
"invocationId": tool_call_id,
|
||||||
"result": content
|
"result": result,
|
||||||
if isinstance(content, str)
|
|
||||||
else "".join(t.get("text") for t in content),
|
|
||||||
}
|
}
|
||||||
await self._send(socket_message)
|
)
|
||||||
|
|
||||||
async def _handle_vad_user_stopped_speaking(self, frame: VADUserStoppedSpeakingFrame):
|
async def _handle_vad_user_stopped_speaking(self, frame: VADUserStoppedSpeakingFrame):
|
||||||
"""Handle VAD user stopped speaking frame.
|
"""Handle VAD user stopped speaking frame.
|
||||||
@@ -567,6 +653,19 @@ class UltravoxRealtimeLLMService(LLMService):
|
|||||||
async def _handle_tool_invocation(
|
async def _handle_tool_invocation(
|
||||||
self, tool_name: str, invocation_id: str, parameters: dict[str, Any]
|
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_STARTED_RESULT)
|
||||||
|
self._started_placeholder_sent.add(invocation_id)
|
||||||
|
|
||||||
await self.run_function_calls(
|
await self.run_function_calls(
|
||||||
[
|
[
|
||||||
FunctionCallFromLLM(
|
FunctionCallFromLLM(
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ from pipecat.frames.frames import (
|
|||||||
UserStoppedSpeakingFrame,
|
UserStoppedSpeakingFrame,
|
||||||
)
|
)
|
||||||
from pipecat.metrics.metrics import LLMTokenUsage
|
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.processors.frame_processor import FrameDirection
|
||||||
from pipecat.services.llm_service import FunctionCallFromLLM, LLMService
|
from pipecat.services.llm_service import FunctionCallFromLLM, LLMService
|
||||||
from pipecat.services.settings import (
|
from pipecat.services.settings import (
|
||||||
@@ -913,6 +914,50 @@ class GrokRealtimeLLMService(LLMService[GrokRealtimeLLMAdapter]):
|
|||||||
sent_new_result = False
|
sent_new_result = False
|
||||||
|
|
||||||
for message in self._context.get_messages():
|
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
|
||||||
|
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
|
||||||
if message.get("role") and message.get("content") != "IN_PROGRESS":
|
if message.get("role") and message.get("content") != "IN_PROGRESS":
|
||||||
tool_call_id = message.get("tool_call_id")
|
tool_call_id = message.get("tool_call_id")
|
||||||
if tool_call_id and tool_call_id not in self._completed_tool_calls:
|
if tool_call_id and tool_call_id not in self._completed_tool_calls:
|
||||||
@@ -939,6 +984,7 @@ class GrokRealtimeLLMService(LLMService[GrokRealtimeLLMAdapter]):
|
|||||||
|
|
||||||
async def _send_tool_result(self, tool_call_id: str, result: str):
|
async def _send_tool_result(self, tool_call_id: str, result: str):
|
||||||
"""Send a tool call result to Grok."""
|
"""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(
|
item = events.ConversationItem(
|
||||||
type="function_call_output",
|
type="function_call_output",
|
||||||
call_id=tool_call_id,
|
call_id=tool_call_id,
|
||||||
|
|||||||
Reference in New Issue
Block a user