Skip to content

fix(daemon): extract subagent reply text from tool_response envelope#55

Draft
rgao-coreweave wants to merge 2 commits into
mainfrom
fix/subagent-output-messages-text
Draft

fix(daemon): extract subagent reply text from tool_response envelope#55
rgao-coreweave wants to merge 2 commits into
mainfrom
fix/subagent-output-messages-text

Conversation

@rgao-coreweave

@rgao-coreweave rgao-coreweave commented May 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • closeSubagentInvokeAgentSpan was storing the full Claude Code tool_response envelope JSON-stringified into gen_ai.output.messages. The chat view rendered the subagent's assistant_message as {"status":"completed","prompt":"...","agentId":"...","content":[...]} instead of just the reply text.
  • New extractSubagentReplyText(...) recognizes the Anthropic envelope shape (object with a content array) and returns the joined text blocks. Plain strings (orphan-path lastAssistantText, error-path messages) pass through verbatim. Unrecognized shapes fall back to JSON.stringify so no data is silently dropped.
  • When the envelope is recognized but contains no text blocks (content: [] or only non-text blocks like tool_use/image/thinking), the helper returns the joined text unconditionally — i.e. the empty string — rather than falling through to JSON.stringify and re-emitting the wrapper. The JSDoc documents this contract so the design intent is locked in.

Why this lives in the instrumentation, not the UI

gen_ai.output.messages in the OTel GenAI semconv is meant to carry the model's actual output messages — [{ role, content }] where content is the assistant's reply. Claude Code's tool_response is a transport envelope around the reply (status, agentId, agentType, prompt, description, totalDurationMs, totalTokens, plus content[] blocks). Stringifying the entire envelope into output.messages.content feeds consumers the wrapper rather than the message — an instrumentation-contract violation. Any OTel-compliant viewer reading the attribute would misrender it the same way; a UI-side unwrap would be a Claude-Code-envelope-specific special case that doesn't generalize.

Why we don't also set gen_ai.tool.call.result on the subagent path

The regular tool path in handlePostToolUse writes the full tool_response to gen_ai.tool.call.result. The subagent path deliberately does not — the subagent is modeled as an invoke_agent (chat-flavored) span, not a tool_call span, so the attribute split mirrors the span-type split. The envelope sibling fields are already reconstructible from other attributes on the span tree:

  • status → mapped to OTel span status (ERROR on failure)
  • agentId / agentType / prompt / description → set on the span at PreToolUse from tool_input
  • totalDurationMs → recoverable from the span's own start/end timestamps
  • totalTokens → covered (more granularly, with cache breakdowns) by per-turn token counts on child chat spans

So OUTPUT_MESSAGES now carries just the assistant message; nothing else is dropped. A JSDoc note on closeSubagentInvokeAgentSpan documents this so the asymmetry vs the regular tool path doesn't look like an oversight.

Discovery

Caught while inspecting the exported /agents/traces/chat response for the talk-to-tim trace — the subagent's assistant_message.text rendered as the full JSON envelope, drowning out the reply text "ok let me push on this…". Cosmetic but user-visible.

Test plan

  • Verified locally with a realistic tool_response carrying sibling fields (status, prompt, agentId, agentType, description, totalDurationMs, totalTokens) — output_messages.content is exactly the subagent's text with no envelope keys leaking.
  • npm run build clean.

🤖 Generated with Claude Code

closeSubagentInvokeAgentSpan was storing the full Claude Code
tool_response envelope JSON-stringified into gen_ai.output.messages:

  {"status":"completed","prompt":"...","agentId":"...",
   "content":[{"type":"text","text":"ok let me push..."}],
   ...}

The chat view then rendered that whole blob as the subagent's
assistant_message, instead of just the subagent's reply text.

Extract `content[*].text` from the Anthropic-shape envelope. Plain
strings (orphan-path lastAssistantText, error-path messages) and
unrecognized shapes pass through unchanged so behavior is preserved
for the non-envelope call sites.

Verified locally against a realistic tool_response with sibling
fields (status, prompt, agentId, agentType, description,
totalDurationMs, totalTokens). After fix, output_messages.content is
exactly the subagent's text — none of the envelope keys leak through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rgao-coreweave rgao-coreweave force-pushed the fix/subagent-output-messages-text branch from cc34932 to 669dccb Compare May 22, 2026 18:39
@w-b-hivemind

w-b-hivemind Bot commented May 22, 2026

Copy link
Copy Markdown

HiveMind Sessions

1 session · 24m · $5.33

Session Agent Duration Tokens Cost Lines
Fix and Polish Claude Code Weave Plugin PR
64fa535f-bdfb-4c53-9583-fedc40b1c238
claude 24m 68.5K $5.33 +169 -25
Total 24m 68.5K $5.33 +169 -25
Screenshots

View all sessions in HiveMind →

Run claude --resume 64fa535f-bdfb-4c53-9583-fedc40b1c238 to pickup where you left off.

- Restructure extractSubagentReplyText so a recognized envelope shape
  (object with a `content` array) always returns the joined text — even
  when no text blocks were present — instead of falling through to
  JSON.stringify and re-emitting the wrapper. The empty-text fall-through
  was a latent bug-class that would silently reintroduce the original
  rendering issue if Anthropic returned a content list of non-text blocks
  (image / tool_use / thinking). JSDoc now states this contract
  explicitly. Drive-by: collapse the redundant null-and-non-object guard
  since the null check is load-bearing only for the object-cast that
  follows.

- Add a JSDoc note to closeSubagentInvokeAgentSpan explaining why we
  deliberately do not set `gen_ai.tool.call.result` on the subagent
  invoke_agent span. The envelope sibling fields (status, prompt,
  agentId, agentType, description, totalDurationMs, totalTokens) are
  fully reconstructible from other span attributes — dispatch metadata
  is set at PreToolUse from tool_input, duration from span timestamps,
  and per-turn token counts on child chat spans — so duplicating the
  envelope would only create a second source of truth.

- Trim the now-redundant call-site comment that duplicated the helper's
  JSDoc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant