The Agent Loop

Write the loop that runs an agent: call the model, run the tool it asks for, feed back the result, and stop for the right reason. Real code you can run.

15 minLesson 3 of 6
On this page
  1. The idea: four moves, repeated
  2. One run, step by step
  3. The loop in code
  4. Good reasons to stop
  5. What each step costs
  6. Try it yourself
  7. Common mistakes
  8. Check yourself
  9. What to practise next

A warehouse manager asks an assistant which of two warehouses has more blue mugs. The assistant has one tool, stockLevel, and needs to call it twice, once per warehouse, then compare. On its first try it writes the warehouse name with a capital letter and gets an error back.

Whether this ends with the right answer, a crash, or a loop that never stops depends on about twenty lines of ordinary code around the model. That code is the agent loop, and this lesson writes it.

The idea: four moves, repeated

What Is an Agent showed the loop as pseudocode. Written properly, every pass does four things:

  1. Ask. Send the whole history to the model and get one reply.
  2. Check. If the reply is a final answer, stop and return it.
  3. Act. Otherwise the reply is a tool request. Run it. If it fails, turn the failure into an error message instead of crashing.
  4. Record. Add the reply and the result to the history, so the next call can see them.

Around those four moves sits one more rule: a hard limit on the number of passes. A model can keep asking for tools forever, so the code, not the model, must have the last word on when to stop.

One run, step by step

Here is the blue-mug question running through the loop. North has 140 mugs and south has 95. The model's replies are realistic but scripted, so you can follow exactly what the code does with each one.

The loop answers the blue-mug questionUse the arrows or ← → keys
history = [goal]for step in 1..maxSteps:  reply = model(history)  history += reply  if reply is an answer: return it  try: result = run the tool  except: result = the error message  history += resultreturn "stopped: step limit"
  1. history
    #entry
    1user: Which warehouse has more blue mugs?
    step
    0
    maxSteps
    10
    entries
    1

    1The history starts with just the goal. Nothing has been asked yet.

  2. history
    #entry
    1user: Which warehouse has more blue mugs?
    2model: stockLevel("North")
    step
    1
    maxSteps
    10
    entries
    2

    2Step 1. The model asks for stockLevel with "North", capital N. It is not an answer, so the loop goes on to run it.

  3. history
    #entry
    1user: Which warehouse has more blue mugs?
    2model: stockLevel("North")
    3error: Unknown warehouse "North". Use "north" or "south".
    step
    1
    maxSteps
    10
    entries
    3

    3The tool throws because it only knows "north". The loop catches it and records the error as a result. Without the try, the whole run would crash here.

  4. history
    #entry
    1user: Which warehouse has more blue mugs?
    2model: stockLevel("North")
    3error: Unknown warehouse "North". Use "north" or "south".
    4model: stockLevel("north")
    step
    2
    maxSteps
    10
    entries
    4

    4Step 2. The model reads the error in the history and fixes its own input.

  5. history
    #entry
    1user: Which warehouse has more blue mugs?
    2model: stockLevel("North")
    3error: Unknown warehouse "North". Use "north" or "south".
    4model: stockLevel("north")
    5result: north, 140
    step
    2
    maxSteps
    10
    entries
    5

    5This time the tool works. The result is recorded: 140 blue mugs in the north warehouse.

  6. history
    #entry
    1user: Which warehouse has more blue mugs?
    2model: stockLevel("North")
    3error: Unknown warehouse "North". Use "north" or "south".
    4model: stockLevel("north")
    5result: north, 140
    6model: stockLevel("south")
    7result: south, 95
    step
    3
    maxSteps
    10
    entries
    7

    6Step 3. Same pattern for the south warehouse: request, run, record. 95 mugs.

  7. history
    #entry
    1user: Which warehouse has more blue mugs?
    2model: stockLevel("North")
    3error: Unknown warehouse "North". Use "north" or "south".
    4model: stockLevel("north")
    5result: north, 140
    6model: stockLevel("south")
    7result: south, 95
    8model: North has more blue mugs: 140 against 95.
    step
    4
    maxSteps
    10
    entries
    8
    stopped
    answered

    7Step 4. With both numbers in the history, the model replies with an answer. The check on line 5 sees it and returns, with the reason it stopped.

  8. history
    #entry
    1user: Which warehouse has more blue mugs?
    2model: stockLevel("North")
    3error: Unknown warehouse "North". Use "north" or "south".
    4model: stockLevel("north")
    5result: north, 140
    6model: stockLevel("south")
    7result: south, 95
    8model: North has more blue mugs: 140 against 95.
    model calls
    4
    tool runs
    3
    errors
    1

    84 model calls, 3 tool runs, 1 error recovered. With maxSteps = 2 the same run would have ended after step 2 with no answer and stopped: step limit.

