Repository navigation
voice-agents-python: the Python runtime holds frames during a resume, fails lost tool calls, counts unknown session frames #3
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
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 == [] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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, partial fix: after a connection_full error, a session's audio and frames still go to the full connection, where its stream number may be another caller's. _error clears _resuming at agent.py:337 but leaves session._connection pointing at the full connection. The new send_audio guard (session.py:98) and the _send hold therefore stop working until the next connection resumes the session. Fix: keep the session in a held state until a resume on the new connection is answered. For example, leave _resuming True on the connection_full branch, so send_audio drops audio and _send keeps frames without sending them.
A small fix can ride a push you are already making; otherwise a short reply is enough.
Why this was flagged
_ready->_resume (agent.py:275-281) attaches a pending session to a new connection, sets session._connection to it and sets _resuming=True. Sent refuses with code connection_full. _error (agent.py:337-342) sets session._resuming=False, removes the session from connection.sessions and calls _open([session], 0.0), but session._connection still points at the full connection. Until the new connection sends connection.ready, the session's own task keeps running. During that gap, send_audio (session.py:98) skips the new
_resumingguard and writes audio_frame(self.stream, ...) on the full connection using the stream number from the old connection. The diff's own comment says that number "may be another caller's" there, so the caller audio can reach a different call. say/clear/tool frames are also sent there rather than held for the replay. The base branch sent audio unconditionally, so this is the same behavior: the diff's fix covers the session.resume wait but not this window.Verification: pre-existing. Trigger: Sent answers a session.resume with error code connection_full.
_error(agent.py:337-342) never changessession._connection, so that still points at the full connection.send_audio(session.py:94-102) passes the new_resumingguard and puts this caller's audio on the full connection under a stale stream number. Basesend_audiohad no_resumingguard at all.