Skip to content

Commit af4d6cb

Browse files
committed
feat(realtime): add response-level retry and recovery for generate_reply
Realtime models had no response-level retry, unlike the pipeline LLM. A recoverable generate_reply failure (timeout, transient server error, or a reply discarded by an automatic session reconnection) left the turn silent with no recovery, surfacing in production as dead air. - classify errors: RealtimeError.recoverable + shared is_fatal_error() (quota/auth/billing are never retried) - reconnect coordination: session_reconnecting event, reconnecting/ wait_reconnected(); OpenAI plugin drives _set_reconnecting/_set_reconnected and logs reconnection at INFO - fix infinite reconnect: reset num_retries only after a healthy connection - cancel_and_wait(): cancel the active response and await its response.done before re-issuing, avoiding conversation_already_has_active_response - retry loop in _realtime_reply_task for pre-response.created failures; handle update_chat_ctx push failures; wire GenerationCreatedEvent.done_fut - tests for the classifier, reconnect state machine, error classification, and cancel_and_wait Fixes #6205
1 parent 438e2bc commit af4d6cb

8 files changed

Lines changed: 595 additions & 47 deletions

File tree

livekit-agents/livekit/agents/llm/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
LLMStream,
2626
)
2727
from .realtime import (
28+
FATAL_REALTIME_ERROR_CODES,
2829
GenerationCreatedEvent,
2930
InputSpeechStartedEvent,
3031
InputSpeechStoppedEvent,
@@ -36,7 +37,9 @@
3637
RealtimeModelError,
3738
RealtimeSession,
3839
RealtimeSessionReconnectedEvent,
40+
RealtimeSessionReconnectingEvent,
3941
RemoteItemAddedEvent,
42+
is_fatal_error,
4043
)
4144
from .realtime_fallback_adapter import (
4245
RealtimeAvailabilityChangedEvent,
@@ -113,7 +116,9 @@
113116
"GenerationCreatedEvent",
114117
"MessageGeneration",
115118
"RealtimeSessionReconnectedEvent",
116-
"RealtimeSessionRestoredEvent",
119+
"RealtimeSessionReconnectingEvent",
120+
"FATAL_REALTIME_ERROR_CODES",
121+
"is_fatal_error",
117122
"LLMError",
118123
"RemoteItemAddedEvent",
119124
]

livekit-agents/livekit/agents/llm/realtime.py

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from collections.abc import AsyncIterable, Awaitable
77
from dataclasses import dataclass
88
from types import TracebackType
9-
from typing import Generic, Literal, TypeVar
9+
from typing import Any, Generic, Literal, TypeVar
1010

1111
from pydantic import BaseModel, ConfigDict, Field
1212

@@ -45,6 +45,15 @@ class GenerationCreatedEvent:
4545
"""True if the message was generated by the user using generate_reply()"""
4646
response_id: str | None = None
4747
"""The response ID associated with this generation, used for metrics attribution"""
48+
done_fut: asyncio.Future[None] | None = None
49+
"""Resolves when the response finishes.
50+
51+
Raises a :class:`RealtimeError` if the response failed *after* it started (e.g.
52+
``response.done`` with status ``failed``). This lets the caller learn about a
53+
post-``response.created`` failure that the ``generate_reply()`` future — which
54+
resolves at ``response.created`` — cannot surface. May be ``None`` for providers
55+
that don't wire it.
56+
"""
4857

4958

5059
class RealtimeModelError(BaseModel):
@@ -87,8 +96,60 @@ class RealtimeCapabilities:
8796

8897

