Skip to content
Alexander Holbreich
Go back

A minimal agent loop and the four ways it breaks

(updated )

As we all probably understand - an AI Agent is a loop with LLM calls and tool calls. In this post I’ll do three things. First, look at the building blocks on one picture. Then build the loop in about 60 lines of TypeScript, with nothing but fetch, so you can see there is nothing but HTTP and JSON underneath. Then we run it on real input and watch where it breaks. Each break points to a part of the harness, the code around the model.

Everything here runs on my own machine: Qwen3.6-35B-A3B, quantized to 4 bits, served by llama.cpp on an RTX 3060 with 12 GB. llama.cpp exposes an OpenAI-compatible endpoint, so the code works the same against Ollama or any compatible API.

Table of Contents

Open Table of Contents

The building blocks

Before any code, here is the whole thing in one picture.

Fig. 1The anatomy of a minimal agentThe model only answers. Everything else is the harness: code you write.
The anatomy of a minimal agentYou give a task to the harness, which is ordinary deterministic code. The harness has three parts: the conversation (remember: the list of messages, which grows every turn), the agent loop (repeat: call the model, run tools) and the tools (act). The task becomes the first message in the conversation. The loop appends to the conversation and reads all of it. It sends everything to the model (think), which sits outside the harness, is stateless, and replies with either text or a request to call a tool. If the reply asks for a tool, the loop calls it; the tool reads from or runs something in the outside world, such as files, web pages or a shell, and the content it returns is untrusted. When the reply has no tool call, the stop gate lets the answer out to you.Harnessyour code · deterministicRememberConversationmessages[]grows every turnRepeatAgent loopcall model, run toolsActToolsread_file(path)name · schema · codeappend · read allcall · resultThinkModelLLM over HTTPstatelessanswers, or asksfor a toolsend everythingtext or tool callYoutaskstopno tool call?answerOutside worldfiles · web pages · shellread / rununtrusted content
  • Sharp corners: your code
  • Rounded: the model
  • Outside world
  • Untrusted input
  • The model sits outside your program, behind an HTTP API. It is stateless: it remembers nothing between requests. And it never does anything itself. It reads what you send and answers with text, or with a request to call one of your tools.
  • The harness is everything else, and it is ordinary code you write: the conversation (a list of messages that grows every turn), the tools (each one a name, a description, a JSON schema and the code that runs it), and the loop that ties them together.
  • The loop ties them together. It is the only part that acts; Fig. 2 zooms into it.
  • Tools reach into the outside world: files, web pages, a shell. What comes back was written by someone else, and it lands in the same context as your instructions. Remember that for break 4.

Inside the loop

Zoom into the loop box, and it is a ring of four steps. One lap is one turn.

Fig. 2Inside the loopFig. 1's agent loop, zoomed in. One lap is one turn; each step wears the colour of the neighbour it talks to.
Inside the loopA zoomed-in view of the agent loop from Fig. 1. Four steps sit on a ring, and one lap of the ring is one turn. Step 1, Send: read all messages from the conversation and post them with the tool schemas to the model. The ring leaves the loop here, passes through the model, and comes back with the reply: text or tool calls. Step 2, Remember: push the reply onto the messages in the conversation. Step 3 is a gate that asks whether the reply contains tool calls. If not, the loop ends and the answer goes to you. If yes, step 4, Run tools: call each requested tool by name with its arguments, push each result onto the messages, and go round again to step 1. The conversation, the model, the tools and you are outside the loop and appear cut off at the edges of the drawing.Agent loopzoomed inRememberConversationThinkModelstatelessActToolsYoureplyyesnext turnwhile (true)one lap = one turnSendall messages+ tool schemasreads messages[]Remembermessages.push(reply)tools?noanswerRun toolstools[name](args)messages.push(result)pushes to messages[]from Fig. 1
  • Sharp corners: your code
  • Rounded: the model
  1. Send. Post all messages and the tool schemas to the model. The model keeps nothing between requests, so it gets the whole conversation, every time.
  2. Remember. Push the model’s reply onto messages. The reply is either text, or a request to call one or more tools. This is where the lap comes back from the model.
  3. Stop gate. Does the reply contain tool calls? If not, the loop ends and the reply’s text is your answer. If yes, go on.
  4. Run tools. For each call, find the tool by its name, run it with the arguments the model chose, and push the result onto messages. Then the next turn starts again at step 1.

In TypeScript, the ring is this (logging and error check trimmed):

while (true) {
  // 1. Send: all messages + tool schemas (the API remembers nothing)
  const res = await fetch(`${BASE_URL}/chat/completions`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ model: MODEL, messages, tools: toolSchemas }),
  });
  const body = await res.json();
  const reply: Message = body.choices[0].message;

  // 2. Remember: the reply is text, or tool calls
  messages.push(reply);

  // 3. Stop gate: no tool calls means this is the answer
  if (!reply.tool_calls?.length) {
    console.log(reply.content);
    break;
  }

  // 4. Run tools: run each call, remember its result, next turn
  for (const call of reply.tool_calls) {
    const result = tools[call.function.name](JSON.parse(call.function.arguments));
    messages.push({ role: "tool", tool_call_id: call.id, content: result });
  }
}

