The Shape of a Tool Round Trip
A support agent is asked one question that no single model call can answer:
user: "Was order 88213 refunded?"
The model cannot answer it — the fact lives in your database. So the conversation does not go user → answer. It goes user → model asks for permission to act → your code runs the tool → the result goes back in → the model continues, possibly asking for a second tool → eventually the model produces prose with no pending calls. That final condition is the only stop signal you get.
user message
│
▼
┌───────────────┐ tool_calls present? ┌────────────────┐
│ model call ├──────── yes ──────────►│ run each tool │
└───────────────┘ └───────┬────────┘
▲ │
│ role:"tool" results, one per call │
└───────────────◄────────────────────────┘
│
│ no tool_calls
▼
final assistant text ──► user
Two rules govern the message sequence, and violating either returns an API error rather than a wrong answer:
- The assistant turn carrying
tool_callsmust be appended verbatim. The next request has to show the model what it asked for; a transcript that skips it is an orphaned set of results. - Every result is its own
role: "tool"message with atool_call_idmatching the call it answers. Results are strings —json.dumpswhatever your function returned.
The transcript for the question above looks like this:
[user] Was order 88213 refunded?
[assistant] tool_calls=[search_orders(customer_id="c_88213", query="88213")]
[tool] [{"id": "88213", "total": 62.40, "status": "refunded"}]
[assistant] tool_calls=[get_refund_policy(order_id="88213")]
[tool] {"window_days": 30, "method": "original payment"}
[assistant] "Yes — order 88213 was refunded $62.40 to the original payment method."
Three model round trips, two tool executions, one answer. Your job is to make that cycle run without a human watching it.
The Execution Loop
The whole engine is a while-shaped loop with a hard cap. The stop test is whether the model returned any tool calls:
import json
from openai import OpenAI
client = OpenAI()
MAX_TURNS = 6
def dispatch(call) -> str:
try:
fn = TOOL_IMPL[call.function.name]
result = fn(**json.loads(call.function.arguments))
except Exception as err: # a raised tool must not kill the run
return json.dumps({"error": type(err).__name__, "message": str(err)})
return json.dumps(result, default=str) # tool content is always a string
def run(user_request: str) -> str:
messages = [{"role": "user", "content": user_request}]
for _ in range(MAX_TURNS):
msg = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=TOOLS,
).choices[0].message
messages.append(msg) # assistant turn must precede its results
if not msg.tool_calls: # no pending calls → the model is finished
return msg.content
for call in msg.tool_calls: # batched calls: one reply per call id
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": dispatch(call),
})
raise RuntimeError("turn cap reached without a final answer")
Executed against the refund question:
> run("Was order 88213 refunded?")
turn 1: tool_calls=[search_orders(customer_id="c_88213", query="88213")]
tool → [{"id": "88213", "total": 62.40, "status": "refunded"}]
turn 2: tool_calls=[get_refund_policy(order_id="88213")]
tool → {"window_days": 30, "method": "original payment"}
turn 3: no tool_calls
return "Yes — order 88213 was refunded $62.40 to the original payment method."
Four lines deserve the attention:
messages.append(msg)before theif. The assistant turn with itstool_callshas to be in history for the following request; appending results without it produces a 400 about unmatched tool calls.if not msg.tool_callsis the termination condition, not a fixed number of steps.Noneand an empty list both mean the model has stopped asking to act.- Batched calls are normal. One assistant message can request several tools; execute each (concurrently when they are independent) and append one
role: "tool"message pertool_call_id. dispatchconverts exceptions into payloads. If a tool raises and you let it propagate, the whole conversation is lost; if you send the error back as a tool message, the model can adjust its arguments and continue — the subject of Shaping Tool Results for Models.
The loop is deliberately dumb. Everything intelligent happens in the model; everything safe — caps, dispatch, error containment — happens in code you can unit test without an API key.
Loops That Never Stop
Left unattended, this loop fails in four characteristic ways:
- It never stops. Without
MAX_TURNS, a confused model re-requests the same tool with the same arguments until the token budget is gone — each round is a full re-prompt with a longer transcript, so cost grows with every turn, not linearly with the answer. An iteration cap is mandatory; goal checks and per-task token budgets are what let a well-behaved run exit early instead of always hitting the cap (Stopping Conditions and Budgets). - A tool exception ends the conversation.
raiseinsidedispatchdiscards every prior turn. Return a structured error payload instead: the model reads it, corrects the arguments, and the run continues (Error Recovery in Agent Runs). - Mismatched or missing
tool_call_id. The provider rejects the request outright. Appending results out of order, replying to only some of a batch, or hand-editing the assistant message all trip this — build results mechanically from thetool_callslist rather than by hand. - A silent loop. The model stops calling tools and returns a confident answer assembled from partial data. No API error will ever surface this; you catch it by logging each turn — inputs, calls, arguments, results — and replaying failed runs offline.
One design choice remains: independent calls in a batch. Serial execution is simpler to trace; concurrent execution (asyncio.gather over the batch) cuts wall-clock latency when the second call does not depend on the first. The rule for correctness is unchanged either way — one role: "tool" message per call id, all of them appended before the next model request.
Which result content goes into those messages decides whether the next turn is useful or wasted. A raw HTML page or an exception traceback handed back verbatim costs tokens and steers the model badly; shaping that payload is the next topic, Shaping Tool Results for Models.
Quiz
1. When does a tool-execution loop terminate?
2. After running the requested tools, what must the next request contain?
3. A registered tool raises an exception mid-run. What should the loop do?
4. What happens if you omit the turn cap?
Flashcards
Question
What is the stop condition of a tool-execution loop?
Click to reveal answer
Answer
The model returns a message with no tool calls. The turn cap bounds runs that never reach that state.
Question
What two message rules must every tool round trip satisfy?
Click to reveal answer
Answer
Append the assistant turn carrying tool_calls, then one role:tool message per call id - each with a matching tool_call_id and string content.
Question
Why must the assistant tool_calls turn be appended before its results?
Click to reveal answer
Answer
The next request has to show the model what it asked for. Skipping or editing it leaves orphaned results and the API returns 400.
Question
What should happen when a tool raises inside the loop?
Click to reveal answer
Answer
Return a structured error payload as the tool message so the model can correct and continue; propagating the exception loses the whole conversation.
Question
How are batched tool calls handled?
Click to reveal answer
Answer
One assistant message can request several tools. Execute them - concurrently when independent - and append one role:tool reply per call id before the next model call.
Revision Notes
Key Takeaways
- 1. The loop ends only when the model returns no tool calls; the turn cap bounds everything else
- 2. Assistant turn first, one role:tool message per call id, content always a string - the API enforces the order
- 3. Convert tool exceptions into payloads; raising discards the entire conversation
- 4. Each round re-sends the transcript, so turn count and payload size drive cost
Interview Tips
- • Draw the round trip: user, assistant with tool_calls, tool messages, repeat, final text
- • Explain the stop condition and the turn cap separately - they solve different failures
- • Describe what you log per turn so a failed run can be replayed offline
- • Say how batched calls are executed - concurrent when independent - and replied to one message per id
Cheat Sheet
Multi-Step Tool Execution — Cheat Sheet
The loop
for _ in range(MAX_TURNS):
msg = create(messages, tools=TOOLS).choices[0].message
messages.append(msg) # assistant turn WITH tool_calls must be kept
if not msg.tool_calls: # None or [] → model is done
return msg.content
for call in msg.tool_calls:
messages.append({"role": "tool",
"tool_call_id": call.id, # must match, or the API 400s
"content": dispatch(call)}) # always a string
raise RuntimeError("turn cap reached")
Message order per round
user → [assistant: tool_calls] → [tool, tool, ... one per call id] → repeat → final text
Non-negotiables
- Append the assistant turn before its tool results; skipping it orphans the results
- Batched calls: run independently-concurrent, reply one message per
tool_call_id json.dumpsevery result — never a dict, never a Python object- Catch tool exceptions and return an error payload; raising discards the conversation
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Token spend climbs all night | no iteration cap | hard MAX_TURNS + goal check + budget |
| 400: unmatched tool call | missing/edited assistant turn, bad id | build results mechanically from tool_calls |
| run dies on first tool error | exception propagated | structured error back as a tool message |
| confident wrong answer, no error | partial data, no observation | log every turn; replay offline |
Cost shape: each round re-sends the whole transcript — round count is the primary cost lever, so prune result payloads (next topic) and prune the tool list per request.
Continue Learning
Before You Start
- No prerequisites listed.