Skip to content

fix(voice): stop the interrupt walk at a queued speech that disallows interruptions - #6644

Open
biztex wants to merge 5 commits into
livekit:mainfrom
biztex:fix/interrupt-partial-application
Open

fix(voice): stop the interrupt walk at a queued speech that disallows interruptions#6644
biztex wants to merge 5 commits into
livekit:mainfrom
biztex:fix/interrupt-partial-application

Conversation

@biztex

@biztex biztex commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

A bug in AgentActivity.interrupt(), independent of how #6635 gets fixed — found while working on #6638, which was closed in favour of #6642. It isn't realtime-specific.

Problem

SpeechHandle.interrupt() raises RuntimeError when the handle disallows interruptions, and the queue loop calls it on every queued speech without handling that. With an interruptible speech playing and a protected one queued behind it, the raise escapes from the middle of interrupt(): the remaining queued speeches are never interrupted and the returned future never resolves.

Reachable today without a realtime model: session.say("...", allow_interruptions=False) queued behind another speech, then any session.interrupt() from user code.

Fix

The queue walk stops at the first speech that disallows interruptions, logging a warning that names the handle and points at force=True. Everything behind it is going to play, so interrupting past it would leave a gap in the conversation — skipping per handle would drop the middle speech of [protected, interruptible, protected] and play the other two.

The queue is walked sorted by (priority, sequence), since _speech_q is a heap and its list order is not the order it pops in.

Behaviour that is deliberately unchanged: the preemptive generation and the background speeches are cancelled as before, rt_session.interrupt() is still called unconditionally (nothing maps the streaming realtime response to a SpeechHandle, so there is no sound way to decide it belongs to a speech that was kept), a protected speech that is currently playing still raises, and force=True interrupts the whole chain.

Verification

tests/test_interrupt_protected_speech.py covers stopping mid-queue with the speeches behind untouched and the warning emitted, the [protected, interruptible, protected] no-hole case, a priority-ordered case where heap order differs from pop order, the playing-protected speech still raising, force=True, and the ordinary all-interruptible path.

ruff format --check, ruff check, check_types.py (mypy strict) and the full pytest --unit suite pass locally.

SpeechHandle.interrupt() raises when the handle disallows interruptions, and
that raise escaped from the middle of AgentActivity.interrupt(): the
preemptive generation was already cancelled and the background speeches
already interrupted, while the queue kept playing, the realtime session was
never told and the returned future never resolved. Background speeches were
filtered but queued ones were not, so a queued say(allow_interruptions=False)
aborted the sequence as well.

The playing speech is now checked before anything is touched (force=True
still interrupts it) and queued handles are filtered like the background
ones. The raise is documented on both interrupt() entry points.

@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: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 2 additional findings.

Open in Devin Review

Comment thread livekit-agents/livekit/agents/voice/agent_activity.py Outdated
…ed speech

filtering the queue per handle punched a hole in it: with
[protected, interruptible, protected] the middle speech was dropped while the
other two still played. The walk now follows the playout order - the playing
speech, then the queue sorted as it will be popped, since _speech_q is a heap
whose list order is not that order - and stops at the first speech that
disallows interruptions, so everything behind it plays untouched.

The preemptive generation and the background speeches do not depend on the
playing speech, so they are cancelled unconditionally as before. The realtime
cancel is sent when nothing is playing or the playing speech was actually
interrupted; otherwise it would truncate the speech that was kept.
@biztex biztex changed the title fix(voice): never half-apply an interrupt on a protected speech fix(voice): interrupt in playout order, stopping at the first protected speech Jul 31, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

the guard keyed off _current_speech, which is wrong in two windows. A
realtime generation sits in _speech_q until the scheduling task promotes it,
so with nothing playing yet the walk could keep a protected queue head and
still cancel the response belonging to it. And SpeechHandle._cancel() is a
no-op once the handle is done, leaving `interrupted` False for a done but
not yet cleared current speech - the provider was then left generating even
though nothing was protected. The cancel now depends on whether the walk
kept the head of the playout order.
devin-ai-integration[bot]

This comment was marked as resolved.

a done or force-interrupted handle lingers in _current_speech and in the
queue until the scheduling task drops it. When such a stale handle disallowed
interruptions the walk stopped on it, leaving the live speeches behind it
playing and suppressing the realtime cancel, even though it can no longer
leave a gap in the conversation. It is skipped now, and the cancel depends on
whether the first speech that will actually play was kept - walking past a
stale handle must not make a live protected one behind it look interrupted.
Comment thread livekit-agents/livekit/agents/voice/agent_activity.py Outdated
Comment thread livekit-agents/livekit/agents/voice/agent_activity.py Outdated
…realtime

per review: nothing maps the streaming realtime response to a SpeechHandle,
so queue position cannot stand in for ownership - the cancel is unconditional
again, as before this PR. The queue is walked in pop order and stops at the
first speech that disallows interruptions, with a warning naming the handle,
so the speeches behind it keep playing instead of the raise escaping and
leaving them scheduled with the future unresolved.
@biztex biztex changed the title fix(voice): interrupt in playout order, stopping at the first protected speech fix(voice): stop the interrupt walk at a queued speech that disallows interruptions Aug 1, 2026

@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 2 additional findings in Devin Review.

Open in Devin Review

Comment thread livekit-agents/livekit/agents/voice/agent_activity.py
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.

2 participants