π§ Implementation: #7203
PR #7203 β feat(voice): expose a counted SpeechHandle interruption hold implements this. All checks green.
It exposes hold_interruptions() as proposed, and additionally:
- keeps the hold count beside
allow_interruptions instead of overwriting it, which fixes a latent bug on main where an assignment during a hold defeated the hold outright;
- narrows rather than demotes the realtime
input_speech_started log, so the genuine desync signal it was added for survives;
- covers the claim that matters through
AgentActivity._on_input_speech_started with server-side turn detection on β a held speech survives the barge-in and no response.cancel is sent;
- documents the three things a hold does not do, including the two that are easy to discover the hard way (
discard_audio_if_uninterruptible defaults to True, so the caller is not heard while a held speech plays; and a user turn completing during a hold generates no reply).
Feature request
Expose a supported way to make one utterance survive barge-in while keeping the realtime model's server-side turn detection and its automatic responses.
SpeechHandle._hold_interruptions() / _release_interruptions() already implement exactly the right semantics, but are private. Please make them public (ideally as a context manager), and treat holding as a supported pattern in realtime mode.
Use case
A voice agent that speaks some wording verbatim via session.say() β recording consent, a regulatory disclosure, a handover announcement. That wording must arrive whole. Everything else in the call should stay freely interruptible, and the caller must still be heard while it plays.
Concretely, per-utterance rather than per-session:
- A compliance prompt for its own playout. "Do I have your consent to�" must land before an answer to it can be accepted.
- A group of utterances as one unit. A handover says why, then a caveat, then a queue length. Queued as three
say() calls, anything the model emits between them interrupts what is still queued β we saw exactly this: sentence one played, the other two were dropped. Our workaround is to concatenate all three into a single say() string, which loses per-sentence structure.
- Across a tool round-trip. "Let me look that up" β external call β "your balance is X", where a caller's "hello?" mid-lookup should not kill the follow-up.
- Uninterruptible except by a deliberate stop.
interrupt(force=True) bypasses a hold, so a stop word / DTMF / supervisor barge-in still cuts through while ordinary overlap does not. This is what a compliance prompt actually wants β not "cannot be stopped".
Why the existing routes do not fit
say(..., allow_interruptions=False) is scrubbed with a warning under server-side turn detection (agent_activity.py:1535), so the call proceeds and the audio is still interruptible.
create_response=False does unlock the flag since #6642 (turn_detection=_server_turn_taking_enabled(...)), and that is a different trade: the server stops answering turns, so the application has to drive every reply. With no VAD or STT the mode resolves to manual. That works for a button-driven turn model (#6635) but not for one that wants the provider to keep doing turn detection and auto-responses β which is the case here, because every locally-driven endpointer we tried was measurably worse on the input that matters most (a caller reading a ten-digit number in groups had their turn ended at each pause).
AgentSession(allow_interruptions=False) is a hard error (agent_activity.py:297), and would be session-wide anyway. We want two prompts protected, not the call.
Detaching the input (session.input.set_audio_enabled(False) for the sentence's duration) does enforce it, and is a trap worth documenting: it made the caller unhearable for 16 s across two protected prompts at the start of a call. They answered twice and neither utterance produced a transcript, so nothing was captured. Note this is also what discard_audio_if_uninterruptible (default True) does on the paths feeding the model β so anyone who does get a held speech today loses the caller's audio unless they also set it False. Worth calling out in the docs next to any holding API.
What works today, and the one rough edge
SpeechHandle.allow_interruptions has a public setter, and the guard is only inside say(), so this does hold the speech:
handle = session.say(text)
handle.allow_interruptions = False # survives barge-in
Two problems with relying on it:
-
It is not the counted/restoring primitive. _hold_interruptions() remembers the previous value and reference-counts holders, so overlapping scopes compose. A bare setter forces False and restores True, which is wrong wherever two reasons to protect overlap.
-
Every barge-in against a held speech logs an exception with a stack trace. In _on_input_speech_started (agent_activity.py:2020):
try:
self.interrupt()
except RuntimeError:
if self._rt_turn_detection_enabled:
logger.exception(
"RealtimeAPI input_speech_started, but current speech is not "
"interruptable, this should never happen!"
)
The speech correctly survives β the RuntimeError is swallowed. But "this should never happen" is reachable by design as soon as a held speech exists in realtime mode, so it logs at exception level on an expected path. If holding becomes supported here, this branch probably wants to be a debug line.
Proposal
# public, counted, restoring
with handle.hold_interruptions():
await handle.wait_for_playout()
Plus a note in the interruption docs that a held speech needs discard_audio_if_uninterruptible=False if the caller should still be heard, and that force=True still lands.
Happy to open a PR if the shape is agreeable.
Version
livekit-agents 1.7.1, livekit-plugins-openai 1.7.1, gpt-realtime.
Related: #6635, #6642, #5359.
Feature request
Expose a supported way to make one utterance survive barge-in while keeping the realtime model's server-side turn detection and its automatic responses.
SpeechHandle._hold_interruptions()/_release_interruptions()already implement exactly the right semantics, but are private. Please make them public (ideally as a context manager), and treat holding as a supported pattern in realtime mode.Use case
A voice agent that speaks some wording verbatim via
session.say()β recording consent, a regulatory disclosure, a handover announcement. That wording must arrive whole. Everything else in the call should stay freely interruptible, and the caller must still be heard while it plays.Concretely, per-utterance rather than per-session:
say()calls, anything the model emits between them interrupts what is still queued β we saw exactly this: sentence one played, the other two were dropped. Our workaround is to concatenate all three into a singlesay()string, which loses per-sentence structure.interrupt(force=True)bypasses a hold, so a stop word / DTMF / supervisor barge-in still cuts through while ordinary overlap does not. This is what a compliance prompt actually wants β not "cannot be stopped".Why the existing routes do not fit
say(..., allow_interruptions=False)is scrubbed with a warning under server-side turn detection (agent_activity.py:1535), so the call proceeds and the audio is still interruptible.create_response=Falsedoes unlock the flag since #6642 (turn_detection=_server_turn_taking_enabled(...)), and that is a different trade: the server stops answering turns, so the application has to drive every reply. With no VAD or STT the mode resolves tomanual. That works for a button-driven turn model (#6635) but not for one that wants the provider to keep doing turn detection and auto-responses β which is the case here, because every locally-driven endpointer we tried was measurably worse on the input that matters most (a caller reading a ten-digit number in groups had their turn ended at each pause).AgentSession(allow_interruptions=False)is a hard error (agent_activity.py:297), and would be session-wide anyway. We want two prompts protected, not the call.Detaching the input (
session.input.set_audio_enabled(False)for the sentence's duration) does enforce it, and is a trap worth documenting: it made the caller unhearable for 16 s across two protected prompts at the start of a call. They answered twice and neither utterance produced a transcript, so nothing was captured. Note this is also whatdiscard_audio_if_uninterruptible(defaultTrue) does on the paths feeding the model β so anyone who does get a held speech today loses the caller's audio unless they also set itFalse. Worth calling out in the docs next to any holding API.What works today, and the one rough edge
SpeechHandle.allow_interruptionshas a public setter, and the guard is only insidesay(), so this does hold the speech:Two problems with relying on it:
It is not the counted/restoring primitive.
_hold_interruptions()remembers the previous value and reference-counts holders, so overlapping scopes compose. A bare setter forcesFalseand restoresTrue, which is wrong wherever two reasons to protect overlap.Every barge-in against a held speech logs an exception with a stack trace. In
_on_input_speech_started(agent_activity.py:2020):The speech correctly survives β the
RuntimeErroris swallowed. But "this should never happen" is reachable by design as soon as a held speech exists in realtime mode, so it logs at exception level on an expected path. If holding becomes supported here, this branch probably wants to be a debug line.Proposal
Plus a note in the interruption docs that a held speech needs
discard_audio_if_uninterruptible=Falseif the caller should still be heard, and thatforce=Truestill lands.Happy to open a PR if the shape is agreeable.
Version
livekit-agents1.7.1,livekit-plugins-openai1.7.1,gpt-realtime.Related: #6635, #6642, #5359.