Skip to content

fix(voice): avoid dropping turns and resuming early while a speech is paused - #2204

Open
rosetta-livekit-bot[bot] wants to merge 4 commits into
mainfrom
port/fix-paused-speech-turns
Open

fix(voice): avoid dropping turns and resuming early while a speech is paused#2204
rosetta-livekit-bot[bot] wants to merge 4 commits into
mainfrom
port/fix-paused-speech-turns

Conversation

@rosetta-livekit-bot

@rosetta-livekit-bot rosetta-livekit-bot Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Ports livekit/agents#6662 to keep adaptive interruption inference alive while speech is paused, commit unjudged realtime overlaps, and defer false-interruption resume until the active turn decision settles.

Adds the corresponding paused-speech, overlap-lifecycle, teardown, skipped-reply, and server-side turn-taking regression coverage plus a patch changeset for @livekit/agents.

Source diff coverage
  • livekit-agents/livekit/agents/voice/agent_activity.py: ported to agents/src/voice/agent_activity.ts. Asyncio timers/tasks are translated to NodeJS.Timeout and Task; timeout values remain in the target framework’s millisecond convention. Includes turn-gate, deferred resume ownership, cancellation consolidation, skipped/server-side reply exceptions, and pause interruption teardown.
  • livekit-agents/livekit/agents/voice/audio_recognition.py: ported to agents/src/voice/audio_recognition.ts. Preserves detector streams across pause/resume, tracks the currently open overlap independently from turn history, closes overlaps exactly once, and tears them down on a real agent-speech end.
  • tests/test_false_interruption_resume.py: adapted to the co-located Vitest counterpart agents/src/voice/agent_activity.test.ts. All eight source scenarios are covered with target-native fake timers, Task/Future, SpeechHandle, and activity stubs.
  • tests/test_realtime_adaptive_interruption.py: adapted to the existing JS counterpart agents/src/voice/realtime_adaptive_interruption.test.ts. Ports the modified unjudged/confirmed turn-gate assertions and all six added detector-stream lifecycle assertions.
  • agents/src/utils.ts and agents/src/utils.test.ts: target-only addition, no Python counterpart. A cancelled getter is added to Task (agents/src/utils.ts:664-665) backed by a disposition snapshot taken at settlement (:501, :565, :573), standing in for Python's asyncio.Task.cancelled(), which has no JS equivalent. It is marked @internal. Two non-guarantees are deliberate and documented at the getter: an abort that arrives after the task already settled reports true, and aborting a controller shared with sibling tasks marks those siblings cancelled. Both preserve the Python behavior this port depends on; neither is a general-purpose cancellation predicate.
  • No other source files were classified as not applicable.

Validation

  • pnpm exec vitest run agents: 111 files, 1571 passed, 5 skipped
  • pnpm --filter @livekit/agents typecheck
  • pnpm --filter @livekit/agents lint
  • pnpm --filter @livekit/agents build
  • pnpm build
  • Prettier check on all changed files

pnpm --filter @livekit/agents api:check was additionally attempted, but the repository’s current API Extractor rejects the existing export * as syntax before API comparison.


Ported from livekit/agents#6662

Original PR description

Three fixes around a paused agent speech: the turn gate, the overlap the barge-in verdict comes from, and the resume timer.

Turns silently dropped (realtime without STT)

a. The gate treated a paused speech as still deciding. on_end_of_turn dropped a turn whenever the agent had a live, uninterrupted speech and no interruption had been flagged, on the assumption that a verdict might still arrive. Once the speech is paused that assumption is permanently false — pausing silences the agent and ends the overlap, so no verdict is coming — and the user was ignored for a whole turn while the agent resumed mid-sentence.

Only a confirmed verdict suppresses a turn now. An unjudged overlap commits, so a barge-in verdict that lands late can interrupt the agent on what may have been a backchannel — an unwanted interruption is audible, so the user hears the agent stop and carries on, while a discarded turn is invisible and leaves them repeating themselves to an agent that never registered them.

