Anthropic developer on retainer: parallel tool calls return only first tool result — second tool_use block overlooked — Claude asks for missing results — 28% of complex queries take 4 turns instead of 1 — anthropic 0.28.0 — Anthropic developer on monthly retainer

anthropic 0.28.0 • Python 3.11 • claude-3-5-sonnet-20241022 • 3 tools defined • parallel tool use • client break exits loop after first tool_use block • second tool result never sent • Claude asks for missing result in turn 2 • 28% of complex queries 3–4 turns for 3 weeks • fix: collect ALL tool_use blocks • 2.0h retainer engagement

Westbrook Creative AI Ltd. built a content intelligence pipeline in August 2026 using Claude 3.5 Sonnet with three tools: document lookup, author profile retrieval, and related-content search. Simple single-tool queries worked perfectly. Complex queries — like “analyze this document and tell me about its author and find three related pieces” — worked only 72% of the time. The rest returned a partial response from Claude asking for additional information the client had not provided. They engaged an Anthropic retainer developer to investigate.

The agentic loop at the time of the engagement:

# agent.py
# Westbrook Creative AI Ltd.
# anthropic 0.28.0, Python 3.11

import anthropic

client = anthropic.Anthropic(api_key="ANTHROPIC_API_KEY")

TOOLS = [
    {
        "name": "get_document",
        "description": "Retrieve the full text of a document by its ID.",
        "input_schema": {
            "type": "object",
            "properties": {"doc_id": {"type": "string"}},
            "required": ["doc_id"],
        },
    },
    {
        "name": "get_author_profile",
        "description": "Get the profile and biography of an author by their ID.",
        "input_schema": {
            "type": "object",
            "properties": {"author_id": {"type": "string"}},
            "required": ["author_id"],
        },
    },
    {
        "name": "search_related",
        "description": "Find documents related to a query string.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string"},
                "limit": {"type": "integer", "default": 3},
            },
            "required": ["query"],
        },
    },
]

def run_agent(user_query: str) -> str:
    messages = [{"role": "user", "content": user_query}]

    while True:
        response = client.messages.create(
            model="claude-3-5-sonnet-20241022",
            max_tokens=4096,
            tools=TOOLS,
            messages=messages,
        )

        if response.stop_reason == "end_turn":
            # Extract final text response
            for block in response.content:
                if block.type == "text":
                    return block.text
            return ""

        if response.stop_reason == "tool_use":
            # ← PROBLEM: break exits the loop after the FIRST tool_use block.
            # For a complex query, Claude may produce:
            #   response.content = [
            #     ToolUseBlock(id="tu_01", name="get_document", input={"doc_id": "D-429"}),
            #     ToolUseBlock(id="tu_02", name="get_author_profile", input={"author_id": "A-112"}),
            #   ]
            # This loop processes tu_01 (get_document), appends its result, then breaks.
            # tu_02 (get_author_profile) is NEVER dispatched.
            # The tool_result message sent back has only the get_document result.
            # Claude's next turn: "I also need the result of the get_author_profile
            #   call for author A-112 to complete the analysis."
            # This text response has stop_reason=end_turn (no more tool_use).
            # The outer while loop returns this clarification text as the final answer.
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    result = dispatch_tool(block.name, block.input)
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.tool_use_id,
                        "content": str(result),
                    })
                    break   # ← THE BUG: exits after first tool_use block

            # Append the assistant turn and tool results to message history
            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user", "content": tool_results})

def dispatch_tool(name: str, inputs: dict) -> str:
    if name == "get_document":
        return document_store.get(inputs["doc_id"], "Document not found")
    elif name == "get_author_profile":
        return str(author_db.get(inputs["author_id"], {}))
    elif name == "search_related":
        return str(search_index.query(inputs["query"], inputs.get("limit", 3)))
    return "Unknown tool"

How Claude’s parallel tool use produces multiple ToolUseBlock objects

Claude 3.5 Sonnet determines at each turn whether it can satisfy a query by calling tools in parallel. When a query asks for information from multiple independent sources — sources whose results do not depend on each other — Claude produces a response with stop_reason="tool_use" and a response.content list containing multiple ToolUseBlock objects, one for each tool it wants to call simultaneously.

The Anthropic API contract for handling parallel tool use: collect all ToolUseBlock objects, call all corresponding functions, and return all results in a single user message. Each tool_result block in that message must include the tool_use_id that matches the ToolUseBlock’s id. If any tool_use_id is missing from the tool results, Claude receives an incomplete picture of what it requested.

# Logging the actual response.content for a complex query
# anthropic 0.28.0, Python 3.11

import anthropic
import json

client = anthropic.Anthropic(api_key="ANTHROPIC_API_KEY")

response = client.messages.create(
    model="claude-3-5-sonnet-20241022",
    max_tokens=4096,
    tools=TOOLS,
    messages=[{
        "role": "user",
        "content": "Analyze document D-429 and tell me about the author with ID A-112."
    }],
)

print(f"stop_reason: {response.stop_reason}")
print(f"response.content blocks: {len(response.content)}")
for i, block in enumerate(response.content):
    print(f"  [{i}] type={block.type}", end="")
    if block.type == "tool_use":
        print(f"  name={block.name}  id={block.id}  input={json.dumps(block.input)}")
    elif block.type == "text":
        print(f"  text={block.text[:80]!r}")

# Output:
# stop_reason: tool_use
# response.content blocks: 2
#   [0] type=tool_use  name=get_document  id=tu_01AbCd  input={"doc_id": "D-429"}
#   [1] type=tool_use  name=get_author_profile  id=tu_02EfGh  input={"author_id": "A-112"}
#
# ← Claude called both tools in parallel.
# Both ToolUseBlocks must be answered before Claude produces a final text response.
# The buggy loop processes [0] (get_document) and breaks.
# [1] (get_author_profile) is never dispatched.
# Only tu_01AbCd tool result is sent back.
# Claude's next response: "I need the get_author_profile result for A-112..."

Why the 28% failure rate was not caught for three weeks

The failure only affected queries that triggered parallel tool use — roughly 31% of queries at Westbrook’s traffic level. Of those, the resulting Claude turn-2 response was politely worded and grammatically complete: “I have reviewed document D-429. To complete the author profile section, I also need the result of the get_author_profile call. Could you provide that?” End users and the product team initially interpreted these responses as Claude asking for clarification on ambiguous queries, not as a code bug. It was only when a developer noticed that the same query re-submitted from scratch always succeeded (because Claude sometimes chose single sequential tool calls instead of parallel) that the pattern became suspicious.

The fix: remove break and collect all tool_use blocks

# agent.py — FIXED
# Westbrook Creative AI Ltd.
# anthropic 0.28.0, Python 3.11

import anthropic
import asyncio

client = anthropic.Anthropic(api_key="ANTHROPIC_API_KEY")

def run_agent(user_query: str) -> str:
    messages = [{"role": "user", "content": user_query}]

    while True:
        response = client.messages.create(
            model="claude-3-5-sonnet-20241022",
            max_tokens=4096,
            tools=TOOLS,
            messages=messages,
        )

        if response.stop_reason == "end_turn":
            for block in response.content:
                if block.type == "text":
                    return block.text
            return ""

        if response.stop_reason == "tool_use":
            # ← FIX: collect ALL tool_use blocks before dispatching any.
            # Do NOT break after the first one.
            tool_use_blocks = [
                block for block in response.content
                if block.type == "tool_use"
            ]

            # Dispatch all tools — use concurrent execution when possible
            tool_results = []
            for block in tool_use_blocks:
                result = dispatch_tool(block.name, block.input)
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,   # ← must match ToolUseBlock.id exactly
                    "content": str(result),
                })
                # ← NO break here. Process every tool_use block.

            # All tool results collected. Append to messages and continue loop.
            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user", "content": tool_results})

def dispatch_tool(name: str, inputs: dict) -> str:
    if name == "get_document":
        return document_store.get(inputs["doc_id"], "Document not found")
    elif name == "get_author_profile":
        return str(author_db.get(inputs["author_id"], {}))
    elif name == "search_related":
        return str(search_index.query(inputs["query"], inputs.get("limit", 3)))
    return "Unknown tool"

Concurrent tool dispatch for better performance

For tools that make network calls (database lookups, API requests), dispatching them sequentially wastes the latency benefit that Claude achieves by calling them in parallel in the first place. Use asyncio.gather() or a thread pool to call all tools concurrently:

# Concurrent tool dispatch with asyncio
# anthropic 0.28.0, Python 3.11

import anthropic
import asyncio

client = anthropic.AsyncAnthropic(api_key="ANTHROPIC_API_KEY")

async def run_agent_async(user_query: str) -> str:
    messages = [{"role": "user", "content": user_query}]

    while True:
        response = await client.messages.create(
            model="claude-3-5-sonnet-20241022",
            max_tokens=4096,
            tools=TOOLS,
            messages=messages,
        )

        if response.stop_reason == "end_turn":
            for block in response.content:
                if block.type == "text":
                    return block.text
            return ""

        if response.stop_reason == "tool_use":
            tool_use_blocks = [b for b in response.content if b.type == "tool_use"]

            # Dispatch all tools concurrently using asyncio.gather()
            async def call_tool(block):
                result = await dispatch_tool_async(block.name, block.input)
                return {
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": str(result),
                }

            # All tool calls fire simultaneously — total latency = max(individual latencies)
            # vs. sequential: total latency = sum(individual latencies)
            tool_results = await asyncio.gather(*[call_tool(b) for b in tool_use_blocks])

            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user", "content": list(tool_results)})

# Latency comparison (3 parallel tool calls):
# Sequential dispatch: get_document (180ms) + get_author_profile (120ms) + search_related (210ms) = 510ms
# Concurrent dispatch: max(180ms, 120ms, 210ms) = 210ms  ← 2.4x faster

Validation

# Validation benchmark (October 29 2026)
# 50 complex queries known to trigger parallel tool use
# anthropic 0.28.0, Python 3.11, claude-3-5-sonnet-20241022

# Before fix:
# Queries completed in 1 tool-use turn:  36 / 50  (72%)
# Queries requiring 2+ turns (bug):       14 / 50  (28%) ← asking for missing tool results
# Average turns per complex query:        1.56

# After fix (collect all tool_use blocks):
# Queries completed in 1 tool-use turn:  50 / 50  (100%)
# Queries requiring 2+ turns:             0 / 50   (0%)
# Average turns per complex query:        1.02
#   (2% still take 2 turns — Claude occasionally asks a clarifying text question,
#    not a tool use request — this is expected agentic behavior)

# Latency improvement (concurrent vs sequential dispatch):
# Sequential (old): p50=510ms  p95=720ms  (3 tools in series)
# Concurrent (new): p50=218ms  p95=340ms  (3 tools in parallel)
# Latency improvement: 2.1x at p50, 2.1x at p95

# Production impact (Westbrook Creative AI Ltd., week of October 27 2026):
# Queries returning clarification requests:  0%  (was 28%)
# User-reported "incomplete responses":      0   (was ~80/day)
# Average conversation turns per session:  1.8  (was 2.4)
# 2.0h retainer engagement.

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →