Skip to content
intermediate Phase 6 · Structured Outputs and Tool Calling

Running Multi-Step Tool Flows

Execute tool calls in a loop, feeding results back until the model finishes.

55m
0 problems
Topic Progress 0%

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:

  1. The assistant turn carrying tool_calls must 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.
  2. Every result is its own role: "tool" message with a tool_call_id matching the call it answers. Results are strings — json.dumps whatever 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 the if. The assistant turn with its tool_calls has to be in history for the following request; appending results without it produces a 400 about unmatched tool calls.
  • if not msg.tool_calls is the termination condition, not a fixed number of steps. None and 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 per tool_call_id.
  • dispatch converts 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. raise inside dispatch discards 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 the tool_calls list 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?

Question 1 options

2. After running the requested tools, what must the next request contain?

Question 2 options

3. A registered tool raises an exception mid-run. What should the loop do?

Question 3 options

4. What happens if you omit the turn cap?

Question 4 options

Flashcards

Question

What is the stop condition of a tool-execution loop?

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?

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?

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?

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?

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.dumps every 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.

Related Reading