All posts
Fundamentals

The complete tool-calling loop, end to end

Part 12 showed the shape of the loop to make a point about why agents exist. This post writes the whole thing out, including the parts that only matter once you run it.

By the end you have a working agent in about forty lines.

The round trip has five steps

One tool call is a conversation with five moves in it, and three of them are yours.

  1. You send the user's message plus the tool definitions.
  2. The model replies with a tool call: a name and a set of arguments.
  3. You execute the function and get a result.
  4. You send everything back: the whole conversation so far, plus that result.
  5. The model answers, using what came back.

Five steps. Steps 1, 3 and 4 are yours; the model only ever produces text.Five steps. Steps 1, 3 and 4 are yours; the model only ever produces text.

Step 3 is the one worth stopping on, because of what it says about step 2.

The model never executes anything. It cannot reach your filesystem, call your API or run your function. What it produces at step 2 is a textual specification: the name of a tool and some arguments, as text. Your code reads that text, decides whether to honour it, and does the work.

That single fact is where every control in this series lives. The model proposes; your code disposes.

The five things you have to build

Working backwards from those steps:

  1. Tool definitions, so the model knows what exists.
  2. The tools themselves, as ordinary functions.
  3. A way to send both to the model.
  4. Something that turns a tool call into an actual function call.
  5. A way to put the result back in the conversation so the model can continue.

Posts 13 and 14 covered 1 and 2. The rest is this post.

The toolbox

You need a lookup from a tool name to a callable. The neat version writes itself.

TOOLS = [read_tickets, search_web, calculate]

tool_box = {tool.__name__: tool for tool in TOOLS}
tool_definitions = [to_tool_definition(tool) for tool in TOOLS]

Three lines, one list. Add a function to TOOLS and it becomes available to the agent, with its schema generated from its own signature. Nothing to register twice, so nothing can drift out of sync.

The __name__ key matters: it is the same string the model will send back, because it came from the schema you generated from that same attribute.

Executing a tool call

The model hands you a name and a JSON string. Turning that into a call is short.

import json

def execute_tool(tool_box: dict, tool_call) -> str:
    name = tool_call.function.name
    args = json.loads(tool_call.function.arguments)   # the model sends JSON as a string
    return tool_box[name](**args)

Three things happen there and each one deserves a look.

tool_call.function.arguments arrives as a string, not a dict. The model produced JSON as text and you parse it, which means a malformed generation surfaces here as a JSONDecodeError.

tool_box[name] is a dictionary lookup on a name the model produced. A model that invents a tool name raises KeyError here.

**args unpacks straight into your function, so a missing required argument raises TypeError.

All three are handled below. Notice that each is a normal Python error at a normal place, which is exactly what you want: the failure lands in your code, where you can see it.

The control loop

Now the whole thing.

def run_agent(system_prompt: str, question: str, max_steps: int = 10) -> str:
    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user",   "content": question},
    ]

    for step in range(max_steps):
        reply = client.chat(
            model=MODEL,
            messages=messages,
            tools=tool_definitions,
        )

        # No tool call means the model has an answer. Done.
        if not reply.tool_calls:
            return reply.content

        messages.append(reply)                       # 1. what it asked for

        for call in reply.tool_calls:                # 2. models can ask for several at once
            try:
                result = execute_tool(tool_box, call)
            except Exception as e:
                result = f"Tool failed: {type(e).__name__}: {e}"
            messages.append({                        # 3. what came back
                "role": "tool",
                "tool_call_id": call.id,             # 4. which call this answers
                "content": str(result),
            })

    return "Stopped: reached the step limit without an answer."

Read the four numbered points, because each is a thing the short version in part 12 left out.

Appending the model's own message. The reply containing the tool call goes back into the list before the result does. Skip it and the model sees a result for a request it has no record of making.

Several calls per reply. A model can ask for three tools in one turn, so the inner loop is a loop. Serial execution is fine to start; these are independent, so asyncio.gather applies here exactly as it did for batching.

Errors become results. A failed tool returns a string describing the failure instead of crashing the agent. The model reads it and can retry with a different argument or explain the problem. This is the error-message discipline from post 14, applied at the loop level.

tool_call_id. When a reply contains multiple calls, this is what pairs each result with its request. Omit it with more than one call in flight and the model has to guess which is which.

Watching it run

The ticket question, with the loop printing each step.

step 0  tool_use   read_tickets(quarter="2026-Q2")
                   -> "password_reset: 118 tickets, 6.2 min avg\nbilling: 74 ..."
step 1  tool_use   search_web(query="input token price per million current")
                   -> "Pricing page: <current rate> per 1M input tokens ..."
step 2  tool_use   calculate(expression="118 * 6.2 / 60")
                   -> "12.19"
step 3  answer     "Password resets consumed about 12.2 staff-hours last quarter..."

Four passes. Nothing in run_agent said four. The model asked for what it needed, in an order it decided, and stopped when it had enough.

That is the whole thing. An agent is this loop plus tools worth calling.

The four things that break it

Runaway loops. A model can keep calling tools forever, especially when a tool keeps returning something unhelpful. max_steps is the seatbelt, and hitting it is a signal to read the transcript rather than to raise the limit.

Growing context. Every tool result stays in messages for the rest of the run. A ten-step run carries nine results on the final call, which is why post 14 argued for small, shaped returns.

Malformed arguments. json.loads fails on a truncated or malformed generation. Catch it, return the failure as a tool result, and the model usually corrects itself on the retry.

Invented tool names. A KeyError on tool_box[name] means the model asked for something that does not exist. The same handler catches it, and the message tells the model what it actually has.

FailureWhere it surfacesWhat the loop does
Never finishesmax_steps exhaustedReturns a stop message you can see
Context grows too largeProvider error, or costShape returns; trim old results
Malformed JSON argumentsjson.loadsReturns the error as a tool result
Tool name does not existtool_box[name]Returns the error as a tool result

Every failure lands as a normal Python exception in your code, then goes back as a message.Every failure lands as a normal Python exception in your code, then goes back as a message.

What to take from this

  • The model never executes anything. It emits a name and arguments as text, and your code decides whether to honour them.
  • The loop is short: send, check for tool calls, execute, append, repeat until there are none.
  • Build the toolbox from one list so the callable and its schema cannot drift apart, and turn every tool failure into a message the model can read.

Forty lines and a list of functions is a working agent. The next problem is that writing those functions for every service you care about does not scale.