# When must an instance reject an OpenTelemetry GenAI agent span whose tool-call token accounting has drifted from its parent invoke_agent span? **Date of Record:** 2026 November 6 **Scope:** Applies to services emitting OpenTelemetry GenAI semantic-convention spans (`gen_ai.operation.name` = `invoke_agent`, `execute_tool`, `chat`) where a single logical agent turn fans out into nested tool calls that each carry their own `gen_ai.usage.input_tokens` / `gen_ai.usage.output_tokens`. Does not apply to single-shot chat spans without tool fan-out, nor to spans where token usage is intentionally aggregated only at the root. ## Core Distinction The GenAI semantic conventions treat `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` as *per-span* measurements of the model call that span represents. They are **not** a rollup. A parent `invoke_agent` span and its child `execute_tool` spans each legitimately report only the tokens consumed by their own model invocation. The non-obvious invariant is therefore *additive over the leaf model calls, not over the span tree*: the sum of `chat`-operation leaf spans (the actual LLM calls) must equal the parent agent's accounted total, while `execute_tool` spans contribute **zero** tokens of their own unless they internally invoke a model. The failure mode is treating the span hierarchy as a token ledger. When a collector, dashboard, or cost-attributor sums `gen_ai.usage.*` across *all* spans in an agent trace, it double-counts because intermediate `invoke_agent` spans re-report tokens that their child `chat` spans already reported. This is the boundary: the moment an aggregator walks the tree and sums every span's usage attributes, the invariant "leaf sum == root total" silently becomes "2× or 3× root total," and per-tool cost attribution becomes meaningless. ## What Was Observed In a multi-tool agent (planner → 3 parallel `execute_tool` calls → synthesizer), the emitted trace showed: - Root `invoke_agent` span: `input_tokens=4820`, `output_tokens=610`. - Three child `execute_tool` spans, each with an *internal* `chat` child: the tool spans carried `gen_ai.usage.input_tokens` copied from their nested chat calls (a common exporter bug where the tool wrapper propagates the child's usage upward). - A naive tree-sum aggregator produced `input_tokens=14,460` (3× the true root) because it summed root + tool spans, and the tool spans had already absorbed their chat children's counts. Separately, spans with `gen_ai.operation.name=execute_tool` but **no** nested model call (pure function tools: a calculator, a DB lookup) were sometimes emitted with `gen_ai.usage.input_tokens=0` and sometimes with the attribute *absent*. Aggregators that used `span.attributes.get("gen_ai.usage.input_tokens", 0)` were correct; those that used `if attr in span` to decide "this span made a model call" mis-classified pure tools as model-bearing. The tell was a per-tool cost report attributing 40% of spend to a `execute_tool` span named `lookup_order_status` — a tool that never calls a model. ## Condition of Validity The additive-leaf invariant holds **only when**: 1. Every span carrying `gen_ai.usage.*` either (a) represents exactly one model invocation (`chat`, or an `invoke_agent` that itself calls the model exactly once), or (b) is the single designated rollup root, and these two roles are never conflated on the same span. 2. `execute_tool` spans that do not invoke a model either omit the usage attributes entirely or set them to `0` — and consumers treat absence and zero identically. 3. The trace has exactly one `invoke_agent` root per logical turn; sub-agents are modeled as nested `invoke_agent` spans whose usage is *already included* in the parent's count (i.e., the parent's number is the model's own accounting of the turn, not a manual sum of children). When any of these is violated — most commonly (1), where an exporter copies a nested chat span's usage onto its `execute_tool` wrapper — the invariant is false and any tree-sum is invalid. ## Decision Boundary An instance **must reject** (fail the span, not silently accept) when it detects, at emission or ingestion: - A span with `gen_ai.operation.name=execute_tool` that carries non-zero `gen_ai.usage.input_tokens` **and** has a descendant `chat` span whose `gen_ai.usage.input_tokens` equals it. This is the copy-up signature; accepting it guarantees double-counting. - A parent `invoke_agent` span whose `gen_ai.usage.output_tokens` is less than the sum of its direct `chat` children's output tokens — the parent cannot have produced fewer tokens than its own model calls. - More than one span in a single trace with `gen_ai.operation.name=invoke_agent` at the *same* depth claiming to be the rollup root (ambiguous root → no valid aggregation). An instance **must not reject** merely because a tree-sum exceeds the root total; that is the *expected* result of a correct trace and is the signal that the consumer, not the producer, is at fault. Rejecting on tree-sum mismatch would punish correct instrumentation. ## Operational Effect Enforcing the boundary at the `execute_tool` copy-up signature converts a silent 2–3× cost inflation into a hard, attributable error at the exporter, where it can be fixed (strip usage attributes from tool wrappers; let the nested `chat` span own them). Downstream, cost attribution becomes trustworthy: per-tool spend is computed only from `chat` leaves, and pure-function tools correctly report zero. The cost is that some vendors' auto-instrumentation emits the copy-up pattern by default and will begin failing spans until patched — a deliberate trade of availability for accounting integrity, justified because a wrong cost number is worse than a missing span in any billing or capacity-planning pipeline. ```python from dataclasses import dataclass, field class GenAISpanAccountingError(RuntimeError): """Raised when a GenAI agent span violates the additive-leaf token invariant.""" @dataclass class Span: name: str operation: str # "invoke_agent" | "execute_tool" | "chat" input_tokens: int | None = None output_tokens: int | None = None children: list["Span"] = field(default_factory=list) def descendants(self): for c in self.children: yield c yield from c.descendants() def _usage(span: Span) -> tuple[int, int]: # Absence and explicit zero are equivalent per the semantic conventions. return (span.input_tokens or 0, span.output_tokens or 0) def validate_agent_trace(root: Span) -> None: if root.operation != "invoke_agent": raise GenAISpanAccountingError( f"trace root must be invoke_agent, got {root.operation!r}" ) roots = [ s for s in [root, *root.descendants()] if s.operation == "invoke_agent" and s is not root ] # Nested invoke_agent spans are allowed only as sub-agents; flag duplicate # same-depth roots only if the caller passed a forest root by mistake. if any(s.name == root.name for s in roots): raise GenAISpanAccountingError( f"ambiguous rollup root: multiple invoke_agent spans named {root.name!r}" ) for span in [root, *root.descendants()]: if span.operation != "execute_tool": continue tin, tout = _usage(span) if tin == 0 and tout == 0: continue for chat in span.descendants(): if chat.operation != "chat": continue cin, cout = _usage(chat) if (tin, tout) == (cin, cout) and (cin or cout): raise GenAISpanAccountingError( f"copy-up signature on execute_tool span {span.name!r}: " f"usage ({tin},{tout}) duplicates nested chat span " f"{chat.name!r}; this guarantees double-counting in tree sums" ) root_in, root_out = _usage(root) direct_chat_out = sum( _usage(c)[1] for c in root.children if c.operation == "chat" ) if root_out < direct_chat_out: raise GenAISpanAccountingError( f"parent {root.name!r} output_tokens={root_out} is less than " f"sum of direct chat children={direct_chat_out}" ) ```