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/stt-keyterms-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@livekit/agents": minor
---

Add conversation-aware STT keyterm biasing and chat context forwarding hooks.
36 changes: 35 additions & 1 deletion agents/src/stt/stt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -161,6 +165,8 @@ export type STTCallbacks = {
export abstract class STT extends (EventEmitter as new () => TypedEmitter<STTCallbacks>) {
abstract label: string;
#capabilities: STTCapabilities;
private keytermsUnsupportedWarned = false;
private chatContextUnsupportedWarned = false;

constructor(capabilities: STTCapabilities) {
super();
Expand Down Expand Up @@ -234,6 +240,34 @@ export abstract class STT extends (EventEmitter as new () => TypedEmitter<STTCal
*/
abstract stream(options?: { connOptions?: APIConnectOptions }): SpeechStream;

/** @internal */
_updateSessionKeyterms(keyterms: string[]): void {
if (!this.#capabilities.keyterms) {
if (!this.keytermsUnsupportedWarned) {
this.keytermsUnsupportedWarned = true;
log().warn(
{ stt: this.label, keyterms },
'keyterms are not supported by this STT, ignoring keyterms update',
);
}
return;
}
}

/** @internal */
_pushConversationItem(ev: ConversationItemAddedEvent): void {
if (!this.#capabilities.chatContext) {
if (!this.chatContextUnsupportedWarned) {
this.chatContextUnsupportedWarned = true;
log().warn(
{ stt: this.label, itemType: ev.item.type },
'chat context is not supported by this STT, ignoring chat context update',
);
}
return;
}
}

async close(): Promise<void> {
return;
}
Expand Down
28 changes: 28 additions & 0 deletions agents/src/voice/agent_activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import {
import type {
AgentState,
AgentStateChangedEvent,
ConversationItemAddedEvent,
EotPredictionEvent,
UserTurnExceededEvent,
_AgentBackchannelOpportunityEvent,
Expand Down Expand Up @@ -232,6 +233,7 @@ export class AgentActivity implements RecognitionHooks {
private lock = new Mutex();
private audioStream = new MultiInputStream<AudioFrame>();
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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -3858,6 +3874,7 @@ export class AgentActivity implements RecognitionHooks {
private async _pauseSchedulingTask(blockedTasks: Task<any>[]): Promise<void> {
if (this._schedulingPaused) return;

await this.agentSession?._keytermDetector.close();
this._schedulingPaused = true;
Comment on lines +3877 to 3878

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Scheduling pause is delayed by an async operation, allowing unintended speech scheduling during drain/handoff

The scheduling-pause flag is set (this._schedulingPaused = true at agent_activity.ts:3878) only AFTER an awaited keyterm detector close (agent_activity.ts:3877), so during that async wait new speech handles can still be enqueued.

Impact: During agent handoffs or session shutdown, extra speech handles may be scheduled and processed, extending drain time unpredictably.

Pre-PR ordering change and race window

Before this PR, _pauseSchedulingTask set this._schedulingPaused = true as its very first statement (after the early-return guard). This immediately caused scheduleSpeech() (agent_activity.ts:3862) to throw SchedulingPausedError for any new callers, preventing new speech handles from entering the queue.

The new code inserts await this.agentSession?._keytermDetector.close() before the flag is set. KeytermDetector.close() (keyterm_detection.ts:192-201) awaits any in-flight detectTask, which can block for up to the detection timeout (default 10 seconds via DETECTION_TIMEOUT at keyterm_detection.ts:42). During this window:

  1. this._schedulingPaused is still false
  2. scheduleSpeech() succeeds instead of throwing
  3. Tool responses or concurrent generateReply() calls can enqueue new speech handles
  4. These handles must then be drained before the pause completes

The fix is to set the flag before the async close, or move the close after the flag is set:

this._schedulingPaused = true;
await this.agentSession?._keytermDetector.close();
Suggested change
await this.agentSession?._keytermDetector.close();
this._schedulingPaused = true;
this._schedulingPaused = true;
await this.agentSession?._keytermDetector.close();
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

this._drainBlockedTasks = blockedTasks;
this.wakeupMainTask();
Expand Down Expand Up @@ -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.agentSession && this.sttConversationItemListener) {
this.agentSession.off(
AgentSessionEventTypes.ConversationItemAdded,
this.sttConversationItemListener,
);
this.sttConversationItemListener = undefined;
}

this.detachAudioInput();
this.realtimeSpans?.clear();
await this.realtimeSession?.close();
Expand Down
27 changes: 27 additions & 0 deletions agents/src/voice/agent_session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -162,6 +167,7 @@ export interface InternalSessionOptions<UserData> extends AgentSessionOptions<Us
turnHandling: InternalTurnHandlingOptions;
useTtsAlignedTranscript: boolean;
maxToolSteps: number;
keytermsOptions: KeytermsOptions;
userAwayTimeout: number | null;
ttsReadIdleTimeout: number;
forwardAudioIdleTimeout: number;
Expand All @@ -175,6 +181,7 @@ export const defaultAgentSessionOptions = {
ttsReadIdleTimeout: 10_000,
forwardAudioIdleTimeout: 10_000,
turnHandling: {},
keytermsOptions: {},
useTtsAlignedTranscript: true,
ttsTextTransforms: ['filter_markdown', 'filter_emoji'],
} as const satisfies AgentSessionOptions;
Expand Down Expand Up @@ -288,6 +295,9 @@ export type AgentSessionOptions<UserData = UnknownUserData> = {

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.
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<AgentActivity['updateOptions']>[0] = {};
if (endpointing !== undefined) {
Expand Down
1 change: 1 addition & 0 deletions agents/src/voice/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading