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

Expose speech generation errors via `SpeechHandle.exception()`.
13 changes: 12 additions & 1 deletion agents/src/voice/agent_activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {
Future,
IdleTimeoutError,
Task,
asError,
cancelAndWait,
delay,
isDevMode,
Expand Down Expand Up @@ -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;
}
Comment on lines 3903 to +3915

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.

🔍 Reachability of try/catch depends on ThrowsPromise.race semantics

In realtimeReplyTask, waitIfNotInterrupted at agents/src/voice/agent_activity.ts:3900 races generationPromise against the interrupt future using ThrowsPromise.race. If generationPromise rejects and ThrowsPromise.race propagates that rejection (like standard Promise.race), the error would throw from line 3900 and never reach the try/catch at lines 3907-3915. The try/catch is only reachable if ThrowsPromise.race treats rejections as settled values rather than propagating them. The existing pre-PR code also did await generationPromise after waitIfNotInterrupted without a try/catch, suggesting ThrowsPromise does NOT propagate rejections from race — otherwise the old code would have had unhandled errors in production. This is worth confirming against the @livekit/throws-transformer implementation.

(Refers to lines 3900-3915)

Open in Devin Review

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


await this.realtimeGenerationTask(
speechHandle,
generationEvent,
Expand Down
41 changes: 41 additions & 0 deletions agents/src/voice/speech_handle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
//
Expand Down
19 changes: 18 additions & 1 deletion agents/src/voice/speech_handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -357,8 +373,9 @@ export class SpeechHandle {
}

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

Expand Down
15 changes: 14 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,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.');
});
});
6 changes: 6 additions & 0 deletions agents/src/voice/testing/run_result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,12 @@ export class RunResult<T = unknown> {
return;
}

const speechError = this.lastSpeechHandle.exception();
if (speechError) {
this.doneFut.reject(speechError);
return;
}
Comment on lines +246 to +250

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.

🔍 Error propagation only checks the last-completing speech handle

In RunResult._markDone() (agents/src/voice/testing/run_result.ts:246), the new exception() check only inspects this.lastSpeechHandle, which is set to whichever handle last triggered _markDoneIfNeeded (agents/src/voice/testing/run_result.ts:226-227). If multiple speech handles are watched and the errored handle completes before a non-errored one, the error is silently lost because lastSpeechHandle will point to the non-errored handle. In practice this is unlikely since typical runs involve a single speech handle, but multi-handle scenarios (e.g. tool-triggered child handles) could mask errors.

Open in Devin Review

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


const finalOutput = this.lastSpeechHandle._maybeRunFinalOutput;
if (finalOutput instanceof Error) {
this.doneFut.reject(finalOutput);
Expand Down
Loading