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/major-parks-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents': patch
---

Add session shutdown API
16 changes: 13 additions & 3 deletions agents/src/voice/agent_session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
type ErrorEvent,
type FunctionToolsExecutedEvent,
type MetricsCollectedEvent,
type ShutdownReason,
type SpeechCreatedEvent,
type UserInputTranscribedEvent,
type UserState,
Expand Down Expand Up @@ -497,13 +498,22 @@ export class AgentSession<
await this.closeImpl(CloseReason.USER_INITIATED);
}

shutdown(options?: { drain?: boolean; reason?: ShutdownReason }): void {
const { drain = true, reason = CloseReason.USER_INITIATED } = options ?? {};

this._closeSoon({
reason,
drain,
});
}

/** @internal */
_closeSoon({
reason,
drain = false,
error = null,
}: {
reason: CloseReason;
reason: ShutdownReason;
drain?: boolean;
error?: RealtimeModelError | STTError | TTSError | LLMError | null;
}): void {
Expand Down Expand Up @@ -662,7 +672,7 @@ export class AgentSession<
}

private async closeImpl(
reason: CloseReason,
reason: ShutdownReason,
error: RealtimeModelError | LLMError | TTSError | STTError | null = null,
drain: boolean = false,
): Promise<void> {
Expand All @@ -676,7 +686,7 @@ export class AgentSession<
}

private async closeImplInner(
reason: CloseReason,
reason: ShutdownReason,
error: RealtimeModelError | LLMError | TTSError | STTError | null = null,
drain: boolean = false,
): Promise<void> {
Expand Down
9 changes: 6 additions & 3 deletions agents/src/voice/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import type {
ChatMessage,
FunctionCall,
FunctionCallOutput,
LLM,
RealtimeModel,
RealtimeModelError,
} from '../llm/index.js';
import type { LLM, RealtimeModel } from '../llm/index.js';
import type { LLMError } from '../llm/llm.js';
import type { AgentMetrics } from '../metrics/base.js';
import type { STT } from '../stt/index.js';
Expand Down Expand Up @@ -38,6 +39,8 @@ export enum CloseReason {
USER_INITIATED = 'user_initiated',
}

export type ShutdownReason = CloseReason | `custom_${string}`;

export type SpeechSource = 'say' | 'generate_reply' | 'tool_response';

export type UserStateChangedEvent = {
Expand Down Expand Up @@ -231,12 +234,12 @@ export const createErrorEvent = (
export type CloseEvent = {
type: 'close';
error: RealtimeModelError | STTError | TTSError | LLMError | null;
reason: CloseReason;
reason: ShutdownReason;
createdAt: number;
};

export const createCloseEvent = (
reason: CloseReason,
reason: ShutdownReason,
error: RealtimeModelError | STTError | TTSError | LLMError | null = null,
createdAt: number = Date.now(),
): CloseEvent => ({
Expand Down
89 changes: 89 additions & 0 deletions examples/src/manual_shutdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: 2025 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
import {
type JobContext,
type JobProcess,
WorkerOptions,
cli,
defineAgent,
llm,
voice,
} from '@livekit/agents';
import * as livekit from '@livekit/agents-plugin-livekit';
import * as silero from '@livekit/agents-plugin-silero';
import { BackgroundVoiceCancellation } from '@livekit/noise-cancellation-node';
import { fileURLToPath } from 'node:url';
import { z } from 'zod';

export default defineAgent({
prewarm: async (proc: JobProcess) => {
proc.userData.vad = await silero.VAD.load();
},
entry: async (ctx: JobContext) => {
const agent = new voice.Agent({
instructions:
"You are a helpful assistant, you can hear the user's message and respond to it, end the call when the user asks you to.",
tools: {
getWeather: llm.tool({
description: 'Get the weather for a given location.',
parameters: z.object({
location: z.string().describe('The location to get the weather for'),
}),
execute: async ({ location }) => {
return `The weather in ${location} is sunny.`;
},
}),
endCall: llm.tool({
description: 'End the call.',
parameters: z.object({
reason: z
.enum([
'assistant-ended-call',
'sip-call-transferred',
'user-ended-call',
'unknown-error',
])
.describe('The reason to end the call'),
}),
execute: async ({ reason }, { ctx }) => {
session.generateReply({
userInput: `You are about to end the call due to ${reason}, notify the user with one last message`,
});
await ctx.waitForPlayout();

session.shutdown({ reason: `custom_${reason}` });
},
}),
},
});

const session = new voice.AgentSession({
stt: 'assemblyai/universal-streaming:en',
llm: 'openai/gpt-4.1-mini',
tts: 'cartesia/sonic-2:9626c31c-bec5-4cca-baa8-f8ba9e84c8bc',
vad: ctx.proc.userData.vad! as silero.VAD,
turnDetection: new livekit.turnDetector.MultilingualModel(),
voiceOptions: {
preemptiveGeneration: true,
},
});

// Track the session close reason
session.on(voice.AgentSessionEventTypes.Close, ({ reason }) => {
console.log(`[Voice Session Closed] Reason: ${reason}`);
});

await session.start({
agent,
room: ctx.room,
inputOptions: {
noiseCancellation: BackgroundVoiceCancellation(),
},
});

session.say('Hello, how can I help you today?');
},
});

cli.runApp(new WorkerOptions({ agent: fileURLToPath(import.meta.url) }));