From 808b61be2abd6639564ddadccd9c13d25af49c82 Mon Sep 17 00:00:00 2001 From: "rosetta-livekit-bot[bot]" <282703043+rosetta-livekit-bot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 03:29:35 +0000 Subject: [PATCH 1/2] feat(agents): add STT keyterm context --- .changeset/stt-keyterms-context.md | 5 + agents/src/stt/stt.ts | 36 ++- agents/src/voice/agent_activity.ts | 28 ++ agents/src/voice/agent_session.ts | 27 ++ agents/src/voice/index.ts | 1 + agents/src/voice/keyterm_detection.ts | 392 ++++++++++++++++++++++++++ agents/src/voice/turn_config/utils.ts | 1 + 7 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 .changeset/stt-keyterms-context.md create mode 100644 agents/src/voice/keyterm_detection.ts diff --git a/.changeset/stt-keyterms-context.md b/.changeset/stt-keyterms-context.md new file mode 100644 index 000000000..5bacfa802 --- /dev/null +++ b/.changeset/stt-keyterms-context.md @@ -0,0 +1,5 @@ +--- +"@livekit/agents": minor +--- + +Add conversation-aware STT keyterm biasing and chat context forwarding hooks. diff --git a/agents/src/stt/stt.ts b/agents/src/stt/stt.ts index 5bd922e32..6f4f163e6 100644 --- a/agents/src/stt/stt.ts +++ b/agents/src/stt/stt.ts @@ -14,7 +14,7 @@ import { DeferredReadableStream } from '../stream/deferred_stream.js'; import { type APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS, intervalForRetry } from '../types.js'; import type { AudioBuffer } from '../utils.js'; import { AsyncIterableQueue, delay, startSoon, toError } from '../utils.js'; -import type { TimedString } from '../voice/index.js'; +import type { ConversationItemAddedEvent, TimedString } from '../voice/index.js'; /** Indicates start/middle/end of speech */ export enum SpeechEventType { @@ -136,6 +136,10 @@ export interface STTCapabilities { alignedTranscript?: 'word' | 'chunk' | false; /** Whether this STT supports speaker diarization. */ diarization?: boolean; + /** Whether this STT supports keyterm prompting. */ + keyterms?: boolean; + /** Whether this STT can natively consume conversation context. */ + chatContext?: boolean; } export interface STTError { @@ -161,6 +165,8 @@ export type STTCallbacks = { export abstract class STT extends (EventEmitter as new () => TypedEmitter) { abstract label: string; #capabilities: STTCapabilities; + private keytermsUnsupportedWarned = false; + private chatContextUnsupportedWarned = false; constructor(capabilities: STTCapabilities) { super(); @@ -234,6 +240,34 @@ export abstract class STT extends (EventEmitter as new () => TypedEmitter { return; } diff --git a/agents/src/voice/agent_activity.ts b/agents/src/voice/agent_activity.ts index b93e5cce9..bf15c9af5 100644 --- a/agents/src/voice/agent_activity.ts +++ b/agents/src/voice/agent_activity.ts @@ -96,6 +96,7 @@ import { import type { AgentState, AgentStateChangedEvent, + ConversationItemAddedEvent, EotPredictionEvent, UserTurnExceededEvent, _AgentBackchannelOpportunityEvent, @@ -232,6 +233,7 @@ export class AgentActivity implements RecognitionHooks { private lock = new Mutex(); private audioStream = new MultiInputStream(); private audioStreamId?: string; + private sttConversationItemListener?: (ev: ConversationItemAddedEvent) => void; // default to null as None, which maps to the default provider tool choice value private toolChoice: ToolChoice | null = null; @@ -548,6 +550,8 @@ export class AgentActivity implements RecognitionHooks { this._resolvedTurnDetection.on('metrics_collected', this.onMetricsCollected); } + this.agentSession._keytermDetector.on('metrics_collected', this.onMetricsCollected); + // Bundled-default VAD is treated as absent when the RealtimeModel does // its own server-side turn detection — the realtime session is already // canonical and an extra audio pipeline would just pay the native model @@ -596,6 +600,18 @@ export class AgentActivity implements RecognitionHooks { reuseResources.turnDetectorStream = undefined; } + const activeStt = this.stt; + if (activeStt instanceof STT) { + this.agentSession._keytermDetector.start(this.agentSession, activeStt); + if (activeStt.capabilities.chatContext) { + this.sttConversationItemListener = (ev) => activeStt._pushConversationItem(ev); + this.agentSession.on( + AgentSessionEventTypes.ConversationItemAdded, + this.sttConversationItemListener, + ); + } + } + this.started = true; this._resumeSchedulingTask(); @@ -3858,6 +3874,7 @@ export class AgentActivity implements RecognitionHooks { private async _pauseSchedulingTask(blockedTasks: Task[]): Promise { if (this._schedulingPaused) return; + await this.agentSession._keytermDetector.close(); this._schedulingPaused = true; this._drainBlockedTasks = blockedTasks; this.wakeupMainTask(); @@ -4314,6 +4331,17 @@ export class AgentActivity implements RecognitionHooks { this._resolvedTurnDetection.off('metrics_collected', this.onMetricsCollected); } + this.agentSession._keytermDetector.off('metrics_collected', this.onMetricsCollected); + await this.agentSession._keytermDetector.close(); + + if (this.sttConversationItemListener) { + this.agentSession.off( + AgentSessionEventTypes.ConversationItemAdded, + this.sttConversationItemListener, + ); + this.sttConversationItemListener = undefined; + } + this.detachAudioInput(); this.realtimeSpans?.clear(); await this.realtimeSession?.close(); diff --git a/agents/src/voice/agent_session.ts b/agents/src/voice/agent_session.ts index 7e2b4423b..2f792189a 100644 --- a/agents/src/voice/agent_session.ts +++ b/agents/src/voice/agent_session.ts @@ -83,6 +83,11 @@ import { createUserStateChangedEvent, } from './events.js'; import { AgentInput, AgentOutput } from './io.js'; +import { + KeytermDetector, + type KeytermsOptions, + resolveKeytermsOptions, +} from './keyterm_detection.js'; import { RecorderIO } from './recorder_io/index.js'; import { RoomSessionTransport, SessionHost } from './remote_session.js'; import { RoomIO, type RoomInputOptions, type RoomOutputOptions } from './room_io/index.js'; @@ -162,6 +167,7 @@ export interface InternalSessionOptions extends AgentSessionOptions = { useTtsAlignedTranscript?: boolean; + /** Keyterm biasing for STTs that accept a term list. */ + keytermsOptions?: KeytermsOptions; + /** * Transforms to apply to TTS input text. Built-in transforms are `filter_markdown` * and `filter_emoji`; pass `null` to disable text transforms. @@ -317,6 +327,8 @@ export type AgentSessionUpdateOptions = { * - `TurnDetectionMode`: set the turn detection strategy to the provided value. */ turnDetection?: TurnDetectionMode | null; + /** Replace the user-defined keyterms applied to the STT. Auto-detected keyterms are kept. */ + keyterms?: string[]; }; type ActivityTransitionOptions = { @@ -350,6 +362,8 @@ export class AgentSession< private sessionHost?: SessionHost; private _chatCtx: ChatContext; + /** @internal */ + _keytermDetector: KeytermDetector; private _userData: UserData | undefined; private _userState: UserState = 'listening'; private _agentState: AgentState = 'initializing'; @@ -512,6 +526,11 @@ export class AgentSession< // This is the "global" chat context, it holds the entire conversation history this._chatCtx = ChatContext.empty(); this.sessionOptions = resolvedSessionOptions; + this.sessionOptions.keytermsOptions = resolveKeytermsOptions(opts.keytermsOptions); + this._keytermDetector = new KeytermDetector({ + staticKeyterms: this.sessionOptions.keytermsOptions.keyterms, + options: this.sessionOptions.keytermsOptions.keytermDetection, + }); this.options = legacyVoiceOptions; this._aecWarmupRemaining = this.sessionOptions.aecWarmupDuration ?? 0; @@ -565,6 +584,10 @@ export class AgentSession< return { modelUsage: this._usageCollector.flatten().map(filterZeroValues) }; } + get keyterms(): string[] { + return this._keytermDetector.keyterms; + } + get useTtsAlignedTranscript(): boolean { return this.sessionOptions.useTtsAlignedTranscript; } @@ -973,6 +996,10 @@ export class AgentSession< this.sessionOptions.turnHandling.turnDetection = normalizedTurnDetection; } + if (options.keyterms !== undefined) { + this._keytermDetector.setStaticKeyterms(options.keyterms); + } + if (this.activity) { const activityOptions: Parameters[0] = {}; if (endpointing !== undefined) { diff --git a/agents/src/voice/index.ts b/agents/src/voice/index.ts index d47931dc9..06619a5b3 100644 --- a/agents/src/voice/index.ts +++ b/agents/src/voice/index.ts @@ -31,6 +31,7 @@ export { type PlaybackStartedEvent, type TimedString, } from './io.js'; +export { type KeytermDetectionOptions, type KeytermsOptions } from './keyterm_detection.js'; export * from './report.js'; export * from './room_io/index.js'; export { RunContext } from './run_context.js'; diff --git a/agents/src/voice/keyterm_detection.ts b/agents/src/voice/keyterm_detection.ts new file mode 100644 index 000000000..8640c4da0 --- /dev/null +++ b/agents/src/voice/keyterm_detection.ts @@ -0,0 +1,392 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +import type { TypedEventEmitter as TypedEmitter } from '@livekit/typed-emitter'; +import { EventEmitter } from 'node:events'; +import { z } from 'zod'; +import { LLM as InferenceLLM } from '../inference/index.js'; +import { ChatContext, ChatMessage, type FunctionCall } from '../llm/chat_context.js'; +import { LLM } from '../llm/llm.js'; +import { type ToolContext, tool } from '../llm/tool_context.js'; +import { log } from '../log.js'; +import type { LLMMetrics } from '../metrics/base.js'; +import type { STT } from '../stt/index.js'; +import type { AgentSession } from './agent_session.js'; +import { AgentSessionEventTypes, type ConversationItemAddedEvent } from './events.js'; + +export type KeytermsOptions = { + /** Static keyterms applied wherever the STT accepts a term list. */ + keyterms?: string[]; + /** LLM-based keyterm extraction, for STTs that accept a term list. */ + keytermDetection?: KeytermDetectionOptions; +}; + +export type KeytermDetectionOptions = { + /** Whether to run the background detector. Defaults to `false`. */ + enabled?: boolean; + /** LLM used for extraction, or a model string resolved through the inference gateway. */ + llm?: LLM | string | null; + /** Run a pass once per N user turns. Defaults to `1`. */ + turnInterval?: number; + /** Cap on confirmed detected keyterms. Defaults to unlimited. */ + maxKeyterms?: number | null; + /** Override the built-in extraction prompt. */ + instructions?: string | null; + /** Milliseconds a single detection pass may run before it is dropped. Defaults to `10000`. */ + timeout?: number; +}; + +type ResolvedKeytermDetectionOptions = Required; +type KeytermDetectorCallbacks = { metrics_collected: (metrics: LLMMetrics) => void }; + +const DETECTION_TIMEOUT = 10_000; +const PENDING_TTL = 3; +const MAX_TRANSCRIPT_MESSAGES = 12; +const DEFAULT_DETECTION_MODEL = 'google/gemini-3.5-flash'; + +const DEFAULT_KEYTERM_INSTRUCTIONS = `You maintain STT keyterms that bias a recognizer toward the correct spelling of distinctive words (names, places, companies, products, technical terms). Each turn, adjust them with one \`record_keyterms\` call. + +A WRONG spelling biases the recognizer for the rest of the call with no recovery, so precision beats coverage: apply only a spelling you can CORROBORATE, and when unsure change nothing. + +USER lines are raw STT - often wrong, and the same error recurs, so repetition is NOT proof a spelling is right. ASSISTANT lines are the agent's own writing: trust the agent's confident use of its OWN names (brands, staff, locations) and confirm those promptly - but an assistant merely echoing the user's sounds, or hedging about a spelling, does NOT corroborate. + +CONFIRM a pending term only when corroborated by one of: + 1. a letter-by-letter spell-out the assistant then accepts WITHOUT reservation - confirm exactly those letters, appending nothing; + 2. the assistant's own confident use of that exact distinctive spelling; + 3. an explicit user correction ("no, not X - it's Y"). +Recurrence alone never confirms. + +HEDGE RULE: if after a spell-out or name read-back the assistant signals the letters may be off ("for now", "with that caveat", "may have that slightly off", "did I catch that?", "to be confirmed", "I don't want to guess", "double-check"), the spelling is unreliable - keep the term PENDING and never confirm it, EVEN IF the user replies "yes". Only a cleanly accepted spell-out confirms. + +Never apply: a user-line word that sounds like a known term (it's that term misheard); a distinctive name glued to an ordinary word ("Blue Haven Hotel" - keep the bare name pending); an odd phrase only the user says and the assistant never adopts; a fragment left by an interruption; ordinary words or fillers. + +Report only CHANGES; never re-list an applied term. + - \`pending\`: a distinctive term seen but not yet corroborated; + - \`confirm\`: a pending term that just met the bar above; + - \`remove\`: only a spelling the user just corrected away. Applied terms are otherwise sticky. +If nothing meets the bar this turn, change nothing.`; + +const recordKeyterms = tool({ + description: 'Update the STT keyterms based on the latest transcript.', + parameters: z.object({ + pending: z.array(z.string()).describe('Distinctive terms seen but not yet trusted.'), + confirm: z.array(z.string()).describe('Pending terms the transcript has now corroborated.'), + remove: z.array(z.string()).describe('Only a spelling the user corrected away.'), + }), + execute: async () => undefined, +}); + +export function resolveDetection( + config: KeytermDetectionOptions | null | undefined, +): ResolvedKeytermDetectionOptions { + return { + enabled: false, + llm: null, + turnInterval: 1, + maxKeyterms: null, + instructions: null, + timeout: DETECTION_TIMEOUT, + ...(config ?? {}), + }; +} + +export function resolveKeytermsOptions(config: KeytermsOptions | null | undefined): { + keyterms: string[]; + keytermDetection: ResolvedKeytermDetectionOptions; +} { + return { + keyterms: [...(config?.keyterms ?? [])], + keytermDetection: resolveDetection(config?.keytermDetection), + }; +} + +function resolveDetectionLLM(configured: LLM | string | null): LLM | undefined { + if (configured instanceof LLM) return configured; + const model = typeof configured === 'string' ? configured : DEFAULT_DETECTION_MODEL; + try { + return InferenceLLM.fromModelString(model); + } catch (error) { + log().warn({ model, error }, 'keyterm detection: could not create detection LLM; skipping'); + return undefined; + } +} + +export class KeytermDetector extends (EventEmitter as new () => TypedEmitter) { + private detection: ResolvedKeytermDetectionOptions; + private maxKeyterms: number | null; + private turnInterval: number; + private instructions: string; + private detectionTimeout: number; + private staticTerms: string[]; + private detectedTerms: string[] = []; + private pendingTerms = new Map(); + private tick = 0; + private stt?: STT; + private llm?: LLM; + private session?: AgentSession; + private turnCount = 0; + private detectTask?: Promise; + private detectTaskPending = false; + + constructor({ + staticKeyterms, + options, + }: { + staticKeyterms?: string[]; + options?: KeytermDetectionOptions | null; + } = {}) { + super(); + this.detection = resolveDetection(options); + this.maxKeyterms = this.detection.maxKeyterms; + this.turnInterval = Math.max(1, this.detection.turnInterval); + this.instructions = this.detection.instructions ?? DEFAULT_KEYTERM_INSTRUCTIONS; + this.detectionTimeout = this.detection.timeout; + this.staticTerms = Array.from(new Set(staticKeyterms ?? [])); + this.llm = this.detection.llm instanceof LLM ? this.detection.llm : undefined; + } + + get keyterms(): string[] { + return Array.from(new Set([...this.staticTerms, ...this.detectedTerms])); + } + + get staticKeyterms(): string[] { + return [...this.staticTerms]; + } + + setStaticKeyterms(terms: string[]): void { + this.staticTerms = Array.from(new Set(terms)); + this.stt?._updateSessionKeyterms(this.keyterms); + } + + start(session: AgentSession, stt: STT): void { + if (stt !== this.stt) { + this.stt = stt; + if (this.keyterms.length > 0) { + this.stt._updateSessionKeyterms(this.keyterms); + } + } + + if (!this.detection.enabled) return; + + if (!stt.capabilities.keyterms) { + log().warn( + { stt: stt.label }, + 'keyterm detection is enabled but the STT does not support keyterms; skipping detection', + ); + return; + } + + const detectLLM = resolveDetectionLLM(this.detection.llm); + if (!detectLLM) { + log().warn('keyterm detection is enabled but no detection LLM is available; skipping'); + return; + } + + this.llm = detectLLM; + detectLLM.on('metrics_collected', this.forwardMetrics); + this.session = session; + this.turnCount = 0; + session.on(AgentSessionEventTypes.ConversationItemAdded, this.onConversationItemAdded); + } + + async close(): Promise { + this.llm?.off('metrics_collected', this.forwardMetrics); + this.session?.off(AgentSessionEventTypes.ConversationItemAdded, this.onConversationItemAdded); + this.session = undefined; + if (this.detectTask) { + await this.detectTask.catch(() => undefined); + this.detectTask = undefined; + this.detectTaskPending = false; + } + } + + private forwardMetrics = (metrics: LLMMetrics): void => { + this.emit('metrics_collected', metrics); + }; + + private onConversationItemAdded = (ev: ConversationItemAddedEvent): void => { + if (!this.session) return; + const item = ev.item; + if (!(item instanceof ChatMessage) || item.role !== 'user' || !item.textContent) return; + + this.turnCount += 1; + if (this.turnCount % this.turnInterval !== 0) return; + if (this.detectTaskPending) return; + + this.detectTaskPending = true; + this.detectTask = this.runOnce(KeytermDetector.snapshot(this.session)) + .catch((error) => log().error({ error }, 'keyterm detection pass failed')) + .finally(() => { + this.detectTaskPending = false; + }); + }; + + static snapshot(session: AgentSession): ChatContext { + return session.history.copy({ + excludeConfigUpdate: true, + excludeFunctionCall: true, + excludeHandoff: true, + excludeEmptyMessage: true, + }); + } + + async runOnce(chatCtx: ChatContext): Promise { + if (!(this.llm instanceof LLM)) return; + + const current: Array<[string, boolean]> = [ + ...this.staticTerms.map((term): [string, boolean] => [term, true]), + ...this.detectedTerms.map((term): [string, boolean] => [term, true]), + ...Array.from(this.pendingTerms.keys()).map((term): [string, boolean] => [term, false]), + ]; + const [pending, confirm, remove] = await detectKeyterms(this.llm, chatCtx, { + currentKeyterms: current, + instructions: this.instructions, + timeout: this.detectionTimeout, + }); + + const before = this.keyterms; + this.tick += 1; + + for (const term of remove) { + this.pendingTerms.delete(term); + this.detectedTerms = this.detectedTerms.filter((t) => t !== term); + } + + for (const term of pending) { + if ( + term && + !this.staticTerms.includes(term) && + !this.detectedTerms.includes(term) && + !this.pendingTerms.has(term) + ) { + this.pendingTerms.set(term, this.tick); + } + } + + for (const term of confirm) { + if (term && !this.staticTerms.includes(term)) { + this.pendingTerms.delete(term); + if (!this.detectedTerms.includes(term)) this.detectedTerms.push(term); + } + } + + for (const [term, since] of this.pendingTerms) { + if (this.tick - since >= PENDING_TTL) this.pendingTerms.delete(term); + } + + if (this.maxKeyterms !== null) { + while (this.detectedTerms.length > this.maxKeyterms) this.detectedTerms.shift(); + } + + const newKeyterms = this.keyterms; + if (!sameList(newKeyterms, before) && this.stt) { + this.stt._updateSessionKeyterms(newKeyterms); + log().debug( + { + added: newKeyterms.filter((term) => !before.includes(term)), + removed: before.filter((term) => !newKeyterms.includes(term)), + }, + 'keyterms changed', + ); + } + } +} + +export async function detectKeyterms( + llm: LLM, + chatCtx: ChatContext, + options: { + instructions?: string | null; + currentKeyterms?: Array<[string, boolean]>; + timeout?: number; + } = {}, +): Promise<[string[], string[], string[]]> { + const userMsg = formatInput(chatCtx, options.currentKeyterms ?? []); + if (userMsg === undefined) return [[], [], []]; + + const reqCtx = ChatContext.empty(); + reqCtx.addMessage({ + role: 'system', + content: options.instructions ?? DEFAULT_KEYTERM_INSTRUCTIONS, + }); + reqCtx.addMessage({ role: 'user', content: userMsg }); + + const stream = llm.chat({ + chatCtx: reqCtx, + toolCtx: { record_keyterms: recordKeyterms } satisfies ToolContext, + toolChoice: 'required', + }); + const timeoutMs = options.timeout ?? DETECTION_TIMEOUT; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + stream.close(); + }, timeoutMs); + + try { + const toolCalls: FunctionCall[] = []; + for await (const chunk of stream) { + if (chunk.delta?.toolCalls) toolCalls.push(...chunk.delta.toolCalls); + } + const result = timedOut ? [[], [], []] : parseToolCall(toolCalls); + if (timedOut) { + log().warn({ timeout: timeoutMs }, 'keyterm detection: pass timed out; skipping'); + } + return result as [string[], string[], string[]]; + } finally { + clearTimeout(timer); + } +} + +export function formatInput( + chatCtx: ChatContext, + currentKeyterms: Array<[string, boolean]>, +): string | undefined { + const turns: string[] = []; + for (const item of [...chatCtx.items].reverse()) { + if (!(item instanceof ChatMessage) || (item.role !== 'user' && item.role !== 'assistant')) { + continue; + } + const text = item.textContent; + if (text) { + const body = text + .split('\n') + .filter((line) => line.trim()) + .join('\n'); + turns.push(`${item.role.toUpperCase()}: ${body}`); + if (turns.length >= MAX_TRANSCRIPT_MESSAGES) break; + } + } + if (turns.length === 0) return undefined; + turns.reverse(); + + const applied = currentKeyterms.filter(([, ok]) => ok).map(([term]) => term); + const candidates = currentKeyterms.filter(([, ok]) => !ok).map(([term]) => term); + return [ + `## Transcript (USER = raw STT, may be wrong; ASSISTANT = correct spelling)\n${turns.join('\n\n')}`, + `## Applied keyterms (biasing the recognizer now)\n${applied.join(', ') || '(none)'}`, + `## Candidate keyterms (seen, not yet applied)\n${candidates.join(', ') || '(none)'}`, + 'Update the keyterms from the latest turns, then call `record_keyterms` once.', + ].join('\n\n'); +} + +export function parseToolCall(toolCalls: FunctionCall[]): [string[], string[], string[]] { + const call = toolCalls.find((c) => c.name === 'record_keyterms'); + if (!call) return [[], [], []]; + try { + const data = JSON.parse(call.args) as Record; + const terms = (key: string) => + Array.isArray(data[key]) + ? data[key].filter( + (term): term is string => typeof term === 'string' && term.trim().length > 0, + ) + : []; + return [terms('pending'), terms('confirm'), terms('remove')]; + } catch { + return [[], [], []]; + } +} + +function sameList(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} diff --git a/agents/src/voice/turn_config/utils.ts b/agents/src/voice/turn_config/utils.ts index 100cda367..f1e4d0312 100644 --- a/agents/src/voice/turn_config/utils.ts +++ b/agents/src/voice/turn_config/utils.ts @@ -26,6 +26,7 @@ const defaultSessionOptions = { ttsReadIdleTimeout: 10_000, forwardAudioIdleTimeout: 10_000, turnHandling: {}, + keytermsOptions: {}, useTtsAlignedTranscript: true, ttsTextTransforms: ['filter_markdown', 'filter_emoji'], } as const satisfies AgentSessionOptions; From 48e902bc6ccbbb0c765f75a0ca4111413741a19e Mon Sep 17 00:00:00 2001 From: "rosetta-livekit-bot[bot]" <282703043+rosetta-livekit-bot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 03:33:19 +0000 Subject: [PATCH 2/2] fix(agents): guard keyterm cleanup in activity tests --- agents/src/voice/agent_activity.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/agents/src/voice/agent_activity.ts b/agents/src/voice/agent_activity.ts index bf15c9af5..b5b943e92 100644 --- a/agents/src/voice/agent_activity.ts +++ b/agents/src/voice/agent_activity.ts @@ -3874,7 +3874,7 @@ export class AgentActivity implements RecognitionHooks { private async _pauseSchedulingTask(blockedTasks: Task[]): Promise { if (this._schedulingPaused) return; - await this.agentSession._keytermDetector.close(); + await this.agentSession?._keytermDetector.close(); this._schedulingPaused = true; this._drainBlockedTasks = blockedTasks; this.wakeupMainTask(); @@ -4331,10 +4331,10 @@ export class AgentActivity implements RecognitionHooks { this._resolvedTurnDetection.off('metrics_collected', this.onMetricsCollected); } - this.agentSession._keytermDetector.off('metrics_collected', this.onMetricsCollected); - await this.agentSession._keytermDetector.close(); + this.agentSession?._keytermDetector.off('metrics_collected', this.onMetricsCollected); + await this.agentSession?._keytermDetector.close(); - if (this.sttConversationItemListener) { + if (this.agentSession && this.sttConversationItemListener) { this.agentSession.off( AgentSessionEventTypes.ConversationItemAdded, this.sttConversationItemListener,