Skip to content

fix(voice): respect allow_interruptions=False with server-side turn detection - #6638

Closed
biztex wants to merge 5 commits into
livekit:mainfrom
biztex:fix/realtime-allow-interruptions
Closed

fix(voice): respect allow_interruptions=False with server-side turn detection#6638
biztex wants to merge 5 commits into
livekit:mainfrom
biztex:fix/realtime-allow-interruptions

Conversation

@biztex

@biztex biztex commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #6635

Problem

RealtimeModel sessions with server-side turn detection forced interruptions on, in three places: AgentActivity raised a ValueError when allow_interruptions=False; say() and generate_reply() silently overrode an explicit False back to the default; and every input_speech_started event unconditionally called interrupt(), sending response.cancel to the provider. Any overlap between agent and user speech cut the agent off — even in manual/button-style turn-taking setups that explicitly configure the provider with interrupt_response=False (the issue reporter was facing a fork to escape this).

Why the coupling existed — and why it can go

The comment above the unconditional interrupt explains it: interrupt() raises when allow_interruptions is False, and input_speech_started only fires with server-side turn detection — so the constraint existed to avoid that raise, not because the combination is meaningless. Guarding the call sites removes the need for the constraint.

Change

  • Construction: the ValueError becomes a warning that reminds the user the provider must be configured to match (e.g. interrupt_response=False in the OpenAI turn_detection settings) — otherwise the server still cancels the in-progress response on its side, which the client cannot prevent.
  • _on_input_speech_started: skips the local interrupt()/response.cancel when interruptions are disallowed (user-state bookkeeping unchanged).
  • say() / generate_reply(): an explicit allow_interruptions=False is respected instead of silently overridden with a warning.

Default behavior (interruptions allowed) is byte-identical.

Tests

New hermetic tests/test_realtime_allow_interruptions.py on the fake realtime model: construction succeeds with the warning (and stays silent when interruptions are allowed), and input_speech_started calls interrupt() when allowed / never when disallowed. Full unit suite passes unchanged.

…etection

RealtimeModel sessions using server-side turn detection forced interruptions
on: the activity raised ValueError when allow_interruptions=False, say() and
generate_reply() silently overrode an explicit False, and every
input_speech_started event unconditionally sent response.cancel - cutting the
agent off on any speech overlap. Manual/button-style turn-taking setups that
configure the provider with interrupt_response=False could not opt out
without forking.

The coupling was defensive, not fundamental (interrupt() would have raised on
a non-interruptable speech). Respect the setting instead: construction logs a
warning reminding that the provider must be configured to match (otherwise
the server still cancels its side), input_speech_started skips the local
interrupt when interruptions are disallowed, and say()/generate_reply() no
longer override an explicit False.

Fixes livekit#6635
@biztex
biztex requested a review from a team as a code owner July 31, 2026 06:53
devin-ai-integration[bot]

This comment was marked as resolved.

interruptibility is a per-speech property: with the say()/generate_reply()
overrides gone, an explicitly protected speech could exist while the session
default allows interruptions, and input_speech_started then raised (and
logged) a spurious RuntimeError from interrupt(). The symmetric case - an
explicitly interruptible speech under a protective session default - was
never interrupted locally. The session-wide default now only decides when
no speech is playing.
devin-ai-integration[bot]

This comment was marked as resolved.

…rupt()

with the overrides gone a protected handle can sit in the speech queue while
an interruptible one plays; SpeechHandle.interrupt() then raised mid-loop,
aborting the rest of the interruption sequence (remaining queued speeches,
rt_session.interrupt(), the done-callbacks wiring the returned future) and
logging a misleading internal error. Queued handles are now filtered the
same way background speeches already are.
devin-ai-integration[bot]

This comment was marked as resolved.

the streaming realtime response belongs to the newest queued generation, so
sending response.cancel while a protected reply sits in the queue truncated
exactly the speech that was just preserved. The cancel is now skipped in
that case (force still cancels); interrupted handles stop locally either way
and truncate their own chat-context item when playout ends partial, which is
independent of the cancel.
devin-ai-integration[bot]

This comment was marked as resolved.

the previous proxy - any protected handle in the queue - suppressed
response.cancel in the common flow where the streaming response belongs to
the playing speech and the protected handle is a not-yet-started say(),
leaving the provider generating a reply nobody hears. The activity now
tracks the handle that owns the in-flight realtime generation, and the
cancel is skipped only when that handle disallows interruptions and is
still live.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 6 additional findings in Devin Review.

Open in Devin Review

Comment thread livekit-agents/livekit/agents/voice/agent_activity.py
@longcw

longcw commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

closing in favor of #6642

@biztex

biztex commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Makes sense — #6642 solves #6635 at the right level; reclassifying create_response=False as client-side turn taking avoids the interruption-path changes I was making here entirely.

Two things the review here turned up are independent of that and still reproduce on main (a pipeline agent with say(allow_interruptions=False) plus session.interrupt() is enough): interrupt() raising from the middle of the sequence after the background speeches are already interrupted, and the _speech_q loop not filtering protected handles the way _interrupt_background_speeches does. I've moved just those into #6644 — everything else here is superseded.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RealtimeModel forces allow_interruptions=True / response.cancel even when OpenAI interrupt_response=False (server VAD + manual responses)

2 participants