feat(voice): support preemptive generation for realtime models - #6537
Open
eh-steve wants to merge 1 commit into
Open
feat(voice): support preemptive generation for realtime models#6537eh-steve wants to merge 1 commit into
eh-steve wants to merge 1 commit into
Conversation
eh-steve
force-pushed
the
realtime-preemptive-generation
branch
from
July 24, 2026 16:04
60d19ba to
16a5df2
Compare
eh-steve
force-pushed
the
realtime-preemptive-generation
branch
2 times, most recently
from
July 24, 2026 17:53
bd91e76 to
b09ded2
Compare
RealtimeModel sessions without server-side turn detection can now start generating on the eager transcript (preemptive generation), matching the existing behavior for llm.LLM pipelines. - eligibility (_can_preemptively_generate): mutable_chat_context, no server turn detection, text-only output, and a live rt_session with no active response. The reply task issues rt_session.generate_reply before the authorization gate so the server streams the response while the turn is still being finalized; playout and the local history commit stay gated on authorization (adoption) - rollback is exit-path-total: the speculation publishes a cleanup future before touching the session (chained behind any previous speculation's cleanup), and every exit other than adoption cancels the speculative response, waits for the server to release it, and removes exactly the speculation's items from the live context, preserving concurrent state such as the committed turn audio. Follow-up generations wait on the cleanup future so they never run against a dirty session. A speculative generate_reply that fails before adoption wakes the parked reply task and marks the handle done, so the adoption check falls back to a normal generation - a speculation is resolved by a later event (end of turn, supersession, interruption, explicit generate_reply/say, commit_user_turn, clear_user_turn, scheduling pause or drain); paths that previously did not account for a pending speculation now cancel it, and a TTL rolls back a speculation whose turn never resolves (e.g. the user vanished mid-utterance) so the cleanup wait cannot tax later replies - realtime remote-item mirroring (_on_remote_item_added) is held back for speculation-owned items while a speculation is in flight: they would otherwise land in the local history as placeholders mid-turn, invalidating the preemptive match on every turn, and leak unplayed content on rollback. Held items are replayed on adoption and removed server-side on rollback; unrelated items (e.g. the committed turn audio) apply normally - RealtimeSession.has_active_generation (new, default False; the OpenAI plugin already implements it and the realtime fallback adapter proxies it) lets the framework serialize response creation on single-active-response APIs: realtime replies (including say(), tool replies and post-barge-in generations) wait for a cancelled response to clear before creating the next one, avoiding conversation_already_has_active_response rejections - the realtime branch of _user_turn_completed_task carries the final transcript through the preemptive match check (the new realtime path would otherwise read user_message.raw_text_content after nulling it), defers commit_audio while a speculation is pending (clear_audio on adoption, commit_audio on invalidation or any early exit such as StopResponse), no longer interrupts the in-flight speculative response when cleaning up an interrupted speech, and cancels a pending speculation when the turn arrives with skip_reply (commit_user_turn) - _realtime_reply_task accepts the user message as llm.ChatMessage (resolves the raw-text TODO) so preemptive metrics injection lands on the committed message
eh-steve
force-pushed
the
realtime-preemptive-generation
branch
from
July 24, 2026 17:59
b09ded2 to
e119424
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Preemptive generation (starting the reply on the eager transcript, before the turn is confirmed) currently only fires for cascaded
llm.LLMpipelines —on_preemptive_generationearly-returns for anyRealtimeModel. Pipelines that pair a text-mode realtime model with an STT that emits eager end-of-turn transcripts (e.g. Cartesia Ink-2'sPREFLIGHT_TRANSCRIPT) can't get the same head start, even though everything downstream of the guard (the_PreemptiveGenerationfingerprint/adoption lifecycle, and_realtime_generation_task's authorization-gated playout) already supports it.The reason a naive guard-lift isn't enough:
_realtime_reply_taskwaits for authorization before issuingrt_session.generate_reply, so an unscheduled preemptive speech would only start generating at adoption time — no head start. And unlike cascaded speculation (a stateless local task), a realtime speculation mutates remote state: a pushed conversation item and, on single-active-response APIs, the session's one response slot — so it needs a real rollback story.What this PR does
on_preemptive_generationnow accepts realtime models via_can_preemptively_generate(): requiresmutable_chat_context, no server-side turn detection, text-only output (the speculative reply is generated from the pushed transcript, keeping the turn's input modality consistent), a livert_session, and no active response._realtime_reply_taskgains apreemptivemode that pushes the transcript into the session context and issuesgenerate_reply()before the authorization gate; playout and the local history commit stay gated on authorization (adoption). On adoption, the existing fingerprint match in_user_turn_completed_taskschedules the held speech and the already-streaming generation plays through the native_realtime_generation_taskpath.generate_replythat fails before adoption wakes the parked task and marks the handle done, so adoption falls back to a normal generation.RealtimeSession.has_active_generation(new ABC property, defaultFalse; the OpenAI plugin already implements it, and the realtime fallback adapter proxies it): lets the framework serialize response creation. Realtime replies — including tool replies and post-barge-in generations — now wait for a cancelled response to clear before creating the next one, avoidingconversation_already_has_active_responserejections._user_turn_completed_task: the final transcript is captured for the preemptive match before the realtime branch nullsuser_message(previously this path would have crashed readinguser_message.raw_text_content);commit_audio()is deferred while a speculation is pending (clear_audio()on adoption,commit_audio()on invalidation or any early exit such asStopResponse, via a try/finally so buffered audio can never leak into a later turn's commit); the barge-in cleanup no longer interrupts the in-flight speculative response, and a turn arriving withskip_reply(e.g.commit_user_turn()) cancels the pending speculation instead of stranding it._realtime_reply_tasknow receives the user message asllm.ChatMessage(resolving the existing TODO), so metrics injected at adoption land on the committed message.Testing
tests/test_realtime_preemptive.py: eager-generate-before-authorization, adoption commit + native generation, rollback of pending and already-created responses (with a duration regression bound), supersession serialization, pre-adoption generation failure fallback, follow-up serialization, eligibility gating, the remote-item mirroring hold-back (buffered + replayed on adoption, removed on rollback, pass-through for unrelated items), the speculation TTL, and five full-AgentSessione2e tests (adoption with a verified head start, invalidation via anon_user_turn_completededit,StopResponsewith the deferred audio commit resolved,skip_reply/commit_user_turncancelling a pending speculation, andpause()completing with a parked speculation).FakeRealtimeSessionnow emitsremote_item_addedacks so the mirroring machinery is covered by the fakes.test_agent_session.py(incl. cascaded preemptive latency tests),test_realtime_reply_chat_ctx.py(updated for theChatMessagesignature),test_realtime_fallback.py,test_realtime_message_metrics.py,test_realtime_adaptive_interruption.py,test_session_host.py,test_remote_session.py— 168 tests total.ruffandmypyclean on the changed files.Notes / possible follow-ups
_wait_for_active_response_clearedcould become an awaitable on theRealtimeSessionABC if you'd prefer event-based signaling; the poll was chosen because it's self-healing across session reconnects (no waiter lifecycle to manage in every plugin).speech_handle.done()), which for cascaded pipelines means a failed preemptive inference falls back to a normal generation instead of scheduling a dead speech — intentional, previously the turn went silent.Made with Cursor
Live verification (real providers)
Verified end-to-end with Cartesia Ink-2 (via LiveKit Inference,
turn_detection="stt") + OpenAI Realtimegpt-realtime-1.5(text-only) on a room-lessAgentSession, streaming synthesized user speech at real-time pace:generate_replylaunches on Ink-2's eager transcript ~0.10-0.14s after the user stops speaking (sometimes before), while the turn only commits at ~0.59s — a consistent ~460-490ms head start across runs, all adopted (single generation per turn,clear_audio, correct reply, identical local/server history).generate_replylaunches within 1ms after the turn commit, as expected.on_user_turn_completededit produced the full rollback + fallback live: speculative response cancelled, its items removed server-side, the committed turn audio preserved, and the fallback generation answered the original question correctly.conversation_already_has_active_responsecollisions (the serialization path).Live testing also caught two integration issues the fake-based tests couldn't, now fixed in this PR: the plugin's
remote_item_addedmirroring placed speculative items into the local history mid-turn (invalidating the preemptive match on every real turn), so mirroring of speculation-owned items is held back until adoption; and the rollback now removes exactly the speculation's items from the live context instead of restoring a snapshot, so concurrent state (e.g. the committed turn audio) survives.Note on supersession coverage: supersession (a second eager transcript superseding an in-flight speculation) could not be triggered against real providers — Ink-2's eager and final end-of-turn signals arrive ~0.5s apart, and
_can_preemptively_generate()intentionally declines to re-speculate while the previous speculative response is still active (the stale speculation is then invalidated by the fingerprint at turn end: safe, just no head start). The rollback-chaining behavior is covered at the unit level (test_preemptive_supersession_serializes_cleanup).