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
- OpenAI developer on retainer — structured outputs
strict=Trueaccepts natural language date string —datetime.fromisoformat()crashes — 18% extraction failures for 7 weeks — openai 1.35.0 - Together AI developer on retainer —
response_formatJSON schema silently ignored for Mixtral-8x7B — Pydantic field name mismatch — 23% extraction failures for 5 weeks — together 1.2.1 - PydanticAI developer on retainer — agent result validator runs before tool output validator — tool output cached with pre-validation value — downstream type errors — pydantic-ai 0.0.14
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 →