Summary
livekit.rtc.EventEmitter stores handlers in a set and emit() iterates a copy of it, so dispatch order is hash-derived rather than registration-ordered. Any handler that reads a field a peer handler mutates during the same emit gets a non-deterministic result — decided once per process, since handler identities are stable.
Concrete case (livekit-agents 1.6.4)
AgentActivity._on_metrics_collected stamps speech_id onto LLM/TTS metrics by mutating the emitted object (ev.speech_id = speech_handle.id) before re-emitting on the session. A user handler subscribed to the same plugin's metrics_collected therefore sees speech_id either stamped or None, depending on which handler the set yields first.
Because the order is fixed per process, this manifests as an all-or-nothing pattern: entire sessions where 100% of plugin-level metrics are unkeyed, and other sessions at 100% keyed. Across a larger dataset it averaged out to roughly a coin flip.
Reproduction
Minimal repro: subscribe a second handler to an LLM plugin's metrics_collected alongside a running AgentSession, record metrics.speech_id as observed at dispatch time, repeat over fresh processes. In 12 fresh sessions our handler observed the pre-stamp (None) state in 7.
Suggestion
An insertion-ordered container for _events (e.g. dict keyed by handler, which preserves insertion order and keeps O(1) add/remove/dedup) would make dispatch deterministic and match the EventEmitter contract familiar from other ecosystems — and would let internal stamping handlers reliably run before user handlers registered later. Happy to open a PR if the direction is acceptable.
Summary
livekit.rtc.EventEmitterstores handlers in asetandemit()iterates a copy of it, so dispatch order is hash-derived rather than registration-ordered. Any handler that reads a field a peer handler mutates during the same emit gets a non-deterministic result — decided once per process, since handler identities are stable.Concrete case (livekit-agents 1.6.4)
AgentActivity._on_metrics_collectedstampsspeech_idonto LLM/TTS metrics by mutating the emitted object (ev.speech_id = speech_handle.id) before re-emitting on the session. A user handler subscribed to the same plugin'smetrics_collectedtherefore seesspeech_ideither stamped orNone, depending on which handler the set yields first.Because the order is fixed per process, this manifests as an all-or-nothing pattern: entire sessions where 100% of plugin-level metrics are unkeyed, and other sessions at 100% keyed. Across a larger dataset it averaged out to roughly a coin flip.
Reproduction
Minimal repro: subscribe a second handler to an LLM plugin's
metrics_collectedalongside a runningAgentSession, recordmetrics.speech_idas observed at dispatch time, repeat over fresh processes. In 12 fresh sessions our handler observed the pre-stamp (None) state in 7.Suggestion
An insertion-ordered container for
_events(e.g.dictkeyed by handler, which preserves insertion order and keeps O(1) add/remove/dedup) would make dispatch deterministic and match the EventEmitter contract familiar from other ecosystems — and would let internal stamping handlers reliably run before user handlers registered later. Happy to open a PR if the direction is acceptable.