Summary
AgentSession.closeImplInner reads this.activity across multiple await boundaries without capturing a local reference. If session.close() races with an activity transition that sets this.activity = undefined (e.g. an agent/sub-agent handoff or activity swap), this.activity becomes undefined mid-method and the currentSpeech access throws:
TypeError: Cannot read properties of undefined (reading 'currentSpeech')
at AgentSession.closeImplInner (.../voice/agent_session.js:968)
await this.activity.currentSpeech?.waitForPlayout();
at AgentSession.closeImpl (.../voice/agent_session.js:945)
at AgentSession.close (.../voice/agent_session.js:756) // CloseReason.USER_INITIATED
at .../ipc/job_proc_lazy_main.js:135 // waitUntilTimeout(sessionClosePromise, SESSION_CLOSE_TIMEOUT)
Version
@livekit/agents 1.4.8. Confirmed the same pattern is present in 1.4.9 and main (the close path still reads this.activity across awaits without a local capture).
Root cause
In closeImplInner (compiled excerpt, 1.4.8):
if (this.activity) {
if (!drain) {
try { await this.activity.interrupt({ force: true }).await; } catch (e) { ... }
}
await this.activity.drain(); // <-- await
await this.activity.currentSpeech?.waitForPlayout();// <-- this.activity may now be undefined -> throws
if (reason !== CloseReason.ERROR) {
this.activity.commitUserTurn({ ... });
}
try { this.activity.detachAudioInput(); } catch (e) {}
}
...
this.emit(AgentSessionEventTypes.Close, createCloseEvent(reason, error)); // never reached on throw
this.activity is set to undefined in the activity-update path (this.activity = void 0; this.activity = this.nextActivity;). When a close() is in flight and an activity transition runs concurrently during one of the awaits above, the guard if (this.activity) has already passed but this.activity is now undefined, so this.activity.currentSpeech throws.
Because the throw happens before this.emit(Close, ...), the Close event is never emitted — so any application logic that depends on the Close event (we use it to classify why a call ended) silently never runs for that session.
Reproduction conditions
- Agent/sub-agent handoff (or any activity swap) in progress, and
session.close(CloseReason.USER_INITIATED) triggered around the same time (in our case ctx.shutdown() on call end).
It's timing-dependent, so not 100% reproducible, but recurs in production under normal call-end + handoff overlap.
Suggested fix
Capture the activity in a local before the awaits and operate on that (or make the accesses null-safe), so a concurrent transition can't turn this.activity undefined mid-method:
const activity = this.activity;
if (activity) {
if (!drain) { try { await activity.interrupt({ force: true }).await; } catch (e) { ... } }
await activity.drain();
await activity.currentSpeech?.waitForPlayout();
if (reason !== CloseReason.ERROR) activity.commitUserTurn({ ... });
try { activity.detachAudioInput(); } catch (e) {}
}
Source: agents/src/voice/agent_session.ts — closeImplInner.
Happy to send a PR if useful.
Summary
AgentSession.closeImplInnerreadsthis.activityacross multipleawaitboundaries without capturing a local reference. Ifsession.close()races with an activity transition that setsthis.activity = undefined(e.g. an agent/sub-agent handoff or activity swap),this.activitybecomesundefinedmid-method and thecurrentSpeechaccess throws:Version
@livekit/agents1.4.8. Confirmed the same pattern is present in 1.4.9 andmain(the close path still readsthis.activityacross awaits without a local capture).Root cause
In
closeImplInner(compiled excerpt, 1.4.8):this.activityis set toundefinedin the activity-update path (this.activity = void 0; this.activity = this.nextActivity;). When aclose()is in flight and an activity transition runs concurrently during one of theawaits above, the guardif (this.activity)has already passed butthis.activityis nowundefined, sothis.activity.currentSpeechthrows.Because the throw happens before
this.emit(Close, ...), theCloseevent is never emitted — so any application logic that depends on theCloseevent (we use it to classify why a call ended) silently never runs for that session.Reproduction conditions
session.close(CloseReason.USER_INITIATED)triggered around the same time (in our casectx.shutdown()on call end).It's timing-dependent, so not 100% reproducible, but recurs in production under normal call-end + handoff overlap.
Suggested fix
Capture the activity in a local before the awaits and operate on that (or make the accesses null-safe), so a concurrent transition can't turn
this.activityundefinedmid-method:Source:
agents/src/voice/agent_session.ts—closeImplInner.Happy to send a PR if useful.