Repository navigation
voice-agents-python: the .NET runtime holds frames during a resume, fails lost tool calls, reports a drained connection's leftovers #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,7 +27,8 @@ | |
| DEFAULT_URL = f"wss://{frames.HOST}{frames.CONNECT_PATH}" | ||
| # The longest wait before another attempt to connect. | ||
| MAX_BACKOFF_S = 30.0 | ||
| # How long a handshake may take before the attempt counts as failed. | ||
| # How long a handshake may take before the attempt counts as failed, and how long an open socket may wait for | ||
| # connection.ready before it counts as dead. | ||
| HANDSHAKE_TIMEOUT_S = 10.0 | ||
| # How many heartbeat intervals without a frame make a connection dead, as Heartbeats in docs/protocol.md says. | ||
| SILENT_INTERVALS = 3 | ||
|
|
@@ -89,6 +90,8 @@ def __init__(self, *, api_key: str, agent_id: str, url: str = DEFAULT_URL) -> No | |
| self._connections: set[_Connection] = set() | ||
| self._sessions: dict[str, Session] = {} | ||
| self._tasks: dict[str, asyncio.Task[None]] = {} | ||
| # Connection-level handlers run off the reader, so a slow one never delays a ping's pong or a session. | ||
| self._background: set[asyncio.Task[None]] = set() | ||
| self._attempt = 0 | ||
| self._stopped = False | ||
| self._done: asyncio.Future[None] | None = None | ||
|
|
@@ -174,9 +177,9 @@ async def _close_all(self) -> None: | |
| connection.task.cancel() | ||
| if connection.task is not None: | ||
| tasks.append(connection.task) | ||
| for task in self._tasks.values(): | ||
| for task in [*self._tasks.values(), *self._background]: | ||
| task.cancel() | ||
| await asyncio.gather(*tasks, *self._tasks.values(), return_exceptions=True) | ||
| await asyncio.gather(*tasks, *self._tasks.values(), *self._background, return_exceptions=True) | ||
|
|
||
| # ----- connections ----- | ||
|
|
||
|
|
@@ -215,11 +218,12 @@ async def _serve(self, connection: _Connection, delay: float) -> None: | |
|
|
||
| async def _read(self, connection: _Connection, socket: ClientConnection) -> None: | ||
| while True: | ||
| timeout = SILENT_INTERVALS * connection.heartbeat_s if connection.ready else None | ||
| # Before connection.ready the socket has the handshake's deadline; after it, the heartbeat's. | ||
| timeout = SILENT_INTERVALS * connection.heartbeat_s if connection.ready else HANDSHAKE_TIMEOUT_S | ||
| try: | ||
| message = await asyncio.wait_for(socket.recv(), timeout) | ||
| except TimeoutError: | ||
| log.warning("no frame for %.0f s, closing", timeout or 0) | ||
| log.warning("no %s for %.0f s, closing", "frame" if connection.ready else "connection.ready", timeout) | ||
| return | ||
| except ConnectionClosed as closed: | ||
| close = closed.rcvd or closed.sent | ||
|
|
@@ -240,6 +244,10 @@ async def _handle(self, connection: _Connection, frame: frames.Frame) -> None: | |
| await self._ready(connection, frame) | ||
| elif isinstance(frame, frames.Ping): | ||
| await connection.send(frames.Pong(ts=frame.ts)) | ||
| # A connection that lived through a heartbeat interval is a good one: the next loss starts the | ||
| # backoff over. A connection that dies before its first ping keeps it growing. | ||
| if connection.ready: | ||
| self._attempt = 0 | ||
| elif isinstance(frame, frames.ConnectionDraining): | ||
| self._drain(connection) | ||
| elif isinstance(frame, frames.SessionStart): | ||
|
|
@@ -248,7 +256,7 @@ async def _handle(self, connection: _Connection, frame: frames.Frame) -> None: | |
| await self._resumed(connection, frame) | ||
| elif isinstance(frame, frames.ErrorFrame): | ||
| self._error(connection, frame) | ||
| await self._safely(self.on_error(frame)) | ||
| self._spawn(self.on_error(frame)) | ||
| elif isinstance(frame, frames.UnknownFrame) and frame.session_id is None: | ||
| log.info("ignored a %s frame", frame.type) | ||
| else: | ||
|
|
@@ -257,13 +265,12 @@ async def _handle(self, connection: _Connection, frame: frames.Frame) -> None: | |
| async def _ready(self, connection: _Connection, frame: frames.ConnectionReady) -> None: | ||
| connection.ready = True | ||
| connection.heartbeat_s = frame.heartbeat_interval_ms / 1000 | ||
| self._attempt = 0 | ||
| pending = [s for s in connection.pending if self._sessions.get(s.id) is s] | ||
| connection.pending = [] | ||
| log.info("ready: protocol %s, up to %s sessions, resuming %s", frame.protocol_version, frame.max_sessions, len(pending)) | ||
| for session in pending: | ||
| await self._resume(connection, session) | ||
| await self._safely(self.on_connected(frame)) | ||
| self._spawn(self.on_connected(frame)) | ||
|
|
||
| async def _resume(self, connection: _Connection, session: Session) -> None: | ||
| for other in self._connections: | ||
|
|
@@ -319,10 +326,14 @@ async def _resumed(self, connection: _Connection, frame: frames.SessionResumed) | |
| await connection.send(frame_again) | ||
|
|
||
| def _error(self, connection: _Connection, frame: frames.ErrorFrame) -> None: | ||
| """An error is uncounted, as Counting frames in docs/protocol.md says. Only one that refuses a | ||
| session.resume changes the session's fate; any other is the handler's to read.""" | ||
| log.warning("error %s: %s", frame.error.code, frame.error.message) | ||
| session = connection.sessions.get(frame.session_id or "") | ||
| if session is None or not session._resuming: | ||
| return | ||
| if frame.frame_type != "session.resume" and frame.error.code not in ("resume_failed", "connection_full"): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 If Sent refuses a resume with no Why this was flaggedThe condition is Sent sending an error for a pending session.resume without Verification: The new guard at agent.py:335 is |
||
| return | ||
| session._resuming = False | ||
| connection.sessions.pop(session.id, None) | ||
| if frame.error.code == "connection_full": | ||
|
Comment on lines
337
to
339
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 pre-existing, not blocking: Pre-existing, partly fixed here: after a connection_full refusal, the agent's audio and frames still go to the full connection. The audio uses a stream number that may be another caller's. Why this was flaggedSent answers session.resume with error code connection_full. Agent._error (agent.py:337-341) sets Verification: In agent.py:337-342, |
||
|
|
@@ -338,6 +349,7 @@ def _forget(self, session: Session) -> None: | |
|
|
||
| def _lost(self, session: Session) -> None: | ||
| self._forget(session) | ||
| session._abandon() | ||
| session._inbox.put_nowait(None) | ||
|
|
||
| async def _closed(self, connection: _Connection) -> None: | ||
|
|
@@ -391,6 +403,12 @@ async def _run_session(self, session: Session) -> None: | |
| await self._safely(self.on_frame(session, item)) | ||
| self._tasks.pop(session.id, None) | ||
|
|
||
| def _spawn(self, handler: object) -> None: | ||
| """Runs a connection-level handler on its own task, off the reader.""" | ||
| task = asyncio.create_task(self._safely(handler)) | ||
| self._background.add(task) | ||
| task.add_done_callback(self._background.discard) | ||
|
|
||
| async def _safely(self, handler: object) -> None: | ||
| """Runs a handler; an exception in it is logged and does not take the session or the agent down.""" | ||
| try: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -50,6 +50,8 @@ def __init__(self, start: frames.SessionStart, connection: _Connection) -> None: | |
| self.received = 0 | ||
| # session.end, once it arrived. | ||
| self.end: frames.SessionEnd | None = None | ||
| # The connection was lost and the session could not be resumed: Sent ends the call without a session.end. | ||
| self.lost = False | ||
| self._connection = connection | ||
| # A session.resume was sent on the current connection and Sent has not answered it yet. | ||
| self._resuming = False | ||
|
|
@@ -64,7 +66,8 @@ def __init__(self, start: frames.SessionStart, connection: _Connection) -> None: | |
|
|
||
| @property | ||
| def ended(self) -> bool: | ||
| return self.end is not None | ||
| """Whether the call has left the agent: session.end arrived, or the session was lost.""" | ||
| return self.end is not None or self.lost | ||
|
|
||
| @property | ||
| def resumable(self) -> bool: | ||
|
|
@@ -87,9 +90,15 @@ async def clear(self) -> None: | |
|
|
||
| async def send_audio(self, samples: bytes) -> None: | ||
| """Audio sessions: play `samples`, 16-bit little-endian mono PCM at the session's sample rate. Send | ||
| 20 ms per call, and no more than 200 ms.""" | ||
| 20 ms per call, and no more than 200 ms. Raises SessionEnded once the call has left the agent.""" | ||
| if self.ended: | ||
| raise SessionEnded(self.id) | ||
|
Comment on lines
+94
to
+95
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) Audio agents, including the bundled conformance agent, now log an ERROR traceback ("a handler failed") for each audio chunk still queued when a call ends or is lost. The reader sets Why this was flaggedIn agent.py:302-307, Verification: |
||
| if self.stream is None: | ||
| raise RuntimeError(f"session {self.id} is a text session") | ||
| if self._resuming: | ||
| # The stream number belongs to the old connection; on the new one it may be another caller's until | ||
| # session.resumed assigns this session's. Audio is not counted, so there is nothing to send again. | ||
| return | ||
| await self._connection.send_bytes(audio_frame(self.stream, samples)) | ||
|
|
||
| async def transfer(self, to: frames.Party | str) -> frames.ToolResult: | ||
|
|
@@ -125,10 +134,15 @@ async def tool(self, name: str, arguments: dict[str, Any] | None = None) -> fram | |
|
|
||
| async def _send(self, frame: frames.Frame) -> None: | ||
| """Sends a frame for the session on its connection, keeping it if it counts. A frame that could not be | ||
| sent still counts: Sent's `received` on the next resume says whether to send it again.""" | ||
| sent still counts: Sent's `received` on the next resume says whether to send it again. A frame that | ||
| breaks the protocol is refused before it counts. While a resume is unanswered the frame is kept and not | ||
| sent: the replay after session.resumed sends it, once, on the connection the session is on.""" | ||
| if self.ended: | ||
| raise SessionEnded(self.id) | ||
| frames.encode(frame) | ||
| self._keep(frame) | ||
| if self._resuming: | ||
| return | ||
| await self._connection.send(frame) | ||
|
Comment on lines
135
to
146
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Python SDK customers will see a changelog line about "the .NET runtime" that describes a feature this change does not ship. The only commit, be2632b, has the subject "fix(sdk): the .NET runtime holds frames during a resume, fails lost tool calls, reports a drained connection's leftovers". release-please copies it verbatim into CHANGELOG.md. No code here reports a drained connection's leftovers: Why this was flaggedThe only commit in the range, be2632b, has the subject "fix(sdk): the .NET runtime holds frames during a resume, fails lost tool calls, reports a drained connection's leftovers". CLAUDE.md says the subject "becomes the changelog line customers read", and REVIEW.md item 7 asks for a fix: subject a customer can read. This repository is the Python SDK, so after the release-please release Python users would read a line about another language's runtime. The line also names "reports a drained connection's leftovers", but Verification: Trigger: the PR is merged and release-please cuts the next release. In release-please-config.json, "fix" maps to the visible "Bug Fixes" section and is not hidden, so this subject goes into CHANGELOG.md as written. This repository is the Python SDK, so the line describes another language's runtime. The diff never touches |
||
|
|
||
| def _keep(self, frame: frames.Frame) -> None: | ||
|
|
@@ -180,6 +194,16 @@ def _answer_tool(self, result: frames.ToolResult) -> None: | |
|
|
||
| def _finish(self, end: frames.SessionEnd) -> None: | ||
| self.end = end | ||
| self._fail_pending() | ||
|
|
||
| def _abandon(self) -> None: | ||
| """The session is lost: no tool.result or session.end will come, so every waiting tool call fails, and | ||
| anything sent from now on is refused.""" | ||
| self.lost = True | ||
| self._resuming = False | ||
| self._fail_pending() | ||
|
|
||
| def _fail_pending(self) -> None: | ||
| for future in self._pending.values(): | ||
| if not future.done(): | ||
| future.set_exception(SessionEnded(self.id)) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| """The agent's bookkeeping around a connection, without a socket: which errors refuse a resume, when the | ||
| backoff starts over, and what a lost session's task sees.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
|
|
||
| from sentdm_voice_agents import frames | ||
| from sentdm_voice_agents.agent import Agent, _Connection | ||
| from sentdm_voice_agents.session import Session, SessionEnded | ||
|
|
||
| from test_session import Recorder, start | ||
|
|
||
|
|
||
| def error(**fields) -> frames.ErrorFrame: | ||
| return frames.decode({"type": "error", "session_id": "vs_1", "error": {"code": "frame_not_allowed", "message": "no"}, **fields}) | ||
|
|
||
|
|
||
| def resuming_session(agent: Agent, connection: _Connection) -> Session: | ||
| session = Session(start(), Recorder()) | ||
| session._resuming = True | ||
| agent._sessions[session.id] = session | ||
| connection.sessions[session.id] = session | ||
| agent._connections.add(connection) | ||
| return session | ||
|
|
||
|
|
||
| async def test_only_an_error_about_the_resume_refuses_it(): | ||
| agent = Agent(api_key="k", agent_id="agent_1") | ||
| connection = _Connection([]) | ||
| session = resuming_session(agent, connection) | ||
| agent._error(connection, error(frame_type="agent.say")) | ||
| assert session._resuming and session.id in connection.sessions and not session.lost | ||
| agent._error(connection, error(frame_type="session.resume", error={"code": "resume_failed", "message": "gone"})) | ||
| assert session.lost and session.id not in connection.sessions and session.id not in agent._sessions | ||
| assert await session._inbox.get() is None | ||
|
|
||
|
|
||
| async def test_a_full_connection_moves_the_resume_elsewhere(): | ||
| agent = Agent(api_key="k", agent_id="agent_1") | ||
| connection = _Connection([]) | ||
| session = resuming_session(agent, connection) | ||
| agent._error(connection, error(error={"code": "connection_full", "message": "full"})) | ||
| assert not session.lost and session.id not in connection.sessions | ||
| opened = [c for c in agent._connections if c is not connection] | ||
| assert len(opened) == 1 and opened[0].pending == [session] | ||
| for c in opened: | ||
| c.task.cancel() | ||
| await asyncio.gather(*(c.task for c in opened), return_exceptions=True) | ||
|
|
||
|
|
||
| async def test_the_backoff_starts_over_after_a_heartbeat_not_on_ready(): | ||
| agent = Agent(api_key="k", agent_id="agent_1") | ||
| connection = _Connection([]) | ||
| agent._attempt = 3 | ||
| await agent._ready(connection, frames.decode({"type": "connection.ready", "protocol_version": "0.2.0", "heartbeat_interval_ms": 15000, "max_sessions": 10})) | ||
| assert agent._attempt == 3 | ||
| await agent._handle(connection, frames.decode({"type": "ping", "ts": 1})) | ||
| assert agent._attempt == 0 | ||
| await asyncio.gather(*agent._background, return_exceptions=True) | ||
|
|
||
|
|
||
| async def test_a_lost_session_reaches_its_handler_after_its_tool_call_fails(): | ||
| seen: list[str] = [] | ||
|
|
||
| class Noticing(Agent): | ||
| async def on_session_start(self, session: Session) -> None: | ||
| try: | ||
| await session.hangup() | ||
| except SessionEnded: | ||
| seen.append("tool failed") | ||
|
|
||
| async def on_session_lost(self, session: Session) -> None: | ||
| seen.append("lost") | ||
|
|
||
| agent = Noticing(api_key="k", agent_id="agent_1") | ||
| connection = _Connection([]) | ||
| agent._connections.add(connection) | ||
| agent._start(connection, start()) | ||
| await asyncio.sleep(0) | ||
| session = agent.sessions[0] | ||
| agent._lost(session) | ||
| await asyncio.wait_for(agent._tasks.get(session.id) or asyncio.sleep(0), 1) | ||
| assert seen == ["tool failed", "lost"] and agent.sessions == [] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 When a second connection keeps failing, its reconnect backoff never grows past a few seconds. A healthy connection's pings keep resetting the shared counter. At agent.py:250 a ping on any ready connection sets the agent-wide
_attemptto 0, even when a different connection is the one failing. Fix: track the attempt count per reconnect chain, for example on_Connectionpassed through_open. Reset it only when that same connection gets its first ping after ready, and have_closedtake the delay from the closing connection's count. On the base branch the counter reset only on connection.ready, so a sibling's failures still grew to MAX_BACKOFF_S.Why this was flagged
An agent can hold two connections at once. One case is a drain:
_drainat agent.py:283-287 opens B while the draining A stays up. The other is an overflow:_errorat agent.py:339-341 opens B for a connection_full session. If B's handshake keeps failing (a 503 from Sent at capacity, or during a deploy), each failure goes_servefinally →_closed(B)→_backoff()(agent.py:375-381), which increments the sharedself._attempt. Meanwhile A, still ready, receives a ping every heartbeat_s (5000-15000 ms in the fixtures)._handle(agent.py:249-250) then setsself._attempt = 0. B therefore retries at 0, 1, 2, 4 s and then starts again from 0, never reaching MAX_BACKOFF_S = 30.0, for as long as A lives. On the base branch only_readyreset the counter, and A's ready came once, so B's retries backed off to 30 s. The new comment says the reset applies to "a connection that lived through a heartbeat interval", but the counter is not per connection.Verification:
_attemptis one counter on the Agent (agent.py:95). The new lines at agent.py:247-250 reset it on a ping from ANY ready connection, not only from the connection whose chain is failing. On the base,_attempt = 0ran only in_ready. The new test (tests/test_agent.py:52-59) uses only one connection and does not cover this.