# Which invariants prevent parent-agent spans from double-counting tokens when child tool-call spans re-emit model invocation events **Date of Record:** 2026 November 12 **Scope:** Applies to agent frameworks that instrument tool-call trees where a child span (tool execution, sub-agent invocation, or retrieval step) performs its own LLM call and emits `gen_ai.client.token.usage` / `gen_ai.usage.input_tokens` / `gen_ai.usage.output_tokens` attributes on that child span. Does not apply to single-shot chat completions with no nested spans, nor to frameworks that aggregate usage exclusively at the root via a separate accounting layer. ## Core Distinction There are two structurally different ways token usage can appear in a span tree, and they are indistinguishable from attributes alone: 1. **Inclusive usage** — the parent span's `gen_ai.usage.*` attributes represent the sum of the parent's own model call plus all descendant model calls. This is the "rollup" convention. 2. **Exclusive usage** — the parent span's `gen_ai.usage.*` attributes represent *only* the tokens consumed by the model invocation event that the parent span directly wraps. Child spans carry their own exclusive counts. The OpenTelemetry GenAI semantic conventions do not mandate either, because a span is nominally a single operation, not an aggregation boundary. But agent frameworks routinely create a parent span (e.g. `invoke_agent`) that both wraps a model call *and* contains tool-call children that themselves invoke models. When that parent emits `gen_ai.usage.input_tokens` for its own call while children emit theirs, a naive sum over all spans double-counts nothing — but a naive sum over *parent spans only* (the common collector-side aggregation) silently undercounts, and a recursive tree-walk that adds `usage` at every node that has it will overcount if any node was instrumented inclusively. The non-obvious failure: **the same attribute key means the opposite accounting semantics depending on which instrumentation library produced the span**, and the conventions provide no `gen_ai.usage.scope` or equivalent discriminator to tell them apart. ## What Was Observed A multi-tool agent (planner → 3 parallel tool calls, each of which internally called a small classifier model → synthesizer) produced a span tree where the root `invoke_agent` span reported `gen_ai.usage.input_tokens = 4820`. The three child tool spans each reported `gen_ai.usage.input_tokens` of roughly 1100–1400. The synthesizer child reported 900. A dashboard summing `input_tokens` across all spans reported 4820 + 3×(~1250) + 900 ≈ 9470 tokens for a request that actually consumed 4820 tokens total (the root figure was already inclusive, because the framework's root instrumentation aggregated its children before emitting). The root span was instrumented by library A (inclusive rollup); the children by library B (exclusive). Both conformed to the letter of the conventions. The collector had no way to reconcile them. The inverse case was also observed in the same fleet: a different agent used exclusive accounting at the root, and a tree-walk that added only leaf spans missed the root's own model call entirely, undercounting by the root's direct invocation (typically the largest single call in the tree). Additional observed artifacts: - `gen_ai.operation.name` on the parent was `invoke_agent`, while the child model calls were `chat`. The conventions define `gen_ai.operation.name` values but do not constrain whether an `invoke_agent` span may also carry `gen_ai.usage.*` for a nested `chat` it performed directly. - `gen_ai.request.model` appeared on both parent and children with different values, confirming the parent's usage belonged to a distinct model invocation event, not a copy of a child's. - Spans emitted token attributes with no accompanying `gen_ai.response.id`, making deduplication by response identity impossible. ## Condition of Validity The double-count hazard is *inert* (no accounting error) when and only when at least one of the following holds: - Every span in the tree is instrumented by a library using the **same** accounting semantics (all inclusive or all exclusive), *and* the collector's aggregation matches that semantics. - The collector aggregates **only leaf spans** and every model invocation is wrapped by a leaf span (i.e. no span both performs a model call and has children). - A framework-level attribute explicitly marks the rollup boundary (e.g. a custom `agent.usage.inclusive=true` on rollup spans), which is outside the GenAI conventions and therefore not portable. The hazard becomes *active* the moment a parent span both (a) carries `gen_ai.usage.*` and (b) has descendant spans that also carry `gen_ai.usage.*`. This is the exact structural condition created by agent tool-call trees, and it is the common case, not the edge case. ## Decision Boundary When building or auditing an agent instrumentation, the decision is not "should I emit usage on the parent" but "does any span in this tree simultaneously own a model invocation and contain model-invoking children." If yes, you must choose one of: - **Exclusive everywhere:** parent emits usage only for its own direct `chat`/`embeddings` call; children emit theirs. Aggregation = sum over all spans. Requires that the framework *never* rolls up, even at the root, and that the root's own call is wrapped in a distinct child span if the root also orchestrates. - **Inclusive at designated rollup nodes only:** exactly one span per tree (the root) emits inclusive usage; all descendants emit nothing, or emit exclusive usage under a distinct key. Aggregation = read root only. Requires the root span to be reliably identifiable (e.g. `parent_span_id` absent) and requires suppressing child usage emission entirely to avoid the mixed case. The boundary that must not be crossed: **never let a span emit inclusive usage while its descendants also emit usage under the same keys.** That combination is unreconcilable downstream without out-of-band metadata, and it is precisely what two independently-correct instrumentation libraries produce when composed. ## Operational Effect Downstream consumers — cost dashboards, rate-limit governors, budget alerts, and trace-based evals that weight by token count — inherit whichever semantics the leaf-most instrumentation happened to use, silently. A budget governor that sums all spans will trip early on inclusive-instrumented trees (overcount) and never trip on exclusive-instrumented trees aggregated root-only (undercount). The failure is not a crash; it is a monotone bias in a number that humans trust. The only durable mitigation at the collector layer is to *refuse to aggregate* spans whose `gen_ai.operation.name` is `invoke_agent` together with spans that have children, and instead require an explicit rollup marker — failing loudly when the marker is absent rather than guessing. ```python class AmbiguousTokenAccountingError(RuntimeError): """Raised when a span tree mixes inclusive and exclusive gen_ai.usage semantics.""" def aggregate_input_tokens(spans: list[dict]) -> int: """ Sum gen_ai.usage.input_tokens across a span tree, refusing to guess when a span both carries usage and has usage-bearing descendants. """ by_id = {s["span_id"]: s for s in spans} children: dict[str, list[str]] = {sid: [] for sid in by_id} for s in spans: parent = s.get("parent_span_id") if parent in children: children[parent].append(s["span_id"]) def has_usage(span: dict) -> bool: return "gen_ai.usage.input_tokens" in span.get("attributes", {}) def usage_bearing_descendants(sid: str) -> int: total = 0 for cid in children[sid]: if has_usage(by_id[cid]): total += 1 total += usage_bearing_descendants(cid) return total for s in spans: if not has_usage(s): continue if usage_bearing_descendants(s["span_id"]) > 0: raise AmbiguousTokenAccountingError( f"span {s['span_id']} (op={s.get('attributes', {}).get('gen_ai.operation.name')}) " f"carries gen_ai.usage.input_tokens but has usage-bearing descendants; " f"cannot determine inclusive vs exclusive semantics without a rollup marker. " f"Refusing to aggregate." ) return sum( s["attributes"]["gen_ai.usage.input_tokens"] for s in spans if has_usage(s) ) ```