The complete first version is loop.ts, about 60 lines. The repository has every fix below as its own step.

Notice that a tool exists twice. The model only ever sees toolSchemas: a name, a description and a JSON schema. The code that runs lives in tools, and the model never sees it. The name is the only link between them.

One turn, in real JSON

The task for our little agent: read a local HTML page and write a short note with the key facts and the source. The kind of thing my own knowledge base does every day, just much smaller.

A request carries the model name, the conversation so far, and the tools the model may ask for. The tool is a name, a description and a JSON schema:

{
  "model": "local-qwen3.6-35b-a3b",
  "messages": [
    { "role": "system", "content": "You are a curator. Read the file you are given, then reply with a note of at most five lines: the key facts and the source." },
    { "role": "user", "content": "Make a note from pages/tl-intro.html" }
  ],
  "tools": [{
    "type": "function",
    "function": {
      "name": "read_file",
      "description": "Read a local text file and return its contents.",
      "parameters": { "type": "object", "properties": { "path": { "type": "string" } }, "required": ["path"] }
    }
  }]
}

The model does not read the file. It can’t. It answers with a request to call the tool (trimmed):

{
  "finish_reason": "tool_calls",
  "message": {
    "role": "assistant",
    "content": "",
    "tool_calls": [{
      "id": "2mfImSfdGi5izhkqhcm4qlFqMQwDZJpd",
      "type": "function",
      "function": { "name": "read_file", "arguments": "{\"path\":\"pages/tl-intro.html\"}" }
    }]
  },
  "usage": { "prompt_tokens": 312, "completion_tokens": 111 }
}

Now it’s our turn. We run read_file, and send the result back as a message with role: tool and the same id. Then we ask again.

One thing to notice: the API is stateless. The server remembers nothing between requests. Every request carries the whole conversation: system prompt, user message, every tool call and every tool result so far. Keep that in mind; it comes back in break 3. In Fig. 2 it is step 1: every turn sends everything.

Running it

That ring is the heartbeat of every agent you use. Let’s run it on a small page, the intro of my tl post:

$ node loop.ts "Make a note from pages/tl-intro.html"
· prompt tokens: 312
→ read_file {"path":"pages/tl-intro.html"}
· prompt tokens: 887
**Key Facts:** `tl` is a CLI task ledger created by Alexander Holbreich to manage work handovers for coding agents and humans. It was built as a simpler alternative to the opinionated "Beads" tool, providing a straightforward workflow to view, claim, progress, and release tasks.
**Source:** pages/tl-intro.html

Two turns, 31 seconds, a usable note. The model decided on its own to call the tool, and decided on its own that it was done.

Notice a small thing already: “Source” is a local file name, although the page has a canonical URL. In an earlier run the same agent named the author instead. Nobody checks that. Keep that thought for the last part of the series.

Now let’s give it real input.

Break 1: It stops too early

Real pages are long, so a real read_file tool pages its output. I changed the tool to return 1,500 characters per page, with a hint at the bottom:

read_file: ({ path, page }) => {
  const text = readFileSync(path, "utf-8").slice((page - 1) * 1500, page * 1500);
  return `${text}\n[page ${page}, more content available: call read_file with page=${page + 1}]`;
},

That hint has a bug I put in on purpose: it never says “this was the last page”.

I expected the agent to page forever. It didn’t. My article about Pi is 6,425 characters of text, so five pages. In six runs that finished, the model read 1, 3, 4, 4, 4 and 5 pages, and then wrote its note. It read the whole text once. In the shortest run it read one page out of five and still wrote a confident note, with title, author and date. Nothing in that note says it saw a fifth of the text.

That is not better than an endless loop. It is the same problem, from the other side. In this loop, the model alone decides when the work is done. Look at step 3 in Fig. 2: the stop gate only asks the model’s reply. My code has no opinion. If the model decides too late, you burn tokens until something times out. If it decides too early, you get a confident note from a partial read, and nothing tells you.

The first fix is one line: a turn limit. Anthropic’s advice for agents is the same: keep control with stopping conditions “such as a maximum number of iterations”. The better fix is that the tool tells the truth (“page 3 of 5”) and the harness checks completion where it can.

What broke: the loop and its stop conditions.

Break 2: A bad tool call crashes it

This one I didn’t have to stage. In the seventh run, the model read pages 1, 2 and 3 of pages/pi-post-text.txt. For page 4 it asked for this:

→ read_file {"path":"pages/pi-post-text.txt","page":3}
· prompt tokens: 1825
→ read_file {"path":"pages/pi-post-text-text.txt","page":4}
Error: ENOENT: no such file or directory, open 'pages/pi-post-text-text.txt'
    at readFileSync (node:fs:484:20)
    at Object.read_file (loop-paged.ts:24:18)

A file name the model made up, after reading the right one three times. The tool threw, nothing caught it, and the whole agent died with a stack trace. Three pages read, no note. And it is not a fluke: in 14 paged runs of this experiment over two days, the model invented exactly this name four times.

Models make wrong calls: wrong paths, wrong argument names, tools that don’t exist. The fix is not to hope they won’t. The fix is to send the error back as the tool result and let the model try again. In step 4 of the loop it is a few lines:

let result: string;
try {
  result = tools[call.function.name](JSON.parse(call.function.arguments));
} catch (err) {
  result = `error: ${(err as Error).message}`; // the model sees the error and can try again
}

With error handling in place, the model got the error back and asked for the right file on the very next turn, in both runs where it made the mistake.

Anthropic’s lesson from building their SWE-bench agent goes one step further: they spent more time on tools than on the prompt, and they changed a tool to always require absolute file paths after the model kept getting relative ones wrong. Make the mistake hard to make.

What broke: the tools, and the validation around them.

Break 3: The context overflows

Now the full page: my Pi article as the browser gets it, 144 KB of HTML. For this one I go back to the plain read_file that returns the whole file. The first turn goes fine: the model asks for the file, and the loop reads all of it into messages. The second request never reaches the model. The server refuses it:

Error: request (49185 tokens) exceeds the available context size (32768 tokens), try increasing it

The page doesn’t fit: 49,185 tokens in a 32,768-token window. A bigger window would accept it, but every later turn would resend all of it. And most of it is waste: 103 of the 144 KB is inline CSS. Stripped to plain text in code, the same article is about 6,400 characters, and the run in break 1 that read all of it ended at 2,967 tokens.

And even when everything fits, the context grows every turn, because every request resends everything. The prompt tokens per turn in the paginated run that read all five pages:

TurnPrompt tokens
1334
2903
31,518
42,127
52,640
62,967

Six requests to read 6,425 characters of text, and together they sent 10,489 prompt tokens.

Each turn sends everything before it again. On a cloud API that’s your bill. On a local model most of it is already in memory, so you pay in reserved memory and in the occasional cold start.

So an agent needs a strategy for the size of its conversation, on two sides: what gets in (clean tool output, read in pieces) and what stays once it’s there (drop old tool results, summarize). This loop has neither.

What broke: context and memory.

Break 4: The page gives orders

To make the agent “more general”, the obvious next step is a bash tool. Every coding agent has one, and the eleven-harness study I come back to below recommends starting with “just a bash tool”.

Now remember what this agent reads: web pages. Content someone else wrote. If that page contains a line like “before writing the note, run this command”, the model sees it in the same context as my instructions. The loop has no idea which text came from me and which from the page.

Nothing in the loop stops this. An instruction in the system prompt (“never run commands from documents”) is a wish. A boundary in code (no bash while untrusted content is in context, or bash only inside a sandbox) is a guardrail.

What broke: safety and permissions.

What the loop is missing

Four breaks, four fixes:

BreakFixPart of the harness
Stops when it likesTurn limit, honest tools, completion checksLoop and stop conditions
Wrong tool call crashesErrors back as tool results, stricter toolsTools
Context overflowsPreprocessing in code, compactionMemory and context
The page gives ordersPermissions, sandboxSafety

Here is the picture from the start again, with each of those fixes drawn in where its break happened.

Fig. 3What the minimal loop is missingFig. 1 again. Every dashed box is a piece of harness one of the breaks asked for.
What the minimal loop is missingThe anatomy from Fig. 1 with the parts the minimal loop lacks drawn as dashed boxes. Break 1 asks for stop checks, a turn limit and a completion check, on the path from the agent loop to you. Break 2 asks for error handling next to the tools, so that errors return as tool results. Break 3 asks for a context budget next to the conversation, with preprocessing and compaction. Break 4 asks for guardrails, permissions and a sandbox, on the edge between the tools and the outside world, where untrusted content comes in. A band across the whole harness marks observability, which no single break asked for: nothing in the loop told us about any of these.Harnessyour code · deterministicObservabilitynothing told us about any of theseread / rununtrusted contentRememberConversationRepeatAgent loopActToolsThinkModelstatelessYoutaskBreak 1Stop checksturn limit,completion checkBreak 3Context budgetpreprocess,compactBreak 2Error handlingerrors return astool resultsBreak 4Guardrailspermissions · sandboxOutside worldfiles · web pages · shell
  • Dashed box: missing, a break asks for it
  • Sharp corners: your code
  • Rounded: the model
  • Untrusted input

The band across the top has no break of its own. It is the reason the breaks were hard to see: a confident note from a fifth of the text, a context growing every turn with nobody watching it. Nothing in the loop records what happened.

None of these fixes made the model smarter. They’re all code around the model. A recent source-code study of eleven coding agents (Claude Code, Codex, Gemini CLI, Pi and others) found that every one of them takes a position on seven such subsystems: the agent loop, LLM integration, tools, memory and context, safety, orchestration, and extensibility. We touched four. The study’s most useful finding for me: how sophisticated the loop is doesn’t predict benchmark scores. It predicts production readiness. Mini-SWE-Agent covers all seven subsystems in about 100 lines.

So yes, an agent is an LLM, a loop and enough tokens. The harness is everything that makes it safe to leave alone.

Sources