b. Pausing tore down the overlap that verdict comes from. Pausing signalled end-of-agent-speech to the adaptive detector, which reset its stream and closed the overlap window. The verdict is emitted when the user's speech ends, so with the window gone none ever arrived: no late detection could land, and every paused backchannel would interrupt the agent.

A pause no longer ends the detector's overlap and a resume no longer restarts its stream. The user's speech ending closes it instead, which emits the verdict and stops inference.

Resume racing the turn decision

The resume timer counts false_interruption_timeout from the VAD END_OF_SPEECH event, while the turn commits at last_speaking_time + endpointing_delay — measured from before the VAD silence window. When the detector reads the pause as mid-utterance that delay becomes max_delay, so with the shipped defaults the resume lands 0.25s before the commit and the agent is cut off right after resuming.

An open turn decision now owns the paused speech: the timer defers to it and resumes only once the turn is dropped, while a committed turn cancels the resume synchronously from on_end_of_turn. The elapsed timeout counts toward that wait, so a resume with no decision pending keeps its current latency.

Cue voice E2E

Evidence provenance: the Cue runs below were captured at cb5422a9, which is no longer the current head. The head is now merge commit 75a80aba, which merges origin/main (including #2209) into this branch to clear a conflict. That merge resolved a single test-file import union and changed none of this PR's source logic — git diff cb5422a9 75a80aba -- agents/src/voice/agent_activity.ts contains no change to any resume, settlement, false-interruption or end-of-turn-task line. The behavior these runs exercise is therefore unchanged at the current head.

Verified exact head cb5422a99e6dc1575d8e2dcb93cac049884abc8b against origin/main (8260fa1e) in voice mode with real providers: inference.VAD (silero), deepgram/nova-3 STT, openai/gpt-4.1-mini LLM, and cartesia/sonic-3 TTS. No framework source was modified for the run.

The scenario is the resume/turn-decision race: a barge-in over live agent speech pauses that speech, and the question is whether the false-interruption resume fires before the turn decision settles. One two-turn script and one predicate were used for both arms:

debug_message(.payload.marker="pause_armed", .payload.eouTaskOpen=true)
 -> debug_message(.payload.marker="user_turn_committed", .payload.resumedBefore < 1)[60s]
  • Head arm, cb5422a9: session sid_c61ffc33c374, agent cue-2204-head, predicate resolved in 22907 ms. Decisive event at offset 32: {"marker":"resume_deferred","eouTaskOpen":true,"pausedStillSet":true,"armOrdinal":1.0,"userTurnsCommitted":0.0}. No false_interruption event occurs anywhere in the session, and the commit records resumedBefore: 0.0 — the resume waited for the decision.
  • Baseline arm, 8260fa1e: session sid_bf98ff262ceb, agent cue-2204-base, predicate failed. Decisive event at offset 33: {"marker":"false_interruption","resumesTotal":1.0,"armOrdinal":1.0,"resumed":true,"eouTaskOpen":true,"userTurnsCommitted":0.0} — it resumed 297 ms after the barge-in with the turn decision still open, and the commit records resumedBefore: 1.0.

Shared precondition. Both arms reached the identical state before diverging, so the negative arm is meaningful rather than a setup failure. Each logged pause_armed at offset 31 with eouTaskOpen: true, agentState: "listening", ordinal: 1, timeoutMs: 300, userTurnsCommitted: 0; the timer then expired 307 ms later on head and 297 ms on baseline. The arms diverge only in what happens at expiry.

Arm attribution. Each arm ran under a distinct agentName with exactly one worker registered for that name, and each session is self-identifying: a build_identity event reads off the loaded prototypes of the code under test, so the artifacts prove their own provenance rather than relying on run bookkeeping. Head reports hasCancelFalseInterruptionTimer: true, hasEndOfTurnTaskGetter: true, startFalseInterruptionTimer body hash 9aaf14da0aec (length 2434); baseline reports false, false, hash 4b797e597651 (length 1480). Each also carries its own gitSha and an agentsModulePath pointing into its own worktree's dist.

Limitations.

  1. agent_false_interruption is not in the AgentSessionEvent proto oneof, so it is relayed through _emitDebugMessage from the framework's own emit site. pause_armed and resume_deferred come from a 10 ms poller, so their timestamps carry ±10 ms.
  2. The race was made deterministic by configuration, not defaults: endpointing.minDelay = maxDelay = 20s, Deepgram endpointing = 1500ms, VAD minSilenceDuration = 100ms, interruption.mode = 'vad', minDuration = 300ms, falseInterruptionTimeout = 300ms. The same ordering bug exists at production defaults but with a much narrower window.
  3. Only the resume/decision ordering was exercised end-to-end. The turn-gate drop path is covered by unit tests, not by this run.
  4. n=1 per arm.

Merge-order note (resolved)

#2209 has landed on main as d96cd050, and this branch has been merged up to it. The predicted conflict in agents/src/voice/agent_activity.test.ts was a strict import union and is resolved in merge commit 75a80aba: the test file now imports AgentActivity, onEnterStorage, transcriptsEquivalent alongside the types EndOfTurnInfo, PreemptiveGenerationInfo. No other file conflicted, and none of this PR's resume or settlement logic changed in the merge. The agents suite is green at 112 files, 1594 passed, 5 skipped — up from 1571 pre-merge, which is #2209's tests joining the tree.

@rosetta-livekit-bot
rosetta-livekit-bot Bot requested a review from a team as a code owner August 2, 2026 08:37
@changeset-bot

changeset-bot Bot commented Aug 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 75a80ab

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 39 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-azure Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hedra Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-krisp Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-protoface Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@rosetta-livekit-bot
rosetta-livekit-bot Bot requested a review from longcw August 2, 2026 08:37

@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 1 additional finding.

Open in Devin Review

… on the promise

Port review of #2204 found the settlement disposition in onTurnSettled diverged
from Python in both directions. A cancelled end-of-turn bounce resolves rather
than rejects (the body observes the abort and returns), so the resume fired on a
decision that was torn down instead of decided; and a bounce that rejected was
swallowed, leaving nothing armed and the agent's audio output paused
indefinitely.

Add an explicit Task.cancelled accessor and check it, then call the resume
synchronously as Python does. Also clear overlapOpen before the sentinel-write
early return, guard the teardown end-of-agent-speech report against rejection,
and record the known _rt_turn_detection_enabled divergence at the cancel gate.

Strengthens the two "keeps the resume armed" tests to drive the real timer to
expiry and assert the pause is released, with a cancelling counterpart proving
they are not vacuous.

Co-authored-by: Cursor <cursoragent@cursor.com>
devin-ai-integration[bot]

This comment was marked as resolved.

…ng the live signal

Task.cancelled consulted the controller's current abort state, so the value was
not a property of how the task finished. Aborting afterwards — including through
a shared controller, or via runEOUDetection's unconditional cancel of the
previous bounce — retroactively reclassified an already-completed task as
cancelled, which made onTurnSettled suppress the resume for a decision that had
completed normally and left the paused speech wedged.

Capture the disposition once, immediately before the result future settles, and
have the getter return that snapshot. On the rejection path a failure unrelated
to the abort is recorded as an exceptional completion rather than a
cancellation, matching asyncio. An ignored abort deliberately still reports
cancelled: the EOU bounce observes the signal and returns normally, and treating
that as a plain completion would reinstate the original bug.

Documents the exact guarantee, the ignored-abort rule, and the shared-controller
limitation on the accessor itself.

Also asserts the synthetic agent-ended overlap sentinel at the real end of agent
speech, closing the coverage gap that let the incorrect overlapOpen hoist pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	agents/src/voice/agent_activity.test.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant