Skip to content

Claude Agent SDK: _handle_result logs main-agent usage, under-counts turns with subagents #564

Description

@lsuarez-ekline

Summary

The Claude Agent SDK integration logs a turn's token metrics from ResultMessage.usage, which is the main-agent-only cumulative. On any turn that spawns subagents (the Task/Agent tool), the subagent tokens are billed but are missing from the trace, so the reported token total is a large under-count.

Where

braintrust/integrations/claude_agent_sdk/tracing.py, ContextTracker._handle_result — the only place token metrics are attached:

if hasattr(message, "usage"):
    usage_metrics, usage_metadata = extract_anthropic_usage(message.usage)
    ctx = self._get_context(None)
    if ctx.llm_span and (usage_metrics or usage_metadata):
        ctx.llm_span.log(metrics=usage_metrics or None, ...)

ResultMessage.usage is the cumulative usage for the main agent. ResultMessage.model_usage is the documented per-model breakdown that is per-agent — it includes subagent calls. The wrapper never reads model_usage.

Reproduction

Requires an Anthropic API key. The script prints the token total the wrapper would log (from usage) next to the authoritative model_usage total for the same turn.

# pip install claude-agent-sdk braintrust
# export ANTHROPIC_API_KEY=...
import anyio
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
from braintrust.wrappers.claude_agent_sdk import setup_claude_agent_sdk

# Instrument the SDK exactly as a Braintrust user would.
setup_claude_agent_sdk()

PROMPT = (
    "Use the Task tool to launch 3 subagents in parallel. Each subagent should "
    "independently run several tool calls exploring a different topic, then report "
    "back. Wait for all three, then synthesize. Actually spawn them via the Task tool."
)

def usage_total(u: dict) -> int:
    return (u.get("input_tokens", 0) + u.get("output_tokens", 0)
            + u.get("cache_read_input_tokens", 0) + u.get("cache_creation_input_tokens", 0))

def model_usage_total(mu: dict) -> int:
    return sum(m.get("inputTokens", 0) + m.get("outputTokens", 0)
               + m.get("cacheReadInputTokens", 0) + m.get("cacheCreationInputTokens", 0)
               for m in mu.values())

async def main():
    result = None
    async for msg in query(
        prompt=PROMPT,
        options=ClaudeAgentOptions(permission_mode="bypassPermissions"),
    ):
        if isinstance(msg, ResultMessage):
            result = msg

    logged = usage_total(result.usage)              # what _handle_result logs today
    authoritative = model_usage_total(result.model_usage)  # per-agent, incl. subagents
    print(f"wrapper-logged (usage):  {logged:,}")
    print(f"model_usage (per-agent): {authoritative:,}")
    print(f"wrapper captures:        {logged / authoritative:.1%}")

anyio.run(main)

Observed (one run, 3 subagents): usage = 126,113 vs model_usage = 873,617 → the wrapper captures ~14.4% (two models: main Opus + Haiku subagents). In the logged trace the tokens land on a single LLM span; the subagent spans carry none. A run without subagents gives usage == model_usage (unaffected). Exact numbers vary per run; the direction does not.

Cross-check that model_usage is the correct one

model_usage × Anthropic list price reconstructs ResultMessage.total_cost_usd (the SDK's own reported cost) exactly; the usage-based total reconstructs only a fraction. So the under-count is in what's logged, not in model_usage.

Suggested fix

In _handle_result, derive the turn's token metrics from model_usage (aggregated across models) rather than usage — or attribute tokens onto the per-subagent spans. Anthropic's SDK documents model_usage as the authoritative per-agent source (anthropics/claude-agent-sdk-python#987).

PR: #565 — prefers the aggregated model_usage for the logged metrics, falls back to usage (metadata unchanged). Open to the per-subagent-span alternative if that's preferred.

Related

Metadata

Metadata

Assignees

No one assigned

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions