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

Support AssemblyAI inference STT agent context carryover.
125 changes: 123 additions & 2 deletions agents/src/inference/stt.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
// SPDX-FileCopyrightText: 2025 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
import { beforeAll, describe, expect, it } from 'vitest';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import * as agents from '../index.js';
import { normalizeLanguage } from '../language.js';
import { initializeLogger } from '../log.js';
import { AgentHandoffItem, ChatMessage } from '../llm/index.js';
import { initializeLogger, log } from '../log.js';
import { type APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS } from '../types.js';
import { VAD, type VADStream } from '../vad.js';
import { createConversationItemAddedEvent } from '../voice/events.js';
import {
STT,
type STTFallbackModel,
Expand All @@ -21,6 +23,10 @@ beforeAll(() => {
initializeLogger({ level: 'silent', pretty: false });
});

afterEach(() => {
vi.restoreAllMocks();
});

/** Helper to create STT with required credentials. */
function makeStt(overrides: Record<string, unknown> = {}) {
const defaults = {
Expand All @@ -32,6 +38,16 @@ function makeStt(overrides: Record<string, unknown> = {}) {
return new STT({ ...defaults, ...overrides });
}

function makeAssemblyStt(overrides: Record<string, unknown> = {}) {
return makeStt({ model: 'assemblyai/universal-3-5-pro', ...overrides });
}

function assistantItemEvent(text: string) {
return createConversationItemAddedEvent(
ChatMessage.create({ role: 'assistant', content: [text] }),
);
}

describe('parseSTTModelString', () => {
it('simple model without language', () => {
const [model, language] = parseSTTModelString('deepgram');
Expand Down Expand Up @@ -329,6 +345,111 @@ describe('STT diarization capabilities', () => {
});
});

describe('STT agent_context carryover', () => {
it('agentContextCarryover defaults to enabled on AssemblyAI U3 Pro family models', () => {
for (const model of ['assemblyai/u3-rt-pro', 'assemblyai/universal-3-5-pro'] as const) {
const stt = makeAssemblyStt({ model });
expect(stt.capabilities.chatContext).toBe(true);
}
});

it('agentContextCarryover defaults off for unsupported models without warning', () => {
const warnSpy = vi.spyOn(log(), 'warn');
const stt = makeAssemblyStt({ model: 'assemblyai/universal-streaming' });

expect(stt.capabilities.chatContext).toBe(false);
expect(warnSpy).not.toHaveBeenCalled();
});

it('agentContextCarryover is off for non-AssemblyAI models', () => {
const stt = makeAssemblyStt({ model: 'deepgram/nova-3' });
expect(stt.capabilities.chatContext).toBe(false);
});

it('explicit true on unsupported model warns and is ignored', () => {
const warnSpy = vi.spyOn(log(), 'warn');
const stt = makeAssemblyStt({
model: 'assemblyai/universal-streaming',
agentContextCarryover: true,
});

expect(stt.capabilities.chatContext).toBe(false);
expect(warnSpy).toHaveBeenCalledWith(
{ model: 'assemblyai/universal-streaming' },
'agentContextCarryover is enabled but model does not support it; ignoring',
);
});

it('explicit false disables carryover on a supported model', () => {
const stt = makeAssemblyStt({ agentContextCarryover: false });
expect(stt.capabilities.chatContext).toBe(false);
});

it('previous_context_n_turns=0 disables the default carryover', () => {
const stt = makeAssemblyStt({ modelOptions: { previous_context_n_turns: 0 } });
expect(stt.capabilities.chatContext).toBe(false);
});

it('explicit true wins over previous_context_n_turns=0', () => {
const stt = makeAssemblyStt({
modelOptions: { previous_context_n_turns: 0 },
agentContextCarryover: true,
});
expect(stt.capabilities.chatContext).toBe(true);
});

it('forwards short assistant replies verbatim', () => {
const stt = makeAssemblyStt();
stt._pushConversationItem(assistantItemEvent('Your room is booked for Tuesday.'));
expect(stt['opts'].modelOptions).toHaveProperty(
'agent_context',
'Your room is booked for Tuesday.',
);
});

it('truncates oversize replies keeping the tail', () => {
const text = 'a'.repeat(2000) + 'b'.repeat(1750);
const stt = makeAssemblyStt();
stt._pushConversationItem(assistantItemEvent(text));
expect(stt['opts'].modelOptions).toHaveProperty('agent_context', 'b'.repeat(1750));
});

it('ignores non-assistant items', () => {
const stt = makeAssemblyStt();

stt._pushConversationItem(
createConversationItemAddedEvent(ChatMessage.create({ role: 'user', content: ['hi there'] })),
);
expect(stt['opts'].modelOptions).not.toHaveProperty('agent_context');

stt._pushConversationItem(
createConversationItemAddedEvent(ChatMessage.create({ role: 'assistant', content: [] })),
);
expect(stt['opts'].modelOptions).not.toHaveProperty('agent_context');
});

it('ignores agent handoff items', () => {
const stt = makeAssemblyStt();
stt._pushConversationItem(
createConversationItemAddedEvent(AgentHandoffItem.create({ newAgentId: 'agent-2' })),
);
expect(stt['opts'].modelOptions).not.toHaveProperty('agent_context');
});

it('preserves explicit agent_context and later overwrites it with carryover', () => {
const stt = makeAssemblyStt({
modelOptions: { agent_context: 'The agent asked for a booking date.' },
});
expect(stt['opts'].modelOptions).toHaveProperty(
'agent_context',
'The agent asked for a booking date.',
);

stt._pushConversationItem(assistantItemEvent('And your zip code?'));
expect(stt['opts'].modelOptions).toHaveProperty('agent_context', 'And your zip code?');
});
});

describe('STT session keyterms', () => {
it('updateOptions does not bake session keyterms into the user baseline', () => {
const stt = makeStt({ model: 'deepgram/nova-3' });
Expand Down
110 changes: 89 additions & 21 deletions agents/src/inference/stt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { WebSocket } from 'ws';
import { APIError, APIStatusError } from '../_exceptions.js';
import { AudioByteStream } from '../audio.js';
import { type LanguageCode, areLanguagesEquivalent, normalizeLanguage } from '../language.js';
import { ChatMessage } from '../llm/index.js';
import { log } from '../log.js';
import { createStreamChannel } from '../stream/stream_channel.js';
import {
Expand All @@ -19,6 +20,7 @@ import {
import { type APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS } from '../types.js';
import { type AudioBuffer, Event, Task, cancelAndWait, shortuuid, waitForAbort } from '../utils.js';
import { type VAD, VADEventType, type VADStream } from '../vad.js';
import type { ConversationItemAddedEvent } from '../voice/events.js';
import { type TimedString, createTimedString } from '../voice/io.js';
import {
type SttServerEvent,
Expand Down Expand Up @@ -120,8 +122,10 @@ export interface AssemblyAIOptions {
keyterms_prompt?: string[];
/** Enable speaker diarization. Default: false. */
speaker_labels?: boolean;
/** Context to bias recognition. Only supported with u3-rt-pro. Max 1500 chars. */
/** Context to bias recognition. Only supported with u3-rt-pro. Max 1750 chars. */
agent_context?: string;
/** Prior turns carried as context; 0 disables carryover. Only supported with u3-rt-pro. */
previous_context_n_turns?: number;
/** Isolate the primary voice. Only supported with u3-rt-pro. */
voice_focus?: 'near-field' | 'far-field';
/** Background suppression strength. Only supported with u3-rt-pro. */
Expand Down Expand Up @@ -270,6 +274,28 @@ function keytermsExtraForModel(
return { [key]: [...new Set([...existing, ...sessionKeyterms])] };
}

const ASSEMBLYAI_CARRYOVER_MODELS = [
'assemblyai/u3-rt-pro',
'assemblyai/universal-3-5-pro',
] as const;

const ASSEMBLYAI_MAX_AGENT_CONTEXT_CHARS = 1750;

function supportsAgentContextCarryover(model: string | undefined): boolean {
return ASSEMBLYAI_CARRYOVER_MODELS.includes(
model as (typeof ASSEMBLYAI_CARRYOVER_MODELS)[number],
);
}

function agentContextCarryoverEnabled(opts: {
model: string | undefined;
modelOptions: Record<string, unknown>;
agentContextCarryover?: boolean;
}): boolean {
if (!supportsAgentContextCarryover(opts.model)) return false;
return opts.agentContextCarryover ?? opts.modelOptions.previous_context_n_turns !== 0;
}

type _STTModels =
| DeepgramModels
| DeepgramFluxModels
Expand Down Expand Up @@ -370,6 +396,7 @@ export interface InferenceSTTOptions<TModel extends STTModels> {
apiKey: string;
apiSecret: string;
modelOptions: STTOptions<TModel>;
agentContextCarryover?: boolean;
fallback?: STTFallbackModel[];
connOptions?: APIConnectOptions;
}
Expand Down Expand Up @@ -408,23 +435,49 @@ export class STT<TModel extends STTModels> extends BaseSTT {
apiSecret?: string;
modelOptions?: STTOptions<TModel>;
fallback?: STTFallbackModelType | STTFallbackModelType[];
agentContextCarryover?: boolean;
connOptions?: APIConnectOptions;
vad?: VAD;
}) {
const modelOptions = (opts?.modelOptions ?? {}) as STTOptions<TModel>;
// Parse language from model string if provided: "provider/model:language".
let nextModel = opts?.model;
let nextLanguage = opts?.language;
let hasLanguageConflict = false;
if (typeof nextModel === 'string') {
const [parsedModel, parsedLanguage] = parseSTTModelString(nextModel);
if (parsedLanguage !== undefined) {
hasLanguageConflict =
!!nextLanguage && !areLanguagesEquivalent(nextLanguage, parsedLanguage);
if (!hasLanguageConflict) {
nextLanguage = parsedLanguage as STTLanguages;
}
nextModel = parsedModel as TModel;
}
}
const carryoverEnabled = agentContextCarryoverEnabled({
model: typeof nextModel === 'string' ? nextModel : undefined,
modelOptions: modelOptions as Record<string, unknown>,
agentContextCarryover: opts?.agentContextCarryover,
});
super({
streaming: true,
interimResults: true,
alignedTranscript: 'word',
diarization: diarizationEnabled(modelOptions as Record<string, unknown>),
keyterms:
keytermsExtraForModel(typeof opts?.model === 'string' ? opts.model : undefined) !==
undefined,
keytermsExtraForModel(typeof nextModel === 'string' ? nextModel : undefined) !== undefined,
chatContext: carryoverEnabled,
});

if (opts?.agentContextCarryover && !supportsAgentContextCarryover(nextModel)) {
log().warn(
{ model: nextModel },
'agentContextCarryover is enabled but model does not support it; ignoring',
);
}

const {
model,
language,
baseURL,
encoding = DEFAULT_ENCODING,
sampleRate = DEFAULT_SAMPLE_RATE,
Expand All @@ -447,22 +500,11 @@ export class STT<TModel extends STTModels> extends BaseSTT {
throw new Error('apiSecret is required: pass apiSecret or set LIVEKIT_API_SECRET');
}

// Parse language from model string if provided: "provider/model:language"
let nextModel = model;
let nextLanguage = language;
if (typeof nextModel === 'string') {
const [parsedModel, parsedLanguage] = parseSTTModelString(nextModel);
if (parsedLanguage !== undefined) {
if (nextLanguage && !areLanguagesEquivalent(nextLanguage, parsedLanguage)) {
this.#logger.warn(
'`language` is provided via both argument and model, using the one from the argument',
{ language: nextLanguage, model: nextModel },
);
} else {
nextLanguage = parsedLanguage as STTLanguages;
}
nextModel = parsedModel as TModel;
}
if (hasLanguageConflict) {
this.#logger.warn(
'`language` is provided via both argument and model, using the one from the argument',
{ language: nextLanguage, model: opts?.model },
);
}
const normalizedFallback = fallback ? normalizeSTTFallback(fallback) : undefined;
this.vad = resolveVADForModel(nextModel, vad);
Expand All @@ -476,6 +518,7 @@ export class STT<TModel extends STTModels> extends BaseSTT {
apiKey: lkApiKey,
apiSecret: lkApiSecret,
modelOptions,
agentContextCarryover: opts?.agentContextCarryover,
fallback: normalizedFallback,
connOptions: connOptions ?? DEFAULT_API_CONNECT_OPTIONS,
};
Expand Down Expand Up @@ -531,12 +574,22 @@ export class STT<TModel extends STTModels> extends BaseSTT {
this._vadPromise = undefined;
this.updateCapabilities({
keyterms: keytermsExtraForModel(this.opts.model) !== undefined,
chatContext: agentContextCarryoverEnabled({
model: this.opts.model,
modelOptions: this.opts.modelOptions as Record<string, unknown>,
agentContextCarryover: this.opts.agentContextCarryover,
}),
});
}

if (nextOpts.modelOptions) {
this.updateCapabilities({
diarization: diarizationEnabled(this.opts.modelOptions as Record<string, unknown>),
chatContext: agentContextCarryoverEnabled({
model: this.opts.model,
modelOptions: this.opts.modelOptions as Record<string, unknown>,
agentContextCarryover: this.opts.agentContextCarryover,
}),
});
// re-apply the active session keyterms on top of the update sent to live streams,
// so a user extra update doesn't drop them. `this.opts.modelOptions` must stay a
Expand Down Expand Up @@ -587,6 +640,21 @@ export class STT<TModel extends STTModels> extends BaseSTT {
}
}

override _pushConversationItem(ev: ConversationItemAddedEvent): void {
const chatItem = ev.item;
if (chatItem instanceof ChatMessage && chatItem.role === 'assistant' && chatItem.textContent) {
let text = chatItem.textContent;
if (text.length > ASSEMBLYAI_MAX_AGENT_CONTEXT_CHARS) {
this.#logger.debug(
{ fromChars: text.length, toChars: ASSEMBLYAI_MAX_AGENT_CONTEXT_CHARS },
'truncating agent_context carryover',
);
text = text.slice(-ASSEMBLYAI_MAX_AGENT_CONTEXT_CHARS);
}
this.updateOptions({ modelOptions: { agent_context: text } as STTOptions<TModel> });
}
}
Comment on lines +643 to +656

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.

🟡 Turned-off conversation context can still be applied to a speech model that rejects it

The assistant reply is written into the speech engine's context option (updateOptions({ modelOptions: { agent_context: text } }) at agents/src/inference/stt.ts:654) without first checking whether context carryover is actually enabled, so a model that had carryover turned off can still have the context applied.
Impact: A speech model configured without carryover (or one that doesn't support it) can still receive assistant context, which for unsupported models can cause the transcription request to be rejected or behave unexpectedly.

How the capability guard is bypassed via the fallback adapter

The base implementation agents/src/stt/stt.ts:265-275 guards on this.#capabilities.chatContext and warn-and-skips when carryover is unsupported/disabled. The agent activity only registers the forwarder when the STT's own chatContext capability is true (agents/src/voice/agent_activity.ts:624), so a directly-used inference STT with carryover disabled is safe.

However, FallbackAdapter._pushConversationItem (agents/src/stt/fallback_adapter.ts:199-204) forwards the item to every wrapped STT, explicitly relying on the comment that "unsupported ones warn-and-skip internally". Its own chatContext capability is true if any child supports it (agents/src/stt/fallback_adapter.ts:144). Because this inference override does not call super._pushConversationItem and does not check this.capabilities.chatContext, a wrapped inference STT whose carryover is disabled (unsupported model, agentContextCarryover: false, or previous_context_n_turns: 0) will still set agent_context in its modelOptions, which is then sent to the gateway even though agent_context is only supported for the U3 Pro carryover models.

Suggested change
override _pushConversationItem(ev: ConversationItemAddedEvent): void {
const chatItem = ev.item;
if (chatItem instanceof ChatMessage && chatItem.role === 'assistant' && chatItem.textContent) {
let text = chatItem.textContent;
if (text.length > ASSEMBLYAI_MAX_AGENT_CONTEXT_CHARS) {
this.#logger.debug(
{ fromChars: text.length, toChars: ASSEMBLYAI_MAX_AGENT_CONTEXT_CHARS },
'truncating agent_context carryover',
);
text = text.slice(-ASSEMBLYAI_MAX_AGENT_CONTEXT_CHARS);
}
this.updateOptions({ modelOptions: { agent_context: text } as STTOptions<TModel> });
}
}
override _pushConversationItem(ev: ConversationItemAddedEvent): void {
if (!this.capabilities.chatContext) return;
const chatItem = ev.item;
if (chatItem instanceof ChatMessage && chatItem.role === 'assistant' && chatItem.textContent) {
let text = chatItem.textContent;
if (text.length > ASSEMBLYAI_MAX_AGENT_CONTEXT_CHARS) {
this.#logger.debug(
{ fromChars: text.length, toChars: ASSEMBLYAI_MAX_AGENT_CONTEXT_CHARS },
'truncating agent_context carryover',
);
text = text.slice(-ASSEMBLYAI_MAX_AGENT_CONTEXT_CHARS);
}
this.updateOptions({ modelOptions: { agent_context: text } as STTOptions<TModel> });
}
}
Open in Devin Review

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


stream(options?: {
language?: STTLanguages | string;
connOptions?: APIConnectOptions;
Expand Down
Loading