Skip to content

AgentSession.closeImplInner throws "Cannot read properties of undefined (reading 'currentSpeech')" when close races an activity transition #1871

Description

@ralonsom

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.tscloseImplInner.

Happy to send a PR if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions