Skip to content

Commit 2aa99ba

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 2aa99ba

6 files changed

Lines changed: 523 additions & 39 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: 106 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,51 @@ class RealtimeCapabilities:
8796

8897

8998
class RealtimeError(Exception):
90-
def __init__(self, message: str) -> None:
99+
def __init__(self, message: str, *, recoverable: bool = True) -> None:
91100
super().__init__(message)
101+
self.recoverable = recoverable
102+
"""Whether re-issuing the request could plausibly succeed.
103+
104+
``True`` for transient failures (timeouts, server errors, connection drops);
105+
``False`` for terminal ones (retries exhausted, fatal account errors). Callers
106+
should not retry when this is ``False``.
107+
"""
108+
109+
110+
# Underlying error codes/types that can never succeed on retry. Retrying these only
111+
# wastes the retry budget and keeps the reconnect loop spinning, so callers should
112+
# fail fast instead.
113+
FATAL_REALTIME_ERROR_CODES = frozenset(
114+
{
115+
"insufficient_quota",
116+
"invalid_api_key",
117+
"account_deactivated",
118+
"billing_hard_limit_reached",
119+
}
120+
)
121+
122+
123+
def is_fatal_error(error: BaseException | None) -> bool:
124+
"""Return ``True`` for errors that can never succeed on retry.
125+
126+
Walks the wrapped-error chain (e.g. ``RealtimeModelError.error`` -> ``APIError.body``
127+
-> ...) and matches any ``code``/``type`` attribute against
128+
:data:`FATAL_REALTIME_ERROR_CODES` (quota / auth / billing).
129+
"""
130+
seen: set[int] = set()
131+
stack: list[Any] = [error]
132+
while stack:
133+
obj = stack.pop()
134+
if obj is None or id(obj) in seen:
135+
continue
136+
seen.add(id(obj))
137+
if getattr(obj, "code", None) in FATAL_REALTIME_ERROR_CODES:
138+
return True
139+
if getattr(obj, "type", None) in FATAL_REALTIME_ERROR_CODES:
140+
return True
141+
stack.append(getattr(obj, "error", None))
142+
stack.append(getattr(obj, "body", None))
143+
return False
92144

93145

94146
class RealtimeModel:
@@ -135,6 +187,7 @@ async def __aexit__(
135187
"input_speech_stopped", # serverside VAD
136188
"input_audio_transcription_completed",
137189
"generation_created",
190+
"session_reconnecting",
138191
"session_reconnected",
139192
"metrics_collected",
140193
"remote_item_added",
@@ -155,6 +208,11 @@ class InputTranscriptionCompleted:
155208
"""confidence score of the transcript (0.0 to 1.0), derived from model logprobs"""
156209

157210

211+
@dataclass
212+
class RealtimeSessionReconnectingEvent:
213+
pass
214+
215+
158216
@dataclass
159217
class RealtimeSessionReconnectedEvent:
160218
pass
@@ -170,6 +228,42 @@ class RealtimeSession(ABC, rtc.EventEmitter[EventTypes | TEvent], Generic[TEvent
170228
def __init__(self, realtime_model: RealtimeModel) -> None:
171229
super().__init__()
172230
self._realtime_model = realtime_model
231+
self._reconnecting = False
232+
self._reconnected_ev = asyncio.Event()
233+
self._reconnected_ev.set() # starts in the "connected" state
234+
235+
@property
236+
def reconnecting(self) -> bool:
237+
"""True while the underlying session is re-establishing its connection."""
238+
return self._reconnecting
239+
240+
async def wait_reconnected(self, timeout: float | None = None) -> bool:
241+
"""Wait until the session finishes reconnecting.
242+
243+
Returns immediately if the session is not currently reconnecting. Returns
244+
``True`` once reconnected, or ``False`` if ``timeout`` elapses first.
245+
"""
246+
if not self._reconnecting:
247+
return True
248+
try:
249+
await asyncio.wait_for(self._reconnected_ev.wait(), timeout)
250+
return True
251+
except asyncio.TimeoutError:
252+
return False
253+
254+
def _set_reconnecting(self) -> None:
255+
"""Plugins call this when a reconnect attempt starts."""
256+
if self._reconnecting:
257+
return
258+
self._reconnecting = True
259+
self._reconnected_ev.clear()
260+
self.emit("session_reconnecting", RealtimeSessionReconnectingEvent())
261+
262+
def _set_reconnected(self) -> None:
263+
"""Plugins call this when the session has successfully reconnected."""
264+
self._reconnecting = False
265+
self._reconnected_ev.set()
266+
self.emit("session_reconnected", RealtimeSessionReconnectedEvent())
173267

174268
def _report_connection_acquired(self, acquire_time: float) -> None:
175269
"""Report connection timing as a RealtimeModelMetrics event with zero usage."""
@@ -253,6 +347,16 @@ def clear_audio(self) -> None: ...
253347
@abstractmethod
254348
def interrupt(self) -> None: ...
255349

350+
async def cancel_and_wait(self, timeout: float = 5.0) -> None:
351+
"""Cancel the current generation and wait for it to clear.
352+
353+
Re-issuing a reply before the previous response is fully cancelled server-side
354+
can be rejected (e.g. ``conversation_already_has_active_response``). The default
355+
implementation just calls :meth:`interrupt` without waiting; plugins that can
356+
await the cancellation override this.
357+
"""
358+
self.interrupt()
359+
256360
# message_id is the ID of the message to truncate (inside the ChatCtx)
257361
@abstractmethod
258362
def truncate(

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

Lines changed: 80 additions & 25 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 "timed out" in str(e):
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,31 +3303,65 @@ 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+
generation_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+
generation_ev = await generate_reply_fut
3343+
break
3344+
except llm.RealtimeError as e:
3345+
last_error = e
3346+
if llm.is_fatal_error(e):
3347+
logger.error(
3348+
"failed to generate a reply%s (fatal, not retrying): %s",
3349+
" after tool execution" if tool_reply else "",
3350+
str(e),
3351+
)
3352+
break
3353+
logger.warning(
3354+
"failed to generate a reply%s (attempt %d/%d): %s",
3355+
" after tool execution" if tool_reply else "",
3356+
attempt + 1,
3357+
_REALTIME_REPLY_MAX_RETRIES + 1,
3358+
str(e),
3359+
)
3360+
3361+
if generation_ev is None:
3362+
# every attempt failed (or a fatal error): record it so callers can read it
3363+
# via SpeechHandle.exception() (awaiting the handle never raises)
3364+
speech_handle._mark_done(error=last_error)
33103365
self._session._update_agent_state("listening")
33113366
return
33123367

0 commit comments

Comments
 (0)