Rename BaseTask → BaseWorker and reserve "task" for asyncio
Replaces every "task" identifier that referred to the BaseTask abstraction with "worker". Asyncio task plumbing (asyncio.Task, BaseTaskManager, TaskManager, create_task, cancel_task, etc.) stays untouched. Highlights: - Classes: BaseTask → BaseWorker, PipelineTask → PipelineWorker, LLMTask → LLMWorker, LLMContextTask → LLMContextWorker, TaskBus → WorkerBus, TaskRegistry → WorkerRegistry, TaskActivationArgs → WorkerActivationArgs, TaskReadyData → WorkerReadyData, TaskRegistryEntry → WorkerRegistryEntry, TaskObserver → WorkerObserver, all Bus*TaskMessage → Bus*WorkerMessage, BusAddTaskMessage.task field → worker, BusWorkerRegistryMessage.tasks field → workers. - Methods/decorators: activate_task → activate_worker, deactivate_task → deactivate_worker, add_task → add_worker, watch_task → watch_worker, @task_ready → @worker_ready, setup_pipeline_task hook → setup_pipeline_worker. - Params/fields: FrameProcessorSetup.pipeline_task and FunctionCallParams.pipeline_task → pipeline_worker. Parameter names like task_name → worker_name; spawn/run accept worker:. - Files: pipeline/base_task.py → base_worker.py, pipeline/task.py → worker.py (plus a re-export shim at pipeline/task.py), task_observer.py → worker_observer.py, task_ready_decorator.py → worker_ready_decorator.py, pipecat.tasks → pipecat.workers, llm_task.py → llm_worker.py, llm_context_task.py → llm_context_worker.py, examples/multi-task → examples/multi-worker. Back-compat: - PipelineTask kept as a deprecated subclass of PipelineWorker that warns on construction. - pipecat.pipeline.task re-exports PipelineWorker/PipelineTask/etc. so existing user imports keep working. - FrameProcessor.pipeline_task kept as a deprecated property that forwards to pipeline_worker. Local variables in examples that hold a worker (task = PipelineTask(...)) are renamed to worker = PipelineWorker(...). Asyncio-task locals (runner_task, etc.) are preserved.
This commit is contained in:
369
examples/multi-worker/README.md
Normal file
369
examples/multi-worker/README.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# Pipecat Multi-Worker Examples
|
||||
|
||||
This directory contains example bots that use the multi-worker framework in `pipecat.workers`, `pipecat.pipeline.runner` (with `add_worker()`), and the `WorkerBus`. Each example shows a different cooperation pattern between workers: hand-off, parallel fan-out, remote workers, etc.
|
||||
|
||||
## Setup
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
uv sync --all-extras
|
||||
source .venv/bin/activate
|
||||
cd examples/multi-worker
|
||||
```
|
||||
|
||||
Copy the env template and fill in your API keys:
|
||||
|
||||
```bash
|
||||
cp env.example .env
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Required by |
|
||||
| ------------------ | --------------------------------------- |
|
||||
| `OPENAI_API_KEY` | LLM workers |
|
||||
| `DEEPGRAM_API_KEY` | STT |
|
||||
| `CARTESIA_API_KEY` | TTS |
|
||||
| `DAILY_API_KEY` | Optional: only with `--transport daily` |
|
||||
|
||||
Additional, example-specific variables are listed below.
|
||||
|
||||
## Table of contents
|
||||
|
||||
**[Local](#local)** (single process)
|
||||
|
||||
- [Handoff between LLM workers](#handoff-between-llm-tasks)
|
||||
- [Parallel debate](#parallel-debate)
|
||||
- [Voice code assistant with Claude Agent SDK](#voice-code-assistant)
|
||||
- [Sensor controller](#sensor-controller)
|
||||
|
||||
**[Distributed](#distributed)** (multi-process)
|
||||
|
||||
- [Handoff via Redis](#handoff-via-redis)
|
||||
- [Handoff via PGMQ (Postgres)](#handoff-via-pgmq-postgres)
|
||||
- [LLM worker via WebSocket proxy](#llm-task-via-websocket-proxy)
|
||||
|
||||
# Local
|
||||
|
||||
Examples where all workers run in the same process on an `AsyncQueueBus`.
|
||||
|
||||
## Handoff between LLM workers
|
||||
|
||||
Two LLM workers (greeter + support) that transfer control to each other during a voice conversation. A main worker owns the transport pipeline and bridges frames to the bus.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
uv run local-handoff/local-handoff-two-agents.py
|
||||
```
|
||||
|
||||
Open <http://localhost:7860/client> in your browser to talk to your bot.
|
||||
|
||||
To use Daily transport:
|
||||
|
||||
```bash
|
||||
uv run local-handoff/local-handoff-two-agents.py --transport daily
|
||||
```
|
||||
|
||||
### Overview
|
||||
|
||||
- **[`local-handoff-two-agents.py`](local-handoff/local-handoff-two-agents.py)** — Two LLM workers (greeter + support) that hand off via `activate_worker(..., deactivate_self=True)`. The main worker owns STT, TTS, transport, and a `BusBridgeProcessor`.
|
||||
- **[`local-handoff-two-agents-tts.py`](local-handoff/local-handoff-two-agents-tts.py)** — Same shape, but each child worker ships with its own `CartesiaTTSService` in a custom pipeline. The main worker has no TTS — audio comes from whichever child is active over the bus.
|
||||
|
||||
## Parallel debate
|
||||
|
||||
Parallel fan-out using `worker.job_group(...)`. A voice bot takes a topic from the user, kicks off three workers in parallel (advocate, critic, analyst), waits for all three to respond, and synthesizes a balanced answer. Each worker keeps its own LLM context across rounds.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
uv run parallel-debate/parallel-debate.py
|
||||
```
|
||||
|
||||
Open <http://localhost:7860/client> in your browser to talk to your bot.
|
||||
|
||||
To use Daily transport:
|
||||
|
||||
```bash
|
||||
uv run parallel-debate/parallel-debate.py --transport daily
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Main worker (transport + LLM + `debate` tool)
|
||||
└── job_group(advocate, critic, analyst)
|
||||
└── DebateWorker (LLMContextWorker, one per role)
|
||||
```
|
||||
|
||||
- **Main worker**: transport (STT, TTS) + LLM moderator with a `debate` direct function that fans out via `worker.job_group(...)`.
|
||||
- **Debate workers**: `LLMContextWorker`s spawned on the runner. Each keeps its own `LLMContext` across rounds and ships its completed turn back as a job response via the assistant-aggregator's `on_assistant_turn_stopped` event.
|
||||
|
||||
## Voice code assistant
|
||||
|
||||
Talk to your codebase hands-free. Ask questions about code, project structure, or file contents and get spoken answers based on actual files. The Claude Agent SDK worker navigates the filesystem using `Read`, `Bash`, `Glob`, and `Grep` tools.
|
||||
|
||||
### Additional environment variables
|
||||
|
||||
| Variable | Required by |
|
||||
| ------------------- | ------------------------------ |
|
||||
| `ANTHROPIC_API_KEY` | Code worker (Claude Agent SDK) |
|
||||
| `PROJECT_PATH` | Optional, defaults to cwd |
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# Default: explores the current directory
|
||||
uv run code-assistant/code-assistant.py
|
||||
|
||||
# Specify a project path
|
||||
PROJECT_PATH=/path/to/your/project uv run code-assistant/code-assistant.py
|
||||
```
|
||||
|
||||
Open <http://localhost:7860/client> in your browser to talk to your bot.
|
||||
|
||||
To use Daily transport:
|
||||
|
||||
```bash
|
||||
uv run code-assistant/code-assistant.py --transport daily
|
||||
```
|
||||
|
||||
### Example questions
|
||||
|
||||
- "What does the main module do?"
|
||||
- "Find all TODO comments in the project"
|
||||
- "How is error handling implemented?"
|
||||
- "What dependencies does this project use?"
|
||||
- "Explain the test structure"
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Main worker (transport + LLM + `ask_code` tool)
|
||||
└── job → CodeWorker (Claude Agent SDK)
|
||||
```
|
||||
|
||||
- **`code-assistant.py`** — Main worker: STT, LLM (with system prompt + `ask_code` direct function), TTS, and transport. The `ask_code` tool dispatches a job to the worker via `worker.job("code_worker", payload=...)`.
|
||||
- **`code_worker.py`** — `CodeWorker`: a bus-only `BaseWorker` spawned on the runner. It accepts `@job`-style requests through the bus and runs them sequentially through a persistent Claude SDK session so follow-up questions share context.
|
||||
|
||||
## Sensor controller
|
||||
|
||||
Two `PipelineWorker`s side by side, communicating only over job RPC. A voice agent has a single `ask_controller(question)` tool that forwards every temperature-related request to a worker; the worker owns a simulated thermometer and its own tool-calling LLM that decides how to answer (read the current value, inspect rolling stats, change the target, change the response rate). The worker is a plain `PipelineWorker` — it does not subclass `LLMWorker` and is not bridged.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
uv run sensor-controller/sensor-controller.py
|
||||
```
|
||||
|
||||
Open <http://localhost:7860/client> in your browser to talk to your bot.
|
||||
|
||||
To use Daily transport:
|
||||
|
||||
```bash
|
||||
uv run sensor-controller/sensor-controller.py --transport daily
|
||||
```
|
||||
|
||||
### Example questions
|
||||
|
||||
- "What's the temperature?"
|
||||
- "Make it warmer."
|
||||
- "Is it stable yet?"
|
||||
- "Why is it slow?" / "Speed up the response."
|
||||
- "What was the highest reading?"
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Voice agent (transport + STT + LLM + TTS, tool: ask_controller)
|
||||
└── job → Controller (PipelineWorker)
|
||||
└── SensorReader -> SensorStats -> user_agg -> llm -> assistant_agg
|
||||
```
|
||||
|
||||
- **[`sensor-controller.py`](sensor-controller/sensor-controller.py)** — `build_sensor_controller()` returns a plain `PipelineWorker`. Jobs arrive via `@worker.event_handler("on_job_request")`, the question is queued onto the worker LLM, and the LLM's reply is paired back to the job via the assistant aggregator's `on_assistant_turn_stopped` event.
|
||||
- **[`sensor.py`](sensor-controller/sensor.py)** — Two custom `FrameProcessor` subclasses: `SensorReader` runs an autonomous tick loop that emits a `SensorReadingFrame` each second (first-order lag toward target plus Gaussian noise; mutable target and response rate); `SensorStats` maintains rolling min/max/avg/trend.
|
||||
|
||||
# Distributed
|
||||
|
||||
Examples where workers run across separate processes or machines.
|
||||
|
||||
## Handoff via Redis
|
||||
|
||||
Same two-worker handoff as the local example, but each worker runs as a separate process connected via Redis pub/sub. Requires `pip install pipecat-ai[redis]`.
|
||||
|
||||
### Quick start (single machine, local Redis)
|
||||
|
||||
_Terminal 1_: start Redis
|
||||
|
||||
```bash
|
||||
docker run --rm -p 6379:6379 redis:7
|
||||
```
|
||||
|
||||
_Terminal 2_: start the greeter worker
|
||||
|
||||
```bash
|
||||
uv run distributed-handoff/redis-handoff/llm.py greeter
|
||||
```
|
||||
|
||||
_Terminal 3_: start the support worker
|
||||
|
||||
```bash
|
||||
uv run distributed-handoff/redis-handoff/llm.py support
|
||||
```
|
||||
|
||||
_Terminal 4_: start the main transport worker
|
||||
|
||||
```bash
|
||||
uv run distributed-handoff/redis-handoff/main.py
|
||||
```
|
||||
|
||||
All processes connect to `redis://localhost:6379` by default.
|
||||
|
||||
### Running across machines
|
||||
|
||||
Point each process at the same Redis instance:
|
||||
|
||||
_Machine A_
|
||||
|
||||
```bash
|
||||
uv run distributed-handoff/redis-handoff/main.py --redis-url redis://your-redis-host:6379
|
||||
```
|
||||
|
||||
_Machine B_
|
||||
|
||||
```bash
|
||||
uv run distributed-handoff/redis-handoff/llm.py greeter --redis-url redis://your-redis-host:6379
|
||||
```
|
||||
|
||||
_Machine C_
|
||||
|
||||
```bash
|
||||
uv run distributed-handoff/redis-handoff/llm.py support --redis-url redis://your-redis-host:6379
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Machine A Redis Machine B
|
||||
+------------+ +-------------+ +-------------+
|
||||
| main.py | <----> | pub/sub | <----> | llm.py |
|
||||
| (transport,| | channel: | | (greeter) |
|
||||
| STT, TTS) | | pipecat:acme| +-------------+
|
||||
+------------+ +-------------+ +-------------+
|
||||
^ | llm.py |
|
||||
+--------------> | (support) |
|
||||
+-------------+
|
||||
```
|
||||
|
||||
- **[main.py](distributed-handoff/redis-handoff/main.py)** — Transport worker: Daily/WebRTC, Deepgram STT, Cartesia TTS, and a `BusBridgeProcessor` over a `RedisBus`.
|
||||
- **[llm.py](distributed-handoff/redis-handoff/llm.py)** — LLM worker: runs either `greeter` or `support` with OpenAI behind a bridged `LLMWorker`.
|
||||
|
||||
## Handoff via PGMQ (Postgres)
|
||||
|
||||
Same shape as the Redis handoff, but the bus is backed by [PGMQ](https://github.com/tembo-io/pgmq) on a shared Postgres database (e.g. Supabase). Requires `pip install pipecat-ai[pgmq]`.
|
||||
|
||||
### Additional environment variables
|
||||
|
||||
| Variable | Required by |
|
||||
| -------------- | -------------------------------------------------------------------- |
|
||||
| `DATABASE_URL` | PostgreSQL DSN (e.g. Supabase pooled connection string) |
|
||||
| `PGMQ_CHANNEL` | Optional, channel prefix for queue names. Defaults to `pipecat_acme` |
|
||||
|
||||
### Quick start
|
||||
|
||||
_Terminal 1_: start the greeter worker
|
||||
|
||||
```bash
|
||||
uv run distributed-handoff/pgmq-handoff/llm.py greeter --database-url $DATABASE_URL
|
||||
```
|
||||
|
||||
_Terminal 2_: start the support worker
|
||||
|
||||
```bash
|
||||
uv run distributed-handoff/pgmq-handoff/llm.py support --database-url $DATABASE_URL
|
||||
```
|
||||
|
||||
_Terminal 3_: start the main transport worker
|
||||
|
||||
```bash
|
||||
uv run distributed-handoff/pgmq-handoff/main.py --database-url $DATABASE_URL
|
||||
```
|
||||
|
||||
You can also set `DATABASE_URL` in `.env` and omit the `--database-url` flag.
|
||||
|
||||
### Architecture
|
||||
|
||||
Same as the Redis handoff above; the `RedisBus` is replaced by a `PgmqBus`, and the "pub/sub channel" is a set of PGMQ queues on the shared Postgres instance.
|
||||
|
||||
## LLM worker via WebSocket proxy
|
||||
|
||||
Runs an LLM worker on a remote server, connected to the main transport worker via a WebSocket proxy. No shared bus required — the proxy workers forward bus messages point-to-point over the WebSocket.
|
||||
|
||||
### Quick start (single machine)
|
||||
|
||||
_Terminal 1_: start the remote assistant server
|
||||
|
||||
```bash
|
||||
uv run remote-proxy-assistant/assistant.py
|
||||
```
|
||||
|
||||
_Terminal 2_: start the main transport worker
|
||||
|
||||
```bash
|
||||
uv run remote-proxy-assistant/main.py --remote-url ws://localhost:8765/ws
|
||||
```
|
||||
|
||||
Open <http://localhost:7860/client> in your browser to talk to the bot.
|
||||
|
||||
### Running across machines
|
||||
|
||||
_Server machine_: start the assistant
|
||||
|
||||
```bash
|
||||
uv run remote-proxy-assistant/assistant.py --host 0.0.0.0 --port 8765
|
||||
```
|
||||
|
||||
_Client machine_: point at the server
|
||||
|
||||
```bash
|
||||
uv run remote-proxy-assistant/main.py --remote-url ws://server-host:8765/ws
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
+-------------+ +-------------+ +-------------+ +-----------------+
|
||||
| | | | | | | |
|
||||
| Main worker | | Proxy worker | <~~~~~> | Proxy worker | | Assistant worker |
|
||||
| | | (client) | | (server) | | |
|
||||
+-------------+ +-------------+ +-------------+ +-----------------+
|
||||
messages messages messages messages
|
||||
│ │ │ │
|
||||
══════════╧═════════════════╧════════ ════════╧════════════════════╧═══════════
|
||||
Task Bus Task Bus
|
||||
═════════════════════════════════════ ═════════════════════════════════════════
|
||||
```
|
||||
|
||||
- **[main.py](remote-proxy-assistant/main.py)** — Transport worker with STT, TTS, and a `BusBridge`. Spawns a `WebSocketProxyClientTask` that connects to the remote server and forwards `BusFrameMessage`s.
|
||||
- **[assistant.py](remote-proxy-assistant/assistant.py)** — FastAPI server. Each WebSocket connection spawns a `WebSocketProxyServerTask` plus a bridged `AcmeAssistant` LLM worker on a per-session `PipelineRunner`.
|
||||
|
||||
### Security
|
||||
|
||||
The proxy workers filter messages by worker name:
|
||||
|
||||
- Only messages targeted at the remote worker cross the WebSocket
|
||||
- Only messages targeted at the local worker are accepted from the WebSocket
|
||||
- Broadcast messages never cross the WebSocket
|
||||
|
||||
Pass HTTP headers for authentication:
|
||||
|
||||
```python
|
||||
proxy = WebSocketProxyClientTask(
|
||||
"proxy",
|
||||
url="wss://server-host:8765/ws",
|
||||
remote_worker_name="assistant",
|
||||
local_worker_name="acme",
|
||||
headers={"Authorization": "Bearer <token>"},
|
||||
)
|
||||
```
|
||||
179
examples/multi-worker/code-assistant/code-assistant.py
Normal file
179
examples/multi-worker/code-assistant/code-assistant.py
Normal file
@@ -0,0 +1,179 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Voice code assistant powered by Claude Agent SDK.
|
||||
|
||||
Talk to your codebase hands-free. Ask questions like "what does the
|
||||
auth middleware do?" or "find all TODO comments" and get spoken answers
|
||||
based on actual file contents. The Claude Agent SDK worker navigates
|
||||
the filesystem using Read, Bash, Glob, and Grep tools.
|
||||
|
||||
Architecture::
|
||||
|
||||
Main worker (transport + LLM + ``ask_code`` tool)
|
||||
└── job → CodeWorker (Claude Agent SDK)
|
||||
|
||||
Requirements:
|
||||
|
||||
- ANTHROPIC_API_KEY
|
||||
- OPENAI_API_KEY
|
||||
- DEEPGRAM_API_KEY
|
||||
- CARTESIA_API_KEY
|
||||
- DAILY_API_KEY (for Daily transport)
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from code_worker import CodeWorker
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
from pipecat.frames.frames import LLMMessagesAppendFrame, LLMRunFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
||||
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.cartesia.tts import CartesiaTTSService
|
||||
from pipecat.services.deepgram.stt import DeepgramSTTService
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
PROJECT_PATH = os.getenv("PROJECT_PATH", os.getcwd())
|
||||
|
||||
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 ask_code(params: FunctionCallParams, question: str):
|
||||
"""Ask a question about the codebase. A Claude Code worker will
|
||||
explore the project by reading files, searching code, and running
|
||||
commands. It remembers previous questions for follow-ups.
|
||||
|
||||
Args:
|
||||
question (str): The question about code, files, structure,
|
||||
dependencies, or anything in the project.
|
||||
"""
|
||||
logger.info(f"Asking code worker: '{question}'")
|
||||
async with params.pipeline_worker.job("code_worker", payload={"question": question}) as job:
|
||||
await params.llm.queue_frame(
|
||||
LLMMessagesAppendFrame(
|
||||
messages=[{"role": "developer", "content": "Give me a moment."}],
|
||||
run_llm=True,
|
||||
)
|
||||
)
|
||||
# The LLM keeps talking while the worker runs.
|
||||
await params.result_callback(job.response)
|
||||
|
||||
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info("Starting code assistant")
|
||||
|
||||
runner = PipelineRunner(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
|
||||
),
|
||||
)
|
||||
llm = OpenAILLMService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAILLMService.Settings(
|
||||
system_instruction=(
|
||||
"You are a voice interface to a code assistant powered by Claude Code. "
|
||||
"Behind you is a worker that can read files, search code with grep and "
|
||||
"glob patterns, and run bash commands on the project. It maintains "
|
||||
"context across questions, so follow-up questions work naturally.\n\n"
|
||||
"When the user asks anything about code, project structure, files, "
|
||||
"dependencies, tests, or wants to explore the codebase, call the "
|
||||
"ask_code tool. When the worker result comes back, summarize it naturally "
|
||||
"for speaking. Keep responses concise and conversational.\n"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
llm.register_direct_function(ask_code, cancel_on_interruption=False, timeout_secs=60)
|
||||
|
||||
context = LLMContext(tools=ToolsSchema(standard_tools=[ask_code]))
|
||||
aggregators = LLMContextAggregatorPair(
|
||||
context,
|
||||
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
|
||||
)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
stt,
|
||||
aggregators.user(),
|
||||
llm,
|
||||
tts,
|
||||
transport.output(),
|
||||
aggregators.assistant(),
|
||||
]
|
||||
)
|
||||
|
||||
worker = PipelineWorker(
|
||||
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("Client connected")
|
||||
context.add_message(
|
||||
{
|
||||
"role": "developer",
|
||||
"content": "Greet the user and tell them you're a code assistant.",
|
||||
}
|
||||
)
|
||||
await worker.queue_frame(LLMRunFrame())
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
logger.info("Client disconnected")
|
||||
await runner.cancel()
|
||||
|
||||
await runner.add_worker(CodeWorker("code_worker", project_path=PROJECT_PATH))
|
||||
await runner.add_worker(worker)
|
||||
|
||||
await runner.run()
|
||||
|
||||
|
||||
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()
|
||||
118
examples/multi-worker/code-assistant/code_worker.py
Normal file
118
examples/multi-worker/code-assistant/code_worker.py
Normal file
@@ -0,0 +1,118 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Code worker that explores a codebase using Claude Agent SDK."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.bus import BusJobRequestMessage
|
||||
from pipecat.pipeline.base_worker import BaseWorker
|
||||
from pipecat.pipeline.job_context import JobStatus
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
except ModuleNotFoundError as e:
|
||||
logger.error(f"Exception: {e}")
|
||||
logger.error("In order to use CodeWorker, you need to `pip install claude-agent-sdk`.")
|
||||
raise Exception(f"Missing module: {e}")
|
||||
|
||||
|
||||
class CodeWorker(BaseWorker):
|
||||
"""Bus-only worker that answers code questions using Claude Agent SDK.
|
||||
|
||||
Maintains a persistent Claude SDK session so follow-up questions
|
||||
share context. Questions are queued and processed sequentially. The
|
||||
worker has no Pipecat pipeline — it consumes job requests from the
|
||||
bus and replies with job responses.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, *, project_path: str):
|
||||
"""Initialize the CodeWorker.
|
||||
|
||||
Args:
|
||||
name: Unique worker name.
|
||||
project_path: Filesystem path the Claude SDK should explore.
|
||||
"""
|
||||
super().__init__(name)
|
||||
|
||||
self._project_path = project_path
|
||||
self._queue: asyncio.Queue = asyncio.Queue()
|
||||
self._worker_task: asyncio.Task | None = None
|
||||
|
||||
self._claude_options = ClaudeAgentOptions(
|
||||
permission_mode="bypassPermissions",
|
||||
system_prompt=(
|
||||
f"You are a code assistant. The project is at: {self._project_path}\n\n"
|
||||
"Answer the user's question by exploring the codebase. Use Read to "
|
||||
"view files, Glob to find files by pattern, and Bash to run commands "
|
||||
"like grep or find. Be thorough but concise in your answer. "
|
||||
"Focus on what the user asked. Respond with a clear, spoken-friendly "
|
||||
"summary (no markdown, no bullet points, no code blocks)."
|
||||
),
|
||||
allowed_tools=["Read", "Bash", "Glob", "Grep"],
|
||||
model="sonnet",
|
||||
max_turns=10,
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Launch the Claude SDK worker loop alongside the standard worker start."""
|
||||
await super().start()
|
||||
self._worker_task = self.create_task(self._worker_loop(), "worker")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel the worker loop before tearing down the worker."""
|
||||
if self._worker_task:
|
||||
await self.cancel_task(self._worker_task)
|
||||
self._worker_task = None
|
||||
await super().stop()
|
||||
|
||||
async def on_job_request(self, message: BusJobRequestMessage) -> None:
|
||||
"""Enqueue an incoming job for the worker loop."""
|
||||
await super().on_job_request(message)
|
||||
logger.info(f"Worker '{self.name}': queued '{message.payload['question']}'")
|
||||
self._queue.put_nowait(message)
|
||||
|
||||
async def _worker_loop(self):
|
||||
client = ClaudeSDKClient(options=self._claude_options)
|
||||
try:
|
||||
await client.connect()
|
||||
except Exception as e:
|
||||
logger.error(f"Worker '{self.name}': failed to start Claude SDK: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
message = await self._queue.get()
|
||||
question = message.payload["question"]
|
||||
logger.info(f"Worker '{self.name}': researching '{question}'")
|
||||
|
||||
try:
|
||||
answer = ""
|
||||
await client.query(prompt=question)
|
||||
async for msg in client.receive_response():
|
||||
if type(msg).__name__ == "AssistantMessage":
|
||||
for block in msg.content:
|
||||
if type(block).__name__ == "TextBlock":
|
||||
answer += block.text
|
||||
|
||||
logger.info(f"Worker '{self.name}': completed ({len(answer)} chars)")
|
||||
await self.send_job_response(message.job_id, {"answer": answer})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Worker '{self.name}': error: {e}")
|
||||
await self.send_job_response(
|
||||
message.job_id, {"error": str(e)}, status=JobStatus.ERROR
|
||||
)
|
||||
finally:
|
||||
# Bypass `async with ClaudeSDKClient` and call disconnect()
|
||||
# ourselves: __aexit__ → Query.close() → _read_task.wait() uses
|
||||
# `with suppress(asyncio.CancelledError)`, which would swallow the
|
||||
# outer task's cancellation. By the time this finally runs, our
|
||||
# CancelledError has already been raised once, so _must_cancel is
|
||||
# cleared and disconnect()'s awaits proceed normally.
|
||||
await client.disconnect()
|
||||
182
examples/multi-worker/distributed-handoff/pgmq-handoff/llm.py
Normal file
182
examples/multi-worker/distributed-handoff/pgmq-handoff/llm.py
Normal file
@@ -0,0 +1,182 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""LLM worker — run on Machine B (or locally alongside ``main.py``).
|
||||
|
||||
A standalone process that runs one LLM worker (greeter or support)
|
||||
attached to the same PGMQ-backed `WorkerBus` as the main worker.
|
||||
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.workers.llm import LLMWorker, LLMWorkerActivationArgs, 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(LLMWorker):
|
||||
"""LLM worker 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 worker name (``"greeter"`` or ``"support"``).
|
||||
system_instruction: System prompt for this LLM role.
|
||||
watch: Sibling worker names this worker 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 workers once ready."""
|
||||
await super().start()
|
||||
for worker_name in self._watch:
|
||||
await self.watch_worker(worker_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.activate_worker(
|
||||
agent,
|
||||
args=LLMWorkerActivationArgs(messages=[{"role": "developer", "content": reason}]),
|
||||
deactivate_self=True,
|
||||
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 (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())
|
||||
196
examples/multi-worker/distributed-handoff/pgmq-handoff/main.py
Normal file
196
examples/multi-worker/distributed-handoff/pgmq-handoff/main.py
Normal file
@@ -0,0 +1,196 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Main transport worker — run on Machine A.
|
||||
|
||||
Handles audio I/O (STT, TTS) and bridges frames to the bus. The LLM
|
||||
workers 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.worker import PipelineParams, PipelineWorker
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.registry.types import WorkerReadyData
|
||||
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.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.workers.llm import LLMWorkerActivationArgs
|
||||
|
||||
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,
|
||||
worker_name=MAIN_NAME,
|
||||
name=f"{MAIN_NAME}::BusBridge",
|
||||
)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
stt,
|
||||
aggregators.user(),
|
||||
bridge,
|
||||
tts,
|
||||
transport.output(),
|
||||
aggregators.assistant(),
|
||||
]
|
||||
)
|
||||
|
||||
worker = PipelineWorker(
|
||||
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 worker.activate_worker(
|
||||
"greeter",
|
||||
args=LLMWorkerActivationArgs(
|
||||
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: WorkerReadyData) -> 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 worker.cancel()
|
||||
|
||||
await runner.run(worker)
|
||||
|
||||
|
||||
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 worker (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)
|
||||
152
examples/multi-worker/distributed-handoff/redis-handoff/llm.py
Normal file
152
examples/multi-worker/distributed-handoff/redis-handoff/llm.py
Normal file
@@ -0,0 +1,152 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""LLM worker — run on Machine B (or locally alongside ``main.py``).
|
||||
|
||||
A standalone process that runs one LLM worker (greeter or support)
|
||||
attached to the same Redis-backed `WorkerBus` as the main worker.
|
||||
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.workers.llm import LLMWorker, LLMWorkerActivationArgs, 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(LLMWorker):
|
||||
"""LLM worker 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 worker name (``"greeter"`` or ``"support"``).
|
||||
system_instruction: System prompt for this LLM role.
|
||||
watch: Sibling worker names this worker 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 workers once ready."""
|
||||
await super().start()
|
||||
for worker_name in self._watch:
|
||||
await self.watch_worker(worker_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.activate_worker(
|
||||
agent,
|
||||
args=LLMWorkerActivationArgs(messages=[{"role": "developer", "content": reason}]),
|
||||
deactivate_self=True,
|
||||
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 (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())
|
||||
169
examples/multi-worker/distributed-handoff/redis-handoff/main.py
Normal file
169
examples/multi-worker/distributed-handoff/redis-handoff/main.py
Normal file
@@ -0,0 +1,169 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Main transport worker — run on Machine A.
|
||||
|
||||
Handles audio I/O (STT, TTS) and bridges frames to the bus. The LLM
|
||||
workers run as separate processes (possibly on different
|
||||
machines) and connect to the same Redis-backed `WorkerBus`.
|
||||
|
||||
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.worker import PipelineParams, PipelineWorker
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.registry.types import WorkerReadyData
|
||||
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.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.workers.llm import LLMWorkerActivationArgs
|
||||
|
||||
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,
|
||||
worker_name=MAIN_NAME,
|
||||
name=f"{MAIN_NAME}::BusBridge",
|
||||
)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
stt,
|
||||
aggregators.user(),
|
||||
bridge,
|
||||
tts,
|
||||
transport.output(),
|
||||
aggregators.assistant(),
|
||||
]
|
||||
)
|
||||
|
||||
worker = PipelineWorker(
|
||||
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 worker.activate_worker(
|
||||
"greeter",
|
||||
args=LLMWorkerActivationArgs(
|
||||
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: WorkerReadyData) -> 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 worker.cancel()
|
||||
|
||||
await runner.run(worker)
|
||||
|
||||
|
||||
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 worker (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)
|
||||
22
examples/multi-worker/env.example
Normal file
22
examples/multi-worker/env.example
Normal file
@@ -0,0 +1,22 @@
|
||||
# Audio services
|
||||
DEEPGRAM_API_KEY=
|
||||
CARTESIA_API_KEY=
|
||||
|
||||
# LLM
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Voice code assistant (Claude Agent SDK)
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# Transport (optional — only needed with --transport daily)
|
||||
DAILY_API_KEY=
|
||||
|
||||
# Distributed handoff via PGMQ
|
||||
# Get from: Supabase dashboard > Project Settings > Database > Connection string
|
||||
# The session-mode pooler (port 5432) is preferred. The transaction-mode
|
||||
# pooler (port 6543) also works but logs benign "resetting connection"
|
||||
# warnings.
|
||||
# Format (session pooler):
|
||||
# postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:5432/postgres
|
||||
DATABASE_URL=
|
||||
PGMQ_CHANNEL=pipecat_acme
|
||||
@@ -0,0 +1,270 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Two LLM workers with per-worker TTS voices.
|
||||
|
||||
Same shape as ``local-handoff-two-agents.py``, but each child worker
|
||||
runs its own TTS with a distinct voice. The main worker has no TTS —
|
||||
audio comes from the child workers via the bus and is played by the
|
||||
main worker's transport. Tasks announce the transfer ("let me connect
|
||||
you with...") before handing off.
|
||||
|
||||
Architecture::
|
||||
|
||||
Main worker (no TTS):
|
||||
transport.in → STT → user_agg → BusBridge → transport.out → assistant_agg
|
||||
|
||||
Child worker (with TTS):
|
||||
bridge_in → LLM → TTS → bridge_out
|
||||
|
||||
Requirements:
|
||||
|
||||
- OPENAI_API_KEY
|
||||
- DEEPGRAM_API_KEY
|
||||
- CARTESIA_API_KEY
|
||||
- DAILY_API_KEY (for Daily transport)
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
from pipecat.bus import BusBridgeProcessor
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
||||
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.cartesia.tts import CartesiaTTSService
|
||||
from pipecat.services.deepgram.stt import DeepgramSTTService
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.workers.llm import LLMWorker, LLMWorkerActivationArgs, tool
|
||||
|
||||
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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class AcmeTTSTask(LLMWorker):
|
||||
"""Child worker with its own LLM + TTS, bridged to the main worker.
|
||||
|
||||
Each child wraps the standard ``Pipeline([llm])`` with an extra
|
||||
TTS processor so audio is produced locally by each child and
|
||||
shipped to the main worker over the bus.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, *, llm: OpenAILLMService, voice_id: str):
|
||||
"""Initialize the child worker.
|
||||
|
||||
Args:
|
||||
name: Unique worker name.
|
||||
llm: The LLM service for this child.
|
||||
voice_id: Cartesia voice id for this child's TTS.
|
||||
"""
|
||||
tts = CartesiaTTSService(
|
||||
api_key=os.environ["CARTESIA_API_KEY"],
|
||||
settings=CartesiaTTSService.Settings(voice=voice_id),
|
||||
)
|
||||
super().__init__(
|
||||
name,
|
||||
llm=llm,
|
||||
pipeline=Pipeline([llm, tts]),
|
||||
bridged=(),
|
||||
)
|
||||
|
||||
@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.activate_worker(
|
||||
agent,
|
||||
messages=[
|
||||
{
|
||||
"role": "developer",
|
||||
"content": f"Tell the user about the transfer ({reason}).",
|
||||
}
|
||||
],
|
||||
args=LLMWorkerActivationArgs(
|
||||
messages=[{"role": "developer", "content": reason}],
|
||||
),
|
||||
deactivate_self=True,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def build_greeter() -> AcmeTTSTask:
|
||||
"""Greeter: routes the user to support when they pick a product."""
|
||||
llm = OpenAILLMService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAILLMService.Settings(
|
||||
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, "
|
||||
"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. Keep responses brief — this is a "
|
||||
"voice conversation."
|
||||
),
|
||||
),
|
||||
)
|
||||
return AcmeTTSTask(
|
||||
"greeter",
|
||||
llm=llm,
|
||||
voice_id="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc", # Jacqueline
|
||||
)
|
||||
|
||||
|
||||
def build_support() -> AcmeTTSTask:
|
||||
"""Support: answers product questions, can hand back to the greeter."""
|
||||
llm = OpenAILLMService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAILLMService.Settings(
|
||||
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. Keep responses brief — this is a voice conversation."
|
||||
),
|
||||
),
|
||||
)
|
||||
return AcmeTTSTask(
|
||||
"support",
|
||||
llm=llm,
|
||||
voice_id="a167e0f3-df7e-4d52-a9c3-f949145efdab", # Blake
|
||||
)
|
||||
|
||||
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info("Starting two-agents-with-tts bot")
|
||||
|
||||
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
|
||||
|
||||
stt = DeepgramSTTService(api_key=os.environ["DEEPGRAM_API_KEY"])
|
||||
|
||||
context = LLMContext()
|
||||
aggregators = LLMContextAggregatorPair(
|
||||
context,
|
||||
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
|
||||
)
|
||||
|
||||
# The main worker has no TTS. Audio comes from the children over
|
||||
# the bus; the main bridge tees user context out and pushes
|
||||
# incoming audio/text frames back into the local pipeline.
|
||||
bridge = BusBridgeProcessor(
|
||||
bus=runner.bus,
|
||||
worker_name=MAIN_NAME,
|
||||
name=f"{MAIN_NAME}::BusBridge",
|
||||
)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
stt,
|
||||
aggregators.user(),
|
||||
bridge,
|
||||
transport.output(),
|
||||
aggregators.assistant(),
|
||||
]
|
||||
)
|
||||
|
||||
worker = PipelineWorker(
|
||||
pipeline,
|
||||
name=MAIN_NAME,
|
||||
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("Client connected")
|
||||
await worker.activate_worker(
|
||||
"greeter",
|
||||
args=LLMWorkerActivationArgs(
|
||||
messages=[
|
||||
{
|
||||
"role": "developer",
|
||||
"content": (
|
||||
"Welcome the user to Acme Corp, mention the available products "
|
||||
"and ask how you can help."
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
logger.info("Client disconnected")
|
||||
await runner.cancel()
|
||||
|
||||
await runner.add_worker(build_greeter())
|
||||
await runner.add_worker(build_support())
|
||||
await runner.add_worker(worker)
|
||||
|
||||
await runner.run()
|
||||
|
||||
|
||||
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()
|
||||
243
examples/multi-worker/local-handoff/local-handoff-two-agents.py
Normal file
243
examples/multi-worker/local-handoff/local-handoff-two-agents.py
Normal file
@@ -0,0 +1,243 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Two LLM workers with a main worker bridging transport to the bus.
|
||||
|
||||
Demonstrates multi-worker coordination: a main worker handles transport I/O
|
||||
(STT, TTS) and bridges frames to the bus. Two LLM workers — a greeter and
|
||||
a support worker — each run their own LLM pipeline and hand off control
|
||||
between each other.
|
||||
|
||||
The user talks to one worker at a time. Hand-offs are seamless — the LLM
|
||||
decides when to transfer based on its tools.
|
||||
|
||||
Requirements:
|
||||
|
||||
- OPENAI_API_KEY
|
||||
- DEEPGRAM_API_KEY
|
||||
- CARTESIA_API_KEY
|
||||
- DAILY_API_KEY (for Daily transport)
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
from pipecat.bus import BusBridgeProcessor
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
||||
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.cartesia.tts import CartesiaTTSService
|
||||
from pipecat.services.deepgram.stt import DeepgramSTTService
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.workers.llm import LLMWorker, LLMWorkerActivationArgs, tool
|
||||
|
||||
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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class AcmeLLMTask(LLMWorker):
|
||||
"""LLM-only child worker with transfer/end tools.
|
||||
|
||||
Receives user context from the main worker via the bus, runs its LLM,
|
||||
and ships generated text frames back. The main worker's TTS turns the
|
||||
text into audio.
|
||||
|
||||
Passing ``bridged=()`` tells :class:`PipelineWorker` to wrap the LLM
|
||||
pipeline with bus edge processors so frames flow between this worker
|
||||
and the main worker automatically.
|
||||
"""
|
||||
|
||||
@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.activate_worker(
|
||||
agent,
|
||||
args=LLMWorkerActivationArgs(
|
||||
messages=[{"role": "developer", "content": reason}],
|
||||
),
|
||||
deactivate_self=True,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def build_greeter() -> AcmeLLMTask:
|
||||
"""Greeter: routes the user to support when they pick a product."""
|
||||
llm = OpenAILLMService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAILLMService.Settings(
|
||||
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."
|
||||
),
|
||||
),
|
||||
)
|
||||
return AcmeLLMTask("greeter", llm=llm, bridged=())
|
||||
|
||||
|
||||
def build_support() -> AcmeLLMTask:
|
||||
"""Support: answers product questions, can hand back to the greeter."""
|
||||
llm = OpenAILLMService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAILLMService.Settings(
|
||||
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."
|
||||
),
|
||||
),
|
||||
)
|
||||
return AcmeLLMTask("support", llm=llm, bridged=())
|
||||
|
||||
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info("Starting two-agent bot")
|
||||
|
||||
runner = PipelineRunner(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()),
|
||||
)
|
||||
|
||||
# The main bridge sends user-side context downstream to the children
|
||||
# via the bus, and the children's generated text comes back here so
|
||||
# the TTS can speak it.
|
||||
bridge = BusBridgeProcessor(
|
||||
bus=runner.bus,
|
||||
worker_name=MAIN_NAME,
|
||||
name=f"{MAIN_NAME}::BusBridge",
|
||||
)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
stt,
|
||||
aggregators.user(),
|
||||
bridge,
|
||||
tts,
|
||||
transport.output(),
|
||||
aggregators.assistant(),
|
||||
]
|
||||
)
|
||||
|
||||
worker = PipelineWorker(
|
||||
pipeline,
|
||||
name=MAIN_NAME,
|
||||
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("Client connected")
|
||||
await worker.activate_worker(
|
||||
"greeter",
|
||||
args=LLMWorkerActivationArgs(
|
||||
messages=[
|
||||
{
|
||||
"role": "developer",
|
||||
"content": (
|
||||
"Welcome the user to Acme Corp, mention the available products "
|
||||
"and ask how you can help."
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
logger.info("Client disconnected")
|
||||
await runner.cancel()
|
||||
|
||||
await runner.add_worker(build_greeter())
|
||||
await runner.add_worker(build_support())
|
||||
await runner.add_worker(worker)
|
||||
|
||||
await runner.run()
|
||||
|
||||
|
||||
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()
|
||||
236
examples/multi-worker/parallel-debate/parallel-debate.py
Normal file
236
examples/multi-worker/parallel-debate/parallel-debate.py
Normal file
@@ -0,0 +1,236 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Parallel debate using job groups.
|
||||
|
||||
A voice bot receives a topic from the user and fans out to three
|
||||
workers in parallel via ``worker.job_group(...)``. Each worker
|
||||
runs its own LLM context, so it remembers previous topics across
|
||||
debate rounds. The bot collects all three perspectives and the
|
||||
main-worker LLM synthesizes a balanced answer.
|
||||
|
||||
Architecture::
|
||||
|
||||
Main worker (transport + LLM + ``debate`` tool)
|
||||
└── job_group(advocate, critic, analyst)
|
||||
└── DebateWorker (LLMContextWorker, one per role)
|
||||
|
||||
Requirements:
|
||||
|
||||
- OPENAI_API_KEY
|
||||
- DEEPGRAM_API_KEY
|
||||
- CARTESIA_API_KEY
|
||||
- DAILY_API_KEY (for Daily transport)
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
from pipecat.bus import BusJobRequestMessage
|
||||
from pipecat.frames.frames import LLMMessagesAppendFrame, LLMRunFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
AssistantTurnStoppedMessage,
|
||||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
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.services.llm_service import FunctionCallParams
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.workers.llm import LLMContextWorker
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
ROLE_PROMPTS = {
|
||||
"advocate": (
|
||||
"You argue IN FAVOR of the topic. Present the strongest case for why "
|
||||
"this is a good idea, with concrete benefits. Be persuasive but honest. "
|
||||
"Be concise, just 2-3 sentences."
|
||||
),
|
||||
"critic": (
|
||||
"You argue AGAINST the topic. Present the strongest concerns, risks, "
|
||||
"and downsides. Be critical but fair. Be concise, just 2-3 sentences."
|
||||
),
|
||||
"analyst": (
|
||||
"You provide a BALANCED, NEUTRAL analysis. Weigh both sides objectively "
|
||||
"and highlight the key trade-offs. Be concise, just 2-3 sentences."
|
||||
),
|
||||
}
|
||||
|
||||
transport_params = {
|
||||
"daily": lambda: DailyParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
"webrtc": lambda: TransportParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class DebateWorker(LLMContextWorker):
|
||||
"""Worker that generates a perspective using its own LLM context.
|
||||
|
||||
Each worker keeps its own ``LLMContext`` so it remembers previous
|
||||
topics across multiple debate rounds. Job requests append the new
|
||||
topic and trigger the LLM; the assistant-aggregator captures the
|
||||
full reply and sends it back as the job response.
|
||||
"""
|
||||
|
||||
def __init__(self, role: str):
|
||||
"""Initialize the DebateWorker.
|
||||
|
||||
Args:
|
||||
role: One of ``"advocate"``, ``"critic"``, ``"analyst"`` —
|
||||
used as the worker name and selects the system prompt.
|
||||
"""
|
||||
llm = OpenAILLMService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAILLMService.Settings(system_instruction=ROLE_PROMPTS[role]),
|
||||
)
|
||||
super().__init__(role, llm=llm)
|
||||
self._role = role
|
||||
self._current_job_id: str | None = None
|
||||
|
||||
@self.assistant_aggregator.event_handler("on_assistant_turn_stopped")
|
||||
async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage):
|
||||
text = message.content
|
||||
logger.info(f"Worker '{self.name}': completed ({len(text)} chars)")
|
||||
if self._current_job_id:
|
||||
job_id = self._current_job_id
|
||||
self._current_job_id = None
|
||||
await self.send_job_response(job_id, {"role": self._role, "text": text})
|
||||
|
||||
async def on_job_request(self, message: BusJobRequestMessage) -> None:
|
||||
"""Inject the topic and run the LLM."""
|
||||
await super().on_job_request(message)
|
||||
self._current_job_id = message.job_id
|
||||
await self.queue_frame(
|
||||
LLMMessagesAppendFrame(
|
||||
messages=[{"role": "developer", "content": f"Topic: {message.payload['topic']}"}],
|
||||
run_llm=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def debate(params: FunctionCallParams, topic: str):
|
||||
"""Analyze a topic from multiple perspectives (advocate, critic, analyst).
|
||||
|
||||
Args:
|
||||
topic (str): The topic or question to debate.
|
||||
"""
|
||||
logger.info(f"Starting debate on '{topic}'")
|
||||
async with params.pipeline_worker.job_group(
|
||||
*ROLE_PROMPTS, payload={"topic": topic}, timeout=30
|
||||
) as tg:
|
||||
pass
|
||||
result = "\n\n".join(f"{r['role'].upper()}: {r['text']}" for r in tg.responses.values())
|
||||
logger.info("Debate complete, synthesizing")
|
||||
await params.result_callback(result)
|
||||
|
||||
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info("Starting parallel-debate bot")
|
||||
|
||||
runner = PipelineRunner(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
|
||||
),
|
||||
)
|
||||
llm = OpenAILLMService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAILLMService.Settings(
|
||||
system_instruction=(
|
||||
"You are a debate moderator in a voice conversation. When the user "
|
||||
"gives you a topic, call the debate tool to gather perspectives from "
|
||||
"three viewpoints (advocate, critic, analyst). Then synthesize the "
|
||||
"results into a clear, balanced summary for the user. Keep your "
|
||||
"responses concise and natural for speaking."
|
||||
),
|
||||
),
|
||||
)
|
||||
llm.register_direct_function(debate, cancel_on_interruption=False, timeout_secs=60)
|
||||
|
||||
context = LLMContext(tools=ToolsSchema(standard_tools=[debate]))
|
||||
aggregators = LLMContextAggregatorPair(
|
||||
context,
|
||||
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
|
||||
)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
stt,
|
||||
aggregators.user(),
|
||||
llm,
|
||||
tts,
|
||||
transport.output(),
|
||||
aggregators.assistant(),
|
||||
]
|
||||
)
|
||||
|
||||
worker = PipelineWorker(
|
||||
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("Client connected")
|
||||
context.add_message(
|
||||
{
|
||||
"role": "developer",
|
||||
"content": (
|
||||
"Greet the user and tell them you can moderate a debate on any "
|
||||
"topic. Ask what they'd like to explore."
|
||||
),
|
||||
}
|
||||
)
|
||||
await worker.queue_frame(LLMRunFrame())
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
logger.info("Client disconnected")
|
||||
await runner.cancel()
|
||||
|
||||
for role in ROLE_PROMPTS:
|
||||
await runner.add_worker(DebateWorker(role))
|
||||
await runner.add_worker(worker)
|
||||
|
||||
await runner.run()
|
||||
|
||||
|
||||
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()
|
||||
120
examples/multi-worker/remote-proxy-assistant/assistant.py
Normal file
120
examples/multi-worker/remote-proxy-assistant/assistant.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Remote assistant LLM server.
|
||||
|
||||
Runs a FastAPI server that accepts WebSocket connections from a
|
||||
``main.py``-style client. Each connection spins up a
|
||||
`WebSocketProxyServerTask` bridging the socket to a local
|
||||
`PipelineRunner` and an `LLMWorker` that handles the conversation.
|
||||
|
||||
Usage::
|
||||
|
||||
python assistant.py
|
||||
python assistant.py --port 9000
|
||||
|
||||
Requirements:
|
||||
|
||||
- OPENAI_API_KEY
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, WebSocket
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.bus import BusFrameMessage
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
from pipecat.workers.llm import LLMWorker, tool
|
||||
from pipecat.workers.proxy.websocket import WebSocketProxyServerTask
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class AcmeAssistant(LLMWorker):
|
||||
"""Handles greetings, product questions, and conversation end."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the AcmeAssistant LLM worker."""
|
||||
llm = OpenAILLMService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAILLMService.Settings(
|
||||
system_instruction=(
|
||||
"You are a friendly assistant 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). Greet the user, help them with product questions, "
|
||||
"and call end_conversation when the user says goodbye. "
|
||||
"Keep responses brief, this is a voice conversation."
|
||||
),
|
||||
),
|
||||
)
|
||||
super().__init__("assistant", llm=llm, bridged=())
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
"""Handle a WebSocket connection from the main bot's proxy."""
|
||||
await websocket.accept()
|
||||
|
||||
runner = PipelineRunner(handle_sigint=False)
|
||||
|
||||
proxy = WebSocketProxyServerTask(
|
||||
"gateway",
|
||||
websocket=websocket,
|
||||
worker_name="assistant",
|
||||
remote_worker_name="acme",
|
||||
forward_messages=(BusFrameMessage,),
|
||||
)
|
||||
|
||||
@proxy.event_handler("on_client_connected")
|
||||
async def on_client_connected(proxy, client):
|
||||
logger.info("WebSocket client connected")
|
||||
|
||||
@proxy.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(proxy, client):
|
||||
logger.info("WebSocket client disconnected")
|
||||
await runner.cancel()
|
||||
|
||||
assistant = AcmeAssistant()
|
||||
|
||||
await runner.add_worker(proxy)
|
||||
await runner.add_worker(assistant)
|
||||
|
||||
logger.info("Assistant server ready, waiting for activation")
|
||||
await runner.run()
|
||||
logger.info("Assistant server session ended")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Remote assistant LLM server")
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to")
|
||||
parser.add_argument("--port", type=int, default=8765, help="Port to listen on")
|
||||
args = parser.parse_args()
|
||||
|
||||
uvicorn.run(app, host=args.host, port=args.port)
|
||||
171
examples/multi-worker/remote-proxy-assistant/main.py
Normal file
171
examples/multi-worker/remote-proxy-assistant/main.py
Normal file
@@ -0,0 +1,171 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Main transport worker with a WebSocket proxy to a remote LLM server.
|
||||
|
||||
Handles audio I/O (STT, TTS) and bridges frames to the bus. A
|
||||
`WebSocketProxyClientTask` forwards bus messages to a remote LLM
|
||||
server (see ``assistant.py``) over WebSocket.
|
||||
|
||||
Usage::
|
||||
|
||||
python main.py --remote-url ws://localhost:8765/ws
|
||||
|
||||
Requirements:
|
||||
|
||||
- DEEPGRAM_API_KEY
|
||||
- CARTESIA_API_KEY
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
from pipecat.bus import BusBridgeProcessor, BusFrameMessage
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.registry.types import WorkerReadyData
|
||||
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.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.workers.llm import LLMWorkerActivationArgs
|
||||
from pipecat.workers.proxy.websocket import WebSocketProxyClientTask
|
||||
|
||||
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):
|
||||
runner = PipelineRunner(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,
|
||||
worker_name=MAIN_NAME,
|
||||
name=f"{MAIN_NAME}::BusBridge",
|
||||
)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
stt,
|
||||
aggregators.user(),
|
||||
bridge,
|
||||
tts,
|
||||
transport.output(),
|
||||
aggregators.assistant(),
|
||||
]
|
||||
)
|
||||
|
||||
worker = PipelineWorker(
|
||||
pipeline,
|
||||
name=MAIN_NAME,
|
||||
params=PipelineParams(
|
||||
enable_metrics=True,
|
||||
enable_usage_metrics=True,
|
||||
),
|
||||
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
|
||||
)
|
||||
|
||||
# Forward bus frame messages over the WebSocket so the remote
|
||||
# assistant sees user-side context and can ship back its replies.
|
||||
proxy = WebSocketProxyClientTask(
|
||||
"proxy",
|
||||
url=runner_args.cli_args.remote_url,
|
||||
local_worker_name=MAIN_NAME,
|
||||
remote_worker_name="assistant",
|
||||
forward_messages=(BusFrameMessage,),
|
||||
)
|
||||
|
||||
async def on_assistant_ready(_data: WorkerReadyData) -> None:
|
||||
logger.info("Remote assistant ready, activating")
|
||||
await worker.activate_worker(
|
||||
"assistant",
|
||||
args=LLMWorkerActivationArgs(
|
||||
messages=[
|
||||
{
|
||||
"role": "developer",
|
||||
"content": (
|
||||
"Welcome the user to Acme Corp, mention the available "
|
||||
"products and ask how you can help."
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
await runner.registry.watch("assistant", on_assistant_ready)
|
||||
|
||||
@transport.event_handler("on_client_connected")
|
||||
async def on_client_connected(transport, client):
|
||||
logger.info("Client connected, activating proxy")
|
||||
await worker.activate_worker("proxy")
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
logger.info("Client disconnected")
|
||||
await runner.cancel()
|
||||
|
||||
await runner.add_worker(proxy)
|
||||
await runner.add_worker(worker)
|
||||
|
||||
await runner.run()
|
||||
|
||||
|
||||
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 worker with WebSocket proxy")
|
||||
parser.add_argument(
|
||||
"--remote-url",
|
||||
default="ws://localhost:8765/ws",
|
||||
help="WebSocket URL of the remote LLM server",
|
||||
)
|
||||
|
||||
main(parser)
|
||||
334
examples/multi-worker/sensor-controller/sensor-controller.py
Normal file
334
examples/multi-worker/sensor-controller/sensor-controller.py
Normal file
@@ -0,0 +1,334 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Voice agent + sensor-controller worker, both as plain PipelineTasks.
|
||||
|
||||
Two ``PipelineWorker`` instances run side by side:
|
||||
|
||||
- The **voice agent** is built inline in ``run_bot`` — a standard
|
||||
transport + STT + LLM + TTS pipeline. Its LLM has a single tool,
|
||||
``ask_controller(question)``, which forwards the user's request to
|
||||
the controller over the bus and speaks back the response.
|
||||
- The **sensor controller** (``build_sensor_controller``) is a
|
||||
``PipelineWorker`` whose pipeline runs a simulated temperature sensor
|
||||
(see ``sensor.py``) alongside its own LLM. The worker LLM has tool
|
||||
access to read the current reading, inspect rolling stats, and
|
||||
mutate the simulated sensor's target temperature and response rate.
|
||||
|
||||
The worker does **not** subclass ``LLMWorker`` and is **not** bridged.
|
||||
The voice agent and the controller communicate exclusively through
|
||||
``BusJobRequestMessage`` / ``BusJobResponseMessage``. The controller
|
||||
collects responses by listening to the assistant aggregator's
|
||||
``on_assistant_turn_stopped`` event and pairing each LLM completion
|
||||
with the in-flight job id.
|
||||
|
||||
Requirements:
|
||||
|
||||
- OPENAI_API_KEY
|
||||
- DEEPGRAM_API_KEY
|
||||
- CARTESIA_API_KEY
|
||||
- DAILY_API_KEY (for Daily transport)
|
||||
|
||||
Example voice exchange::
|
||||
|
||||
User: What's the temperature?
|
||||
Controller: 22.1°C, holding steady.
|
||||
|
||||
User: Make it warmer.
|
||||
Controller: I set the target to 26°C. Give it about 20 seconds.
|
||||
|
||||
User: Is it stable yet?
|
||||
Controller: It's at 25.4°C and still climbing — almost there.
|
||||
|
||||
User: Why is it slow?
|
||||
Controller: The response rate is 5%. I sped it up to 20%; it'll settle faster now.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
from sensor import SensorReader, SensorStats
|
||||
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
from pipecat.bus import BusJobRequestMessage
|
||||
from pipecat.frames.frames import LLMMessagesAppendFrame, LLMRunFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
AssistantTurnStoppedMessage,
|
||||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
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.services.llm_service import FunctionCallParams
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
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 build_sensor_controller() -> PipelineWorker:
|
||||
"""Build the controller worker as a plain :class:`PipelineWorker`.
|
||||
|
||||
The pipeline shape is::
|
||||
|
||||
SensorReader -> SensorStats -> user_agg -> llm -> assistant_agg
|
||||
|
||||
``SensorReader`` runs an autonomous tick loop that emits a
|
||||
:class:`SensorReadingFrame` every second; ``SensorStats`` consumes
|
||||
those readings and exposes rolling statistics. The LLM has four
|
||||
direct tools that read or mutate the sensor.
|
||||
|
||||
Jobs arrive via the ``on_job_request`` event handler. The handler
|
||||
stores the active ``job_id``, then queues an
|
||||
:class:`LLMMessagesAppendFrame` with the user's question and runs
|
||||
the LLM. When the assistant turn finishes (signalled by the
|
||||
assistant aggregator's ``on_assistant_turn_stopped`` event), the
|
||||
handler sends a :class:`BusJobResponseMessage` carrying the LLM's
|
||||
answer back to the voice agent.
|
||||
"""
|
||||
sensor = SensorReader()
|
||||
stats = SensorStats()
|
||||
|
||||
async def get_current_reading(params: FunctionCallParams):
|
||||
"""Read the sensor's current temperature in degrees Celsius."""
|
||||
await params.result_callback({"temperature": round(sensor.current, 2)})
|
||||
|
||||
async def get_stats(params: FunctionCallParams):
|
||||
"""Rolling minimum, maximum, average, and trend of the temperature."""
|
||||
await params.result_callback(
|
||||
{
|
||||
"min": round(stats.min, 2),
|
||||
"max": round(stats.max, 2),
|
||||
"avg": round(stats.avg, 2),
|
||||
"trend": stats.trend,
|
||||
}
|
||||
)
|
||||
|
||||
async def set_target_temperature(params: FunctionCallParams, target_celsius: float):
|
||||
"""Adjust the target temperature; the sensor will drift toward it.
|
||||
|
||||
Args:
|
||||
target_celsius (float): The new target temperature in degrees Celsius.
|
||||
"""
|
||||
sensor.set_target(target_celsius)
|
||||
await params.result_callback({"ok": True, "new_target": target_celsius})
|
||||
|
||||
async def set_response_rate(params: FunctionCallParams, rate: float):
|
||||
"""Set how aggressively the sensor approaches the target.
|
||||
|
||||
Args:
|
||||
rate (float): Response rate between 0.01 (slow) and 0.3 (fast).
|
||||
"""
|
||||
sensor.set_response_rate(rate)
|
||||
await params.result_callback({"ok": True, "new_rate": rate})
|
||||
|
||||
llm = OpenAILLMService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAILLMService.Settings(
|
||||
system_instruction=(
|
||||
"You are a temperature sensor controller. You manage a single "
|
||||
"thermometer and answer the user's questions about it. Use the "
|
||||
"provided tools to read the current temperature, inspect rolling "
|
||||
"statistics, change the target temperature, or change how fast "
|
||||
"the sensor responds. When the user asks for a vague change "
|
||||
"('make it warmer', 'cooler'), pick a sensible target and call "
|
||||
"set_target_temperature. Always answer in one or two short "
|
||||
"sentences — your reply is spoken aloud."
|
||||
),
|
||||
),
|
||||
)
|
||||
llm.register_direct_function(get_current_reading)
|
||||
llm.register_direct_function(get_stats)
|
||||
llm.register_direct_function(set_target_temperature)
|
||||
llm.register_direct_function(set_response_rate)
|
||||
|
||||
context = LLMContext(
|
||||
tools=ToolsSchema(
|
||||
standard_tools=[
|
||||
get_current_reading,
|
||||
get_stats,
|
||||
set_target_temperature,
|
||||
set_response_rate,
|
||||
]
|
||||
)
|
||||
)
|
||||
aggregators = LLMContextAggregatorPair(context)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
sensor,
|
||||
stats,
|
||||
aggregators.user(),
|
||||
llm,
|
||||
aggregators.assistant(),
|
||||
]
|
||||
)
|
||||
|
||||
worker = PipelineWorker(pipeline, name="controller")
|
||||
|
||||
# The controller handles one job at a time (the LLM pipeline can only
|
||||
# run one turn at a time). ``state["job_id"]`` pairs the in-flight
|
||||
# job with the next ``on_assistant_turn_stopped`` event.
|
||||
state: dict[str, str | None] = {"job_id": None}
|
||||
|
||||
@worker.event_handler("on_job_request")
|
||||
async def on_request(_task, message: BusJobRequestMessage):
|
||||
question = message.payload["question"]
|
||||
logger.info(f"Controller: received question '{question}'")
|
||||
state["job_id"] = message.job_id
|
||||
await worker.queue_frame(
|
||||
LLMMessagesAppendFrame(
|
||||
messages=[{"role": "user", "content": question}],
|
||||
run_llm=True,
|
||||
)
|
||||
)
|
||||
|
||||
@aggregators.assistant().event_handler("on_assistant_turn_stopped")
|
||||
async def on_assistant_turn_stopped(_aggregator, message: AssistantTurnStoppedMessage):
|
||||
# The aggregator fires this event on every ``LLMFullResponseEndFrame``,
|
||||
# including the tool-call round that precedes the tool result and has
|
||||
# no spoken text. Skip those so we only forward the LLM's final
|
||||
# response to the voice agent.
|
||||
if not message.content:
|
||||
return
|
||||
if state["job_id"] is None:
|
||||
return
|
||||
job_id, state["job_id"] = state["job_id"], None
|
||||
logger.info(f"Controller: answering job {job_id[:8]}")
|
||||
await worker.send_job_response(job_id, response={"answer": message.content})
|
||||
|
||||
return worker
|
||||
|
||||
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info("Starting sensor-controller bot")
|
||||
|
||||
# Voice agent: standard transport + STT + LLM + TTS pipeline. The
|
||||
# only tool the voice LLM has is ``ask_controller`` — it does not
|
||||
# know anything about temperatures, trends, or response rates.
|
||||
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
|
||||
),
|
||||
)
|
||||
|
||||
async def ask_controller(params: FunctionCallParams, question: str):
|
||||
"""Ask the temperature sensor controller anything about the sensor.
|
||||
|
||||
Forward the user's request verbatim and speak back the answer.
|
||||
|
||||
Args:
|
||||
question (str): The user's question or instruction to forward to the controller.
|
||||
"""
|
||||
logger.info(f"Voice agent: forwarding to controller: '{question}'")
|
||||
async with params.pipeline_worker.job(
|
||||
"controller", payload={"question": question}, timeout=30
|
||||
) as t:
|
||||
pass
|
||||
await params.result_callback(t.response["answer"])
|
||||
|
||||
llm = OpenAILLMService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAILLMService.Settings(
|
||||
system_instruction=(
|
||||
"You are a friendly voice assistant with access to a temperature "
|
||||
"sensor controller. For ANY request about the temperature — "
|
||||
"reading it, adjusting it, checking trends, changing how fast it "
|
||||
"responds — call the ask_controller tool. Forward the user's "
|
||||
"request verbatim. Then speak the controller's answer back. "
|
||||
"Keep responses brief; do not add extra commentary."
|
||||
),
|
||||
),
|
||||
)
|
||||
llm.register_direct_function(ask_controller, timeout_secs=60)
|
||||
|
||||
context = LLMContext(tools=ToolsSchema(standard_tools=[ask_controller]))
|
||||
aggregators = LLMContextAggregatorPair(
|
||||
context,
|
||||
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
|
||||
)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
stt,
|
||||
aggregators.user(),
|
||||
llm,
|
||||
tts,
|
||||
transport.output(),
|
||||
aggregators.assistant(),
|
||||
]
|
||||
)
|
||||
|
||||
worker = PipelineWorker(
|
||||
pipeline,
|
||||
params=PipelineParams(
|
||||
enable_metrics=True,
|
||||
enable_usage_metrics=True,
|
||||
),
|
||||
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
|
||||
)
|
||||
|
||||
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
|
||||
|
||||
@transport.event_handler("on_client_connected")
|
||||
async def on_client_connected(transport, client):
|
||||
logger.info("Client connected")
|
||||
context.add_message(
|
||||
{
|
||||
"role": "developer",
|
||||
"content": (
|
||||
"Greet the user and let them know you can read or adjust a "
|
||||
"temperature sensor on their behalf."
|
||||
),
|
||||
}
|
||||
)
|
||||
await worker.queue_frame(LLMRunFrame())
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
logger.info("Client disconnected")
|
||||
await runner.cancel()
|
||||
|
||||
await runner.add_worker(build_sensor_controller())
|
||||
await runner.add_worker(worker)
|
||||
|
||||
await runner.run()
|
||||
|
||||
|
||||
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/multi-worker/sensor-controller/sensor.py
Normal file
186
examples/multi-worker/sensor-controller/sensor.py
Normal file
@@ -0,0 +1,186 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Temperature sensor processors for the sensor-controller example.
|
||||
|
||||
Two custom :class:`FrameProcessor` subclasses that give the worker
|
||||
pipeline real autonomous frame flow:
|
||||
|
||||
- :class:`SensorReader` simulates a thermometer. It runs an async tick
|
||||
loop that advances ``current`` toward ``target`` with a first-order
|
||||
lag plus Gaussian noise, and pushes a :class:`SensorReadingFrame` on
|
||||
every tick. ``target`` and ``response_rate`` are mutable so the
|
||||
worker's LLM can adjust them via tool calls.
|
||||
- :class:`SensorStats` consumes the readings, maintains a rolling
|
||||
window, and exposes ``current`` / ``min`` / ``max`` / ``avg`` /
|
||||
``trend`` as properties. The worker LLM reads these directly when
|
||||
answering the user.
|
||||
"""
|
||||
|
||||
import random
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pipecat.frames.frames import DataFrame, Frame, StartFrame
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
|
||||
|
||||
@dataclass
|
||||
class SensorReadingFrame(DataFrame):
|
||||
"""A single temperature reading emitted by :class:`SensorReader`.
|
||||
|
||||
Parameters:
|
||||
temperature: The reading in degrees Celsius.
|
||||
timestamp: Unix timestamp when the reading was taken.
|
||||
"""
|
||||
|
||||
temperature: float = 0.0
|
||||
timestamp: float = 0.0
|
||||
|
||||
|
||||
class SensorReader(FrameProcessor):
|
||||
"""Simulated temperature sensor with adjustable target and response rate.
|
||||
|
||||
Each tick, ``current`` is updated as::
|
||||
|
||||
current += (target - current) * response_rate + gauss(0, noise_sigma)
|
||||
|
||||
This is a first-order lag toward ``target``. With ``response_rate=0.05``
|
||||
and a 1s tick, the current reading reaches ~halfway to target in ~14s;
|
||||
with ``response_rate=0.2`` it converges in ~5–10s.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
start_temp: float = 22.0,
|
||||
sample_period_s: float = 1.0,
|
||||
response_rate: float = 0.05,
|
||||
noise_sigma: float = 0.1,
|
||||
):
|
||||
"""Initialize the sensor.
|
||||
|
||||
Args:
|
||||
start_temp: Initial temperature and initial target (°C).
|
||||
sample_period_s: Seconds between successive readings.
|
||||
response_rate: Fraction of the gap toward target closed each tick
|
||||
(clamped to ``[0.0, 1.0]``).
|
||||
noise_sigma: Standard deviation of the Gaussian noise added to
|
||||
each reading.
|
||||
"""
|
||||
super().__init__()
|
||||
self._current = start_temp
|
||||
self._target = start_temp
|
||||
self._response_rate = max(0.0, min(1.0, response_rate))
|
||||
self._noise_sigma = noise_sigma
|
||||
self._sample_period_s = sample_period_s
|
||||
self._tick_task = None
|
||||
|
||||
@property
|
||||
def current(self) -> float:
|
||||
"""The most recent temperature reading (°C)."""
|
||||
return self._current
|
||||
|
||||
@property
|
||||
def target(self) -> float:
|
||||
"""The temperature the sensor is drifting toward (°C)."""
|
||||
return self._target
|
||||
|
||||
@property
|
||||
def response_rate(self) -> float:
|
||||
"""Fraction of the target-current gap closed per tick."""
|
||||
return self._response_rate
|
||||
|
||||
def set_target(self, value: float) -> None:
|
||||
"""Set a new target temperature (°C)."""
|
||||
self._target = value
|
||||
|
||||
def set_response_rate(self, rate: float) -> None:
|
||||
"""Set how aggressively the sensor approaches the target.
|
||||
|
||||
Args:
|
||||
rate: Fraction in ``[0.0, 1.0]``. Clamped to that range.
|
||||
"""
|
||||
self._response_rate = max(0.0, min(1.0, rate))
|
||||
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
if isinstance(frame, StartFrame) and self._tick_task is None:
|
||||
self._tick_task = self.create_task(self._tick_loop(), "ticker")
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
if self._tick_task is not None:
|
||||
await self.cancel_task(self._tick_task)
|
||||
self._tick_task = None
|
||||
await super().cleanup()
|
||||
|
||||
async def _tick_loop(self) -> None:
|
||||
import asyncio
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(self._sample_period_s)
|
||||
gap = self._target - self._current
|
||||
self._current += gap * self._response_rate + random.gauss(0, self._noise_sigma)
|
||||
await self.push_frame(
|
||||
SensorReadingFrame(temperature=self._current, timestamp=time.time()),
|
||||
FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
|
||||
|
||||
class SensorStats(FrameProcessor):
|
||||
"""Rolling-window statistics over :class:`SensorReadingFrame`s.
|
||||
|
||||
Consumes readings as they flow downstream and exposes rolling
|
||||
``min`` / ``max`` / ``avg`` / ``trend`` as properties — the worker
|
||||
LLM reads them directly when responding to the user.
|
||||
"""
|
||||
|
||||
def __init__(self, window: int = 30):
|
||||
"""Initialize the stats aggregator.
|
||||
|
||||
Args:
|
||||
window: Number of recent readings to retain.
|
||||
"""
|
||||
super().__init__()
|
||||
self._readings: deque[float] = deque(maxlen=window)
|
||||
|
||||
@property
|
||||
def current(self) -> float:
|
||||
"""The most recent reading, or 0.0 if none have been seen."""
|
||||
return self._readings[-1] if self._readings else 0.0
|
||||
|
||||
@property
|
||||
def min(self) -> float:
|
||||
return min(self._readings) if self._readings else 0.0
|
||||
|
||||
@property
|
||||
def max(self) -> float:
|
||||
return max(self._readings) if self._readings else 0.0
|
||||
|
||||
@property
|
||||
def avg(self) -> float:
|
||||
return sum(self._readings) / len(self._readings) if self._readings else 0.0
|
||||
|
||||
@property
|
||||
def trend(self) -> str:
|
||||
"""``"rising"`` / ``"falling"`` / ``"stable"`` based on first vs. last half of the window."""
|
||||
if len(self._readings) < 4:
|
||||
return "stable"
|
||||
half = len(self._readings) // 2
|
||||
old_avg = sum(list(self._readings)[:half]) / half
|
||||
new_avg = sum(list(self._readings)[half:]) / (len(self._readings) - half)
|
||||
diff = new_avg - old_avg
|
||||
if abs(diff) < 0.25:
|
||||
return "stable"
|
||||
return "rising" if diff > 0 else "falling"
|
||||
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
if isinstance(frame, SensorReadingFrame):
|
||||
self._readings.append(frame.temperature)
|
||||
await self.push_frame(frame, direction)
|
||||
Reference in New Issue
Block a user