fix(workflows): forward Twilio CallToken during warm transfers - #7309
piyush-gambhir wants to merge 5 commits into
Conversation
| with contextlib.suppress(asyncio.CancelledError): | ||
| await asyncio.wait({task}, timeout=remaining) |
There was a problem hiding this comment.
🟡 Failed transfer swallows cancellation
_wait_for_twilio_cleanup swallows cancellation when an unanswered transfer is already handling ToolError. The task later raises ToolError, so shutdown cannot observe its requested cancellation.
Learn more
Cleanup runs both while propagating an existing cancellation and while propagating an ordinary transfer failure. Suppressing CancelledError is necessary only in the first case. In the second case, a new cancellation must replace the pending failure under normal asyncio task semantics. The background task already remains owned by _twilio_cleanup_tasks, so propagating that new cancellation does not abandon cleanup.
Example: _wait_for_human_agent raises ToolError after no answer. While the Twilio cancellation request is blocked, worker shutdown calls dial.cancel(). The task waits up to five seconds and reports ToolError instead of CancelledError.
Recommended fix: Make cancellation suppression conditional on whether the caller entered cleanup because of CancelledError. For cleanup entered from another exception, leave the owned task running in _twilio_cleanup_tasks and propagate any new cancellation immediately.
Was this helpful? React with 👍 or 👎 to provide feedback.
A warm transfer creates a new outbound call, so human recipients normally see the business number rather than the inbound customer's number. This adds per-call caller-ID preservation through Twilio's CallToken, while keeping the business number as the default and fallback. It lets applications use the SDK's consultation and cleanup flow instead of replacing Twilio call creation to preserve customer identity.
API and behavior
twilio_from_numberstays the agent/business Twilio number or verified caller ID.original_caller_numbercarries the original inbound webhook'sFrom.twilio_call_tokenopts into preservation and requires the matching original caller number. No separate enable flag or fallback-number option is needed.Keep the number/token pair in server-side state keyed by the incoming
CallSid, obtained from a validated Twilio webhook. Tokens are not included in connector requests, TwiML, prompts, or participant attributes. The token is not permission to choose arbitrary caller IDs.This can help recipients identify the customer and support caller-ID-based lookup/callback records; the PR does not implement CRM matching. Caller-ID presentation remains subject to Twilio acceptance and downstream carrier behavior. The existing Calls API is used, with no conference or LiveKit server API change.
Twilio references
From,CallSid, andCallToken.JavaScript counterpart: livekit/agents-js#2510.
Compatibility and validation
calls.createsignature before connector creation/dialing and gives an upgrade instruction if unsupported. Token-free transfers still work with older clients.make fix, repository Ruff checks, and focused strict mypy passed. Fullmake checkremains blocked at optional plugin typechecking (livekit.plugins.anammissing its type marker in this core-only environment).Live QA — 2026-09-16, current fallback behavior
Ran both local SDK workers against UAT LiveKit Cloud and real Twilio calls. Two temporary phone numbers were used: a customer/receiver in a temporary subaccount and a business number in the SDK account. This makes the customer's number unverified in the dialing account. Each SDK passed all four cases below (8/8 total):
Every successful case had one receiver webhook and published audio tracks for the customer and transferred human in the caller room. The invalid-token Python attempt also recorded the SDK's explicit caller-ID fallback warning. Unit tests verify both request bodies, the exact single-retry limit, and failure exclusions.
One Python harness attempt raced room initialization before dialing. After adding an explicit room connection in the harness, that case passed using the same temporary numbers; interrupted harness attempts are excluded from the final matrix. No SDK behavior was changed to work around that harness issue.
Independent API reads confirmed both purchased numbers released (404), the temporary subaccount closed, 38 associated call legs checked with zero active, and zero QA rooms remaining. Local QA workers and the webhook tunnel were stopped.
Earlier live QA on the pre-fallback revision also covered supervisor decline, no answer, busy/rejected calls, and caller hangup during ringing in both SDKs. Those lifecycle cases are historical validation, not a fresh full-matrix run of this revision. Current regression tests cover cancellation of an unanswered fallback call.
Limits: automated endpoints verify caller-ID signaling and published audio tracks, not physical handset display, subjective two-way audio quality, or every carrier. Telnyx is not included in these results.
Cancellation and cleanup deadlines
The synchronous Twilio HTTP request keeps running if its
asyncio.to_threadawait is cancelled. Initial and fallback creation retain the pending result so a late call SID can still be canceled. Cleanup has a five-second absolute wait deadline, which repeated cancellation cannot reset. After that deadline, transfer teardown proceeds and a strongly referenced background task continues best-effort cleanup while the worker is alive. Cancellation of an already-created, unanswered call uses the same bounded wait.Twilio's HTTP client is configured with a ten-second socket timeout for creation and cancellation requests. This is separate from the teardown deadline; it is not a guarantee of total wall-clock request duration. Network/provider failure or worker termination can still prevent cleanup, and an ambiguous failed creation request is never redialed.
The focused suite passes 32 tests. Four creation-cancellation regression cases cover initial/fallback requests, late success/rejection, and repeated cancellation. Two additional real-thread barrier tests hold creation or cancellation stalled, verify teardown completes before releasing the blocked worker, then verify late cleanup completes and its background ownership is released. Ruff and focused strict mypy pass. Full repository typechecking remains blocked by the optional-plugin environment limitation described above.
These cancellation and deadline fixes were tested deterministically, without another live PSTN run. The live caller-ID matrix above applies to the preceding fallback revision. JavaScript does not use Python's thread-cancellation mechanism; this change is scoped to the Python review finding.