Skip to content
Open
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/port-end-call-tool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents': minor
---

Add beta EndCallTool for ending calls from agent tools
7 changes: 7 additions & 0 deletions agents/src/beta/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,10 @@ export {
type InstructionParts,
} from './workflows/index.js';
export { Instructions } from '../llm/index.js';
export {
END_CALL_DESCRIPTION,
EndCallTool,
type EndCallToolCalledEvent,
type EndCallToolCompletedEvent,
type EndCallToolOptions,
} from './tools/index.js';
170 changes: 170 additions & 0 deletions agents/src/beta/tools/end_call.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
import { getJobContext } from '../../job.js';
import {
RealtimeModel,
type ToolCalledEvent,
type ToolCompletedEvent,
Toolset,
tool,
} from '../../llm/index.js';
import { log } from '../../log.js';
import {
AgentSessionEventTypes,
type CloseEvent,
type SpeechCreatedEvent,
} from '../../voice/events.js';
import type { RunContext, UnknownUserData } from '../../voice/run_context.js';

export const END_CALL_DESCRIPTION = `
Ends the current call and disconnects immediately.

Call when:
- The user clearly indicates they are done (e.g., "that's all, bye").

Do not call when:
- The user asks to pause, hold, or transfer.
- Intent is unclear.

This is the final action the agent can take.
Once called, no further interaction is possible with the user.
Don't generate any other text or response when the tool is called.
`;

export type EndCallToolCalledEvent<UserData = UnknownUserData> = ToolCalledEvent<UserData>;

export type EndCallToolCompletedEvent<UserData = UnknownUserData> = ToolCompletedEvent<UserData>;

export type EndCallToolOptions<UserData = UnknownUserData> = {
/** Additional description to add to the end call tool. */
extraDescription?: string;
/**
* Whether to delete the room when the user ends the call.
* Deleting the room disconnects all remote users, including SIP callers.
*/
deleteRoom?: boolean;
/** Tool output to the LLM for generating the tool response. */
endInstructions?: string | null;
/** Callback to call when the tool is called. */
onToolCalled?: (event: EndCallToolCalledEvent<UserData>) => Promise<void> | void;
/** Callback to call when the tool is completed. */
onToolCompleted?: (event: EndCallToolCompletedEvent<UserData>) => Promise<void> | void;
};

/**
* Allows the agent to end the call and disconnect from the room.
*/
export class EndCallTool<UserData = UnknownUserData> extends Toolset {
private readonly deleteRoom: boolean;
private readonly endInstructions: string | null;
private readonly onToolCalled?: (event: EndCallToolCalledEvent<UserData>) => Promise<void> | void;
private readonly onToolCompleted?: (
event: EndCallToolCompletedEvent<UserData>,
) => Promise<void> | void;
private shutdownSessionTimeout: NodeJS.Timeout | undefined;

constructor({
extraDescription = '',
deleteRoom = true,
endInstructions = 'say goodbye to the user',
onToolCalled,
onToolCompleted,
}: EndCallToolOptions<UserData> = {}) {
const handlers: {
endCall?: (ctx: RunContext<UserData>) => Promise<string | undefined>;
} = {};
const endCallTool = tool<UserData, string | undefined>({
name: 'end_call',
description: `${END_CALL_DESCRIPTION}\n${extraDescription}`,
execute: async (_args, { ctx }) => handlers.endCall!(ctx),
});

super({ id: 'end_call', tools: [endCallTool] });
handlers.endCall = (ctx) => this.endCall(ctx);

this.deleteRoom = deleteRoom;
this.endInstructions = endInstructions;
this.onToolCalled = onToolCalled;
this.onToolCompleted = onToolCompleted;
}

private async endCall(ctx: RunContext<UserData>): Promise<string | undefined> {
log().debug('end_call tool called');
const llm = ctx.session.currentAgent.getActivityOrThrow().llm;

ctx.speechHandle.addDoneCallback(() => {
if (!(llm instanceof RealtimeModel) || !llm.capabilities.autoToolReplyGeneration) {
ctx.session.shutdown();
return;
}

this.delayedSessionShutdown(ctx);
});

ctx.session.once(AgentSessionEventTypes.Close, this.onSessionClose);

if (this.onToolCalled) {
await this.onToolCalled({ ctx, arguments: {} });
}

const completedEvent = {
ctx,
output:
this.endInstructions === null
? undefined
: ({ type: 'output', value: this.endInstructions } as const),
};
if (this.onToolCompleted) {
await this.onToolCompleted(completedEvent);
}

return this.endInstructions ?? undefined;
}

private delayedSessionShutdown(ctx: RunContext<UserData>): void {
const onSpeechCreated = (event: SpeechCreatedEvent) => {
this.clearDelayedShutdown(ctx, onSpeechCreated);
void event.speechHandle.waitForPlayout().finally(() => ctx.session.shutdown());
};

ctx.session.once(AgentSessionEventTypes.SpeechCreated, onSpeechCreated);
this.shutdownSessionTimeout = setTimeout(() => {
this.clearDelayedShutdown(ctx, onSpeechCreated);
log().warn('tool reply timed out, shutting down session');
ctx.session.shutdown();
}, 5000);
}

private clearDelayedShutdown(
ctx: RunContext<UserData>,
onSpeechCreated: (event: SpeechCreatedEvent) => void,
): void {
ctx.session.off(AgentSessionEventTypes.SpeechCreated, onSpeechCreated);
if (this.shutdownSessionTimeout) {
clearTimeout(this.shutdownSessionTimeout);
this.shutdownSessionTimeout = undefined;
}
}

private onSessionClose = (event: CloseEvent): void => {
if (this.shutdownSessionTimeout) {
clearTimeout(this.shutdownSessionTimeout);
this.shutdownSessionTimeout = undefined;
}

const jobCtx = getJobContext(false);
if (!jobCtx) {
return;
}

if (this.deleteRoom) {
jobCtx.addShutdownCallback(async () => {
log().info('deleting the room because the user ended the call');
await jobCtx.deleteRoom();
});
}

jobCtx.shutdown(String(event.reason));
};
}
10 changes: 10 additions & 0 deletions agents/src/beta/tools/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
export {
END_CALL_DESCRIPTION,
EndCallTool,
type EndCallToolCalledEvent,
type EndCallToolCompletedEvent,
type EndCallToolOptions,
} from './end_call.js';
2 changes: 1 addition & 1 deletion agents/src/voice/agent_activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import {
type InputSpeechStartedEvent,
type InputSpeechStoppedEvent,
type InputTranscriptionCompleted,
isFunctionTool,
LLM,
RealtimeModel,
type RealtimeModelError,
Expand All @@ -42,6 +41,7 @@ import {
type ToolContextEntry,
ToolFlag,
Toolset,
isFunctionTool,
} from '../llm/index.js';
import type { LLMError } from '../llm/llm.js';
import { isSameToolChoice } from '../llm/tool_context.js';
Expand Down
Loading