# When must an instance emit `gen_ai.agent.turn.id` versus inherit the parent turn for agent-to-agent correlation **Date of Record:** 2026 November 14 **Scope:** Applies to OpenTelemetry-style GenAI semantic conventions where spans carry `gen_ai.agent.id`, `gen_ai.agent.turn.id`, and a causal link (`gen_ai.agent.parent.turn.id` or a span `Link`) between an initiating agent turn and a responding agent turn. Does not apply to single-agent request/response spans, nor to tool-call spans that are children of the same turn. ## Core Distinction The failure is a conflation of two distinct relations: **continuation** (the responder is executing inside the causal frame of the requester's turn) versus **delegation** (the responder opens a new turn whose cause is the requester's turn). The invariant is: > A span MUST inherit the parent `gen_ai.agent.turn.id` only if it is a *synchronous continuation* of that turn — i.e., the parent turn is blocked awaiting this span's completion and contributes no independent scheduling. If the responding agent can be invoked, retried, or scheduled independently of the requester's turn lifetime, it MUST mint a fresh `gen_ai.agent.turn.id` and record causality via `gen_ai.agent.parent.turn.id` (or a `Link`), never by reusing the turn ID. Reusing a turn ID across an asynchronous agent boundary is the non-obvious failure: the trace remains *structurally valid* (IDs resolve, parent/child edges form) but the causal graph becomes a lie — two causally distinct turns collapse into one, and any consumer that reasons over turn cardinality (cost attribution, retry deduplication, loop detection) produces wrong answers without erroring. ## What Was Observed A multi-agent pipeline (planner → N workers → aggregator) emitted worker spans with `gen_ai.agent.turn.id` equal to the planner's turn ID whenever the worker was "spawned by" the planner, regardless of whether the planner awaited it. In the synchronous fan-out path this was correct. In the asynchronous path — workers enqueued and drained by a separate pool — three anomalies appeared: 1. **Turn cardinality undercount.** A turn-counting consumer reported 1 turn where 1 planner + 7 workers had actually executed. Cost dashboards attributed all worker tokens to a single turn, masking per-worker cost. 2. **Retry deduplication collapse.** When a worker was retried after a transient failure, both attempts shared the inherited turn ID; a dedup layer keyed on `(agent.id, turn.id)` discarded the successful retry, and the aggregation step consumed a stale failure result. 3. **Causality inversion in loop detection.** A worker that re-invoked the planner produced a graph where the planner's turn had itself as an ancestor at depth 2 (planner turn → worker span inheriting planner turn → planner re-invocation inheriting the same turn). Loop detection that walked `parent.turn.id` saw a self-cycle and aborted a legitimate recovery path. None of these raised a schema-validation error. The attributes were well-typed and referentially resolvable. ## Condition of Validity Inheritance of `gen_ai.agent.turn.id` is valid **iff all** of the following hold: - The child span's execution is bounded by the parent turn: the parent cannot complete, commit, or be cancelled while the child is in flight without the child also being cancelled. - The child is not independently retryable, reschedulable, or persistable beyond the parent turn's lifetime (no durable queue, no outbox, no cross-request continuation). - The child contributes no independent token accounting boundary: its cost is the parent turn's cost. - No second, causally-parallel child shares the inherited ID while the first is still open (single-open-span-per-turn discipline). If any condition fails, the span crosses a **delegation boundary** and MUST mint a new turn ID. The boundary is precisely at the point where the responder's lifetime decouples from the requester's turn — typically the enqueue/dequeue seam, the durable-message boundary, or the first point where the responder can be retried without the requester re-executing. ## Decision Boundary The decision is not "is this a child agent?" but "is this agent's execution a continuation of the caller's turn or a new turn caused by it?" The diagnostic test: - **Blocking call, in-process, parent awaits to completion** → continuation → inherit turn ID. - **Fire-and-forget, queued, or retried independently** → delegation → fresh turn ID + `parent.turn.id` link. - **Ambiguous (e.g., a bounded-timeout await that may abandon the child)** → treat as delegation. The abandonment case is the tell: if the parent can complete while the child runs on, inheritance is already false. A common wrong heuristic is "same logical conversation ⇒ same turn." Conversation identity belongs in `gen_ai.conversation.id`; turn identity is an execution-lifetime fact, not a conversational one. Conflating them is the root of the observed failure. ## Operational Effect Correcting the boundary — minting fresh turn IDs at the delegation seam and recording `parent.turn.id` — restored turn cardinality (7 workers counted as 7 turns), made retry dedup key on the new per-attempt turn ID, and broke the spurious self-cycle in loop detection (the planner re-invocation now has a distinct turn whose parent is the worker's turn, an acyclic chain). The cost: consumers must now traverse `parent.turn.id` to reconstruct causal chains rather than relying on turn-ID equality, and dashboards that previously grouped by turn ID must group by conversation ID for aggregate views. This traversal cost is the price of an honest causal graph; the alternative is a structurally valid trace that silently misattributes causality. ```python from __future__ import annotations from dataclasses import dataclass class TurnBoundaryViolation(RuntimeError): """Raised when a span inherits a parent turn across a delegation boundary.""" @dataclass(frozen=True) class SpanContext: agent_id: str turn_id: str parent_turn_id: str | None # True iff the parent turn is blocked awaiting this span to completion. synchronous_continuation: bool # True iff the span can be retried/rescheduled without the parent re-running. independently_retryable: bool # True iff the span's lifetime can outlive the parent turn. outlives_parent: bool def assign_turn_id( *, parent: SpanContext, child_agent_id: str, synchronous_continuation: bool, independently_retryable: bool, outlives_parent: bool, proposed_turn_id: str, ) -> SpanContext: """Return the child span context, enforcing the turn-inheritance invariant. Inheritance of parent.turn_id is permitted only for synchronous continuations that are neither independently retryable nor able to outlive the parent turn. Otherwise a fresh turn id is required. """ inherits = proposed_turn_id == parent.turn_id delegation_boundary = ( not synchronous_continuation or independently_retryable or outlives_parent ) if inherits and delegation_boundary: raise TurnBoundaryViolation( f"agent {child_agent_id!r} reused turn_id {parent.turn_id!r} " f"across a delegation boundary " f"(sync={synchronous_continuation}, " f"retryable={independently_retryable}, " f"outlives_parent={outlives_parent}); " f"mint a fresh gen_ai.agent.turn.id and set " f"gen_ai.agent.parent.turn.id={parent.turn_id!r}" ) if not inherits: return SpanContext( agent_id=child_agent_id, turn_id=proposed_turn_id, parent_turn_id=parent.turn_id, synchronous_continuation=False, independently_retryable=independently_retryable, outlives_parent=outlives_parent, ) # Legitimate continuation: inherit the turn, no parent link needed. return SpanContext( agent_id=child_agent_id, turn_id=parent.turn_id, parent_turn_id=parent.parent_turn_id, synchronous_continuation=True, independently_retryable=False, outlives_parent=False, ) ```