Skip to content

perf(voice): start CO conversation lookup on setup, not first prompt - #103

Merged
xinghaohuang91 merged 4 commits into
mainfrom
perf/voice-early-co-init
Aug 14, 2026
Merged

perf(voice): start CO conversation lookup on setup, not first prompt#103
xinghaohuang91 merged 4 commits into
mainfrom
perf/voice-early-co-init

Conversation

@xinghaohuang91

@xinghaohuang91 xinghaohuang91 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

  • On every voice call's first turn, the Conversation Orchestrator (CO) conversation lookup (list_conversations poll + 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.
  • ConversationRelay creates the CO conversation as soon as the call connects, not when the caller speaks — there's no reason to wait for the first prompt before starting the lookup.
  • This PR kicks off _initialize_conversation in the background as soon as the "setup" WS message arrives (call_sid is 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:

  • On "setup", if Conversation Orchestrator is enabled, start _initialize_conversation(...) via asyncio.create_task instead of waiting.
  • On the first "prompt", await that task (instead of calling _initialize_conversation fresh) to get conv_id/session_state.
  • In the finally cleanup 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 its conv_id so cleanup still runs instead of leaking the websocket registration. (The conversation entry itself intentionally stays in _conversations until CO's CLOSED webhook, same as any other orchestrator-mode call — unrelated to this change.)
  • The finally block's CancelledError handling only swallows the cancellation it caused itself (tracked via an explicit we_cancelled_it flag, set when we call init_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 on await init_task also cancels init_task as a side effect, making external cancellation indistinguishable from our own.)
  • Raised _POLL_ATTEMPTS from 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, so await init_task at 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 in self._conversations and the websocket in WebSocketManager) 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_sid may now return a session before on_amd fires (under machine_detection="Enable"), where it previously always returned None at that point. end_call is 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

  • Rewrote test_setup_message_does_not_initialize_conversation (which asserted the old behavior) as test_setup_message_starts_background_conversation_init, which verifies:
    • the CO lookup runs to completion starting from "setup", not "prompt"
    • if the call disconnects before any prompt arrives, the websocket registration is still cleaned up (not leaked), while the conversation entry legitimately stays tracked until CO's CLOSED webhook, matching existing _cleanup_connection semantics
  • test_error_when_no_conversations_found / test_error_when_multiple_conversations_found updated for the _POLL_ATTEMPTS bump (now assert 10 poll calls instead of 5). test_subsequent_prompts_reuse_conversation and other existing tests continue to pass unchanged.
  • Full suite: 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):

Stage Duration
CO conversation poll 452ms
CO list_participants 314ms
_initialize_conversation total 768ms — paid in full on the critical path

After (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):

Call _initialize_conversation total (background) Added latency at first prompt
1 4136ms 0ms (already_done=True)
2 3595ms 0ms (already_done=True)
3 (said "hi") 4351ms 0ms (already_done=True)
4 (said "hi") 4122ms 0ms (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 that await point, 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

  • Bug fix
  • New feature
  • Breaking change (see "Behavior change" above — a documented get_conversation_session_by_call_sid guarantee no longer holds in orchestrator mode)
  • Documentation update (docstrings for get_conversation_session_by_call_sid / end_call)
  • Refactoring (CO lookup scheduling)
  • Release / version bump

Checklist

  • Tests added/updated
  • Documentation updated
  • Tested E2E (real phone calls against ConversationRelay + CO + Memory)

SDK Parity

This is the Python SDK. If this change affects shared functionality, ensure the TypeScript SDK is updated as well.

  • Change is Python-specific (no TypeScript update needed) — asyncio task-scheduling change local to VoiceChannel; the behavior change noted above is Python-SDK-internal and doesn't correspond to shared cross-SDK semantics
  • TypeScript SDK PR created:

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 13, 2026 20:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 background asyncio task 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.

Comment thread src/tac/channels/voice/channel.py
Comment thread src/tac/channels/voice/channel.py
@xinghaohuang91
xinghaohuang91 force-pushed the perf/voice-early-co-init branch from 7c54fc8 to 4967c0f Compare August 13, 2026 22:08
xinghaohuang91 added a commit that referenced this pull request Aug 13, 2026
- 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>
@xinghaohuang91
xinghaohuang91 requested a balanced review from Copilot August 13, 2026 22:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.0 in pyproject.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.

Comment thread src/tac/channels/voice/channel.py Outdated
Comment thread src/tac/channels/voice/channel.py

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_it only proves that this code called init_task.cancel(); it does not prove that the CancelledError caught 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 via asyncio.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_task before the await creates a cleanup race during external cancellation. If _initialize_conversation finishes (and registers the session/WebSocket) just as this handler is cancelled, the await can raise CancelledError before assigning conv_id; finally then 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, so finally can 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

xinghaohuang91 and others added 3 commits August 14, 2026 09:00
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>
@xinghaohuang91
xinghaohuang91 force-pushed the perf/voice-early-co-init branch from 6412093 to c83b600 Compare August 14, 2026 16:00
Comment thread src/tac/channels/voice/channel.py Outdated
task_to_await = init_task
init_task = None
conv_id, session_state = await task_to_await
elif self.tac.is_orchestrator_enabled():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@xinghaohuang91
xinghaohuang91 merged commit cdfa9bb into main Aug 14, 2026
16 checks passed
@xinghaohuang91
xinghaohuang91 deleted the perf/voice-early-co-init branch August 14, 2026 16:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants