Skip to content

feat(telemetry): one agent_turn span per speech handle - #7143

Open
davidzhao wants to merge 2 commits into
dz/telemetry-coveragefrom
dz/telemetry-agent-turn
Open

feat(telemetry): one agent_turn span per speech handle#7143
davidzhao wants to merge 2 commits into
dz/telemetry-coveragefrom
dz/telemetry-agent-turn

Conversation

@davidzhao

@davidzhao davidzhao commented Sep 6, 2026

Copy link
Copy Markdown
Member

What

A reply that calls a tool runs two generations (LLM steps) in two tasks. They were two agent_turn spans linked only by lk.parent_generation_id, so one response rendered as two turns. A speech handle is now exactly one agent_turn.

How

  • The speech handle owns the span. The first reply task (pipeline, realtime, or say) opens agent_turn under agent_session; the follow-up generation after a tool call runs in a new task but continues the open span instead of opening a second one. It ends with the speech in SpeechHandle._mark_done, whichever step that was on, recording the speech's error (redaction-aware) and the gen_ai.invoke_agent.duration metric for the whole turn.
  • Each generation is a generation event on the span with its lk.generation_id and lk.parent_generation_id. On the span itself lk.generation_id names the latest generation and the new lk.generation_count how many there were (more than one means tools ran before the final reply). lk.speech_id is set at creation.
  • Every step's llm_node, function_tool, tts_node, realtime_inference and agent_speaking nest under the one turn.
  • A preemptive generation discarded for a successor answering the same user turn (a newer attempt, or the real reply after on_user_turn_completed changed the transcript) hands its open agent_turn to that successor: the wasted generation stays visible under the one turn with a preemptive_generation_discarded event, and lk.speech_id follows the speech that answered. An attempt cancelled with no successor yet (the user resumed) still ends as its own turn. The queue-wait and interruption helpers tolerate a span already ended with the speech.
agent_turn  (lk.speech_id, lk.generation_count=2; events: generation ×2)
├─ llm_node          generation 1: the tool call
├─ function_tool
├─ llm_node          generation 2: the reply
├─ tts_node
└─ agent_speaking

Tests

tests/test_agent_turn_span.py: a tool-calling reply through the fake session yields one agent_turn with two generation events, both llm_nodes, the tool and the speech inside it, and the turn outlasting the last child; a plain reply is one generation. Existing coverage (test_coverage_spans, test_eou_wait_span, test_agent_session) passes unchanged.

Stacked on #7137.

🤖 Generated with Claude Code

@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from bc899e3 to e3a6d53 Compare September 6, 2026 08:09
@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch 2 times, most recently from 1cd55fc to cb30881 Compare September 6, 2026 16:48
@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from cb30881 to 8711b59 Compare September 6, 2026 16:58
@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from 8711b59 to fa6a7d9 Compare September 6, 2026 17:13
@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from fa6a7d9 to 8548b9c Compare September 6, 2026 21:10
@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from 8548b9c to a8dd605 Compare September 6, 2026 22:13
@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch 3 times, most recently from 4d52f09 to 8f6a814 Compare September 6, 2026 23:04
@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch 3 times, most recently from 34962fe to 93d145c Compare September 7, 2026 00:10
@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from 93d145c to 18cc1b4 Compare September 7, 2026 03:54
@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from 18cc1b4 to c17f8b5 Compare September 7, 2026 04:03
@davidzhao
davidzhao marked this pull request as ready for review September 7, 2026 04:07
@davidzhao
davidzhao requested a review from a team as a code owner September 7, 2026 04:07
devin-ai-integration[bot]

This comment was marked as resolved.

@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from c17f8b5 to 5fe9604 Compare September 7, 2026 04:14
@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from 98737dc to 1971bb7 Compare September 8, 2026 05:21
@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from 1971bb7 to 1fc0e02 Compare September 8, 2026 05:33
@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from 1fc0e02 to 301ed4e Compare September 8, 2026 05:48
@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from 301ed4e to 198ad41 Compare September 8, 2026 05:58
@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from 198ad41 to 028b552 Compare September 8, 2026 06:10
@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from 028b552 to 836bec6 Compare September 8, 2026 07:30

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 1 new potential issue.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment on lines +392 to +393
if isinstance(error, Exception):
trace_utils.record_exception(span, error)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Pipeline failures leave turns successful

When pipeline inference stores a failure in SpeechHandle._error, _mark_done() receives no error. The overall turn then closes successfully despite the failed reply.

Suggested change
if isinstance(error, Exception):
trace_utils.record_exception(span, error)
effective_error = error or self._error
if isinstance(effective_error, Exception):
trace_utils.record_exception(span, effective_error)
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from 836bec6 to c88ad15 Compare September 9, 2026 05:51

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 2 new potential issues.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Turn failures can expose customer content

Failed reply tasks call _mark_done without their exception. The original traceback enters logs instead of the span's redaction-aware exception path.

(Refers to this code)

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +298 to +299
if (carry := discarded._take_agent_turn()) is not None:
successor._continue_agent_turn(carry, discarded=discarded)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Rapid preemptive retries duplicate turns

When replacement precedes the canceled reply task's first run, _continue_discarded_turn transfers nothing. Both tasks later create separate spans for one turn.

Prompt for agents
Handle preemptive replacements whose discarded SpeechHandle has not opened its agent_turn span yet. In livekit-agents/livekit/agents/voice/agent_activity.py, _continue_discarded_turn currently transfers only an existing span. A newly created task may not have run before another synchronous preemptive callback replaces it, so _take_agent_turn returns None. The canceled task can later enter _agent_turn and create one span while the successor creates another. Preserve the logical turn association across this pre-start state, or prevent the canceled handle from opening an independent span. Add coverage where replacement occurs before the discarded task first enters _agent_turn.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems valid.

@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch 2 times, most recently from a5d279a to b8b852f Compare September 9, 2026 05:56

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment on lines +387 to +399
span, self._agent_turn_span = self._agent_turn_span, None
if span is None or not span.is_recording():
return
from ..telemetry import otel_metrics, utils as trace_utils

if isinstance(error, Exception):
trace_utils.record_exception(span, error)
if self._agent_turn_started_at is not None and self._agent_turn_agent_name is not None:
otel_metrics.record_invoke_agent_duration(
time.perf_counter() - self._agent_turn_started_at,
agent_name=self._agent_turn_agent_name,
)
span.end()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Trace sampling disables duration metrics

When tracing drops an agent_turn, _end_agent_turn returns before recording its invoke-agent duration metric. Trace sampling therefore removes latency observations.

Suggested change
span, self._agent_turn_span = self._agent_turn_span, None
if span is None or not span.is_recording():
return
from ..telemetry import otel_metrics, utils as trace_utils
if isinstance(error, Exception):
trace_utils.record_exception(span, error)
if self._agent_turn_started_at is not None and self._agent_turn_agent_name is not None:
otel_metrics.record_invoke_agent_duration(
time.perf_counter() - self._agent_turn_started_at,
agent_name=self._agent_turn_agent_name,
)
span.end()
span, self._agent_turn_span = self._agent_turn_span, None
if span is None:
return
from ..telemetry import otel_metrics, utils as trace_utils
if self._agent_turn_started_at is not None and self._agent_turn_agent_name is not None:
otel_metrics.record_invoke_agent_duration(
time.perf_counter() - self._agent_turn_started_at,
agent_name=self._agent_turn_agent_name,
)
if not span.is_recording():
return
if isinstance(error, Exception):
trace_utils.record_exception(span, error)
span.end()
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from 652c975 to b8b852f Compare September 9, 2026 07:15
@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from b8b852f to 6bc2a3c Compare September 9, 2026 07:30
@davidzhao
davidzhao force-pushed the dz/telemetry-agent-turn branch from 6bc2a3c to 6050ce8 Compare September 9, 2026 07:37
davidzhao and others added 2 commits September 9, 2026 00:57
A reply that calls a tool runs two generations (LLM steps) in two tasks; they
were two agent_turn spans linked only by lk.parent_generation_id, so one
response read as two turns. The speech handle now owns a single agent_turn for
its whole life: the first reply task opens it, a follow-up generation continues
it, and it ends with the speech in SpeechHandle._mark_done (recording the
speech's error, if any, and the invoke_agent duration for the whole turn).

Each generation is a 'generation' event on the span carrying its id and parent
id; lk.generation_id names the latest one and lk.generation_count how many
there were. Tool, inference and playout spans of every step nest under the one
turn.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the successor

A preemptive attempt invalidated at commit, or superseded by a newer attempt,
was cancelled and its successor opened a second agent_turn for the same user
turn. The discarded speech now detaches its open span and the successor
continues it, with a preemptive_generation_discarded event; an attempt
cancelled with no successor still ends as its own turn.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@chenghao-mou chenghao-mou left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm. One of the Devin comment seems valid when the task is only cancelled later:

`SpeechHandle._cancel()` does not immediately cancel the task. It:

1. Sets `_interrupt_fut`.
2. Leaves the task runnable.
3. Calls `task.cancel()` only after the five-second interruption timeout.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants