Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 { activeMockTools, RunResult, 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