Skip to content

claude-agent-sdk: subagent tool calls mis-parented to root agent (PreToolUse hook delivers parent_tool_use_id=None) #3255

Description

@mbegur

Summary

In openinference-instrumentation-claude-agent-sdk, tool calls made inside a subagent (spawned via the Task/Agent tool) are mis-parented to the top-level agent span instead of nesting under the subagent's ClaudeAgentSDK.Agent span. Result: a multi-subagent run renders as a mostly-flat list of tool spans, defeating subagent grouping in Phoenix.

Versions

  • openinference-instrumentation-claude-agent-sdk==0.1.6 (latest)
  • claude-agent-sdk==0.2.104
  • opentelemetry-sdk==1.42.1, Python 3.12.8

Root cause

The instrumentor creates tool spans from the injected PreToolUse hook and parents them via parent_tool_use_id read from the hook payload (_wrappers.py, pre_tool_use -> _get_field(input_data, "parent_tool_use_id") -> start_tool_span(...)).

But the PreToolUse hook payload delivers parent_tool_use_id=None for subagent tool calls — even though the streamed AssistantMessages for those same tool calls do carry the correct parent_tool_use_id. So the hook-created span has no subagent parent and falls back to the root agent.

Evidence

A task that spawns one general-purpose subagent which runs 3 Bash calls + 1 Write. Same run, comparing the message stream vs. the hook payload vs. the resulting span parent:

tool AssistantMessage.parent_tool_use_id PreToolUse hook parent_tool_use_id span parent
Agent (Task) None None root agent (correct)
Bash ×3 (subagent) toolu_01NSj9… (the Agent id) None root agent (wrong)
Write (subagent) toolu_01NSj9… None subagent (raced through the message path)

The parent_tool_use_id the SDK exposes on the message stream is correct and sufficient to nest these tools under the subagent; only the hook payload is missing it.

Minimal repro

import asyncio
from claude_agent_sdk import (AssistantMessage, ClaudeAgentOptions, ClaudeSDKClient, HookMatcher)
from openinference.instrumentation.claude_agent_sdk import ClaudeAgentSDKInstrumentor
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

exporter = InMemorySpanExporter(); provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
ClaudeAgentSDKInstrumentor().instrument(tracer_provider=provider)

HOOK_LOG = []
async def pre(input_data, tool_use_id=None, context=None):
    g = (lambda d,k: d.get(k) if isinstance(d,dict) else getattr(d,k,None))
    HOOK_LOG.append((g(input_data,"tool_name"), g(input_data,"parent_tool_use_id")))
    return {}

PROMPT = ("Use the Task tool to spawn ONE general-purpose subagent. Instruct it to run three "
          "Bash commands (echo alpha/beta/gamma) then Write subout.txt containing DONE. Then reply 'finished'.")

async def main():
    opts = ClaudeAgentOptions(allowed_tools=["Bash","Write","Task"], max_turns=40,
        system_prompt={"type":"preset","preset":"claude_code"},
        hooks={"PreToolUse":[HookMatcher(hooks=[pre])]})
    c = ClaudeSDKClient(options=opts); await c.connect(PROMPT)
    async for m in c.receive_response():
        if isinstance(m, AssistantMessage):
            for b in m.content:
                if getattr(b,"name",None):
                    print("MSG", b.name, "parent=", getattr(m,"parent_tool_use_id",None))
    await c.disconnect()

asyncio.run(main()); provider.force_flush()
print("HOOK parent_tool_use_id values:", HOOK_LOG)
nm = {s.context.span_id: s.name for s in exporter.get_finished_spans()}
for s in exporter.get_finished_spans():
    print(s.name, "->", nm.get(s.parent.span_id) if s.parent else "ROOT")

Suggested fix

Prefer the message-stream parent_tool_use_id for tool-span parenting (it is reliably populated in the AssistantMessage that introduces each tool_use block), rather than relying on the PreToolUse hook payload's value — or fall back to the message value when the hook delivers None. (Separately, the hook payload arguably should include parent_tool_use_id, which may warrant an upstream fix in claude-agent-sdk itself.)

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions