fix(realtime): support externally transcribed user turns - #6786
fix(realtime): support externally transcribed user turns#6786TonyG-FWE wants to merge 43 commits into
Conversation
845d94e to
5c4c1ac
Compare
|
@codex review |
|
@codex review |
|
Codex Review: Didn't find any major issues. Already looking forward to the next diff. Reviewed commit: βΉοΈ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with π. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
@codex review |
|
Codex Review: Didn't find any major issues. π Reviewed commit: βΉοΈ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with π. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
@codex review |
|
Codex Review: Didn't find any major issues. More of your lovely PRs please. Reviewed commit: βΉοΈ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with π. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
Codex Review: Didn't find any major issues. Breezy! Reviewed commit: βΉοΈ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with π. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
TonyG-FWE
left a comment
There was a problem hiding this comment.
Dashboard-only Devin findings on f96f43792 were rechecked against the exact head before production changes:
- Confirmed: Python 3.10 shutdown compatibility and unbounded callback settlement. Fixed in
99f48a4c; focused Python 3.10 and bounded-cleanup regressions pass. - Confirmed: prospective text-mode validation reused stale detector explicitness. Fixed transactionally in
3af88ef60; rejected updates preserve policy and listeners. - False positive: both VAD- and STT-based late-final/empty-turn races already preserve the newer transcript. Event-driven regressions pass without a production edit.
- Intended / bounded debt: realtime preemptive-generation gating, replay-exclusion retention, and duplicate VAD/STT onset ordering remain unchanged because their lifecycle reproductions preserve the documented behavior.
The follow-up set also preserves the established commit-helper contract in 204c6dfe5 and replaces scheduling sleeps with deterministic events in 744b9370c.
TonyG-FWE
left a comment
There was a problem hiding this comment.
Fresh Devin shutdown finding on 744b9370c was split by lifecycle evidence:
- Confirmed: an empty realtime-audio candidate could finish its hook after close blocked new work and then be appended as
ChatMessage(content=[""]).test_close_does_not_commit_empty_bounded_realtime_audio_turnin4dfcadf09fails on the untouched reviewed head and also verifies no generation starts and owned audio is cleared. - Not reproducible under valid ownership: the duplicate-provider-transcript half. Server-detected and manually submitted audio turns exit before this callback path. In the close race, provider audio is still unsubmitted; the existing regression verifies
commit_audio_calls == 0, so retaining non-empty externally bounded text prevents data loss rather than duplicating a provider item. - Fix:
a53327677routes every shutdown-bounded local commit through one idempotent helper that rejects empty/whitespace candidates while retaining a non-empty unsubmitted turn exactly once.
Post-fix evidence: the close module passes 9 tests; close + external-input + reply-context + AgentSession pass 189 tests; full Ruff and strict package typing pass.
Fresh Devin follow-up (
|
Final Devin ownership-boundary follow-up (
|
| }, | ||
| ) | ||
|
|
||
| if committed or allow_empty_transcript: |
There was a problem hiding this comment.
π‘ A declined short user turn can still wipe the speech recognizer and leave the model holding stale audio
A user turn that the session deliberately declined is torn down anyway (if committed or allow_empty_transcript: at livekit-agents/livekit/agents/voice/audio_recognition.py:1876) instead of being kept open, so the recognizer is restarted while the model still holds the old audio.
Impact: With a minimum-word interruption threshold configured, brief noise while the agent is talking can drop in-flight speech recognition and let the leftover audio bleed into the user's next sentence.
Mechanism: `allow_empty_transcript` overrides the `committed=False` verdict from `on_end_of_turn`
_run_eou_detection is invoked with allow_empty_transcript=True from two places when a realtime model uses client-side turn detection and finalize_empty_transcript_on_timeout is set: audio_recognition.py:1490-1491 (STT end-of-speech with no text) and audio_recognition.py:2112-2117 (transcription timeout).
Inside _bounce_eou_task, the cleanup block is now gated on committed or allow_empty_transcript (audio_recognition.py:1876). It ends the user-turn span, resets _user_turn_start, clears _audio_transcript/_audio_interim_transcript/_audio_preflight_transcript, and calls _restart_stt_input() (audio_recognition.py:1898-1901), which tears down and recreates the STT stream (audio_recognition.py:1141-1147).
However AgentActivity.on_end_of_turn can return False for that same turn via the minimum-word interruption gate at agent_activity.py:3184-3198. That branch explicitly does not discard the buffered provider audio, with the comment "AudioRecognition retains this transcript for the next endpointing verdict. Keep the matching provider audio under the same logical input owner as well."
The two layers therefore disagree: the activity keeps the realtime turn transaction open and the provider audio buffered, while AudioRecognition closes the turn and recreates the STT stream. Two consequences follow:
- The in-flight STT stream is destroyed, so any audio still buffered inside it (which could have produced the transcript the activity said it would re-evaluate) is lost.
- The provider-side empty turn is never settled β the exact failure the
finalize_empty_transcript_on_timeoutpath was added to fix β so that audio prefixes the next committed turn.
Reachability: _current_speech active and non-interruptible-yet, interruption["min_words"] > 0 (opt-in; default is 0 per voice/turn.py:197), self.stt is not None, _turn_detection != "manual", and an empty transcript at timeout. The sibling backchannel branch at agent_activity.py:3202-3216 cannot collide because it requires self.stt is None.
Prompt for agents
In `AudioRecognition._run_eou_detection`'s inner `_bounce_eou_task`, the turn-teardown block is gated on `committed or allow_empty_transcript`. `allow_empty_transcript` is decided when `_run_eou_detection` is called, before the end-of-turn hook runs, so it can force teardown even when `self._hooks.on_end_of_turn(...)` returned `False`.
`AgentActivity.on_end_of_turn` returns `False` from the minimum-word interruption gate (see the branch guarded by `self._session.options.interruption["min_words"] > 0` in `livekit-agents/livekit/agents/voice/agent_activity.py`). That branch intentionally leaves the realtime turn transaction open and the buffered provider audio owned by the current turn, expecting AudioRecognition to keep accumulating for the next endpointing verdict. But the teardown block ends the user-turn span, resets `_user_turn_start`, clears the transcript buffers, and calls `_restart_stt_input()`, destroying the live STT stream.
Decide which layer owns the decision and make them agree. Options to consider: (a) only run the teardown block when `committed` is true, and handle the empty-turn settlement separately (e.g. only reset span/transcript state, without `_restart_stt_input()`, when the hook declined); or (b) have the activity treat an `allow_empty_transcript` turn as authoritative so the minimum-word gate does not decline it (for example by discarding the provider audio and returning `True` in that case). Ensure the existing tests `test_vad_empty_bounce_is_replaced_by_late_final` and `test_stt_empty_bounce_consumes_late_final_before_cleanup` in `tests/test_audio_recognition_turn_detection.py` still pass, and add coverage for the declined + `allow_empty_transcript` combination.
Was this helpful? React with π or π to provide feedback.
Fixes #5408
Problem
Realtime agents could treat raw provider audio and externally finalized STT text as two independent inputs for the same user turn. That split authority caused duplicate input, lost turns, incorrect interruption, and cleanup/reconnect races.
"audio"(default)"text"(opt-in)ChatMessage, includingon_user_turn_completededitsText mode never also commits the same raw audio as model input.
Ownership architecture
ACCEPTED,REJECTED, orUNKNOWN. Provider chat-mirror equality is not a correctness gate.The default remains backward-compatible.
AgentActivitycontains no provider-name checks or provider-specific types.Final confirmed fixes
The final review hypotheses were reproduced on the prior head before production changes:
7ec384a51adds five fail-before ownership-boundary regressions.1245db908restores explicit provider-ownedclear_user_turn()and activity notifications without transferring ownership into framework transactions. It also preserves a per-replyallow_interruptions=Trueoverride when the session default is disabled.946be984eobserves eventual provider-close failures after caller cancellation and converts unexpected automatic fallback-swap failures into one terminalRealtimeModelError; explicitly awaited restart failures still propagate normally.Guarded STT segment recreation, the internal empty-transcript timeout, bounded bookkeeping/replay sets, intentional Gemini fresh-restart behavior, and the terminal all-models-failed window remain unchanged because focused lifecycle evidence did not establish production defects.
Validation
Current head:
946be984eeb1a340c600a434a6d0bfcd5331c27749bfd8b31git diff --check: passedEnvironmental limitation
The local Windows full-unit run reached 1,815 passed and 7 skipped before the known event-loop/native-resource cascade; GitHub Linux completed the authoritative suite. No live-provider credentials were available, requested, or used. Provider lifecycle coverage is credential-free and hermetic.
This PR remains a draft with maintainer edits enabled. A final Devin rerun is pending on this exact green head; no additional Codex review was requested.