Skip to content

Retry OpenAI realtime responses that fail before output#6454

Open
RitwijParmar wants to merge 2 commits into
livekit:mainfrom
RitwijParmar:codex/livekit-realtime-response-retry
Open

Retry OpenAI realtime responses that fail before output#6454
RitwijParmar wants to merge 2 commits into
livekit:mainfrom
RitwijParmar:codex/livekit-realtime-response-retry

Conversation

@RitwijParmar

Copy link
Copy Markdown

Fixes #6205.

OpenAI realtime can return response.done with status failed after response.created but before any output item starts. Before this change, that closes the generation and the agent turn can disappear without another response attempt.

This retries that narrow case with the existing connection retry policy:

  • only failed responses
  • only before output starts
  • only retryable errors
  • bounded by max_retry
  • reuses the same generation streams so callers do not get a duplicate generation event

I did not retry after output starts. That needs a separate policy for partial audio/text cleanup and transcript history.

Checked locally:

  • uv run pytest tests/test_realtime/test_openai_realtime_model.py tests/test_openai_realtime_chat_ctx.py -q
  • uv run ruff check livekit-plugins/livekit-plugins-openai/livekit/plugins/openai/realtime/realtime_model.py tests/test_realtime/test_openai_realtime_model.py
  • uv run ruff format --check livekit-plugins/livekit-plugins-openai/livekit/plugins/openai/realtime/realtime_model.py tests/test_realtime/test_openai_realtime_model.py
  • uv run mypy livekit-plugins/livekit-plugins-openai/livekit/plugins/openai/realtime/realtime_model.py

@RitwijParmar
RitwijParmar requested a review from a team as a code owner July 16, 2026 16:28
@CLAassistant

CLAassistant commented Jul 16, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

devin-ai-integration[bot]

This comment was marked as resolved.

@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 found 1 new potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines 1838 to 1846
message_ch=utils.aio.Chan(),
function_ch=utils.aio.Chan(),
messages={},
response_create_params=self._response_create_params.pop(client_event_id, None)
if client_event_id
else None,
_created_timestamp=time.time(),
_done_fut=asyncio.Future(),
)

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.

πŸ”΄ Agent turn can hang forever when a new response starts during a failed-response retry

A newly started reply replaces the in-progress reply (self._current_generation = _ResponseGeneration(...) at livekit-plugins/livekit-plugins-openai/livekit/plugins/openai/realtime/realtime_model.py:1837) without ever closing the earlier reply that is still waiting to be retried, so that earlier reply's output streams stay open forever.
Impact: The conversation turn tied to the abandoned reply never completes, hanging the agent until the session is restarted.

Orphaned generation when a superseding response.created arrives during the retry window

Before this PR, _current_generation was always closed and set to None by _handle_response_done before any new response.created arrived, so the normal branch in _handle_response_created (realtime_model.py:1837-1846) never overwrote a live generation.

Now _maybe_retry_failed_response (realtime_model.py:2205-2255) deliberately leaves self._current_generation open (channels not closed, _done_fut unresolved) while it schedules a delayed retry. During that retry window (0.1s–2s, _interval_for_retry), any new response.created β€” e.g. a server-VAD auto-response triggered by the user speaking β€” takes the normal branch at realtime_model.py:1837 and reassigns self._current_generation to a brand new _ResponseGeneration. The previous (retrying) generation object is no longer referenced by self._current_generation, so it is never passed to _close/_close_current_generation, and its message_ch/function_ch are never closed.

The consumer in livekit-agents/livekit/agents/voice/agent_activity.py:3667 (async for msg in generation_ev.message_stream) and the surrounding await speech_handle.wait_if_not_interrupted([...]) at agent_activity.py:3718 block until those channels close, so the abandoned turn hangs indefinitely. The pending retry task later aborts cleanly (self._current_generation is not retry_generation), but it does not close the orphaned generation. The new test test_response_done_failed_does_not_retry_against_new_generation exercises exactly this superseding scenario but does not assert that the old generation was closed.

(Refers to lines 1837-1846)

Prompt for agents
The retry feature (_maybe_retry_failed_response) intentionally keeps self._current_generation open while a delayed retry is pending, instead of closing it like the pre-PR _handle_response_done did. This breaks the previous invariant that _current_generation is always None (or already closed) when _handle_response_created creates a new generation.

Problem: If a new response.created arrives while a retry is pending (for example a server-VAD auto-response triggered by the user speaking during the retry_interval), the normal branch in _handle_response_created (around realtime_model.py:1837) reassigns self._current_generation to a new _ResponseGeneration without closing the previous, still-open retrying generation. That orphaned generation's message_ch/function_ch are never closed and its _done_fut never resolves, so the AgentActivity consumer (agent_activity.py:3667 async-for over message_stream, and the wait at agent_activity.py:3718) hangs forever.

Possible fix: before overwriting self._current_generation in the normal branch of _handle_response_created, detect when the existing self._current_generation is a live _ResponseGeneration that is being superseded (i.e. not the expected reuse path) and close it (close its message channels, resolve modalities/_done_fut) similar to _close_current_generation, and clean up any associated _response_retry_event_ids/_response_retry_generations entries. Also consider handling this when the pending retry task aborts because self._current_generation changed.
Open in Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

@RitwijParmar

Copy link
Copy Markdown
Author

One thing I would want to pin down before this grows is the generation identity across a retry. Reusing streams avoids a duplicate generation in the caller, but the trace and metrics still need to say whether the first attempt failed before output and the second attempt completed it. I would carry one generation id with an attempt number and emit the retry reason once. Then a failed first attempt cannot look like two user generations in dashboards. Is that useful for the direction here?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Realtime models have no response-level retry for recoverable generate_reply failures (parity gap with the pipeline LLM)

2 participants