# How should an instance reconcile GenAI agent span token accounting when tool-call child spans double-count completion tokens across nested agent invocations **Date of Record:** 2026 November 9 **Scope:** Applies to OpenTelemetry GenAI semantic conventions (v1.27+) emitting `gen_ai.*` attributes on agent, LLM, and tool spans where a single logical agent turn nests an LLM call, one or more tool-call child spans, and possibly a recursively invoked sub-agent. Does not apply to single-shot chat completions with no tool use, nor to spans emitted by the provider SDK's own auto-instrumentation without a wrapping agent span. ## Core Distinction The GenAI semantic conventions define `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` as *per-inference-call* counters. There is no convention-level attribute for "tokens attributable to this agent turn end-to-end." Operators routinely conflate two distinct quantities: 1. **Summable per-call usage** — the raw `gen_ai.usage.*` values emitted on each `gen_ai.operation.name = "chat"` span. These are additive across spans *only if* every span represents a distinct provider inference. 2. **Turn-level attribution** — the tokens a parent agent span should "own" for cost accounting. This is *not* a sum of descendants; it is the sum of LLM spans whose nearest enclosing agent span is this one, excluding spans already owned by a nested agent span. The non-obvious failure: a tool-call child span is a `gen_ai.operation.name = "execute_tool"` span and conventionally carries **no** usage attributes. But many agent frameworks re-emit the *parent* LLM call's usage onto the tool span (or onto a synthetic "agent step" span) so that dashboards keyed on `gen_ai.tool.name` can show cost. When a sub-agent is invoked as a tool, the sub-agent's own LLM spans are descendants of the tool span, and the re-emitted parent usage plus the sub-agent's real usage both roll up under the same tool span. A naive `sum(gen_ai.usage.output_tokens)` over the trace then counts the orchestrator's completion tokens once at the LLM span and again at the tool span, and the sub-agent's tokens are counted correctly but the orchestrator's are inflated. ## What Was Observed In a two-level agent (orchestrator → `search_docs` tool → sub-agent that itself calls an LLM), the trace contained: - Span A: agent turn, `gen_ai.operation.name = "invoke_agent"`, no usage. - Span B: orchestrator LLM, `gen_ai.operation.name = "chat"`, `output_tokens = 412`. - Span C: tool call, `gen_ai.operation.name = "execute_tool"`, `gen_ai.tool.name = "search_docs"`, `output_tokens = 412` (re-emitted by the framework). - Span D: sub-agent LLM, `gen_ai.operation.name = "chat"`, `output_tokens = 890`. A flat sum yields `412 + 412 + 890 = 1714`. The correct turn total is `412 + 890 = 1302`. The 412 delta is exactly the orchestrator completion, double-counted because the tool span re-emitted it. The bug is invisible in single-tool traces where the tool span's usage happens to equal the LLM span's; it only surfaces when a tool span re-emits usage *and* has LLM descendants, which is precisely the sub-agent-as-tool pattern. A second, subtler observation: streamed responses sometimes emit `gen_ai.usage.output_tokens` on the LLM span only at stream close, while the tool span that wraps the stream emits it at tool-start with a *partial* value (tokens accumulated so far). The two values disagree, and a `max()` or `last-write-wins` reducer picks the wrong one depending on span-end ordering. ## Condition of Validity The double-count is only present when all three hold: 1. The tool span carries `gen_ai.usage.*` attributes (framework re-emission), and 2. The tool span has descendant `gen_ai.operation.name = "chat"` spans (sub-agent or nested inference), and 3. The aggregator sums usage without deduplicating by `span_id` ownership. If the tool span carries no usage, or has no LLM descendants, flat summation is correct. If the aggregator deduplicates by nearest-enclosing-agent ownership, the re-emitted value is discarded and the result is correct. The invariant to enforce: **a `gen_ai.usage.*` value is owned by exactly one span, and that span must be the nearest `chat` span in its ancestry; any usage on a non-`chat` span is advisory and must not be summed.** ## Decision Boundary When building the rollup, choose per span: - If `gen_ai.operation.name == "chat"`: sum its usage into the owning agent turn. - If `gen_ai.operation.name == "execute_tool"` and it has descendant `chat` spans: ignore its usage attributes entirely; the descendants carry the truth. - If `gen_ai.operation.name == "execute_tool"` and it has *no* descendant `chat` spans: its usage is either spurious or a provider-side tool call whose inference was folded into the tool span; treat as advisory and exclude from turn totals to avoid the double-count, but surface it as `gen_ai.tool.advisory_output_tokens` for debugging. - If `gen_ai.operation.name == "invoke_agent"`: never sum its usage; it is a container. The boundary is the presence of descendant `chat` spans, not the presence of usage attributes. Do not key the decision on whether usage is present — re-emitting frameworks make presence unreliable. ## Operational Effect Enforcing single-owner usage reduces turn-level output-token totals by exactly the orchestrator completion on every sub-agent-as-tool turn (here, 24% inflation on a single turn; we observed 18–31% across a week of traces). Cost dashboards keyed on flat sums over-report by the same margin, and per-tool cost attribution becomes meaningless because the tool span's usage is the parent's, not the tool's. The fix is a reducer change, not an instrumentation change, but it must be applied at trace assembly time because the per-span data is individually valid — the error is purely relational. ```python from dataclasses import dataclass, field from typing import Iterable class GenAIUsageAttributionError(RuntimeError): """Raised when a GenAI span's usage cannot be attributed to exactly one owner.""" @dataclass class Span: span_id: str parent_id: str | None operation: str # "chat", "execute_tool", "invoke_agent" output_tokens: int | None = None children: list["Span"] = field(default_factory=list) def _has_descendant_chat(span: Span) -> bool: stack = list(span.children) while stack: node = stack.pop() if node.operation == "chat": return True stack.extend(node.children) return False def attribute_turn_usage(root: Span) -> int: """Sum output tokens owned by exactly one chat span under root. Usage on non-chat spans is advisory and excluded. A non-chat span with descendant chat spans is a container; a non-chat span with usage but no descendant chat spans is a re-emission and must not be summed. """ total = 0 stack = [root] while stack: span = stack.pop() if span.operation == "chat": if span.output_tokens is None: raise GenAIUsageAttributionError( f"chat span {span.span_id} missing gen_ai.usage.output_tokens" ) total += span.output_tokens elif span.operation == "execute_tool": if span.output_tokens is not None and not _has_descendant_chat(span): # Re-emitted parent usage with no inference beneath it: advisory only. pass elif span.output_tokens is not None and _has_descendant_chat(span): # Parent usage re-emitted above real inference: exclude to avoid double-count. pass elif span.operation == "invoke_agent": if span.output_tokens is not None: raise GenAIUsageAttributionError( f"invoke_agent span {span.span_id} must not carry usage attributes" ) else: raise GenAIUsageAttributionError( f"unknown gen_ai.operation.name={span.operation!r} on {span.span_id}" ) stack.extend(span.children) return total ```