1 / 8

The loop in code

The model argument is any function that takes the history and returns either { type: "answer", text } or { type: "tool", tool, input }. In a real app it wraps a call to a model API and converts the reply into one of those two shapes. Here it is a stand-in, so the code runs without one.

function runAgent(model, tools, goal, maxSteps = 10) {
  const history = [{ role: "user", text: goal }];
  for (let step = 1; step <= maxSteps; step++) {
    const reply = model(history);
    history.push(reply);
    if (reply.type === "answer") {
      return { answer: reply.text, steps: step, stopped: "answered" };
    }
    let result;
    try {
      const tool = tools[reply.tool];
      if (!tool) throw new Error(`No tool named ${reply.tool}`);
      result = tool(reply.input);
    } catch (err) {
      result = { error: err.message }; // the model reads errors too
    }
    history.push({ role: "tool", result });
  }
  return { answer: null, steps: maxSteps, stopped: "step limit" };
}
def run_agent(model, tools, goal, max_steps=10):
    history = [{"role": "user", "text": goal}]
    for step in range(1, max_steps + 1):
        reply = model(history)
        history.append(reply)
        if reply["type"] == "answer":
            return {"answer": reply["text"], "steps": step, "stopped": "answered"}
        try:
            tool = tools.get(reply["tool"])
            if tool is None:
                raise ValueError(f"No tool named {reply['tool']}")
            result = tool(**reply["input"])
        except Exception as err:
            result = {"error": str(err)}  # the model reads errors too
        history.append({"role": "tool", "result": result})
    return {"answer": None, "steps": max_steps, "stopped": "step limit"}

And the run from the tracer, with a fake tool and a scripted model that returns one reply per pass:

Running it
const stock = { north: 140, south: 95 };
const tools = {
  stockLevel: ({ warehouse }) => {
    if (!(warehouse in stock)) {
      throw new Error(`Unknown warehouse "${warehouse}". Use "north" or "south".`);
    }
    return { warehouse, blueMugs: stock[warehouse] };
  },
};
const script = [
  { type: "tool", tool: "stockLevel", input: { warehouse: "North" } },
  { type: "tool", tool: "stockLevel", input: { warehouse: "north" } },
  { type: "tool", tool: "stockLevel", input: { warehouse: "south" } },
  { type: "answer", text: "North has more blue mugs: 140 against 95." },
];
const fakeModel = (history) => script[(history.length - 1) / 2];
 
console.log(runAgent(fakeModel, tools, "Which warehouse has more blue mugs?"));
// { answer: 'North has more blue mugs: 140 against 95.', steps: 4, stopped: 'answered' }
console.log(runAgent(fakeModel, tools, "Which warehouse has more blue mugs?", 2));
// { answer: null, steps: 2, stopped: 'step limit' }
stock = {"north": 140, "south": 95}
 
def stock_level(warehouse):
    if warehouse not in stock:
        raise ValueError(f'Unknown warehouse "{warehouse}". Use "north" or "south".')
    return {"warehouse": warehouse, "blue_mugs": stock[warehouse]}
 
