perf(voice): start CO conversation lookup on setup, not first prompt - #103
Conversation
There was a problem hiding this comment.
Pull request overview
This PR reduces first-turn latency in the Voice channel by starting Conversation Orchestrator (CO) conversation initialization as soon as the WebSocket "setup" message arrives, so CO polling overlaps with the caller’s initial speech/transcription time instead of running serially on the first "prompt".
Changes:
- Start
_initialize_conversation(...)in a backgroundasynciotask on"setup"(when orchestrator mode is enabled) and await it on the first"prompt". - Add
finally-path handling to cancel/adopt the init task result when a call disconnects before any prompt arrives, to avoid leaking websocket registration. - Update voice channel tests to assert the new background-init behavior and disconnect cleanup semantics.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/tac/channels/voice/channel.py |
Starts CO initialization on "setup" via a background task; awaits/adopts/cancels it for prompt handling and early-disconnect cleanup. |
tests/test_voice_channel.py |
Rewrites the setup-message test to validate that background CO initialization starts on "setup" and cleanup still runs on disconnect-before-prompt. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
7c54fc8 to
4967c0f
Compare
- Document the session-lifecycle change from starting CO init at "setup": in orchestrator mode a session may now exist before the caller has spoken (e.g. before on_amd fires), where it previously never did. Update get_conversation_session_by_call_sid and end_call docstrings accordingly. - Fix the finally-block CancelledError handling: only swallow the cancellation we caused ourselves (init_task.cancel()); a real external cancellation (e.g. server shutdown) now runs best-effort cleanup first, then propagates instead of being silently absorbed. - Log background CO lookup failures that occur when the call ends before any prompt ever arrives, so they don't go unnoticed. Addresses both review comments from Copilot on PR #103. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
src/tac/channels/voice/channel.py:574
- This documents the breaking public behavior described in the PR, but the package version remains
2.2.0inpyproject.toml. The PR description explicitly says this should ship with a version bump, so include the appropriate breaking-version update or clarify that versioning is handled by a separate release workflow.
Relay-only mode creates the session on the caller's first prompt.
Orchestrator mode creates it earlier — as soon as the background CO
lookup started at WebSocket setup finishes — so it may already exist
before the caller has said anything, including before ``on_amd``
fires. Either way, treat this as racy and use :meth:`end_call` to hang
up, which works whether or not a session exists yet.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/tac/channels/voice/channel.py:765
- The setup-time poll can now fail permanently before the first prompt. For example, if CO is unavailable for the initial ~10.75-second window but recovers before a caller speaks at 15 seconds, this await raises the completed task's error and closes the handler; previously the prompt-time lookup would have succeeded immediately. Keep polling until the prompt establishes the deadline, or retry an exhausted “conversation not found” result when the first prompt arrives.
task_to_await = init_task
init_task = None
conv_id, session_state = await task_to_await
src/tac/channels/voice/channel.py:825
we_cancelled_itonly proves that this code calledinit_task.cancel(); it does not prove that theCancelledErrorcaught by this await came from that call. If server shutdown cancels the handler while it is suspended here, the external cancellation is caught and later suppressed because the flag is already true. Preserve cancellation whenever the enclosing handler task is cancelling (for example viaasyncio.current_task().cancelling()), even if the child was also cancelled locally.
except asyncio.CancelledError as e:
# Defer re-raise decision until after cleanup below;
# we_cancelled_it (not init_task.cancelled(), which can't
# tell the two apart) decides whether to.
cancelled_error = e
src/tac/channels/voice/channel.py:765
- Clearing
init_taskbefore the await creates a cleanup race during external cancellation. If_initialize_conversationfinishes (and registers the session/WebSocket) just as this handler is cancelled, the await can raiseCancelledErrorbefore assigningconv_id;finallythen has neither the task nor the ID, so the WebSocket registration leaks. Keep the task referenced until the result is assigned, clearing it only on success or on ordinary exceptions, sofinallycan recover a completed result before propagating cancellation.
This issue also appears on line 821 of the same file.
task_to_await = init_task
init_task = None
conv_id, session_state = await task_to_await
The CO conversation lookup (list_conversations poll + list_participants) was only triggered once the first "prompt" WS message arrived — i.e. after the caller's speech had already been transcribed — adding its full latency (roughly 0.8-4s observed locally) serially in front of memory retrieval and the LLM call on every call's first turn. ConversationRelay creates the CO conversation as soon as the call connects, not when the caller speaks, so there's no reason to wait for the first prompt to start looking it up. Kick it off in the background as soon as "setup" arrives (call_sid is known immediately), and await it when the first prompt lands instead of starting it there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Document the session-lifecycle change from starting CO init at "setup": in orchestrator mode a session may now exist before the caller has spoken (e.g. before on_amd fires), where it previously never did. Update get_conversation_session_by_call_sid and end_call docstrings accordingly. - Fix the finally-block CancelledError handling: only swallow the cancellation we caused ourselves (init_task.cancel()); a real external cancellation (e.g. server shutdown) now runs best-effort cleanup first, then propagates instead of being silently absorbed. - Log background CO lookup failures that occur when the call ends before any prompt ever arrives, so they don't go unnoticed. Addresses both review comments from Copilot on PR #103. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two more issues found in review of the early-CO-init change: - The CO poll window used to be measured from the first prompt (i.e. after the caller had already started speaking), giving CO plenty of time to create the conversation. Starting it at "setup" instead means the same fixed window is now measured from call-connect, which is earlier and has less slack — an early transient miss could exhaust all attempts before the caller even finishes speaking. Raised _POLL_ATTEMPTS to 10 and capped the exponential backoff via _POLL_MAX_DELAY (was unbounded, growing to ~2min at 10 attempts) so the background lookup started at "setup" has enough of its own window to comfortably cover the wait for the first prompt too, without a second poll-and-give-up phase duplicating the same logic. - The finally-block cleanup used init_task.cancelled() to decide whether a CancelledError was self-inflicted (from our own init_task.cancel() above) or a real external cancellation (e.g. server shutdown) that should propagate. That check doesn't actually distinguish the two: cancelling this coroutine's own enclosing task while it's suspended on `await init_task` also cancels init_task as a side effect, so external cancellation looks identical to our own. Track it explicitly with a we_cancelled_it flag instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
6412093 to
c83b600
Compare
| task_to_await = init_task | ||
| init_task = None | ||
| conv_id, session_state = await task_to_await | ||
| elif self.tac.is_orchestrator_enabled(): |
There was a problem hiding this comment.
init_task has value when call_sid && self.tac.is_orchestrator_enabled()
so this elif self.tac.is_orchestrator_enabled() branch here i don't think will ever be ran, right?
There was a problem hiding this comment.
yes! good point! fix it!
init_task is created at "setup" iff call_sid and tac.is_orchestrator_enabled() were both true — both depend only on static config that can't change mid-connection, so by the time the first prompt checks `elif self.tac.is_orchestrator_enabled()`, that condition is guaranteed false whenever init_task is None (the only way to reach the elif). The branch was unreachable; removing it collapses the three-way branch to init_task-present vs. relay-only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary
list_conversationspoll +list_participants) was only triggered once the first"prompt"WebSocket message arrived — i.e. after the caller's speech had already been transcribed. That latency (0.8s–4.1s observed locally, see data below) was paid serially, in front of memory retrieval and the LLM call, on every call._initialize_conversationin the background as soon as the"setup"WS message arrives (call_sidis known immediately), so CO's polling overlaps with the wait for the caller's first utterance (speech + ASR) instead of adding to it. The first"prompt"handler now just awaits the already-running (often already-finished) task.Reason
Investigated first-utterance latency on a local ConversationRelay + Conversation Orchestrator + Memory setup. Breaking down the first turn end-to-end showed CO's conversation lookup was a fixed serial cost on every call, unrelated to what the caller said, entirely avoidable by starting it earlier.
Solution
In
VoiceChannel.handle_websocket:"setup", if Conversation Orchestrator is enabled, start_initialize_conversation(...)viaasyncio.create_taskinstead of waiting."prompt",awaitthat task (instead of calling_initialize_conversationfresh) to getconv_id/session_state.finallycleanup path, handle the case where the call disconnects before any prompt arrives: cancel the task if it's still running, or — if it already completed and registered the session/websocket as a side effect — adopt itsconv_idso cleanup still runs instead of leaking the websocket registration. (The conversation entry itself intentionally stays in_conversationsuntil CO'sCLOSEDwebhook, same as any other orchestrator-mode call — unrelated to this change.)finallyblock'sCancelledErrorhandling only swallows the cancellation it caused itself (tracked via an explicitwe_cancelled_itflag, set when we callinit_task.cancel()); a real external cancellation (e.g. server shutdown) runs the same best-effort cleanup first, then propagates instead of being silently absorbed. (init_task.cancelled()looked like it could answer this without an extra flag, but it can't — cancelling this coroutine's own enclosing task while suspended onawait init_taskalso cancelsinit_taskas a side effect, making external cancellation indistinguishable from our own.)_POLL_ATTEMPTSfrom 5 to 10 and capped the exponential backoff via_POLL_MAX_DELAY(was unbounded — 10 attempts uncapped would balloon to ~2 minutes of sleep). The CO poll window used to be measured from the first prompt (i.e. after the caller had already started speaking, giving CO plenty of time); starting it at"setup"instead measures the same window from call-connect, which is earlier and leaves less slack — an early transient miss could exhaust all attempts before the caller even finishes speaking. The longer, capped window on the single background poll comfortably covers the wait for the first prompt too, soawait init_taskat the first prompt just waits out whatever's left rather than needing a second poll-and-give-up phase.Behavior change (flagged in review, addressed via docs)
In orchestrator mode,
_initialize_conversation's side effects (registering the session inself._conversationsand the websocket inWebSocketManager) can now happen before the caller has said anything, since they run as soon as the background CO lookup finishes rather than being gated on the first prompt. Concretely:get_conversation_session_by_call_sidmay now return a session beforeon_amdfires (undermachine_detection="Enable"), where it previously always returnedNoneat that point.end_callis unaffected — it already handles both cases. Updated the docstrings on both methods to reflect this.Given this changes a documented public-API guarantee, this should ship with a version bump.
Tests
test_setup_message_does_not_initialize_conversation(which asserted the old behavior) astest_setup_message_starts_background_conversation_init, which verifies:"setup", not"prompt"CLOSEDwebhook, matching existing_cleanup_connectionsemanticstest_error_when_no_conversations_found/test_error_when_multiple_conversations_foundupdated for the_POLL_ATTEMPTSbump (now assert 10 poll calls instead of 5).test_subsequent_prompts_reuse_conversationand other existing tests continue to pass unchanged.make check(ruff + mypy strict + pytest) — all green, no regressions.Data
Measured via a temporary local logging harness (not included in this PR) across real calls against a ConversationRelay + CO + Memory + streaming OpenAI Agents SDK setup.
Before (CO init triggered on first prompt, fully serial):
_initialize_conversationtotalAfter (CO init triggered on setup, in background), across 4 separate real calls, including one where the caller only said "hi" (shortest realistic case, smallest overlap margin):
_initialize_conversationtotal (background)already_done=True)already_done=True)already_done=True)already_done=True)In every case, by the time the first
"prompt"(i.e. transcribed speech) arrived, the background CO lookup had already finished — confirmed via a direct measurement of the wait at thatawaitpoint, not inferred from totals. Even the shortest realistic utterance ("hi") didn't erode the overlap margin enough to leak latency back onto the critical path in these tests, though the margin does shrink as the caller's first utterance gets shorter — worth keeping an eye on in production telemetry.Type of Change
get_conversation_session_by_call_sidguarantee no longer holds in orchestrator mode)get_conversation_session_by_call_sid/end_call)Checklist
SDK Parity
This is the Python SDK. If this change affects shared functionality, ensure the TypeScript SDK is updated as well.
asynciotask-scheduling change local toVoiceChannel; the behavior change noted above is Python-SDK-internal and doesn't correspond to shared cross-SDK semantics🤖 Generated with Claude Code