Add distributed-handoff examples (redis and pgmq)

Two transports of the same shape: a main task that hosts the
voice pipeline plus a network-backed `TaskBus` (`RedisBus` or
`PgmqBus`), and a standalone `llm.py` worker process for the
greeter / support LLM. Workers connect to the same bus channel,
register on the shared `TaskRegistry`, and the main task waits
on `runner.registry.watch("greeter", ...)` before sending the
welcome activation so it doesn't fire before the worker is up.
This commit is contained in:
Aleix Conchillo Flaqué
2026-05-13 23:26:28 -07:00
parent 0f7211d072
commit 4509caa724
4 changed files with 701 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""LLM worker task — run on Machine B (or locally alongside ``main.py``).
A standalone process that runs one LLM task (greeter or support)
attached to the same PGMQ-backed `TaskBus` as the main task.
Multiple instances can run on different machines as long as they
share a Postgres database with the PGMQ extension enabled.
Usage::
python llm.py greeter --database-url postgresql://...
python llm.py support --database-url postgresql://...
Requirements:
- OPENAI_API_KEY
- DATABASE_URL (or ``--database-url``)
"""
import argparse
import asyncio
import os
from urllib.parse import unquote, urlparse
from dotenv import load_dotenv
from loguru import logger
from pgmq.async_queue import PGMQueue
from pipecat.bus.network.pgmq import PgmqBus
from pipecat.pipeline.runner import PipelineRunner
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.tasks.llm import LLMTask, LLMTaskActivationArgs, tool
load_dotenv(override=True)
WORKER_CONFIG = {
"greeter": {
"system_instruction": (
"You are a friendly greeter for Acme Corp. The available products "
"are: the Acme Rocket Boots, the Acme Invisible Paint, and the Acme "
"Tornado Kit. Ask which one they'd like to learn more about. "
"When the user picks a product or asks a question about one, "
"immediately call the transfer_to_agent tool with agent 'support'. "
"Do not answer product questions yourself. If the user says goodbye, "
"call the end_conversation tool. Do not mention transferring — just do it "
"seamlessly. Keep responses brief — this is a voice conversation."
),
"watch": ["support"],
},
"support": {
"system_instruction": (
"You are a support agent for Acme Corp. You know about three "
"products: Acme Rocket Boots (jet-powered boots, $299, run up "
"to 60 mph), Acme Invisible Paint (makes anything invisible for "
"24 hours, $49 per can), and Acme Tornado Kit (portable tornado "
"generator, $199, batteries included). Answer the user's questions "
"about these products. If the user wants to browse other products "
"or start over, call the transfer_to_agent tool with agent "
"'greeter'. If the user says goodbye, call the end_conversation "
"tool. Do not mention transferring — just do it seamlessly. "
"Keep responses brief — this is a voice conversation."
),
"watch": ["greeter"],
},
}
def pgmq_from_url(database_url: str, *, pool_size: int = 4) -> PGMQueue:
"""Build a `PGMQueue` from a Postgres DSN string."""
parsed = urlparse(database_url)
if parsed.scheme not in ("postgres", "postgresql"):
raise ValueError(f"Unsupported scheme '{parsed.scheme}' for database URL")
return PGMQueue(
host=parsed.hostname or "localhost",
port=str(parsed.port or 5432),
database=(parsed.path or "/postgres").lstrip("/") or "postgres",
username=unquote(parsed.username or "postgres"),
password=unquote(parsed.password or ""),
pool_size=pool_size,
)
class AcmeLLMTask(LLMTask):
"""LLM task for Acme Corp with transfer and end tools."""
def __init__(self, name: str, *, system_instruction: str, watch: list[str]):
"""Initialize the AcmeLLMTask.
Args:
name: Unique task name (``"greeter"`` or ``"support"``).
system_instruction: System prompt for this LLM role.
watch: Sibling task names this task will watch via the
registry so it knows when they become available for
handoff.
"""
llm = OpenAILLMService(
name=f"{name}::OpenAILLMService",
api_key=os.environ["OPENAI_API_KEY"],
settings=OpenAILLMService.Settings(system_instruction=system_instruction),
)
super().__init__(name, llm=llm, bridged=())
self._watch = watch
async def start(self) -> None:
"""Register watches for sibling tasks once ready."""
await super().start()
for task_name in self._watch:
await self.watch_task(task_name)
@tool(cancel_on_interruption=False)
async def transfer_to_agent(self, params: FunctionCallParams, agent: str, reason: str):
"""Transfer the user to another agent.
Args:
agent (str): The agent to transfer to (e.g. 'greeter', 'support').
reason (str): Why the user is being transferred.
"""
logger.info(f"Task '{self.name}': transferring to '{agent}' ({reason})")
await self.handoff_to(
agent,
activation_args=LLMTaskActivationArgs(
messages=[{"role": "developer", "content": reason}]
),
result_callback=params.result_callback,
)
@tool
async def end_conversation(self, params: FunctionCallParams, reason: str):
"""End the conversation when the user says goodbye.
Args:
reason (str): Why the conversation is ending.
"""
logger.info(f"Task '{self.name}': ending conversation ({reason})")
await self.end(
reason=reason,
messages=[{"role": "developer", "content": reason}],
result_callback=params.result_callback,
)
async def main_async() -> None:
parser = argparse.ArgumentParser(description="LLM worker task (greeter or support)")
parser.add_argument("worker", choices=list(WORKER_CONFIG), help="Which worker to run")
parser.add_argument(
"--database-url",
default=os.getenv("DATABASE_URL"),
help="PostgreSQL DSN (or set DATABASE_URL env var)",
)
parser.add_argument(
"--channel",
default=os.getenv("PGMQ_CHANNEL", "pipecat_acme"),
help="PGMQ channel prefix",
)
args = parser.parse_args()
if not args.database_url:
parser.error("--database-url is required (or set DATABASE_URL env var)")
pgmq = pgmq_from_url(args.database_url)
await pgmq.init()
bus = PgmqBus(pgmq=pgmq, channel=args.channel)
config = WORKER_CONFIG[args.worker]
worker = AcmeLLMTask(
args.worker,
system_instruction=config["system_instruction"],
watch=config["watch"],
)
runner = PipelineRunner(bus=bus, handle_sigint=True)
logger.info(f"Starting {args.worker} worker, waiting for activation...")
await runner.run(worker)
if __name__ == "__main__":
asyncio.run(main_async())

View File

@@ -0,0 +1,196 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Main transport task — run on Machine A.
Handles audio I/O (STT, TTS) and bridges frames to the bus. The LLM
worker tasks run as separate processes (possibly on different
machines) connected via PGMQ on a shared Postgres database
(e.g. Supabase).
Usage::
python main.py --database-url postgresql://...
Requirements:
- DEEPGRAM_API_KEY
- CARTESIA_API_KEY
- DATABASE_URL (or ``--database-url``)
"""
import argparse
import os
from urllib.parse import unquote, urlparse
from dotenv import load_dotenv
from loguru import logger
from pgmq.async_queue import PGMQueue
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.bus import BusBridgeProcessor
from pipecat.bus.network.pgmq import PgmqBus
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.registry.types import TaskReadyData
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.tasks.llm import LLMTaskActivationArgs
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams
load_dotenv(override=True)
MAIN_NAME = "acme"
transport_params = {
"daily": lambda: DailyParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
"webrtc": lambda: TransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
}
def pgmq_from_url(database_url: str, *, pool_size: int = 4) -> PGMQueue:
"""Build a `PGMQueue` from a Postgres DSN string."""
parsed = urlparse(database_url)
if parsed.scheme not in ("postgres", "postgresql"):
raise ValueError(f"Unsupported scheme '{parsed.scheme}' for database URL")
return PGMQueue(
host=parsed.hostname or "localhost",
port=str(parsed.port or 5432),
database=(parsed.path or "/postgres").lstrip("/") or "postgres",
username=unquote(parsed.username or "postgres"),
password=unquote(parsed.password or ""),
pool_size=pool_size,
)
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
pgmq = pgmq_from_url(runner_args.cli_args.database_url)
await pgmq.init()
bus = PgmqBus(pgmq=pgmq, channel=runner_args.cli_args.channel)
runner = PipelineRunner(bus=bus, handle_sigint=runner_args.handle_sigint)
stt = DeepgramSTTService(api_key=os.environ["DEEPGRAM_API_KEY"])
tts = CartesiaTTSService(
api_key=os.environ["CARTESIA_API_KEY"],
settings=CartesiaTTSService.Settings(
voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc", # Jacqueline
),
)
context = LLMContext()
aggregators = LLMContextAggregatorPair(
context,
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
)
bridge = BusBridgeProcessor(
bus=runner.bus,
task_name=MAIN_NAME,
name=f"{MAIN_NAME}::BusBridge",
)
pipeline = Pipeline(
[
transport.input(),
stt,
aggregators.user(),
bridge,
tts,
transport.output(),
aggregators.assistant(),
]
)
task = PipelineTask(
pipeline,
name=MAIN_NAME,
params=PipelineParams(
enable_metrics=True,
enable_usage_metrics=True,
),
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
)
# The remote LLM workers may take a moment to register on the bus.
# We only activate ``greeter`` once *both* the client is connected
# and the worker has been observed via the registry.
state = {"client_connected": False, "greeter_ready": False}
async def maybe_activate():
if not (state["client_connected"] and state["greeter_ready"]):
return
await task.activate_task(
"greeter",
args=LLMTaskActivationArgs(
messages=[
{
"role": "developer",
"content": (
"Welcome the user to Acme Corp, mention the available "
"products and ask how you can help."
),
},
],
),
)
async def on_greeter_ready(_data: TaskReadyData) -> None:
state["greeter_ready"] = True
await maybe_activate()
await runner.registry.watch("greeter", on_greeter_ready)
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info("Client connected")
state["client_connected"] = True
await maybe_activate()
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info("Client disconnected")
await task.cancel()
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
parser = argparse.ArgumentParser(description="Main transport task (PGMQ bus)")
parser.add_argument(
"--database-url",
default=os.getenv("DATABASE_URL"),
help="PostgreSQL DSN (or set DATABASE_URL env var)",
)
parser.add_argument(
"--channel",
default=os.getenv("PGMQ_CHANNEL", "pipecat_acme"),
help="PGMQ channel prefix",
)
main(parser)

