Skip to content

fix(openai): fail generate_reply fast on conversation_already_has_act… - #6818

Open
ByteMaster-1 wants to merge 2 commits into
livekit:mainfrom
ByteMaster-1:fix/conversation-already-active
Open

fix(openai): fail generate_reply fast on conversation_already_has_act…#6818
ByteMaster-1 wants to merge 2 commits into
livekit:mainfrom
ByteMaster-1:fix/conversation-already-active

Conversation

@ByteMaster-1

Copy link
Copy Markdown
Contributor

Fixes #6817

Problem

OpenAI Realtime allows one active response per conversation. When a client
response.create races an already-active response (typically a server-VAD-created
one), the server rejects it with conversation_already_has_active_response. The
rejection arrives as an error frame whose error.event_id is the client event id —
not a response.created, and no response.done follows.

generate_reply() registers _response_created_futures[event_id] and arms a 10s
timeout. _handle_error() logged/emitted the error but never touched that future, so
it orphaned until the timeout fired — and then resolved as a generic
"generate_reply timed out", indistinguishable from a real timeout. Downstream the
SpeechHandle stayed unset for ~10s and SpeechHandle.exception() raised
InvalidStateError until then.

Fix (before → after)

  • Before: collision → future orphaned ~10s → generic "generate_reply timed out".
  • After: collision → _handle_error correlates error.event_id to the pending
    future and fails it immediately with a typed llm.RealtimeError whose .code is
    conversation_already_has_active_response. _realtime_reply_task already
    except llm.RealtimeError and routes it onto the SpeechHandle via _mark_done.

Both surfaces fire: the exception (via handle.exception()) and the existing
recoverable "error" event.

Scope (maintainer-aligned)

Detect → correlate → fail fast → surface typed + event. No auto-retry and no queue
retry is client-side. Docstring on AgentSession.generate_reply shows the recipe
(await handle never raises; check handle.exception(); on the code, await
current_speech.wait_for_playout() then retry).

Future work (not in this PR): an opt-in SDK-side serialization of response.create
to avoid the collision entirely.

Tests

  • Positive: a fake error whose event_id matches a pending future fails it
    immediately with the typed error/code, and the error event still emits.
  • Negative: an error with an unknown/absent event_id touches no future.
  • pytest tests/test_realtime/ (45 passed), ruff, and mypy all pass.

…ive_response

A client response.create that races an already-active response (usually a
server-VAD reply) is rejected with an error frame, not a response.created, so
the future generate_reply handed out was never settled and orphaned until the
10s timeout — surfacing as a generic "generate_reply timed out" long after the
fact. Correlate the error to the pending future via error.event_id, fail it
immediately with a typed llm.RealtimeError carrying the provider code, and still
emit the error event. No SDK-side retry/queue; retry stays client-side.

Fixes livekit#6817
@ByteMaster-1
ByteMaster-1 requested a review from a team as a code owner August 12, 2026 11:31

@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 bugs or issues to report.

Open in Devin Review

@longcw longcw 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.

looks good to me, something nit:

# handed out. Fail it now with the provider code attached, instead of orphaning it until
# the 10s timeout turns it into a generic "generate_reply timed out". Fall through so the
# error still surfaces as an "error" event.
if (event_id := event.error.event_id) and (

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.

maybe merge this and the above check under a single if (event_id := event.error.event_id) block and make the comments shorter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — merged both waiter checks under a single if event_id := event.error.event_id:. Since the chat-ctx and response.create event-ids are separate namespaces (chat_ctx_* vs response_create_*) they can't collide, so the second check is now an elif, and the comments are trimmed to one line each. The response.create branch intentionally falls through instead of returning, so the error still hits the existing emit path (recoverable) or the fatal _is_fatal_error raise (terminal) — same reconnect-stopping behavior as before.

Comment on lines +1469 to +1486
Note:
``await handle`` waits for the reply to finish and never raises; inspect
``handle.exception()`` for the failure instead. With a realtime model, a reply
that races an already-active response (server-VAD created) fails fast with an
``llm.RealtimeError`` whose ``code`` is ``conversation_already_has_active_response``
rather than stalling until a timeout. The retry policy is yours to choose::

handle = session.generate_reply(user_input="...")
await handle
err = handle.exception()
if isinstance(err, llm.RealtimeError) and (
err.code == "conversation_already_has_active_response"
):
# let the in-flight response play out, then retry
if session.current_speech is not None:
await session.current_speech.wait_for_playout()
handle = session.generate_reply(user_input="...")
await handle

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.

simplify the docstring? handle.exception() is worth to mention but maybe not the example here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed to a short Note: — kept the handle.exception() guidance and the conversation_already_has_active_response code so callers know what to branch on, and dropped the full retry example.

@ByteMaster-1
ByteMaster-1 requested a review from longcw August 12, 2026 12:53
@ByteMaster-1
ByteMaster-1 force-pushed the fix/conversation-already-active branch from 3f96ba2 to 21ec4fe Compare August 12, 2026 13:01
@ByteMaster-1

Copy link
Copy Markdown
Contributor Author

@longcw can we please check this if possible ,
thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants