项目文件夹

文件
2026-07-13 13:39:38 +08:00

1946 行
77 KiB
Python

from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import AsyncIterable
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
import pytest
from livekit.agents import (
NOT_GIVEN,
Agent,
AgentFalseInterruptionEvent,
AgentStateChangedEvent,
ConversationItemAddedEvent,
FlushSentinel,
LanguageCode,
MetricsCollectedEvent,
ModelSettings,
NotGivenOr,
TurnHandlingOptions,
UserInputTranscribedEvent,
UserStateChangedEvent,
function_tool,
inference,
vad,
)
from livekit.agents.llm import (
FunctionTool,
FunctionToolCall,
InputTranscriptionCompleted,
RawFunctionTool,
ToolContext,
ToolFlag,
Toolset,
)
from livekit.agents.llm.chat_context import ChatContext, ChatMessage
from livekit.agents.stt import SpeechData, SpeechEvent, SpeechEventType
from livekit.agents.utils import aio
from livekit.agents.voice.agent_activity import AgentActivity
from livekit.agents.voice.audio_recognition import AudioRecognition, _EndOfTurnInfo
from livekit.agents.voice.endpointing import BaseEndpointing
from livekit.agents.voice.events import FunctionToolsExecutedEvent
from livekit.agents.voice.io import PlaybackFinishedEvent
from .fake_session import FakeActions, create_session, run_session
pytestmark = [pytest.mark.unit, pytest.mark.virtual_time, pytest.mark.no_concurrent]
class MyAgent(Agent):
def __init__(
self,
*,
generate_reply_on_enter: bool = False,
say_on_user_turn_completed: bool = False,
on_user_turn_completed_delay: float = 0.0,
turn_handling: NotGivenOr[TurnHandlingOptions] = NOT_GIVEN,
) -> None:
super().__init__(
instructions=("You are a helpful assistant."),
turn_handling=turn_handling,
)
self.generate_reply_on_enter = generate_reply_on_enter
self.say_on_user_turn_completed = say_on_user_turn_completed
self.on_user_turn_completed_delay = on_user_turn_completed_delay
self._close_session_task: asyncio.Task[None] | None = None
async def on_enter(self) -> None:
if self.generate_reply_on_enter:
self.session.generate_reply(instructions="instructions:say hello to the user")
@function_tool
async def get_weather(self, location: str) -> str:
"""
Called when the user asks about the weather.
Args:
location: The location to get the weather for
"""
return f"The weather in {location} is sunny today."
@function_tool
async def goodbye(self) -> None:
await self.session.generate_reply(instructions="instructions:say goodbye to the user")
self._close_session_task = asyncio.create_task(self.session.aclose())
async def on_user_turn_completed(self, turn_ctx: ChatContext, new_message: ChatMessage) -> None:
if self.say_on_user_turn_completed:
self.session.say("session.say from on_user_turn_completed")
if self.on_user_turn_completed_delay > 0.0:
await asyncio.sleep(self.on_user_turn_completed_delay)
SESSION_TIMEOUT = 60.0
def test_realtime_user_input_transcription_preserves_item_id() -> None:
captured_events: list[UserInputTranscribedEvent] = []
class DummySession:
def _user_input_transcribed(self, ev: UserInputTranscribedEvent) -> None:
captured_events.append(ev)
activity = object.__new__(AgentActivity)
activity._session = DummySession()
AgentActivity._on_input_audio_transcription_completed(
activity,
InputTranscriptionCompleted(
item_id="item_123",
transcript="hello",
is_final=False,
),
)
assert len(captured_events) == 1
assert captured_events[0].transcript == "hello"
assert captured_events[0].is_final is False
assert captured_events[0].item_id == "item_123"
async def test_events_and_metrics() -> None:
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "Hello, how are you?", stt_delay=0.2) # EOU at 2.5+0.5=3.0s
actions.add_llm("I'm doing well, thank you!", ttft=0.1, duration=0.3)
actions.add_tts(
2.0, ttfb=0.2, duration=0.3
) # audio playout starts at 3.0+0.3+0.2=3.5s, ends at 5.5s
session = create_session(actions, speed_factor=speed)
agent = MyAgent()
user_state_events: list[UserStateChangedEvent] = []
agent_state_events: list[AgentStateChangedEvent] = []
metrics_events: list[MetricsCollectedEvent] = []
conversation_events: list[ConversationItemAddedEvent] = []
user_transcription_events: list[UserInputTranscribedEvent] = []
session.on("user_state_changed", user_state_events.append)
session.on("agent_state_changed", agent_state_events.append)
session.on("metrics_collected", metrics_events.append)
session.on("conversation_item_added", conversation_events.append)
session.on("user_input_transcribed", user_transcription_events.append)
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
# conversation_item_added
assert len(conversation_events) == 3
assert conversation_events[0].item.type == "agent_handoff"
assert conversation_events[1].item.type == "message"
assert conversation_events[1].item.role == "user"
assert conversation_events[1].item.text_content == "Hello, how are you?"
check_timestamp(conversation_events[1].created_at - t_origin, 3.0, speed_factor=speed)
assert conversation_events[2].item.type == "message"
assert conversation_events[2].item.role == "assistant"
assert conversation_events[2].item.text_content == "I'm doing well, thank you!"
check_timestamp(conversation_events[2].created_at - t_origin, 5.5, speed_factor=speed)
# user_input_transcribed
assert len(user_transcription_events) >= 1
assert user_transcription_events[-1].transcript == "Hello, how are you?"
assert user_transcription_events[-1].is_final is True
check_timestamp(user_transcription_events[-1].created_at - t_origin, 2.7, speed_factor=speed)
# user_state_changed
assert len(user_state_events) == 2
check_timestamp(user_state_events[0].created_at - t_origin, 0.5, speed_factor=speed)
assert user_state_events[0].new_state == "speaking"
check_timestamp(user_state_events[1].created_at - t_origin, 3.0, speed_factor=speed)
assert user_state_events[1].new_state == "listening"
# agent_state_changed
assert len(agent_state_events) == 4
assert agent_state_events[0].old_state == "initializing"
assert agent_state_events[0].new_state == "listening"
assert agent_state_events[1].new_state == "thinking"
check_timestamp(agent_state_events[1].created_at - t_origin, 3.0, speed_factor=speed)
assert agent_state_events[2].new_state == "speaking"
check_timestamp(agent_state_events[2].created_at - t_origin, 3.5, speed_factor=speed)
assert agent_state_events[3].new_state == "listening"
check_timestamp(agent_state_events[3].created_at - t_origin, 5.5, speed_factor=speed)
# metrics
metrics_events = [ev for ev in metrics_events if ev.metrics.type != "vad_metrics"]
assert len(metrics_events) == 3
assert metrics_events[0].metrics.type == "eou_metrics"
check_timestamp(metrics_events[0].metrics.end_of_utterance_delay, 0.5, speed_factor=speed)
check_timestamp(metrics_events[0].metrics.transcription_delay, 0.2, speed_factor=speed)
assert metrics_events[1].metrics.type == "llm_metrics"
check_timestamp(metrics_events[1].metrics.ttft, 0.1, speed_factor=speed)
check_timestamp(metrics_events[1].metrics.duration, 0.3, speed_factor=speed)
assert metrics_events[2].metrics.type == "tts_metrics"
check_timestamp(metrics_events[2].metrics.ttfb, 0.2, speed_factor=speed)
check_timestamp(metrics_events[2].metrics.audio_duration, 2.0, speed_factor=speed)
async def test_tts_node_ttfb_excludes_upstream_latency() -> None:
# the LLM stream stays open for its full duration and the fake TTS only starts
# synthesizing once its input is flushed. tts_node_ttfb must anchor on the text
# being handed to the TTS provider (~2.0s in), not on the first LLM token (~0.1s in),
# otherwise the LLM streaming time is misattributed to the TTS
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "Hello, how are you?", stt_delay=0.2)
actions.add_llm("I'm doing well, thank you!", ttft=0.1, duration=2.0)
actions.add_tts(1.0, ttfb=0.2, duration=0.3)
session = create_session(actions, speed_factor=speed)
agent = MyAgent()
conversation_events: list[ConversationItemAddedEvent] = []
session.on("conversation_item_added", conversation_events.append)
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
assistant_messages = [
ev.item
for ev in conversation_events
if ev.item.type == "message" and ev.item.role == "assistant"
]
assert len(assistant_messages) == 1
metrics = assistant_messages[0].metrics
assert "tts_node_ttfb" in metrics
check_timestamp(metrics["tts_node_ttfb"], 0.2, speed_factor=speed)
async def test_tool_call() -> None:
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "What's the weather in Tokyo?")
actions.add_llm(
content="Let me check the weather for you.",
tool_calls=[
FunctionToolCall(name="get_weather", arguments='{"location": "Tokyo"}', call_id="1")
],
)
actions.add_tts(2.0) # audio for the content alongside the tool call
actions.add_llm(
content="The weather in Tokyo is sunny today.",
input="The weather in Tokyo is sunny today.",
)
actions.add_tts(3.0) # audio for the tool response
session = create_session(actions, speed_factor=speed)
agent = MyAgent()
agent_state_events: list[AgentStateChangedEvent] = []
tool_executed_events: list[FunctionToolsExecutedEvent] = []
playback_finished_events: list[PlaybackFinishedEvent] = []
session.on("agent_state_changed", agent_state_events.append)
session.on("function_tools_executed", tool_executed_events.append)
session.output.audio.on("playback_finished", playback_finished_events.append)
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
assert len(playback_finished_events) == 2
check_timestamp(playback_finished_events[0].playback_position, 2.0, speed_factor=speed)
check_timestamp(playback_finished_events[1].playback_position, 3.0, speed_factor=speed)
assert len(agent_state_events) == 6
assert agent_state_events[0].old_state == "initializing"
assert agent_state_events[0].new_state == "listening"
assert agent_state_events[1].new_state == "thinking"
assert agent_state_events[2].new_state == "speaking"
assert (
agent_state_events[3].new_state == "thinking"
) # from speaking to thinking when tool call is executed
check_timestamp(agent_state_events[3].created_at - t_origin, 5.5, speed_factor=speed)
assert agent_state_events[4].new_state == "speaking"
assert agent_state_events[5].new_state == "listening"
assert len(tool_executed_events) == 1
assert tool_executed_events[0].function_calls[0].name == "get_weather"
assert tool_executed_events[0].function_calls[0].arguments == '{"location": "Tokyo"}'
assert tool_executed_events[0].function_calls[0].call_id == "1"
# chat context
chat_ctx_items = agent.chat_ctx.items
assert len(chat_ctx_items) == 7
assert chat_ctx_items[0].type == "message"
assert chat_ctx_items[0].role == "system"
assert chat_ctx_items[1].type == "agent_config_update"
assert chat_ctx_items[2].type == "message"
assert chat_ctx_items[2].role == "user"
assert chat_ctx_items[2].text_content == "What's the weather in Tokyo?"
assert chat_ctx_items[3].type == "message"
assert chat_ctx_items[3].role == "assistant"
assert chat_ctx_items[3].text_content == "Let me check the weather for you."
assert chat_ctx_items[4].type == "function_call"
assert chat_ctx_items[4].name == "get_weather"
assert chat_ctx_items[5].type == "function_call_output"
assert chat_ctx_items[5].output == "The weather in Tokyo is sunny today."
assert chat_ctx_items[6].type == "message"
assert chat_ctx_items[6].role == "assistant"
assert chat_ctx_items[6].text_content == "The weather in Tokyo is sunny today."
@pytest.mark.parametrize(
"resume_false_interruption, expected_interruption_time",
[
(False, 5.5), # when vad event, 5 + 0.5
(True, 5.5), # pause/resume is disabled for fake audio output
],
)
async def test_interruption(
resume_false_interruption: bool, expected_interruption_time: float
) -> None:
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "Tell me a story.")
actions.add_llm("Here is a long story for you ... the end.")
actions.add_tts(10.0) # playout starts at 3.5s
actions.add_user_speech(5.0, 6.0, "Stop!", stt_delay=0.2)
# interrupted at 5.5s, min_interruption_duration=0.5
session = create_session(
actions,
speed_factor=speed,
turn_handling={"interruption": {"resume_false_interruption": resume_false_interruption}},
)
agent = MyAgent()
agent_state_events: list[AgentStateChangedEvent] = []
playback_finished_events: list[PlaybackFinishedEvent] = []
session.on("agent_state_changed", agent_state_events.append)
session.output.audio.on("playback_finished", playback_finished_events.append)
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
chat_ctx_items = agent.chat_ctx.items
assert len(chat_ctx_items) == 5
assert chat_ctx_items[1].type == "agent_config_update"
assert chat_ctx_items[3].type == "message"
assert chat_ctx_items[3].role == "assistant"
assert chat_ctx_items[3].interrupted is True
assert len(agent_state_events) == 6
assert agent_state_events[0].old_state == "initializing"
assert agent_state_events[0].new_state == "listening"
assert agent_state_events[1].new_state == "thinking"
assert agent_state_events[2].new_state == "speaking"
assert agent_state_events[3].new_state == "listening"
check_timestamp(
agent_state_events[3].created_at - t_origin, expected_interruption_time, speed_factor=speed
)
assert agent_state_events[4].new_state == "thinking"
check_timestamp(agent_state_events[4].created_at - t_origin, 6.5, speed_factor=speed)
assert agent_state_events[5].new_state == "listening"
check_timestamp(agent_state_events[5].created_at - t_origin, 6.5, speed_factor=speed)
assert len(playback_finished_events) == 1
assert playback_finished_events[0].interrupted is True
if not resume_false_interruption:
# fake audio output doesn't support pause/resume
check_timestamp(playback_finished_events[0].playback_position, 2.0, speed_factor=speed)
async def test_interruption_options() -> None:
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "Tell me a story.")
actions.add_llm("Here is a long story for you ... the end.")
actions.add_tts(5.0) # playout starts at 3.5s
actions.add_user_speech(5.0, 6.0, "Stop!")
actions.add_user_speech(6.5, 7.5, "ok, stop!", stt_delay=0.0)
# it should interrupt at 7.5s after stt, playback position is 4.0s
# test min_interruption_words
session = create_session(
actions,
speed_factor=speed,
turn_handling={"interruption": {"min_words": 3}},
)
playback_finished_events: list[PlaybackFinishedEvent] = []
session.output.audio.on("playback_finished", playback_finished_events.append)
await asyncio.wait_for(run_session(session, MyAgent()), timeout=SESSION_TIMEOUT)
assert len(playback_finished_events) == 1
assert playback_finished_events[0].interrupted is True
check_timestamp(playback_finished_events[0].playback_position, 4.0, speed_factor=speed)
# test allow_interruptions=False
session = create_session(
actions,
speed_factor=speed,
turn_handling={"interruption": {"enabled": False}},
)
playback_finished_events.clear()
session.output.audio.on("playback_finished", playback_finished_events.append)
await asyncio.wait_for(run_session(session, MyAgent()), timeout=SESSION_TIMEOUT)
assert len(playback_finished_events) == 1
assert playback_finished_events[0].interrupted is False
check_timestamp(playback_finished_events[0].playback_position, 5.0, speed_factor=speed)
async def test_interruption_by_text_input() -> None:
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "Tell me a story.")
actions.add_llm("Here is a long story for you ... the end.")
actions.add_tts(15.0)
actions.add_llm("Ok, I'll stop now.", input="stop from text input")
actions.add_tts(2.0)
session = create_session(actions, speed_factor=speed)
agent = MyAgent()
agent_state_events: list[AgentStateChangedEvent] = []
playback_finished_events: list[PlaybackFinishedEvent] = []
session.on("agent_state_changed", agent_state_events.append)
session.output.audio.on("playback_finished", playback_finished_events.append)
def fake_text_input() -> None:
session.interrupt()
session.generate_reply(user_input="stop from text input")
asyncio.get_event_loop().call_later(5 / speed, fake_text_input)
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
assert len(playback_finished_events) == 2
assert playback_finished_events[0].interrupted is True
assert len(agent_state_events) == 6
assert agent_state_events[0].old_state == "initializing"
assert agent_state_events[0].new_state == "listening"
assert agent_state_events[1].new_state == "thinking"
assert agent_state_events[2].new_state == "speaking"
# interrupted by text while speaking -> straight to thinking for the new reply
assert agent_state_events[3].new_state == "thinking"
assert agent_state_events[4].new_state == "speaking"
assert agent_state_events[5].new_state == "listening"
chat_ctx_items = agent.chat_ctx.items
assert len(chat_ctx_items) == 6
assert chat_ctx_items[0].type == "message"
assert chat_ctx_items[0].role == "system"
assert chat_ctx_items[1].type == "agent_config_update"
assert chat_ctx_items[2].type == "message"
assert chat_ctx_items[2].role == "user"
assert chat_ctx_items[2].text_content == "Tell me a story."
assert chat_ctx_items[3].type == "message"
assert chat_ctx_items[3].role == "assistant"
assert chat_ctx_items[3].interrupted is True # assistant message should be before text input
assert chat_ctx_items[4].type == "message"
assert chat_ctx_items[4].role == "user"
assert chat_ctx_items[4].text_content == "stop from text input"
assert chat_ctx_items[5].type == "message"
assert chat_ctx_items[5].role == "assistant"
assert chat_ctx_items[5].text_content == "Ok, I'll stop now."
@pytest.mark.parametrize(
"resume_false_interruption, expected_interruption_time",
[
(False, 3.5), # 3 + 0.5
(True, 3.5), # pause/resume is disabled for fake audio output
],
)
async def test_interruption_before_speaking(
resume_false_interruption: bool, expected_interruption_time: float
) -> None:
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "Tell me a story.")
actions.add_llm("Here is a long story for you ... the end.", duration=1.0)
actions.add_tts(10.0)
actions.add_user_speech(3.0, 4.0, "Stop!", stt_delay=0.2)
session = create_session(
actions,
speed_factor=speed,
turn_handling={"interruption": {"resume_false_interruption": resume_false_interruption}},
)
agent = MyAgent()
agent_state_events: list[AgentStateChangedEvent] = []
playback_finished_events: list[PlaybackFinishedEvent] = []
session.on("agent_state_changed", agent_state_events.append)
session.output.audio.on("playback_finished", playback_finished_events.append)
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
assert len(agent_state_events) == 5
assert agent_state_events[0].old_state == "initializing"
assert agent_state_events[0].new_state == "listening"
assert agent_state_events[1].new_state == "thinking" # without speaking state
assert agent_state_events[2].new_state == "listening"
check_timestamp(
agent_state_events[2].created_at - t_origin, expected_interruption_time, speed_factor=speed
) # interrupted at 3.5s
assert agent_state_events[3].new_state == "thinking"
assert agent_state_events[4].new_state == "listening"
assert len(playback_finished_events) == 0
assert len(agent.chat_ctx.items) == 4
assert agent.chat_ctx.items[0].type == "message"
assert agent.chat_ctx.items[0].role == "system"
assert agent.chat_ctx.items[1].type == "agent_config_update"
assert agent.chat_ctx.items[2].type == "message"
assert agent.chat_ctx.items[2].role == "user"
assert agent.chat_ctx.items[2].text_content == "Tell me a story."
# before we insert an empty assistant message with interrupted=True
# now we ignore it when the text is empty
assert agent.chat_ctx.items[3].type == "message"
assert agent.chat_ctx.items[3].role == "user"
assert agent.chat_ctx.items[3].text_content == "Stop!"
async def test_interrupt_before_speaking_with_pausable_audio() -> None:
"""
Regression test for https://github.com/livekit/agents/issues/5509
User turn starting while the agent is ``thinking`` must pause the
pausable output so the stale reply never promotes to ``speaking``.
"""
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "Tell me a story.")
actions.add_llm("Here is a long story for you ... the end.", duration=1.0)
actions.add_tts(10.0)
actions.add_user_speech(3.0, 4.0, "Stop!", stt_delay=0.2)
session = create_session(actions, speed_factor=speed, can_pause_audio=True)
agent = MyAgent()
agent_state_events: list[AgentStateChangedEvent] = []
playback_finished_events: list[PlaybackFinishedEvent] = []
session.on("agent_state_changed", agent_state_events.append)
session.output.audio.on("playback_finished", playback_finished_events.append)
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
# core assertion: the stale reply never promotes to "speaking"
assert not any(ev.new_state == "speaking" for ev in agent_state_events), (
"stale reply should have been paused before the first frame reached the transport"
)
# state sequence mirrors test_interruption_before_speaking (can_pause=False variant),
# proving the pause path is observationally equivalent to the interrupt path
assert len(agent_state_events) == 5
assert agent_state_events[0].old_state == "initializing"
assert agent_state_events[0].new_state == "listening"
assert agent_state_events[1].new_state == "thinking"
assert agent_state_events[2].new_state == "listening"
check_timestamp(agent_state_events[2].created_at - t_origin, 3.5, speed_factor=speed)
# nothing audible reached the transport — the pause cleanup emits a single
# playback_finished with interrupted=True and playback_position=0
assert len(playback_finished_events) == 1
assert playback_finished_events[0].interrupted is True
assert playback_finished_events[0].playback_position == 0.0
# stale assistant reply is dropped; chat_ctx holds user turn 1 and (after
# the on_final_transcript commit) user turn 2
user_messages = [
item for item in agent.chat_ctx.items if item.type == "message" and item.role == "user"
]
assert [m.text_content for m in user_messages] == ["Tell me a story.", "Stop!"]
assert not any(
item.type == "message" and item.role == "assistant" for item in agent.chat_ctx.items
)
async def test_false_interruption_before_speaking_resumes() -> None:
"""
Brief VAD-only noise during ``thinking`` must pause then resume on VAD EOS,
letting the stale reply play through normally.
"""
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "Tell me a story.")
actions.add_llm("Here is a short reply.", ttft=0.05, duration=0.05)
actions.add_tts(5.0, ttfb=0.05, duration=0.05)
# brief VAD-only noise — same shape as the can_pause=False test, different capability
actions.add_user_speech(3.0, 3.3, "")
session = create_session(actions, speed_factor=speed, can_pause_audio=True)
agent = MyAgent()
agent_state_events: list[AgentStateChangedEvent] = []
playback_finished_events: list[PlaybackFinishedEvent] = []
session.on("agent_state_changed", agent_state_events.append)
session.output.audio.on("playback_finished", playback_finished_events.append)
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
# the agent resumes and speaks the reply after the false interruption clears
speaking_events = [ev for ev in agent_state_events if ev.new_state == "speaking"]
assert len(speaking_events) == 1
# playout was postponed: the noise ran 3.0–3.3s, so "speaking" should fire at
# ~3.8s (resume on VAD EOS=3.3s + 0.5s min_silence_duration)
check_timestamp(speaking_events[0].created_at - t_origin, 3.8, speed_factor=speed)
# the reply plays to completion (not interrupted); playback_position covers the
# full audio duration
assert len(playback_finished_events) == 1
assert playback_finished_events[0].interrupted is False
check_timestamp(playback_finished_events[0].playback_position, 5.0, speed_factor=speed)
async def test_generate_reply() -> None:
"""
Test `generate_reply` in `on_enter` and tool call, `say` in `on_user_turn_completed`
"""
speed = 1
actions = FakeActions()
# llm and tts response for generate_reply() and say()
actions.add_llm("What can I do for you!", input="instructions:say hello to the user")
actions.add_tts(2.0)
actions.add_llm("Goodbye! have a nice day!", input="instructions:say goodbye to the user")
actions.add_tts(3.0)
actions.add_tts(1.0, ttfb=0, input="session.say from on_user_turn_completed")
# user speech
actions.add_user_speech(3.0, 4.0, "bye")
actions.add_llm(
content="",
tool_calls=[FunctionToolCall(name="goodbye", arguments="", call_id="1")],
)
# tool started at 4.5(EOU) + 1.0(session.say)
# tool finished at 5.5 + 0.5 (second LLM + TTS) + 3 (audio)
session = create_session(actions, speed_factor=speed)
agent = MyAgent(generate_reply_on_enter=True, say_on_user_turn_completed=True)
conversation_events: list[ConversationItemAddedEvent] = []
playback_finished_events: list[PlaybackFinishedEvent] = []
tool_executed_events: list[FunctionToolsExecutedEvent] = []
session.on("conversation_item_added", conversation_events.append)
session.on("function_tools_executed", tool_executed_events.append)
session.output.audio.on("playback_finished", playback_finished_events.append)
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
# playback_finished
assert len(playback_finished_events) == 3
assert playback_finished_events[0].interrupted is False
check_timestamp(playback_finished_events[0].playback_position, 2.0, speed_factor=speed)
assert playback_finished_events[1].interrupted is False
check_timestamp(playback_finished_events[1].playback_position, 1.0, speed_factor=speed)
assert playback_finished_events[2].interrupted is False
check_timestamp(playback_finished_events[2].playback_position, 3.0, speed_factor=speed)
# function_tools_executed
assert len(tool_executed_events) == 1
assert tool_executed_events[0].function_calls[0].name == "goodbye"
# conversation_item_added
assert len(conversation_events) == 5
assert conversation_events[0].item.type == "agent_handoff"
assert conversation_events[1].item.type == "message"
assert conversation_events[1].item.role == "assistant"
assert conversation_events[1].item.text_content == "What can I do for you!"
check_timestamp(conversation_events[1].created_at - t_origin, 2.5, speed_factor=speed)
assert conversation_events[2].item.type == "message"
assert conversation_events[2].item.role == "user"
assert conversation_events[2].item.text_content == "bye"
check_timestamp(conversation_events[2].created_at - t_origin, 4.5, speed_factor=speed)
assert conversation_events[3].item.type == "message"
assert conversation_events[3].item.role == "assistant"
assert conversation_events[3].item.text_content == "session.say from on_user_turn_completed"
check_timestamp(
conversation_events[3].created_at - t_origin, 5.5, speed_factor=speed, max_abs_diff=1.0
)
assert conversation_events[4].item.type == "message"
assert conversation_events[4].item.role == "assistant"
assert conversation_events[4].item.text_content == "Goodbye! have a nice day!"
check_timestamp(
conversation_events[4].created_at - t_origin, 9.0, speed_factor=speed, max_abs_diff=1.0
)
# chat context
assert len(agent.chat_ctx.items) == 8
assert agent.chat_ctx.items[0].type == "message"
assert agent.chat_ctx.items[0].role == "system"
assert agent.chat_ctx.items[1].type == "agent_config_update"
assert agent.chat_ctx.items[2].type == "message"
assert agent.chat_ctx.items[2].role == "assistant"
assert agent.chat_ctx.items[2].text_content == "What can I do for you!"
assert agent.chat_ctx.items[3].type == "message"
assert agent.chat_ctx.items[3].role == "user"
assert agent.chat_ctx.items[3].text_content == "bye"
assert agent.chat_ctx.items[4].type == "message"
assert agent.chat_ctx.items[4].role == "assistant"
assert agent.chat_ctx.items[4].text_content == "session.say from on_user_turn_completed"
assert agent.chat_ctx.items[5].type == "function_call"
assert agent.chat_ctx.items[6].type == "message"
assert agent.chat_ctx.items[6].role == "assistant"
assert agent.chat_ctx.items[6].text_content == "Goodbye! have a nice day!"
assert agent.chat_ctx.items[7].type == "function_call_output"
async def test_on_enter_hides_ignore_on_enter_tools() -> None:
"""IGNORE_ON_ENTER tools (bare + toolset-nested) are hidden inside on_enter, restored after."""
class _Toolset(Toolset):
def __init__(self) -> None:
end_call = function_tool(
self._end_call,
name="end_call",
description="ends the call",
flags=ToolFlag.IGNORE_ON_ENTER,
)
keep = function_tool(self._keep, name="keep", description="a normal tool")
super().__init__(id="ts", tools=[end_call, keep])
async def _end_call(self) -> None: ...
async def _keep(self) -> None: ...
@function_tool(flags=ToolFlag.IGNORE_ON_ENTER)
async def bare_ignored() -> None:
"""A bare flagged tool."""
class _Agent(Agent):
def __init__(self) -> None:
super().__init__(instructions="you are helpful", tools=[_Toolset(), bare_ignored])
async def on_enter(self) -> None:
self.session.generate_reply(instructions="instructions:say hello to the user")
actions = FakeActions()
actions.add_llm("Hello!", input="instructions:say hello to the user")
actions.add_tts(1.0)
actions.add_user_speech(2.0, 3.0, "hi there")
actions.add_llm("How can I help?")
actions.add_tts(1.0)
session = create_session(actions)
captured: list[set[str]] = []
orig_chat = session.llm.chat
def _recording_chat(*, chat_ctx, tools=None, **kwargs): # type: ignore[no-untyped-def]
captured.append(
{t.info.name for t in (tools or []) if isinstance(t, (FunctionTool, RawFunctionTool))}
)
return orig_chat(chat_ctx=chat_ctx, tools=tools, **kwargs)
session.llm.chat = _recording_chat # type: ignore[method-assign]
await asyncio.wait_for(run_session(session, _Agent()), timeout=SESSION_TIMEOUT)
assert len(captured) == 2
# on_enter reply: flagged tools hidden, normal tool still offered
assert captured[0] == {"keep"}
# a later (non-on_enter) turn sees every tool again
assert captured[1] == {"keep", "end_call", "bare_ignored"}
async def test_on_enter_hides_tools_in_nested_tool_reply() -> None:
"""When an on_enter reply calls a tool, the tool-response follow-up (a nested speech task)
also hides the flagged tools — proving the on_enter contextvar reaches nested tasks."""
class _Toolset(Toolset):
def __init__(self) -> None:
end_call = function_tool(
self._end_call, name="end_call", description="ends", flags=ToolFlag.IGNORE_ON_ENTER
)
keep = function_tool(self._keep, name="keep", description="a normal tool")
super().__init__(id="ts", tools=[end_call, keep])
async def _end_call(self) -> None: ...
async def _keep(self) -> str:
return "kept"
@function_tool(flags=ToolFlag.IGNORE_ON_ENTER)
async def bare_ignored() -> None:
"""A bare flagged tool."""
class _Agent(Agent):
def __init__(self) -> None:
super().__init__(instructions="you are helpful", tools=[_Toolset(), bare_ignored])
async def on_enter(self) -> None:
self.session.generate_reply(instructions="instructions:say hello to the user")
actions = FakeActions()
# on_enter reply calls the visible `keep` tool instead of speaking, spawning a follow-up
actions.add_llm(
"",
tool_calls=[FunctionToolCall(name="keep", arguments="{}", call_id="1")],
input="instructions:say hello to the user",
)
# tool-response follow-up (keyed on the `keep` return value)
actions.add_llm("Hello there!", input="kept")
actions.add_tts(1.0)
# a user turn ends the run and confirms tools are restored afterwards
actions.add_user_speech(4.0, 5.0, "hi there")
actions.add_llm("How can I help?")
actions.add_tts(1.0)
session = create_session(actions)
captured: list[set[str]] = []
orig_chat = session.llm.chat
def _recording_chat(*, chat_ctx, tools=None, **kwargs): # type: ignore[no-untyped-def]
captured.append(
{t.info.name for t in (tools or []) if isinstance(t, (FunctionTool, RawFunctionTool))}
)
return orig_chat(chat_ctx=chat_ctx, tools=tools, **kwargs)
session.llm.chat = _recording_chat # type: ignore[method-assign]
await asyncio.wait_for(run_session(session, _Agent()), timeout=SESSION_TIMEOUT)
assert len(captured) == 3
greeting, tool_reply, user_turn = captured
# both the greeting reply and its nested tool-response follow-up hide the flagged tools
assert greeting == {"keep"}
assert tool_reply == {"keep"}
# the later (non-on_enter) user turn sees every tool again
assert user_turn == {"keep", "end_call", "bare_ignored"}
def test_on_enter_ignored_tools() -> None:
"""_on_enter_ignored_tools returns flagged tools only inside this agent/session's on_enter."""
from livekit.agents.voice.agent_activity import _OnEnterContextVar, _OnEnterData
class _Toolset(Toolset):
def __init__(self) -> None:
end_call = function_tool(
self._end_call, name="end_call", description="ends", flags=ToolFlag.IGNORE_ON_ENTER
)
ts_keep = function_tool(self._keep, name="ts_keep", description="normal")
super().__init__(id="ts", tools=[end_call, ts_keep])
async def _end_call(self) -> None: ...
async def _keep(self) -> None: ...
@function_tool(flags=ToolFlag.IGNORE_ON_ENTER)
async def bare_ignored() -> None:
"""A flagged bare tool."""
@function_tool
async def bare_keep() -> None:
"""A normal bare tool."""
activity = object.__new__(AgentActivity)
activity._agent = object() # type: ignore[assignment]
activity._session = object() # type: ignore[assignment]
tool_ctx = ToolContext([_Toolset(), bare_ignored, bare_keep])
# outside on_enter: nothing is ignored
assert activity._on_enter_ignored_tools(tool_ctx) == []
# inside this agent/session's on_enter: flagged tools (bare + nested) are returned
tk = _OnEnterContextVar.set(_OnEnterData(session=activity._session, agent=activity._agent))
try:
ignored = {t.info.name for t in activity._on_enter_ignored_tools(tool_ctx)}
finally:
_OnEnterContextVar.reset(tk)
assert ignored == {"end_call", "bare_ignored"}
# a different agent's on_enter must not leak in
tk = _OnEnterContextVar.set(_OnEnterData(session=activity._session, agent=object()))
try:
assert activity._on_enter_ignored_tools(tool_ctx) == []
finally:
_OnEnterContextVar.reset(tk)
async def test_aec_warmup() -> None:
"""AEC warmup should block audio-activity-based interruptions during the warmup window.
Without warmup, VAD-based interruption fires at 4.0 + 0.5 = 4.5s.
With warmup (3.0s from speaking at 3.5s, expires at 6.5s), the VAD path is blocked.
The interruption is delayed to 5.5s (EOU: speech end 5.0 + 0.5 endpointing delay)
because FakeSTT is timer-based and still produces transcripts during warmup.
"""
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "Tell me a story.")
actions.add_llm("Here is a long story for you ... the end.")
actions.add_tts(15.0) # playout starts at 3.5s
# user speaks at 4.0-5.0s — within warmup window (3.5 + 3.0 = 6.5s expiry)
# without warmup: VAD interruption at 4.0 + 0.5 = 4.5s
# with warmup: VAD blocked, falls through to EOU at 5.0 + 0.5 = 5.5s
actions.add_user_speech(4.0, 5.0, "Stop!", stt_delay=0.2)
session = create_session(
actions,
speed_factor=speed,
extra_kwargs={"aec_warmup_duration": 3.0},
)
agent = MyAgent()
agent_state_events: list[AgentStateChangedEvent] = []
playback_finished_events: list[PlaybackFinishedEvent] = []
session.on("agent_state_changed", agent_state_events.append)
session.output.audio.on("playback_finished", playback_finished_events.append)
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
assert len(playback_finished_events) == 1
assert playback_finished_events[0].interrupted is True
assert agent_state_events[0].new_state == "listening"
assert agent_state_events[1].new_state == "thinking"
assert agent_state_events[2].new_state == "speaking"
# interruption delayed to 5.5s (EOU), not 4.5s (VAD was blocked by warmup)
speaking_to_listening = next(e for e in agent_state_events[3:] if e.new_state == "listening")
check_timestamp(speaking_to_listening.created_at - t_origin, 5.5, speed_factor=speed)
async def test_start_boundary_does_not_block_vad_interruption() -> None:
"""backchannel boundary should not interfere with VAD-based interruption when adaptive
detection is not active. The cooldown timer runs but has no effect on the VAD path.
This validates that the backchannel_boundary config is properly handled and doesn't
regress normal interruption behavior.
"""
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "Tell me a story.")
actions.add_llm("Here is a long story for you ... the end.")
actions.add_tts(15.0) # playout starts at ~3.5s
# user speaks at 4.0-5.0s — within the 1s warmup window (3.5 + 1.0 = 4.5s expiry)
# VAD interruption at 4.0 + 0.5 = 4.5s (warmup does NOT block VAD)
actions.add_user_speech(4.0, 5.0, "Stop!", stt_delay=0.2)
session = create_session(
actions,
speed_factor=speed,
extra_kwargs={"aec_warmup_duration": None},
)
agent = MyAgent()
agent_state_events: list[AgentStateChangedEvent] = []
playback_finished_events: list[PlaybackFinishedEvent] = []
session.on("agent_state_changed", agent_state_events.append)
session.output.audio.on("playback_finished", playback_finished_events.append)
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
assert len(playback_finished_events) == 1
assert playback_finished_events[0].interrupted is True
assert agent_state_events[0].new_state == "listening"
assert agent_state_events[1].new_state == "thinking"
assert agent_state_events[2].new_state == "speaking"
# VAD interruption fires normally at ~4.5s (warmup doesn't block VAD path)
speaking_to_listening = next(e for e in agent_state_events[3:] if e.new_state == "listening")
check_timestamp(speaking_to_listening.created_at - t_origin, 4.5, speed_factor=speed)
async def test_backchannel_boundary_suppresses_start_boundary_backchannel() -> None:
actions = FakeActions()
session = create_session(
actions,
turn_handling={"interruption": {"backchannel_boundary": (0.05, 0.0)}},
)
hooks = _TestRecognitionHooks()
recognition = AudioRecognition(
session,
hooks=hooks,
endpointing=BaseEndpointing(min_delay=0.1, max_delay=1.0),
stt=None,
vad=None,
using_default_vad=False,
interruption_detection=None,
turn_detection="vad",
)
try:
recognition._on_start_of_agent_speech(started_at=time.time())
# backchannels during the cooldown are dropped (they are a no-op anyway,
# but this guards against the gate firing on `on_interruption`)
await recognition._on_overlap_speech_event(_backchannel_event())
assert hooks.interruptions == []
# a real interruption during the cooldown must still fire
await recognition._on_overlap_speech_event(_interruption_event())
assert len(hooks.interruptions) == 1
# after cooldown, both event types behave normally
await asyncio.sleep(0.06)
await recognition._on_overlap_speech_event(_backchannel_event())
await recognition._on_overlap_speech_event(_interruption_event())
assert len(hooks.interruptions) == 2
finally:
await _close_test_session(session)
async def _make_stt_eos_recognition() -> AudioRecognition:
return AudioRecognition(
create_session(FakeActions()),
hooks=_TestRecognitionHooks(),
endpointing=BaseEndpointing(min_delay=0.0, max_delay=0.0),
stt=None,
vad=None,
using_default_vad=False,
interruption_detection=None,
turn_detection="stt",
)
async def test_stt_eos_resets_active_vad_stream_without_restarting_vad() -> None:
recognition = await _make_stt_eos_recognition()
recognition._speaking = True
recognition._vad_speech_started = True
recognition._vad = MagicMock()
resettable_stream = MagicMock()
recognition._vad_stream = resettable_stream
try:
with patch.object(recognition, "_update_vad") as update_vad:
await recognition._on_stt_event(SpeechEvent(type=SpeechEventType.END_OF_SPEECH))
resettable_stream.flush.assert_called_once_with()
update_vad.assert_not_called()
assert recognition._vad_stream is resettable_stream
finally:
if recognition._end_of_turn_task is not None:
await aio.cancel_and_wait(recognition._end_of_turn_task)
await _close_test_session(recognition._session)
async def test_stt_eos_falls_back_to_update_vad_when_no_active_stream() -> None:
recognition = await _make_stt_eos_recognition()
recognition._speaking = True
recognition._vad_speech_started = True
recognition._vad = MagicMock()
recognition._vad_stream = None
try:
with patch.object(recognition, "_update_vad") as update_vad:
await recognition._on_stt_event(SpeechEvent(type=SpeechEventType.END_OF_SPEECH))
update_vad.assert_called_once_with(recognition._vad)
finally:
if recognition._end_of_turn_task is not None:
await aio.cancel_and_wait(recognition._end_of_turn_task)
await _close_test_session(recognition._session)
async def test_backchannel_boundary_releases_end_boundary_transcript() -> None:
actions = FakeActions()
session = create_session(
actions,
turn_handling={"interruption": {"backchannel_boundary": (0.0, 0.5)}},
)
recognition = AudioRecognition(
session,
hooks=_TestRecognitionHooks(),
endpointing=BaseEndpointing(min_delay=0.1, max_delay=1.0),
stt=None,
vad=None,
using_default_vad=False,
interruption_detection=None,
turn_detection="vad",
)
recognition._interruption_enabled = True
recognition._interruption_ch = aio.Chan[inference.InterruptionDataFrameType]()
input_started_at = time.time() - 10.0
# the input anchor lives on the STT pipeline (see _STTPipeline.input_started_at)
recognition._stt_pipeline = SimpleNamespace(input_started_at=input_started_at) # type: ignore[assignment]
try:
# the agent speaks for a couple of seconds so the held transcript still lands
# after the agent-speech start (the lower bound of the ignore window)
recognition._on_start_of_agent_speech(started_at=time.time() - 2.0)
speech_ended_at = time.time()
recognition._on_end_of_agent_speech(ignore_user_transcript_until=speech_ended_at)
assert not recognition._should_hold_stt_event(
_final_transcript_event(
text="near the boundary",
start_time=speech_ended_at - input_started_at - 0.25,
end_time=speech_ended_at - input_started_at,
)
)
assert recognition._should_hold_stt_event(
_final_transcript_event(
text="before the boundary",
start_time=speech_ended_at - input_started_at - 0.75,
end_time=speech_ended_at - input_started_at - 0.5,
)
)
finally:
recognition._interruption_ch.close()
await _close_test_session(session)
async def test_interruption_detection_error_is_not_session_error() -> None:
actions = FakeActions()
session = create_session(actions)
activity = AgentActivity(MyAgent(), session)
fallback = Mock()
activity._fallback_to_vad_interruption = fallback
error_events: list[object] = []
session.on("error", error_events.append)
try:
recoverable = inference.InterruptionDetectionError(
label="test",
error=RuntimeError("temporary failure"),
recoverable=True,
)
activity._on_error(recoverable)
unrecoverable = inference.InterruptionDetectionError(
label="test",
error=RuntimeError("adaptive unavailable"),
recoverable=False,
)
activity._on_error(unrecoverable)
assert error_events == []
fallback.assert_called_once_with(unrecoverable)
finally:
await _close_test_session(session)
async def test_vad_fallback_uses_next_vad_inference_event(
caplog: pytest.LogCaptureFixture,
) -> None:
actions = FakeActions()
session = create_session(actions)
activity = AgentActivity(MyAgent(), session)
error = inference.InterruptionDetectionError(
label="test",
error=RuntimeError("adaptive unavailable"),
recoverable=False,
)
audio_recognition = MagicMock()
current_speech = MagicMock()
current_speech.interrupted = False
current_speech.allow_interruptions = True
activity._audio_recognition = audio_recognition
activity._current_speech = current_speech
activity._interruption_detection_enabled = True
activity._interruption_by_audio_activity_enabled = False
activity._default_interruption_by_audio_activity_enabled = True
caplog.set_level(logging.INFO, logger="livekit.agents")
try:
activity._fallback_to_vad_interruption(error)
audio_recognition._update_interruption_detection.assert_called_once_with(None)
current_speech.interrupt.assert_not_called()
assert activity._interruption_detection_enabled is False
assert activity._interruption_by_audio_activity_enabled is True
activity.on_vad_inference_done(
vad.VADEvent(
type=vad.VADEventType.INFERENCE_DONE,
samples_index=0,
timestamp=time.time(),
speech_duration=session.options.interruption["min_duration"] - 0.01,
silence_duration=0.0,
speaking=True,
)
)
current_speech.interrupt.assert_not_called()
activity.on_vad_inference_done(
vad.VADEvent(
type=vad.VADEventType.INFERENCE_DONE,
samples_index=0,
timestamp=time.time(),
speech_duration=session.options.interruption["min_duration"],
silence_duration=0.0,
speaking=True,
)
)
current_speech.interrupt.assert_called_once_with()
assert any(
record.levelno == logging.INFO
and "falling back to VAD-based interruption" in record.message
for record in caplog.records
)
assert not [record for record in caplog.records if record.levelno >= logging.WARNING]
finally:
await _close_test_session(session)
async def test_force_flush_held_transcripts_emits_buffered_events() -> None:
actions = FakeActions()
session = create_session(actions)
hooks = _TestRecognitionHooks()
recognition = AudioRecognition(
session,
hooks=hooks,
endpointing=BaseEndpointing(min_delay=0.1, max_delay=1.0),
stt=None,
vad=None,
using_default_vad=False,
interruption_detection=None,
turn_detection="manual",
)
recognition._transcript_buffer.append(
_final_transcript_event(text="held transcript", start_time=0.0, end_time=1.0)
)
try:
await recognition._flush_held_transcripts(cooldown=0.0, force=True)
assert hooks.final_transcripts == ["held transcript"]
assert not recognition._transcript_buffer
finally:
await _close_test_session(session)
@pytest.mark.parametrize(
"preemptive_generation, expected_latency",
[
({"preemptive_tts": True}, 0.7),
({"preemptive_tts": False}, 0.8),
({"enabled": False}, 1.1),
],
)
async def test_preemptive_generation(preemptive_generation: dict, expected_latency: float) -> None:
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.0, "Hello, how are you?", stt_delay=0.1)
actions.add_llm("I'm doing great, thank you!", ttft=0.1, duration=0.3)
actions.add_tts(3.0, ttfb=0.3)
# preemptive_generation with TTS enabled: e2e latency is 0.1+0.3+0.3=0.7s
# preemptive_generation without TTS enabled: e2e latency is max(0.1+0.3, 0.5)+0.3=0.8s
# preemptive_generation disabled: e2e latency is 0.5+0.3+0.3=1.1s
session = create_session(
actions,
speed_factor=speed,
turn_handling={"preemptive_generation": preemptive_generation},
)
agent = MyAgent()
agent_state_events: list[AgentStateChangedEvent] = []
user_state_events: list[UserStateChangedEvent] = []
session.on("agent_state_changed", agent_state_events.append)
session.on("user_state_changed", user_state_events.append)
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
assert len(user_state_events) == 2
assert user_state_events[0].old_state == "listening"
assert user_state_events[0].new_state == "speaking"
assert user_state_events[1].new_state == "listening"
t_user_stop_speaking = user_state_events[1].created_at
assert len(agent_state_events) == 4
assert agent_state_events[0].old_state == "initializing"
assert agent_state_events[0].new_state == "listening"
assert agent_state_events[1].new_state == "thinking"
assert agent_state_events[2].new_state == "speaking"
t_agent_start_speaking = agent_state_events[2].created_at
check_timestamp(
t_agent_start_speaking - t_user_stop_speaking,
t_target=expected_latency,
speed_factor=speed,
max_abs_diff=0.2,
)
assert agent_state_events[3].new_state == "listening"
@pytest.mark.parametrize(
"session_preemptive, agent_preemptive, expected_latency",
[
# agent disables what the session enabled -> no preemptive generation (1.1s)
({"preemptive_tts": True}, {"enabled": False}, 1.1),
# agent enables (with TTS) what the session disabled -> fully preemptive (0.7s)
({"enabled": False}, {"enabled": True, "preemptive_tts": True}, 0.7),
],
)
async def test_preemptive_generation_on_agent(
session_preemptive: dict, agent_preemptive: dict, expected_latency: float
) -> None:
# preemptive generation set on the agent must override the session value
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.0, "Hello, how are you?", stt_delay=0.1)
actions.add_llm("I'm doing great, thank you!", ttft=0.1, duration=0.3)
actions.add_tts(3.0, ttfb=0.3)
# preemptive_generation with TTS enabled: e2e latency is 0.1+0.3+0.3=0.7s
# preemptive_generation disabled: e2e latency is 0.5+0.3+0.3=1.1s
session = create_session(
actions,
speed_factor=speed,
turn_handling={"preemptive_generation": session_preemptive},
)
agent = MyAgent(turn_handling={"preemptive_generation": agent_preemptive})
agent_state_events: list[AgentStateChangedEvent] = []
user_state_events: list[UserStateChangedEvent] = []
session.on("agent_state_changed", agent_state_events.append)
session.on("user_state_changed", user_state_events.append)
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
t_user_stop_speaking = user_state_events[1].created_at
t_agent_start_speaking = agent_state_events[2].created_at
check_timestamp(
t_agent_start_speaking - t_user_stop_speaking,
t_target=expected_latency,
speed_factor=speed,
max_abs_diff=0.2,
)
@pytest.mark.parametrize(
"preemptive_generation, on_user_turn_completed_delay",
[
(False, 0.0),
(False, 2.0),
(True, 0.0),
(True, 2.0),
],
)
async def test_interrupt_during_on_user_turn_completed(
preemptive_generation: bool, on_user_turn_completed_delay: float
) -> None:
"""
Test interrupt during preemptive generation and on_user_turn_completed.
"""
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.0, "Tell me a story", stt_delay=0.2)
actions.add_llm("Here is a story for you...", ttft=0.1, duration=0.3)
actions.add_tts(10.0, ttfb=1.0) # latency after end of turn: 1.3s
actions.add_user_speech(2.6, 3.2, "about a firefighter.") # interrupt before speaking
actions.add_llm("Here is a story about a firefighter...", ttft=0.1, duration=0.3)
actions.add_tts(10.0, ttfb=0.3)
session = create_session(
actions,
speed_factor=speed,
turn_handling={"preemptive_generation": {"enabled": preemptive_generation}},
)
agent = MyAgent(on_user_turn_completed_delay=on_user_turn_completed_delay / speed)
agent_state_events: list[AgentStateChangedEvent] = []
conversation_events: list[ConversationItemAddedEvent] = []
session.on("agent_state_changed", agent_state_events.append)
session.on("conversation_item_added", conversation_events.append)
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
assert agent_state_events[0].old_state == "initializing"
assert agent_state_events[0].new_state == "listening"
if on_user_turn_completed_delay == 0.0:
# on_user_turn_completed already committed before interrupting
assert len(agent_state_events) == 6
assert agent_state_events[1].new_state == "thinking"
assert agent_state_events[2].new_state == "listening"
assert agent_state_events[3].new_state == "thinking"
assert agent_state_events[4].new_state == "speaking"
assert agent_state_events[5].new_state == "listening"
else:
assert len(agent_state_events) == 4
assert agent_state_events[1].new_state == "thinking"
assert agent_state_events[2].new_state == "speaking"
assert agent_state_events[3].new_state == "listening"
assert len(conversation_events) == 4
assert conversation_events[0].item.type == "agent_handoff"
assert conversation_events[1].item.type == "message"
assert conversation_events[1].item.role == "user"
assert conversation_events[1].item.text_content == "Tell me a story"
assert conversation_events[2].item.type == "message"
assert conversation_events[2].item.role == "user"
assert conversation_events[2].item.text_content == "about a firefighter."
assert conversation_events[3].item.type == "message"
assert conversation_events[3].item.role == "assistant"
assert conversation_events[3].item.text_content == "Here is a story about a firefighter..."
async def test_unknown_function_call() -> None:
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "Check the weather")
actions.add_llm(
content="",
tool_calls=[
FunctionToolCall(
name="nonexistent_tool", arguments='{"location": "Tokyo"}', call_id="1"
)
],
)
actions.add_llm(
content="I don't have access to that function.",
input="Unknown function: nonexistent_tool",
)
actions.add_tts(2.0)
session = create_session(actions, speed_factor=speed)
agent = MyAgent()
tool_executed_events: list[FunctionToolsExecutedEvent] = []
session.on("function_tools_executed", tool_executed_events.append)
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
assert len(tool_executed_events) == 1
assert tool_executed_events[0].function_calls[0].name == "nonexistent_tool"
assert tool_executed_events[0].function_call_outputs[0].is_error is True
assert "Unknown function" in tool_executed_events[0].function_call_outputs[0].output
chat_ctx_items = agent.chat_ctx.items
error_outputs = [
item
for item in chat_ctx_items
if item.type == "function_call_output" and item.is_error is True
]
assert len(error_outputs) == 1
assert "Unknown function: nonexistent_tool" in error_outputs[0].output
async def test_invalid_tool_arguments_surface_as_tool_error() -> None:
"""When the LLM emits a tool call with invalid arguments (missing required
field, wrong type, malformed JSON, etc.), the faulty turn must NOT be
stripped from the conversation. Instead the schema error is wrapped in a
ToolError so the model receives a descriptive message and can self-correct
on the next turn."""
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "What's the weather?")
# get_weather requires `location: str` — emit a call with no args so it
# fails pydantic validation.
actions.add_llm(
content="",
tool_calls=[
FunctionToolCall(name="get_weather", arguments="{}", call_id="1"),
],
)
session = create_session(actions, speed_factor=speed)
agent = MyAgent()
tool_executed_events: list[FunctionToolsExecutedEvent] = []
session.on("function_tools_executed", tool_executed_events.append)
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
# Event was emitted with both the call AND a non-None output (i.e., not stripped).
assert len(tool_executed_events) == 1
ev = tool_executed_events[0]
assert len(ev.function_calls) == 1
assert ev.function_calls[0].name == "get_weather"
assert ev.function_call_outputs[0] is not None
output = ev.function_call_outputs[0]
assert output.is_error is True
# The model must see a descriptive, schema-specific error — NOT the generic
# "An internal error occurred" string we reserve for unexpected exceptions.
assert "An internal error occurred" not in output.output
assert "get_weather" in output.output
# Pydantic validation error references the missing field.
assert "location" in output.output
# The faulty call AND its error output must both end up in chat history so
# the LLM can see what it did wrong on the next turn (not stripped).
items = agent.chat_ctx.items
function_calls = [i for i in items if i.type == "function_call"]
function_call_outputs = [i for i in items if i.type == "function_call_output"]
assert len(function_calls) == 1
assert function_calls[0].name == "get_weather"
assert function_calls[0].call_id == "1"
assert len(function_call_outputs) == 1
assert function_call_outputs[0].call_id == "1"
assert function_call_outputs[0].is_error is True
async def test_tool_internal_exception_returns_generic_error() -> None:
"""When a tool body raises a non-ToolError exception, the model receives
the generic "An internal error occurred" message so we don't leak internal
details. Validation-error path is tested separately."""
class _BrokenToolAgent(Agent):
def __init__(self) -> None:
super().__init__(instructions="You are a helpful assistant.")
@function_tool
async def get_weather(self, location: str) -> str:
"""Always blows up."""
raise RuntimeError("kaboom: secret database password leaked")
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "What's the weather in Tokyo?")
actions.add_llm(
content="",
tool_calls=[
FunctionToolCall(name="get_weather", arguments='{"location": "Tokyo"}', call_id="1"),
],
)
session = create_session(actions, speed_factor=speed)
agent = _BrokenToolAgent()
tool_executed_events: list[FunctionToolsExecutedEvent] = []
session.on("function_tools_executed", tool_executed_events.append)
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
assert len(tool_executed_events) == 1
output = tool_executed_events[0].function_call_outputs[0]
assert output is not None
assert output.is_error is True
# Generic message — the RuntimeError details must NOT leak to the model.
assert output.output == "An internal error occurred"
assert "kaboom" not in output.output
assert "secret" not in output.output
# helpers
class _TestRecognitionHooks:
def __init__(self) -> None:
self.interruptions: list[inference.OverlappingSpeechEvent] = []
self.final_transcripts: list[str] = []
def on_interruption(self, ev: inference.OverlappingSpeechEvent) -> None:
self.interruptions.append(ev)
def on_start_of_speech(self, ev: object, speech_start_time: float) -> None:
pass
def on_vad_inference_done(self, ev: object) -> None:
pass
def on_end_of_speech(self, ev: object) -> None:
pass
def on_interim_transcript(self, ev: SpeechEvent, *, speaking: bool | None) -> None:
pass
def on_final_transcript(self, ev: SpeechEvent, *, speaking: bool | None = None) -> None:
self.final_transcripts.append(ev.alternatives[0].text)
def on_end_of_turn(self, info: _EndOfTurnInfo) -> bool:
return True
def on_preemptive_generation(self, info: object) -> None:
pass
def retrieve_chat_ctx(self) -> ChatContext:
return ChatContext.empty()
def _interruption_event() -> inference.OverlappingSpeechEvent:
return inference.OverlappingSpeechEvent(
type="overlapping_speech",
is_interruption=True,
overlap_started_at=time.time(),
detected_at=time.time(),
)
def _backchannel_event() -> inference.OverlappingSpeechEvent:
return inference.OverlappingSpeechEvent(
type="overlapping_speech",
is_interruption=False,
overlap_started_at=time.time(),
detected_at=time.time(),
)
def _final_transcript_event(*, text: str, start_time: float, end_time: float) -> SpeechEvent:
return SpeechEvent(
type=SpeechEventType.FINAL_TRANSCRIPT,
alternatives=[
SpeechData(
text=text,
language=LanguageCode(""),
start_time=start_time,
end_time=end_time,
)
],
)
async def _close_test_session(session: object) -> None:
await session.aclose()
audio_output = session.output.audio
synchronizer = getattr(audio_output, "_synchronizer", None)
if synchronizer is not None:
await synchronizer.aclose()
def check_timestamp(
t_event: float,
t_target: float,
*,
speed_factor: float = 1.0,
max_abs_diff: float = 0.75,
min_real_time_diff: float = 0.3,
) -> None:
"""
Check if the event timestamp is within the target timestamp +/- max_abs_diff.
The event timestamp is scaled by the speed factor.
``max_abs_diff`` is expressed in scaled time. A real-time floor of
``min_real_time_diff`` (wallclock seconds) is also applied so high
``speed_factor`` values don't compress the effective tolerance below the
scheduling-jitter noise floor on CI runners — without this, the real-time
tolerance is ``max_abs_diff / speed_factor``, which at speed=5 is only
150 ms and routinely flakes.
"""
t_event_scaled = t_event * speed_factor
effective_diff = max(max_abs_diff, min_real_time_diff * speed_factor)
print(
f"check_timestamp: t_event={t_event_scaled} (real {t_event:.3f}s), "
f"t_target={t_target}, effective_diff={effective_diff} "
f"(max_abs_diff={max_abs_diff}, min_real_time_diff={min_real_time_diff})"
)
assert abs(t_event_scaled - t_target) <= effective_diff, (
f"event timestamp {t_event_scaled} is not within {effective_diff} of target {t_target} "
f"(real-time tolerance {effective_diff / speed_factor:.3f}s)"
)
async def test_silent_tool_call_pause_state_does_not_leak_into_tool_reply() -> None:
speed = 1
actions = FakeActions()
actions.add_user_speech(0.1, 0.2, "What's the weather in Tokyo?", stt_delay=0.05)
# Silent tool-call step: no spoken preamble/audio before the function call.
actions.add_llm(
content="",
tool_calls=[
FunctionToolCall(
name="get_weather",
arguments='{"location": "Tokyo"}',
call_id="1",
)
],
ttft=0.05,
duration=1.0,
)
# VAD-only speech starts during the silent tool-call generation and remains
# active after the tool reply starts.
actions.add_user_speech(0.85, 2.0, "", stt_delay=0.05)
actions.add_llm(
content="The weather in Tokyo is sunny today.",
input="The weather in Tokyo is sunny today.",
ttft=0.0,
duration=0.0,
)
actions.add_tts(0.5, ttfb=0.0, duration=0.0)
session = create_session(
actions,
speed_factor=speed,
can_pause_audio=True,
turn_handling={"interruption": {"false_interruption_timeout": 0.2 / speed}},
)
agent = MyAgent()
agent_state_events: list[AgentStateChangedEvent] = []
false_interruption_events: list[AgentFalseInterruptionEvent] = []
session.on("agent_state_changed", agent_state_events.append)
session.on("agent_false_interruption", false_interruption_events.append)
await asyncio.wait_for(
run_session(session, agent, drain_delay=0.8 / speed),
timeout=SESSION_TIMEOUT,
)
transitions = [(ev.old_state, ev.new_state) for ev in agent_state_events]
silent_step_finished = transitions.index(("speaking", "listening"))
# Before the fix this is ("listening", "thinking") because the pause state
# captured during the silent tool-call step leaks into the tool reply.
assert transitions[silent_step_finished + 1] == ("listening", "speaking")
assert false_interruption_events
assert false_interruption_events[-1].resumed is True
async def test_default_vad_is_auto_provisioned() -> None:
from livekit.agents.voice.agent_session import AgentSession
session = AgentSession()
try:
assert session.vad is not None
assert session._using_default_vad is True
finally:
await session.aclose()
async def test_explicit_vad_none_opts_out() -> None:
from livekit.agents.voice.agent_session import AgentSession
session = AgentSession(vad=None)
try:
assert session.vad is None
assert session._using_default_vad is False
finally:
await session.aclose()
async def test_user_supplied_vad_clears_default_flag() -> None:
from livekit.agents.voice.agent_session import AgentSession
from .fake_vad import FakeVAD
user_vad = FakeVAD(fake_user_speeches=[])
session = AgentSession(vad=user_vad)
try:
assert session.vad is user_vad
assert session._using_default_vad is False
finally:
await session.aclose()
async def test_default_turn_detection_builds_default_eot() -> None:
"""No turn_detection given → session auto-provisions a default TurnDetector."""
from livekit.agents.voice.agent_session import AgentSession
from livekit.agents.voice.turn import _StreamingTurnDetector
session = AgentSession()
try:
assert isinstance(session.turn_detection, _StreamingTurnDetector)
finally:
await session.aclose()
async def test_turn_detection_none_opts_out() -> None:
"""Explicit None opts out of turn detection (no default detector built)."""
from livekit.agents.voice.agent_session import AgentSession
session = AgentSession(turn_handling={"turn_detection": None})
try:
assert session.turn_detection is None
finally:
await session.aclose()
async def test_user_supplied_turn_detector_passes_through() -> None:
from livekit.agents import inference
from livekit.agents.voice.agent_session import AgentSession
user_detector = inference.TurnDetector(version="v1-mini")
session = AgentSession(turn_handling={"turn_detection": user_detector})
try:
assert session.turn_detection is user_detector
finally:
await session.aclose()
async def test_streaming_detector_uses_streaming_endpointing_defaults() -> None:
"""Default session → streaming detector → tighter 0.3/2.5 endpointing defaults."""
from livekit.agents.voice.agent_session import AgentSession
session = AgentSession()
try:
assert session._opts.endpointing["min_delay"] == 0.3
assert session._opts.endpointing["max_delay"] == 2.5
assert session._opts.endpointing_overrides == {}
finally:
await session.aclose()
async def test_non_streaming_detector_uses_legacy_endpointing_defaults() -> None:
"""A non-streaming mode keeps the legacy 0.5/3.0 defaults."""
from livekit.agents.voice.agent_session import AgentSession
session = AgentSession(turn_handling={"turn_detection": "vad"})
try:
assert session._opts.endpointing["min_delay"] == 0.5
assert session._opts.endpointing["max_delay"] == 3.0
finally:
await session.aclose()
async def test_explicit_endpointing_overrides_streaming_default_per_key() -> None:
"""An explicit delay is honored; the unset one still gets the streaming default."""
from livekit.agents.voice.agent_session import AgentSession
session = AgentSession(turn_handling={"endpointing": {"min_delay": 0.4}})
try:
assert session._opts.endpointing["min_delay"] == 0.4
assert session._opts.endpointing["max_delay"] == 2.5
assert session._opts.endpointing_overrides == {"min_delay": 0.4}
finally:
await session.aclose()
async def test_user_streaming_detector_uses_streaming_defaults() -> None:
"""A user-constructed streaming detector also triggers the streaming defaults."""
from livekit.agents import inference
from livekit.agents.voice.agent_session import AgentSession
session = AgentSession(
turn_handling={"turn_detection": inference.TurnDetector(version="v1-mini")}
)
try:
assert session._opts.endpointing["min_delay"] == 0.3
assert session._opts.endpointing["max_delay"] == 2.5
finally:
await session.aclose()
async def test_deprecated_turn_detection_vad_uses_legacy_defaults() -> None:
"""Deprecated turn_detection arg + no delays → legacy defaults (non-streaming)."""
from livekit.agents.voice.agent_session import AgentSession
session = AgentSession(turn_detection="vad")
try:
assert session._opts.endpointing["min_delay"] == 0.5
assert session._opts.endpointing["max_delay"] == 3.0
finally:
await session.aclose()
async def test_agent_turn_detection_override_resolves_endpointing_per_activity() -> None:
"""endpointing_opts uses the activity's resolved detector, not just the session's."""
from livekit.agents.voice.agent_session import AgentSession
from .fake_vad import FakeVAD
# session default → streaming detector; provide VAD so it validates
session = AgentSession(vad=FakeVAD(fake_user_speeches=[]))
try:
streaming_activity = AgentActivity(Agent(instructions="test"), session)
assert streaming_activity.endpointing_opts["min_delay"] == 0.3
assert streaming_activity.endpointing_opts["max_delay"] == 2.5
# an agent overriding to VAD falls back to legacy defaults for this activity
vad_activity = AgentActivity(Agent(instructions="test", turn_detection="vad"), session)
assert vad_activity.endpointing_opts["min_delay"] == 0.5
assert vad_activity.endpointing_opts["max_delay"] == 3.0
finally:
await session.aclose()
async def test_runtime_endpointing_opts_survive_handoff() -> None:
"""update_options changes are recorded as overrides, so a new activity keeps them."""
from livekit.agents.voice.agent_session import AgentSession
from .fake_vad import FakeVAD
session = AgentSession(vad=FakeVAD(fake_user_speeches=[]))
try:
session.update_options(endpointing_opts={"mode": "dynamic", "alpha": 0.5, "min_delay": 0.4})
# a fresh activity (as built on agent handoff) re-resolves from overrides
activity = AgentActivity(Agent(instructions="test"), session)
assert activity.endpointing_opts["mode"] == "dynamic"
assert activity.endpointing_opts["alpha"] == 0.5
assert activity.endpointing_opts["min_delay"] == 0.4
# untouched key still gets the streaming default
assert activity.endpointing_opts["max_delay"] == 2.5
finally:
await session.aclose()
class FlushMultiSegmentAgent(Agent):
"""Agent whose llm_node flushes the reply into two segments via FlushSentinel."""
def __init__(self) -> None:
super().__init__(instructions="You are a helpful assistant.")
async def llm_node(
self,
chat_ctx: ChatContext,
tools: list,
model_settings: ModelSettings,
) -> AsyncIterable[str | FlushSentinel]:
yield "Hello there. "
yield FlushSentinel()
yield "How are you?"
async def test_pipeline_multi_segment_flush() -> None:
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "Hello, how are you?", stt_delay=0.2)
# the agent's llm_node injects a FlushSentinel, splitting the reply into two
# segments; register a TTS response keyed by each segment's text
actions.add_tts(1.0, input="Hello there. ", ttfb=0.1, duration=0.1)
actions.add_tts(1.0, input="How are you?", ttfb=0.1, duration=0.1)
session = create_session(actions, speed_factor=speed)
agent = FlushMultiSegmentAgent()
playback_finished_events: list[PlaybackFinishedEvent] = []
session.output.audio.on("playback_finished", playback_finished_events.append)
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
# each FlushSentinel-delimited segment plays out independently
assert len(playback_finished_events) == 2
assert all(not ev.interrupted for ev in playback_finished_events)
# but both segments join into a single assistant message
assistant_msgs = [
it for it in agent.chat_ctx.items if it.type == "message" and it.role == "assistant"
]
assert len(assistant_msgs) == 1
assert assistant_msgs[0].text_content == "Hello there. How are you?"
async def test_pipeline_multi_segment_interrupted() -> None:
speed = 1
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "Hello, how are you?", stt_delay=0.2)
# long first segment so the interrupt lands while it is still playing
actions.add_tts(15.0, input="Hello there. ", ttfb=0.1, duration=0.1)
actions.add_tts(1.0, input="How are you?", ttfb=0.1, duration=0.1)
session = create_session(actions, speed_factor=speed)
agent = FlushMultiSegmentAgent()
playback_finished_events: list[PlaybackFinishedEvent] = []
session.output.audio.on("playback_finished", playback_finished_events.append)
asyncio.get_event_loop().call_later(5 / speed, session.interrupt)
await asyncio.wait_for(run_session(session, agent, drain_delay=0.5), timeout=SESSION_TIMEOUT)
# interrupted during the first segment: only that segment plays, the second
# segment is never forwarded
assert len(playback_finished_events) == 1
assert playback_finished_events[0].interrupted is True
assistant_msgs = [
it for it in agent.chat_ctx.items if it.type == "message" and it.role == "assistant"
]
assert len(assistant_msgs) == 1
assert assistant_msgs[0].interrupted is True
assert "How are you?" not in (assistant_msgs[0].text_content or "")