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.)
Summary
In
openinference-instrumentation-claude-agent-sdk, tool calls made inside a subagent (spawned via theTask/Agenttool) are mis-parented to the top-level agent span instead of nesting under the subagent'sClaudeAgentSDK.Agentspan. 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.104opentelemetry-sdk==1.42.1, Python 3.12.8Root cause
The instrumentor creates tool spans from the injected
PreToolUsehook and parents them viaparent_tool_use_idread from the hook payload (_wrappers.py,pre_tool_use->_get_field(input_data, "parent_tool_use_id")->start_tool_span(...)).But the
PreToolUsehook payload deliversparent_tool_use_id=Nonefor subagent tool calls — even though the streamedAssistantMessages for those same tool calls do carry the correctparent_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
Bashcalls + 1Write. Same run, comparing the message stream vs. the hook payload vs. the resulting span parent:AssistantMessage.parent_tool_use_idPreToolUsehookparent_tool_use_idAgent(Task)NoneNoneBash×3 (subagent)toolu_01NSj9…(the Agent id)NoneWrite(subagent)toolu_01NSj9…NoneThe
parent_tool_use_idthe 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
Suggested fix
Prefer the message-stream
parent_tool_use_idfor tool-span parenting (it is reliably populated in theAssistantMessagethat introduces eachtool_useblock), rather than relying on thePreToolUsehook payload's value — or fall back to the message value when the hook deliversNone. (Separately, the hook payload arguably should includeparent_tool_use_id, which may warrant an upstream fix inclaude-agent-sdkitself.)