Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions livekit-agents/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
LIVEKIT_URL=
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
OPENAI_API_KEY=
96 changes: 60 additions & 36 deletions livekit-agents/README.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,61 @@
# LiveKit Agents for Python

Realtime framework for production-grade multimodal and voice AI agents.

See [https://docs.livekit.io/agents/](https://docs.livekit.io/agents/) for quickstarts, documentation, and examples.

```python
from dotenv import load_dotenv

from livekit import agents
from livekit.agents import AgentSession, Agent, RoomInputOptions
from livekit.plugins import openai

load_dotenv()

async def entrypoint(ctx: agents.JobContext):
await ctx.connect()

session = AgentSession(
llm=openai.realtime.RealtimeModel(
voice="coral"
)
)

await session.start(
room=ctx.room,
agent=Agent(instructions="You are a helpful voice AI assistant.")
)

await session.generate_reply(
instructions="Greet the user and offer your assistance."
)


if __name__ == "__main__":
agents.cli.run_app(agents.WorkerOptions(entrypoint_fnc=entrypoint))
# LiveKit Agent with Smart Interruption Handling

This project implements a LiveKit voice agent that intelligently handles user interruptions. It uses the OpenAI Realtime API alongside Silero VAD and OpenAI STT to achieve natural, seamless conversational dynamics.

## Features & Evaluation Criteria Addressed

### 1. Strict Functionality (No Hiccups on Filler Words)
The agent features a robust semantic interruption filter. When the user says filler words like "yeah", "ok", or "hmm" while the agent is actively speaking, the agent will **continue speaking without any pauses or hiccups**.
- **How it works:** We disabled server-side turn detection (`turn_detection=None` in RealtimeModel) and implemented a client-side barge-in control. When the STT detects incoming speech during an active agent turn, the text is evaluated against a fuzzy-matching logic. If it matches an "ignored" word, the interruption signal is bypassed, leaving the agent's current audio stream completely unaffected.

### 2. State Awareness (Responding to Short Answers)
The agent correctly differentiates between "filler words during its own speech" and "short valid answers when it is listening".
- **How it works:** The ignore logic only prevents the *cancellation of an active agent speech task*. If the agent has finished speaking and is waiting for a response, saying "yeah" or "ok" is processed as a standard user turn. The VAD and STT capture the utterance, pass it to the LLM as part of the conversation context, and the agent responds accordingly.

### 3. Code Quality (Modular & Configurable)
The logic separating interruptions from filler words is highly modular and easily configurable.
- **How it works:** The word lists are not hardcoded deep in the agent logic. Instead, they are passed as configuration arrays to the `AgentSession` instantiation in `main.py`:
```python
session = AgentSession(
vad=silero.VAD.load(),
stt=openai.STT(language="en"),
llm=openai.realtime.RealtimeModel(voice="alloy", turn_detection=None),
ignore_interruption_words=["yeah", "ok", "hmm", "mhm", "right", "uh-huh"],
stop_interruption_words=["wait", "stop", "no"]
)
```
This makes it trivial to modify the behavior. The arrays can easily be populated from environment variables, a database, or a JSON configuration file without needing to modify the core agent source code.

---

## Getting Started

### Prerequisites
- Python 3.9+
- A LiveKit Cloud project (URL, API Key, API Secret)
- An OpenAI API Key (for Realtime model and STT)

### Setup

1. **Install Dependencies**
The project uses standard dependency management. Make sure you install the required packages in your virtual environment.

2. **Configure Environment Variables**
Ensure you have a `.env` file in the root directory (`livekit-agents/.env`) with your keys:
```env
LIVEKIT_URL=wss://<your-project>.livekit.cloud
LIVEKIT_API_KEY=<your-api-key>
LIVEKIT_API_SECRET=<your-api-secret>
OPENAI_API_KEY=<your-openai-key>
```

3. **Customizing the Interruption Words (Optional)**
Open `main.py` and modify the `ignore_interruption_words` or `stop_interruption_words` lists passed to `AgentSession` if you wish to add support for additional filler words or commands.

### Running the Agent
Start the agent worker by running:
```bash
python main.py dev
```

Once the worker is running, it will connect to your LiveKit room and wait for a participant to join. The agent will begin speaking, and you can test the interruption functionality by saying "yeah" (which will be ignored and allow the agent to continue smoothly) or "wait" (which will immediately halt the agent).
58 changes: 53 additions & 5 deletions livekit-agents/livekit/agents/voice/agent_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import contextvars
import heapq
import json
import re
import time
from collections.abc import AsyncIterable, Coroutine, Sequence
from dataclasses import dataclass
Expand Down Expand Up @@ -1176,14 +1177,31 @@ def _interrupt_by_audio_activity(self) -> None:

if (
self.stt is not None
and opt.min_interruption_words > 0
and self._audio_recognition is not None
):
text = self._audio_recognition.current_transcript

# TODO(long): better word splitting for multi-language
if len(split_words(text, split_character=True)) < opt.min_interruption_words:
return
words = [w[0].lower() for w in split_words(text, split_character=True)]
fuzzy_words = [fw for fw in (re.sub(r'[^a-z]', '', w) for w in words) if fw]

should_interrupt_immediately = False
if opt.stop_interruption_words:
fuzzy_stops = [re.sub(r'[^a-z]', '', w.lower()) for w in opt.stop_interruption_words]
if any(any(w.startswith(stop) for stop in fuzzy_stops) for w in fuzzy_words if w):
should_interrupt_immediately = True

if not should_interrupt_immediately:
if opt.ignore_interruption_words:
fuzzy_ignores = [re.sub(r'[^a-z]', '', w.lower()) for w in opt.ignore_interruption_words]
if len(fuzzy_words) == 0:
# Wait for STT to provide the first word
return
if all(any(ignore.startswith(w) or w.startswith(ignore) for ignore in fuzzy_ignores) for w in fuzzy_words if w):
# Only soft inputs spoken so far, do not interrupt yet
return

if opt.min_interruption_words > 0:
if len(words) < opt.min_interruption_words:
return

if self._rt_session is not None:
self._rt_session.start_user_activity()
Expand Down Expand Up @@ -1414,13 +1432,36 @@ async def _user_turn_completed_task(
if self._rt_session is not None:
self._rt_session.commit_audio()

words = [w[0].lower() for w in split_words(info.new_transcript, split_character=True)]
fuzzy_words = [fw for fw in (re.sub(r'[^a-z]', '', w) for w in words) if fw]

is_only_ignore_words = False
if self._session.options.ignore_interruption_words:
fuzzy_ignores = [re.sub(r'[^a-z]', '', w.lower()) for w in self._session.options.ignore_interruption_words]
if len(fuzzy_words) > 0 and all(any(ignore.startswith(w) or w.startswith(ignore) for ignore in fuzzy_ignores) for w in fuzzy_words if w):
is_only_ignore_words = True

contains_stop_words = False
if self._session.options.stop_interruption_words:
fuzzy_stops = [re.sub(r'[^a-z]', '', w.lower()) for w in self._session.options.stop_interruption_words]
if len(fuzzy_words) > 0 and any(any(w.startswith(stop) for stop in fuzzy_stops) for w in fuzzy_words if w):
contains_stop_words = True

if self._current_speech is not None:
if not self._current_speech.allow_interruptions:
logger.warning(
"skipping reply to user input, current speech generation cannot be interrupted",
extra={"user_input": info.new_transcript},
)
return

if is_only_ignore_words:
logger.debug(
"skipping user turn, transcript contains only ignored words",
extra={"user_input": info.new_transcript},
)
return

await self._interrupt_paused_speech(self._interrupt_paused_speech_task)

if self._current_speech:
Expand All @@ -1429,6 +1470,13 @@ async def _user_turn_completed_task(
if self._rt_session is not None:
self._rt_session.interrupt()

if contains_stop_words:
logger.debug(
"skipping response, transcript contains stop words",
extra={"user_input": info.new_transcript},
)
return

user_message = llm.ChatMessage(
role="user",
content=[info.new_transcript],
Expand Down
15 changes: 15 additions & 0 deletions livekit-agents/livekit/agents/voice/agent_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ class AgentSessionOptions:
preemptive_generation: bool
tts_text_transforms: Sequence[TextTransforms] | None
ivr_detection: bool
ignore_interruption_words: Sequence[str] | None
stop_interruption_words: Sequence[str] | None



Userdata_T = TypeVar("Userdata_T")
Expand Down Expand Up @@ -159,6 +162,8 @@ def __init__(
tts_text_transforms: NotGivenOr[Sequence[TextTransforms] | None] = NOT_GIVEN,
preemptive_generation: bool = False,
ivr_detection: bool = False,
ignore_interruption_words: NotGivenOr[Sequence[str] | None] = NOT_GIVEN,
stop_interruption_words: NotGivenOr[Sequence[str] | None] = NOT_GIVEN,
conn_options: NotGivenOr[SessionConnectOptions] = NOT_GIVEN,
loop: asyncio.AbstractEventLoop | None = None,
# deprecated
Expand Down Expand Up @@ -245,6 +250,10 @@ def __init__(
Defaults to ``False``.
ivr_detection (bool): Whether to detect if the agent is interacting with an IVR system.
Default ``False``.
ignore_interruption_words (Sequence[str], optional): List of words that
are treated as soft inputs and will not interrupt the agent.
stop_interruption_words (Sequence[str], optional): List of words that
will immediately interrupt the agent if detected.
conn_options (SessionConnectOptions, optional): Connection options for
stt, llm, and tts.
loop (asyncio.AbstractEventLoop, optional): Event loop to bind the
Expand Down Expand Up @@ -288,6 +297,12 @@ def __init__(
use_tts_aligned_transcript=use_tts_aligned_transcript
if is_given(use_tts_aligned_transcript)
else None,
ignore_interruption_words=ignore_interruption_words
if is_given(ignore_interruption_words)
else None,
stop_interruption_words=stop_interruption_words
if is_given(stop_interruption_words)
else None,
)
self._conn_options = conn_options or SessionConnectOptions()
self._started = False
Expand Down
28 changes: 28 additions & 0 deletions livekit-agents/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from dotenv import load_dotenv
from livekit.agents import Agent, AgentSession, JobContext, WorkerOptions, cli
from livekit.plugins import openai, silero

load_dotenv()

async def entrypoint(ctx: JobContext):
await ctx.connect()

session = AgentSession(
vad=silero.VAD.load(),
stt=openai.STT(language="en"),
llm=openai.realtime.RealtimeModel(voice="alloy", turn_detection=None),
ignore_interruption_words=["yeah", "ok", "hmm","mhm", "right", "uh-huh"],
stop_interruption_words=["wait", "stop", "no"]
)

await session.start(
agent=Agent(instructions="You are a helpful assistant."),
room=ctx.room
)

await session.generate_reply(
instructions="Start speaking something long."
)

if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
6 changes: 3 additions & 3 deletions livekit-agents/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ dependencies = [
"numpy>=1.26.0",
"pydantic>=2.0,<3",
"nest-asyncio>=1.6.0",
"opentelemetry-api>=1.34",
"opentelemetry-sdk>=1.34.1",
"opentelemetry-exporter-otlp>=1.34.1",
"opentelemetry-api>=1.34,<1.39",
"opentelemetry-sdk>=1.34.1,<1.39",
"opentelemetry-exporter-otlp>=1.34.1,<1.39",
"prometheus-client>=0.22",
"openai>=1.99.2",
"aiofiles>=24",
Expand Down