You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
What applications need from a failed warm transfer
When a warm transfer does not complete, the application has to make four decisions, and each one depends on why it failed:
what to say to the caller: the human declined, reached voicemail, did not answer, or the line dropped after connecting;
whether to retry or try another destination: a busy or no-answer SIP status is retryable, a decline or voicemail is not;
whether to alert: a declined transfer is a normal outcome, a trunk failure or an unexpected exception is an incident;
how to report it: transfer dashboards need failures split by cause, not one bucket.
Today every failure arrives as a ToolError with a message string, so the only way to make those decisions is to match message text. In our deployment that produced a failed-transfer rate of several percent on one destination that we could not diagnose from the task result, and every one of those failures was recorded as an error in traces even though most were ordinary hangups.
WarmTransferTask resolves every unsuccessful transfer as a ToolError whose only content is a message string. Each terminal path discards structured information the task already holds:
Path
Today
Information discarded
Dial failed
ToolError("could not dial human agent")
The api.SipCallError from create_sip_participant, including sip_status_code / sip_status (busy, no answer, decline, trunk failure)
The room-closed path has a second problem beyond typing. The task only listens for its own connection to the human-agent room dropping. The consult session runs with close_on_disconnect and delete_room_on_close, so when the human-agent SIP participant leaves, the session closes, the room is deleted, and the task reports ROOM_DELETED. That is the cleanup reason, not the cause. The participant's disconnect_reason (CLIENT_INITIATED for a clean disconnect after connection, or USER_UNAVAILABLE / USER_REJECTED / SIP_TRUNK_FAILURE before connection) is never read.
This is inconsistent with how the rest of the platform reports SIP outcomes. The outbound-call docs tell users to branch on SipCallError.sip_status_code, and the SIP participant reference documents a disconnect_reason table that already distinguishes pre-connection from post-connection failures:
The warm-transfer task is the one place where the SDK hides exactly those facts from the application.
Proposal
Deliver a ToolError subclass that carries provenance (which task path ended the transfer) plus the platform facts the task already observed. No interpretation, no new taxonomy, message strings unchanged, transfer lifecycle unchanged.
classWarmTransferFailure(str, Enum):
DIAL_FAILED="dial_failed"# __cause__ is the api.SipCallError when availableDESTINATION_LEFT="destination_left"# human-agent participant disconnected before mergeDECLINED="declined"# decline_transfer toolVOICEMAIL="voicemail"# voicemail_detected toolROOM_CLOSED="room_closed"# the task's own consult-room connection droppedCALLER_LEFT="caller_left"# Node only today: caller disconnected before mergeclassWarmTransferError(ToolError):
code: WarmTransferFailuredisconnect_reason: rtc.DisconnectReason.ValueType|None=None# destination_left, room_closedcall_status: str|None=None# sip.callStatus at departurereason: str|None=None# declined
Requested contract
Record, do not complete. Subscribe to participant_disconnected on the human-agent room for the SIP participant identity. That listener only records participant.disconnect_reason and participant.attributes.get("sip.callStatus"). It never completes the task. The existing room-close handler and the dial-rejection handler remain the only completers and use the recorded facts to pick the code: DESTINATION_LEFT when the participant's departure was recorded, ROOM_CLOSED otherwise, DIAL_FAILED when the dial rejected. This keeps completion timing as it is today, and it avoids the race during dialing where a pre-connection departure and a SipCallError rejection arrive together and the SIP status would otherwise be lost. Explicit decline, voicemail, caller cancellation, and abort keep their current reasons and timing.
Preserve the transfer lifecycle around the participant move. A successful move_participant makes the SIP participant leave the consult room, so the listener must be detached before the move and re-attached if the move fails, mirroring how the room-close observer is already handled. fix: handle room disconnect after failed warm transfer agents-js#2361 fixed a hang at exactly that boundary when the close observer stayed detached after a failed move. A departure recorded before the move must not be reported as a failure after a successful handoff.
Preserve the underlying cause through the existing completion mechanism. The dial handler currently passes a fresh ToolError to _set_result. Keep that mechanism, but have the delivered exception carry the caught SipCallError as __cause__ (cause on Node) so sip_status_code / sip_status survive. The Twilio connector subclass already chains its ringing-timeout cause.
Keep message strings unchanged so the LLM-facing text and any except ToolError handlers keep working.
Regression coverage worth asking for: successful handoff records no failure; failed move restores observation and still completes on a later departure; destination departure followed by room deletion reports DESTINATION_LEFT with the participant's reason rather than ROOM_DELETED; pre-connection dial rejection reports DIAL_FAILED with the SIP status intact.
Whether declined and voicemail should eventually be a resolved outcome rather than a raised error is a separate contract question. A subclass is additive and does not preclude that later.
Workarounds / Alternatives
Match on message text ("room closed", "voicemail detected", "declined"). Brittle, undocumented, and it still cannot recover the SIP status or the participant's disconnect reason because they were never captured.
Classify by exception type only. This is what we ship today: an opaque ToolError rejection becomes an "unknown" failure with the message kept in logs, an unexpected non-ToolError exception stays a technical error, and the timeout and caller-disconnect outcomes we can observe ourselves stay separate. It stops false error alerts, but it cannot tell a destination leg that connected and then disconnected before handoff from one that failed before connection with SIP 480, 486, or 603.
Fork the task to add the participant listener and the typed error. Duplicates SDK-owned lifecycle logic and drifts on every release.
Additional Context
Evidence, from Node @livekit/agents 1.8.0 in production on 2026-09-08 between 07:00 and 21:14:32 UTC: 561 distinct warm transfers ended in room closed: 5 (ROOM_DELETED). Room events for sampled calls show the human-agent SIP participant leaving with CLIENT_INITIATED roughly 600 ms before the task observed ROOM_DELETED. Connected destination legs lasted from a few seconds to several minutes before disconnecting. CLIENT_INITIATED establishes only that the leg connected and then disconnected cleanly before handoff; it does not establish that a person answered. None of that is recoverable from the task result today. The Python task is cited here as a source comparison; the production data is from Node.
Node parity: agents-js has the identical string design at the same sites plus a caller-hangup-before-merge path that Python does not have (main @ b11b7b4: https://github.com/livekit/agents-js/blob/b11b7b4d0d734082b3d2d21a447b0b093b4482fc/agents/src/workflows/warm_transfer.ts#L286, #L362, #L617, #L626, #L707). The same subclass shape applies. livekit-server-sdk 2.17.0 added SipCallError with sipStatusCode / sipStatus; agents-js currently pins ^2.14.1, so typing the dial cause there needs that floor raised. @livekit/rtc-node 0.13.34, which agents-js pins, already exposes RemoteParticipant.disconnectReason.
Possible Python parity gap, unverified at runtime: _merge_calls detaches the room-close observer before the move, and the connect_to_caller tool has no re-attach on a failed move, which is the shape fix: handle room disconnect after failed warm transfer agents-js#2361 fixed on Node. The regression coverage above would catch it either way.
Precedent for a structured ToolError subclass already exists in the Node task-group workflow (OutOfScopeError carrying targetTaskIds).
Environment for the evidence: Node.js 22.x, @livekit/agents 1.8.0, livekit-server-sdk 2.17.0, inbound SIP caller with an outbound SIP warm transfer via a stored trunk. Python livekit-api 1.2.x also has SipCallError, and livekit-rtc exposes RemoteParticipant.disconnect_reason.
Feature Type
Would make my life easier
Feature Description
What applications need from a failed warm transfer
When a warm transfer does not complete, the application has to make four decisions, and each one depends on why it failed:
Today every failure arrives as a
ToolErrorwith a message string, so the only way to make those decisions is to match message text. In our deployment that produced a failed-transfer rate of several percent on one destination that we could not diagnose from the task result, and every one of those failures was recorded as an error in traces even though most were ordinary hangups.WarmTransferTaskresolves every unsuccessful transfer as aToolErrorwhose only content is a message string. Each terminal path discards structured information the task already holds:ToolError("could not dial human agent")api.SipCallErrorfromcreate_sip_participant, includingsip_status_code/sip_status(busy, no answer, decline, trunk failure)ToolError(f"room closed: {DisconnectReason.Name(reason)}")disconnect_reasonandsip.callStatusat departureToolError(f"human agent declined to connect: {reason}")ToolError("voicemail detected")Permalinks (main @ 3334771):
agents/livekit-agents/livekit/agents/beta/workflows/warm_transfer.py
Line 223 in 3334771
agents/livekit-agents/livekit/agents/beta/workflows/warm_transfer.py
Line 248 in 3334771
agents/livekit-agents/livekit/agents/beta/workflows/warm_transfer.py
Line 253 in 3334771
agents/livekit-agents/livekit/agents/beta/workflows/warm_transfer.py
Line 263 in 3334771
The room-closed path has a second problem beyond typing. The task only listens for its own connection to the human-agent room dropping. The consult session runs with
close_on_disconnectanddelete_room_on_close, so when the human-agent SIP participant leaves, the session closes, the room is deleted, and the task reportsROOM_DELETED. That is the cleanup reason, not the cause. The participant'sdisconnect_reason(CLIENT_INITIATEDfor a clean disconnect after connection, orUSER_UNAVAILABLE/USER_REJECTED/SIP_TRUNK_FAILUREbefore connection) is never read.This is inconsistent with how the rest of the platform reports SIP outcomes. The outbound-call docs tell users to branch on
SipCallError.sip_status_code, and the SIP participant reference documents adisconnect_reasontable that already distinguishes pre-connection from post-connection failures:The warm-transfer task is the one place where the SDK hides exactly those facts from the application.
Proposal
Deliver a
ToolErrorsubclass that carries provenance (which task path ended the transfer) plus the platform facts the task already observed. No interpretation, no new taxonomy, message strings unchanged, transfer lifecycle unchanged.Requested contract
Record, do not complete. Subscribe to
participant_disconnectedon the human-agent room for the SIP participant identity. That listener only recordsparticipant.disconnect_reasonandparticipant.attributes.get("sip.callStatus"). It never completes the task. The existing room-close handler and the dial-rejection handler remain the only completers and use the recorded facts to pick the code:DESTINATION_LEFTwhen the participant's departure was recorded,ROOM_CLOSEDotherwise,DIAL_FAILEDwhen the dial rejected. This keeps completion timing as it is today, and it avoids the race during dialing where a pre-connection departure and aSipCallErrorrejection arrive together and the SIP status would otherwise be lost. Explicit decline, voicemail, caller cancellation, and abort keep their current reasons and timing.Preserve the transfer lifecycle around the participant move. A successful
move_participantmakes the SIP participant leave the consult room, so the listener must be detached before the move and re-attached if the move fails, mirroring how the room-close observer is already handled. fix: handle room disconnect after failed warm transfer agents-js#2361 fixed a hang at exactly that boundary when the close observer stayed detached after a failed move. A departure recorded before the move must not be reported as a failure after a successful handoff.Preserve the underlying cause through the existing completion mechanism. The dial handler currently passes a fresh
ToolErrorto_set_result. Keep that mechanism, but have the delivered exception carry the caughtSipCallErroras__cause__(causeon Node) sosip_status_code/sip_statussurvive. The Twilio connector subclass already chains its ringing-timeout cause.Keep message strings unchanged so the LLM-facing text and any
except ToolErrorhandlers keep working.Regression coverage worth asking for: successful handoff records no failure; failed move restores observation and still completes on a later departure; destination departure followed by room deletion reports
DESTINATION_LEFTwith the participant's reason rather thanROOM_DELETED; pre-connection dial rejection reportsDIAL_FAILEDwith the SIP status intact.Whether declined and voicemail should eventually be a resolved outcome rather than a raised error is a separate contract question. A subclass is additive and does not preclude that later.
Workarounds / Alternatives
"room closed","voicemail detected","declined"). Brittle, undocumented, and it still cannot recover the SIP status or the participant's disconnect reason because they were never captured.ToolErrorrejection becomes an "unknown" failure with the message kept in logs, an unexpected non-ToolErrorexception stays a technical error, and the timeout and caller-disconnect outcomes we can observe ourselves stay separate. It stops false error alerts, but it cannot tell a destination leg that connected and then disconnected before handoff from one that failed before connection with SIP 480, 486, or 603.Additional Context
@livekit/agents1.8.0 in production on 2026-09-08 between 07:00 and 21:14:32 UTC: 561 distinct warm transfers ended inroom closed: 5(ROOM_DELETED). Room events for sampled calls show the human-agent SIP participant leaving withCLIENT_INITIATEDroughly 600 ms before the task observedROOM_DELETED. Connected destination legs lasted from a few seconds to several minutes before disconnecting.CLIENT_INITIATEDestablishes only that the leg connected and then disconnected cleanly before handoff; it does not establish that a person answered. None of that is recoverable from the task result today. The Python task is cited here as a source comparison; the production data is from Node.agents-jshas the identical string design at the same sites plus a caller-hangup-before-merge path that Python does not have (main @ b11b7b4: https://github.com/livekit/agents-js/blob/b11b7b4d0d734082b3d2d21a447b0b093b4482fc/agents/src/workflows/warm_transfer.ts#L286, #L362, #L617, #L626, #L707). The same subclass shape applies.livekit-server-sdk2.17.0 addedSipCallErrorwithsipStatusCode/sipStatus;agents-jscurrently pins^2.14.1, so typing the dial cause there needs that floor raised.@livekit/rtc-node0.13.34, whichagents-jspins, already exposesRemoteParticipant.disconnectReason._merge_callsdetaches the room-close observer before the move, and theconnect_to_callertool has no re-attach on a failed move, which is the shape fix: handle room disconnect after failed warm transfer agents-js#2361 fixed on Node. The regression coverage above would catch it either way.ToolErrorsubclass already exists in the Node task-group workflow (OutOfScopeErrorcarryingtargetTaskIds).@livekit/agents1.8.0,livekit-server-sdk2.17.0, inbound SIP caller with an outbound SIP warm transfer via a stored trunk. Pythonlivekit-api1.2.x also hasSipCallError, andlivekit-rtcexposesRemoteParticipant.disconnect_reason.