From f0234ac8924aa150700e6c7b02a6e19ff2a77c14 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 07:24:20 +0000 Subject: [PATCH 1/5] fix(workflows): cancel warm transfer when caller hangs up before merge 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 Claude-Session: https://claude.ai/code/session_012wowvUn9QorK87g8ytL5bE --- .../warm-transfer-caller-hangup-cancel.md | 5 ++ agents/src/workflows/warm_transfer.ts | 81 ++++++++++++++++++- 2 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 .changeset/warm-transfer-caller-hangup-cancel.md diff --git a/.changeset/warm-transfer-caller-hangup-cancel.md b/.changeset/warm-transfer-caller-hangup-cancel.md new file mode 100644 index 000000000..c0055e355 --- /dev/null +++ b/.changeset/warm-transfer-caller-hangup-cancel.md @@ -0,0 +1,5 @@ +--- +'@livekit/agents': patch +--- + +fix(workflows): cancel a warm transfer when the caller hangs up before the merge. `WarmTransferTask` now watches the caller room from `onEnter`: if the caller disconnects while the human agent's phone is still ringing or during the briefing, the pending SIP dial is aborted, the human agent room is torn down (ending the call), and the task completes with a `ToolError` — instead of the human agent being connected into an empty room. diff --git a/agents/src/workflows/warm_transfer.ts b/agents/src/workflows/warm_transfer.ts index 8a94426f1..71dcbd01d 100644 --- a/agents/src/workflows/warm_transfer.ts +++ b/agents/src/workflows/warm_transfer.ts @@ -91,6 +91,10 @@ type IoState = { * Build a warm-transfer {@link AgentTask} that dials a human agent over SIP, briefs them * in a private room, and (on confirmation) merges them into the caller room. * + * If the caller hangs up before the merge — including while the human agent's phone is + * still ringing — the transfer is cancelled: the pending dial is aborted, the human agent + * room is torn down (ending the SIP call), and the task completes with a {@link ToolError}. + * * This is the functional core; {@link WarmTransferTask} is a thin class wrapper over it. */ export function createWarmTransferTask({ @@ -151,6 +155,9 @@ export function createWarmTransferTask({ // Resolves when the human agent room/session fails, so onEnter stops waiting. const humanAgentFailedFut = new Future(); + // Resolves when the caller hangs up before the merge, so onEnter cancels a + // still-pending dial (e.g. while the human agent's phone is ringing). + const callerHangupFut = new Future(); // `task` is created at the end of this function. The helpers and tools below // only read it at runtime (inside their bodies), long after it's assigned, so @@ -174,6 +181,11 @@ export function createWarmTransferTask({ const setResult = (result: WarmTransferResult | Error): void => { if (task.done) return; + // Every completion path (merge, decline, voicemail, failure, hangup) ends + // the pre-merge hangup watch; connect_to_caller re-attaches its own + // post-merge cleanup listener. + callerRoom?.off(RoomEvent.ParticipantDisconnected, onCallerLeftBeforeMerge); + if (transferAgentSession) { // shutdown() triggers deleteRoomOnClose, which disconnects the human agent // room and frees its WebSocket. The human agent is already moved out @@ -201,6 +213,38 @@ export function createWarmTransferTask({ setResult(new ToolError(`room closed: ${reason}`)); }; + const hasCallerParticipant = (): boolean => { + if (!callerRoom) return false; + for (const participant of callerRoom.remoteParticipants.values()) { + if (DEFAULT_PARTICIPANT_KINDS.includes(participant.kind)) { + return true; + } + } + return false; + }; + + const cancelForCallerHangup = (participantIdentity?: string): void => { + logger.info( + { participantIdentity }, + 'caller hung up before the transfer completed, cancelling transfer', + ); + callerHangupFut.resolve(); + setResult(new ToolError('caller hung up before the transfer completed')); + }; + + // Pre-merge watch: cancels the transfer if the caller leaves while the human + // agent's phone is still ringing or they're being briefed. Without it the + // dial keeps going and the human agent gets merged into an empty room. + const onCallerLeftBeforeMerge = (participant: { + identity: string; + kind: ParticipantKind; + }): void => { + if (!DEFAULT_PARTICIPANT_KINDS.includes(participant.kind)) { + return; + } + cancelForCallerHangup(participant.identity); + }; + const onCallerParticipantDisconnected = (participant: { identity: string; kind: ParticipantKind; @@ -388,6 +432,11 @@ export function createWarmTransferTask({ if (!callerRoom) { throw new Error('caller room is not available'); } + if (task.done) { + // e.g. the caller hung up while the human agent was confirming; + // don't move them into an empty room. + throw new ToolError('the transfer was already cancelled'); + } await mergeCalls(); setResult({ humanAgentIdentity }); @@ -432,26 +481,50 @@ export function createWarmTransferTask({ jobCtx = getJobContext(); callerRoom = jobCtx.room; + callerRoom.on(RoomEvent.ParticipantDisconnected, onCallerLeftBeforeMerge); + if (!hasCallerParticipant()) { + // The caller was already gone before the listener could attach. + cancelForCallerHangup(); + return; + } + if (holdAudio !== null) { await backgroundAudio.start({ room: callerRoom }); holdAudioHandle = backgroundAudio.play(holdAudio, true); } + if (task.done) { + // The caller hung up while the hold audio was starting. + return; + } + setIoEnabled(false); - // Race the dial against a human-agent-room failure. AbortController lets - // the `finally` cancel a still-pending dial when the room dies first. + // Race the dial against a human-agent-room failure or a caller hangup. + // AbortController lets the `finally` cancel a still-pending dial when + // either of those wins the race. const abortController = new AbortController(); const dialPromise = dialHumanAgent(abortController.signal); try { const result = await Promise.race([ - dialPromise.then((session) => ({ session })), - humanAgentFailedFut.await.then(() => ({ session: null })), + dialPromise.then((session) => ({ session, callerHungUp: false })), + humanAgentFailedFut.await.then(() => ({ session: null, callerHungUp: false })), + callerHangupFut.await.then(() => ({ session: null, callerHungUp: true })), ]); + if (result.callerHungUp) { + // cancelForCallerHangup already completed the task; the `finally` + // below aborts the pending dial and tears down the half-built room. + return; + } if (!result.session) { throw new Error('human agent room closed'); } + if (task.done) { + // The caller hung up in the same tick the dial completed; leave + // `transferAgentSession` unset so the `finally` discards the session. + return; + } transferAgentSession = result.session; } catch (error) { logger.error({ error }, 'could not dial human agent'); From ac1f1cc04347391217e59672f6e82fa404f0d1ed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 08:56:03 +0000 Subject: [PATCH 2/5] fix: don't start hold playback after a caller hangup cancelled the task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012wowvUn9QorK87g8ytL5bE --- agents/src/workflows/warm_transfer.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/agents/src/workflows/warm_transfer.ts b/agents/src/workflows/warm_transfer.ts index 71dcbd01d..3dbf3e815 100644 --- a/agents/src/workflows/warm_transfer.ts +++ b/agents/src/workflows/warm_transfer.ts @@ -490,14 +490,14 @@ export function createWarmTransferTask({ if (holdAudio !== null) { await backgroundAudio.start({ room: callerRoom }); + if (task.done) { + // The caller hung up while the hold audio was starting; setResult + // already closed the player, so don't create an orphaned play handle. + return; + } holdAudioHandle = backgroundAudio.play(holdAudio, true); } - if (task.done) { - // The caller hung up while the hold audio was starting. - return; - } - setIoEnabled(false); // Race the dial against a human-agent-room failure or a caller hangup. From cfee3776eb04b22ec233112a8598c392dff34f83 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 11:24:16 +0000 Subject: [PATCH 3/5] feat: announce caller hangup to an answered human agent before ending 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 Claude-Session: https://claude.ai/code/session_012wowvUn9QorK87g8ytL5bE --- .../warm-transfer-caller-hangup-cancel.md | 2 +- agents/src/workflows/warm_transfer.ts | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/.changeset/warm-transfer-caller-hangup-cancel.md b/.changeset/warm-transfer-caller-hangup-cancel.md index c0055e355..440c6a7cc 100644 --- a/.changeset/warm-transfer-caller-hangup-cancel.md +++ b/.changeset/warm-transfer-caller-hangup-cancel.md @@ -2,4 +2,4 @@ '@livekit/agents': patch --- -fix(workflows): cancel a warm transfer when the caller hangs up before the merge. `WarmTransferTask` now watches the caller room from `onEnter`: if the caller disconnects while the human agent's phone is still ringing or during the briefing, the pending SIP dial is aborted, the human agent room is torn down (ending the call), and the task completes with a `ToolError` — instead of the human agent being connected into an empty room. +fix(workflows): cancel a warm transfer when the caller hangs up before the merge. `WarmTransferTask` now watches the caller room from `onEnter`: if the caller disconnects while the human agent's phone is still ringing or during the briefing, the pending SIP dial is aborted, the human agent room is torn down (ending the call), and the task completes with a `ToolError` — instead of the human agent being connected into an empty room. A human agent who already answered hears a short announcement (configurable via the new `callerHangupMessage` option, `null` disables) before their call is ended. diff --git a/agents/src/workflows/warm_transfer.ts b/agents/src/workflows/warm_transfer.ts index 3dbf3e815..809d89c31 100644 --- a/agents/src/workflows/warm_transfer.ts +++ b/agents/src/workflows/warm_transfer.ts @@ -65,6 +65,12 @@ export interface WarmTransferTaskOptions { ringingTimeout?: number | null; /** Audio played to the caller while they are on hold during the transfer. */ holdAudio?: AudioSourceType | AudioConfig | AudioConfig[] | null; + /** + * Message spoken to the human agent before their call is ended when the caller hangs up + * mid-transfer (after the human agent answered but before the merge). Set to `null` to end + * the call immediately without an announcement. + */ + callerHangupMessage?: string | null; /** * Instructions for the human agent briefing. Pass a full string to replace the built-in prompt * entirely, or {@link InstructionParts} to override individual sections (e.g. `persona`) while @@ -94,6 +100,8 @@ type IoState = { * If the caller hangs up before the merge — including while the human agent's phone is * still ringing — the transfer is cancelled: the pending dial is aborted, the human agent * room is torn down (ending the SIP call), and the task completes with a {@link ToolError}. + * A human agent who already answered hears {@link WarmTransferTaskOptions.callerHangupMessage} + * before their call is ended. * * This is the functional core; {@link WarmTransferTask} is a thin class wrapper over it. */ @@ -106,6 +114,7 @@ export function createWarmTransferTask({ dtmf, ringingTimeout, holdAudio = { source: BuiltinAudioClip.HOLD_MUSIC, volume: 0.8 }, + callerHangupMessage = 'Sorry, the caller hung up before the transfer could be completed. Ending the call now.', instructions, chatCtx, turnDetection, @@ -223,12 +232,41 @@ export function createWarmTransferTask({ return false; }; + // Announces the caller hangup to the human agent, then hangs up on them by + // shutting the session down (deleteRoomOnClose ends the SIP call). + const notifyHumanAgentOfHangup = async ( + session: AgentSession, + message: string, + ): Promise => { + try { + session.interrupt(); + const handle = session.say(message, { allowInterruptions: false }); + // Cap the wait so teardown can't hang on a stuck playout. + await waitUntilAborted(handle.waitForPlayout(), AbortSignal.timeout(10_000)); + } catch (error) { + logger.warn({ error }, 'failed to notify human agent of caller hangup'); + } finally { + session.shutdown(); + } + }; + const cancelForCallerHangup = (participantIdentity?: string): void => { + if (task.done) return; logger.info( { participantIdentity }, 'caller hung up before the transfer completed, cancelling transfer', ); callerHangupFut.resolve(); + + // If the human agent already answered, take the session out of setResult's + // reach and let them know before hanging up, instead of dropping the call + // on them mid-briefing. + const session = transferAgentSession; + if (session && callerHangupMessage !== null) { + transferAgentSession = null; + humanAgentRoom = null; + void notifyHumanAgentOfHangup(session, callerHangupMessage); + } setResult(new ToolError('caller hung up before the transfer completed')); }; From 47d435d5f1d123c46d0bcd5a2843768edb794a2c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 15:10:04 +0000 Subject: [PATCH 4/5] refactor: generate the caller-hangup notice via LLM instead of fixed TTS 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 Claude-Session: https://claude.ai/code/session_012wowvUn9QorK87g8ytL5bE --- .../warm-transfer-caller-hangup-cancel.md | 2 +- agents/src/workflows/warm_transfer.ts | 34 +++++++++++-------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/.changeset/warm-transfer-caller-hangup-cancel.md b/.changeset/warm-transfer-caller-hangup-cancel.md index 440c6a7cc..e28716629 100644 --- a/.changeset/warm-transfer-caller-hangup-cancel.md +++ b/.changeset/warm-transfer-caller-hangup-cancel.md @@ -2,4 +2,4 @@ '@livekit/agents': patch --- -fix(workflows): cancel a warm transfer when the caller hangs up before the merge. `WarmTransferTask` now watches the caller room from `onEnter`: if the caller disconnects while the human agent's phone is still ringing or during the briefing, the pending SIP dial is aborted, the human agent room is torn down (ending the call), and the task completes with a `ToolError` — instead of the human agent being connected into an empty room. A human agent who already answered hears a short announcement (configurable via the new `callerHangupMessage` option, `null` disables) before their call is ended. +fix(workflows): cancel a warm transfer when the caller hangs up before the merge. `WarmTransferTask` now watches the caller room from `onEnter`: if the caller disconnects while the human agent's phone is still ringing or during the briefing, the pending SIP dial is aborted, the human agent room is torn down (ending the call), and the task completes with a `ToolError` — instead of the human agent being connected into an empty room. A human agent who already answered is told the caller left — a reply generated from the new `callerHangupInstruction` option (built-in default when not provided) — before their call is ended. diff --git a/agents/src/workflows/warm_transfer.ts b/agents/src/workflows/warm_transfer.ts index 809d89c31..c25528553 100644 --- a/agents/src/workflows/warm_transfer.ts +++ b/agents/src/workflows/warm_transfer.ts @@ -66,11 +66,11 @@ export interface WarmTransferTaskOptions { /** Audio played to the caller while they are on hold during the transfer. */ holdAudio?: AudioSourceType | AudioConfig | AudioConfig[] | null; /** - * Message spoken to the human agent before their call is ended when the caller hangs up - * mid-transfer (after the human agent answered but before the merge). Set to `null` to end - * the call immediately without an announcement. + * Instructions used to generate the reply spoken to the human agent before their call is + * ended when the caller hangs up mid-transfer (after the human agent answered but before + * the merge). Falls back to a built-in instruction when not provided. */ - callerHangupMessage?: string | null; + callerHangupInstruction?: string | null; /** * Instructions for the human agent briefing. Pass a full string to replace the built-in prompt * entirely, or {@link InstructionParts} to override individual sections (e.g. `persona`) while @@ -100,8 +100,8 @@ type IoState = { * If the caller hangs up before the merge — including while the human agent's phone is * still ringing — the transfer is cancelled: the pending dial is aborted, the human agent * room is torn down (ending the SIP call), and the task completes with a {@link ToolError}. - * A human agent who already answered hears {@link WarmTransferTaskOptions.callerHangupMessage} - * before their call is ended. + * A human agent who already answered is told the caller left (a reply generated from + * {@link WarmTransferTaskOptions.callerHangupInstruction}) before their call is ended. * * This is the functional core; {@link WarmTransferTask} is a thin class wrapper over it. */ @@ -114,7 +114,7 @@ export function createWarmTransferTask({ dtmf, ringingTimeout, holdAudio = { source: BuiltinAudioClip.HOLD_MUSIC, volume: 0.8 }, - callerHangupMessage = 'Sorry, the caller hung up before the transfer could be completed. Ending the call now.', + callerHangupInstruction, instructions, chatCtx, turnDetection, @@ -234,13 +234,16 @@ export function createWarmTransferTask({ // Announces the caller hangup to the human agent, then hangs up on them by // shutting the session down (deleteRoomOnClose ends the SIP call). - const notifyHumanAgentOfHangup = async ( - session: AgentSession, - message: string, - ): Promise => { + const notifyHumanAgentOfHangup = async (session: AgentSession): Promise => { try { session.interrupt(); - const handle = session.say(message, { allowInterruptions: false }); + const handle = session.generateReply({ + instructions: callerHangupInstruction ?? CALLER_HANGUP_INSTRUCTION, + allowInterruptions: false, + // The transfer is already cancelled; the reply must speak, not call + // connect_to_caller/decline_transfer. + toolChoice: 'none', + }); // Cap the wait so teardown can't hang on a stuck playout. await waitUntilAborted(handle.waitForPlayout(), AbortSignal.timeout(10_000)); } catch (error) { @@ -262,10 +265,10 @@ export function createWarmTransferTask({ // reach and let them know before hanging up, instead of dropping the call // on them mid-briefing. const session = transferAgentSession; - if (session && callerHangupMessage !== null) { + if (session) { transferAgentSession = null; humanAgentRoom = null; - void notifyHumanAgentOfHangup(session, callerHangupMessage); + void notifyHumanAgentOfHangup(session); } setResult(new ToolError('caller hung up before the transfer completed')); }; @@ -653,6 +656,9 @@ function formatConversationHistory(chatCtx?: ChatContext): string { return previousConversation; } +const CALLER_HANGUP_INSTRUCTION = `The caller has hung up before the transfer could be completed. +Briefly inform the human agent that the caller has left and that you are ending the call now.`; + const PERSONA = `# Identity You are an agent that is reaching out to a human agent for help. There has been a previous conversation From 3b32198e85b1afd29e322f29cce935e3963564bd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 15:21:10 +0000 Subject: [PATCH 5/5] fix: make hangup-notify session ownership explicit 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 Claude-Session: https://claude.ai/code/session_012wowvUn9QorK87g8ytL5bE --- agents/src/workflows/warm_transfer.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/agents/src/workflows/warm_transfer.ts b/agents/src/workflows/warm_transfer.ts index c25528553..09ed9d115 100644 --- a/agents/src/workflows/warm_transfer.ts +++ b/agents/src/workflows/warm_transfer.ts @@ -159,6 +159,9 @@ export function createWarmTransferTask({ // creation, so getJobContext() would read an empty/stale store there. let jobCtx: JobContext | null = null; let transferAgentSession: AgentSession | null = null; + // Session handed off to the caller-hangup notification flow, which owns its + // teardown from that point on — no other cleanup path may close it. + let hangupNotifySession: AgentSession | null = null; let holdAudioHandle: PlayHandle | null = null; let originalIoState: IoState | null = null; @@ -268,6 +271,7 @@ export function createWarmTransferTask({ if (session) { transferAgentSession = null; humanAgentRoom = null; + hangupNotifySession = session; void notifyHumanAgentOfHangup(session); } setResult(new ToolError('caller hung up before the transfer completed')); @@ -572,9 +576,10 @@ export function createWarmTransferTask({ setResult(new ToolError('could not dial human agent')); } finally { abortController.abort(); - // If the dial won the race we kept its session; otherwise discard it. + // If the dial won the race we kept its session; if the hangup-notify + // flow took it, that flow shuts it down; otherwise discard it. const session = await dialPromise.catch(() => null); - if (session && transferAgentSession !== session) { + if (session && transferAgentSession !== session && hangupNotifySession !== session) { await cleanupHumanAgentDial(session, humanAgentRoom); humanAgentRoom = null; }