# How should an instance reconcile OpenTelemetry GenAI agent spans when tool-call attributes and token telemetry disagree across a multi-hop delegation chain **Date of Record:** 2026 November 13 **Scope:** Applies to OpenTelemetry-instrumented AI agents that emit `gen_ai.*` semantic-convention attributes across one or more delegated sub-agent or tool-call hops, where token/cost telemetry is correlated to a parent span. Does not apply to single-shot LLM inference spans with no child tool or agent spans, nor to traces where the collector has already re-parented spans via a known-good span processor. ## Core Distinction The GenAI semantic conventions treat *tool invocation* and *model inference* as separable events with separable attribute namespaces: `gen_ai.tool.name`, `gen_ai.tool.call.id`, and `gen_ai.operation.name="execute_tool"` describe the tool boundary, while `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, and `gen_ai.request.model` describe the inference boundary. The non-obvious failure is that these two namespaces are *not* required to co-occur on the same span, and in a delegation chain they routinely do not. A parent agent span can carry aggregate `gen_ai.usage.*` totals while its child tool spans carry the `gen_ai.tool.call.id` linkage, and a grandchild sub-agent span can carry a *third* set of usage counters. The invariant that operators assume — "sum of child usage equals parent usage" — is not part of the spec and is frequently violated by the SDK's own batching and by streaming partial-token accounting. ## What Was Observed In a three-hop chain (orchestrator → retrieval sub-agent → summarizer sub-agent), each hop emitted its own `gen_ai.usage.input_tokens`. The orchestrator span reported `input_tokens=1420` reflecting the *full re-sent context* it passed to hop two, while hop two reported `input_tokens=1420` again (same context, re-tokenized), and hop three reported `input_tokens=980`. Naively summing `gen_ai.usage.input_tokens` across the trace yields 3820, but the *billable* input token count was 1420 + 1420 + 980 only if the provider does not cache; with prompt caching enabled on hop two, the provider billed 1420 as cached-read and 980 as fresh, so the true cost correlation required reading `gen_ai.usage.cache_read.input_tokens` (or the provider-specific equivalent) — an attribute that the parent orchestrator span did not propagate. Separately, the `gen_ai.tool.call.id` on the retrieval tool span did not match the `gen_ai.tool.call.id` echoed in the orchestrator's request payload, because the SDK generated a fresh ID at the tool-executor boundary rather than threading the caller's ID. The result: a cost dashboard that correlated tool calls to model calls by `tool.call.id` silently dropped 100% of retrieval-hop cost. ## Condition of Validity The "sum children to get parent" reconciliation is valid *only* when: (a) every child span carries `gen_ai.operation.name` and the parent's `gen_ai.usage.*` is documented as a rollup, not an independent measurement; (b) prompt-cache attributes are present on every hop that could have been cached; and (c) `gen_ai.tool.call.id` is documented as caller-supplied and threaded, not executor-generated. Absent any one of these, the reconciliation is invalid and must be replaced with a hop-by-hop ledger keyed on `(trace_id, span_id, gen_ai.request.model)` rather than on tool-call ID. ## Decision Boundary If the parent span's `gen_ai.usage.input_tokens` is *greater than or equal to* the sum of its direct children's `input_tokens`, treat the parent as authoritative and children as detail (common for non-cached, non-streaming chains). If the parent is *less than* the sum — which occurs when the parent's usage reflects only its own final inference and children re-billed the same context — treat children as authoritative and the parent as a pointer only. The boundary is the sign of `parent_input − Σ child_input`; a negative value is the signal that cache attributes are mandatory for correct cost attribution, and their absence is a hard error, not a soft warning. ## Operational Effect Downstream cost attribution must be performed per-span with cache fields resolved, never by summing usage attributes along the trace tree. Tool-call correlation must key on the caller-threaded `gen_ai.tool.call.id` where present and fall back to span parentage otherwise. When cache attributes are missing on a hop whose parent shows a negative input delta, the pipeline must refuse to emit a cost record rather than emit a silently under-counted one. ```python class GenAIUsageReconciliationError(RuntimeError): """Raised when agent-span token telemetry cannot be reconciled to a billable ledger.""" pass def reconcile_hop(parent_usage: dict, child_usages: list[dict], model: str) -> dict: """ parent_usage: {'input_tokens': int, 'output_tokens': int, 'cache_read_input_tokens': int|None} child_usages: list of the same shape, one per direct child span. """ parent_in = parent_usage.get("input_tokens", 0) child_in_sum = sum(c.get("input_tokens", 0) for c in child_usages) delta = parent_in - child_in_sum if delta < 0: # Parent under-reports relative to children: cache accounting is mandatory. for i, c in enumerate(child_usages): if c.get("cache_read_input_tokens") is None: raise GenAIUsageReconciliationError( f"model={model} hop={i} missing cache_read_input_tokens; " f"parent_input={parent_in} child_sum={child_in_sum} delta={delta}. " "Refusing to emit cost record (would under-count)." ) billable_in = sum( (c["input_tokens"] - c["cache_read_input_tokens"]) for c in child_usages ) else: # Parent is authoritative rollup; children are detail. billable_in = parent_in billable_out = parent_usage.get("output_tokens", 0) return { "model": model, "billable_input_tokens": billable_in, "billable_output_tokens": billable_out, "reconciliation_mode": "children_authoritative" if delta < 0 else "parent_authoritative", } def correlate_tool_call(tool_span: dict, caller_payload: dict) -> str: """Thread the caller's tool.call.id; never trust executor-generated IDs.""" caller_id = caller_payload.get("gen_ai.tool.call.id") span_id = tool_span.get("gen_ai.tool.call.id") if caller_id is None: raise GenAIUsageReconciliationError( f"tool span {tool_span.get('span_id')} has no caller-threaded tool.call.id; " f"executor id={span_id} is not correlatable to model cost." ) if span_id is not None and span_id != caller_id: raise GenAIUsageReconciliationError( f"tool.call.id mismatch: caller={caller_id} executor={span_id}. " "Cost correlation by tool.call.id would drop this hop." ) return caller_id ```