Skip to content

fix(workflows): cancel warm transfer when caller hangs up before merge#2008

Open
toubatbrian wants to merge 5 commits into
mainfrom
claude/warm-transfer-hangup-cancel-419u07
Open

fix(workflows): cancel warm transfer when caller hangs up before merge#2008
toubatbrian wants to merge 5 commits into
mainfrom
claude/warm-transfer-hangup-cancel-419u07

Conversation

@toubatbrian

@toubatbrian toubatbrian commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Description

If the caller hangs up at the start of a warm transfer — before the transfer destination (human agent) picks up — the transfer is not cancelled. The human agent's phone keeps ringing, they answer, get the AI briefing, and when they confirm with connect_to_caller they are moved into a room the caller already left.

Root cause: WarmTransferTask only attached its caller-room ParticipantDisconnected listener inside the connect_to_caller tool, i.e. after the merge. Nothing watched the caller room during the ringing/briefing phase, so a caller hangup during that window went unnoticed.

This PR makes WarmTransferTask watch the caller room from onEnter. When the caller disconnects before the merge, the transfer is cancelled end to end: the pending SIP dial is aborted, the human agent room/session is torn down (which ends the outbound call via deleteRoomOnClose), caller-session IO is restored, and the task completes with a ToolError('caller hung up before the transfer completed'). If the human agent had already answered, the agent tells them the caller left before their call is ended, so the hangup isn't abrupt.

Changes Made

  • Attach a pre-merge ParticipantDisconnected listener on the caller room in onEnter (filtered to DEFAULT_PARTICIPANT_KINDS, same as the existing post-merge listener); it resolves a new callerHangupFut and completes the task via setResult.
  • Add callerHangupFut as a third branch of the dial race in onEnter, so a hangup while the human agent's phone is still ringing aborts the in-flight dial through the existing AbortController/cleanup path.
  • Notify a human agent who already answered: interrupt the briefing and generateReply with instructions that the caller hung up and the call is ending (new callerHangupInstruction option, built-in default when not provided; toolChoice: 'none' so the reply can't invoke tools; playout capped at 10s), then shut the session down.
  • Handle the edge windows: caller already gone when the task enters (listener attached, then remoteParticipants checked), caller hanging up while hold audio is starting, and the dial completing in the same tick as the hangup (session discarded via the existing finally cleanup instead of being kept).
  • Guard connect_to_caller with a task.done check so a hangup that lands while the human agent is confirming can't merge them into an empty room.
  • Detach the pre-merge listener in setResult so every completion path (merge, decline, voicemail, failure, hangup) ends the watch; connect_to_caller still attaches the post-merge cleanup listener as before.
  • Add a changeset (@livekit/agents patch).

Pre-Review Checklist

  • Build passes: pnpm build:agents, ESLint, and Prettier pass locally
  • AI-generated code reviewed: Removed unnecessary comments and ensured code quality
  • Changes explained: All changes are properly documented and justified above
  • Scope appropriate: All changes relate to the PR title
  • Video demo: N/A — SIP warm-transfer flow, not reproducible in Agent Playground

Testing

  • Automated tests added/updated (if applicable) — the workflows module currently has no unit-test harness; the fix is event wiring against a live room + SIP dial
  • All tests pass
  • Make sure both restaurant_agent.ts and realtime_agent.ts work properly (for major changes) — not a major change; only workflows/warm_transfer.ts is touched

Additional Notes

Manual verification scenarios for the reviewer (require a SIP trunk, e.g. via examples/src/warm_transfer.ts):

  1. Caller hangs up while the human agent's phone is ringing → ringing stops, human agent room is deleted, task rejects with ToolError.
  2. Caller hangs up during the briefing (after the human agent answered) → the human agent hears the agent explain the caller left, then their call ends, instead of being connected to an empty room.
  3. Normal flow (caller stays) → unchanged: hold music, briefing, connect_to_caller merge all behave as before.

🤖 Generated with Claude Code

https://claude.ai/code/session_012wowvUn9QorK87g8ytL5bE

The caller-room ParticipantDisconnected listener was only attached in
connect_to_caller, i.e. after the merge. If the caller hung up while the
human agent's phone was still ringing (or during the briefing), nothing
cancelled the transfer: the phone kept ringing and, on confirmation, the
human agent was moved into an empty room.

WarmTransferTask now watches the caller room from onEnter and, on caller
hangup before the merge, aborts the pending dial, tears down the human
agent room/session (ending the SIP call), and completes the task with a
ToolError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wowvUn9QorK87g8ytL5bE
@toubatbrian toubatbrian requested a review from a team as a code owner July 9, 2026 07:24
@changeset-bot

changeset-bot Bot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3b32198

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 36 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hedra Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

If the caller hung up while backgroundAudio.start() was awaiting the
track publish, setResult had already closed the player, but onEnter
resumed and called play() before the done guard — orphaning a play
handle nothing would ever stop. Check task.done between start() and
play() instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wowvUn9QorK87g8ytL5bE

@davidzhao davidzhao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

should we let the escalation point (remaining human) know that the caller has left? so the hang up is smoother

claude added 2 commits July 9, 2026 11:24
… the call

Per review feedback: when the caller hangs up after the human agent has
answered, interrupt the briefing and speak a short notice (configurable
via callerHangupMessage, null disables) before shutting the session
down, instead of dropping the call abruptly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wowvUn9QorK87g8ytL5bE
Replace the fixed say() message with generateReply() so the agent
phrases the notice in context, and rename the option to
callerHangupInstruction (built-in default instruction when not
provided). toolChoice is forced to 'none' so the reply can't invoke
connect_to_caller on an already-cancelled transfer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wowvUn9QorK87g8ytL5bE
devin-ai-integration[bot]

This comment was marked as resolved.

Guard the onEnter dial-cleanup path against closing a session that the
caller-hangup notification flow has taken over, so the announcement
can't be torn down mid-playout by a concurrent cleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wowvUn9QorK87g8ytL5bE

toubatbrian commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Good idea — implemented. If the human agent has already answered when the caller hangs up, the transfer agent interrupts the briefing and generates a reply telling them the caller left and the call is ending (via generateReply with toolChoice: 'none', so it speaks in context rather than reading a canned line), then ends the call. Playout is capped at 10s so teardown can't hang. The instruction is configurable via a new callerHangupInstruction option, with a built-in default when not provided. While the phone is still ringing, the dial is torn down immediately as before — there's nobody to announce to yet.

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.

4 participants