Lesson 4 - Errors, Parallel Tools, and Stopping Conditions
Welcome to Errors, Parallel Tools, and Stopping Conditions
In Lesson 3 you built a real agent loop: call the model, run any tools it requests, feed the results back, and repeat until Claude answers in plain prose. That loop works beautifully when everything goes right. But real tools fail — a network call times out, an argument is invalid, a currency pair isn’t supported. And a loop that runs the model again and again is only as safe as the limits you put around it. This lesson is about making the loop robust: handling errors so Claude can recover, running parallel tool calls correctly, and bounding the loop so it can never run away.
By the end of this lesson, you will be able to:
- Wrap each tool call in
try/exceptand return anis_errortool_resultinstead of crashing - Run multiple tool calls from a single assistant turn and return all results in one user message
- Stop the loop safely with a natural stop reason plus a
max_stepscap - Explain why an unbounded agent loop is dangerous for cost and reliability
These three guardrails are what separate a demo loop from one you’d trust to run on its own. Let’s add them.
Handling Tool Errors So Claude Can Recover
The most important rule of tool execution: never let a failing tool crash your loop, and never drop a failed tool. Claude is waiting for a result for every tool_use block it sent. If your code throws an exception and dies, the agent stops mid-conversation. If you silently skip a tool that failed, the conversation breaks because a tool_use has no matching tool_result. The fix is to wrap each call in try/except and, on failure, return a tool_result that carries the error message and the flag "is_error": True. That way Claude sees the failure and can decide what to do — apologize, try a different tool, or ask the user for clarification.
Here is the relevant slice of the loop. Notice that it always appends a tool_result — a successful one, or an error one — and that all results go back in a single user message:
for block in response.content:
if block.type != "tool_use":
continue
fn = tool_functions.get(block.name)
try:
if fn is None:
raise KeyError(f"no such tool: {block.name}")
result = fn(**block.input)
tool_results.append({"type": "tool_result", "tool_use_id": block.id,
"content": str(result)})
except Exception as exc:
tool_results.append({"type": "tool_result", "tool_use_id": block.id,
"content": f"Error: {exc}", "is_error": True})
messages.append({"role": "user", "content": tool_results}) # ALL results, one messageTwo design choices do the heavy lifting. First, an unknown tool name is treated as a failure (raise KeyError) rather than a crash, so even a hallucinated tool comes back as a clean error. Second, any exception the tool raises is caught and converted into an error result — your loop keeps going no matter what the tool did.
Here is a verified run where convert_currency was asked for an unsupported pair (GBP to USD) and raised a KeyError. The loop caught it, returned an is_error result, and Claude recovered gracefully:
step 1: Claude calls convert_currency({'amount': 50, 'from_currency': 'GBP', 'to_currency': 'USD'})
tool_result content: Error: ('GBP', 'USD')
is_error flag present: True
final answer: I can't convert GBP to USD yet — I support JPY->USD and USD->EUR.The tool blew up, but the agent didn’t. Claude read the error result, understood that the conversion wasn’t possible, and gave the user a helpful, honest answer instead of a stack trace. That is the entire point of returning errors to the model: it turns a failure into something the agent can reason about.
Parallel Tool Calls: Many Requests, One Reply
A single assistant turn can contain more than one tool_use block. When Claude sees that two tasks are independent — say, checking the weather in two different cities — it may ask for both at once. Your loop has to handle that correctly, and the rule is strict: run every tool the assistant requested, and return all of their tool_result blocks together in one user message. Splitting them across multiple messages, or omitting any one of them, breaks the conversation — every tool_use must be answered, in the same turn, by a matching tool_result.
The good news is that the loop you already have does this correctly without any special handling. Look again at the code above: it iterates over every block in response.content, appends a result for each tool_use it finds into one tool_results list, and then appends that whole list as a single user message. Whether the assistant sent one tool call or five, they all run and all come back together.
for block in response.content: # iterates over ALL tool_use blocks
...
tool_results.append({...}) # one result per tool_use
messages.append({"role": "user", "content": tool_results}) # all of them, togetherThis is exactly the pattern that handled the parallel case in Lesson 3’s verified run, where the model returned two tool_use blocks in a single step: both tools ran, and both results were sent back in one message. The takeaway is that “collect all results, then append once” is not just convenient — it is the correct shape for parallel tool use. If you ever find yourself calling messages.append inside the per-block loop, that’s the bug: you’d be splitting the results across messages.
Stopping Conditions: Natural Stops and Safety Limits
An agent loop needs to know when to stop. There are two kinds of stopping conditions, and a robust loop uses both.
The first is the natural stop: when Claude responds with a stop_reason that is not "tool_use" (such as "end_turn"), it means the model is done acting and has given its final answer. That’s the normal, happy exit — you saw it in Lesson 3 when the loop ran a tool and then Claude answered in prose.
The second is a safety limit, and it matters because the natural stop is not guaranteed. A misbehaving or confused model can keep calling tools forever, never producing a final answer. To protect against that, you cap the number of iterations with max_steps. Here is a verified run against a (deliberately) broken model that always calls a tool and never finishes, with max_steps=3:
step 1: Claude calls get_weather({'city': 'Kyoto'})
step 2: Claude calls get_weather({'city': 'Kyoto'})
step 3: Claude calls get_weather({'city': 'Kyoto'})
final answer: Stopped: reached the step limit.
steps taken: 3The model never stopped on its own — but the loop did, after three steps, returning a clear “reached the step limit” message instead of spinning forever. Without that cap, this loop would run indefinitely, calling the paid model API on every single iteration. A step cap turns “infinite loop” into “bounded, predictable behavior.”
max_steps is the simplest safety limit, but the same idea extends to other budgets. You can track a cost cap (stop once you’ve spent a set number of tokens or dollars) or a timeout (stop once the loop has run longer than some wall-clock limit). All three answer the same question — how much is this agent allowed to do before we force it to stop? — and a production agent usually has at least one of them in place.
Always bound the loop
An agent loop with no max_steps or budget can run forever — and because it calls the model on every iteration, an infinite loop is also an unbounded bill. A confused model that keeps requesting tools, or a tool whose result always provokes another tool call, will happily spin until something external stops it. Set a step cap on every agent you build, and add a cost or time budget for anything that runs unattended. The cap should never be the expected exit — it’s the safety net for when the natural stop doesn’t happen.
Practice Exercises
Exercise 1: Why return an error instead of crashing?
A tool you wrote raises an exception while the agent is running. Why is it better to catch it and return a tool_result with "is_error": True than to let the exception propagate and stop the program?
Hint
Crashing ends the conversation and gives the user nothing useful. Returning an is_error result keeps the loop alive and, crucially, lets Claude see the failure — so it can apologize, try a different tool, or ask the user for clarification. In the verified run, a KeyError from convert_currency came back as an error result, and Claude responded with a helpful “I can’t convert GBP to USD yet” instead of a stack trace. Remember: every tool_use must get a matching tool_result, even when the tool failed.
Exercise 2: Where do three parallel tool results go?
Claude returns a single assistant message containing three tool_use blocks. You run all three tools. How many user messages should you append, and what goes in them?
Hint
Exactly one user message, containing all three tool_result blocks in its content list (each with its own tool_use_id). Splitting them across multiple messages, or omitting any one of them, breaks the conversation — every tool_use from that turn must be answered together. The loop pattern “collect all results, then messages.append once” produces exactly this shape.
Exercise 3: What stops a model that never stops?
You have an agent whose model keeps calling tools and never produces a final answer. The natural stop reason (end_turn) never arrives. What stops the loop, and why is relying only on the natural stop dangerous?
Hint
A max_steps cap stops it. In the verified run with max_steps=3, the model called get_weather three times in a row and never finished, but the loop exited with “Stopped: reached the step limit.” Relying only on the natural stop is dangerous because nothing guarantees it ever happens — an unbounded loop calls the paid API on every iteration and can run forever. A cost or time budget does the same protective job.
Summary
A robust agent loop adds three guardrails to the basic loop from Lesson 3. Error handling: wrap each tool call in try/except and, on failure, return a tool_result with "is_error": True and the error message — never crash, and never drop a failed tool, so Claude sees the error and can recover. Parallel tool calls: a single assistant turn can contain many tool_use blocks; run them all and return all their tool_result blocks in one user message — the “collect results, append once” pattern handles this for free. Stopping conditions: exit naturally on a non-tool_use stop reason, but always add a max_steps cap (and ideally a cost or time budget) so the loop can never run forever.
Key Concepts
is_errortool_result — return{"type": "tool_result", "tool_use_id": ..., "content": f"Error: {exc}", "is_error": True}so the model sees and recovers from failures.- Never drop a tool — every
tool_useblock needs a matchingtool_result, success or error. - Parallel tool calls — many
tool_useblocks in one turn, all results returned in one user message. max_stepscap — a hard limit on iterations; the safety net when the natural stop never comes.- Budgets — cost and timeout caps that bound an agent the same way
max_stepsbounds iterations.
Why This Matters
The difference between a loop that works in a demo and one you’d let run on its own is exactly these guardrails. Tools will fail in production, and an agent that turns failures into recoverable error results is far more reliable than one that crashes. Parallel tool handling keeps the conversation valid when Claude batches its work. And a step or budget cap is the single most important safety habit in agent engineering — it’s what stands between you and an infinite loop that quietly drains your API budget. With these in place, you have a loop sturdy enough to build a real project on, which is exactly what comes next.
Next Steps
Continue to Lesson 5 - Guided Project: Atlas's First Tool Loop
Put the full, robust agent loop together in a hands-on guided project with Atlas.
Back to Module Overview
Return to The Agent Loop with Claude module overview
Continue Building Your Skills
You can now make an agent loop robust: it catches tool errors and hands them back as is_error results so Claude can recover, it runs parallel tool calls and returns every result in one message, and it bounds itself with a max_steps cap so it can never run away. In the next lesson you’ll bring everything together in a guided project, building Atlas’s first complete tool loop from scratch.