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
6 changes: 6 additions & 0 deletions .changeset/soft-realtime-turns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@livekit/agents": patch
"@livekit/agents-plugin-openai": patch
---

Do not drop realtime replies when the pre-reply chat context update times out.
1 change: 1 addition & 0 deletions agents/src/llm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export {
} from './llm.js';

export {
RealtimeError,
RealtimeModel,
RealtimeSession,
type GenerationCreatedEvent,
Expand Down
12 changes: 12 additions & 0 deletions agents/src/llm/realtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,18 @@ export interface RealtimeCapabilities {
nativeTranscriptSync?: boolean;
}

/**
* Error raised by a realtime provider when an operation fails or times out
* (for example a chat-context update or reply generation).
*/
export class RealtimeError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'RealtimeError';
Error.captureStackTrace(this, RealtimeError);
}
}
Comment thread
toubatbrian marked this conversation as resolved.

export interface InputTranscriptionCompleted {
itemId: string;
transcript: string;
Expand Down
98 changes: 98 additions & 0 deletions agents/src/voice/agent_activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
FunctionCallOutput,
} from '../llm/chat_context.js';
import { LLM, type LLMStream } from '../llm/llm.js';
import { type GenerationCreatedEvent, RealtimeError } from '../llm/realtime.js';
import { type Tool, ToolContext, ToolFlag, Toolset, tool } from '../llm/tool_context.js';
import { Future, Task } from '../utils.js';
import { AgentTask, _getActivityTaskInfo } from './agent.js';
Expand Down Expand Up @@ -1096,6 +1097,103 @@ describe('AgentActivity - interrupted tool completion', () => {
});
});

describe('AgentActivity - realtime reply chat context push', () => {
function buildRealtimeReplyActivity(updateError?: unknown) {
const generationEvent = {} as GenerationCreatedEvent;
const realtimeSession = {
chatCtx: ChatContext.empty(),
tools: ToolContext.empty(),
updateChatCtx: vi.fn(async (chatCtx: ChatContext) => {
if (updateError) {
throw updateError;
}
realtimeSession.chatCtx = chatCtx.copy();
}),
updateTools: vi.fn(async () => {}),
updateOptions: vi.fn(),
generateReply: vi.fn(async () => generationEvent),
};
const activity = {
realtimeSession,
toolChoice: undefined,
_onEnterIgnoredTools: () => [],
agent: { _chatCtx: ChatContext.empty() },
agentSession: {
_conversationItemAdded: vi.fn(),
},
realtimeGenerationTask: vi.fn(async () => {}),
logger: { info() {}, debug() {}, warn: vi.fn(), error: vi.fn() },
};
Object.setPrototypeOf(activity, AgentActivity.prototype);

return { activity, realtimeSession };
}

async function runRealtimeReplyTask(activity: unknown, speechHandle: SpeechHandle) {
const realtimeReplyTask = (
AgentActivity.prototype as unknown as {
realtimeReplyTask(
this: unknown,
args: {
speechHandle: SpeechHandle;
modelSettings: { toolChoice?: never };
abortController: AbortController;
userInput: string;
},
): Promise<void>;
}
).realtimeReplyTask;

await realtimeReplyTask.call(activity, {
speechHandle,
modelSettings: {},
abortController: new AbortController(),
userInput: 'hello',
});
}

it('generates a reply when updateChatCtx succeeds', async () => {
const { activity, realtimeSession } = buildRealtimeReplyActivity();
const handle = SpeechHandle.create();
handle._authorizeGeneration();

await runRealtimeReplyTask(activity, handle);

expect(realtimeSession.generateReply).toHaveBeenCalledTimes(1);
expect(activity.realtimeGenerationTask).toHaveBeenCalledTimes(1);
expect(handle.done()).toBe(false);
});

it('still generates a reply when updateChatCtx raises RealtimeError', async () => {
const { activity, realtimeSession } = buildRealtimeReplyActivity(
new RealtimeError('update_chat_ctx timed out.'),
);
const handle = SpeechHandle.create();
handle._authorizeGeneration();

await runRealtimeReplyTask(activity, handle);

expect(realtimeSession.generateReply).toHaveBeenCalledTimes(1);
expect(activity.realtimeGenerationTask).toHaveBeenCalledTimes(1);
expect(handle.done()).toBe(false);
expect(activity.agent._chatCtx.items.some((item) => item.type === 'message')).toBe(true);
});

it('marks the speech handle failed for unexpected updateChatCtx errors', async () => {
const error = new Error('boom');
const { activity, realtimeSession } = buildRealtimeReplyActivity(error);
const handle = SpeechHandle.create();
handle._authorizeGeneration();

await runRealtimeReplyTask(activity, handle);

expect(realtimeSession.generateReply).not.toHaveBeenCalled();
expect(activity.realtimeGenerationTask).not.toHaveBeenCalled();
expect(handle.done()).toBe(true);
expect(handle.exception()).toBe(error);
});
});

describe('AgentActivity - interruption while waiting for tools', () => {
function buildToolOutput() {
const call = FunctionCall.create({
Expand Down
19 changes: 18 additions & 1 deletion agents/src/voice/agent_activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
type InputTranscriptionCompleted,
LLM,
type MessageGeneration,
RealtimeError,
RealtimeModel,
type RealtimeModelError,
type RealtimeSession,
Expand Down Expand Up @@ -4077,7 +4078,23 @@ export class AgentActivity implements RecognitionHooks {
role: 'user',
content: userInput,
});
await this.realtimeSession.updateChatCtx(chatCtx);
try {
await this.realtimeSession.updateChatCtx(chatCtx);
} catch (error) {
if (error instanceof RealtimeError) {
this.logger.warn(
{ error: error.message },
'failed to update the chat context before generating the reply',
);
} else {
this.logger.error(
{ error },
'failed to update the chat context before generating the reply',
);
speechHandle._markDone(error);
return;
}
}
this.agent._chatCtx.insert(message);
this.agentSession._conversationItemAdded(message);
}
Expand Down
20 changes: 20 additions & 0 deletions agents/src/voice/speech_handle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,23 @@ describe('SpeechHandle._markDone - generation completion', () => {
expect(outcome).toBe('resolved');
});
});

describe('SpeechHandle.exception', () => {
it('throws when the handle is not done yet', () => {
const handle = SpeechHandle.create();
expect(() => handle.exception()).toThrow(/not done yet/);
});

it('returns undefined when the handle completed without an error', () => {
const handle = SpeechHandle.create();
handle._markDone();
expect(handle.exception()).toBeUndefined();
});

it('returns the error passed to _markDone', () => {
const handle = SpeechHandle.create();
const error = new Error('realtime failed');
handle._markDone(error);
expect(handle.exception()).toBe(error);
});
});
19 changes: 18 additions & 1 deletion agents/src/voice/speech_handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export class SpeechHandle {
private doneFut = new Future<void>();
private generations: Future<void>[] = [];
private _chatItems: ChatItem[] = [];
private _error: unknown;

/** @internal */
_tasks: Task<void>[] = [];
Expand Down Expand Up @@ -183,6 +184,19 @@ export class SpeechHandle {
return this.doneFut.done;
}

/**
* Returns the error that caused this SpeechHandle to complete, if any.
*
* @throws Error if the SpeechHandle is not done yet.
*/
exception(): unknown {
if (!this.doneFut.done) {
throw new Error('SpeechHandle is not done yet');
}

return this._error;
}
Comment thread
toubatbrian marked this conversation as resolved.

get chatItems(): ChatItem[] {
return this._chatItems;
}
Expand Down Expand Up @@ -357,8 +371,11 @@ export class SpeechHandle {
}

/** @internal */
_markDone(): void {
_markDone(error?: unknown): void {
if (!this.doneFut.done) {
if (error !== undefined) {
this._error = error;
}
this.doneFut.resolve();
}

Expand Down
27 changes: 26 additions & 1 deletion agents/src/voice/testing/run_result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ToolContext, tool } from '../../llm/tool_context.js';
import { Agent } from '../agent.js';
import { performToolExecutions } from '../generation.js';
import { SpeechHandle } from '../speech_handle.js';
import { activeMockTools, withMockTools } from './run_result.js';
import { RunResult, activeMockTools, withMockTools } from './run_result.js';

class AgentA extends Agent {
constructor() {
Expand Down Expand Up @@ -180,3 +180,28 @@ describe('withMockTools', () => {
expect(output.output[0]?.toolCallOutput?.isError).toBe(true);
});
});

describe('RunResult speech handle error propagation', () => {
it('rejects when the last SpeechHandle completed with an error', async () => {
const run = new RunResult();
const handle = SpeechHandle.create();
run._watchHandle(handle);

const error = new Error('update_chat_ctx failed unexpectedly');
handle._markDone(error);
run._markDoneIfNeeded(handle);

await expect(run.wait()).rejects.toThrow('update_chat_ctx failed unexpectedly');
});

it('resolves when the last SpeechHandle completed without an error', async () => {
const run = new RunResult();
const handle = SpeechHandle.create();
run._watchHandle(handle);

handle._markDone();
run._markDoneIfNeeded(handle);

await expect(run.wait()).resolves.toBe(run);
});
});
10 changes: 10 additions & 0 deletions agents/src/voice/testing/run_result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,16 @@ export class RunResult<T = unknown> {
return;
}

// Propagate speech-handle errors (e.g. LLM or realtime failures), matching
// Python RunResult which rejects on SpeechHandle._error before final output.
const handleError = this.lastSpeechHandle.exception();
if (handleError !== undefined && handleError !== null) {
this.doneFut.reject(
handleError instanceof Error ? handleError : new Error(String(handleError)),
);
return;
}

const finalOutput = this.lastSpeechHandle._maybeRunFinalOutput;
if (finalOutput instanceof Error) {
this.doneFut.reject(finalOutput);
Expand Down
2 changes: 1 addition & 1 deletion plugins/openai/src/realtime/realtime_model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,7 @@ export class RealtimeSession extends llm.RealtimeSession {
const timeoutController = new AbortController();
const timeoutPromise = delay(5000, { signal: timeoutController.signal }).then(() => {
cleanupTimedOutFutures();
throw new Error('Chat ctx update events timed out');
throw new llm.RealtimeError('update_chat_ctx timed out.');
});

try {
Expand Down
Loading