Compare commits
167 Commits
pk/nova-so
...
pk/optiona
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fee91ddec | ||
|
|
638294c1cc | ||
|
|
ea96b7aec7 | ||
|
|
666c619113 | ||
|
|
797d09a1d5 | ||
|
|
ee1538d18e | ||
|
|
8330c3487d | ||
|
|
4479a3a6af | ||
|
|
8631518388 | ||
|
|
47e2f7a037 | ||
|
|
6d21507e95 | ||
|
|
5fef239b68 | ||
|
|
9148e307cc | ||
|
|
703d23b658 | ||
|
|
227ba288da | ||
|
|
3e8c5c08f4 | ||
|
|
644030584f | ||
|
|
0740021ff4 | ||
|
|
68f265fa62 | ||
|
|
b9f052079d | ||
|
|
130bb7371c | ||
|
|
5d61763987 | ||
|
|
7984556692 | ||
|
|
bea9e4b3ba | ||
|
|
19df443500 | ||
|
|
07f241143b | ||
|
|
2fdb9bbf42 | ||
|
|
0146947b68 | ||
|
|
e2bfa6352f | ||
|
|
abd28e2ac1 | ||
|
|
88deebbf5f | ||
|
|
c2bdc1aada | ||
|
|
fc0589e8f1 | ||
|
|
67f8d34e9f | ||
|
|
d3b8710720 | ||
|
|
86e2aa85d3 | ||
|
|
b89500256d | ||
|
|
a52bdef32b | ||
|
|
afd9fc5fdf | ||
|
|
7f98dba925 | ||
|
|
6a27ed35b1 | ||
|
|
a34864d643 | ||
|
|
007fa3a3a8 | ||
|
|
5dd7413c00 | ||
|
|
8e0a338d96 | ||
|
|
d65aee9181 | ||
|
|
1755016679 | ||
|
|
b7f6298601 | ||
|
|
396873ac7e | ||
|
|
5b33964a1b | ||
|
|
8b37cd1d3a | ||
|
|
7a2b667fa1 | ||
|
|
ee8c607315 | ||
|
|
71578e7151 | ||
|
|
77058b01c4 | ||
|
|
4f85e7c089 | ||
|
|
15531c8112 | ||
|
|
b9e8f13105 | ||
|
|
784667bad2 | ||
|
|
33db71ec32 | ||
|
|
dc035df0aa | ||
|
|
df1b071a13 | ||
|
|
95bcebe774 | ||
|
|
5509377344 | ||
|
|
e21180b962 | ||
|
|
53922819ed | ||
|
|
6faeffb884 | ||
|
|
9086a46900 | ||
|
|
1a4a6f4edf | ||
|
|
ff80cde44e | ||
|
|
fb74f7714c | ||
|
|
4864eddbc7 | ||
|
|
d831930bd0 | ||
|
|
2c65713c99 | ||
|
|
b14a03d01f | ||
|
|
ad0f0a1294 | ||
|
|
72d0fb418a | ||
|
|
94a94ee28c | ||
|
|
c46ede8335 | ||
|
|
457a68ce64 | ||
|
|
b78cecf7b2 | ||
|
|
952dddca8b | ||
|
|
e3e90d38aa | ||
|
|
d1c8162b0c | ||
|
|
1fa0310ea8 | ||
|
|
2281cd8359 | ||
|
|
480eca42f5 | ||
|
|
1073510574 | ||
|
|
47c05f3f30 | ||
|
|
24904b89f5 | ||
|
|
c78977e4c7 | ||
|
|
f78b5f9240 | ||
|
|
406f8b730b | ||
|
|
7a2cec2e45 | ||
|
|
edfcd6948b | ||
|
|
991ee9e0e6 | ||
|
|
a696729343 | ||
|
|
ba705e9501 | ||
|
|
98c370457b | ||
|
|
6189e920e1 | ||
|
|
73625a273a | ||
|
|
f91a55c97c | ||
|
|
5f256e241c | ||
|
|
954f63dc7b | ||
|
|
6cc66a3df1 | ||
|
|
a445399337 | ||
|
|
5ed2057599 | ||
|
|
cacde00e26 | ||
|
|
b1b598f65e | ||
|
|
c48ee93892 | ||
|
|
cf22dac171 | ||
|
|
36f6e22aee | ||
|
|
921a7a46cb | ||
|
|
fda18a9afa | ||
|
|
d146a7f8e0 | ||
|
|
90f0f7cd27 | ||
|
|
37376b3506 | ||
|
|
729418c2b7 | ||
|
|
4512038a17 | ||
|
|
a23baf9de6 | ||
|
|
d18fe7c39c | ||
|
|
41124dc494 | ||
|
|
95db08646c | ||
|
|
03e5ebb266 | ||
|
|
5daf267c11 | ||
|
|
1cb77b422a | ||
|
|
0c779b4c3d | ||
|
|
138991418a | ||
|
|
94e136a6b7 | ||
|
|
9598e262b5 | ||
|
|
8c3521f2e4 | ||
|
|
eda98fb13f | ||
|
|
3722ee223c | ||
|
|
2620e76dab | ||
|
|
2447db766e | ||
|
|
61a81ed87b | ||
|
|
735cd09c7e | ||
|
|
2616076bec | ||
|
|
40667e50fc | ||
|
|
e06e0c0282 | ||
|
|
84eefba4df | ||
|
|
fe3af5d9f7 | ||
|
|
7729eecfe4 | ||
|
|
fa31a2fd63 | ||
|
|
678d40e102 | ||
|
|
8becafee38 | ||
|
|
83190d38e9 | ||
|
|
7519c26ac5 | ||
|
|
b2b7e9ee6f | ||
|
|
e864d5778a | ||
|
|
89f10dd9a1 | ||
|
|
f67e3ef0b2 | ||
|
|
5b087d6aeb | ||
|
|
e780f759d0 | ||
|
|
35153de28e | ||
|
|
9886d72f5e | ||
|
|
90e6b51acd | ||
|
|
61acdba3ae | ||
|
|
f1a3ee97de | ||
|
|
b363b91d12 | ||
|
|
43abca0b06 | ||
|
|
30efd11e15 | ||
|
|
440738f727 | ||
|
|
7da94436f5 | ||
|
|
492c9702ee | ||
|
|
13643b192b | ||
|
|
6cab2ce3f7 |
1
.agents/skills/changelog
Symbolic link
1
.agents/skills/changelog
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.claude/skills/changelog
|
||||
1
.agents/skills/cleanup
Symbolic link
1
.agents/skills/cleanup
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.claude/skills/cleanup
|
||||
1
.agents/skills/code-review
Symbolic link
1
.agents/skills/code-review
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.claude/skills/code-review
|
||||
1
.agents/skills/docstring
Symbolic link
1
.agents/skills/docstring
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.claude/skills/docstring
|
||||
1
.agents/skills/pr-description
Symbolic link
1
.agents/skills/pr-description
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.claude/skills/pr-description
|
||||
1
.agents/skills/pr-submit
Symbolic link
1
.agents/skills/pr-submit
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.claude/skills/pr-submit
|
||||
1
.agents/skills/update-docs
Symbolic link
1
.agents/skills/update-docs
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.claude/skills/update-docs
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: cleanup
|
||||
description: Review, refactor, document, and validate code changes in the current branch
|
||||
---
|
||||
|
||||
# Code Cleanup Skill
|
||||
|
||||
The **Code Cleanup Skill** reviews, refactors, and documents code changes in your current branch, ensuring alignment with **Pipecat's architecture, coding standards, and example patterns**.
|
||||
|
||||
1
.github/workflows/coverage.yaml
vendored
1
.github/workflows/coverage.yaml
vendored
@@ -42,6 +42,7 @@ jobs:
|
||||
--extra langchain \
|
||||
--extra livekit \
|
||||
--extra piper \
|
||||
--extra runner \
|
||||
--extra sagemaker \
|
||||
--extra tracing \
|
||||
--extra websocket
|
||||
|
||||
1
.github/workflows/tests.yaml
vendored
1
.github/workflows/tests.yaml
vendored
@@ -46,6 +46,7 @@ jobs:
|
||||
--extra langchain \
|
||||
--extra livekit \
|
||||
--extra piper \
|
||||
--extra runner \
|
||||
--extra sagemaker \
|
||||
--extra tracing \
|
||||
--extra websocket
|
||||
|
||||
174
AGENTS.md
Normal file
174
AGENTS.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to AI coding agents when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Pipecat is an open-source Python framework for building real-time voice and multimodal conversational AI agents. It orchestrates audio/video, AI services, transports, and conversation pipelines using a frame-based architecture.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Setup development environment
|
||||
uv sync --group dev --all-extras --no-extra gstreamer --no-extra local
|
||||
|
||||
# Install pre-commit hooks
|
||||
uv run pre-commit install
|
||||
|
||||
# Run all tests
|
||||
uv run pytest
|
||||
|
||||
# Run a single test file
|
||||
uv run pytest tests/test_name.py
|
||||
|
||||
# Run a specific test
|
||||
uv run pytest tests/test_name.py::test_function_name
|
||||
|
||||
# Preview changelog
|
||||
uv run towncrier build --draft --version Unreleased
|
||||
|
||||
# Lint and format check
|
||||
uv run ruff check
|
||||
uv run ruff format --check
|
||||
|
||||
# Update dependencies (after editing pyproject.toml)
|
||||
uv lock && uv sync
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frame-Based Pipeline Processing
|
||||
|
||||
All data flows as **Frame** objects through a pipeline of **FrameProcessors**:
|
||||
|
||||
```
|
||||
[Processor1] → [Processor2] → ... → [ProcessorN]
|
||||
```
|
||||
|
||||
**Key components:**
|
||||
|
||||
- **Frames** (`src/pipecat/frames/frames.py`): Data units (audio, text, video) and control signals. Flow DOWNSTREAM (input→output) or UPSTREAM (acknowledgments/errors).
|
||||
|
||||
- **FrameProcessor** (`src/pipecat/processors/frame_processor.py`): Base processing unit. Each processor receives frames, processes them, and pushes results downstream.
|
||||
|
||||
- **Pipeline** (`src/pipecat/pipeline/pipeline.py`): Chains processors together.
|
||||
|
||||
- **ParallelPipeline** (`src/pipecat/pipeline/parallel_pipeline.py`): Runs multiple pipelines in parallel.
|
||||
|
||||
- **Transports** (`src/pipecat/transports/`): Transports are frame processors used for external I/O layer (Daily WebRTC, LiveKit WebRTC, WebSocket, Local). Abstract interface via `BaseTransport`, `BaseInputTransport` and `BaseOutputTransport`.
|
||||
|
||||
- **Pipeline Task (`src/pipecat/pipeline/task.py`)**: Runs and manages a pipeline. Pipeline tasks send the first frame, `StartFrame`, to the pipeline in order for processors to know they can start processing and pushing frames. Pipeline tasks internally create a pipeline with two additional processors, a source processor before the user-defined pipeline and a sink processor at the end. Those are used for multiple things: error handling, pipeline task level events, heartbeat monitoring, etc.
|
||||
|
||||
- **Pipeline Runner (`src/pipecat/pipeline/runner.py`)**: High-level entry point for executing pipeline tasks. Handles signal management (SIGINT/SIGTERM) for graceful shutdown and optional garbage collection. Run a single pipeline task with `await runner.run(task)` or multiple concurrently with `await asyncio.gather(runner.run(task1), runner.run(task2))`.
|
||||
|
||||
- **Services** (`src/pipecat/services/`): 60+ AI provider integrations (STT, TTS, LLM, etc.). Extend base classes: `AIService`, `LLMService`, `STTService`, `TTSService`, `VisionService`.
|
||||
|
||||
- **Serializers** (`src/pipecat/serializers/`): Convert frames to/from wire formats for WebSocket transports. `FrameSerializer` base class defines `serialize()` and `deserialize()`. Telephony serializers (Twilio, Plivo, Vonage, Telnyx, Exotel, Genesys) handle provider-specific protocols and audio encoding (e.g., μ-law).
|
||||
|
||||
- **RTVI** (`src/pipecat/processors/frameworks/rtvi.py`): Real-Time Voice Interface protocol bridging clients and the pipeline. `RTVIProcessor` handles incoming client messages (text input, audio, function call results). `RTVIObserver` converts pipeline frames to outgoing messages: user/bot speaking events, transcriptions, LLM/TTS lifecycle, function calls, metrics, and audio levels.
|
||||
|
||||
- **Observers** (`src/pipecat/observers/`): Monitor frame flow without modifying the pipeline. Passed to `PipelineTask` via the `observers` parameter. Implement `on_process_frame()` and `on_push_frame()` callbacks.
|
||||
|
||||
### Important Patterns
|
||||
|
||||
- **Context Aggregation**: `LLMContext` accumulates messages for LLM calls; `UserResponse` aggregates user input
|
||||
|
||||
- **Turn Management**: Turn management is done through `LLMUserAggregator` and
|
||||
`LLMAssistantAggregator`, created with `LLMContextAggregatorPair`
|
||||
|
||||
- **User turn strategies**: Detection of when the user starts and stops speaking is done via user turn start/stop strategies. They push `UserStartedSpeakingFrame` and `UserStoppedSpeakingFrame` respectively.
|
||||
|
||||
- **Interruptions**: Interruptions are usually triggered by a user turn start strategy (e.g. `VADUserTurnStartStrategy`) but they can be triggered by other processors as well, in which case the user turn start strategies don't need to. An `InterruptionFrame` carries an optional `asyncio.Event` that is set when the frame reaches the pipeline sink. If a processor stops an `InterruptionFrame` from propagating downstream (i.e., doesn't push it), it **must** call `frame.complete()` to avoid stalling `push_interruption_task_frame_and_wait()` callers.
|
||||
|
||||
- **Uninterruptible Frames**: These are frames that will not be removed from internal queues even if there's an interruption. For example, `EndFrame` and `StopFrame`.
|
||||
|
||||
- **Events**: Most classes in Pipecat have `BaseObject` as the very base class. `BaseObject` has support for events. Events can run in the background in an async task (default) or synchronously (`sync=True`) if we want immediate action. Synchronous event handlers need to execute fast.
|
||||
|
||||
- **Async Task Management**: Always use `self.create_task(coroutine, name)` instead of raw `asyncio.create_task()`. The `TaskManager` automatically tracks tasks and cleans them up on processor shutdown. Use `await self.cancel_task(task, timeout)` for cancellation.
|
||||
|
||||
- **Error Handling**: Use `await self.push_error(msg, exception, fatal)` to push errors upstream. Services should use `fatal=False` (the default) so application code can handle errors and take action (e.g. switch to another service).
|
||||
|
||||
### Key Directories
|
||||
|
||||
| Directory | Purpose |
|
||||
| -------------------------- | -------------------------------------------------- |
|
||||
| `src/pipecat/frames/` | Frame definitions (100+ types) |
|
||||
| `src/pipecat/processors/` | FrameProcessor base + aggregators, filters, audio |
|
||||
| `src/pipecat/pipeline/` | Pipeline orchestration |
|
||||
| `src/pipecat/services/` | AI service integrations (60+ providers) |
|
||||
| `src/pipecat/transports/` | Transport layer (Daily, LiveKit, WebSocket, Local) |
|
||||
| `src/pipecat/serializers/` | Frame serialization for WebSocket protocols |
|
||||
| `src/pipecat/observers/` | Pipeline observers for monitoring frame flow |
|
||||
| `src/pipecat/audio/` | VAD, filters, mixers, turn detection, DTMF |
|
||||
| `src/pipecat/turns/` | User turn management |
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Docstrings**: Google-style. Classes describe purpose; `__init__` has `Args:` section; dataclasses use `Parameters:` section.
|
||||
- **Deprecations**: Use the `.. deprecated:: <version>` Sphinx directive in docstrings (never inline tags like `[DEPRECATED]`), and pair it with a runtime `warnings.warn(..., DeprecationWarning)` at the call site. See `CONTRIBUTING.md` for full conventions.
|
||||
- **Linting**: Ruff (line length 100). Pre-commit hooks enforce formatting.
|
||||
- **Type hints**: Required for complex async code.
|
||||
- **Dataclass vs Pydantic**: Use `@dataclass` for frames and internal pipeline data (high-frequency, no validation needed). Use Pydantic `BaseModel` for configuration, parameters, metrics, and external API data (benefits from validation and serialization). Specifically:
|
||||
- `@dataclass`: Frame types, context aggregator pairs, internal data containers
|
||||
- `BaseModel`: Service `InputParams`, transport/VAD/turn params, metrics data, API request/response models, serializer params
|
||||
|
||||
### Docstring Example
|
||||
|
||||
```python
|
||||
class MyService(LLMService):
|
||||
"""Description of what the service does.
|
||||
|
||||
More detailed description.
|
||||
|
||||
Event handlers available:
|
||||
|
||||
- on_connected: Called when we are connected
|
||||
|
||||
Example::
|
||||
|
||||
@service.event_handler("on_connected")
|
||||
async def on_connected(service, frame):
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(self, param1: str, **kwargs):
|
||||
"""Initialize the service.
|
||||
|
||||
Args:
|
||||
param1: Description of param1.
|
||||
**kwargs: Additional arguments passed to parent.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
# Pydantic params class with a deprecated field
|
||||
class MyParams(BaseModel):
|
||||
"""Configuration parameters for MyService.
|
||||
|
||||
Parameters:
|
||||
new_setting: Replacement for ``old_setting``.
|
||||
old_setting: Legacy setting, no longer used.
|
||||
|
||||
.. deprecated:: 1.2.0
|
||||
Use ``new_setting`` instead. Will be removed in 2.0.0.
|
||||
"""
|
||||
|
||||
new_setting: str = "default"
|
||||
old_setting: str | None = None
|
||||
```
|
||||
|
||||
## Service Implementation
|
||||
|
||||
When adding a new service:
|
||||
|
||||
1. Extend the appropriate base class (`STTService`, `TTSService`, `LLMService`, etc.)
|
||||
2. Implement required abstract methods
|
||||
3. Handle necessary frames
|
||||
4. By default, all frames should be pushed in the direction they came
|
||||
5. Push `ErrorFrame` on failures
|
||||
6. Add metrics tracking via `MetricsData` if relevant
|
||||
7. Follow the pattern of existing services in `src/pipecat/services/`
|
||||
|
||||
## Testing
|
||||
|
||||
Test utilities live in `src/pipecat/tests/utils.py`. Use `run_test()` to send frames through a pipeline and assert expected output frames in each direction. Use `SleepFrame(sleep=N)` to add delays between frames.
|
||||
158
CLAUDE.md
158
CLAUDE.md
@@ -1,157 +1 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Pipecat is an open-source Python framework for building real-time voice and multimodal conversational AI agents. It orchestrates audio/video, AI services, transports, and conversation pipelines using a frame-based architecture.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Setup development environment
|
||||
uv sync --group dev --all-extras --no-extra gstreamer --no-extra local
|
||||
|
||||
# Install pre-commit hooks
|
||||
uv run pre-commit install
|
||||
|
||||
# Run all tests
|
||||
uv run pytest
|
||||
|
||||
# Run a single test file
|
||||
uv run pytest tests/test_name.py
|
||||
|
||||
# Run a specific test
|
||||
uv run pytest tests/test_name.py::test_function_name
|
||||
|
||||
# Preview changelog
|
||||
uv run towncrier build --draft --version Unreleased
|
||||
|
||||
# Lint and format check
|
||||
uv run ruff check
|
||||
uv run ruff format --check
|
||||
|
||||
# Update dependencies (after editing pyproject.toml)
|
||||
uv lock && uv sync
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frame-Based Pipeline Processing
|
||||
|
||||
All data flows as **Frame** objects through a pipeline of **FrameProcessors**:
|
||||
|
||||
```
|
||||
[Processor1] → [Processor2] → ... → [ProcessorN]
|
||||
```
|
||||
|
||||
**Key components:**
|
||||
|
||||
- **Frames** (`src/pipecat/frames/frames.py`): Data units (audio, text, video) and control signals. Flow DOWNSTREAM (input→output) or UPSTREAM (acknowledgments/errors).
|
||||
|
||||
- **FrameProcessor** (`src/pipecat/processors/frame_processor.py`): Base processing unit. Each processor receives frames, processes them, and pushes results downstream.
|
||||
|
||||
- **Pipeline** (`src/pipecat/pipeline/pipeline.py`): Chains processors together.
|
||||
|
||||
- **ParallelPipeline** (`src/pipecat/pipeline/parallel_pipeline.py`): Runs multiple pipelines in parallel.
|
||||
|
||||
- **Transports** (`src/pipecat/transports/`): Transports are frame processors used for external I/O layer (Daily WebRTC, LiveKit WebRTC, WebSocket, Local). Abstract interface via `BaseTransport`, `BaseInputTransport` and `BaseOutputTransport`.
|
||||
|
||||
- **Pipeline Task (`src/pipecat/pipeline/task.py`)**: Runs and manages a pipeline. Pipeline tasks send the first frame, `StartFrame`, to the pipeline in order for processors to know they can start processing and pushing frames. Pipeline tasks internally create a pipeline with two additional processors, a source processor before the user-defined pipeline and a sink processor at the end. Those are used for multiple things: error handling, pipeline task level events, heartbeat monitoring, etc.
|
||||
|
||||
- **Pipeline Runner (`src/pipecat/pipeline/runner.py`)**: High-level entry point for executing pipeline tasks. Handles signal management (SIGINT/SIGTERM) for graceful shutdown and optional garbage collection. Run a single pipeline task with `await runner.run(task)` or multiple concurrently with `await asyncio.gather(runner.run(task1), runner.run(task2))`.
|
||||
|
||||
- **Services** (`src/pipecat/services/`): 60+ AI provider integrations (STT, TTS, LLM, etc.). Extend base classes: `AIService`, `LLMService`, `STTService`, `TTSService`, `VisionService`.
|
||||
|
||||
- **Serializers** (`src/pipecat/serializers/`): Convert frames to/from wire formats for WebSocket transports. `FrameSerializer` base class defines `serialize()` and `deserialize()`. Telephony serializers (Twilio, Plivo, Vonage, Telnyx, Exotel, Genesys) handle provider-specific protocols and audio encoding (e.g., μ-law).
|
||||
|
||||
- **RTVI** (`src/pipecat/processors/frameworks/rtvi.py`): Real-Time Voice Interface protocol bridging clients and the pipeline. `RTVIProcessor` handles incoming client messages (text input, audio, function call results). `RTVIObserver` converts pipeline frames to outgoing messages: user/bot speaking events, transcriptions, LLM/TTS lifecycle, function calls, metrics, and audio levels.
|
||||
|
||||
- **Observers** (`src/pipecat/observers/`): Monitor frame flow without modifying the pipeline. Passed to `PipelineTask` via the `observers` parameter. Implement `on_process_frame()` and `on_push_frame()` callbacks.
|
||||
|
||||
### Important Patterns
|
||||
|
||||
- **Context Aggregation**: `LLMContext` accumulates messages for LLM calls; `UserResponse` aggregates user input
|
||||
|
||||
- **Turn Management**: Turn management is done through `LLMUserAggregator` and
|
||||
`LLMAssistantAggregator`, created with `LLMContextAggregatorPair`
|
||||
|
||||
- **User turn strategies**: Detection of when the user starts and stops speaking is done via user turn start/stop strategies. They push `UserStartedSpeakingFrame` and `UserStoppedSpeakingFrame` respectively.
|
||||
|
||||
- **Interruptions**: Interruptions are usually triggered by a user turn start strategy (e.g. `VADUserTurnStartStrategy`) but they can be triggered by other processors as well, in which case the user turn start strategies don't need to. An `InterruptionFrame` carries an optional `asyncio.Event` that is set when the frame reaches the pipeline sink. If a processor stops an `InterruptionFrame` from propagating downstream (i.e., doesn't push it), it **must** call `frame.complete()` to avoid stalling `push_interruption_task_frame_and_wait()` callers.
|
||||
|
||||
- **Uninterruptible Frames**: These are frames that will not be removed from internal queues even if there's an interruption. For example, `EndFrame` and `StopFrame`.
|
||||
|
||||
- **Events**: Most classes in Pipecat have `BaseObject` as the very base class. `BaseObject` has support for events. Events can run in the background in an async task (default) or synchronously (`sync=True`) if we want immediate action. Synchronous event handlers need to execute fast.
|
||||
|
||||
- **Async Task Management**: Always use `self.create_task(coroutine, name)` instead of raw `asyncio.create_task()`. The `TaskManager` automatically tracks tasks and cleans them up on processor shutdown. Use `await self.cancel_task(task, timeout)` for cancellation.
|
||||
|
||||
- **Error Handling**: Use `await self.push_error(msg, exception, fatal)` to push errors upstream. Services should use `fatal=False` (the default) so application code can handle errors and take action (e.g. switch to another service).
|
||||
|
||||
### Key Directories
|
||||
|
||||
| Directory | Purpose |
|
||||
| -------------------------- | -------------------------------------------------- |
|
||||
| `src/pipecat/frames/` | Frame definitions (100+ types) |
|
||||
| `src/pipecat/processors/` | FrameProcessor base + aggregators, filters, audio |
|
||||
| `src/pipecat/pipeline/` | Pipeline orchestration |
|
||||
| `src/pipecat/services/` | AI service integrations (60+ providers) |
|
||||
| `src/pipecat/transports/` | Transport layer (Daily, LiveKit, WebSocket, Local) |
|
||||
| `src/pipecat/serializers/` | Frame serialization for WebSocket protocols |
|
||||
| `src/pipecat/observers/` | Pipeline observers for monitoring frame flow |
|
||||
| `src/pipecat/audio/` | VAD, filters, mixers, turn detection, DTMF |
|
||||
| `src/pipecat/turns/` | User turn management |
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Docstrings**: Google-style. Classes describe purpose; `__init__` has `Args:` section; dataclasses use `Parameters:` section.
|
||||
- **Linting**: Ruff (line length 100). Pre-commit hooks enforce formatting.
|
||||
- **Type hints**: Required for complex async code.
|
||||
- **Dataclass vs Pydantic**: Use `@dataclass` for frames and internal pipeline data (high-frequency, no validation needed). Use Pydantic `BaseModel` for configuration, parameters, metrics, and external API data (benefits from validation and serialization). Specifically:
|
||||
- `@dataclass`: Frame types, context aggregator pairs, internal data containers
|
||||
- `BaseModel`: Service `InputParams`, transport/VAD/turn params, metrics data, API request/response models, serializer params
|
||||
|
||||
### Docstring Example
|
||||
|
||||
```python
|
||||
class MyService(LLMService):
|
||||
"""Description of what the service does.
|
||||
|
||||
More detailed description.
|
||||
|
||||
Event handlers available:
|
||||
|
||||
- on_connected: Called when we are connected
|
||||
|
||||
Example::
|
||||
|
||||
@service.event_handler("on_connected")
|
||||
async def on_connected(service, frame):
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(self, param1: str, **kwargs):
|
||||
"""Initialize the service.
|
||||
|
||||
Args:
|
||||
param1: Description of param1.
|
||||
**kwargs: Additional arguments passed to parent.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
```
|
||||
|
||||
## Service Implementation
|
||||
|
||||
When adding a new service:
|
||||
|
||||
1. Extend the appropriate base class (`STTService`, `TTSService`, `LLMService`, etc.)
|
||||
2. Implement required abstract methods
|
||||
3. Handle necessary frames
|
||||
4. By default, all frames should be pushed in the direction they came
|
||||
5. Push `ErrorFrame` on failures
|
||||
6. Add metrics tracking via `MetricsData` if relevant
|
||||
7. Follow the pattern of existing services in `src/pipecat/services/`
|
||||
|
||||
## Testing
|
||||
|
||||
Test utilities live in `src/pipecat/tests/utils.py`. Use `run_test()` to send frames through a pipeline and assert expected output frames in each direction. Use `SleepFrame(sleep=N)` to add delays between frames.
|
||||
@AGENTS.md
|
||||
|
||||
1
changelog/4397.changed.md
Normal file
1
changelog/4397.changed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Lowered the per-message log in `SmallWebRTCInputTransport._handle_app_message` from `debug` to `trace`. App messages can be high-frequency and were noisy at debug level; set the loguru level to `TRACE` to see them again.
|
||||
1
changelog/4401.changed.md
Normal file
1
changelog/4401.changed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Changed the default model for `GrokRealtimeLLMService` to `grok-voice-think-fast-1.0`, xAI's recommended Voice Agent model. The previous default of `grok-voice-fast-1.0` has been deprecated by xAI and is being removed.
|
||||
1
changelog/4401.fixed.md
Normal file
1
changelog/4401.fixed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Fixed `GrokRealtimeLLMService` ignoring the configured model. The model was stored in `Settings` but never sent to xAI, so every session silently fell back to xAI's server-side default. The model is now passed via the `?model=` query parameter on the WebSocket URL as xAI's Voice Agent API requires.
|
||||
1
changelog/4404.added.md
Normal file
1
changelog/4404.added.md
Normal file
@@ -0,0 +1 @@
|
||||
- Added an opt-in `add_tool_change_messages` flag to the LLM aggregators (set via `LLMContextAggregatorPair(..., add_tool_change_messages=True)`) that appends a developer-role message to the context whenever `LLMSetToolsFrame` changes the set of advertised standard tools. Helps the LLM stay coherent across mid-conversation tool changes, mitigating several flavors of tool-call-related hallucination: calling tools that have been removed, avoiding tools that have been re-added, and hallucinating output (made-up answers or tool-call-shaped non-tool-calls) when tools are unavailable.
|
||||
1
changelog/4405.added.2.md
Normal file
1
changelog/4405.added.2.md
Normal file
@@ -0,0 +1 @@
|
||||
- Added `LLMTurnCompletionUserTurnStopStrategy` in `pipecat.turns.user_stop`. When installed, the strategy gates `on_user_turn_stopped` on a `UserTurnInferenceCompletedFrame` (a new fieldless system frame emitted by any component that can judge turn completeness — e.g. the `UserTurnCompletionLLMServiceMixin` on `✓`). A `finalization_timeout` provides a safety net if no completion frame ever arrives.
|
||||
1
changelog/4405.added.3.md
Normal file
1
changelog/4405.added.3.md
Normal file
@@ -0,0 +1 @@
|
||||
- Added `deferred(strategy)` and `DeferredUserTurnStopStrategy` in `pipecat.turns.user_stop`. Wraps a stop strategy so it fires only the inference-triggered event and suppresses `on_user_turn_stopped`, leaving finalization to another strategy in the chain such as `LLMTurnCompletionUserTurnStopStrategy`.
|
||||
1
changelog/4405.added.4.md
Normal file
1
changelog/4405.added.4.md
Normal file
@@ -0,0 +1 @@
|
||||
- Added `FilterIncompleteUserTurnStrategies` in `pipecat.turns.user_turn_strategies` — a `UserTurnStrategies` specialization that wraps the detector chain with `deferred(...)` and appends `LLMTurnCompletionUserTurnStopStrategy` as the finalizer. Common case: `user_turn_strategies=FilterIncompleteUserTurnStrategies()`. Pass `config=UserTurnCompletionConfig(...)` to customize timeouts and prompts.
|
||||
1
changelog/4405.added.5.md
Normal file
1
changelog/4405.added.5.md
Normal file
@@ -0,0 +1 @@
|
||||
- Added `ExternalUserTurnCompletionStopStrategy` in `pipecat.turns.user_stop` — a generic stop strategy that finalizes the user turn whenever a `UserTurnInferenceCompletedFrame` arrives, regardless of which component produced it. `LLMTurnCompletionUserTurnStopStrategy` now extends this base; future producers (Flux, custom end-of-turn classifiers, etc.) can use the base directly or subclass it to add producer-specific setup.
|
||||
1
changelog/4405.added.md
Normal file
1
changelog/4405.added.md
Normal file
@@ -0,0 +1 @@
|
||||
- Added `on_user_turn_inference_triggered`, a new event on the user turn controller, processor, aggregator and stop strategies that fires when a strategy has enough signal to start LLM inference. By default it fires together with `on_user_turn_stopped`; a gating strategy can fire only the inference-triggered event and defer finalization to a peer.
|
||||
1
changelog/4405.deprecated.md
Normal file
1
changelog/4405.deprecated.md
Normal file
@@ -0,0 +1 @@
|
||||
- Deprecated `LLMUserAggregatorParams.filter_incomplete_user_turns`. Use `user_turn_strategies=FilterIncompleteUserTurnStrategies()` (or add `LLMTurnCompletionUserTurnStopStrategy` to a custom `user_turn_strategies.stop`) instead. Setting the legacy flag still works for one release: the aggregator emits a `DeprecationWarning` and rewires the strategies as if you had passed `FilterIncompleteUserTurnStrategies` directly.
|
||||
1
changelog/4405.fixed.md
Normal file
1
changelog/4405.fixed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Fixed `on_user_turn_stopped` firing prematurely when `filter_incomplete_user_turns` was enabled. The event now fires only after the LLM confirms the user turn is complete (`✓`); previously the smart-turn detector's tentative stop was bubbling up before the LLM had a chance to veto it, causing observers, transcript appenders and UI indicators to receive an early — and sometimes duplicated — signal.
|
||||
6
changelog/4407.added.md
Normal file
6
changelog/4407.added.md
Normal file
@@ -0,0 +1,6 @@
|
||||
- Added first-class RTVI support for the UI Agent Protocol:
|
||||
- Adds `ui-event`, `ui-snapshot`, and `ui-cancel-task` client-to-server messages, plus `ui-command` and `ui-task` server-to-client messages, with paired `*Data` / `*Message` pydantic models.
|
||||
- Adds built-in command payload models for `Toast`, `Navigate`, `ScrollTo`, `Highlight`, `Focus`, `Click`, `SetInputValue`, and `SelectText`; matching default handlers live in `@pipecat-ai/client-react`.
|
||||
- Adds `RTVIProcessor.on_ui_message` for inbound `ui-event`, `ui-snapshot`, and `ui-cancel-task` messages.
|
||||
- Adds five UI pipeline frames, mirroring the `client-message` frame-and-event pattern: downstream code pushes `RTVIUICommandFrame` / `RTVIUITaskFrame` for the observer to wrap into outbound `UICommandMessage` / `UITaskMessage` envelopes, while the processor pushes inbound `RTVIUIEventFrame`, `RTVIUISnapshotFrame`, and `RTVIUICancelTaskFrame` alongside `on_ui_message`.
|
||||
- Bumps the RTVI `PROTOCOL_VERSION` from `1.2.0` to `1.3.0`.
|
||||
1
changelog/4414.fixed.md
Normal file
1
changelog/4414.fixed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Fixed `TTSSpeakFrame(append_to_context=True)` greetings sometimes splitting across two assistant messages in the LLM context and not surfacing in `on_assistant_turn_stopped`. The `LLMAssistantPushAggregationFrame` emitted at the end of a TTS context now carries a PTS just past the last word so it can't overtake clock-queued `TTSTextFrame`s in the transport's output, and `LLMAssistantAggregator` now triggers `on_assistant_turn_started`/`on_assistant_turn_stopped` when it receives the frame outside an LLM response cycle (restoring v0.0.104 behavior for greeting transcripts).
|
||||
1
changelog/4415.fixed.md
Normal file
1
changelog/4415.fixed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Fixed `ElevenLabsTTSService` and `ElevenLabsHttpTTSService` producing merged words (e.g. `bookLook`) when using Flash models. Flash often splits sentences mid-stream into alignment chunks that begin with a real inter-word space, but the previous fix unconditionally stripped that space from every chunk. Leading spaces are now stripped only on the first alignment chunk of an utterance, so subsequent chunks correctly flush partial words across boundaries.
|
||||
1
changelog/4416.added.md
Normal file
1
changelog/4416.added.md
Normal file
@@ -0,0 +1 @@
|
||||
- AWS Transcribe STT, Polly TTS, Bedrock LLM, and the Bedrock AgentCore processor now resolve credentials via the standard boto3 provider chain (EC2 instance profiles, EKS pod roles / IRSA, ECS task roles, SSO, `~/.aws/credentials`) when explicit credentials and `AWS_*` environment variables are absent. Services running with IAM roles no longer need to export static credentials.
|
||||
1
changelog/4416.fixed.md
Normal file
1
changelog/4416.fixed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Fixed AWS Polly TTS, Bedrock LLM, and the Bedrock AgentCore processor erroring out when only one of `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` was set in the environment. The half-populated kwargs are no longer forwarded to aioboto3; partial env-var configurations now fall through to the boto3 credential chain like fully-unset configurations do.
|
||||
1
changelog/4417.security.md
Normal file
1
changelog/4417.security.md
Normal file
@@ -0,0 +1 @@
|
||||
- Fixed a path traversal issue in the development runner's `/files/{filename:path}` download endpoint. Previously, when the runner was started with `--folder`, a request like `/files/..%2F..%2Fetc%2Fpasswd` could escape the configured folder because `%2F`-encoded separators bypassed Starlette's path normalisation. The endpoint now resolves the joined path and rejects any filename that escapes the allowed base with a 403, and also returns 404 (instead of an implicit `null` 200) when `--folder` is unset.
|
||||
1
changelog/4422.changed.md
Normal file
1
changelog/4422.changed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Changed the default Inworld TTS model from `inworld-tts-1.5-max` to `inworld-tts-2` (Realtime TTS-2) across `InworldHttpTTSService`, `InworldTTSService`, and the `InworldRealtimeLLMService` cascade. Existing users can pin the prior model explicitly via the `model`/`tts_model` argument; both `inworld-tts-1.5-max` and `inworld-tts-1.5-mini` remain valid model IDs.
|
||||
1
changelog/4424.fixed.md
Normal file
1
changelog/4424.fixed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Fixed `ElevenLabsTTSService` and `ElevenLabsHttpTTSService` writing romanized/normalized text to the LLM context. With non-Latin input (e.g., Chinese), the assistant transcript was getting populated with pinyin (`Ni Hao !` instead of `你好!`), which then degraded subsequent LLM turns. The services now consume `alignment` by default and only switch to `normalizedAlignment` / `normalized_alignment` when `pronunciation_dictionary_locators` is configured (where `alignment` has overlapping restarts that produce duplicated/garbled words, per #4316). Both fields are read with preferred-with-fallback semantics since each is nullable per the API schema.
|
||||
1
changelog/4426.added.md
Normal file
1
changelog/4426.added.md
Normal file
@@ -0,0 +1 @@
|
||||
- Added `keyterms` support to ElevenLabs STT services so Scribe V2 callers can bias transcription for both file-based and realtime transcription.
|
||||
1
changelog/4428.deprecated.md
Normal file
1
changelog/4428.deprecated.md
Normal file
@@ -0,0 +1 @@
|
||||
- Deprecated `ResampyResampler` in favor of `SOXRAudioResampler` (or the `create_file_resampler()` / `create_stream_resampler()` factories). Instantiating `ResampyResampler` now emits a `DeprecationWarning`. The class will be removed in Pipecat 2.0 along with the default `resampy` and `numba` dependencies.
|
||||
1
changelog/4429.changed.md
Normal file
1
changelog/4429.changed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Changed the default model for `GrokLLMService` from `grok-3` to `grok-4.20-non-reasoning`. xAI is retiring `grok-3` on May 15, 2026.
|
||||
1
changelog/4430.added.md
Normal file
1
changelog/4430.added.md
Normal file
@@ -0,0 +1 @@
|
||||
- Added `watchdog_min_timeout` parameter to `DeepgramFluxSTT` and `DeepgramFluxSageMakerSTT` (default `0.5` seconds) to control the minimum silence duration before the watchdog sends a silence packet to prevent dangling turns. The actual threshold is `max(chunk_duration * 2, watchdog_min_timeout)`, so it also adapts automatically to the audio chunk size in use.
|
||||
1
changelog/4430.changed.md
Normal file
1
changelog/4430.changed.md
Normal file
@@ -0,0 +1 @@
|
||||
- `DeepgramFluxSTT` watchdog silence threshold is now dynamic: `max(chunk_duration * 2, watchdog_min_timeout)` instead of a fixed 500 ms. This prevents false silence injections when large audio chunks are sent at lower frequency.
|
||||
1
changelog/4431.fixed.md
Normal file
1
changelog/4431.fixed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Fixed a deadlock in `TTSService` that could permanently stall pipeline processing when all three conditions occurred together: `pause_frame_processing=True`, an interruption arrived before any TTS audio was played, and an `UninterruptibleFrame` (e.g. `TTSUpdateSettingsFrame`, `FunctionCallResultFrame`) was in the processing queue at that moment. The process task would block on `__process_event.wait()` indefinitely because `BotStoppedSpeakingFrame` never arrives (no audio was played) and the interruption handler did not resume processing. Affects services using `pause_frame_processing=True` such as ElevenLabs, Rime, AsyncAI, Gradium, and ResembleAI.
|
||||
1
changelog/4433.changed.md
Normal file
1
changelog/4433.changed.md
Normal file
@@ -0,0 +1 @@
|
||||
- `ElevenLabsTTSService` now sends `close_context` to the server as soon as the turn is complete (on `on_turn_context_completed`) rather than waiting until all audio has finished playing back. The `isFinal` message from ElevenLabs is now used to signal `TTSStoppedFrame` and clean up the audio context, improving turn transition timing.
|
||||
1
changelog/4434.fixed.md
Normal file
1
changelog/4434.fixed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Fixed interruptions being delayed when a slow non-uninterruptible frame was processing and an uninterruptible frame was waiting in the queue. The bot would stall until the slow frame finished instead of cancelling it immediately on interruption.
|
||||
1
changelog/4435.fixed.md
Normal file
1
changelog/4435.fixed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Fixed `TTSService` dropping uninterruptible frames (e.g. `FunctionCallResultFrame`) from its internal serialization queue when an interruption occurs. Previously, the queue was recreated on every interruption, silently discarding any queued frames. The queue is now reset instead of recreated, preserving uninterruptible frames so they are always delivered downstream.
|
||||
1
changelog/4440.fixed.md
Normal file
1
changelog/4440.fixed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Fixed a race condition in the Daily transport that caused `AttributeError: 'NoneType' object has no attribute 'send_app_message'` when tearing down a pipeline. Both `DailyInputTransport` and `DailyOutputTransport` share the same `DailyTransportClient` and both call `cleanup()`, which was releasing the underlying `CallClient` on the first call — leaving the second caller with a `None` client.
|
||||
1
changelog/4441.fixed.md
Normal file
1
changelog/4441.fixed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Restored `cancel_on_interruption=False` support for `AWSNovaSonicLLMService` and `OpenAIRealtimeLLMService`. These services previously honored the flag by simply not cancelling in-flight function calls on interruption; the introduction of the new async-tool mechanism (which threads started/intermediate/final messages through the LLM context) broke that path because the realtime services didn't know how to interpret those messages. Note that new-style streamed intermediate results (`FunctionCallResultProperties(is_final=False)`) are not supported on these realtime services. Similar fixes for other impacted realtime services are forthcoming.
|
||||
1
changelog/4443.fixed.md
Normal file
1
changelog/4443.fixed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Fixed two misspelled Gemini TTS voice names in `GeminiTTSService.AVAILABLE_VOICES`.
|
||||
1
changelog/4446.change.md
Normal file
1
changelog/4446.change.md
Normal file
@@ -0,0 +1 @@
|
||||
- Updated `InworldHttpTTSService` and `InworldTTSService` to use PCM audio encoding by default, which returns audio bytes without headers.
|
||||
1
changelog/4447.fixed.md
Normal file
1
changelog/4447.fixed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Extended the `cancel_on_interruption=False` regression fix to `GrokRealtimeLLMService`, `AzureRealtimeLLMService`, and `UltravoxRealtimeLLMService`. Grok and Azure use the same approach as in #4441 (each service detects async-tool messages in the LLM context and routes the final result to its formal tool-result channel; Azure inherits transitively from `OpenAIRealtimeLLMService`). Ultravox needed a different approach because its API freezes the conversation between `client_tool_invocation` and the matching `client_tool_result` — for async-registered functions it now ships a placeholder `client_tool_result` immediately when the function is invoked (to unfreeze the conversation), then injects the real result as user-side text once the tool finishes. Streamed intermediate results (`FunctionCallResultProperties(is_final=False)`) are still not supported on any of these realtime services. `GeminiLiveLLMService` and `InworldRealtimeLLMService` are excluded for now: Gemini Live's async-tool path needs deeper investigation, and Inworld appears to have a pre-existing problem with even simple tool calling on its Realtime API.
|
||||
1
changelog/4448.added.md
Normal file
1
changelog/4448.added.md
Normal file
@@ -0,0 +1 @@
|
||||
- Added `cancel_on_interruption=False` support for `GeminiLiveLLMService` on models that support Gemini's NON_BLOCKING tool mechanism (currently Gemini 2.x); the conversation now continues while the tool runs. On models that don't yet support NON_BLOCKING (Gemini 3.x), the service surfaces a one-time warning explaining the limitation. (Note: an intermittent 1008 error can occasionally fire on Gemini 2.5 during long-running tool calls; we auto-reconnect.)
|
||||
1
changelog/4449.changed.md
Normal file
1
changelog/4449.changed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Moved `create_task`, `cancel_task`, the `task_manager` property, and `setup(task_manager)` up from `FrameProcessor` to `BaseObject`. Custom `BaseObject` subclasses (turn strategies, controllers, etc.) now inherit these methods directly instead of reimplementing the task manager wiring. Owners propagate the task manager to their child `BaseObject`s via `await child.setup(task_manager)`.
|
||||
1
changelog/4450.changed.md
Normal file
1
changelog/4450.changed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Changed the default OpenAI Realtime input audio transcription model from `gpt-4o-transcribe` to `gpt-realtime-whisper` for both `OpenAIRealtimeSTTService` and `OpenAIRealtimeLLMService`. The new model does not accept the `prompt` parameter; if a prompt is supplied alongside `gpt-realtime-whisper`, it is dropped automatically and a warning is logged. To keep using prompt hints, explicitly pin `model="gpt-4o-transcribe"` (or `"gpt-4o-mini-transcribe"`).
|
||||
1
changelog/4462.changed.md
Normal file
1
changelog/4462.changed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Updated the default model for `CartesiaTTSService` and `CartesiaHttpTTSService` from `sonic-3` to `sonic-3.5`.
|
||||
1
changelog/4464.added.2.md
Normal file
1
changelog/4464.added.2.md
Normal file
@@ -0,0 +1 @@
|
||||
- Added NVIDIA Magpie TTS services via AWS SageMaker: `NvidiaSageMakerHTTPTTSService` (single HTTP invocation, streams raw PCM back) and `NvidiaSageMakerWebsocketTTSService` (persistent HTTP/2 bidi-stream with full interruption support via `InterruptibleTTSService`).
|
||||
1
changelog/4464.added.md
Normal file
1
changelog/4464.added.md
Normal file
@@ -0,0 +1 @@
|
||||
- Added `NvidiaSageMakerWebsocketSTTService` for streaming speech recognition using NVIDIA Nemotron ASR via an AWS SageMaker bidirectional-stream endpoint. Produces `InterimTranscriptionFrame` and `TranscriptionFrame` frames, is VAD-aware, and automatically reconnects on error.
|
||||
1
changelog/4465.fixed.md
Normal file
1
changelog/4465.fixed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Fixed `OpenAIRealtimeLLMService` handling of multi-output-item responses (observed with `gpt-realtime-2`). A single response can now contain more than one audio item, and the first item's `audio.done` may arrive after the second item's deltas have started. Deltas still arrive strictly in playback order, so we continue to forward them as received (matching OpenAI's reference implementation). The fix removes spurious warnings, ensures truncation always targets the latest audio item, and emits a single bracketing `TTSStartedFrame`/`TTSStoppedFrame` pair per assistant turn (the Stopped is now pushed on `response.done`).
|
||||
1
changelog/4470.added.md
Normal file
1
changelog/4470.added.md
Normal file
@@ -0,0 +1 @@
|
||||
- Added support for `reasoning` configuration on `OpenAIRealtimeLLMService`, for use with reasoning-capable Realtime models such as `gpt-realtime-2`.
|
||||
1
changelog/4472.changed.md
Normal file
1
changelog/4472.changed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Changed the default model for `OpenAIRealtimeLLMService` from `gpt-realtime-1.5` to `gpt-realtime-2`.
|
||||
1
changelog/4480.added.md
Normal file
1
changelog/4480.added.md
Normal file
@@ -0,0 +1 @@
|
||||
- Added `wait_for_transcript_to_end_user_turn` on `LLMUserAggregatorParams` for pipelines where local turn detection drives a realtime service like Gemini Live. Set it to False to avoid unnecessary latency from transcript delay — the realtime service consumes user audio directly, so we don't need user transcripts in context before it can respond. The option makes it so that (1) turn strategies do not consider user transcripts, letting the user turn end sooner, and (2) user transcripts are then handled by the aggregator: a simple timer gives it time to gather those transcripts after the user turn ends, and once gathered, the aggregator emits a new `on_user_turn_message_finalized` event with the new user context message. The new event also fires in the default mode (coinciding with `on_user_turn_stopped`), so consumers that want the populated user transcript can subscribe to it uniformly. See `examples/realtime/realtime-gemini-live-local-vad.py` for the full pattern.
|
||||
@@ -132,6 +132,10 @@ NOVITA_API_KEY=...
|
||||
|
||||
# NVIDIA
|
||||
NVIDIA_API_KEY=...
|
||||
# For a full example of how to deploy to SageMaker, see:
|
||||
# https://github.com/pipecat-ai/pipecat-examples/tree/main/nvidia_sagemaker_example/deployment/aws-sagemaker-nvidia
|
||||
SAGEMAKER_ASR_ENDPOINT_NAME=...
|
||||
SAGEMAKER_MAGPIE_ENDPOINT_NAME=...
|
||||
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=...
|
||||
|
||||
232
examples/features/features-add-tool-change-messages.py
Normal file
232
examples/features/features-add-tool-change-messages.py
Normal file
@@ -0,0 +1,232 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Manual validation harness for the ``add_tool_change_messages`` feature.
|
||||
|
||||
When tools change mid-conversation, LLMs can produce a few different
|
||||
flavors of tool-call-related hallucination:
|
||||
|
||||
- **Forward hallucination** — calling a tool that has been removed.
|
||||
- **Negative hallucination** — refusing to call a tool that has been
|
||||
re-added (because recent context is full of "I can't" responses).
|
||||
- **Hallucinated output when tools are unavailable** — making up an
|
||||
answer rather than declining gracefully, or producing JSON that
|
||||
*looks* like a tool call but is actually just an assistant text
|
||||
response.
|
||||
|
||||
The ``add_tool_change_messages`` feature mitigates these by appending a
|
||||
developer-role message to the conversation whenever ``LLMSetToolsFrame``
|
||||
changes the set of advertised tools, so the LLM stays in sync with what's
|
||||
actually available.
|
||||
|
||||
This harness exercises all of those flavors by flipping the advertised
|
||||
tool set on a turn counter:
|
||||
|
||||
Phase 0 (turns 1–4): weather tool ACTIVE — confirm baseline.
|
||||
Phase 1 (turns 5–8): tool REMOVED — keep asking for weather.
|
||||
Phase 2 (turn 9+): tool RE-ADDED — does the LLM call it again?
|
||||
|
||||
Set ``ADD_TOOL_CHANGE_MESSAGES=0`` to disable the mitigation and see the
|
||||
unmitigated behavior. The default is ON so a fresh run shows the feature
|
||||
working.
|
||||
|
||||
Defaults to Llama 3.1 8B Instruct via a locally-running Ollama —
|
||||
anecdotally one of the more hallucination-prone of the easily accessible
|
||||
models. Pull the model once with ``ollama pull llama3.1:8b`` and make
|
||||
sure ``ollama serve`` is running. Swap the LLM service to validate other
|
||||
providers.
|
||||
|
||||
Run with::
|
||||
|
||||
uv run examples/features/features-add-tool-change-messages.py
|
||||
ADD_TOOL_CHANGE_MESSAGES=0 uv run examples/features/features-add-tool-change-messages.py
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
from pipecat.frames.frames import LLMRunFrame, LLMSetToolsFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
from pipecat.processors.aggregators.llm_context import NOT_GIVEN, 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.ollama.llm import OLLamaLLMService
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
# Default ON so a fresh run shows the feature working. Set to "0" to A/B
|
||||
# against the unmitigated behavior.
|
||||
ADD_TOOL_CHANGE_MESSAGES = os.environ.get("ADD_TOOL_CHANGE_MESSAGES", "1") == "1"
|
||||
|
||||
|
||||
async def fetch_weather_from_api(params: FunctionCallParams):
|
||||
await params.result_callback({"conditions": "nice", "temperature": "75"})
|
||||
|
||||
|
||||
weather_function = FunctionSchema(
|
||||
name="get_current_weather",
|
||||
description="Get the current weather",
|
||||
properties={
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "The city and state, e.g. San Francisco, CA",
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["celsius", "fahrenheit"],
|
||||
"description": "The temperature unit to use. Infer this from the user's location.",
|
||||
},
|
||||
},
|
||||
required=["location", "format"],
|
||||
)
|
||||
weather_tools = ToolsSchema(standard_tools=[weather_function])
|
||||
|
||||
|
||||
transport_params = {
|
||||
"daily": lambda: DailyParams(audio_in_enabled=True, audio_out_enabled=True),
|
||||
"twilio": lambda: FastAPIWebsocketParams(audio_in_enabled=True, audio_out_enabled=True),
|
||||
"webrtc": lambda: TransportParams(audio_in_enabled=True, audio_out_enabled=True),
|
||||
}
|
||||
|
||||
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info(
|
||||
f"Starting add_tool_change_messages demo bot "
|
||||
f"(ADD_TOOL_CHANGE_MESSAGES={ADD_TOOL_CHANGE_MESSAGES})"
|
||||
)
|
||||
|
||||
stt = DeepgramSTTService(api_key=os.environ["DEEPGRAM_API_KEY"])
|
||||
|
||||
tts = CartesiaTTSService(
|
||||
api_key=os.environ["CARTESIA_API_KEY"],
|
||||
settings=CartesiaTTSService.Settings(
|
||||
voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady
|
||||
),
|
||||
)
|
||||
|
||||
llm = OLLamaLLMService(
|
||||
settings=OLLamaLLMService.Settings(
|
||||
# Llama 3.1 8B Instruct is anecdotally one of the more
|
||||
# hallucination-prone of the easily accessible models — exactly
|
||||
# what we want for this validation harness. Pull it with
|
||||
# ``ollama pull llama3.1:8b`` and make sure ``ollama serve``
|
||||
# is running.
|
||||
model="llama3.1:8b",
|
||||
system_instruction=(
|
||||
"You are a helpful assistant in a voice conversation. Your responses "
|
||||
"will be spoken aloud, so avoid emojis, bullet points, or other "
|
||||
"formatting that can't be spoken. Respond briefly and naturally. "
|
||||
"If the user asks for the current weather, use the `get_current_weather` "
|
||||
"function if it's available. IMPORTANT: if you do not have access to the function, "
|
||||
"say something along the lines of 'Sorry, I can't check the weather right now.'."
|
||||
),
|
||||
),
|
||||
)
|
||||
llm.register_function("get_current_weather", fetch_weather_from_api)
|
||||
|
||||
context = LLMContext(tools=weather_tools)
|
||||
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(
|
||||
context,
|
||||
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
|
||||
add_tool_change_messages=ADD_TOOL_CHANGE_MESSAGES,
|
||||
)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
stt,
|
||||
user_aggregator,
|
||||
llm,
|
||||
tts,
|
||||
transport.output(),
|
||||
assistant_aggregator,
|
||||
]
|
||||
)
|
||||
|
||||
task = PipelineTask(
|
||||
pipeline,
|
||||
params=PipelineParams(enable_metrics=True, enable_usage_metrics=True),
|
||||
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
|
||||
)
|
||||
|
||||
# Phase controller: roughly 4 turns per phase.
|
||||
user_turn_count = 0
|
||||
REMOVE_AT_TURN = 5 # tool gone for turn N onward
|
||||
READD_AT_TURN = 9 # tool back for turn N onward
|
||||
|
||||
@user_aggregator.event_handler("on_user_turn_stopped")
|
||||
async def on_user_turn_stopped(aggregator, strategy, message):
|
||||
nonlocal user_turn_count
|
||||
user_turn_count += 1
|
||||
logger.info(f"=== User turn {user_turn_count} complete ===")
|
||||
|
||||
if user_turn_count == REMOVE_AT_TURN - 1:
|
||||
logger.info(
|
||||
"=== Phase 1: weather tool REMOVED. Keep asking about the weather "
|
||||
"to exercise hallucination scenarios. ==="
|
||||
)
|
||||
await task.queue_frame(LLMSetToolsFrame(tools=NOT_GIVEN))
|
||||
elif user_turn_count == READD_AT_TURN - 1:
|
||||
logger.info(
|
||||
"=== Phase 2: weather tool RE-ADDED. Ask for the weather again — "
|
||||
"does the LLM call it, or keep refusing? (THIS IS THE TEST.) ==="
|
||||
)
|
||||
await task.queue_frame(LLMSetToolsFrame(tools=weather_tools))
|
||||
|
||||
@transport.event_handler("on_client_connected")
|
||||
async def on_client_connected(transport, client):
|
||||
logger.info("Client connected")
|
||||
logger.info(
|
||||
"=== Phase 0: weather tool ACTIVE. Ask for the weather a few times "
|
||||
"to confirm it's working. ==="
|
||||
)
|
||||
context.add_message(
|
||||
{
|
||||
"role": "developer",
|
||||
"content": (
|
||||
"Please introduce yourself briefly to the user, then invite them "
|
||||
"to ask about the weather."
|
||||
),
|
||||
}
|
||||
)
|
||||
await task.queue_frames([LLMRunFrame()])
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
logger.info("Client disconnected")
|
||||
await task.cancel()
|
||||
|
||||
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
|
||||
await runner.run(task)
|
||||
|
||||
|
||||
async def bot(runner_args: RunnerArguments):
|
||||
"""Main bot entry point compatible with Pipecat Cloud."""
|
||||
transport = await create_transport(runner_args, transport_params)
|
||||
await run_bot(transport, runner_args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pipecat.runner.run import main
|
||||
|
||||
main()
|
||||
187
examples/function-calling/function-calling-missing-handler.py
Normal file
187
examples/function-calling/function-calling-missing-handler.py
Normal file
@@ -0,0 +1,187 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Manual demonstration of the missing-handler (developer-error) recovery path.
|
||||
|
||||
When a tool is advertised to the LLM via ``tools``/``LLMContext`` but
|
||||
the developer forgets to call ``llm.register_function(...)`` to wire up
|
||||
its handler, the LLM happily emits a tool call and then... nothing
|
||||
happens on the Pipecat side, leaving the conversation stuck.
|
||||
|
||||
Pipecat's recovery path (``LLMService._missing_function_call_handler``)
|
||||
catches this case:
|
||||
|
||||
- Logs a ``logger.error`` distinguishing **developer error** (tool advertised
|
||||
but no handler registered) from a hallucination (tool not advertised),
|
||||
pointing at the missing ``register_function`` call.
|
||||
- Returns a neutral terminal tool result
|
||||
(``LLMService.MISSING_FUNCTION_CALL_MESSAGE_TEMPLATE``: "The function
|
||||
`X` is not currently available.") so the call still terminates with a
|
||||
normal tool result instead of leaving the conversation stuck.
|
||||
|
||||
This example is **deliberately broken**: the weather schema is in
|
||||
``tools`` but ``register_function`` is *not* called. Ask the bot about
|
||||
the weather and observe:
|
||||
|
||||
1. The LLM emits a tool call for ``get_current_weather``.
|
||||
2. ``logger.error`` fires with "advertised … but has no registered handler
|
||||
— did you forget to call register_function()?"
|
||||
3. The terminal tool result is fed back to the LLM.
|
||||
4. The LLM responds in voice based on that result (typically something
|
||||
like "the weather function isn't available right now").
|
||||
|
||||
Uses the OpenAI LLM service with defaults. Swap to another provider to
|
||||
validate this behavior elsewhere.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
from pipecat.frames.frames import LLMRunFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.runner.types import RunnerArguments
|
||||
from pipecat.runner.utils import create_transport
|
||||
from pipecat.services.cartesia.tts import CartesiaTTSService
|
||||
from pipecat.services.deepgram.stt import DeepgramSTTService
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
weather_function = FunctionSchema(
|
||||
name="get_current_weather",
|
||||
description="Get the current weather",
|
||||
properties={
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "The city and state, e.g. San Francisco, CA",
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["celsius", "fahrenheit"],
|
||||
"description": "The temperature unit to use. Infer this from the user's location.",
|
||||
},
|
||||
},
|
||||
required=["location", "format"],
|
||||
)
|
||||
weather_tools = ToolsSchema(standard_tools=[weather_function])
|
||||
|
||||
|
||||
transport_params = {
|
||||
"daily": lambda: DailyParams(audio_in_enabled=True, audio_out_enabled=True),
|
||||
"twilio": lambda: FastAPIWebsocketParams(audio_in_enabled=True, audio_out_enabled=True),
|
||||
"webrtc": lambda: TransportParams(audio_in_enabled=True, audio_out_enabled=True),
|
||||
}
|
||||
|
||||
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info("Starting missing-handler demo bot (no handler is registered on purpose)")
|
||||
|
||||
stt = DeepgramSTTService(api_key=os.environ["DEEPGRAM_API_KEY"])
|
||||
|
||||
tts = CartesiaTTSService(
|
||||
api_key=os.environ["CARTESIA_API_KEY"],
|
||||
settings=CartesiaTTSService.Settings(
|
||||
voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady
|
||||
),
|
||||
)
|
||||
|
||||
llm = OpenAILLMService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAILLMService.Settings(
|
||||
system_instruction=(
|
||||
"You are a helpful assistant in a voice conversation. Your responses "
|
||||
"will be spoken aloud, so avoid emojis, bullet points, or other "
|
||||
"formatting that can't be spoken. Respond briefly and naturally. "
|
||||
"Always use the get_current_weather function to answer questions "
|
||||
"about the current weather."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# *** DELIBERATELY OMITTED ***
|
||||
# The whole point of this example is to demonstrate the missing-handler
|
||||
# recovery path. Re-add this line to wire the tool up correctly:
|
||||
#
|
||||
# llm.register_function("get_current_weather", fetch_weather_from_api)
|
||||
|
||||
context = LLMContext(tools=weather_tools)
|
||||
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(
|
||||
context,
|
||||
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
|
||||
)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
stt,
|
||||
user_aggregator,
|
||||
llm,
|
||||
tts,
|
||||
transport.output(),
|
||||
assistant_aggregator,
|
||||
]
|
||||
)
|
||||
|
||||
task = PipelineTask(
|
||||
pipeline,
|
||||
params=PipelineParams(enable_metrics=True, enable_usage_metrics=True),
|
||||
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
|
||||
)
|
||||
|
||||
@transport.event_handler("on_client_connected")
|
||||
async def on_client_connected(transport, client):
|
||||
logger.info("Client connected")
|
||||
logger.info(
|
||||
"=== Ask for the weather. Watch for a logger.error about the missing "
|
||||
"handler, and listen for the LLM's response based on the recovery "
|
||||
"message. ==="
|
||||
)
|
||||
context.add_message(
|
||||
{
|
||||
"role": "developer",
|
||||
"content": (
|
||||
"Please introduce yourself briefly to the user, then invite "
|
||||
"them to ask about the weather."
|
||||
),
|
||||
}
|
||||
)
|
||||
await task.queue_frames([LLMRunFrame()])
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
logger.info("Client disconnected")
|
||||
await task.cancel()
|
||||
|
||||
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
|
||||
await runner.run(task)
|
||||
|
||||
|
||||
async def bot(runner_args: RunnerArguments):
|
||||
"""Main bot entry point compatible with Pipecat Cloud."""
|
||||
transport = await create_transport(runner_args, transport_params)
|
||||
await run_bot(transport, runner_args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pipecat.runner.run import main
|
||||
|
||||
main()
|
||||
@@ -29,7 +29,7 @@ from pipecat.runner.types import RunnerArguments
|
||||
from pipecat.runner.utils import create_transport
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
from pipecat.services.openai.stt import OpenAISTTService
|
||||
from pipecat.services.openai.stt import OpenAIRealtimeSTTService
|
||||
from pipecat.services.openai.tts import OpenAITTSService
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
@@ -69,13 +69,7 @@ transport_params = {
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info(f"Starting bot")
|
||||
|
||||
stt = OpenAISTTService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAISTTService.Settings(
|
||||
model="gpt-4o-transcribe",
|
||||
prompt="Expect words related weather, such as temperature and conditions. And restaurant names.",
|
||||
),
|
||||
)
|
||||
stt = OpenAIRealtimeSTTService(api_key=os.environ["OPENAI_API_KEY"])
|
||||
|
||||
tts = OpenAITTSService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
|
||||
@@ -25,7 +25,7 @@ from pipecat.runner.types import RunnerArguments
|
||||
from pipecat.runner.utils import create_transport
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
from pipecat.services.openai.stt import OpenAISTTService
|
||||
from pipecat.services.openai.stt import OpenAIRealtimeSTTService
|
||||
from pipecat.services.openai.tts import OpenAITTSService
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
@@ -63,13 +63,7 @@ transport_params = {
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info(f"Starting bot")
|
||||
|
||||
stt = OpenAISTTService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAISTTService.Settings(
|
||||
model="gpt-4o-transcribe",
|
||||
prompt="Expect words related weather, such as temperature and conditions. And restaurant names.",
|
||||
),
|
||||
)
|
||||
stt = OpenAIRealtimeSTTService(api_key=os.environ["OPENAI_API_KEY"])
|
||||
|
||||
tts = OpenAITTSService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
|
||||
184
examples/realtime/realtime-aws-nova-sonic-async-tool.py
Normal file
184
examples/realtime/realtime-aws-nova-sonic-async-tool.py
Normal file
@@ -0,0 +1,184 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Example: async function call with the AWS Nova Sonic LLM service.
|
||||
|
||||
The ``get_current_weather`` tool is registered with
|
||||
``cancel_on_interruption=False`` and simulates a slow API call (10s sleep).
|
||||
While the call is in flight the conversation continues; the result arrives
|
||||
later via the async-tool mechanism and is forwarded to Nova Sonic via the
|
||||
formal toolResult channel so the model can integrate it naturally into its
|
||||
next turn.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
from pipecat.frames.frames import LLMRunFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.runner.types import RunnerArguments
|
||||
from pipecat.runner.utils import create_transport
|
||||
from pipecat.services.aws.nova_sonic.llm import AWSNovaSonicLLMService
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
async def fetch_weather_from_api(params: FunctionCallParams):
|
||||
# Simulate a long-running API call so we can demonstrate that the
|
||||
# conversation continues while the tool is in flight.
|
||||
await asyncio.sleep(10)
|
||||
temperature = (
|
||||
random.randint(60, 85)
|
||||
if params.arguments["format"] == "fahrenheit"
|
||||
else random.randint(15, 30)
|
||||
)
|
||||
await params.result_callback(
|
||||
{
|
||||
"conditions": "nice",
|
||||
"temperature": temperature,
|
||||
"location": params.arguments["location"],
|
||||
"format": params.arguments["format"],
|
||||
"timestamp": datetime.now().strftime("%Y%m%d_%H%M%S"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
weather_function = FunctionSchema(
|
||||
name="get_current_weather",
|
||||
description="Get the current weather",
|
||||
properties={
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "The city and state, e.g. San Francisco, CA",
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["celsius", "fahrenheit"],
|
||||
"description": "The temperature unit to use. Infer this from the users location.",
|
||||
},
|
||||
},
|
||||
required=["location", "format"],
|
||||
)
|
||||
|
||||
tools = ToolsSchema(standard_tools=[weather_function])
|
||||
|
||||
|
||||
transport_params = {
|
||||
"daily": lambda: DailyParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
"twilio": lambda: FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
"webrtc": lambda: TransportParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info(f"Starting bot")
|
||||
|
||||
system_instruction = (
|
||||
"You are a friendly assistant. The user and you will engage in a spoken "
|
||||
"dialog exchanging the transcripts of a natural real-time conversation. "
|
||||
"Keep your responses short, generally two or three sentences for chatty "
|
||||
"scenarios. When the user asks for the weather, call get_current_weather. "
|
||||
"While you wait for the result, keep chatting with the user. When the "
|
||||
"result arrives, share it with the user naturally."
|
||||
)
|
||||
|
||||
llm = AWSNovaSonicLLMService(
|
||||
secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"],
|
||||
access_key_id=os.environ["AWS_ACCESS_KEY_ID"],
|
||||
region=os.environ["AWS_REGION"],
|
||||
session_token=os.getenv("AWS_SESSION_TOKEN"),
|
||||
settings=AWSNovaSonicLLMService.Settings(
|
||||
voice="tiffany",
|
||||
system_instruction=system_instruction,
|
||||
),
|
||||
)
|
||||
|
||||
llm.register_function(
|
||||
"get_current_weather",
|
||||
fetch_weather_from_api,
|
||||
cancel_on_interruption=False,
|
||||
)
|
||||
|
||||
context = LLMContext(tools=tools)
|
||||
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(
|
||||
context,
|
||||
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
|
||||
)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
user_aggregator,
|
||||
llm,
|
||||
transport.output(),
|
||||
assistant_aggregator,
|
||||
]
|
||||
)
|
||||
|
||||
task = PipelineTask(
|
||||
pipeline,
|
||||
params=PipelineParams(
|
||||
enable_metrics=True,
|
||||
enable_usage_metrics=True,
|
||||
),
|
||||
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
|
||||
)
|
||||
|
||||
@transport.event_handler("on_client_connected")
|
||||
async def on_client_connected(transport, client):
|
||||
logger.info(f"Client connected")
|
||||
context.add_message(
|
||||
{"role": "developer", "content": "Please introduce yourself to the user."}
|
||||
)
|
||||
await task.queue_frames([LLMRunFrame()])
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
logger.info(f"Client disconnected")
|
||||
await task.cancel()
|
||||
|
||||
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
|
||||
await runner.run(task)
|
||||
|
||||
|
||||
async def bot(runner_args: RunnerArguments):
|
||||
"""Main bot entry point compatible with Pipecat Cloud."""
|
||||
transport = await create_transport(runner_args, transport_params)
|
||||
await run_bot(transport, runner_args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pipecat.runner.run import main
|
||||
|
||||
main()
|
||||
@@ -46,11 +46,6 @@ async def fetch_weather_from_api(params: FunctionCallParams):
|
||||
if params.arguments["format"] == "fahrenheit"
|
||||
else random.randint(15, 30)
|
||||
)
|
||||
# Simulate a long network delay.
|
||||
# You can continue chatting while waiting for this to complete.
|
||||
# With Nova 2 Sonic (the default model), the assistant will respond
|
||||
# appropriately once the function call is complete.
|
||||
await asyncio.sleep(5)
|
||||
await params.result_callback(
|
||||
{
|
||||
"conditions": "nice",
|
||||
@@ -150,9 +145,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
# Register function for function calls
|
||||
# you can either register a single function for all function calls, or specific functions
|
||||
# llm.register_function(None, fetch_weather_from_api)
|
||||
llm.register_function(
|
||||
"get_current_weather", fetch_weather_from_api, cancel_on_interruption=False
|
||||
)
|
||||
llm.register_function("get_current_weather", fetch_weather_from_api)
|
||||
|
||||
# Set up context and context management.
|
||||
context = LLMContext(tools=tools)
|
||||
|
||||
195
examples/realtime/realtime-azure-async-tool.py
Normal file
195
examples/realtime/realtime-azure-async-tool.py
Normal file
@@ -0,0 +1,195 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Example: async function call with the Azure Realtime LLM service.
|
||||
|
||||
The ``get_current_weather`` tool is registered with
|
||||
``cancel_on_interruption=False`` and simulates a slow API call (10s sleep).
|
||||
While the call is in flight the conversation continues; the result arrives
|
||||
later via the async-tool mechanism and is forwarded to Azure Realtime as a
|
||||
``function_call_output`` so the model can integrate it naturally into its
|
||||
next turn.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
from pipecat.frames.frames import LLMRunFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.runner.types import RunnerArguments
|
||||
from pipecat.runner.utils import create_transport
|
||||
from pipecat.services.azure.realtime.llm import AzureRealtimeLLMService
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
from pipecat.services.openai.realtime.events import (
|
||||
AudioConfiguration,
|
||||
AudioInput,
|
||||
InputAudioTranscription,
|
||||
SessionProperties,
|
||||
)
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
async def fetch_weather_from_api(params: FunctionCallParams):
|
||||
# Simulate a long-running API call so we can demonstrate that the
|
||||
# conversation continues while the tool is in flight.
|
||||
await asyncio.sleep(10)
|
||||
temperature = (
|
||||
random.randint(60, 85)
|
||||
if params.arguments["format"] == "fahrenheit"
|
||||
else random.randint(15, 30)
|
||||
)
|
||||
await params.result_callback(
|
||||
{
|
||||
"conditions": "nice",
|
||||
"temperature": temperature,
|
||||
"location": params.arguments["location"],
|
||||
"format": params.arguments["format"],
|
||||
"timestamp": datetime.now().strftime("%Y%m%d_%H%M%S"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
weather_function = FunctionSchema(
|
||||
name="get_current_weather",
|
||||
description="Get the current weather",
|
||||
properties={
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "The city and state, e.g. San Francisco, CA",
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["celsius", "fahrenheit"],
|
||||
"description": "The temperature unit to use. Infer this from the users location.",
|
||||
},
|
||||
},
|
||||
required=["location", "format"],
|
||||
)
|
||||
|
||||
tools = ToolsSchema(standard_tools=[weather_function])
|
||||
|
||||
|
||||
system_instruction = (
|
||||
"You are a friendly assistant. The user and you will engage in a spoken "
|
||||
"dialog exchanging the transcripts of a natural real-time conversation. "
|
||||
"Keep your responses short, generally two or three sentences for chatty "
|
||||
"scenarios. When the user asks for the weather, call get_current_weather. "
|
||||
"While you wait for the result, keep chatting with the user. When the "
|
||||
"result arrives, share it with the user naturally."
|
||||
)
|
||||
|
||||
|
||||
transport_params = {
|
||||
"daily": lambda: DailyParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
"twilio": lambda: FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
"webrtc": lambda: TransportParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info(f"Starting bot")
|
||||
|
||||
llm = AzureRealtimeLLMService(
|
||||
api_key=os.environ["AZURE_REALTIME_API_KEY"],
|
||||
base_url=os.environ["AZURE_REALTIME_BASE_URL"],
|
||||
settings=AzureRealtimeLLMService.Settings(
|
||||
system_instruction=system_instruction,
|
||||
session_properties=SessionProperties(
|
||||
audio=AudioConfiguration(
|
||||
input=AudioInput(
|
||||
transcription=InputAudioTranscription(model="whisper-1"),
|
||||
)
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
llm.register_function(
|
||||
"get_current_weather",
|
||||
fetch_weather_from_api,
|
||||
cancel_on_interruption=False,
|
||||
)
|
||||
|
||||
context = LLMContext(tools=tools)
|
||||
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(
|
||||
context,
|
||||
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
|
||||
)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
user_aggregator,
|
||||
llm,
|
||||
transport.output(),
|
||||
assistant_aggregator,
|
||||
]
|
||||
)
|
||||
|
||||
task = PipelineTask(
|
||||
pipeline,
|
||||
params=PipelineParams(
|
||||
enable_metrics=True,
|
||||
enable_usage_metrics=True,
|
||||
),
|
||||
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
|
||||
)
|
||||
|
||||
@transport.event_handler("on_client_connected")
|
||||
async def on_client_connected(transport, client):
|
||||
logger.info(f"Client connected")
|
||||
context.add_message(
|
||||
{"role": "developer", "content": "Please introduce yourself to the user."}
|
||||
)
|
||||
await task.queue_frames([LLMRunFrame()])
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
logger.info(f"Client disconnected")
|
||||
await task.cancel()
|
||||
|
||||
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
|
||||
await runner.run(task)
|
||||
|
||||
|
||||
async def bot(runner_args: RunnerArguments):
|
||||
"""Main bot entry point compatible with Pipecat Cloud."""
|
||||
transport = await create_transport(runner_args, transport_params)
|
||||
await run_bot(transport, runner_args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pipecat.runner.run import main
|
||||
|
||||
main()
|
||||
@@ -4,15 +4,25 @@
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Example: async function call with the Gemini Live LLM service.
|
||||
|
||||
The ``get_current_weather`` tool is registered with
|
||||
``cancel_on_interruption=False`` and simulates a slow API call (10s sleep).
|
||||
While the call is in flight the conversation continues; the result arrives
|
||||
later via the async-tool mechanism and is forwarded to Gemini Live as a
|
||||
FunctionResponse so the model can integrate it naturally into its next turn.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import AdapterType, ToolsSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.frames.frames import LLMRunFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
@@ -31,33 +41,55 @@ load_dotenv(override=True)
|
||||
|
||||
|
||||
async def fetch_weather_from_api(params: FunctionCallParams):
|
||||
temperature = 75 if params.arguments["format"] == "fahrenheit" else 24
|
||||
# Simulate a long-running API call so we can demonstrate that the
|
||||
# conversation continues while the tool is in flight.
|
||||
await asyncio.sleep(10)
|
||||
temperature = (
|
||||
random.randint(60, 85)
|
||||
if params.arguments["format"] == "fahrenheit"
|
||||
else random.randint(15, 30)
|
||||
)
|
||||
await params.result_callback(
|
||||
{
|
||||
"conditions": "nice",
|
||||
"temperature": temperature,
|
||||
"location": params.arguments["location"],
|
||||
"format": params.arguments["format"],
|
||||
"timestamp": datetime.now().strftime("%Y%m%d_%H%M%S"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def fetch_restaurant_recommendation(params: FunctionCallParams):
|
||||
await params.result_callback({"name": "The Golden Dragon"})
|
||||
weather_function = FunctionSchema(
|
||||
name="get_current_weather",
|
||||
description="Get the current weather",
|
||||
properties={
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "The city and state, e.g. San Francisco, CA",
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["celsius", "fahrenheit"],
|
||||
"description": "The temperature unit to use. Infer this from the user's location.",
|
||||
},
|
||||
},
|
||||
required=["location", "format"],
|
||||
)
|
||||
|
||||
tools = ToolsSchema(standard_tools=[weather_function])
|
||||
|
||||
|
||||
system_instruction = """
|
||||
You are a helpful assistant who can answer questions and use tools.
|
||||
|
||||
You have three tools available to you:
|
||||
1. get_current_weather: Use this tool to get the current weather in a specific location.
|
||||
2. get_restaurant_recommendation: Use this tool to get a restaurant recommendation in a specific location.
|
||||
3. google_search: Use this tool to search the web for information.
|
||||
"""
|
||||
system_instruction = (
|
||||
"You are a friendly assistant. The user and you will engage in a spoken "
|
||||
"dialog exchanging the transcripts of a natural real-time conversation. "
|
||||
"Keep your responses short, generally two or three sentences for chatty "
|
||||
"scenarios. When the user asks for the weather, call get_current_weather. "
|
||||
"While you wait for the result, keep chatting with the user. When the "
|
||||
"result arrives, share it with the user naturally."
|
||||
)
|
||||
|
||||
|
||||
# We use lambdas to defer transport parameter creation until the transport
|
||||
# type is selected at runtime.
|
||||
transport_params = {
|
||||
"daily": lambda: DailyParams(
|
||||
audio_in_enabled=True,
|
||||
@@ -77,42 +109,6 @@ transport_params = {
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info(f"Starting bot")
|
||||
|
||||
weather_function = FunctionSchema(
|
||||
name="get_current_weather",
|
||||
description="Get the current weather",
|
||||
properties={
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "The city and state, e.g. San Francisco, CA",
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["celsius", "fahrenheit"],
|
||||
"description": "The temperature unit to use. Infer this from the user's location.",
|
||||
},
|
||||
},
|
||||
required=["location", "format"],
|
||||
)
|
||||
restaurant_function = FunctionSchema(
|
||||
name="get_restaurant_recommendation",
|
||||
description="Get a restaurant recommendation",
|
||||
properties={
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "The city and state, e.g. San Francisco, CA",
|
||||
},
|
||||
},
|
||||
required=["location"],
|
||||
)
|
||||
search_tool = {"google_search": {}}
|
||||
# KNOWN ISSUE: If using GeminiVertexLiveLLMService, it appears
|
||||
# you cannot use the "google_search" tool alongside other tools.
|
||||
# See https://github.com/googleapis/python-genai/issues/941.
|
||||
tools = ToolsSchema(
|
||||
standard_tools=[weather_function, restaurant_function],
|
||||
custom_tools={AdapterType.GEMINI: [search_tool]},
|
||||
)
|
||||
|
||||
llm = GeminiLiveLLMService(
|
||||
api_key=os.environ["GOOGLE_API_KEY"],
|
||||
settings=GeminiLiveLLMService.Settings(
|
||||
@@ -121,13 +117,12 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
llm.register_function("get_current_weather", fetch_weather_from_api)
|
||||
llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation)
|
||||
llm.register_function(
|
||||
"get_current_weather",
|
||||
fetch_weather_from_api,
|
||||
cancel_on_interruption=False,
|
||||
)
|
||||
|
||||
# You can provide the system instructions and tools in the context rather
|
||||
# than as arguments to GeminiLiveLLMService, but note that doing so will
|
||||
# trigger a (fast) reconnection when the GeminiLiveLLMService first
|
||||
# receives the context (i.e. when we send the LLMRunFrame below).
|
||||
context = LLMContext()
|
||||
# Server-side VAD is enabled by default; no local VAD is added.
|
||||
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(context)
|
||||
@@ -154,7 +149,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
@transport.event_handler("on_client_connected")
|
||||
async def on_client_connected(transport, client):
|
||||
logger.info(f"Client connected")
|
||||
# Kick off the conversation.
|
||||
context.add_message(
|
||||
{"role": "developer", "content": "Please introduce yourself to the user."}
|
||||
)
|
||||
@@ -166,7 +160,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
await task.cancel()
|
||||
|
||||
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
|
||||
|
||||
await runner.run(task)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from pipecat.processors.aggregators.llm_response_universal import (
|
||||
AssistantTurnStoppedMessage,
|
||||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
UserMessageFinalizedMessage,
|
||||
UserTurnStoppedMessage,
|
||||
)
|
||||
from pipecat.runner.types import RunnerArguments
|
||||
@@ -70,10 +71,25 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
},
|
||||
],
|
||||
)
|
||||
# `wait_for_transcript_to_end_user_turn=False` is the right setting
|
||||
# for pipelines like this one — local turn detection driving a
|
||||
# realtime service. It avoids unnecessary latency from transcript
|
||||
# delay: the realtime service consumes user audio directly, so
|
||||
# we don't need user transcripts in context before it can respond.
|
||||
# With this option:
|
||||
#
|
||||
# - Turn strategies do not consider user transcripts, so the user
|
||||
# turn ends sooner.
|
||||
# - User transcripts are handled by the aggregator: a simple
|
||||
# post-turn transcript wait gives them time to arrive after the
|
||||
# user turn ends, then the aggregator emits
|
||||
# `on_user_turn_message_finalized` with the new user context
|
||||
# message.
|
||||
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(
|
||||
context,
|
||||
user_params=LLMUserAggregatorParams(
|
||||
vad_analyzer=SileroVADAnalyzer(),
|
||||
wait_for_transcript_to_end_user_turn=False,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -107,8 +123,23 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info(f"Client disconnected")
|
||||
await task.cancel()
|
||||
|
||||
# `on_user_turn_stopped` fires at the end of the user turn. With
|
||||
# `wait_for_transcript_to_end_user_turn=False`, no user
|
||||
# transcripts have arrived yet at this point, so
|
||||
# `message.content` is empty. Logged here to make the end-of-turn
|
||||
# signal visible alongside the later finalization event.
|
||||
@user_aggregator.event_handler("on_user_turn_stopped")
|
||||
async def on_user_turn_stopped(aggregator, strategy, message: UserTurnStoppedMessage):
|
||||
logger.info(f"User turn ended (strategy: {type(strategy).__name__})")
|
||||
|
||||
# `on_user_turn_message_finalized` fires when the user message has
|
||||
# been finalized into the context. Here it fires later than
|
||||
# `on_user_turn_stopped`, after the aggregator's post-turn
|
||||
# transcript wait completes.
|
||||
@user_aggregator.event_handler("on_user_turn_message_finalized")
|
||||
async def on_user_turn_message_finalized(
|
||||
aggregator, strategy, message: UserMessageFinalizedMessage
|
||||
):
|
||||
timestamp = f"[{message.timestamp}] " if message.timestamp else ""
|
||||
line = f"{timestamp}user: {message.content}"
|
||||
logger.info(f"Transcript: {line}")
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import AdapterType, ToolsSchema
|
||||
from pipecat.frames.frames import LLMRunFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
@@ -23,6 +26,7 @@ from pipecat.processors.aggregators.llm_response_universal import (
|
||||
from pipecat.runner.types import RunnerArguments
|
||||
from pipecat.runner.utils import create_transport
|
||||
from pipecat.services.google.gemini_live.llm import GeminiLiveLLMService
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||
@@ -30,6 +34,32 @@ from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
async def fetch_weather_from_api(params: FunctionCallParams):
|
||||
temperature = 75 if params.arguments["format"] == "fahrenheit" else 24
|
||||
await params.result_callback(
|
||||
{
|
||||
"conditions": "nice",
|
||||
"temperature": temperature,
|
||||
"format": params.arguments["format"],
|
||||
"timestamp": datetime.now().strftime("%Y%m%d_%H%M%S"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def fetch_restaurant_recommendation(params: FunctionCallParams):
|
||||
await params.result_callback({"name": "The Golden Dragon"})
|
||||
|
||||
|
||||
system_instruction = """
|
||||
You are a helpful assistant who can answer questions and use tools.
|
||||
|
||||
You have three tools available to you:
|
||||
1. get_current_weather: Use this tool to get the current weather in a specific location.
|
||||
2. get_restaurant_recommendation: Use this tool to get a restaurant recommendation in a specific location.
|
||||
3. google_search: Use this tool to search the web for information.
|
||||
"""
|
||||
|
||||
|
||||
# We use lambdas to defer transport parameter creation until the transport
|
||||
# type is selected at runtime.
|
||||
transport_params = {
|
||||
@@ -51,23 +81,55 @@ transport_params = {
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info(f"Starting bot")
|
||||
|
||||
weather_function = FunctionSchema(
|
||||
name="get_current_weather",
|
||||
description="Get the current weather",
|
||||
properties={
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "The city and state, e.g. San Francisco, CA",
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["celsius", "fahrenheit"],
|
||||
"description": "The temperature unit to use. Infer this from the user's location.",
|
||||
},
|
||||
},
|
||||
required=["location", "format"],
|
||||
)
|
||||
restaurant_function = FunctionSchema(
|
||||
name="get_restaurant_recommendation",
|
||||
description="Get a restaurant recommendation",
|
||||
properties={
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "The city and state, e.g. San Francisco, CA",
|
||||
},
|
||||
},
|
||||
required=["location"],
|
||||
)
|
||||
search_tool = {"google_search": {}}
|
||||
# KNOWN ISSUE: If using GeminiVertexLiveLLMService, it appears
|
||||
# you cannot use the "google_search" tool alongside other tools.
|
||||
# See https://github.com/googleapis/python-genai/issues/941.
|
||||
tools = ToolsSchema(
|
||||
standard_tools=[weather_function, restaurant_function],
|
||||
custom_tools={AdapterType.GEMINI: [search_tool]},
|
||||
)
|
||||
|
||||
llm = GeminiLiveLLMService(
|
||||
api_key=os.environ["GOOGLE_API_KEY"],
|
||||
settings=GeminiLiveLLMService.Settings(
|
||||
system_instruction=system_instruction,
|
||||
voice="Aoede", # Puck, Charon, Kore, Fenrir, Aoede
|
||||
# system_instruction="Talk like a pirate."
|
||||
),
|
||||
# inference_on_context_initialization=False,
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
context = LLMContext(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Say hello. Then ask if I want to hear a joke.",
|
||||
},
|
||||
],
|
||||
)
|
||||
llm.register_function("get_current_weather", fetch_weather_from_api)
|
||||
llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation)
|
||||
|
||||
context = LLMContext()
|
||||
# Server-side VAD is enabled by default; no local VAD is added.
|
||||
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(context)
|
||||
|
||||
@@ -94,6 +156,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
async def on_client_connected(transport, client):
|
||||
logger.info(f"Client connected")
|
||||
# Kick off the conversation.
|
||||
context.add_message(
|
||||
{"role": "developer", "content": "Please introduce yourself to the user."}
|
||||
)
|
||||
await task.queue_frames([LLMRunFrame()])
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
|
||||
179
examples/realtime/realtime-grok-async-tool.py
Normal file
179
examples/realtime/realtime-grok-async-tool.py
Normal file
@@ -0,0 +1,179 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Example: async function call with the Grok Realtime LLM service.
|
||||
|
||||
The ``get_current_weather`` tool is registered with
|
||||
``cancel_on_interruption=False`` and simulates a slow API call (10s sleep).
|
||||
While the call is in flight the conversation continues; the result arrives
|
||||
later via the async-tool mechanism and is forwarded to Grok Realtime as a
|
||||
``function_call_output`` so the model can integrate it naturally into its
|
||||
next turn.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.frames.frames import LLMRunFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair
|
||||
from pipecat.runner.types import RunnerArguments
|
||||
from pipecat.runner.utils import create_transport
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
from pipecat.services.xai.realtime.events import SessionProperties
|
||||
from pipecat.services.xai.realtime.llm import GrokRealtimeLLMService
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
async def fetch_weather_from_api(params: FunctionCallParams):
|
||||
# Simulate a long-running API call so we can demonstrate that the
|
||||
# conversation continues while the tool is in flight.
|
||||
await asyncio.sleep(10)
|
||||
temperature = (
|
||||
random.randint(60, 85)
|
||||
if params.arguments["format"] == "fahrenheit"
|
||||
else random.randint(15, 30)
|
||||
)
|
||||
await params.result_callback(
|
||||
{
|
||||
"conditions": "nice",
|
||||
"temperature": temperature,
|
||||
"location": params.arguments["location"],
|
||||
"format": params.arguments["format"],
|
||||
"timestamp": datetime.now().strftime("%Y%m%d_%H%M%S"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
weather_function = FunctionSchema(
|
||||
name="get_current_weather",
|
||||
description="Get the current weather",
|
||||
properties={
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "The city and state, e.g. San Francisco, CA",
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["celsius", "fahrenheit"],
|
||||
"description": "The temperature unit to use. Infer this from the users location.",
|
||||
},
|
||||
},
|
||||
required=["location", "format"],
|
||||
)
|
||||
|
||||
tools = ToolsSchema(standard_tools=[weather_function])
|
||||
|
||||
|
||||
system_instruction = (
|
||||
"You are a friendly assistant. The user and you will engage in a spoken "
|
||||
"dialog exchanging the transcripts of a natural real-time conversation. "
|
||||
"Keep your responses short, generally two or three sentences for chatty "
|
||||
"scenarios. When the user asks for the weather, call get_current_weather. "
|
||||
"While you wait for the result, keep chatting with the user. When the "
|
||||
"result arrives, share it with the user naturally."
|
||||
)
|
||||
|
||||
|
||||
# Note: Grok has built-in server-side VAD, so we don't need local VAD.
|
||||
transport_params = {
|
||||
"daily": lambda: DailyParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
"twilio": lambda: FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
"webrtc": lambda: TransportParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info(f"Starting bot")
|
||||
|
||||
llm = GrokRealtimeLLMService(
|
||||
api_key=os.environ["XAI_API_KEY"],
|
||||
settings=GrokRealtimeLLMService.Settings(
|
||||
system_instruction=system_instruction,
|
||||
session_properties=SessionProperties(
|
||||
voice="Ara",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
llm.register_function(
|
||||
"get_current_weather",
|
||||
fetch_weather_from_api,
|
||||
cancel_on_interruption=False,
|
||||
)
|
||||
|
||||
context = LLMContext(tools=tools)
|
||||
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(context)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
user_aggregator,
|
||||
llm,
|
||||
transport.output(),
|
||||
assistant_aggregator,
|
||||
]
|
||||
)
|
||||
|
||||
task = PipelineTask(
|
||||
pipeline,
|
||||
params=PipelineParams(
|
||||
enable_metrics=True,
|
||||
enable_usage_metrics=True,
|
||||
),
|
||||
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
|
||||
)
|
||||
|
||||
@transport.event_handler("on_client_connected")
|
||||
async def on_client_connected(transport, client):
|
||||
logger.info(f"Client connected")
|
||||
context.add_message(
|
||||
{"role": "developer", "content": "Please introduce yourself to the user."}
|
||||
)
|
||||
await task.queue_frames([LLMRunFrame()])
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
logger.info(f"Client disconnected")
|
||||
await task.cancel()
|
||||
|
||||
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
|
||||
await runner.run(task)
|
||||
|
||||
|
||||
async def bot(runner_args: RunnerArguments):
|
||||
"""Main bot entry point compatible with Pipecat Cloud."""
|
||||
transport = await create_transport(runner_args, transport_params)
|
||||
await run_bot(transport, runner_args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pipecat.runner.run import main
|
||||
|
||||
main()
|
||||
198
examples/realtime/realtime-openai-async-tool.py
Normal file
198
examples/realtime/realtime-openai-async-tool.py
Normal file
@@ -0,0 +1,198 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Example: async function call with the OpenAI Realtime LLM service.
|
||||
|
||||
The ``get_current_weather`` tool is registered with
|
||||
``cancel_on_interruption=False`` and simulates a slow API call (10s sleep).
|
||||
While the call is in flight the conversation continues; the result arrives
|
||||
later via the async-tool mechanism and is forwarded to OpenAI Realtime as a
|
||||
``function_call_output`` so the model can integrate it naturally into its
|
||||
next turn.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
from pipecat.frames.frames import LLMRunFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.runner.types import RunnerArguments
|
||||
from pipecat.runner.utils import create_transport
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
from pipecat.services.openai.realtime.events import (
|
||||
AudioConfiguration,
|
||||
AudioInput,
|
||||
InputAudioNoiseReduction,
|
||||
InputAudioTranscription,
|
||||
SemanticTurnDetection,
|
||||
SessionProperties,
|
||||
)
|
||||
from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMService
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
async def fetch_weather_from_api(params: FunctionCallParams):
|
||||
# Simulate a long-running API call so we can demonstrate that the
|
||||
# conversation continues while the tool is in flight.
|
||||
await asyncio.sleep(10)
|
||||
temperature = (
|
||||
random.randint(60, 85)
|
||||
if params.arguments["format"] == "fahrenheit"
|
||||
else random.randint(15, 30)
|
||||
)
|
||||
await params.result_callback(
|
||||
{
|
||||
"conditions": "nice",
|
||||
"temperature": temperature,
|
||||
"location": params.arguments["location"],
|
||||
"format": params.arguments["format"],
|
||||
"timestamp": datetime.now().strftime("%Y%m%d_%H%M%S"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
weather_function = FunctionSchema(
|
||||
name="get_current_weather",
|
||||
description="Get the current weather",
|
||||
properties={
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "The city and state, e.g. San Francisco, CA",
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["celsius", "fahrenheit"],
|
||||
"description": "The temperature unit to use. Infer this from the users location.",
|
||||
},
|
||||
},
|
||||
required=["location", "format"],
|
||||
)
|
||||
|
||||
tools = ToolsSchema(standard_tools=[weather_function])
|
||||
|
||||
|
||||
system_instruction = (
|
||||
"You are a friendly assistant. The user and you will engage in a spoken "
|
||||
"dialog exchanging the transcripts of a natural real-time conversation. "
|
||||
"Keep your responses short, generally two or three sentences for chatty "
|
||||
"scenarios. When the user asks for the weather, call get_current_weather. "
|
||||
"While you wait for the result, keep chatting with the user. When the "
|
||||
"result arrives, share it with the user naturally."
|
||||
)
|
||||
|
||||
|
||||
transport_params = {
|
||||
"daily": lambda: DailyParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
"twilio": lambda: FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
"webrtc": lambda: TransportParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info(f"Starting bot")
|
||||
|
||||
llm = OpenAIRealtimeLLMService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAIRealtimeLLMService.Settings(
|
||||
system_instruction=system_instruction,
|
||||
session_properties=SessionProperties(
|
||||
audio=AudioConfiguration(
|
||||
input=AudioInput(
|
||||
transcription=InputAudioTranscription(),
|
||||
turn_detection=SemanticTurnDetection(),
|
||||
noise_reduction=InputAudioNoiseReduction(type="near_field"),
|
||||
)
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
llm.register_function(
|
||||
"get_current_weather",
|
||||
fetch_weather_from_api,
|
||||
cancel_on_interruption=False,
|
||||
)
|
||||
|
||||
context = LLMContext(tools=tools)
|
||||
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(
|
||||
context,
|
||||
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
|
||||
)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
user_aggregator,
|
||||
llm,
|
||||
transport.output(),
|
||||
assistant_aggregator,
|
||||
]
|
||||
)
|
||||
|
||||
task = PipelineTask(
|
||||
pipeline,
|
||||
params=PipelineParams(
|
||||
enable_metrics=True,
|
||||
enable_usage_metrics=True,
|
||||
),
|
||||
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
|
||||
)
|
||||
|
||||
@transport.event_handler("on_client_connected")
|
||||
async def on_client_connected(transport, client):
|
||||
logger.info(f"Client connected")
|
||||
context.add_message(
|
||||
{"role": "developer", "content": "Please introduce yourself to the user."}
|
||||
)
|
||||
await task.queue_frames([LLMRunFrame()])
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
logger.info(f"Client disconnected")
|
||||
await task.cancel()
|
||||
|
||||
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
|
||||
await runner.run(task)
|
||||
|
||||
|
||||
async def bot(runner_args: RunnerArguments):
|
||||
"""Main bot entry point compatible with Pipecat Cloud."""
|
||||
transport = await create_transport(runner_args, transport_params)
|
||||
await run_bot(transport, runner_args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pipecat.runner.run import main
|
||||
|
||||
main()
|
||||
182
examples/realtime/realtime-openai-local-vad.py
Normal file
182
examples/realtime/realtime-openai-local-vad.py
Normal file
@@ -0,0 +1,182 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
from pipecat.frames.frames import LLMRunFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
AssistantTurnStoppedMessage,
|
||||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
UserMessageFinalizedMessage,
|
||||
UserTurnStoppedMessage,
|
||||
)
|
||||
from pipecat.runner.types import RunnerArguments
|
||||
from pipecat.runner.utils import create_transport
|
||||
from pipecat.services.openai.realtime.events import (
|
||||
AudioConfiguration,
|
||||
AudioInput,
|
||||
InputAudioTranscription,
|
||||
SessionProperties,
|
||||
)
|
||||
from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMService
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
# We use lambdas to defer transport parameter creation until the transport
|
||||
# type is selected at runtime.
|
||||
transport_params = {
|
||||
"daily": lambda: DailyParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
"twilio": lambda: FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
"webrtc": lambda: TransportParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info(f"Starting bot")
|
||||
|
||||
# `turn_detection=False` disables OpenAI Realtime's server-side VAD,
|
||||
# so this pipeline's local turn detection drives turn boundaries.
|
||||
# The service then sends `input_audio_buffer.commit` +
|
||||
# `response.create` when it sees `UserStoppedSpeakingFrame`.
|
||||
llm = OpenAIRealtimeLLMService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAIRealtimeLLMService.Settings(
|
||||
session_properties=SessionProperties(
|
||||
audio=AudioConfiguration(
|
||||
input=AudioInput(
|
||||
transcription=InputAudioTranscription(),
|
||||
turn_detection=False,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
context = LLMContext(
|
||||
[
|
||||
{
|
||||
"role": "developer",
|
||||
"content": "Say hello. Then ask if I want to hear a joke.",
|
||||
},
|
||||
],
|
||||
)
|
||||
# `wait_for_transcript_to_end_user_turn=False` is the right setting
|
||||
# for pipelines like this one — local turn detection driving a
|
||||
# realtime service. It avoids unnecessary latency from transcript
|
||||
# delay: the realtime service consumes user audio directly, so
|
||||
# we don't need user transcripts in context before it can respond.
|
||||
# With this option:
|
||||
#
|
||||
# - Turn strategies do not consider user transcripts, so the user
|
||||
# turn ends sooner.
|
||||
# - User transcripts are handled by the aggregator: a simple
|
||||
# post-turn transcript wait gives them time to arrive after the
|
||||
# user turn ends, then the aggregator emits
|
||||
# `on_user_turn_message_finalized` with the new user context
|
||||
# message.
|
||||
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(
|
||||
context,
|
||||
user_params=LLMUserAggregatorParams(
|
||||
vad_analyzer=SileroVADAnalyzer(),
|
||||
wait_for_transcript_to_end_user_turn=False,
|
||||
),
|
||||
)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
user_aggregator,
|
||||
llm,
|
||||
transport.output(),
|
||||
assistant_aggregator,
|
||||
]
|
||||
)
|
||||
|
||||
task = PipelineTask(
|
||||
pipeline,
|
||||
params=PipelineParams(
|
||||
enable_metrics=True,
|
||||
enable_usage_metrics=True,
|
||||
),
|
||||
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
|
||||
)
|
||||
|
||||
@transport.event_handler("on_client_connected")
|
||||
async def on_client_connected(transport, client):
|
||||
logger.info(f"Client connected")
|
||||
# Kick off the conversation.
|
||||
await task.queue_frames([LLMRunFrame()])
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
logger.info(f"Client disconnected")
|
||||
await task.cancel()
|
||||
|
||||
# `on_user_turn_stopped` fires at the end of the user turn. With
|
||||
# `wait_for_transcript_to_end_user_turn=False`, no user
|
||||
# transcripts have arrived yet at this point, so
|
||||
# `message.content` is empty. Logged here to make the end-of-turn
|
||||
# signal visible alongside the later finalization event.
|
||||
@user_aggregator.event_handler("on_user_turn_stopped")
|
||||
async def on_user_turn_stopped(aggregator, strategy, message: UserTurnStoppedMessage):
|
||||
logger.info(f"User turn ended (strategy: {type(strategy).__name__})")
|
||||
|
||||
# `on_user_turn_message_finalized` fires when the user message has
|
||||
# been finalized into the context. Here it fires later than
|
||||
# `on_user_turn_stopped`, after the aggregator's post-turn
|
||||
# transcript wait completes.
|
||||
@user_aggregator.event_handler("on_user_turn_message_finalized")
|
||||
async def on_user_turn_message_finalized(
|
||||
aggregator, strategy, message: UserMessageFinalizedMessage
|
||||
):
|
||||
timestamp = f"[{message.timestamp}] " if message.timestamp else ""
|
||||
line = f"{timestamp}user: {message.content}"
|
||||
logger.info(f"Transcript: {line}")
|
||||
|
||||
@assistant_aggregator.event_handler("on_assistant_turn_stopped")
|
||||
async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage):
|
||||
timestamp = f"[{message.timestamp}] " if message.timestamp else ""
|
||||
line = f"{timestamp}assistant: {message.content}"
|
||||
logger.info(f"Transcript: {line}")
|
||||
|
||||
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
|
||||
|
||||
await runner.run(task)
|
||||
|
||||
|
||||
async def bot(runner_args: RunnerArguments):
|
||||
"""Main bot entry point compatible with Pipecat Cloud."""
|
||||
transport = await create_transport(runner_args, transport_params)
|
||||
await run_bot(transport, runner_args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pipecat.runner.run import main
|
||||
|
||||
main()
|
||||
@@ -232,6 +232,20 @@ Remember, your responses should be short. Just one or two sentences, usually. Re
|
||||
# [LLMUpdateSettingsFrame(settings=SessionProperties(tools=new_tools).model_dump())]
|
||||
# )
|
||||
|
||||
# Reasoning effort can be changed at runtime too. Only
|
||||
# reasoning-capable Realtime models (e.g. gpt-realtime-2) support this.
|
||||
# await task.queue_frames(
|
||||
# [
|
||||
# LLMUpdateSettingsFrame(
|
||||
# delta=OpenAIRealtimeLLMService.Settings(
|
||||
# session_properties=SessionProperties(
|
||||
# reasoning=Reasoning(effort="xhigh"),
|
||||
# ),
|
||||
# )
|
||||
# )
|
||||
# ]
|
||||
# )
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
logger.info(f"Client disconnected")
|
||||
|
||||
186
examples/realtime/realtime-ultravox-async-tool.py
Normal file
186
examples/realtime/realtime-ultravox-async-tool.py
Normal file
@@ -0,0 +1,186 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Example: async function call with the Ultravox Realtime LLM service.
|
||||
|
||||
The ``get_current_weather`` tool is registered with
|
||||
``cancel_on_interruption=False`` and simulates a slow API call (10s sleep).
|
||||
|
||||
Ultravox's API freezes the conversation between ``client_tool_invocation``
|
||||
and the matching ``client_tool_result``, so the service ships a placeholder
|
||||
``client_tool_result`` immediately when an async-registered function is
|
||||
invoked (to unfreeze the conversation). When the real tool finishes, the
|
||||
actual result is injected as user-side text so the model picks it up.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import os
|
||||
import random
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.runner.types import RunnerArguments
|
||||
from pipecat.runner.utils import create_transport
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
from pipecat.services.ultravox.llm import OneShotInputParams, UltravoxRealtimeLLMService
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||
from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy
|
||||
from pipecat.turns.user_turn_strategies import UserTurnStrategies
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
async def fetch_weather_from_api(params: FunctionCallParams):
|
||||
# Simulate a long-running API call so we can demonstrate that the
|
||||
# conversation continues while the tool is in flight.
|
||||
await asyncio.sleep(10)
|
||||
temperature = (
|
||||
random.randint(60, 85)
|
||||
if params.arguments["format"] == "fahrenheit"
|
||||
else random.randint(15, 30)
|
||||
)
|
||||
await params.result_callback(
|
||||
{
|
||||
"conditions": "nice",
|
||||
"temperature": temperature,
|
||||
"location": params.arguments["location"],
|
||||
"format": params.arguments["format"],
|
||||
"timestamp": datetime.datetime.now().strftime("%Y%m%d_%H%M%S"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
weather_function = FunctionSchema(
|
||||
name="get_current_weather",
|
||||
description="Get the current weather",
|
||||
properties={
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "The city and state, e.g. San Francisco, CA",
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["celsius", "fahrenheit"],
|
||||
"description": "The temperature unit to use. Infer this from the users location.",
|
||||
},
|
||||
},
|
||||
required=["location", "format"],
|
||||
)
|
||||
|
||||
|
||||
system_prompt = (
|
||||
"You are a friendly assistant. The user and you will engage in a spoken "
|
||||
"dialog exchanging the transcripts of a natural real-time conversation. "
|
||||
"Keep your responses short, generally two or three sentences for chatty "
|
||||
"scenarios. When the user asks for the weather, call get_current_weather. "
|
||||
"While you wait for the result, keep chatting with the user. When the "
|
||||
"result arrives, share it with the user naturally."
|
||||
)
|
||||
|
||||
|
||||
transport_params = {
|
||||
"daily": lambda: DailyParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
"twilio": lambda: FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
"webrtc": lambda: TransportParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info(f"Starting bot")
|
||||
|
||||
llm = UltravoxRealtimeLLMService(
|
||||
params=OneShotInputParams(
|
||||
api_key=os.environ["ULTRAVOX_API_KEY"],
|
||||
system_prompt=system_prompt,
|
||||
temperature=0.3,
|
||||
max_duration=datetime.timedelta(minutes=3),
|
||||
),
|
||||
one_shot_selected_tools=ToolsSchema(standard_tools=[weather_function]),
|
||||
)
|
||||
|
||||
llm.register_function(
|
||||
"get_current_weather",
|
||||
fetch_weather_from_api,
|
||||
cancel_on_interruption=False,
|
||||
)
|
||||
|
||||
context = LLMContext([])
|
||||
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(
|
||||
context,
|
||||
user_params=LLMUserAggregatorParams(
|
||||
user_turn_strategies=UserTurnStrategies(
|
||||
stop=[SpeechTimeoutUserTurnStopStrategy()],
|
||||
),
|
||||
vad_analyzer=SileroVADAnalyzer(),
|
||||
),
|
||||
)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
user_aggregator,
|
||||
llm,
|
||||
transport.output(),
|
||||
assistant_aggregator,
|
||||
]
|
||||
)
|
||||
|
||||
task = PipelineTask(
|
||||
pipeline,
|
||||
params=PipelineParams(
|
||||
enable_metrics=True,
|
||||
enable_usage_metrics=True,
|
||||
),
|
||||
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
|
||||
)
|
||||
|
||||
@transport.event_handler("on_client_connected")
|
||||
async def on_client_connected(transport, client):
|
||||
logger.info(f"Client connected")
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
logger.info(f"Client disconnected")
|
||||
await task.cancel()
|
||||
|
||||
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
|
||||
await runner.run(task)
|
||||
|
||||
|
||||
async def bot(runner_args: RunnerArguments):
|
||||
"""Main bot entry point compatible with Pipecat Cloud."""
|
||||
transport = await create_transport(runner_args, transport_params)
|
||||
await run_bot(transport, runner_args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pipecat.runner.run import main
|
||||
|
||||
main()
|
||||
@@ -49,13 +49,7 @@ transport_params = {
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info(f"Starting bot")
|
||||
|
||||
stt = OpenAIRealtimeSTTService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAIRealtimeSTTService.Settings(
|
||||
model="gpt-4o-transcribe",
|
||||
prompt="Expect words related to dogs, such as breed names.",
|
||||
),
|
||||
)
|
||||
stt = OpenAIRealtimeSTTService(api_key=os.environ["OPENAI_API_KEY"])
|
||||
|
||||
tl = TranscriptionLogger()
|
||||
vad_processor = VADProcessor(vad_analyzer=SileroVADAnalyzer())
|
||||
|
||||
@@ -10,7 +10,7 @@ Demonstrates LLM-based turn completion detection to suppress bot responses when
|
||||
the user was cut off mid-thought. The LLM outputs one of three markers:
|
||||
- ✓ (complete): User finished their thought, respond normally
|
||||
- ○ (incomplete short): User was cut off, wait ~5s then prompt
|
||||
- ◐ (incomplete long): User needs time to think, wait ~15s then prompt
|
||||
- ◐ (incomplete long): User needs time to think, wait ~10s then prompt
|
||||
|
||||
When incomplete is detected, the bot's response is suppressed. After the timeout
|
||||
expires, the LLM is automatically prompted to re-engage the user.
|
||||
@@ -41,6 +41,7 @@ from pipecat.services.openai.llm import OpenAILLMService
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||
from pipecat.turns.user_turn_strategies import FilterIncompleteUserTurnStrategies
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
@@ -83,23 +84,28 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
)
|
||||
|
||||
context = LLMContext()
|
||||
# `FilterIncompleteUserTurnStrategies` pairs the default detector
|
||||
# chain with `LLMTurnCompletionUserTurnStopStrategy`: detectors
|
||||
# trigger LLM inference but the public `on_user_turn_stopped` event
|
||||
# fires only when the LLM confirms ✓. The LLM marks each response
|
||||
# with one of:
|
||||
# ✓ = complete (respond normally)
|
||||
# ○ = incomplete short (wait 5s, then prompt)
|
||||
# ◐ = incomplete long (wait 10s, then prompt)
|
||||
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(
|
||||
context,
|
||||
user_params=LLMUserAggregatorParams(
|
||||
vad_analyzer=SileroVADAnalyzer(),
|
||||
# Enable turn completion filtering - the LLM will output:
|
||||
# ✓ = complete (respond normally)
|
||||
# ○ = incomplete short (wait 5s, then prompt)
|
||||
# ◐ = incomplete long (wait 15s, then prompt)
|
||||
filter_incomplete_user_turns=True,
|
||||
# Optional: customize turn completion behavior
|
||||
# turn_completion_config=TurnCompletionConfig(
|
||||
# incomplete_short_timeout=5.0,
|
||||
# incomplete_long_timeout=15.0,
|
||||
# incomplete_short_prompt="Custom prompt...",
|
||||
# incomplete_long_prompt="Custom prompt...",
|
||||
# instructions="Custom turn completion instructions...",
|
||||
# ),
|
||||
user_turn_strategies=FilterIncompleteUserTurnStrategies(
|
||||
# Optional: customize turn completion behavior
|
||||
# config=UserTurnCompletionConfig(
|
||||
# incomplete_short_timeout=5.0,
|
||||
# incomplete_long_timeout=10.0,
|
||||
# incomplete_short_prompt="Custom prompt...",
|
||||
# incomplete_long_prompt="Custom prompt...",
|
||||
# instructions="Custom turn completion instructions...",
|
||||
# ),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
129
examples/voice/voice-nvidia-sagemaker.py
Normal file
129
examples/voice/voice-nvidia-sagemaker.py
Normal file
@@ -0,0 +1,129 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
# For a full example of how to deploy to SageMaker, see:
|
||||
# https://github.com/pipecat-ai/pipecat-examples/tree/main/nvidia_sagemaker_example/deployment/aws-sagemaker-nvidia
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
from pipecat.frames.frames import LLMRunFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.runner.types import RunnerArguments
|
||||
from pipecat.runner.utils import create_transport
|
||||
from pipecat.services.nvidia.llm import NvidiaLLMService
|
||||
from pipecat.services.nvidia.sagemaker.stt import NvidiaSageMakerSTTService
|
||||
from pipecat.services.nvidia.sagemaker.tts import NvidiaSageMakerTTSService
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
# We use lambdas to defer transport parameter creation until the transport
|
||||
# type is selected at runtime.
|
||||
transport_params = {
|
||||
"daily": lambda: DailyParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
"twilio": lambda: FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
"webrtc": lambda: TransportParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info(f"Starting bot")
|
||||
|
||||
stt = NvidiaSageMakerSTTService(
|
||||
endpoint_name=os.environ["SAGEMAKER_ASR_ENDPOINT_NAME"],
|
||||
region=os.getenv("AWS_REGION", "us-west-2"),
|
||||
)
|
||||
|
||||
llm = NvidiaLLMService(
|
||||
api_key=os.environ["NVIDIA_API_KEY"],
|
||||
settings=NvidiaLLMService.Settings(
|
||||
model="meta/llama-3.3-70b-instruct",
|
||||
system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.",
|
||||
),
|
||||
)
|
||||
|
||||
tts = NvidiaSageMakerTTSService(
|
||||
endpoint_name=os.environ["SAGEMAKER_MAGPIE_ENDPOINT_NAME"],
|
||||
region=os.getenv("AWS_REGION", "us-west-2"),
|
||||
)
|
||||
|
||||
context = LLMContext()
|
||||
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(
|
||||
context,
|
||||
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
|
||||
)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(), # Transport user input
|
||||
stt, # STT
|
||||
user_aggregator, # User responses
|
||||
llm, # LLM
|
||||
tts, # TTS
|
||||
transport.output(), # Transport bot output
|
||||
assistant_aggregator, # Assistant spoken responses
|
||||
]
|
||||
)
|
||||
|
||||
task = PipelineTask(
|
||||
pipeline,
|
||||
params=PipelineParams(
|
||||
enable_metrics=True,
|
||||
enable_usage_metrics=True,
|
||||
),
|
||||
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
|
||||
)
|
||||
|
||||
@transport.event_handler("on_client_connected")
|
||||
async def on_client_connected(transport, client):
|
||||
logger.info(f"Client connected")
|
||||
# Kick off the conversation.
|
||||
context.add_message(
|
||||
{"role": "developer", "content": "Please introduce yourself to the user."}
|
||||
)
|
||||
await task.queue_frames([LLMRunFrame()])
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
logger.info(f"Client disconnected")
|
||||
await task.cancel()
|
||||
|
||||
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
|
||||
|
||||
await runner.run(task)
|
||||
|
||||
|
||||
async def bot(runner_args: RunnerArguments):
|
||||
"""Main bot entry point compatible with Pipecat Cloud."""
|
||||
transport = await create_transport(runner_args, transport_params)
|
||||
await run_bot(transport, runner_args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pipecat.runner.run import main
|
||||
|
||||
main()
|
||||
@@ -25,7 +25,6 @@ from pipecat.runner.utils import create_transport
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
from pipecat.services.openai.stt import OpenAIRealtimeSTTService
|
||||
from pipecat.services.openai.tts import OpenAITTSService
|
||||
from pipecat.transcriptions.language import Language
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
from pipecat.transports.daily.transport import DailyParams
|
||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||
@@ -53,14 +52,7 @@ transport_params = {
|
||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info(f"Starting bot")
|
||||
|
||||
stt = OpenAIRealtimeSTTService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAIRealtimeSTTService.Settings(
|
||||
model="gpt-4o-transcribe",
|
||||
prompt="Expect words related to dogs, such as breed names.",
|
||||
language=Language.EN,
|
||||
),
|
||||
)
|
||||
stt = OpenAIRealtimeSTTService(api_key=os.environ["OPENAI_API_KEY"])
|
||||
|
||||
tts = OpenAITTSService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
@@ -72,7 +64,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
llm = OpenAILLMService(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
settings=OpenAILLMService.Settings(
|
||||
system_instruction="You are very knowledgable about dogs. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. Respond to what the user said in a creative and helpful way.",
|
||||
system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -223,12 +223,11 @@ TESTS_REALTIME = [
|
||||
# ("realtime/realtime-azure.py", EVAL_WEATHER),
|
||||
("realtime/realtime-openai-text.py", EVAL_WEATHER),
|
||||
("realtime/realtime-openai-live-video.py", EVAL_VISION_CAMERA),
|
||||
("realtime/realtime-gemini-live.py", EVAL_SIMPLE_MATH),
|
||||
("realtime/realtime-gemini-live.py", EVAL_WEATHER),
|
||||
("realtime/realtime-gemini-live-local-vad.py", EVAL_SIMPLE_MATH),
|
||||
("realtime/realtime-gemini-live-function-calling.py", EVAL_WEATHER),
|
||||
("realtime/realtime-gemini-live-video.py", EVAL_VISION_CAMERA),
|
||||
("realtime/realtime-gemini-live-google-search.py", EVAL_ONLINE_SEARCH),
|
||||
("realtime/realtime-gemini-live-vertex-function-calling.py", EVAL_WEATHER),
|
||||
("realtime/realtime-gemini-live-vertex.py", EVAL_WEATHER),
|
||||
("realtime/realtime-aws-nova-sonic.py", EVAL_SIMPLE_MATH),
|
||||
("realtime/realtime-ultravox.py", EVAL_ORDER),
|
||||
("realtime/realtime-grok.py", EVAL_WEATHER),
|
||||
|
||||
@@ -139,6 +139,36 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]):
|
||||
|
||||
return formatted_standard_tools + custom_gemini_tools
|
||||
|
||||
@staticmethod
|
||||
def to_function_response_dict(content: Any) -> dict[str, Any]:
|
||||
"""Convert a tool-result content value to Gemini's FunctionResponse.response shape.
|
||||
|
||||
Gemini's ``FunctionResponse.response`` field requires a dict, so
|
||||
non-dict values (e.g. plain strings, JSON-encoded scalars, or
|
||||
sentinel strings like ``"COMPLETED"`` used when a function returned
|
||||
no value) are wrapped as ``{"value": <value>}``. JSON strings that
|
||||
decode to a dict are passed through as-is.
|
||||
|
||||
Args:
|
||||
content: The tool-result content. Typically the JSON-encoded
|
||||
return value of a function, but can also be a plain string
|
||||
(e.g. ``"COMPLETED"``) or already-parsed dict.
|
||||
|
||||
Returns:
|
||||
A dict suitable for ``FunctionResponse.response``.
|
||||
"""
|
||||
if isinstance(content, dict):
|
||||
return content
|
||||
if not isinstance(content, str):
|
||||
return {"value": content}
|
||||
try:
|
||||
decoded = json.loads(content)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return {"value": content}
|
||||
if isinstance(decoded, dict):
|
||||
return decoded
|
||||
return {"value": decoded}
|
||||
|
||||
def get_messages_for_logging(self, context: LLMContext) -> list[dict[str, Any]]:
|
||||
"""Get messages from a universal LLM context in a format ready for logging about Gemini.
|
||||
|
||||
@@ -382,16 +412,7 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]):
|
||||
)
|
||||
elif role == "tool":
|
||||
role = "user"
|
||||
try:
|
||||
response = json.loads(msg["content"])
|
||||
if isinstance(response, dict):
|
||||
response_dict = response
|
||||
else:
|
||||
response_dict = {"value": response}
|
||||
except Exception as e:
|
||||
# Response might not be JSON-deserializable.
|
||||
# This occurs with a UserImageFrame, for example, where we get a plain "COMPLETED" string.
|
||||
response_dict = {"value": msg["content"]}
|
||||
response_dict = self.to_function_response_dict(msg["content"])
|
||||
|
||||
# Get function name from mapping using tool_call_id, or fallback
|
||||
tool_call_id = msg.get("tool_call_id")
|
||||
|
||||
@@ -10,6 +10,8 @@ This module provides an audio resampler that uses the resampy library
|
||||
for high-quality audio sample rate conversion.
|
||||
"""
|
||||
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
import resampy
|
||||
|
||||
@@ -21,6 +23,11 @@ class ResampyResampler(BaseAudioResampler):
|
||||
|
||||
This resampler uses the resampy library's Kaiser windowing filter
|
||||
for high-quality audio resampling with good performance characteristics.
|
||||
|
||||
.. deprecated:: 1.2.0
|
||||
ResampyResampler is deprecated and will be removed in Pipecat 2.0.
|
||||
Use SOXRAudioResampler, create_file_resampler(), or create_stream_resampler()
|
||||
instead.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
@@ -29,7 +36,15 @@ class ResampyResampler(BaseAudioResampler):
|
||||
Args:
|
||||
**kwargs: Additional keyword arguments (currently unused).
|
||||
"""
|
||||
pass
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"ResampyResampler is deprecated and will be removed in Pipecat 2.0. "
|
||||
"Use SOXRAudioResampler, create_file_resampler(), or "
|
||||
"create_stream_resampler() instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
async def resample(self, audio: bytes, in_rate: int, out_rate: int) -> bytes:
|
||||
"""Resample audio data using resampy library.
|
||||
|
||||
@@ -339,6 +339,40 @@ class LLMTextFrame(TextFrame):
|
||||
self.includes_inter_frame_spaces = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMMarkerFrame(DataFrame):
|
||||
"""Sideband marker emitted by an LLM service.
|
||||
|
||||
A marker is short, structured assistant output that should be
|
||||
persisted in the conversation context but should not flow through
|
||||
the standard text path (TTS, transcript). The assistant aggregator
|
||||
writes the marker to the context so the LLM can self-condition on
|
||||
prior markers on subsequent turns.
|
||||
|
||||
The primary use today is the ``filter_incomplete_user_turns``
|
||||
protocol, where ``UserTurnCompletionLLMServiceMixin`` emits the
|
||||
turn-completion markers ✓ / ○ / ◐ on every response. The frame is
|
||||
intentionally generic so other components — STT services with
|
||||
built-in turn signals, end-of-turn classifiers, custom annotations,
|
||||
etc. — can use the same mechanism to inject sideband signals into
|
||||
the assistant context.
|
||||
|
||||
Parameters:
|
||||
marker: The marker payload (typically a short string such as a
|
||||
single character).
|
||||
append_to_context_immediately: If True, the marker is written
|
||||
to the context as its own standalone assistant message as
|
||||
soon as it's received. If False, the marker is appended to
|
||||
the running assistant aggregation and flushed to the
|
||||
context together with the following text as a single
|
||||
message (e.g. for the ✓ case the context message ends up
|
||||
as "✓ <response>").
|
||||
"""
|
||||
|
||||
marker: str
|
||||
append_to_context_immediately: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class AggregatedTextFrame(TextFrame):
|
||||
"""Text frame representing an aggregation of TextFrames.
|
||||
@@ -661,6 +695,11 @@ class FunctionCallResultProperties:
|
||||
is_final: Whether this is the final result for the function call. When
|
||||
``False`` the result is treated as an intermediate update. Defaults to ``True``.
|
||||
Only meaningful for async function calls (``cancel_on_interruption=False``).
|
||||
Note: realtime LLM services do not support streamed intermediate
|
||||
results; they deliver only the final result to the provider. An
|
||||
intermediate result reported to a realtime service is dropped
|
||||
and an error is raised. Use a non-realtime LLM service if your
|
||||
tool needs to stream intermediate results.
|
||||
"""
|
||||
|
||||
run_llm: bool | None = None
|
||||
@@ -970,6 +1009,24 @@ class UserSpeakingFrame(SystemFrame):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserTurnInferenceCompletedFrame(SystemFrame):
|
||||
"""Frame indicating that the user turn is semantically complete.
|
||||
|
||||
Emitted by any component that can judge conversational turn
|
||||
completeness — for example an LLM with turn-completion markers, an
|
||||
STT service with built-in turn detection, or a dedicated
|
||||
end-of-turn classifier. Stop strategies that gate the
|
||||
user-turn-stop event on an external completeness signal (e.g.
|
||||
``LLMTurnCompletionUserTurnStopStrategy``) consume this frame to
|
||||
finalize the turn. Producers should emit this frame only when they
|
||||
judge the turn complete; an absence of this frame means the turn is
|
||||
not yet considered complete.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class VADUserStartedSpeakingFrame(SystemFrame):
|
||||
"""Frame emitted when VAD definitively detects user started speaking.
|
||||
|
||||
@@ -303,7 +303,7 @@ class PipelineTask(BasePipelineTask):
|
||||
|
||||
# This task maneger will handle all the asyncio tasks created by this
|
||||
# PipelineTask and its frame processors.
|
||||
self._task_manager = task_manager or TaskManager()
|
||||
self._pipeline_task_manager = task_manager or TaskManager()
|
||||
|
||||
# This queue is the queue used to push frames to the pipeline.
|
||||
self._push_queue = asyncio.Queue()
|
||||
@@ -386,7 +386,7 @@ class PipelineTask(BasePipelineTask):
|
||||
# The task observer acts as a proxy to the provided observers. This way,
|
||||
# we only need to pass a single observer (using the StartFrame) which
|
||||
# then just acts as a proxy.
|
||||
self._observer = TaskObserver(observers=observers, task_manager=self._task_manager)
|
||||
self._observer = TaskObserver(observers=observers)
|
||||
|
||||
# These events can be used to check which frames make it to the source
|
||||
# or sink processors. Instead of calling the event handlers for every
|
||||
@@ -654,32 +654,24 @@ class PipelineTask(BasePipelineTask):
|
||||
|
||||
async def _create_tasks(self):
|
||||
"""Create and start all pipeline processing tasks."""
|
||||
self._process_push_task = self._task_manager.create_task(
|
||||
self._process_push_queue(), f"{self}::_process_push_queue"
|
||||
)
|
||||
self._process_push_task = self.create_task(self._process_push_queue())
|
||||
return self._process_push_task
|
||||
|
||||
def _maybe_start_heartbeat_tasks(self):
|
||||
"""Start heartbeat tasks if heartbeats are enabled and not already running."""
|
||||
if self._params.enable_heartbeats and self._heartbeat_push_task is None:
|
||||
self._heartbeat_push_task = self._task_manager.create_task(
|
||||
self._heartbeat_push_handler(), f"{self}::_heartbeat_push_handler"
|
||||
)
|
||||
self._heartbeat_monitor_task = self._task_manager.create_task(
|
||||
self._heartbeat_monitor_handler(), f"{self}::_heartbeat_monitor_handler"
|
||||
)
|
||||
self._heartbeat_push_task = self.create_task(self._heartbeat_push_handler())
|
||||
self._heartbeat_monitor_task = self.create_task(self._heartbeat_monitor_handler())
|
||||
|
||||
def _maybe_start_idle_task(self):
|
||||
"""Start idle monitoring task if idle timeout is configured."""
|
||||
if self._idle_timeout_secs:
|
||||
self._idle_monitor_task = self._task_manager.create_task(
|
||||
self._idle_monitor_handler(), f"{self}::_idle_monitor_handler"
|
||||
)
|
||||
self._idle_monitor_task = self.create_task(self._idle_monitor_handler())
|
||||
|
||||
async def _cancel_tasks(self):
|
||||
"""Cancel all running pipeline tasks."""
|
||||
if self._process_push_task:
|
||||
await self._task_manager.cancel_task(self._process_push_task)
|
||||
await self.cancel_task(self._process_push_task)
|
||||
self._process_push_task = None
|
||||
|
||||
await self._maybe_cancel_heartbeat_tasks()
|
||||
@@ -691,17 +683,17 @@ class PipelineTask(BasePipelineTask):
|
||||
return
|
||||
|
||||
if self._heartbeat_push_task:
|
||||
await self._task_manager.cancel_task(self._heartbeat_push_task)
|
||||
await self.cancel_task(self._heartbeat_push_task)
|
||||
self._heartbeat_push_task = None
|
||||
|
||||
if self._heartbeat_monitor_task:
|
||||
await self._task_manager.cancel_task(self._heartbeat_monitor_task)
|
||||
await self.cancel_task(self._heartbeat_monitor_task)
|
||||
self._heartbeat_monitor_task = None
|
||||
|
||||
async def _maybe_cancel_idle_task(self):
|
||||
"""Cancel idle monitoring task if it is running."""
|
||||
if self._idle_monitor_task:
|
||||
await self._task_manager.cancel_task(self._idle_monitor_task)
|
||||
await self.cancel_task(self._idle_monitor_task)
|
||||
self._idle_monitor_task = None
|
||||
|
||||
def _initial_metrics_frame(self) -> MetricsFrame:
|
||||
@@ -759,12 +751,14 @@ class PipelineTask(BasePipelineTask):
|
||||
|
||||
async def _setup(self, params: PipelineTaskParams):
|
||||
"""Set up the pipeline task and all processors."""
|
||||
await super().setup(self._pipeline_task_manager)
|
||||
|
||||
mgr_params = TaskManagerParams(loop=params.loop)
|
||||
self._task_manager.setup(mgr_params)
|
||||
self.task_manager.setup(mgr_params)
|
||||
|
||||
setup = FrameProcessorSetup(
|
||||
clock=self._clock,
|
||||
task_manager=self._task_manager,
|
||||
task_manager=self.task_manager,
|
||||
observer=self._observer,
|
||||
pipeline_task=self,
|
||||
# Populate the deprecated `tool_resources` field for backwards
|
||||
@@ -780,6 +774,7 @@ class PipelineTask(BasePipelineTask):
|
||||
await self._load_setup_files()
|
||||
|
||||
# Start task observer.
|
||||
await self._observer.setup(self.task_manager)
|
||||
await self._observer.start()
|
||||
|
||||
async def _cleanup(self, cleanup_pipeline: bool):
|
||||
@@ -1019,7 +1014,7 @@ class PipelineTask(BasePipelineTask):
|
||||
|
||||
def _print_dangling_tasks(self):
|
||||
"""Log any dangling tasks that haven't been properly cleaned up."""
|
||||
tasks = [t.get_name() for t in self._task_manager.current_tasks()]
|
||||
tasks = [t.get_name() for t in self.task_manager.current_tasks()]
|
||||
if tasks:
|
||||
logger.warning(f"{self} dangling tasks detected: {tasks}")
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ from typing import Any
|
||||
from attr import dataclass
|
||||
|
||||
from pipecat.observers.base_observer import BaseObserver, FrameProcessed, FramePushed
|
||||
from pipecat.utils.asyncio.task_manager import BaseTaskManager
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -62,19 +61,16 @@ class TaskObserver(BaseObserver):
|
||||
self,
|
||||
*,
|
||||
observers: list[BaseObserver] | None = None,
|
||||
task_manager: BaseTaskManager,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the TaskObserver.
|
||||
|
||||
Args:
|
||||
observers: List of observers to manage. Defaults to empty list.
|
||||
task_manager: Task manager for creating and managing observer tasks.
|
||||
**kwargs: Additional arguments passed to the base observer.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._observers = observers or []
|
||||
self._task_manager = task_manager
|
||||
self._proxies: dict[BaseObserver, Proxy] | None = (
|
||||
None # Becomes a dict after start() is called
|
||||
)
|
||||
@@ -106,7 +102,7 @@ class TaskObserver(BaseObserver):
|
||||
# Remove the proxy so it doesn't get called anymore.
|
||||
del self._proxies[observer]
|
||||
# Cancel the proxy task right away.
|
||||
await self._task_manager.cancel_task(proxy.task)
|
||||
await self.cancel_task(proxy.task)
|
||||
|
||||
# Remove the observer from the list.
|
||||
if observer in self._observers:
|
||||
@@ -122,7 +118,7 @@ class TaskObserver(BaseObserver):
|
||||
return
|
||||
|
||||
for proxy in self._proxies.values():
|
||||
await self._task_manager.cancel_task(proxy.task)
|
||||
await self.cancel_task(proxy.task)
|
||||
|
||||
async def cleanup(self):
|
||||
"""Cleanup all proxy observers."""
|
||||
@@ -157,9 +153,8 @@ class TaskObserver(BaseObserver):
|
||||
def _create_proxy(self, observer: BaseObserver) -> Proxy:
|
||||
"""Create a proxy for a single observer."""
|
||||
queue = asyncio.Queue()
|
||||
task = self._task_manager.create_task(
|
||||
self._proxy_task_handler(queue, observer),
|
||||
f"TaskObserver::{observer}::_proxy_task_handler",
|
||||
task = self.create_task(
|
||||
self._proxy_task_handler(queue, observer), f"{observer}::_proxy_task_handler"
|
||||
)
|
||||
proxy = Proxy(queue=queue, task=task, observer=observer)
|
||||
return proxy
|
||||
|
||||
286
src/pipecat/processors/aggregators/async_tool_messages.py
Normal file
286
src/pipecat/processors/aggregators/async_tool_messages.py
Normal file
@@ -0,0 +1,286 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Helpers for the async-tool message protocol used in LLM contexts.
|
||||
|
||||
When a function is registered with ``cancel_on_interruption=False``, the
|
||||
``LLMUserContextAggregator`` / ``LLMAssistantContextAggregator`` pair appends
|
||||
async-tool messages to the conversation context as the underlying task
|
||||
progresses:
|
||||
|
||||
- A ``started`` message (``role="tool"``) is appended immediately when the
|
||||
tool starts running.
|
||||
- An ``intermediate`` message (``role="developer"``) is appended each time an
|
||||
intermediate result is reported via
|
||||
``result_callback(..., FunctionCallResultProperties(is_final=False))``.
|
||||
- A ``final`` message (``role="developer"``) is appended when the task
|
||||
finishes.
|
||||
|
||||
This module is the single source of truth for the on-the-wire payload shape:
|
||||
|
||||
- The aggregator uses the ``build_*_message`` functions when injecting messages.
|
||||
- Realtime LLM services use ``parse_message`` to detect async-tool messages
|
||||
while iterating the context, then read ``payload.result`` and deliver it via
|
||||
their formal tool-result channel.
|
||||
|
||||
Internally, ``AsyncToolMessagePayload`` is the canonical structured form;
|
||||
the on-the-wire JSON string is always derived from it (never stored) so the
|
||||
two representations can't drift.
|
||||
|
||||
Consumers are expected to import the module rather than its individual
|
||||
functions, e.g.::
|
||||
|
||||
from pipecat.processors.aggregators import async_tool_messages
|
||||
...
|
||||
async_tool_messages.build_started_message(tool_call_id)
|
||||
async_tool_messages.parse_message(msg)
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from pipecat.processors.aggregators.llm_context import LLMStandardMessage
|
||||
|
||||
AsyncToolMessageKind = Literal["started", "intermediate", "final"]
|
||||
|
||||
# --- Payload shape (private; canonical source of truth) ---------------------
|
||||
|
||||
# The ``type`` field that identifies an async-tool message payload. Both the
|
||||
# builders and the parser use this constant; do not duplicate the literal.
|
||||
_PAYLOAD_TYPE = "async_tool"
|
||||
|
||||
# Status value for started / intermediate messages (task still running).
|
||||
_STATUS_RUNNING = "running"
|
||||
|
||||
# Status value for the final message (task complete).
|
||||
_STATUS_FINISHED = "finished"
|
||||
|
||||
# Description shipped on the started message. The text is intentionally
|
||||
# self-explanatory so a model reading the context can tell what's about to
|
||||
# happen even without out-of-band knowledge of the protocol.
|
||||
_STARTED_DESCRIPTION = (
|
||||
"An asynchronous task associated with this tool_call_id has started "
|
||||
"running. Expect results to arrive later as developer messages that look "
|
||||
"roughly like this one (with 'type=async_tool' and a matching tool_call_id) "
|
||||
"but with a 'result' field. Note that there *may* be more than one result "
|
||||
"(i.e., a stream of results), but there doesn't have to be (there may be "
|
||||
"only one). The last result will come in a message with 'status=finished'."
|
||||
)
|
||||
|
||||
# Description shipped on each intermediate-result message.
|
||||
_INTERMEDIATE_DESCRIPTION = (
|
||||
"This is an intermediate result for the asynchronous task associated with "
|
||||
"this tool_call_id. The task is still running. More intermediate results "
|
||||
"may follow, or the next result may be the final one with "
|
||||
"'status=finished'."
|
||||
)
|
||||
|
||||
# Description shipped on the final-result message.
|
||||
_FINAL_DESCRIPTION = (
|
||||
"This is the final result for the asynchronous task associated with this "
|
||||
"tool_call_id. The task has completed. No further results will arrive for "
|
||||
"this tool_call_id."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AsyncToolMessagePayload:
|
||||
"""The structured contents of an async-tool message in an LLM context.
|
||||
|
||||
Parameters:
|
||||
kind: Which of the three async-tool message stages this is.
|
||||
tool_call_id: The id of the tool invocation this payload relates to.
|
||||
status: ``"running"`` for started/intermediate, ``"finished"`` for
|
||||
the final message.
|
||||
description: Human-readable description from the payload. May be empty.
|
||||
result: For ``intermediate`` and ``final`` messages, the JSON-encoded
|
||||
result string (or the literal ``"COMPLETED"`` if the function
|
||||
returned no value). ``None`` for ``started`` messages.
|
||||
"""
|
||||
|
||||
kind: AsyncToolMessageKind
|
||||
tool_call_id: str
|
||||
status: Literal["running", "finished"]
|
||||
description: str
|
||||
result: str | None
|
||||
|
||||
|
||||
# --- Internal: payload ↔ on-the-wire forms -----------------------------------
|
||||
|
||||
|
||||
def _payload_to_json(payload: AsyncToolMessagePayload) -> str:
|
||||
"""Serialize a payload to its on-the-wire JSON string form.
|
||||
|
||||
Fields that don't apply to the payload's kind are omitted (notably
|
||||
``result`` is left out of ``started`` payloads, since the task hasn't
|
||||
produced a result yet).
|
||||
"""
|
||||
obj: dict[str, Any] = {
|
||||
"type": _PAYLOAD_TYPE,
|
||||
"status": payload.status,
|
||||
"tool_call_id": payload.tool_call_id,
|
||||
"description": payload.description,
|
||||
}
|
||||
if payload.result is not None:
|
||||
obj["result"] = payload.result
|
||||
return json.dumps(obj)
|
||||
|
||||
|
||||
def _payload_to_message(payload: AsyncToolMessagePayload) -> LLMStandardMessage:
|
||||
"""Wrap a payload in the LLM context message shape that matches its kind.
|
||||
|
||||
- ``started``: ``role="tool"`` plus ``tool_call_id`` at the top level
|
||||
(so the message can sit alongside other regular tool-result messages).
|
||||
- ``intermediate`` / ``final``: ``role="developer"``; ``tool_call_id``
|
||||
lives only inside the JSON payload.
|
||||
"""
|
||||
content = _payload_to_json(payload)
|
||||
if payload.kind == "started":
|
||||
return {
|
||||
"role": "tool",
|
||||
"content": content,
|
||||
"tool_call_id": payload.tool_call_id,
|
||||
}
|
||||
return {
|
||||
"role": "developer",
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
# --- Builders ----------------------------------------------------------------
|
||||
|
||||
|
||||
def build_started_message(tool_call_id: str) -> LLMStandardMessage:
|
||||
"""Build a ``started`` async-tool message for an LLM context.
|
||||
|
||||
Append the returned message to the LLM context immediately when an async
|
||||
function call (registered with ``cancel_on_interruption=False``) starts
|
||||
running. The message lets the model know a task is in flight and that its
|
||||
results will arrive later in subsequent ``developer``-role messages.
|
||||
|
||||
Args:
|
||||
tool_call_id: The id of the tool invocation this message is for.
|
||||
|
||||
Returns:
|
||||
A message ready to pass to ``LLMContext.add_message``.
|
||||
"""
|
||||
return _payload_to_message(
|
||||
AsyncToolMessagePayload(
|
||||
kind="started",
|
||||
tool_call_id=tool_call_id,
|
||||
status=_STATUS_RUNNING,
|
||||
description=_STARTED_DESCRIPTION,
|
||||
result=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_intermediate_result_message(tool_call_id: str, result: str) -> LLMStandardMessage:
|
||||
"""Build an intermediate-result async-tool message for an LLM context.
|
||||
|
||||
Append the returned message to the LLM context each time the running async
|
||||
function reports a non-final result via
|
||||
``result_callback(..., FunctionCallResultProperties(is_final=False))``.
|
||||
|
||||
Args:
|
||||
tool_call_id: The id of the tool invocation the result is for.
|
||||
result: The JSON-encoded result string (caller is responsible for
|
||||
encoding the function's actual return value, typically via
|
||||
``json.dumps``).
|
||||
|
||||
Returns:
|
||||
A message ready to pass to ``LLMContext.add_message``.
|
||||
"""
|
||||
return _payload_to_message(
|
||||
AsyncToolMessagePayload(
|
||||
kind="intermediate",
|
||||
tool_call_id=tool_call_id,
|
||||
status=_STATUS_RUNNING,
|
||||
description=_INTERMEDIATE_DESCRIPTION,
|
||||
result=result,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_final_result_message(tool_call_id: str, result: str) -> LLMStandardMessage:
|
||||
"""Build a final-result async-tool message for an LLM context.
|
||||
|
||||
Append the returned message to the LLM context when the async function
|
||||
finishes. After this message no further async-tool messages will arrive
|
||||
for this ``tool_call_id``.
|
||||
|
||||
Args:
|
||||
tool_call_id: The id of the tool invocation the result is for.
|
||||
result: The JSON-encoded result string, or the literal ``"COMPLETED"``
|
||||
sentinel when the function returned ``None`` (matching the same
|
||||
convention used for synchronous tool calls).
|
||||
|
||||
Returns:
|
||||
A message ready to pass to ``LLMContext.add_message``.
|
||||
"""
|
||||
return _payload_to_message(
|
||||
AsyncToolMessagePayload(
|
||||
kind="final",
|
||||
tool_call_id=tool_call_id,
|
||||
status=_STATUS_FINISHED,
|
||||
description=_FINAL_DESCRIPTION,
|
||||
result=result,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# --- Parsing -----------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_message(message: LLMStandardMessage) -> AsyncToolMessagePayload | None:
|
||||
"""Decode an async-tool message payload, or return None if not async-tool.
|
||||
|
||||
Args:
|
||||
message: A standard message from the LLM context. Callers iterating
|
||||
over ``LLMContext.get_messages()`` should filter out
|
||||
``LLMSpecificMessage`` entries first; only ``LLMStandardMessage``
|
||||
values can carry async-tool payloads.
|
||||
|
||||
Returns:
|
||||
An ``AsyncToolMessagePayload`` if the message is a recognized
|
||||
async-tool payload, otherwise ``None``.
|
||||
"""
|
||||
role = message.get("role")
|
||||
if role not in ("tool", "developer"):
|
||||
return None
|
||||
content = message.get("content")
|
||||
if not isinstance(content, str):
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(content)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(payload, dict) or payload.get("type") != _PAYLOAD_TYPE:
|
||||
return None
|
||||
tool_call_id = payload.get("tool_call_id")
|
||||
status = payload.get("status")
|
||||
if not isinstance(tool_call_id, str) or status not in (_STATUS_RUNNING, _STATUS_FINISHED):
|
||||
return None
|
||||
description = payload.get("description", "")
|
||||
if not isinstance(description, str):
|
||||
description = ""
|
||||
result = payload.get("result")
|
||||
if result is not None and not isinstance(result, str):
|
||||
result = None
|
||||
if result is None:
|
||||
kind: AsyncToolMessageKind = "started"
|
||||
elif status == _STATUS_FINISHED:
|
||||
kind = "final"
|
||||
else:
|
||||
kind = "intermediate"
|
||||
return AsyncToolMessagePayload(
|
||||
kind=kind,
|
||||
tool_call_id=tool_call_id,
|
||||
status=status,
|
||||
description=description,
|
||||
result=result,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ import asyncio
|
||||
import dataclasses
|
||||
import traceback
|
||||
import warnings
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import (
|
||||
@@ -217,9 +217,6 @@ class FrameProcessor(BaseObject):
|
||||
# Clock
|
||||
self._clock: BaseClock | None = None
|
||||
|
||||
# Task Manager
|
||||
self._task_manager: BaseTaskManager | None = None
|
||||
|
||||
# Observer
|
||||
self._observer: BaseObserver | None = None
|
||||
|
||||
@@ -368,20 +365,6 @@ class FrameProcessor(BaseObject):
|
||||
"""
|
||||
return self._report_only_initial_ttfb
|
||||
|
||||
@property
|
||||
def task_manager(self) -> BaseTaskManager:
|
||||
"""Get the task manager for this processor.
|
||||
|
||||
Returns:
|
||||
The task manager instance.
|
||||
|
||||
Raises:
|
||||
Exception: If the task manager is not initialized.
|
||||
"""
|
||||
if not self._task_manager:
|
||||
raise Exception(f"{self} TaskManager is still not initialized.")
|
||||
return self._task_manager
|
||||
|
||||
@property
|
||||
def pipeline_task(self) -> PipelineTask | None:
|
||||
"""Get the :class:`PipelineTask` this processor is running in.
|
||||
@@ -511,43 +494,14 @@ class FrameProcessor(BaseObject):
|
||||
await self.stop_processing_metrics()
|
||||
await self.stop_text_aggregation_metrics()
|
||||
|
||||
def create_task(self, coroutine: Coroutine, name: str | None = None) -> asyncio.Task:
|
||||
"""Create a new task managed by this processor.
|
||||
|
||||
Args:
|
||||
coroutine: The coroutine to run in the task.
|
||||
name: Optional name for the task.
|
||||
|
||||
Returns:
|
||||
The created asyncio task.
|
||||
"""
|
||||
if name:
|
||||
name = f"{self}::{name}"
|
||||
else:
|
||||
name = f"{self}::{coroutine.cr_code.co_name}"
|
||||
return self.task_manager.create_task(coroutine, name)
|
||||
|
||||
async def cancel_task(self, task: asyncio.Task, timeout: float | None = 1.0):
|
||||
"""Cancel a task managed by this processor.
|
||||
|
||||
A default timeout if 1 second is used in order to avoid potential
|
||||
freezes caused by certain libraries that swallow
|
||||
`asyncio.CancelledError`.
|
||||
|
||||
Args:
|
||||
task: The task to cancel.
|
||||
timeout: Optional timeout for task cancellation.
|
||||
"""
|
||||
await self.task_manager.cancel_task(task, timeout)
|
||||
|
||||
async def setup(self, setup: FrameProcessorSetup):
|
||||
"""Set up the processor with required components.
|
||||
|
||||
Args:
|
||||
setup: Configuration object containing setup parameters.
|
||||
"""
|
||||
await super().setup(setup.task_manager)
|
||||
self._clock = setup.clock
|
||||
self._task_manager = setup.task_manager
|
||||
self._observer = setup.observer
|
||||
self._pipeline_task = setup.pipeline_task
|
||||
|
||||
@@ -555,7 +509,7 @@ class FrameProcessor(BaseObject):
|
||||
self.__create_input_task()
|
||||
|
||||
if self._metrics is not None:
|
||||
await self._metrics.setup(self._task_manager)
|
||||
await self._metrics.setup(self.task_manager)
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up processor resources."""
|
||||
@@ -877,14 +831,19 @@ class FrameProcessor(BaseObject):
|
||||
current_is_uninterruptible = isinstance(
|
||||
self.__process_current_frame, UninterruptibleFrame
|
||||
)
|
||||
if current_is_uninterruptible or self.__process_queue.has_uninterruptible:
|
||||
# We don't want to cancel an UninterruptibleFrame (either the
|
||||
# one currently being processed or one waiting in the queue),
|
||||
# so we simply cleanup the queue keeping only
|
||||
# UninterruptibleFrames.
|
||||
if current_is_uninterruptible:
|
||||
# The frame currently being processed is uninterruptible, so we
|
||||
# must not cancel it. Just flush non-uninterruptible frames from
|
||||
# the queue; any uninterruptible ones will be kept and processed
|
||||
# after the current frame finishes.
|
||||
self.__reset_process_queue()
|
||||
else:
|
||||
# Cancel and re-create the process task.
|
||||
# Cancel and re-create the process task. Previously this branch
|
||||
# was skipped when the queue contained an uninterruptible frame,
|
||||
# which caused slow non-uninterruptible frames to block
|
||||
# interruptions. Uninterruptible queued frames are safe here
|
||||
# because __create_process_task calls __reset_process_queue
|
||||
# internally, which always preserves them.
|
||||
await self.__cancel_process_task()
|
||||
self.__create_process_task()
|
||||
except Exception as e:
|
||||
|
||||
@@ -10,6 +10,11 @@ from pipecat.processors.frameworks.rtvi.frames import (
|
||||
RTVIClientMessageFrame,
|
||||
RTVIServerMessageFrame,
|
||||
RTVIServerResponseFrame,
|
||||
RTVIUICancelTaskFrame,
|
||||
RTVIUICommandFrame,
|
||||
RTVIUIEventFrame,
|
||||
RTVIUISnapshotFrame,
|
||||
RTVIUITaskFrame,
|
||||
)
|
||||
from pipecat.processors.frameworks.rtvi.observer import (
|
||||
RTVIFunctionCallReportLevel,
|
||||
@@ -26,4 +31,9 @@ __all__ = [
|
||||
"RTVIProcessor",
|
||||
"RTVIServerMessageFrame",
|
||||
"RTVIServerResponseFrame",
|
||||
"RTVIUICancelTaskFrame",
|
||||
"RTVIUICommandFrame",
|
||||
"RTVIUIEventFrame",
|
||||
"RTVIUISnapshotFrame",
|
||||
"RTVIUITaskFrame",
|
||||
]
|
||||
|
||||
@@ -10,6 +10,7 @@ from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pipecat.frames.frames import SystemFrame
|
||||
from pipecat.processors.frameworks.rtvi.models import UITaskData
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -27,6 +28,132 @@ class RTVIServerMessageFrame(SystemFrame):
|
||||
return f"{self.name}(data: {self.data})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RTVIUICommandFrame(SystemFrame):
|
||||
"""A frame for sending a UI command to the client.
|
||||
|
||||
Pipeline-side counterpart of the ``ui-command`` RTVI message.
|
||||
The observer wraps the ``command`` + ``payload`` into a
|
||||
``UICommandMessage`` envelope before pushing it to the transport,
|
||||
so the wire shape is:
|
||||
``{label, type: "ui-command", data: {command, payload}}``.
|
||||
|
||||
Parameters:
|
||||
command: App-defined command (e.g. ``"toast"``,
|
||||
``"navigate"``, or any app-specific command).
|
||||
payload: App-defined payload. Pydantic command models
|
||||
(``Toast``, ``Navigate``, ``ScrollTo``, ...) should be
|
||||
converted to a plain dict via ``model_dump()`` before
|
||||
being placed here; an arbitrary dict works as well.
|
||||
"""
|
||||
|
||||
command: str = ""
|
||||
payload: Any = None
|
||||
|
||||
def __str__(self):
|
||||
"""String representation of the UI command frame."""
|
||||
return f"{self.name}(command: {self.command})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RTVIUITaskFrame(SystemFrame):
|
||||
"""A frame for sending a UI task lifecycle envelope to the client.
|
||||
|
||||
Pipeline-side counterpart of the ``ui-task`` RTVI message. The
|
||||
observer wraps the ``data`` into a ``UITaskMessage`` envelope
|
||||
before pushing it to the transport, so the wire shape is:
|
||||
``{label, type: "ui-task", data: <one of the four kinds>}``.
|
||||
|
||||
Parameters:
|
||||
data: One of the four task-lifecycle data models from
|
||||
``rtvi.models`` (``UITaskGroupStartedData``,
|
||||
``UITaskUpdateData``, ``UITaskCompletedData``, or
|
||||
``UITaskGroupCompletedData``). The ``kind`` field on
|
||||
each discriminates which lifecycle phase this is.
|
||||
"""
|
||||
|
||||
data: UITaskData | None = None
|
||||
|
||||
def __str__(self):
|
||||
"""String representation of the UI task frame."""
|
||||
kind = getattr(self.data, "kind", "?")
|
||||
return f"{self.name}(kind: {kind})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RTVIUIEventFrame(SystemFrame):
|
||||
"""An inbound UI event from the client.
|
||||
|
||||
Pushed downstream by ``RTVIProcessor`` whenever a ``ui-event``
|
||||
message arrives from the client, alongside firing the
|
||||
``on_ui_message`` event handler. Mirrors the
|
||||
frame-and-event pattern used by ``client-message``: pipeline
|
||||
observers and processors that want to react to UI events at the
|
||||
pipeline level can match on this frame; code that subscribes to
|
||||
events instead (like the bridge in ``pipecat-ai-subagents``)
|
||||
keeps using the event handler.
|
||||
|
||||
Parameters:
|
||||
msg_id: The RTVI message id, as set by the client.
|
||||
event: App-defined event (the ``data.event`` field).
|
||||
payload: App-defined payload (the ``data.payload`` field).
|
||||
"""
|
||||
|
||||
msg_id: str = ""
|
||||
event: str = ""
|
||||
payload: Any = None
|
||||
|
||||
def __str__(self):
|
||||
"""String representation of the UI event frame."""
|
||||
return f"{self.name}(event: {self.event})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RTVIUISnapshotFrame(SystemFrame):
|
||||
"""An inbound accessibility-snapshot from the client.
|
||||
|
||||
Pushed downstream by ``RTVIProcessor`` whenever a ``ui-snapshot``
|
||||
message arrives, alongside firing ``on_ui_message``. Carries
|
||||
the serialized accessibility tree the client took of its DOM.
|
||||
|
||||
Parameters:
|
||||
msg_id: The RTVI message id, as set by the client.
|
||||
tree: The serialized accessibility tree.
|
||||
"""
|
||||
|
||||
msg_id: str = ""
|
||||
tree: Any = None
|
||||
|
||||
def __str__(self):
|
||||
"""String representation of the UI snapshot frame."""
|
||||
return f"{self.name}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RTVIUICancelTaskFrame(SystemFrame):
|
||||
"""An inbound user-task-group cancellation request from the client.
|
||||
|
||||
Pushed downstream by ``RTVIProcessor`` whenever a
|
||||
``ui-cancel-task`` message arrives, alongside firing
|
||||
``on_ui_message``. The server-side framework should look up the
|
||||
matching task group and cancel it (subject to whatever
|
||||
cancellable policy the group was registered with).
|
||||
|
||||
Parameters:
|
||||
msg_id: The RTVI message id, as set by the client.
|
||||
task_id: The task group id the client wants cancelled.
|
||||
reason: Optional human-readable reason.
|
||||
"""
|
||||
|
||||
msg_id: str = ""
|
||||
task_id: str = ""
|
||||
reason: str | None = None
|
||||
|
||||
def __str__(self):
|
||||
"""String representation of the UI cancel-task frame."""
|
||||
return f"{self.name}(task_id: {self.task_id})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RTVIClientMessageFrame(SystemFrame):
|
||||
"""A frame for sending messages from the client to the RTVI server.
|
||||
|
||||
@@ -20,14 +20,14 @@ from typing import (
|
||||
Literal,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from pipecat.frames.frames import (
|
||||
AggregationType,
|
||||
)
|
||||
|
||||
# -- Constants --
|
||||
PROTOCOL_VERSION = "1.2.0"
|
||||
PROTOCOL_VERSION = "1.3.0"
|
||||
|
||||
MESSAGE_LABEL = "rtvi-ai"
|
||||
MessageLiteral = Literal["rtvi-ai"]
|
||||
@@ -549,3 +549,474 @@ class SystemLogMessage(BaseModel):
|
||||
label: MessageLiteral = MESSAGE_LABEL
|
||||
type: Literal["system-log"] = "system-log"
|
||||
data: TextMessageData
|
||||
|
||||
|
||||
# -- UI Agent Protocol -------------------------------------------------------
|
||||
#
|
||||
# A structured RTVI message vocabulary that lets server-side AI agents
|
||||
# observe and drive a GUI app on the client side. The protocol covers
|
||||
# five first-class RTVI message types:
|
||||
#
|
||||
# ui-event client-to-server event message
|
||||
# ui-command server-to-client command message
|
||||
# ui-snapshot client-to-server accessibility snapshot
|
||||
# ui-cancel-task client-to-server cancellation request
|
||||
# ui-task server-to-client task lifecycle envelope
|
||||
#
|
||||
# This section is data only (constants and payload models, no
|
||||
# behavior). Higher-level frameworks like ``pipecat-ai-subagents``
|
||||
# build the agent abstractions on top, and single-LLM Pipecat apps can
|
||||
# target the same wire format directly via custom tools that emit
|
||||
# typed RTVI messages with these types. The matching client-side
|
||||
# implementation lives in ``@pipecat-ai/client-js`` and
|
||||
# ``@pipecat-ai/client-react``.
|
||||
|
||||
# The wire-format ``type`` strings (``"ui-event"``, ``"ui-command"``,
|
||||
# ``"ui-snapshot"``, ``"ui-cancel-task"``, ``"ui-task"``) are pinned
|
||||
# as ``Literal[...]`` field defaults on the corresponding ``*Message``
|
||||
# pydantic class below, matching the convention used for every other
|
||||
# RTVI message type in this module.
|
||||
|
||||
# Each ``ui-task`` envelope carries a ``kind`` field that the client's
|
||||
# task reducer dispatches on. The four kinds form the lifecycle of a
|
||||
# user-facing task group:
|
||||
#
|
||||
# group_started → task_update* → task_completed × N → group_completed
|
||||
#
|
||||
# where N is the number of workers in the group. The kind strings are
|
||||
# pinned as ``Literal[...]`` defaults on the matching ``UITask*Data``
|
||||
# class below.
|
||||
|
||||
|
||||
# -- UI envelope data classes --
|
||||
|
||||
|
||||
class UIEventData(BaseModel):
|
||||
"""Inner ``data`` for a ``ui-event`` message.
|
||||
|
||||
Parameters:
|
||||
event: App-defined event.
|
||||
payload: App-defined payload, schemaless by design.
|
||||
"""
|
||||
|
||||
event: str
|
||||
payload: Any | None = None
|
||||
|
||||
|
||||
class UICommandData(BaseModel):
|
||||
"""Inner ``data`` for a ``ui-command`` message.
|
||||
|
||||
Parameters:
|
||||
command: App-defined command.
|
||||
payload: App-defined payload (already a plain dict by the
|
||||
time it lands on the wire). The standard command payload models
|
||||
below produce the right shape via ``model_dump()``.
|
||||
"""
|
||||
|
||||
command: str
|
||||
payload: Any | None = None
|
||||
|
||||
|
||||
class A11yNode(BaseModel):
|
||||
"""One node in the UI accessibility snapshot tree.
|
||||
|
||||
Mirrors the client-side ``A11yNode`` wire shape. Extra fields are
|
||||
allowed so clients can add platform-specific or future metadata
|
||||
without breaking older servers.
|
||||
|
||||
Parameters:
|
||||
ref: Stable client-assigned element reference.
|
||||
role: ARIA-style role for the node.
|
||||
name: Optional accessible name.
|
||||
value: Optional current value for inputs/progress/etc.
|
||||
state: Optional short state tags (e.g. ``"focused"``,
|
||||
``"disabled"``, ``"offscreen"``).
|
||||
level: Optional heading level.
|
||||
colcount: Optional column count for grid-like containers.
|
||||
rowcount: Optional row count for grid-like containers.
|
||||
children: Optional child nodes.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
ref: str
|
||||
role: str
|
||||
name: str | None = None
|
||||
value: str | None = None
|
||||
state: list[str] | None = None
|
||||
level: int | None = None
|
||||
colcount: int | None = None
|
||||
rowcount: int | None = None
|
||||
children: list["A11yNode"] | None = None
|
||||
|
||||
|
||||
class A11ySelection(BaseModel):
|
||||
"""The user's current text selection in the UI snapshot.
|
||||
|
||||
Extra fields are allowed for forward compatibility with client
|
||||
snapshot additions.
|
||||
|
||||
Parameters:
|
||||
ref: Ref of the element that carries the selection.
|
||||
text: Selected text.
|
||||
start_offset: Optional selection start offset.
|
||||
end_offset: Optional selection end offset.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
ref: str
|
||||
text: str
|
||||
start_offset: int | None = None
|
||||
end_offset: int | None = None
|
||||
|
||||
|
||||
class A11ySnapshot(BaseModel):
|
||||
"""Client accessibility snapshot sent in a ``ui-snapshot`` message.
|
||||
|
||||
Mirrors the client-side ``A11ySnapshot`` wire shape. Extra fields
|
||||
are allowed so clients can add compatible metadata over time.
|
||||
|
||||
Parameters:
|
||||
root: Root accessibility node.
|
||||
captured_at: Client-side epoch milliseconds when captured.
|
||||
selection: Optional current text selection.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
root: A11yNode
|
||||
captured_at: int
|
||||
selection: A11ySelection | None = None
|
||||
|
||||
|
||||
class UISnapshotData(BaseModel):
|
||||
"""Inner ``data`` for a ``ui-snapshot`` message.
|
||||
|
||||
The accessibility snapshot tree mirrors the client-side
|
||||
``A11ySnapshot`` wire shape and is kept forward-compatible by
|
||||
allowing extra fields on the snapshot models.
|
||||
|
||||
Parameters:
|
||||
tree: The serialized accessibility tree.
|
||||
"""
|
||||
|
||||
tree: A11ySnapshot
|
||||
|
||||
|
||||
class UICancelTaskData(BaseModel):
|
||||
"""Inner ``data`` for a ``ui-cancel-task`` message.
|
||||
|
||||
Parameters:
|
||||
task_id: The task group id the client wants cancelled.
|
||||
reason: Optional human-readable reason.
|
||||
"""
|
||||
|
||||
task_id: str
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class UITaskGroupStartedData(BaseModel):
|
||||
"""``data`` for a ``ui-task`` envelope with kind ``group_started``.
|
||||
|
||||
Parameters:
|
||||
kind: Always ``"group_started"``.
|
||||
task_id: Shared task identifier for the group.
|
||||
agents: Names of the agents the work was dispatched to.
|
||||
label: Optional human-readable label for the group.
|
||||
cancellable: Whether the client may request cancellation.
|
||||
at: Epoch milliseconds when the group started.
|
||||
"""
|
||||
|
||||
kind: Literal["group_started"] = "group_started"
|
||||
task_id: str
|
||||
agents: list[str] | None = None
|
||||
label: str | None = None
|
||||
cancellable: bool = True
|
||||
at: int = 0
|
||||
|
||||
|
||||
class UITaskUpdateData(BaseModel):
|
||||
"""``data`` for a ``ui-task`` envelope with kind ``task_update``.
|
||||
|
||||
Parameters:
|
||||
kind: Always ``"task_update"``.
|
||||
task_id: The shared task identifier.
|
||||
agent_name: The worker that produced the update.
|
||||
data: The worker's update payload, forwarded verbatim.
|
||||
at: Epoch milliseconds when the update was emitted.
|
||||
"""
|
||||
|
||||
kind: Literal["task_update"] = "task_update"
|
||||
task_id: str
|
||||
agent_name: str
|
||||
data: Any | None = None
|
||||
at: int = 0
|
||||
|
||||
|
||||
class UITaskCompletedData(BaseModel):
|
||||
"""``data`` for a ``ui-task`` envelope with kind ``task_completed``.
|
||||
|
||||
Parameters:
|
||||
kind: Always ``"task_completed"``.
|
||||
task_id: The shared task identifier.
|
||||
agent_name: The worker that produced the response.
|
||||
status: Completion status string.
|
||||
response: The worker's response payload.
|
||||
at: Epoch milliseconds when the response was received.
|
||||
"""
|
||||
|
||||
kind: Literal["task_completed"] = "task_completed"
|
||||
task_id: str
|
||||
agent_name: str
|
||||
status: str
|
||||
response: Any | None = None
|
||||
at: int = 0
|
||||
|
||||
|
||||
class UITaskGroupCompletedData(BaseModel):
|
||||
"""``data`` for a ``ui-task`` envelope with kind ``group_completed``.
|
||||
|
||||
Parameters:
|
||||
kind: Always ``"group_completed"``.
|
||||
task_id: The shared task identifier.
|
||||
at: Epoch milliseconds when the group completed.
|
||||
"""
|
||||
|
||||
kind: Literal["group_completed"] = "group_completed"
|
||||
task_id: str
|
||||
at: int = 0
|
||||
|
||||
|
||||
#: Discriminated union over the four task-lifecycle data shapes,
|
||||
#: keyed by the ``kind`` field.
|
||||
UITaskData = (
|
||||
UITaskGroupStartedData | UITaskUpdateData | UITaskCompletedData | UITaskGroupCompletedData
|
||||
)
|
||||
|
||||
|
||||
# -- UI envelope message classes --
|
||||
|
||||
|
||||
class UIEventMessage(BaseModel):
|
||||
"""RTVI ``ui-event`` message (client → server)."""
|
||||
|
||||
label: MessageLiteral = MESSAGE_LABEL
|
||||
type: Literal["ui-event"] = "ui-event"
|
||||
id: str
|
||||
data: UIEventData
|
||||
|
||||
|
||||
class UICommandMessage(BaseModel):
|
||||
"""RTVI ``ui-command`` message (server → client)."""
|
||||
|
||||
label: MessageLiteral = MESSAGE_LABEL
|
||||
type: Literal["ui-command"] = "ui-command"
|
||||
data: UICommandData
|
||||
|
||||
|
||||
class UISnapshotMessage(BaseModel):
|
||||
"""RTVI ``ui-snapshot`` message (client → server)."""
|
||||
|
||||
label: MessageLiteral = MESSAGE_LABEL
|
||||
type: Literal["ui-snapshot"] = "ui-snapshot"
|
||||
id: str
|
||||
data: UISnapshotData
|
||||
|
||||
|
||||
class UICancelTaskMessage(BaseModel):
|
||||
"""RTVI ``ui-cancel-task`` message (client → server)."""
|
||||
|
||||
label: MessageLiteral = MESSAGE_LABEL
|
||||
type: Literal["ui-cancel-task"] = "ui-cancel-task"
|
||||
id: str
|
||||
data: UICancelTaskData
|
||||
|
||||
|
||||
class UITaskMessage(BaseModel):
|
||||
"""RTVI ``ui-task`` message (server → client).
|
||||
|
||||
The ``data`` field is one of the four task-lifecycle
|
||||
discriminated by the ``kind`` field.
|
||||
"""
|
||||
|
||||
label: MessageLiteral = MESSAGE_LABEL
|
||||
type: Literal["ui-task"] = "ui-task"
|
||||
data: UITaskData
|
||||
|
||||
|
||||
# -- UI command payloads --
|
||||
#
|
||||
# These models describe commands that have matching default React
|
||||
# handlers in ``@pipecat-ai/client-react``'s ``standardHandlers``.
|
||||
# Apps can use them as-is, override the client handler to customize
|
||||
# rendering, or ignore them entirely and define their own command
|
||||
# names.
|
||||
#
|
||||
# Server-side helpers that send commands accept these models directly.
|
||||
# ``BaseModel.model_dump()`` converts them to the plain-dict shape
|
||||
# that travels over the wire.
|
||||
|
||||
|
||||
class Toast(BaseModel):
|
||||
"""A transient notification surface shown on the client.
|
||||
|
||||
Parameters:
|
||||
title: Required headline.
|
||||
subtitle: Optional second line beneath the title.
|
||||
description: Optional body text.
|
||||
image_url: Optional leading image.
|
||||
duration_ms: Optional dismiss timer. Client default applies
|
||||
when None.
|
||||
"""
|
||||
|
||||
title: str
|
||||
subtitle: str | None = None
|
||||
description: str | None = None
|
||||
image_url: str | None = None
|
||||
duration_ms: int | None = None
|
||||
|
||||
|
||||
class Navigate(BaseModel):
|
||||
"""Client-side navigation to a named view.
|
||||
|
||||
Parameters:
|
||||
view: App-defined view name (route, screen id, tab key, etc.).
|
||||
params: Optional view-specific parameters.
|
||||
"""
|
||||
|
||||
view: str
|
||||
params: dict | None = None
|
||||
|
||||
|
||||
class ScrollTo(BaseModel):
|
||||
"""Scroll a target element into view.
|
||||
|
||||
The client resolves the target by ``ref`` first (a snapshot ref
|
||||
like ``"e42"`` assigned by the a11y walker), then falls back to
|
||||
``target_id`` (``document.getElementById``). Supply whichever you
|
||||
have; ``ref`` is the normal choice when acting on a node from
|
||||
``<ui_state>``.
|
||||
|
||||
Parameters:
|
||||
ref: Snapshot ref from ``<ui_state>``.
|
||||
target_id: Element id registered on the client.
|
||||
behavior: Optional scroll behavior hint. Typical values:
|
||||
``"smooth"`` or ``"instant"``. Clients may ignore.
|
||||
"""
|
||||
|
||||
ref: str | None = None
|
||||
target_id: str | None = None
|
||||
behavior: str | None = None
|
||||
|
||||
|
||||
class Highlight(BaseModel):
|
||||
"""Briefly emphasize a target element (flash, glow, pulse).
|
||||
|
||||
Parameters:
|
||||
ref: Snapshot ref from ``<ui_state>``.
|
||||
target_id: Element id registered on the client.
|
||||
duration_ms: Optional highlight duration. Client default
|
||||
applies when None.
|
||||
"""
|
||||
|
||||
ref: str | None = None
|
||||
target_id: str | None = None
|
||||
duration_ms: int | None = None
|
||||
|
||||
|
||||
class Focus(BaseModel):
|
||||
"""Move input focus to a target element.
|
||||
|
||||
Parameters:
|
||||
ref: Snapshot ref from ``<ui_state>``.
|
||||
target_id: Element id registered on the client.
|
||||
"""
|
||||
|
||||
ref: str | None = None
|
||||
target_id: str | None = None
|
||||
|
||||
|
||||
class Click(BaseModel):
|
||||
"""Click an element on the client.
|
||||
|
||||
Closes the form-fill loop for non-text inputs (checkboxes, radios)
|
||||
and exposes the rest of the action vocabulary (submit buttons,
|
||||
links, app-specific clickable nodes). The standard handler
|
||||
silently no-ops on ``disabled`` targets so the agent can't bypass
|
||||
UI affordances the user is meant to control.
|
||||
|
||||
For native ``<select>``, prefer ``SetInputValue`` (clicking
|
||||
options doesn't reliably change the selection); for custom
|
||||
comboboxes (ARIA listbox + popup), apps wire their own command
|
||||
matching the library's interaction model.
|
||||
|
||||
Parameters:
|
||||
ref: Snapshot ref from ``<ui_state>``.
|
||||
target_id: Element id registered on the client. Used as a
|
||||
fallback when ``ref`` is not set or has gone stale.
|
||||
"""
|
||||
|
||||
ref: str | None = None
|
||||
target_id: str | None = None
|
||||
|
||||
|
||||
class SetInputValue(BaseModel):
|
||||
"""Write a value into a text input or textarea on the client.
|
||||
|
||||
Use this for form-filling: the agent has decided what should go
|
||||
into a field (clarifying answer, tax form entry, etc.) and asks
|
||||
the client to populate it. With ``replace=True`` (the default),
|
||||
the existing value is overwritten; with ``replace=False`` the
|
||||
value is appended.
|
||||
|
||||
The standard handler silently no-ops on ``disabled``, ``readonly``,
|
||||
and ``<input type="hidden">`` targets so the agent can't write
|
||||
into fields the user can't.
|
||||
|
||||
Parameters:
|
||||
value: The text to write.
|
||||
ref: Snapshot ref from ``<ui_state>``. Typically the ref of
|
||||
an ``<input>`` or ``<textarea>``.
|
||||
target_id: Element id registered on the client. Used as a
|
||||
fallback when ``ref`` is not set or has gone stale.
|
||||
replace: When True (the default), overwrite the current
|
||||
value. When False, append to it.
|
||||
"""
|
||||
|
||||
value: str = ""
|
||||
ref: str | None = None
|
||||
target_id: str | None = None
|
||||
replace: bool = True
|
||||
|
||||
|
||||
class SelectText(BaseModel):
|
||||
"""Select text on the page so the user can see what the agent means.
|
||||
|
||||
Mirror of the ``selection`` field surfaced in the snapshot. Use
|
||||
this to point the user's attention at a specific paragraph or
|
||||
range after the agent has decided what it's referring to.
|
||||
|
||||
With ``start_offset`` and ``end_offset`` omitted, the entire
|
||||
target's text content is selected (``Range.selectNodeContents``
|
||||
for document elements; ``el.select()`` for ``<input>`` /
|
||||
``<textarea>``).
|
||||
|
||||
Parameters:
|
||||
ref: Snapshot ref from ``<ui_state>``. Typically the ref of
|
||||
a paragraph or input element.
|
||||
target_id: Element id registered on the client. Used as a
|
||||
fallback when ``ref`` is not set or has gone stale.
|
||||
start_offset: Character offset within the target's text
|
||||
where the selection should start. For ``<input>`` and
|
||||
``<textarea>`` this is the value offset; for document
|
||||
elements it is computed against the concatenation of
|
||||
descendant text nodes in document order.
|
||||
end_offset: End character offset, exclusive. Same coordinate
|
||||
system as ``start_offset``.
|
||||
"""
|
||||
|
||||
ref: str | None = None
|
||||
target_id: str | None = None
|
||||
start_offset: int | None = None
|
||||
end_offset: int | None = None
|
||||
|
||||
@@ -58,6 +58,8 @@ from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
from pipecat.processors.frameworks.rtvi.frames import (
|
||||
RTVIServerMessageFrame,
|
||||
RTVIServerResponseFrame,
|
||||
RTVIUICommandFrame,
|
||||
RTVIUITaskFrame,
|
||||
)
|
||||
from pipecat.transports.base_output import BaseOutputTransport
|
||||
from pipecat.utils.string import match_endofsentence
|
||||
@@ -430,6 +432,15 @@ class RTVIObserver(BaseObserver):
|
||||
elif isinstance(frame, RTVIServerMessageFrame):
|
||||
message = RTVI.ServerMessage(data=frame.data)
|
||||
await self.send_rtvi_message(message)
|
||||
elif isinstance(frame, RTVIUICommandFrame):
|
||||
message = RTVI.UICommandMessage(
|
||||
data=RTVI.UICommandData(command=frame.command, payload=frame.payload)
|
||||
)
|
||||
await self.send_rtvi_message(message)
|
||||
elif isinstance(frame, RTVIUITaskFrame):
|
||||
if frame.data is not None:
|
||||
message = RTVI.UITaskMessage(data=frame.data)
|
||||
await self.send_rtvi_message(message)
|
||||
elif isinstance(frame, RTVIServerResponseFrame):
|
||||
if frame.error is not None:
|
||||
await self._send_error_response(frame)
|
||||
|
||||
@@ -32,7 +32,12 @@ from pipecat.frames.frames import (
|
||||
SystemFrame,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
from pipecat.processors.frameworks.rtvi.frames import RTVIClientMessageFrame
|
||||
from pipecat.processors.frameworks.rtvi.frames import (
|
||||
RTVIClientMessageFrame,
|
||||
RTVIUICancelTaskFrame,
|
||||
RTVIUIEventFrame,
|
||||
RTVIUISnapshotFrame,
|
||||
)
|
||||
from pipecat.processors.frameworks.rtvi.observer import RTVIObserver, RTVIObserverParams
|
||||
from pipecat.services.llm_service import (
|
||||
FunctionCallParams, # TODO(aleix): we shouldn't import `services` from `processors`
|
||||
@@ -76,6 +81,7 @@ class RTVIProcessor(FrameProcessor):
|
||||
self._register_event_handler("on_bot_started")
|
||||
self._register_event_handler("on_client_ready")
|
||||
self._register_event_handler("on_client_message")
|
||||
self._register_event_handler("on_ui_message")
|
||||
|
||||
self._input_transport = None
|
||||
self._transport = transport
|
||||
@@ -288,6 +294,41 @@ class RTVIProcessor(FrameProcessor):
|
||||
case "client-message":
|
||||
data = RTVI.RawClientMessageData.model_validate(message.data)
|
||||
await self._handle_client_message(message.id, data)
|
||||
case "ui-event":
|
||||
event_data = RTVI.UIEventData.model_validate(message.data or {})
|
||||
await self.push_frame(
|
||||
RTVIUIEventFrame(
|
||||
msg_id=message.id,
|
||||
event=event_data.event,
|
||||
payload=event_data.payload,
|
||||
)
|
||||
)
|
||||
await self._call_event_handler(
|
||||
"on_ui_message",
|
||||
RTVI.UIEventMessage(id=message.id, data=event_data),
|
||||
)
|
||||
case "ui-snapshot":
|
||||
snapshot_data = RTVI.UISnapshotData.model_validate(message.data or {})
|
||||
await self.push_frame(
|
||||
RTVIUISnapshotFrame(msg_id=message.id, tree=snapshot_data.tree)
|
||||
)
|
||||
await self._call_event_handler(
|
||||
"on_ui_message",
|
||||
RTVI.UISnapshotMessage(id=message.id, data=snapshot_data),
|
||||
)
|
||||
case "ui-cancel-task":
|
||||
cancel_data = RTVI.UICancelTaskData.model_validate(message.data or {})
|
||||
await self.push_frame(
|
||||
RTVIUICancelTaskFrame(
|
||||
msg_id=message.id,
|
||||
task_id=cancel_data.task_id,
|
||||
reason=cancel_data.reason,
|
||||
)
|
||||
)
|
||||
await self._call_event_handler(
|
||||
"on_ui_message",
|
||||
RTVI.UICancelTaskMessage(id=message.id, data=cancel_data),
|
||||
)
|
||||
case "llm-function-call-result":
|
||||
data = RTVI.LLMFunctionCallResultData.model_validate(message.data)
|
||||
await self._handle_function_call_result(data)
|
||||
|
||||
@@ -27,7 +27,7 @@ try:
|
||||
|
||||
gi.require_version("Gst", "1.0")
|
||||
gi.require_version("GstApp", "1.0")
|
||||
from gi.repository import Gst, GstApp
|
||||
from gi.repository import Gst, GstApp # pyright: ignore[reportAttributeAccessIssue]
|
||||
except ModuleNotFoundError as e:
|
||||
logger.error(f"Exception: {e}")
|
||||
logger.error(
|
||||
|
||||
@@ -20,7 +20,6 @@ from pipecat.metrics.metrics import (
|
||||
TTFBMetricsData,
|
||||
TTSUsageMetricsData,
|
||||
)
|
||||
from pipecat.utils.asyncio.task_manager import BaseTaskManager
|
||||
from pipecat.utils.base_object import BaseObject
|
||||
|
||||
|
||||
@@ -40,36 +39,12 @@ class FrameProcessorMetrics(BaseObject):
|
||||
processing times, and usage statistics.
|
||||
"""
|
||||
super().__init__()
|
||||
self._task_manager = None
|
||||
self._start_ttfb_time = 0
|
||||
self._start_processing_time = 0
|
||||
self._start_text_aggregation_time = 0
|
||||
self._last_ttfb_time = 0
|
||||
self._should_report_ttfb = True
|
||||
|
||||
async def setup(self, task_manager: BaseTaskManager):
|
||||
"""Set up the metrics collector with a task manager.
|
||||
|
||||
Args:
|
||||
task_manager: The task manager for handling async operations.
|
||||
"""
|
||||
self._task_manager = task_manager
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up metrics collection resources."""
|
||||
await super().cleanup()
|
||||
|
||||
@property
|
||||
def task_manager(self) -> BaseTaskManager:
|
||||
"""Get the associated task manager.
|
||||
|
||||
Returns:
|
||||
The task manager instance for async operations.
|
||||
"""
|
||||
if self._task_manager is None:
|
||||
raise RuntimeError("task_manager not set; call setup() first")
|
||||
return self._task_manager
|
||||
|
||||
@property
|
||||
def ttfb(self) -> float | None:
|
||||
"""Get the current TTFB value in seconds.
|
||||
|
||||
@@ -209,6 +209,17 @@ def _configure_server_app(args: argparse.Namespace):
|
||||
logger.warning(f"Unknown transport type: {args.transport}")
|
||||
|
||||
|
||||
def _resolve_download_path(folder: str, filename: str) -> Path:
|
||||
"""Resolve a download path and ensure it stays within the downloads folder."""
|
||||
allowed_base = Path(folder).resolve()
|
||||
file_path = (allowed_base / filename).resolve()
|
||||
|
||||
if not file_path.is_relative_to(allowed_base):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
def _setup_webrtc_routes(app: FastAPI, args: argparse.Namespace):
|
||||
"""Set up WebRTC-specific routes."""
|
||||
try:
|
||||
@@ -250,16 +261,16 @@ def _setup_webrtc_routes(app: FastAPI, args: argparse.Namespace):
|
||||
async def download_file(filename: str):
|
||||
"""Handle file downloads."""
|
||||
if not args.folder:
|
||||
logger.warning(f"Attempting to dowload {filename}, but downloads folder not setup.")
|
||||
return
|
||||
logger.warning(f"Attempting to download {filename}, but downloads folder not setup.")
|
||||
raise HTTPException(404)
|
||||
|
||||
file_path = Path(args.folder) / filename
|
||||
if not os.path.exists(file_path):
|
||||
file_path = _resolve_download_path(args.folder, filename)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404)
|
||||
|
||||
media_type, _ = mimetypes.guess_type(file_path)
|
||||
|
||||
return FileResponse(path=file_path, media_type=media_type, filename=filename)
|
||||
return FileResponse(path=file_path, media_type=media_type, filename=file_path.name)
|
||||
|
||||
# Initialize the SmallWebRTC request handler
|
||||
small_webrtc_handler: SmallWebRTCRequestHandler = SmallWebRTCRequestHandler(
|
||||
|
||||
@@ -509,35 +509,29 @@ async def create_transport(
|
||||
"daily": lambda: DailyParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
vad_analyzer=SileroVADAnalyzer(),
|
||||
),
|
||||
"webrtc": lambda: TransportParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
vad_analyzer=SileroVADAnalyzer(),
|
||||
),
|
||||
"twilio": lambda: FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
vad_analyzer=SileroVADAnalyzer(),
|
||||
# add_wav_header and serializer will be set automatically
|
||||
),
|
||||
"telnyx": lambda: FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
vad_analyzer=SileroVADAnalyzer(),
|
||||
# add_wav_header and serializer will be set automatically
|
||||
),
|
||||
"plivo": lambda: FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
vad_analyzer=SileroVADAnalyzer(),
|
||||
# add_wav_header and serializer will be set automatically
|
||||
),
|
||||
"exotel": lambda: FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
vad_analyzer=SileroVADAnalyzer(),
|
||||
# add_wav_header and serializer will be set automatically
|
||||
),
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ Amazon Bedrock AgentCore Runtime and streams their responses as LLMTextFrames.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
|
||||
import aioboto3
|
||||
@@ -27,6 +26,7 @@ from pipecat.frames.frames import (
|
||||
)
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext, LLMSpecificMessage
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
from pipecat.services.aws.utils import resolve_credentials
|
||||
|
||||
|
||||
def default_context_to_payload_transformer(
|
||||
@@ -122,8 +122,11 @@ class AWSAgentCoreProcessor(FrameProcessor):
|
||||
|
||||
Args:
|
||||
agentArn: The Amazon Web Services Resource Name (ARN) of the agent.
|
||||
aws_access_key: AWS access key ID. If None, uses default credentials.
|
||||
aws_secret_key: AWS secret access key. If None, uses default credentials.
|
||||
aws_access_key: AWS access key ID. If None, falls back to
|
||||
environment variables and the default boto3 credential chain
|
||||
(instance profiles, IRSA, ECS task roles, SSO, etc.).
|
||||
aws_secret_key: AWS secret access key. Same fallback behaviour as
|
||||
``aws_access_key``.
|
||||
aws_session_token: AWS session token for temporary credentials.
|
||||
aws_region: AWS region.
|
||||
context_to_payload_transformer: Optional callable to transform
|
||||
@@ -139,13 +142,13 @@ class AWSAgentCoreProcessor(FrameProcessor):
|
||||
self._agentArn = agentArn
|
||||
self._aws_session = aioboto3.Session()
|
||||
|
||||
# Store AWS session parameters for creating client in async context
|
||||
self._aws_params = {
|
||||
"aws_access_key_id": aws_access_key or os.getenv("AWS_ACCESS_KEY_ID"),
|
||||
"aws_secret_access_key": aws_secret_key or os.getenv("AWS_SECRET_ACCESS_KEY"),
|
||||
"aws_session_token": aws_session_token or os.getenv("AWS_SESSION_TOKEN"),
|
||||
"region_name": aws_region or os.getenv("AWS_REGION", "us-east-1"),
|
||||
}
|
||||
# Resolve credentials using the shared chain (explicit → env → boto3).
|
||||
self._aws_params = resolve_credentials(
|
||||
aws_access_key_id=aws_access_key,
|
||||
aws_secret_access_key=aws_secret_key,
|
||||
aws_session_token=aws_session_token,
|
||||
region=aws_region,
|
||||
).to_boto_kwargs()
|
||||
|
||||
# Set transformers with defaults
|
||||
self._context_to_payload_transformer = (
|
||||
@@ -204,7 +207,8 @@ class AWSAgentCoreProcessor(FrameProcessor):
|
||||
# aioboto3's `client()` is an async context manager but its stubs don't
|
||||
# advertise `__aenter__` / `__aexit__` in a way pyright can see.
|
||||
async with self._aws_session.client( # pyright: ignore[reportGeneralTypeIssues]
|
||||
"bedrock-agentcore", **self._aws_params
|
||||
"bedrock-agentcore",
|
||||
**self._aws_params, # pyright: ignore[reportArgumentType]
|
||||
) as client:
|
||||
# Invoke the AgentCore agent
|
||||
response = await client.invoke_agent_runtime(
|
||||
|
||||
@@ -13,7 +13,6 @@ function calling.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
@@ -36,6 +35,7 @@ from pipecat.frames.frames import (
|
||||
from pipecat.metrics.metrics import LLMTokenUsage
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.services.aws.utils import resolve_credentials
|
||||
from pipecat.services.llm_service import LLMService
|
||||
from pipecat.services.settings import NOT_GIVEN, LLMSettings, _NotGiven, assert_given
|
||||
from pipecat.utils.tracing.service_decorators import traced_llm
|
||||
@@ -135,8 +135,11 @@ class AWSBedrockLLMService(LLMService[AWSBedrockLLMAdapter]):
|
||||
.. deprecated:: 0.0.105
|
||||
Use ``settings=AWSBedrockLLMService.Settings(model=...)`` instead.
|
||||
|
||||
aws_access_key: AWS access key ID. If None, uses default credentials.
|
||||
aws_secret_key: AWS secret access key. If None, uses default credentials.
|
||||
aws_access_key: AWS access key ID. If None, falls back to
|
||||
environment variables and the default boto3 credential chain
|
||||
(instance profiles, IRSA, ECS task roles, SSO, etc.).
|
||||
aws_secret_key: AWS secret access key. Same fallback behaviour as
|
||||
``aws_access_key``.
|
||||
aws_session_token: AWS session token for temporary credentials.
|
||||
aws_region: AWS region for the Bedrock service.
|
||||
params: Model parameters and configuration.
|
||||
@@ -215,14 +218,14 @@ class AWSBedrockLLMService(LLMService[AWSBedrockLLMAdapter]):
|
||||
|
||||
self._aws_session = aioboto3.Session()
|
||||
|
||||
# Store AWS session parameters for creating client in async context
|
||||
self._aws_params = {
|
||||
"aws_access_key_id": aws_access_key or os.getenv("AWS_ACCESS_KEY_ID"),
|
||||
"aws_secret_access_key": aws_secret_key or os.getenv("AWS_SECRET_ACCESS_KEY"),
|
||||
"aws_session_token": aws_session_token or os.getenv("AWS_SESSION_TOKEN"),
|
||||
"region_name": aws_region or os.getenv("AWS_REGION", "us-east-1"),
|
||||
"config": client_config,
|
||||
}
|
||||
# Resolve credentials using the shared chain (explicit → env → boto3).
|
||||
resolved = resolve_credentials(
|
||||
aws_access_key_id=aws_access_key,
|
||||
aws_secret_access_key=aws_secret_key,
|
||||
aws_session_token=aws_session_token,
|
||||
region=aws_region,
|
||||
)
|
||||
self._aws_params = {**resolved.to_boto_kwargs(), "config": client_config}
|
||||
|
||||
self._retry_timeout_secs = retry_timeout_secs
|
||||
self._retry_on_timeout = retry_on_timeout
|
||||
|
||||
@@ -49,6 +49,7 @@ from pipecat.frames.frames import (
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
)
|
||||
from pipecat.processors.aggregators import async_tool_messages
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext, LLMSpecificMessage
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.services.aws.nova_sonic.session_continuation import (
|
||||
@@ -620,6 +621,45 @@ class AWSNovaSonicLLMService(LLMService[AWSNovaSonicLLMAdapter]):
|
||||
# standard tool-result messages — skip them.
|
||||
if isinstance(message, LLMSpecificMessage):
|
||||
continue
|
||||
|
||||
# Async-tool messages live alongside regular tool messages in the
|
||||
# context; detect and route them before the regular logic so we
|
||||
# don't try to send the async-tool envelope JSON as a tool result.
|
||||
async_payload = async_tool_messages.parse_message(message)
|
||||
if async_payload is not None:
|
||||
if async_payload.tool_call_id in self._completed_tool_calls:
|
||||
continue
|
||||
if async_payload.kind == "started":
|
||||
# The provider already issued the tool call and natively
|
||||
# awaits a result; nothing to send for the started marker.
|
||||
continue
|
||||
if async_payload.kind == "intermediate":
|
||||
logger.error(
|
||||
f"{self}: Nova Sonic does not support streamed async "
|
||||
f"tool results; dropping intermediate result for "
|
||||
f"tool_call_id={async_payload.tool_call_id}. Use a "
|
||||
f"non-realtime LLM service if your tool needs to "
|
||||
f"stream intermediate results."
|
||||
)
|
||||
await self.push_error(
|
||||
error_msg="Nova Sonic does not support streamed async tool results.",
|
||||
)
|
||||
continue
|
||||
if async_payload.kind == "final":
|
||||
# Deliver via the formal toolResult channel — same path
|
||||
# as a synchronous tool result, just delayed.
|
||||
if send_new_results:
|
||||
await self._send_tool_result(
|
||||
async_payload.tool_call_id, async_payload.result
|
||||
)
|
||||
self._completed_tool_calls.add(async_payload.tool_call_id)
|
||||
continue
|
||||
# Defensive: any async-tool message must not fall through
|
||||
# to the regular tool-result block below, even if it
|
||||
# carries a kind we don't recognize.
|
||||
continue
|
||||
|
||||
# Look for newly-completed "regular" (as opposed to async-tool) results
|
||||
if message.get("role") == "tool" and message.get("content") not in [
|
||||
"IN_PROGRESS",
|
||||
"CANCELLED",
|
||||
@@ -875,6 +915,8 @@ class AWSNovaSonicLLMService(LLMService[AWSNovaSonicLLMAdapter]):
|
||||
if not self._stream or not self._prompt_name:
|
||||
return
|
||||
|
||||
logger.debug(f"Sending tool result to Nova Sonic for tool_call_id={tool_call_id}")
|
||||
|
||||
content_name = str(uuid.uuid4())
|
||||
|
||||
result_content_start = f'''
|
||||
|
||||
@@ -63,8 +63,8 @@ class SageMakerBidiClient:
|
||||
self,
|
||||
endpoint_name: str,
|
||||
region: str,
|
||||
model_invocation_path: str = "",
|
||||
model_query_string: str = "",
|
||||
model_invocation_path: str | None = "",
|
||||
model_query_string: str | None = "",
|
||||
):
|
||||
"""Initialize the SageMaker BiDi client.
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ speech-to-text transcription with support for multiple languages and audio forma
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
from collections.abc import AsyncGenerator
|
||||
@@ -29,7 +28,12 @@ from pipecat.frames.frames import (
|
||||
StartFrame,
|
||||
TranscriptionFrame,
|
||||
)
|
||||
from pipecat.services.aws.utils import build_event_message, decode_event, get_presigned_url
|
||||
from pipecat.services.aws.utils import (
|
||||
build_event_message,
|
||||
decode_event,
|
||||
get_presigned_url,
|
||||
resolve_credentials,
|
||||
)
|
||||
from pipecat.services.settings import STTSettings, assert_given
|
||||
from pipecat.services.stt_latency import AWS_TRANSCRIBE_TTFS_P99
|
||||
from pipecat.services.stt_service import WebsocketSTTService
|
||||
@@ -81,9 +85,12 @@ class AWSTranscribeSTTService(WebsocketSTTService):
|
||||
"""Initialize the AWS Transcribe STT service.
|
||||
|
||||
Args:
|
||||
api_key: AWS secret access key. If None, uses AWS_SECRET_ACCESS_KEY environment variable.
|
||||
aws_access_key_id: AWS access key ID. If None, uses AWS_ACCESS_KEY_ID environment variable.
|
||||
aws_session_token: AWS session token for temporary credentials. If None, uses AWS_SESSION_TOKEN environment variable.
|
||||
api_key: AWS secret access key. If None, falls back to environment
|
||||
variables and the default boto3 credential chain (instance
|
||||
profiles, IRSA, ECS task roles, SSO, etc.).
|
||||
aws_access_key_id: AWS access key ID. Same fallback behaviour as
|
||||
``api_key``.
|
||||
aws_session_token: AWS session token for temporary credentials.
|
||||
region: AWS region for the service.
|
||||
sample_rate: Audio sample rate in Hz. If None, uses the pipeline sample rate.
|
||||
AWS Transcribe only supports 8000 or 16000 Hz; other values are
|
||||
@@ -129,11 +136,19 @@ class AWSTranscribeSTTService(WebsocketSTTService):
|
||||
self._show_speaker_label = False
|
||||
self._enable_channel_identification = False
|
||||
|
||||
# Resolve credentials using the shared chain (explicit → env → boto3).
|
||||
resolved = resolve_credentials(
|
||||
aws_access_key_id=aws_access_key_id,
|
||||
aws_secret_access_key=api_key,
|
||||
aws_session_token=aws_session_token,
|
||||
region=region,
|
||||
)
|
||||
|
||||
self._credentials = {
|
||||
"aws_access_key_id": aws_access_key_id or os.getenv("AWS_ACCESS_KEY_ID"),
|
||||
"aws_secret_access_key": api_key or os.getenv("AWS_SECRET_ACCESS_KEY"),
|
||||
"aws_session_token": aws_session_token or os.getenv("AWS_SESSION_TOKEN"),
|
||||
"region": region or os.getenv("AWS_REGION", "us-east-1"),
|
||||
"aws_access_key_id": resolved.access_key,
|
||||
"aws_secret_access_key": resolved.secret_key,
|
||||
"aws_session_token": resolved.session_token,
|
||||
"region": resolved.region,
|
||||
}
|
||||
|
||||
self._receive_task = None
|
||||
|
||||
@@ -10,7 +10,6 @@ This module provides integration with Amazon Polly for text-to-speech synthesis,
|
||||
supporting multiple languages, voices, and SSML features.
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncGenerator
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -23,6 +22,7 @@ from pipecat.frames.frames import (
|
||||
Frame,
|
||||
TTSAudioRawFrame,
|
||||
)
|
||||
from pipecat.services.aws.utils import resolve_credentials
|
||||
from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven
|
||||
from pipecat.services.tts_service import TTSService
|
||||
from pipecat.transcriptions.language import Language, resolve_language
|
||||
@@ -191,8 +191,11 @@ class AWSPollyTTSService(TTSService):
|
||||
"""Initializes the AWS Polly TTS service.
|
||||
|
||||
Args:
|
||||
api_key: AWS secret access key. If None, uses AWS_SECRET_ACCESS_KEY environment variable.
|
||||
aws_access_key_id: AWS access key ID. If None, uses AWS_ACCESS_KEY_ID environment variable.
|
||||
api_key: AWS secret access key. If None, falls back to environment
|
||||
variables and the default boto3 credential chain (instance
|
||||
profiles, IRSA, ECS task roles, SSO, etc.).
|
||||
aws_access_key_id: AWS access key ID. Same fallback behaviour as
|
||||
``api_key``.
|
||||
aws_session_token: AWS session token for temporary credentials.
|
||||
region: AWS region for Polly service. Defaults to 'us-east-1'.
|
||||
voice_id: Voice ID to use for synthesis. Defaults to 'Joanna'.
|
||||
@@ -250,13 +253,13 @@ class AWSPollyTTSService(TTSService):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Get credentials from environment variables if not provided
|
||||
self._aws_params = {
|
||||
"aws_access_key_id": aws_access_key_id or os.getenv("AWS_ACCESS_KEY_ID"),
|
||||
"aws_secret_access_key": api_key or os.getenv("AWS_SECRET_ACCESS_KEY"),
|
||||
"aws_session_token": aws_session_token or os.getenv("AWS_SESSION_TOKEN"),
|
||||
"region_name": region or os.getenv("AWS_REGION", "us-east-1"),
|
||||
}
|
||||
# Resolve credentials using the shared chain (explicit → env → boto3).
|
||||
self._aws_params = resolve_credentials(
|
||||
aws_access_key_id=aws_access_key_id,
|
||||
aws_secret_access_key=api_key,
|
||||
aws_session_token=aws_session_token,
|
||||
region=region,
|
||||
).to_boto_kwargs()
|
||||
|
||||
self._aws_session = aioboto3.Session()
|
||||
|
||||
@@ -348,7 +351,8 @@ class AWSPollyTTSService(TTSService):
|
||||
# aioboto3's `client()` is an async context manager but its stubs
|
||||
# don't advertise `__aenter__` / `__aexit__` to pyright.
|
||||
async with self._aws_session.client( # pyright: ignore[reportGeneralTypeIssues]
|
||||
"polly", **self._aws_params
|
||||
"polly",
|
||||
**self._aws_params, # pyright: ignore[reportArgumentType]
|
||||
) as polly:
|
||||
response = await polly.synthesize_speech(**filtered_params)
|
||||
if "AudioStream" in response:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user