8998
class RealtimeError(Exception):
90-
def __init__(self, message: str) -> None:
99+
def __init__(
100+
self, message: str, *, recoverable: bool = True, is_timeout: bool = False
101+
) -> None:
91102
super().__init__(message)
103+
self.recoverable = recoverable
104+
"""Whether re-issuing the request could plausibly succeed.
105+
106+
``True`` for transient failures (timeouts, server errors, connection drops);
107+
``False`` for terminal ones (retries exhausted, fatal account errors). Callers
108+
should not retry when this is ``False``.
109+
"""
110+
self.is_timeout = is_timeout
111+
"""``True`` if the request failed because it timed out waiting for the server.
112+
113+
Providers set this instead of relying on the message text, so callers can detect
114+
a timeout without fragile string matching.
115+
"""
116+
117+
118+
# Underlying error codes/types that can never succeed on retry. Retrying these only
119+
# wastes the retry budget and keeps the reconnect loop spinning, so callers should
120+
# fail fast instead.
121+
FATAL_REALTIME_ERROR_CODES = frozenset(
122+
{
123+
"insufficient_quota",
124+
"invalid_api_key",
125+
"account_deactivated",
126+
"billing_hard_limit_reached",
127+
}
128+
)
129+
130+
131+
def is_fatal_error(error: object | None) -> bool:
132+
"""Return ``True`` for errors that can never succeed on retry.
133+
134+
Accepts any object (exceptions as well as provider error models) and walks the
135+
wrapped-error chain (e.g. ``RealtimeModelError.error`` -> ``APIError.body`` -> ...)
136+
matching any ``code``/``type`` attribute against :data:`FATAL_REALTIME_ERROR_CODES`
137+
(quota / auth / billing).
138+
"""
139+
seen: set[int] = set()
140+
stack: list[Any] = [error]
141+
while stack:
142+
obj = stack.pop()
143+
if obj is None or id(obj) in seen:
144+
continue
145+
seen.add(id(obj))
146+
if getattr(obj, "code", None) in FATAL_REALTIME_ERROR_CODES:
147+
return True
148+
if getattr(obj, "type", None) in FATAL_REALTIME_ERROR_CODES:
149+
return True
150+
stack.append(getattr(obj, "error", None))
151+
stack.append(getattr(obj, "body", None))
152+
return False
92153

93154

