Knaph

The Loop13 of 376 min read

The Loop in Twenty Lines

Leila asks mini "Which of my notes mentions Omid?". It reads notes.txt, sees nothing, and answers "I couldn't find him." It never looked at todo.txt, because mini only allows one round of tools: after the first result, the model has to answer. A person would look in the next file. This lesson lets mini do the same, and it takes about twenty lines.


Leila So I copy the "if it asked for tools, run them and ask again" part, and paste it a few more times?

Omid How many times?

Leila I don't know. As many as the task needs. Which I can't know in advance... so it has to be a loop. It keeps going while the model asks for tools.

Omid And it stops when?

Leila When a reply has no tool calls. Then that reply is the answer.

Omid Is "asked for tools" or "didn't" the only thing a reply can tell you?

Leila There's stop_reason. "tool_use" when it wants a tool. And when it's finished, I suppose there's another value.

Omid "end_turn". But there are others. What if the model is cut off by max_tokens halfway through writing a tool call?

Leila Then the input could be half written. Half a file name, half a command... I shouldn't run that.

Omid No. And one more: what if the model never stops asking?

Leila Then my loop runs forever and I pay for every turn. So I need a limit too.


The loop

Here is mini's agent loop, run_agent. It takes the task, the names of the tools this agent may use, and the system prompt:

MAX_TURNS = 40
 
def run_agent(task, tool_names, system, max_turns=MAX_TURNS):
    messages = [{"role": "user", "content": task}]
    tools = tool_schemas(tool_names)
    for turn in range(max_turns):
        response = client.messages.create(model=MODEL, max_tokens=16000, system=system,
                                          tools=tools, messages=messages)
        messages.append({"role": "assistant", "content": response.content})
        if response.stop_reason == "end_turn":
            return "".join(b.text for b in response.content if b.type == "text")
        if response.stop_reason != "tool_use":  # max_tokens, refusal: don't run a cut-off call
            return f"stopped: {response.stop_reason}"
        calls = [b for b in response.content if b.type == "tool_use"]
        messages.append({"role": "user", "content": [run_tool(b, tool_names) for b in calls]})
    return f"stopped: no answer after {max_turns} turns"
Python

Read it as the cycle you know from the agent loop:

  1. Start the conversation with the task.
  2. Each turn: send everything, and add the model's whole reply to the conversation.
  3. If the model has finished, return its words: the text blocks joined together, thinking left out.
  4. If it stopped for any reason other than wanting tools, stop too, and say why.
  5. Otherwise run every tool call, add one user message of results, and go round again.
  6. If the turns run out, say so.

The agent loop as a flow chart. Send the request, then add the reply, then a fork on stop_reason. end_turn leads to returning the answer. tool_use leads to running every tool call and adding the results, then back to the start, at most 40 times. Anything else, like max_tokens or refusal, leads to stopping and saying why.

One small change from the last module: run_tool now gets tool_names too, the tools this agent was given. A call for any other tool, even one in TOOLS, comes back as an error. It matters later, when one agent hands work to a helper that may only read.

Every stop reason

stop_reason is the reply telling you why the model stopped writing. The loop must handle each one:

  • "end_turn": the model has finished and its text is the answer. Return it.
  • "tool_use": it wrote one or more tool calls and is waiting. Run them all and continue.
  • "max_tokens": it hit the max_tokens limit you set for one reply. If it was in the middle of a tool call, that call's input may be cut short, so never run it. mini stops and reports it; a smarter harness could retry with a bigger limit.
  • "refusal": the model declined to go on, for safety reasons. Don't run any tool calls in that reply; stop and show it.

mini treats everything that isn't end_turn or tool_use the same way: stop, and say which reason it was. That rule is safe even for a stop reason added after you wrote the code.

A limit on turns

MAX_TURNS = 40 is a guard, not a goal. A normal task takes from 2 to 20 turns. But a model can get stuck: trying the same failing fix again and again, or reading the same files in a circle. Without a limit, a stuck loop runs until your money does. Forty turns is enough for real work, and small enough that a stuck run costs cents, not dollars. Bigger agents also limit time and money; the idea is the same.

The messages list over three turns. Turn one sends one message. Turn two sends three: the task, the model's tool call and its result. Turn three sends five. Each request carries everything before it.


Leila It's strange. This is the whole "agent" part? A for loop and two ifs?

Omid The loop is that small. What makes it work is that every turn brings back something real: a file's text, an error, a test result. Where do you think most of an agent's code goes, then?

Leila Into the tools. And into deciding what's allowed, what to keep in the messages, what goes in the system prompt...

Omid That's the rest of this course.


Seeing what it does

While you're building, it helps to see each call as it happens. One line inside the loop, just after calls is built, does it:

        for b in calls:
            print(f"  [{turn + 1}] {b.name} {json.dumps(b.input)[:80]}")
Python

It prints the turn number, the tool's name and the start of its input, so you can watch the loop think. (Add import json at the top if it isn't there.)

Try it

Put run_agent into your mini (the next lesson gives it real file tools; for now it has read_file), set your key and run it with a task that needs two looks, like "Which of notes.txt and todo.txt mentions Omid?". With the print line in, count the turns. With Haiku a run like this costs a fraction of a cent. Without a key, the exercise after this lesson gives you a fake model that replays a script, and you write the loop around it.

Using it well

  1. Branch on stop_reason, not on what the content looks like. It's the model's own statement of why it stopped, including the cases you'd miss by checking for tool calls.
  2. Never run a tool call from a reply that didn't end with tool_use. A cut-off or refused reply can hold a half-written call.
  3. Always set a turn limit. Pick it from the longest task you expect, and make hitting it visible, not silent.
  4. Print the calls while you build. Most loop bugs are obvious the moment you see the calls go by.

Words to know

  • run_agent - mini's loop: send, add the reply, run the tools, add the results, until the model finishes or a limit is reached
  • end_turn - the stop reason that means the model has finished and its text is the answer
  • max_tokens - the most tokens one reply may have; as a stop reason, it means the reply was cut off there
  • turn limit - the most times the loop may go round before it gives up

Big idea

An agent's loop is about twenty lines: keep sending the conversation, run whatever tools the model asks for, and stop when stop_reason says it has finished, or anything unexpected, or the turns run out. The loop is simple; the care is in reading the stop reasons right.

Test your understanding

Sign in to test your understanding of this lesson and track your progress.