OpenAI realtime: a long chat context sync or reconnect replay is sent in one event loop turn
Los mantenedores suelen responder en 1 día
Evaluación
- Dificultad
- 3/5
- Tiempo estimado
- 1-2 días
- Aptitud para principiantes
- 35/100
- Tipo de issue
- Error
- Claridad
- Bien especificado
- Estado de actividad
- Estancado
- Stack tecnológico
- python
- Área
- audio-video-rtc, backend
Línea de trabajo
Start with RealtimeSession._send_task and the reconnect replay loop in _main_task, identified in the issue; inspect how each loop sends queued events and where it can yield to the event loop. Use the provided reproduction script with 1000 chat messages to check both update_chat_ctx and reconnect replay. Done means other tasks can run between sends in both paths while event order is preserved; linked pull request #8228 indicates work is already underway.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
Bug Description
RealtimeSession sends a long chat context, and the replay after a reconnect, in one event loop turn. No other task on the loop runs during that time.
_send_task reads the queued client events with async for msg in self._msg_ch. Chan.recv() returns without a suspension while the channel holds an item, and aiohttp send_str() waits only when the transport buffer is over its limit (writer.py#L126-L129). So the loop sends the full queue in one turn. The reconnect replay in _main_task uses the same kind of loop.
update_chat_ctx queues one conversation.item.create per chat item. So a restored long conversation goes out in one turn, and each reconnect replays it in one turn. A reconnect occurs after a connection error and after max_session_duration, 20 minutes by default. With 1000 messages, each of these sends holds the loop for 40 to 48 ms (Apple M4 Max, local server, no TLS).
Expected Behavior
Other tasks on the event loop, for example audio input and output, run between the events of a long send.
Reproduction Steps
uv run --python 3.12 --with livekit-agents==1.8.10 --with livekit-plugins-openai==1.8.10 repro.py
The script starts a local websocket server in a child process, so no request goes to OpenAI. The session syncs a chat context of 1000 messages with update_chat_ctx, then reconnects after a max_session_duration of 3 seconds. A probe task runs once per event loop turn and counts the openai_client_event_queued events since its previous turn. It exits with 1 when the problem is present.
repro.py
# /// script
# requires-python = ">=3.10"
# dependencies = ["livekit-agents", "livekit-plugins-openai"]
# ///
"""The OpenAI Realtime session sends a burst of client events in one event loop turn.
The script starts a local websocket server in a child process. The server speaks
enough of the OpenAI Realtime protocol to confirm chat items. No request goes to
OpenAI.
1. update_chat_ctx: the session syncs a chat context of 1000 messages.
2. reconnect: max_session_duration expires, and the session replays the chat
context on a new connection.
A probe task runs once per event loop turn. It counts the client events that the
session sent since its previous turn ("openai_client_event_queued" events). The
time in parentheses is the time from the first to the last send of the largest
turn. It depends on the machine.
--fixed applies the proposed change to RealtimeSession._run_ws and
RealtimeSession._main_task in the process.
"""
from __future__ import annotations
import __future__
import asyncio
import inspect
import json
import logging
import sys
import textwrap
import time
from importlib.metadata import version
from aiohttp import WSMsgType, web
from livekit.agents.llm import ChatContext
from livekit.agents.llm._realtime import openai as realtime_openai
from livekit.plugins.openai.realtime import RealtimeModel
MESSAGES = 1000
MAX_SESSION_DURATION = 3.0
TIMEOUT = 30.0
FIX = {
"_run_ws": (
(
" except Exception:\n"
' logger.exception("failed to send event")\n'
"\n"
" closing = True\n",
" except Exception:\n"
' logger.exception("failed to send event")\n'
"\n"
" # one event per loop turn, so a long queue does not block other tasks\n"
" await asyncio.sleep(0)\n"
"\n"
" closing = True\n",
),
),
"_main_task": (
(
' self.emit("openai_client_event_queued", ev)\n'
" await ws_conn.send_str(json.dumps(ev))\n",
' self.emit("openai_client_event_queued", ev)\n'
" await ws_conn.send_str(json.dumps(ev))\n"
" # one event per loop turn, so a long replay does not block other tasks\n"
" await asyncio.sleep(0)\n",
),
),
}
def apply_fix() -> None:
for name, replacements in FIX.items():
function = inspect.unwrap(getattr(realtime_openai.RealtimeSession, name))
source = textwrap.dedent(inspect.getsource(function))
for old, new in replacements:
if source.count(old) != 1:
sys.exit(f"--fixed: {name} does not contain the expected text:\n{old}")
source = source.replace(old, new)
namespace = dict(vars(realtime_openai))
code = compile(
source,
realtime_openai.__file__,
"exec",
flags=__future__.annotations.compiler_flag,
dont_inherit=True,
)
exec(code, namespace)
function.__code__ = inspect.unwrap(namespace[name]).__code__
async def realtime_endpoint(request: web.Request) -> web.WebSocketResponse:
"""Confirm the session update and every conversation.item.create."""
ws = web.WebSocketResponse()
await ws.prepare(request)
session = {"id": "sess_local", "object": "realtime.session", "model": "gpt-realtime"}
await ws.send_str(json.dumps({"type": "session.created", "event_id": "ev_c", "session": session}))
async for message in ws:
if message.type is not WSMsgType.TEXT:
continue
event = json.loads(message.data)
if event["type"] == "session.update":
await ws.send_str(
json.dumps({"type": "session.updated", "event_id": "ev_u", "session": session})
)
elif event["type"] == "conversation.item.create":
previous_item_id = event.get("previous_item_id")
await ws.send_str(
json.dumps(
{
"type": "conversation.item.added",
"event_id": f"ev_{event['item']['id']}",
"previous_item_id": None if previous_item_id == "root" else previous_item_id,
"item": event["item"],
}
)
)
return ws
async def serve() -> None:
"""Child process: serve until the parent closes stdin."""
app = web.Application()
app.router.add_get("/realtime", realtime_endpoint)
runner = web.AppRunner(app, shutdown_timeout=1.0)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
print(runner.addresses[0][1], flush=True)
await asyncio.get_running_loop().run_in_executor(None, sys.stdin.read)
await runner.cleanup()
class SendProbe:
"""Count the client events that the session sends in each event loop turn."""
def __init__(self, session: realtime_openai.RealtimeSession) -> None:
self.reset()
session.on("openai_client_event_queued", self._on_event)
def reset(self) -> None:
self.sent = 0
self.most_in_one_turn = 0
self.largest_turn_ms = 0.0
self._turn: list[float] = []
def _on_event(self, _event: dict) -> None:
self.sent += 1
self._turn.append(time.perf_counter())
def end_turn(self) -> None:
if len(self._turn) > self.most_in_one_turn:
self.most_in_one_turn = len(self._turn)
self.largest_turn_ms = (self._turn[-1] - self._turn[0]) * 1000
self._turn.clear()
async def run(self) -> None:
while True:
self.end_turn()
await asyncio.sleep(0)
def report(self, phase: str) -> int:
self.end_turn()
print(
f"{phase}: the session sent {self.sent} events, at most {self.most_in_one_turn} "
f"in one event loop turn ({self.largest_turn_ms:.1f} ms)"
)
most = self.most_in_one_turn
self.reset()
return most
async def wait_for(condition, timeout: float = TIMEOUT) -> bool:
for _ in range(int(timeout / 0.01)):
if condition():
return True
await asyncio.sleep(0.01)
return False
async def sync_then_reconnect(base_url: str) -> tuple[int, int]:
model = RealtimeModel(
api_key="local",
base_url=base_url,
model="gpt-realtime",
max_session_duration=MAX_SESSION_DURATION,
)
session = model.session()
probe = SendProbe(session)
reconnected: list = []
session.on("session_reconnected", reconnected.append)
if not await wait_for(lambda: probe.sent == 1):
sys.exit("the session did not send session.update")
probe.reset()
probe_task = asyncio.create_task(probe.run())
chat_ctx = ChatContext()
for i in range(MESSAGES):
chat_ctx.add_message(role="user" if i % 2 == 0 else "assistant", content=f"message {i}")
await asyncio.wait_for(session.update_chat_ctx(chat_ctx), TIMEOUT)
if reconnected:
sys.exit("max_session_duration expired during update_chat_ctx, raise MAX_SESSION_DURATION")
sync = probe.report(f"update_chat_ctx with {MESSAGES} messages")
if not await wait_for(lambda: reconnected):
sys.exit("the session did not reconnect")
replay = probe.report("reconnect after max_session_duration")
probe_task.cancel()
await session.aclose()
await model.aclose()
return sync, replay
async def main() -> int:
server = await asyncio.create_subprocess_exec(
sys.executable,
__file__,
"--serve",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
)
try:
port = int(await asyncio.wait_for(server.stdout.readline(), TIMEOUT))
sync, replay = await sync_then_reconnect(f"http://127.0.0.1:{port}")
finally:
server.stdin.close()
await asyncio.wait_for(server.wait(), TIMEOUT)
most = max(sync, replay)
if most > 1:
print(f"PROBLEM PRESENT: the session sent up to {most} client events in one event loop turn")
return 1
print("PROBLEM ABSENT: the session sent at most 1 client event in one event loop turn")
return 0
if __name__ == "__main__":
if "--serve" in sys.argv:
asyncio.run(serve())
sys.exit(0)
logging.getLogger("livekit").setLevel(logging.CRITICAL + 1)
if "--fixed" in sys.argv:
apply_fix()
print(
f"livekit-agents {version('livekit-agents')}, "
f"livekit-plugins-openai {version('livekit-plugins-openai')}, "
f"aiohttp {version('aiohttp')}, Python {sys.version.split()[0]}"
)
sys.exit(asyncio.run(main()))
Output with livekit-agents 1.8.10:
livekit-agents 1.8.10, livekit-plugins-openai 1.8.10, aiohttp 3.14.5, Python 3.12.12
update_chat_ctx with 1000 messages: the session sent 1000 events, at most 1000 in one event loop turn (48.2 ms)
reconnect after max_session_duration: the session sent 1001 events, at most 1001 in one event loop turn (40.5 ms)
PROBLEM PRESENT: the session sent up to 1001 client events in one event loop turn
Operating System
macOS 26
Models Used
OpenAI Realtime (local fake server)
Package Versions
livekit-agents==1.8.10
livekit-plugins-openai==1.8.10
aiohttp==3.14.5
Python 3.12.12
Session/Room/Call IDs
No response
Proposed Solution
await ws_conn.send_str(json.dumps(msg))
...
# one event per loop turn, so a long queue does not block other tasks
await asyncio.sleep(0)
The same yield goes after send_str in the reconnect replay loop. The order on the wire stays the order of the queue, and a cancellation at the yield loses no event, because the event is already sent. The cost is one event loop iteration per event.
Additional Context
No response
Screenshots and Recordings
No response
- Lenguaje dominante
- Python
- Estrellas
- 14.6k
- Forks
- 3.9k
- Merge medio
- 2 d 6 h
- PR fusionados (30 d)
- 227
Preparar el entorno
Este proyecto no incluye contenedor de desarrollo, Dockerfile ni guía de contribución, así que la configuración corre por tu cuenta: empieza por su README y consulta nuestra guía para la primera contribución para los pasos generales.
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de livekit/agents
-
Dificultad 1/5 Menos de una hora Aptitud para principiantes 78/100
Los mantenedores suelen responder en 1 día
-
mistralai: expose reasoning_effort on the LLMPosiblemente ocupada Un pull request vinculado a esta issue está abierto o ya se fusionó. Abiertoenhancement
Dificultad 2/5 1-3 horas Aptitud para principiantes 66/100
Los mantenedores suelen responder en 1 día
-
ci: unit tests pin Python 3.12 while the package advertises 3.14 supportPosiblemente ocupada @huong8373tt-beep la tomó hace 1 día. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 74/100
livekit/agents#8231 · 3 comentarios ·
Los mantenedores suelen responder en 1 día
-
Assistant markup is stripped from chat history when expressive mode is offPosiblemente ocupada @ShivamSharma43 la tomó hace 22 días. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
livekit/agents#7647 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
docs: openai.LLM vs openai.responses.LLM for HTTP OpenAI-compat hostsQuizá libre de nuevo @AbarnaaSree la tomó hace 62 días y no hay ningún pull request abierto. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
livekit/agents#7022 · 1 comentario ·
Los mantenedores suelen responder en 1 día
Todos los issues de livekit/agents
Issues similares
-
getchaintips leaves out the active tip once a header is staged on it, where Core always lists itAbierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 88/100
btclib-org/btclib-node#2158 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 85/100
dbt-msft/dbt-sqlserver#897 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 65/100
Gathin23/cleanyourtv#2770 · 2 comentarios ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
vllm-project/vllm#64845 ·
Los mantenedores suelen responder en 1 día
-
bug
Dificultad 2/5 1-3 horas Aptitud para principiantes 84/100
dekobon/big-code-analysis#1729 ·
Los mantenedores suelen responder en 1 día