Repository navigation
voice-agents-python: the TypeScript runtime holds frames during a resume, fails lost tool calls, contains a throwing handler #4
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 |
|---|---|---|
|
|
@@ -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 will now log a "a handler failed" traceback at the end of most calls. Any code after Why this was flaggedThe trigger is an audio session whose session.end arrives, or whose session is lost, while caller audio frames are still queued in Verification: Trigger: in an audio session, session.end arrives or the session is lost while caller audio frames are still queued in |
||
| 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) | ||
|
|
||
| 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.
🟣 pre-existing, not blocking: Pre-existing: after a
connection_fullrefusal, agent audio can still go to another caller. The new resume guard does not cover this case._errorclears_resumingbut leavessession._connectionon the full connection, sosend_audioand_sendstill transmit there with the old stream number. Fix: keep the session marked detached (for example_resumingstays True, or a separate flag) from the refusal untilsession.resumedarrives on the new connection, so both audio and frames are held.A small fix can ride a push you are already making; otherwise a short reply is enough.
Why this was flagged
The trigger is Sent answering a session.resume with code
connection_full. agent.py:337 setssession._resuming = Falseand agent.py:341 opens a new connection, butsession._connectionstill points at the connection that refused it (set at agent.py:279). Until the new connection is ready, session.py:98 no longer drops audio.send_audiosendsaudio_frame(self.stream, ...)on the full connection.self.streambelongs to the old, lost connection and can match another caller's stream there. That is the hazard the new comment at session.py:99-100 describes. Counted frames are also sent there at session.py:146 instead of waiting for the replay. The base branch has the same behaviour. The diff's guard only covers the time a resume is unanswered and misses this window.Verification: Pre-existing.
_error(agent.py:337-342) setssession._resuming = Falseand callsself._open([session], 0.0). It never detachessession._connection, so the session still points at the full connection. During that gap, the new guard insend_audio(session.py:98,if self._resuming: return) does not fire. On the base,send_audiohad no_resumingcheck at all.