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

feat(voice/avatar): add avatar join waiting and cleanup participant on close
45 changes: 45 additions & 0 deletions agents/src/voice/avatar/avatar_session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import type { Room } from '@livekit/rtc-node';
import { RoomEvent, TrackKind } from '@livekit/rtc-node';
import type { TypedEventEmitter as TypedEmitter } from '@livekit/typed-emitter';
import { RoomServiceClient } from 'livekit-server-sdk';
import { EventEmitter } from 'node:events';
import { getJobContext } from '../../job.js';
import { log } from '../../log.js';
Expand Down Expand Up @@ -88,11 +89,55 @@ export class AvatarSession extends (EventEmitter as new () => TypedEmitter<Avata
return undefined;
}

/**
* Wait until the avatar participant has joined the room and published its video track.
*
* @param timeout - Timeout in milliseconds. Pass `null` to wait indefinitely.
*/
async waitForJoin({ timeout = 30000 }: { timeout?: number | null } = {}): Promise<void> {
if (!this.#waitAvatarJoinPromise) return;
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.

🔴 waitForJoin() silently resolves immediately when room is not yet connected at start() time

When start() is called while the room is not yet connected, #waitAvatarJoinPromise is not set — instead, only a ConnectionStateChanged listener is registered (avatar_session.ts:87), and #startWaitAvatarJoin() runs later when the room connects. However, waitForJoin() at line 98 checks if (!this.#waitAvatarJoinPromise) return; and returns immediately (resolves successfully) if the promise hasn't been created yet.

This means the typical usage pattern await avatarSession.start(agentSession, room); await avatarSession.waitForJoin(); will silently succeed without actually waiting for the avatar to join whenever the room isn't connected at start() time. Callers will proceed under the false assumption that the avatar participant is present in the room.

Prompt for agents
The problem is in `waitForJoin()` at line 98 of `agents/src/voice/avatar/avatar_session.ts`. When `start()` is called with a room that is not yet connected, `#waitAvatarJoinPromise` is not set until the `ConnectionStateChanged` event fires (see `start()` lines 84-88 and `#onConnectionStateChanged` at line 221). But `waitForJoin()` returns immediately if `#waitAvatarJoinPromise` is falsy.

To fix this, `waitForJoin` needs to handle the case where the join promise hasn't been created yet. One approach: always create `#waitAvatarJoinPromise` eagerly in `start()` (even when the room isn't connected yet), and have it internally wait for the connection first before waiting for the participant. Another approach: in `waitForJoin`, if `#waitAvatarJoinPromise` is not yet set but `start()` has been called (i.e. `#room` is set), wait for the promise to be created (e.g. using a Future/deferred that resolves when `#startWaitAvatarJoin` runs) before proceeding with the timeout race.
Open in Devin Review

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

if (timeout === null) {
await this.#waitAvatarJoinPromise;
return;
}

let timer: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
this.#waitAvatarJoinPromise,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error('timed out waiting for avatar participant')),
timeout,
);
}),
]).finally(() => clearTimeout(timer));
}

/**
* Release any resources owned by this avatar session. Default implementation is a no-op;
* subclasses can override to perform cleanup.
*/
async aclose(): Promise<void> {
const roomName = this.#room?.name;
if (this.#room?.isConnected && roomName !== undefined) {
const jobCtx = getJobContext(false);
if (jobCtx !== undefined) {
try {
const client = new RoomServiceClient(
jobCtx.info.url,
jobCtx.info.apiKey,
jobCtx.info.apiSecret,
);
await client.removeParticipant(roomName, this.avatarIdentity);
} catch (error) {
this.#logger.warn(
{ error, identity: this.avatarIdentity },
'failed to remove avatar participant',
);
}
}
}

if (this.#agentSession) {
this.#agentSession.off(
AgentSessionEventTypes.ConversationItemAdded,
Expand Down
Loading