View File

@@ -0,0 +1,153 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""LLM worker task — run on Machine B (or locally alongside ``main.py``).
A standalone process that runs one LLM task (greeter or support)
attached to the same Redis-backed `TaskBus` as the main task.
Multiple instances can run on different machines.
Usage::
python llm.py greeter --redis-url redis://localhost:6379
python llm.py support --redis-url redis://localhost:6379
Requirements:
- OPENAI_API_KEY
"""
import argparse
import asyncio
import os
from dotenv import load_dotenv
from loguru import logger
from redis.asyncio import Redis
from pipecat.bus.network.redis import RedisBus
from pipecat.pipeline.runner import PipelineRunner
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.tasks.llm import LLMTask, LLMTaskActivationArgs, tool
load_dotenv(override=True)
WORKER_CONFIG = {
"greeter": {
"system_instruction": (
"You are a friendly greeter for Acme Corp. The available products "
"are: the Acme Rocket Boots, the Acme Invisible Paint, and the Acme "
"Tornado Kit. Ask which one they'd like to learn more about. "
"When the user picks a product or asks a question about one, "
"immediately call the transfer_to_agent tool with agent 'support'. "
"Do not answer product questions yourself. If the user says goodbye, "
"call the end_conversation tool. Do not mention transferring — just do it "
"seamlessly. Keep responses brief — this is a voice conversation."
),
"watch": ["support"],
},
"support": {
"system_instruction": (
"You are a support agent for Acme Corp. You know about three "
"products: Acme Rocket Boots (jet-powered boots, $299, run up "
"to 60 mph), Acme Invisible Paint (makes anything invisible for "
"24 hours, $49 per can), and Acme Tornado Kit (portable tornado "
"generator, $199, batteries included). Answer the user's questions "
"about these products. If the user wants to browse other products "
"or start over, call the transfer_to_agent tool with agent "
"'greeter'. If the user says goodbye, call the end_conversation "
"tool. Do not mention transferring — just do it seamlessly. "
"Keep responses brief — this is a voice conversation."
),
"watch": ["greeter"],
},
}
class AcmeLLMTask(LLMTask):
"""LLM task for Acme Corp with transfer and end tools."""
def __init__(self, name: str, *, system_instruction: str, watch: list[str]):
"""Initialize the AcmeLLMTask.
Args:
name: Unique task name (``"greeter"`` or ``"support"``).
system_instruction: System prompt for this LLM role.
watch: Sibling task names this task will watch via the
registry so it knows when they become available for
handoff.
"""
llm = OpenAILLMService(
name=f"{name}::OpenAILLMService",
api_key=os.environ["OPENAI_API_KEY"],
settings=OpenAILLMService.Settings(system_instruction=system_instruction),
)
super().__init__(name, llm=llm, bridged=())
self._watch = watch
async def start(self) -> None:
"""Register watches for sibling tasks once ready."""
await super().start()
for task_name in self._watch:
await self.watch_task(task_name)
@tool(cancel_on_interruption=False)
async def transfer_to_agent(self, params: FunctionCallParams, agent: str, reason: str):
"""Transfer the user to another agent.
Args:
agent (str): The agent to transfer to (e.g. 'greeter', 'support').
reason (str): Why the user is being transferred.
"""
logger.info(f"Task '{self.name}': transferring to '{agent}' ({reason})")
await self.handoff_to(
agent,
activation_args=LLMTaskActivationArgs(
messages=[{"role": "developer", "content": reason}]
),
result_callback=params.result_callback,
)
@tool
async def end_conversation(self, params: FunctionCallParams, reason: str):
"""End the conversation when the user says goodbye.
Args:
reason (str): Why the conversation is ending.
"""
logger.info(f"Task '{self.name}': ending conversation ({reason})")
await self.end(
reason=reason,
messages=[{"role": "developer", "content": reason}],
result_callback=params.result_callback,
)
async def main_async() -> None:
parser = argparse.ArgumentParser(description="LLM worker task (greeter or support)")
parser.add_argument("worker", choices=list(WORKER_CONFIG), help="Which worker to run")
parser.add_argument("--redis-url", default="redis://localhost:6379", help="Redis URL")
parser.add_argument("--channel", default="pipecat:acme", help="Redis pub/sub channel")
args = parser.parse_args()
redis = Redis.from_url(args.redis_url)
bus = RedisBus(redis=redis, channel=args.channel)
config = WORKER_CONFIG[args.worker]
worker = AcmeLLMTask(
args.worker,
system_instruction=config["system_instruction"],
watch=config["watch"],
)
runner = PipelineRunner(bus=bus, handle_sigint=True)
logger.info(f"Starting {args.worker} worker, waiting for activation...")
await runner.run(worker)
if __name__ == "__main__":
asyncio.run(main_async())

View File

@@ -0,0 +1,169 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Main transport task — run on Machine A.
Handles audio I/O (STT, TTS) and bridges frames to the bus. The LLM
worker tasks run as separate processes (possibly on different
machines) and connect to the same Redis-backed `TaskBus`.
Usage::
python main.py --redis-url redis://localhost:6379
Requirements:
- DEEPGRAM_API_KEY
- CARTESIA_API_KEY
"""
import argparse
import os
from dotenv import load_dotenv
from loguru import logger
from redis.asyncio import Redis
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.bus import BusBridgeProcessor
from pipecat.bus.network.redis import RedisBus
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.registry.types import TaskReadyData
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.tasks.llm import LLMTaskActivationArgs
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams
load_dotenv(override=True)
MAIN_NAME = "acme"
transport_params = {
"daily": lambda: DailyParams(
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):
redis = Redis.from_url(runner_args.cli_args.redis_url)
bus = RedisBus(redis=redis, channel=runner_args.cli_args.channel)
runner = PipelineRunner(bus=bus, handle_sigint=runner_args.handle_sigint)
stt = DeepgramSTTService(api_key=os.environ["DEEPGRAM_API_KEY"])
tts = CartesiaTTSService(
api_key=os.environ["CARTESIA_API_KEY"],
settings=CartesiaTTSService.Settings(
voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc", # Jacqueline
),
)
context = LLMContext()
aggregators = LLMContextAggregatorPair(
context,
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
)
bridge = BusBridgeProcessor(
bus=runner.bus,
task_name=MAIN_NAME,
name=f"{MAIN_NAME}::BusBridge",
)
pipeline = Pipeline(
[
transport.input(),
stt,
aggregators.user(),
bridge,
tts,
transport.output(),
aggregators.assistant(),
]
)
task = PipelineTask(
pipeline,
name=MAIN_NAME,
params=PipelineParams(
enable_metrics=True,
enable_usage_metrics=True,
),
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
)
# The remote LLM workers may take a moment to register on the bus.
# We only activate ``greeter`` once *both* the client is connected
# and the worker has been observed via the registry.
state = {"client_connected": False, "greeter_ready": False}
async def maybe_activate():
if not (state["client_connected"] and state["greeter_ready"]):
return
await task.activate_task(
"greeter",
args=LLMTaskActivationArgs(
messages=[
{
"role": "developer",
"content": (
"Welcome the user to Acme Corp, mention the available "
"products and ask how you can help."
),
},
],
),
)
async def on_greeter_ready(_data: TaskReadyData) -> None:
state["greeter_ready"] = True
await maybe_activate()
await runner.registry.watch("greeter", on_greeter_ready)
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info("Client connected")
state["client_connected"] = True
await maybe_activate()
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info("Client disconnected")
await task.cancel()
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
parser = argparse.ArgumentParser(description="Main transport task (Redis bus)")
parser.add_argument("--redis-url", default="redis://localhost:6379", help="Redis URL")
parser.add_argument("--channel", default="pipecat:acme", help="Redis pub/sub channel")
main(parser)