tools = {"stock_level": stock_level}
script = [
    {"type": "tool", "tool": "stock_level", "input": {"warehouse": "North"}},
    {"type": "tool", "tool": "stock_level", "input": {"warehouse": "north"}},
    {"type": "tool", "tool": "stock_level", "input": {"warehouse": "south"}},
    {"type": "answer", "text": "North has more blue mugs: 140 against 95."},
]
fake_model = lambda history: script[(len(history) - 1) // 2]
 
print(run_agent(fake_model, tools, "Which warehouse has more blue mugs?"))
# {'answer': 'North has more blue mugs: 140 against 95.', 'steps': 4, 'stopped': 'answered'}
print(run_agent(fake_model, tools, "Which warehouse has more blue mugs?", max_steps=2))
# {'answer': None, 'steps': 2, 'stopped': 'step limit'}

Swap fakeModel for a real model call and tools for real ones, and the loop itself does not change. Real frameworks add streaming, logging and several tool calls per reply, but the core is this loop.

Good reasons to stop

An answer is the stop you want. The others are safety nets, and the loop should return which one fired, so the caller never mistakes a cut-off run for a finished one.

  • Step limit. The one shown above. Pick it from real runs: if a task normally takes 5 steps, a limit of 15 leaves room without allowing endless wandering.
  • Budget or time. Stop when total tokens or seconds pass a limit, for the same reason.
  • Repeats. If the model asks for the exact same tool with the exact same input it already tried, it is probably stuck. Keep a set of past requests, and on a repeat either stop or add a note to the history: "You already tried this. Try something different or answer with what you have."
  • A person says stop. Long runs should be cancellable.

What each step costs

The number of model calls grows in a straight line with the number of steps. The amount of text the model reads does not. Every call re-reads the whole history, and the history gets longer every step.

Say each step adds about 500 tokens to the history. Then call 1 reads about 500, call 2 about 1,000, and call 20 about 10,000. Over 20 calls that is 500 × (1 + 2 + … + 20) = 500 × 210 = 105,000 tokens read, for a history that ends only about 10,000 tokens long.

ApproachTimeSpaceWhy
Model callsO(n)One per step.
Tokens read in totalO(n²)O(n)Call i re-reads about i steps of history. The history itself only grows linearly.

That is why long runs get slow and expensive faster than you expect, and why short tool results matter so much. Many systems cut the cost by caching the unchanged start of the history, or by replacing old steps with a short summary. A summary can drop a detail the agent needs later, so it is a trade-off. Long histories also hurt the quality of answers: see Context Rot.

Try it yourself

Try it: Find the bugs in this loop

This loop works in the demo and fails in production. Find at least three problems and say how to fix each.

Show a sample answer
  1. No step limit. while True runs until the model chooses to answer, which may be never. Use a for loop up to max_steps and return a clear "stopped: step limit".
  2. Tool failures crash the run. An exception inside a tool, or a misspelled tool name (a KeyError on tools[...]), ends the whole program. Catch errors and put the message into the history so the model can correct itself.
  3. The model's own request is never recorded. Only the result goes into the history. On the next call the model sees an answer to a question it cannot see itself asking. Append the reply, then the result.
  4. The caller cannot tell how it ended. Returning only text hides whether the run answered or was cut off. Return the reason too.

Each of these is invisible on a happy demo run, which is why they reach production.

Common mistakes

Check yourself

Quick check

A tool throws an exception inside the loop. What should the loop do?

Each step adds about the same amount of text to the history. How does the total text the model reads grow with the number of steps n?

Why should the loop return the reason it stopped?

What to practise next

Copy the loop, write a scripted model that asks for the same tool twice with the same input, and add the repeat check. Then continue with Planning and Multi-Agent Systems, for tasks too big for one loop to keep in its head.