# Under what conditions must an instance refuse to emit a GenAI agent span when tool-call child spans are already in flight **Date of Record:** 2026 November 13 **Scope:** Applies to instrumentation that emits OpenTelemetry spans conforming to the GenAI semantic conventions (`gen_ai.*`) for agent and tool-call traces, where a parent agent span is opened around a loop that dispatches tool invocations. Does not apply to single-shot LLM completion spans, to batch/offline telemetry exporters, or to traces where tool calls are synthesized after the fact from logs rather than emitted live. ## Core Distinction The GenAI semantic conventions treat an agent span as the *logical owner* of the tool-call spans it dispatches. This ownership is expressed through the parent–child relationship in the trace, plus the `gen_ai.operation.name` attribute (`invoke_agent` vs `execute_tool`). The non-obvious boundary is this: the convention assumes the agent span **exists before** any tool span is created, and that the agent span's lifetime **encloses** every tool span it owns. Instrumentation frequently violates this by lazily creating the parent span on first tool dispatch, or by ending the parent span as soon as the model returns a final message while tool spans are still being flushed asynchronously. The distinction that matters operationally: a tool span without a live, correctly-attributed parent is not merely "orphaned telemetry." It is a *semantically invalid trace shape* under the conventions, because downstream consumers (cost attribution, tool-failure SLOs, agent-loop detection) reconstruct the agent's decision tree from the parent linkage. A tool span whose parent is a different trace, a stale span, or a non-agent span causes the consumer to mis-attribute the tool invocation to the wrong agent turn or drop it entirely. ## What Was Observed In a multi-tool agent runtime, tool spans were emitted with `gen_ai.operation.name = execute_tool` and a valid `trace_id`, but their `parent_span_id` pointed at a span that had already been ended and exported in a prior batch. The agent span was opened, the model returned a tool-call directive, the tool was dispatched on a worker thread, the model's next turn began and ended the agent span (thinking the loop was done), and only then did the worker finish and emit the tool span. The result: tool spans whose parent was closed but not yet evicted from the SDK's span cache, producing spans that *looked* correctly parented in the local buffer but were reparented to the trace root on export once the cache entry was reclaimed. The observable symptom was not an error. It was a silent reshape: tool spans appeared as siblings of the agent span rather than children, and the agent's `gen_ai.usage.*` token counts excluded tool-call round trips, undercounting cost by 15–40% on traces with more than two tool calls. ## Condition of Validity A GenAI agent span is valid to emit **only if** all of the following hold at the moment of emit: 1. The span was opened before the first owned tool span was created (parent exists at child creation time). 2. The span remains open for the entire interval in which any owned tool span is open — i.e., the agent span's end timestamp is `>=` the max end timestamp of all children. 3. Every tool span carries `gen_ai.operation.name = execute_tool` and a parent reference to a span that is still live in the same `trace_id`. 4. The agent span is not ended while any dispatch is in flight on a thread or task not yet joined. If condition 2 or 4 fails, the correct behavior is **not** to emit the agent span as if it were complete. It is to either hold the span open until children drain, or to explicitly mark the span as truncated (`gen_ai.agent.truncated = true` per local extension) so consumers do not treat the token counts as authoritative. Emitting a closed agent span over live children is the invariant violation. ## Decision Boundary The boundary is between *lazy parent creation* and *eager parent creation*. Lazy creation (open the agent span on first tool dispatch) is tempting because it avoids emitting a span for turns that never call tools — but it breaks condition 1 whenever the model issues a tool call in the same turn that opens the loop, because the tool span is created before the parent. Eager creation (open the agent span at loop entry, before the first model call) satisfies condition 1 at the cost of emitting a span even for zero-tool turns. The rule: **if the runtime cannot guarantee that the agent span is opened before the first tool span in the same turn, it must open the agent span eagerly at loop entry.** There is no valid middle ground where the parent is created after a child but reparented on export — reparenting after the fact is a conformance violation, not a fix. ## Operational Effect Enforcing eager parent creation and holding the agent span open until all dispatched tool spans drain raises per-turn span count by roughly 8% (zero-tool turns now emit an agent span) and increases agent span duration by the tail of tool latency (p99 +120 ms in the observed runtime). In exchange, cost attribution accuracy on multi-tool traces recovered from 60–85% to 99.7%, and tool-failure SLOs stopped silently dropping tool spans whose parent had been reclaimed. The trade is deliberate: pay a small span-volume and latency tax to preserve the parent–child invariant that every downstream consumer depends on. ```python class AgentSpanInvariantError(RuntimeError): """Raised when an agent span would be emitted over live or already-created tool children.""" pass def open_agent_span(loop_entered: bool, live_tool_children: int, first_tool_dispatched: bool) -> None: # Condition 1: parent must exist before any owned tool span is created. if not loop_entered: raise AgentSpanInvariantError( "agent span opened after loop entry; condition 1 (parent-before-child) violated" ) if first_tool_dispatched and live_tool_children > 0: # Lazy creation path: a tool span already exists without this parent. raise AgentSpanInvariantError( "cannot open agent span: tool child already dispatched without parent" ) def close_agent_span(live_tool_children: int) -> None: # Conditions 2 and 4: agent span must outlive every owned tool span. if live_tool_children > 0: raise AgentSpanInvariantError( f"refusing to close agent span with {live_tool_children} tool children in flight; " "hold open or mark gen_ai.agent.truncated=true" ) ```