Bug Description
ChatMessage.metrics["e2e_latency"] is absent on the first agent turn after an agent handoff.
The value is computed from the preceding user turn's metrics, which are held by the AgentActivity that handled that user turn. A handoff creates a new activity, and the incoming agent's first generation starts with no user metrics at all, so the branch that sets e2e_latency never runs.
The SDK already recognises that this value has to outlive a single generation. When a reply produces only tool calls and no speech, the metrics are threaded into the follow-up generation explicitly (livekit-agents/livekit/agents/voice/agent_activity.py):
tool_response_task = self._create_speech_task(
self._pipeline_reply_task(
...
# in case the current reply only generated tools (no speech), re-use the current user_metrics for the next
# tool response generation
_previous_user_metrics=user_metrics if not forwarded_text else None,
),
...
)
But that threading is internal to one AgentActivity. A few lines above, the handoff path calls self._session.update_agent(new_agent_task) and sets draining = True; the incoming agent's activity then generates its reply with _previous_user_metrics left at its None default. _previous_user_metrics is a private keyword argument on an internal coroutine, so application code cannot supply it either.
The turns this hits are the worst ones to lose. A post-handoff turn also pays for the tool call and the new agent's context build, so it is reliably the slowest turn in a call — dropping e2e_latency there biases every latency aggregate optimistically.
It is not only the field on the message:
- The OpenTelemetry histogram
lk.agents.turn.e2e_latency (telemetry/otel_metrics.py) is recorded from the same report, so a dashboard built on it silently omits post-handoff turns rather than showing them as slow.
- The
lk.e2e_latency span attribute (trace_types.ATTR_E2E_LATENCY) is set in the same branch and is likewise absent.
- The documented alternative — correlating
EOUMetrics / LLMMetrics / TTSMetrics by speech_id, per Measure conversation latency — is a sum of pipeline stages, not the same measurement, and the docs point at e2e_latency as the simplest source.
Expected Behavior
e2e_latency is reported on any agent turn that answers a user turn, including one spoken by an agent reached through a handoff. The field is documented as "time from when the user stopped speaking to when the agent began responding", which does not depend on which agent ends up answering.
Speech that is not a reply to a user turn — a proactive greeting, an idle say() — should continue to carry no e2e_latency, as it does today.
Reproduction Steps
1. Build a two-agent session where a @function_tool returns the second agent (a standard handoff).
2. Subscribe to conversation_item_added and log ev.item.metrics for assistant messages.
3. Say something that triggers the handoff.
The assistant turn spoken by the second agent carries llm_node_ttft, tts_node_ttfb,
started_speaking_at and stopped_speaking_at, but no e2e_latency. A turn within a single
agent, including one that chains tool calls, carries it as expected.
Operating System
macOS 26.5
Models Used
Deepgram Flux (STT), OpenAI via LiveKit Inference (LLM), Cartesia Sonic (TTS) — STT-LLM-TTS pipeline
Package Versions
livekit==1.1.15
livekit-agents==1.7.1
livekit-api==1.2.1
livekit-protocol==1.1.26
python 3.12
Session/Room/Call IDs
No response
Proposed Solution
# Hold the pending user-turn metrics on the session rather than on the AgentActivity, so
# they survive the activity swap a handoff performs, and clear them once a turn has
# consumed them — which keeps say()-driven speech correctly without an e2e_latency.
Additional Context
Our workaround, for anyone hitting this before it is fixed: remember stopped_speaking_at from each user turn in a conversation_item_added handler, and on the next assistant turn that has started_speaking_at but no e2e_latency, write the difference. The mark has to be consumed once — otherwise a later say() picks up a stale user turn and reports the whole intervening silence as latency.
Screenshots and Recordings
No response
Bug Description
ChatMessage.metrics["e2e_latency"]is absent on the first agent turn after an agent handoff.The value is computed from the preceding user turn's metrics, which are held by the
AgentActivitythat handled that user turn. A handoff creates a new activity, and the incoming agent's first generation starts with no user metrics at all, so the branch that setse2e_latencynever runs.The SDK already recognises that this value has to outlive a single generation. When a reply produces only tool calls and no speech, the metrics are threaded into the follow-up generation explicitly (
livekit-agents/livekit/agents/voice/agent_activity.py):But that threading is internal to one
AgentActivity. A few lines above, the handoff path callsself._session.update_agent(new_agent_task)and setsdraining = True; the incoming agent's activity then generates its reply with_previous_user_metricsleft at itsNonedefault._previous_user_metricsis a private keyword argument on an internal coroutine, so application code cannot supply it either.The turns this hits are the worst ones to lose. A post-handoff turn also pays for the tool call and the new agent's context build, so it is reliably the slowest turn in a call — dropping
e2e_latencythere biases every latency aggregate optimistically.It is not only the field on the message:
lk.agents.turn.e2e_latency(telemetry/otel_metrics.py) is recorded from the same report, so a dashboard built on it silently omits post-handoff turns rather than showing them as slow.lk.e2e_latencyspan attribute (trace_types.ATTR_E2E_LATENCY) is set in the same branch and is likewise absent.EOUMetrics/LLMMetrics/TTSMetricsbyspeech_id, per Measure conversation latency — is a sum of pipeline stages, not the same measurement, and the docs point ate2e_latencyas the simplest source.Expected Behavior
e2e_latencyis reported on any agent turn that answers a user turn, including one spoken by an agent reached through a handoff. The field is documented as "time from when the user stopped speaking to when the agent began responding", which does not depend on which agent ends up answering.Speech that is not a reply to a user turn — a proactive greeting, an idle
say()— should continue to carry noe2e_latency, as it does today.Reproduction Steps
1. Build a two-agent session where a @function_tool returns the second agent (a standard handoff). 2. Subscribe to conversation_item_added and log ev.item.metrics for assistant messages. 3. Say something that triggers the handoff. The assistant turn spoken by the second agent carries llm_node_ttft, tts_node_ttfb, started_speaking_at and stopped_speaking_at, but no e2e_latency. A turn within a single agent, including one that chains tool calls, carries it as expected.Operating System
macOS 26.5
Models Used
Deepgram Flux (STT), OpenAI via LiveKit Inference (LLM), Cartesia Sonic (TTS) — STT-LLM-TTS pipeline
Package Versions
Session/Room/Call IDs
No response
Proposed Solution
Additional Context
Our workaround, for anyone hitting this before it is fixed: remember
stopped_speaking_atfrom each user turn in aconversation_item_addedhandler, and on the next assistant turn that hasstarted_speaking_atbut noe2e_latency, write the difference. The mark has to be consumed once — otherwise a latersay()picks up a stale user turn and reports the whole intervening silence as latency.Screenshots and Recordings
No response