# Where must an instance record the tool-call boundary when OpenTelemetry GenAI semantic conventions disagree with the agent runtime's own span lifecycle **Date of Record:** 2026 November 9 **Scope:** Applies to agent runtimes that emit `gen_ai.*` spans (agent invocation, tool call, and model chat) under the OpenTelemetry GenAI semantic conventions, where the runtime's internal notion of "a tool call" is decoupled from the span's start/end lifecycle. Does not apply to single-shot LLM client instrumentation where the span lifetime exactly brackets the network request. ## Core Distinction The GenAI semantic conventions define attributes (`gen_ai.operation.name`, `gen_ai.tool.name`, `gen_ai.tool.call.id`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`) and an *intended* span shape: one `execute_tool` span per tool invocation, one `chat` span per model call, one `invoke_agent` span per agent turn. The convention implicitly assumes the span's temporal extent equals the operation's temporal extent. An agent runtime does not work this way. The runtime schedules tool calls as *deferred intents*: the model emits a `tool_calls` array inside a `chat` span, the runtime decides (possibly after another model turn, a human approval, a retry, or a cancellation) whether and when to execute each call, and only later does an executor actually run it. The boundary that the convention wants to represent — "this tool call happened, and here is its result" — is therefore a *logical* event, not a *temporal interval*. The two cannot be collapsed without losing information. The non-obvious invariant: **the `gen_ai.tool.call.id` must be bound to the span at creation, not at completion, and the span must be allowed to end with an unresolved status.** Any instrumentation that opens the tool-call span only when the executor starts will silently drop calls that are cancelled, deduplicated, or rejected before execution — exactly the calls that matter most for accounting and for post-hoc audits of agent misbehavior. ## What Was Observed A multi-turn agent (model → tool → model → tool) was instrumented with a naive "span wraps the executor" pattern. Under nominal conditions the trace looked correct: `invoke_agent` → `chat` → `execute_tool` → `chat`. Under three ordinary conditions the trace became *wrong in a way that is not visible in the span tree*: 1. **Parallel tool calls.** The model emitted three `tool_calls` in one `chat` response. The runtime executed them concurrently. Three `execute_tool` spans were emitted, each starting at executor entry. All three were siblings of the `chat` span, not children, because the `chat` span had already ended when the model response was parsed. The trace showed three orphan tool spans with no causal parent; a downstream consumer that sums `gen_ai.usage.output_tokens` over the `chat` subtree undercounted by the entire tool phase. 2. **Cancelled-before-execution.** A tool call was rejected by a policy check (argument validation) before the executor ran. No span was emitted. The trace showed one `chat` span with a `tool_calls` array containing an id that appears nowhere in the trace. Token accounting was unaffected, but the audit trail was incomplete: the model's *intent* to call a tool was recorded only in the raw response payload, not as a span. 3. **Retried tool call.** The executor failed and the runtime retried once. Two `execute_tool` spans were emitted with the same `gen_ai.tool.call.id`. A consumer that keyed on `tool.call.id` to deduplicate saw two events and double-counted the tool's side effects; a consumer that keyed on span id saw two independent calls. Neither interpretation matched the runtime's actual semantics (one logical call, two attempts). The common failure is not a missing attribute. It is a **misplaced span boundary**: the instrumentation placed the span where the *executor* runs, not where the *call* is defined. The convention's attribute names are satisfied; the convention's model is violated. ## Condition of Validity The "span wraps executor" pattern is valid only when all of the following hold: - The model emits exactly one tool call per `chat` turn (no `parallel_tool_calls`). - Tool calls are executed synchronously within the `chat` span's lifetime (no deferred scheduling, no human-in-the-loop approval, no queue). - No pre-execution rejection path exists (or, if it does, it is instrumented separately with an explicit `gen_ai.tool.call.id` and a status of `ERROR` or a custom `gen_ai.tool.call.outcome=rejected`). - The runtime does not retry tool calls, or retries are modeled as distinct calls with distinct ids. When any of these break — and in production agent runtimes at least one always breaks — the executor-wrapping pattern produces a trace that is internally consistent but *externally wrong*: it will pass schema validation, render cleanly in a trace viewer, and still mis-attribute tokens, drop calls, and corrupt side-effect accounting. ## Decision Boundary The decision is not "which span name to use" but **where the span's start and end are anchored relative to the call's logical identity**. - Anchor the span at **call definition** (when the model's response is parsed and the call id is assigned). The span's parent is the `chat` span that produced the call. The span's end is at **call resolution** — executed, rejected, cancelled, or expired. This preserves causality, preserves the call id even for unexecuted calls, and lets a retry be modeled as a child span of the logical call rather than a sibling. - Do *not* anchor the span at executor entry. That anchor is only correct under the narrow validity condition above, and it fails silently rather than loudly when the condition breaks. For token accounting, the boundary has a second consequence: `gen_ai.usage.*` belongs on the `chat` span (the model call), never on `execute_tool`. Tool execution consumes no model tokens. Instrumentations that copy usage attributes onto tool spans — a common shortcut when the executor calls a sub-model — double-count. If a tool internally invokes a model, that is a *nested `chat` span*, not a usage attribute on the tool span. ## Operational Effect Anchoring at call definition changes three downstream behaviors: - **Trace completeness.** Every `tool.call.id` that appears in a `chat` span's response is guaranteed to appear as a span (or an explicit rejection event), so an auditor can join model intent to runtime outcome without parsing raw payloads. - **Token accounting correctness.** Summing `gen_ai.usage.input_tokens` and `output_tokens` over the `invoke_agent` subtree yields the true model cost, because tool spans carry no usage and chat spans are never orphaned by deferred execution. - **Retry semantics.** A retried call is one logical `execute_tool` span with N child attempt spans, so side-effect counters keyed on `tool.call.id` count once, and failure-rate metrics keyed on attempt spans count N. The cost is that the tool-call span is now open across an arbitrary interval that may include model turns, human approval, and queueing. That is the correct cost: the span represents a logical operation, not a CPU-bound function call, and its duration is *supposed* to include the wait. Instrumentations that resist this — that insist on tight spans around executor entry — are optimizing for trace aesthetics at the expense of trace truth. ```python from __future__ import annotations from dataclasses import dataclass, field from typing import Optional class ToolCallBoundaryError(RuntimeError): """Raised when a tool-call span is opened at executor entry rather than at call definition.""" @dataclass class ToolCallSpan: call_id: str parent_chat_span_id: str started_at_call_definition: bool resolved: bool = False outcome: Optional[str] = None # "executed" | "rejected" | "cancelled" | "expired" attempt_spans: list[str] = field(default_factory=list) def open_tool_call_span( call_id: str, parent_chat_span_id: str, *, opened_at_executor_entry: bool, ) -> ToolCallSpan: if not call_id: raise ToolCallBoundaryError( "tool call span opened without gen_ai.tool.call.id; " "the id must be bound at call definition, not at execution" ) if opened_at_executor_entry: # This is the silent-failure path: the span will be a sibling of the # chat span, orphaned from the model turn that produced the call, and # any call rejected/cancelled before execution will be dropped. raise ToolCallBoundaryError( f"tool call {call_id!r} span opened at executor entry; " "anchor the span at call definition (parent=" f"{parent_chat_span_id!r}) so cancelled and retried calls are preserved" ) return ToolCallSpan( call_id=call_id, parent_chat_span_id=parent_chat_span_id, started_at_call_definition=True, ) def resolve_tool_call(span: ToolCallSpan, outcome: str) -> ToolCallSpan: if outcome not in {"executed", "rejected", "cancelled", "expired"}: raise ToolCallBoundaryError(f"unknown tool call outcome {outcome!r}") if span.resolved: raise ToolCallBoundaryError( f"tool call {span.call_id!r} already resolved as {span.outcome!r}; " "retries must be child attempt spans, not re-resolutions of the logical call" ) span.resolved = True span.outcome = outcome return span ```