Repository navigation
voice-agents-python: the Python runtime holds frames during a resume, fails lost tool calls, counts unknown session frames #6
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)) | ||
|
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) An agent that sets up state in Why this was flaggedOn the base branch, _ready awaited self._safely(self.on_connected(frame)) on the reader. No later frame on that connection was read until on_connected returned, so no session.start could create a session task before then. Now agent.py:273 calls self._spawn(self.on_connected(frame)), and _read (agent.py:240) goes straight to the next frame. If that frame is session.start, _start (agent.py:289-293) creates the session task immediately. Its on_session_start runs as soon as on_connected yields at any await, such as a config fetch or opening a client. A handler that reads state on_connected was still preparing now sees missing or stale state, for example an AttributeError logged by _safely and a session with no greeting. Nothing orders the two tasks: _spawn returns no handle the session path waits on. The on_connected docstring ("A connection to Sent is ready for sessions.") and README still suggest it runs before sessions on that connection. Verification: On the base, |
||
|
|
||
| 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"): | ||
| return | ||
| session._resuming = False | ||
| connection.sessions.pop(session.id, None) | ||
| if frame.error.code == "connection_full": | ||
|
Comment on lines
+335
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: after a Why this was flagged
Verification: pre-existing (security-relevant). Trigger: Sent refuses a session.resume with |
||
|
|
@@ -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 |
|---|---|---|
| @@ -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.
🟡 nit (optional): release-please will publish a changelog line that does not describe this change. The commit subject is "fix(sdk): a hand render can name its subject and its language". The diff actually changes the handshake timeout, the reconnect backoff, resume buffering, audio during a resume, and lost-session behaviour. Fix: reword the
fix:subject so a customer can read what changed (e.g. resume and connection-loss handling), and split unrelated changes per "One logical change per PR".Why this was flagged
CLAUDE.md lines 27-28 say the Conventional Commit subject becomes the changelog line customers read. REVIEW.md item 7 asks for a
fix:subject a customer can read. Commit 0d691de's subject names a hand render and a subject/language, but no file in the diff touches rendering, subject or language. The diff touches agent.py (timeout, backoff at agent.py:245-250, background handlers), session.py (resume gating, lost flag) and frames.py. Once merged, release-please writes the misleading line into CHANGELOG.md and the release notes.Verification: nit: the misleading changelog line appears once the PR merges and release-please cuts the release. The PR range has one commit,
0d691de fix(sdk): a hand render can name its subject and its language. Nothing in the diff deals with rendering, a subject or a language. The PR title has no prefix, so release-please takes only this subject, against CLAUDE.md:27-28.