The Agent Loop — Tool Use Is a Contract, Not a Function Call
Tool Use Is a Contract, Not a Function Call
AI engineeringAI engineering / agents
Build the whole thing in 6 lines
msgs = [{"role": "user", "content": "Why is checkout 500ing?"}]
while True:
r = client.messages.create(model="claude-opus-5", max_tokens=4096, tools=TOOLS, messages=msgs)
msgs.append({"role": "assistant", "content": r.content})
if r.stop_reason != "tool_use": break
msgs.append({"role": "user", "content": [run(b) for b in r.content if b.type == "tool_use"]})
where run(b) returns {"type": "tool_result", "tool_use_id": b.id, "content": ...}
That's an agent. Memory, planning, "autonomy" — all decoration on this loop.
What each line actually teaches
🔹 The model never executes anything. It emits a tool_use block {id, name, input}. YOUR code runs it and hands back the result. "Function calling" is a misleading name — nothing is called.
🔹 Append r.content, not r.content[0].text. A turn can hold thinking + text + three tool_use blocks. Flattening to a string silently corrupts the history you resend next turn.
stop_reason is the loop condition, not a log field:
- "tool_use" → run tools, continue
- "end_turn" → done
- "max_tokens" → truncated mid-thought. NOT done. Treating it as done is how agents "finish" with half an answer.
🔹 Results go back as a user message of tool_result blocks. The contract: every tool_use gets exactly one tool_result, with a matching tool_use_id, in the very next message. Miss one → 400.
Four things intermediate builders get wrong
- Splitting parallel results. One assistant turn can request 3 tools at once. Return those 3 tool_results in ONE user message. Split them across messages and, where the API tolerates it, you've taught the model to stop batching — your 3-parallel-call agent becomes a 3-round-trip agent. Same work, 3x the latency and 3x the prefill.
- Raising on tool failure. Don't let a 404 blow up the loop. Send tool_result with is_error: true and the message ("no such user_id"). The model retries with better arguments. An exception teaches it nothing.
- No turn cap. A model that keeps calling list_files can burn your budget in one request. Cap iterations (say 15) and cap tokens; log every tool call with its latency.
- Treating tool descriptions as docs. They're prompt. "Search orders" vs "Search orders by customer email; returns last 50, newest first; use get_order for full detail" is the difference between a working agent and one that guesses. And past ~20 tools, selection accuracy drops — split into sub-agents or filter the tool list per request.
The mental model
The model is a pure function from conversation → next block. All state, all side effects, all safety live in your loop. If your agent is "unreliable", the bug is usually in the loop — not the model.
Your exercise
Instrument the loop: print turn number, tool name, input, and latency per iteration. Then deliberately break one tool (raise on a valid input) and return it as is_error: true instead of crashing. Does the model recover, retry differently, or loop forever? That answer tells you whether you need a turn cap today.