From 8459a809e79c11ebb5a2d5c2df9f7e9cac70abcf Mon Sep 17 00:00:00 2001 From: "rosetta-livekit-bot[bot]" <282703043+rosetta-livekit-bot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 04:13:21 +0000 Subject: [PATCH] fix(voice): expose speech handle generation errors --- .changeset/speech-handle-exception.md | 5 +++ agents/src/voice/agent_activity.ts | 13 ++++++- agents/src/voice/speech_handle.test.ts | 41 +++++++++++++++++++++ agents/src/voice/speech_handle.ts | 19 +++++++++- agents/src/voice/testing/run_result.test.ts | 15 +++++++- agents/src/voice/testing/run_result.ts | 6 +++ 6 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 .changeset/speech-handle-exception.md diff --git a/.changeset/speech-handle-exception.md b/.changeset/speech-handle-exception.md new file mode 100644 index 000000000..8cacc4c26 --- /dev/null +++ b/.changeset/speech-handle-exception.md @@ -0,0 +1,5 @@ +--- +'@livekit/agents': patch +--- + +Expose speech generation errors via `SpeechHandle.exception()`. diff --git a/agents/src/voice/agent_activity.ts b/agents/src/voice/agent_activity.ts index 656646681..10c91ca88 100644 --- a/agents/src/voice/agent_activity.ts +++ b/agents/src/voice/agent_activity.ts @@ -75,6 +75,7 @@ import { Future, IdleTimeoutError, Task, + asError, cancelAndWait, delay, isDevMode, @@ -3902,7 +3903,17 @@ export class AgentActivity implements RecognitionHooks { return; } - const generationEvent = await generationPromise; + let generationEvent: GenerationCreatedEvent; + try { + generationEvent = await generationPromise; + } catch (error) { + const cause = asError(error); + this.logger.error(cause, 'failed to generate a reply'); + speechHandle._markDone(cause); + this.agentSession._updateAgentState('listening'); + return; + } + await this.realtimeGenerationTask( speechHandle, generationEvent, diff --git a/agents/src/voice/speech_handle.test.ts b/agents/src/voice/speech_handle.test.ts index 3ae783e1d..12f02e2e8 100644 --- a/agents/src/voice/speech_handle.test.ts +++ b/agents/src/voice/speech_handle.test.ts @@ -116,6 +116,47 @@ describe('SpeechHandle - awaitable protocol', () => { }); }); +describe('SpeechHandle.exception', () => { + it('does not throw from await when generation failed', async () => { + const handle = SpeechHandle.create(); + const error = new Error('generate_reply timed out.'); + + handle._markDone(error); + + const result = await handle; + expect(result).toBe(handle); + + await handle.waitForPlayout(); + expect(handle.exception()).toBe(error); + }); + + it('returns undefined when generation did not fail', async () => { + const handle = SpeechHandle.create(); + handle._markDone(); + + await handle; + expect(handle.exception()).toBeUndefined(); + }); + + it('throws if the handle is not done yet', () => { + const handle = SpeechHandle.create(); + + expect(() => handle.exception()).toThrow('SpeechHandle is not done yet'); + }); + + it('ignores errors after the handle is already done', () => { + const handle = SpeechHandle.create(); + const first = new Error('first'); + const second = new Error('second'); + + handle._markDone(first); + handle._markDone(); + handle._markDone(second); + + expect(handle.exception()).toBe(first); + }); +}); + describe('SpeechHandle - simulated tool-call deadlock scenario', () => { // Models the previously-broken pattern: // diff --git a/agents/src/voice/speech_handle.ts b/agents/src/voice/speech_handle.ts index 2c1fcb111..43cbe3d29 100644 --- a/agents/src/voice/speech_handle.ts +++ b/agents/src/voice/speech_handle.ts @@ -93,6 +93,8 @@ export class SpeechHandle { /** @internal - used by AgentTask/RunResult final output plumbing */ _maybeRunFinalOutput?: unknown; + private error?: Error; + private itemAddedCallbacks: Set<(item: ChatItem) => void> = new Set(); private doneCallbacks: Set<(sh: SpeechHandle) => void> = new Set(); @@ -183,6 +185,20 @@ export class SpeechHandle { return this.doneFut.done; } + /** + * Return the error that caused this speech to fail, if any. + * + * Awaiting a SpeechHandle never throws; call this method after the handle is + * done to check whether the generation failed. + */ + exception(): Error | undefined { + if (!this.doneFut.done) { + throw new Error('SpeechHandle is not done yet'); + } + + return this.error; + } + get chatItems(): ChatItem[] { return this._chatItems; } @@ -357,8 +373,9 @@ export class SpeechHandle { } /** @internal */ - _markDone(): void { + _markDone(error?: Error): void { if (!this.doneFut.done) { + this.error = error; this.doneFut.resolve(); } diff --git a/agents/src/voice/testing/run_result.test.ts b/agents/src/voice/testing/run_result.test.ts index 3aa01aba0..66bd04ce1 100644 --- a/agents/src/voice/testing/run_result.test.ts +++ b/agents/src/voice/testing/run_result.test.ts @@ -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() { @@ -180,3 +180,16 @@ describe('withMockTools', () => { expect(output.output[0]?.toolCallOutput?.isError).toBe(true); }); }); + +describe('RunResult', () => { + it('propagates speech handle errors', async () => { + const runResult = new RunResult(); + const handle = SpeechHandle.create(); + const error = new Error('generate_reply timed out.'); + + runResult._watchHandle(handle); + handle._markDone(error); + + await expect(runResult.wait()).rejects.toThrow('generate_reply timed out.'); + }); +}); diff --git a/agents/src/voice/testing/run_result.ts b/agents/src/voice/testing/run_result.ts index 21c3a9e11..25b57a788 100644 --- a/agents/src/voice/testing/run_result.ts +++ b/agents/src/voice/testing/run_result.ts @@ -243,6 +243,12 @@ export class RunResult { return; } + const speechError = this.lastSpeechHandle.exception(); + if (speechError) { + this.doneFut.reject(speechError); + return; + } + const finalOutput = this.lastSpeechHandle._maybeRunFinalOutput; if (finalOutput instanceof Error) { this.doneFut.reject(finalOutput);