diff --git a/.changeset/major-parks-behave.md b/.changeset/major-parks-behave.md new file mode 100644 index 00000000..f4b2073c --- /dev/null +++ b/.changeset/major-parks-behave.md @@ -0,0 +1,5 @@ +--- +'@livekit/agents': patch +--- + +Add session shutdown API diff --git a/agents/src/voice/agent_session.ts b/agents/src/voice/agent_session.ts index 4792a5ef..fd92d34c 100644 --- a/agents/src/voice/agent_session.ts +++ b/agents/src/voice/agent_session.ts @@ -45,6 +45,7 @@ import { type ErrorEvent, type FunctionToolsExecutedEvent, type MetricsCollectedEvent, + type ShutdownReason, type SpeechCreatedEvent, type UserInputTranscribedEvent, type UserState, @@ -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 { @@ -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 { @@ -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 { diff --git a/agents/src/voice/events.ts b/agents/src/voice/events.ts index 4a809c4c..f52f80be 100644 --- a/agents/src/voice/events.ts +++ b/agents/src/voice/events.ts @@ -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'; @@ -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 = { @@ -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 => ({ diff --git a/examples/src/manual_shutdown.ts b/examples/src/manual_shutdown.ts new file mode 100644 index 00000000..a4659f2c --- /dev/null +++ b/examples/src/manual_shutdown.ts @@ -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) }));