Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/warm-transfer-caller-hangup-cancel.md
Original file line number Diff line number Diff line change
@@ -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. 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.
134 changes: 128 additions & 6 deletions agents/src/workflows/warm_transfer.ts
Comment thread
toubatbrian marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
* 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.
*/
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
Expand All @@ -91,6 +97,12 @@ 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}.
* 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.
*/
export function createWarmTransferTask({
Expand All @@ -102,6 +114,7 @@ export function createWarmTransferTask({
dtmf,
ringingTimeout,
holdAudio = { source: BuiltinAudioClip.HOLD_MUSIC, volume: 0.8 },
callerHangupInstruction,
instructions,
chatCtx,
turnDetection,
Expand Down Expand Up @@ -146,11 +159,17 @@ 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;

// Resolves when the human agent room/session fails, so onEnter stops waiting.
const humanAgentFailedFut = new Future<void>();
// 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<void>();

// `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
Expand All @@ -174,6 +193,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
Expand Down Expand Up @@ -201,6 +225,71 @@ 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;
};

// 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): Promise<void> => {
try {
session.interrupt();
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) {
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) {
transferAgentSession = null;
humanAgentRoom = null;
hangupNotifySession = session;
void notifyHumanAgentOfHangup(session);
}
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;
Expand Down Expand Up @@ -388,6 +477,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 });
Expand Down Expand Up @@ -432,35 +526,60 @@ 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 });
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);
}

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');
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;
}
Expand Down Expand Up @@ -542,6 +661,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
Expand Down
Loading