94155
class RealtimeModel:
@@ -135,6 +196,7 @@ async def __aexit__(
135196
"input_speech_stopped", # serverside VAD
136197
"input_audio_transcription_completed",
137198
"generation_created",
199+
"session_reconnecting",
138200
"session_reconnected",
139201
"metrics_collected",
140202
"remote_item_added",
@@ -155,6 +217,11 @@ class InputTranscriptionCompleted:
155217
"""confidence score of the transcript (0.0 to 1.0), derived from model logprobs"""
156218

157219

220+
@dataclass
221+
class RealtimeSessionReconnectingEvent:
222+
pass
223+
224+
158225
@dataclass
159226
class RealtimeSessionReconnectedEvent:
160227
pass
@@ -170,6 +237,42 @@ class RealtimeSession(ABC, rtc.EventEmitter[EventTypes | TEvent], Generic[TEvent
170237
def __init__(self, realtime_model: RealtimeModel) -> None:
171238
super().__init__()
172239
self._realtime_model = realtime_model
240+
self._reconnecting = False
241+
self._reconnected_ev = asyncio.Event()
242+
self._reconnected_ev.set() # starts in the "connected" state
243+
244+
@property
245+
def reconnecting(self) -> bool:
246+
"""True while the underlying session is re-establishing its connection."""
247+
return self._reconnecting
248+
249+
async def wait_reconnected(self, timeout: float | None = None) -> bool:
250+
"""Wait until the session finishes reconnecting.
251+
252+
Returns immediately if the session is not currently reconnecting. Returns
253+
``True`` once reconnected, or ``False`` if ``timeout`` elapses first.
254+
"""
255+
if not self._reconnecting:
256+
return True
257+
try:
258+
await asyncio.wait_for(self._reconnected_ev.wait(), timeout)
259+
return True
260+
except asyncio.TimeoutError:
261+
return False
262+
263+
def _set_reconnecting(self) -> None:
264+
"""Plugins call this when a reconnect attempt starts."""
265+
if self._reconnecting:
266+
return
267+
self._reconnecting = True
268+
self._reconnected_ev.clear()
269+
self.emit("session_reconnecting", RealtimeSessionReconnectingEvent())
270+
271+
def _set_reconnected(self) -> None:
272+
"""Plugins call this when the session has successfully reconnected."""
273+
self._reconnecting = False
274+
self._reconnected_ev.set()
275+
self.emit("session_reconnected", RealtimeSessionReconnectedEvent())
173276

174277
def _report_connection_acquired(self, acquire_time: float) -> None:
175278
"""Report connection timing as a RealtimeModelMetrics event with zero usage."""
@@ -253,6 +356,16 @@ def clear_audio(self) -> None: ...
253356
@abstractmethod
254357
def interrupt(self) -> None: ...
255358

359+
async def cancel_and_wait(self, timeout: float = 5.0) -> None:
360+
"""Cancel the current generation and wait for it to clear.
361+
362+
Re-issuing a reply before the previous response is fully cancelled server-side
363+
can be rejected (e.g. ``conversation_already_has_active_response``). The default
364+
implementation just calls :meth:`interrupt` without waiting; plugins that can
365+
await the cancellation override this.
366+
"""
367+
self.interrupt()
368+
256369
# message_id is the ID of the message to truncate (inside the ChatCtx)
257370
@abstractmethod
258371
def truncate(

livekit-agents/livekit/agents/llm/realtime_fallback_adapter.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
RealtimeModel,
2222
RealtimeModelError,
2323
RealtimeSession,
24-
RealtimeSessionReconnectedEvent,
2524
)
2625
from .tool_context import Tool, ToolChoice, ToolContext
2726

@@ -336,7 +335,7 @@ async def _bring_up(index: int) -> Exception | None:
336335
return
337336

338337
# a swap is a reconnect from the caller's perspective
339-
self.emit("session_reconnected", RealtimeSessionReconnectedEvent())
338+
self._set_reconnected()
340339

341340
# re-issue the interrupted reply on the new session
342341
if (

livekit-agents/livekit/agents/voice/agent_activity.py

Lines changed: 82 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,15 @@
103103
_SpeechHandleContextVar = contextvars.ContextVar["SpeechHandle"]("agents_speech_handle")
104104
_IdleHoldContextVar = contextvars.ContextVar[bool]("agents_idle_hold", default=False)
105105

106+
# How many times a realtime generate_reply is re-issued when it fails *before the reply
107+
# starts* (timeout / server error / discarded on reconnection). Fatal errors (quota, auth)
108+
# are never retried.
109+
_REALTIME_REPLY_MAX_RETRIES = 3
110+
# Delay between retry attempts (a reconnect wait, when applicable, happens on top of this).
111+
_REALTIME_REPLY_RETRY_INTERVAL = 0.5
112+
# Upper bound on how long a retry waits for an in-progress reconnection before giving up.
113+
_REALTIME_RECONNECT_WAIT_TIMEOUT = 10.0
114+
106115

107116
class ActivityClosedError(Exception):
108117
"""Raised by ``wait_for_idle`` when the target activity/session has closed."""
@@ -3259,7 +3268,19 @@ async def _realtime_reply_task(
32593268
if user_input is not None:
32603269
chat_ctx = self._rt_session.chat_ctx.copy()
32613270
msg = chat_ctx.add_message(role="user", content=user_input)
3262-
await self._rt_session.update_chat_ctx(chat_ctx)
3271+
try:
3272+
await self._rt_session.update_chat_ctx(chat_ctx)
3273+
except llm.RealtimeError as e:
3274+
# A timeout means the item events are already on the wire; re-pushing would
3275+
# duplicate the user message, so proceed and let the reply run. Any other
3276+
# failure means the message never landed — surface it and don't reply.
3277+
if e.is_timeout:
3278+
logger.warning("update_chat_ctx timed out; assuming the message was queued")
3279+
else:
3280+
logger.error("failed to push user message before reply: %s", str(e))
3281+
speech_handle._mark_done(error=e)
3282+
self._session._update_agent_state("listening")
3283+
return
32633284
self._agent._chat_ctx._upsert_item(msg)
32643285
self._session._conversation_item_added(msg)
32653286

@@ -3282,38 +3303,73 @@ async def _realtime_reply_task(
32823303
ori_tools = self._rt_session.tools.flatten()
32833304
await self._rt_session.update_tools(llm.ToolContext(tools).flatten())
32843305

3285-
generate_reply_fut = self._rt_session.generate_reply(
3286-
instructions=instructions or NOT_GIVEN,
3287-
tool_choice=(model_settings.tool_choice if per_response_tool_choice else NOT_GIVEN),
3288-
tools=(
3289-
llm.ToolContext(tools).flatten()
3290-
if per_response_tool_choice and tools is not None
3291-
else NOT_GIVEN
3292-
),
3293-
)
3294-
await speech_handle.wait_if_not_interrupted([generate_reply_fut])
3295-
if speech_handle.interrupted:
3296-
# cancel the pending generation; the plugin emits response.cancel
3297-
if not generate_reply_fut.done():
3298-
generate_reply_fut.cancel()
3299-
return
3300-
3301-
try:
3302-
generation_ev = await generate_reply_fut
3303-
except llm.RealtimeError as e:
3304-
logger.error(
3305-
"failed to generate a reply%s: %s",
3306-
" after tool execution" if tool_reply else "",
3307-
str(e),
3306+
# Retry the reply while it fails *before it starts* (pre-``response.created``):
3307+
# timeouts, server errors that produced no output, or a response discarded by a
3308+
# session reconnection. All of these raise from ``await generate_reply_fut``, so we
3309+
# re-issue here. Failures *after* the reply starts are handled by the generation
3310+
# task below and surfaced through ``SpeechHandle.exception()``.
3311+
reply_ev: llm.GenerationCreatedEvent | None = None
3312+
last_error: llm.RealtimeError | None = None
3313+
for attempt in range(_REALTIME_REPLY_MAX_RETRIES + 1):
3314+
if attempt > 0:
3315+
# clear the previous (failed) response and wait for the socket to be
3316+
# healthy again before re-issuing, so we neither collide with a still-active
3317+
# response nor fire into a reconnecting session
3318+
await self._rt_session.cancel_and_wait()
3319+
if self._rt_session.reconnecting:
3320+
await self._rt_session.wait_reconnected(_REALTIME_RECONNECT_WAIT_TIMEOUT)
3321+
await asyncio.sleep(_REALTIME_REPLY_RETRY_INTERVAL)
3322+
3323+
generate_reply_fut = self._rt_session.generate_reply(
3324+
instructions=instructions or NOT_GIVEN,
3325+
tool_choice=(
3326+
model_settings.tool_choice if per_response_tool_choice else NOT_GIVEN
3327+
),
3328+
tools=(
3329+
llm.ToolContext(tools).flatten()
3330+
if per_response_tool_choice and tools is not None
3331+
else NOT_GIVEN
3332+
),
33083333
)
3309-
speech_handle._mark_done(error=e)
3334+
await speech_handle.wait_if_not_interrupted([generate_reply_fut])
3335+
if speech_handle.interrupted:
3336+
# cancel the pending generation; the plugin emits response.cancel
3337+
if not generate_reply_fut.done():
3338+
generate_reply_fut.cancel()
3339+
return
3340+
3341+
try:
3342+
reply_ev = await generate_reply_fut
3343+
break
3344+
except llm.RealtimeError as e:
3345+
last_error = e
3346+
if not e.recoverable:
3347+
# fatal / non-recoverable (quota, auth, retries exhausted upstream)
3348+
logger.error(
3349+
"failed to generate a reply%s (not recoverable, not retrying): %s",
3350+
" after tool execution" if tool_reply else "",
3351+
str(e),
3352+
)
3353+
break
3354+
logger.warning(
3355+
"failed to generate a reply%s (attempt %d/%d): %s",
3356+
" after tool execution" if tool_reply else "",
3357+
attempt + 1,
3358+
_REALTIME_REPLY_MAX_RETRIES + 1,
3359+
str(e),
3360+
)
3361+
3362+
if reply_ev is None:
3363+
# every attempt failed (or a fatal error): record it so callers can read it
3364+
# via SpeechHandle.exception() (awaiting the handle never raises)
3365+
speech_handle._mark_done(error=last_error)
33103366
self._session._update_agent_state("listening")
33113367
return
33123368

33133369
# _realtime_generation_task will clear the authorization
33143370
await self._realtime_generation_task(
33153371
speech_handle=speech_handle,
3316-
generation_ev=generation_ev,
3372+
generation_ev=reply_ev,
33173373
model_settings=model_settings,
33183374
instructions=instructions,
33193375
)

livekit-plugins/livekit-plugins-nvidia/livekit/plugins/nvidia/experimental/realtime/realtime_model.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -383,10 +383,7 @@ async def _main_task(self) -> None:
383383
self._msg_ch = utils.aio.Chan[bytes]()
384384

385385
if restart_wait_task in done:
386-
self.emit(
387-
"session_reconnected",
388-
llm.RealtimeSessionReconnectedEvent(),
389-
)
386+
self._set_reconnected()
390387

391388
except Exception as e:
392389
logger.error(f"PersonaPlex WebSocket error: {e}", exc_info=True)

0 commit comments

Comments
 (0)