Skip to content

e2e_latency is never set on the agent turn that follows a handoff #7157

Description

@marctorsoc

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions