Skip to content

WarmTransferTask: report a typed failure reason with the underlying SIP evidence preserved #7200

Description

@dtran26

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:

  • 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)
Human agent room closed ToolError(f"room closed: {DisconnectReason.Name(reason)}") The human-agent SIP participant's own disconnect_reason and sip.callStatus at departure
Declined ToolError(f"human agent declined to connect: {reason}") The decline reason as a field
Voicemail ToolError("voicemail detected") Nothing structural, but it is indistinguishable from an error without string matching

Permalinks (main @ 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_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.

class WarmTransferFailure(str, Enum):
    DIAL_FAILED = "dial_failed"            # __cause__ is the api.SipCallError when available
    DESTINATION_LEFT = "destination_left"  # human-agent participant disconnected before merge
    DECLINED = "declined"                  # decline_transfer tool
    VOICEMAIL = "voicemail"                # voicemail_detected tool
    ROOM_CLOSED = "room_closed"            # the task's own consult-room connection dropped
    CALLER_LEFT = "caller_left"            # Node only today: caller disconnected before merge


class WarmTransferError(ToolError):
    code: WarmTransferFailure
    disconnect_reason: rtc.DisconnectReason.ValueType | None = None  # destination_left, room_closed
    call_status: str | None = None                                    # sip.callStatus at departure
    reason: str | None = None                                         # declined

Requested contract

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions