Skip to content
Merged
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
33 changes: 23 additions & 10 deletions livekit-agents/livekit/agents/voice/agent_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -4451,8 +4451,18 @@ async def _wait_for_auto_tool_reply() -> None:

def _disallow_interruptions(self, speech_handle: SpeechHandle) -> None:
speech_handle.allow_interruptions = False
if self._paused_speech is not None and self._paused_speech.handle is speech_handle:
self._reconcile_playout_pause(speech_handle)
paused_speech = self._paused_speech
if paused_speech is None or paused_speech.handle is not speech_handle:
return

if (
not speech_handle.done()
and self._session.output.audio_enabled
and self._session.output.audio is not None
):
self._restore_paused_speech_state(paused_speech)

self._reconcile_playout_pause(speech_handle)

def _update_paused_speech(self, speech_handle: SpeechHandle, timeout: float) -> None:
"""Record that ``speech_handle`` is paused.
Expand Down Expand Up @@ -4523,6 +4533,16 @@ def _cancel_false_interruption_timer(self) -> None:
self._false_interruption_timer = None
self._false_interruption_pending = False

def _restore_paused_speech_state(self, paused_speech: _PausedSpeechInfo) -> None:
self._session._update_agent_state(
paused_speech.agent_state,
otel_context=paused_speech.handle._agent_turn_context,
)
if self._audio_recognition and paused_speech.agent_state == "speaking":
self._audio_recognition._on_start_of_agent_speech(started_at=time.time())
if self.interruption_enabled:
self._disable_vad_interruption_soon()

def _start_false_interruption_timer(self, timeout: float) -> None:
self._cancel_false_interruption_timer()

Expand All @@ -4541,14 +4561,7 @@ def _on_false_interruption() -> None:
and audio_output.can_pause
and not self._paused_speech.handle.done()
):
self._session._update_agent_state(
self._paused_speech.agent_state,
otel_context=self._paused_speech.handle._agent_turn_context,
)
if self._audio_recognition and self._paused_speech.agent_state == "speaking":
self._audio_recognition._on_start_of_agent_speech(started_at=time.time())
if self.interruption_enabled:
self._disable_vad_interruption_soon()
self._restore_paused_speech_state(self._paused_speech)
audio_output.resume()
resumed = True
logger.debug("resumed false interrupted speech", extra={"timeout": timeout})
Expand Down
88 changes: 86 additions & 2 deletions tests/test_disallow_interruptions_pause.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from __future__ import annotations

import asyncio
from collections.abc import AsyncIterable

import pytest

from livekit.agents import Agent, RunContext, function_tool
from livekit.agents.llm import FunctionToolCall
from livekit.agents import Agent, FlushSentinel, ModelSettings, RunContext, function_tool
from livekit.agents.llm import ChatChunk, ChatContext, ChoiceDelta, FunctionToolCall, Tool
from livekit.agents.voice.io import PlaybackFinishedEvent

from .fake_session import FakeActions, create_session, run_session
Expand All @@ -20,17 +21,54 @@ def __init__(self) -> None:
super().__init__(instructions="Transfer callers when requested.")
self.transfer_started = asyncio.Event()
self.transfer_completed = asyncio.Event()
self.paused_parent_state_before_disallow: str | None = None
self.parent_state_after_disallow: str | None = None
self.recognition_speaking_after_disallow: bool | None = None

@function_tool
async def transfer_to_live_agent(self, context: RunContext) -> None:
"""Transfer the caller to a live agent."""
self.transfer_started.set()
activity = context.session._activity
assert activity is not None and activity._audio_recognition is not None
paused_speech = activity._paused_speech
self.paused_parent_state_before_disallow = (
paused_speech.agent_state if paused_speech is not None else None
)
context.disallow_interruptions()
self.parent_state_after_disallow = context.session.agent_state
self.recognition_speaking_after_disallow = activity._audio_recognition._agent_speaking
context.session.input.set_audio_enabled(False)
await context.session.say(TRANSFER_MESSAGE, allow_interruptions=False)
self.transfer_completed.set()


class DelayedTransferAgent(TransferAgent):
async def llm_node(
self,
chat_ctx: ChatContext,
tools: list[Tool],
model_settings: ModelSettings,
) -> AsyncIterable[ChatChunk | str | FlushSentinel]:
del chat_ctx, tools, model_settings
yield "Happy to connect you."
yield FlushSentinel()
await asyncio.sleep(3.0)
yield ChatChunk(
id="transfer-response",
delta=ChoiceDelta(
role="assistant",
tool_calls=[
FunctionToolCall(
name="transfer_to_live_agent",
arguments="{}",
call_id="transfer-1",
)
],
),
)


async def test_uninterruptible_tool_speech_plays_after_paused_parent() -> None:
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "Connect me to an agent.")
Expand Down Expand Up @@ -68,3 +106,49 @@ async def test_uninterruptible_tool_speech_plays_after_paused_parent() -> None:
assert [event.playback_position for event in playback_finished_events] == pytest.approx(
[2.0, 1.0], abs=0.02
)


async def test_uninterruptible_tool_restores_paused_parent_speaking_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
actions = FakeActions()
actions.add_user_speech(0.5, 2.5, "Connect me to an agent.")
# The first LLM segment reaches TTS immediately. Its playout is active when
# VAD pauses the parent, before DelayedTransferAgent emits the tool call.
actions.add_tts(5.0, input="Happy to connect you.", ttfb=0.1, duration=0.2)
# VAD pauses the parent after playback has started but before the tool runs.
actions.add_user_speech(4.0, 8.0, "")
actions.add_tts(1.0, input=TRANSFER_MESSAGE)

session = create_session(actions, can_pause_audio=True)
agent = DelayedTransferAgent()
audio_output = session.output.audio
assert audio_output is not None
original_resume = audio_output.resume
restored_state_observed_during_resume = False

def resume_with_state_assertion() -> None:
nonlocal restored_state_observed_during_resume
if agent.transfer_started.is_set() and agent.parent_state_after_disallow is None:
activity = session._activity
assert activity is not None and activity._audio_recognition is not None
assert session.agent_state == "speaking"
assert activity._audio_recognition._agent_speaking
restored_state_observed_during_resume = True
original_resume()

monkeypatch.setattr(audio_output, "resume", resume_with_state_assertion)

run_task = asyncio.create_task(run_session(session, agent))
try:
await asyncio.wait_for(agent.transfer_started.wait(), timeout=7.0)
await asyncio.wait_for(agent.transfer_completed.wait(), timeout=8.0)
finally:
if not run_task.done():
await session.aclose()
await run_task

assert agent.paused_parent_state_before_disallow == "speaking"
assert restored_state_observed_during_resume
assert agent.parent_state_after_disallow == "speaking"
assert agent.recognition_speaking_after_disallow is True