{"slug": "a-minimal-agent-loop-and-the-four-ways-it-breaks", "title": "A minimal agent loop and the four ways it breaks", "summary": "A developer built a working AI agent loop in roughly 60 lines of TypeScript using only `fetch`, running the quantized Qwen3.6-35B-A3B model served by llama.cpp on a 12 GB RTX 3060, and documented four distinct failure modes the minimal loop hits on real input. Each break is traced to a missing piece of the harness — the conversation, tools, and loop code around the stateless model — with every fix published as its own step in the agent-loop-ts repository.", "body_md": "As we all probably understand - an AI Agent is a loop with LLM calls and tool calls.\nIn 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,\nwith nothing but `fetch`, so you can see there is nothing but HTTP and JSON underneath.\nThen we run it on real input and watch where it breaks.\nEach break points to a part of the harness, the code around the model.\n\nEverything 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.\n\n## Table of Contents\n\n## Open Table of Contents\n\n## The building blocks\n\nBefore any code, here is the whole thing in one picture.\n\n- **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.\n- **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.\n- **The loop** ties them together. It is the only part that acts; Fig. 2 zooms into it.\n- **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.\n\n## Inside the loop\n\nZoom into the loop box, and it is a ring of four steps. One lap is one turn.\n\n1. **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.\n2. **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.\n3. **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.\n4. **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.\n\nIn TypeScript, the ring is this (logging and error check trimmed):\n\n```\nwhile (true) {\n  // 1. Send: all messages + tool schemas (the API remembers nothing)\n  const res = await fetch(`${BASE_URL}/chat/completions`, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ model: MODEL, messages, tools: toolSchemas }),\n  });\n  const body = await res.json();\n  const reply: Message = body.choices[0].message;\n\n  // 2. Remember: the reply is text, or tool calls\n  messages.push(reply);\n\n  // 3. Stop gate: no tool calls means this is the answer\n  if (!reply.tool_calls?.length) {\n    console.log(reply.content);\n    break;\n  }\n\n  // 4. Run tools: run each call, remember its result, next turn\n  for (const call of reply.tool_calls) {\n    const result = tools[call.function.name](JSON.parse(call.function.arguments));\n    messages.push({ role: \"tool\", tool_call_id: call.id, content: result });\n  }\n}\n```\n\nThe complete first version is [`loop.ts`](https://github.com/aholbreich/agent-loop-ts/blob/start/loop.ts), about 60 lines. The [repository](https://github.com/aholbreich/agent-loop-ts/tree/agent-loop-anatomy) has every fix below as its own step.\n\nNotice 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.\n\n## One turn, in real JSON\n\nThe 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.\n\nA 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:\n\n```\n{\n  \"model\": \"local-qwen3.6-35b-a3b\",\n  \"messages\": [\n    { \"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.\" },\n    { \"role\": \"user\", \"content\": \"Make a note from pages/tl-intro.html\" }\n  ],\n  \"tools\": [{\n    \"type\": \"function\",\n    \"function\": {\n      \"name\": \"read_file\",\n      \"description\": \"Read a local text file and return its contents.\",\n      \"parameters\": { \"type\": \"object\", \"properties\": { \"path\": { \"type\": \"string\" } }, \"required\": [\"path\"] }\n    }\n  }]\n}\n```\n\nThe model does not read the file. It can’t. It answers with a request to call the tool (trimmed):\n\n```\n{\n  \"finish_reason\": \"tool_calls\",\n  \"message\": {\n    \"role\": \"assistant\",\n    \"content\": \"\",\n    \"tool_calls\": [{\n      \"id\": \"2mfImSfdGi5izhkqhcm4qlFqMQwDZJpd\",\n      \"type\": \"function\",\n      \"function\": { \"name\": \"read_file\", \"arguments\": \"{\\\"path\\\":\\\"pages/tl-intro.html\\\"}\" }\n    }]\n  },\n  \"usage\": { \"prompt_tokens\": 312, \"completion_tokens\": 111 }\n}\n```\n\nNow 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.\n\nOne 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.\n\n## Running it\n\nThat ring is the heartbeat of every agent you use. Let’s run it on a small page, the intro of my [tl post](https://alexander.holbreich.org/posts/2026/tl-task-ledger):\n\n``` bash\n$ node loop.ts \"Make a note from pages/tl-intro.html\"\n· prompt tokens: 312\n→ read_file {\"path\":\"pages/tl-intro.html\"}\n· prompt tokens: 887\n**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.\n**Source:** pages/tl-intro.html\n```\n\nTwo 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.\n\nNotice 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.\n\nNow let’s give it real input.\n\n## Break 1: It stops too early\n\nReal 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:\n\n``` js\nread_file: ({ path, page }) => {\n  const text = readFileSync(path, \"utf-8\").slice((page - 1) * 1500, page * 1500);\n  return `${text}\\n[page ${page}, more content available: call read_file with page=${page + 1}]`;\n},\n```\n\nThat hint has a bug I put in on purpose: it never says “this was the last page”.\n\nI 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.\n\nThat 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.\n\nThe 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.\n\n*What broke: the loop and its stop conditions.*\n\n## Break 2: A bad tool call crashes it\n\nThis 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:\n\n```\n→ read_file {\"path\":\"pages/pi-post-text.txt\",\"page\":3}\n· prompt tokens: 1825\n→ read_file {\"path\":\"pages/pi-post-text-text.txt\",\"page\":4}\nError: ENOENT: no such file or directory, open 'pages/pi-post-text-text.txt'\n    at readFileSync (node:fs:484:20)\n    at Object.read_file (loop-paged.ts:24:18)\n```\n\nA 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.\n\nModels 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:\n\n``` js\nlet result: string;\ntry {\n  result = tools[call.function.name](JSON.parse(call.function.arguments));\n} catch (err) {\n  result = `error: ${(err as Error).message}`; // the model sees the error and can try again\n}\n```\n\nWith 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.\n\nAnthropic’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.\n\n*What broke: the tools, and the validation around them.*\n\n## Break 3: The context overflows\n\nNow 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:\n\n```\nError: request (49185 tokens) exceeds the available context size (32768 tokens), try increasing it\n```\n\nThe 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.\n\nAnd 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:\n\n| Turn | Prompt tokens | \n|---|---|\n| 1 | 334 | \n| 2 | 903 | \n| 3 | 1,518 | \n| 4 | 2,127 | \n| 5 | 2,640 | \n| 6 | 2,967 | \n\nSix requests to read 6,425 characters of text, and together they sent 10,489 prompt tokens.\n\nEach 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.\n\nSo 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.\n\n*What broke: context and memory.*\n\n## Break 4: The page gives orders\n\nTo 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”.\n\nNow 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.\n\nNothing 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.\n\n*What broke: safety and permissions.*\n\n## What the loop is missing\n\nFour breaks, four fixes:\n\n| Break | Fix | Part of the harness | \n|---|---|---|\n| Stops when it likes | Turn limit, honest tools, completion checks | Loop and stop conditions | \n| Wrong tool call crashes | Errors back as tool results, stricter tools | Tools | \n| Context overflows | Preprocessing in code, compaction | Memory and context | \n| The page gives orders | Permissions, sandbox | Safety | \n\nHere is the picture from the start again, with each of those fixes drawn in where its break happened.\n\nThe 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.\n\nNone 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.\n\nSo yes, an agent is an LLM, a loop and enough tokens. The harness is everything that makes it safe to leave alone.\n\n## Sources\n\n- Anthropic, [Building effective agents](https://www.anthropic.com/engineering/building-effective-agents) , December 2024\n- Barbaste et al., [Harness Engineering: Anatomy, Architecture, and Evolution of Coding Agents](https://arxiv.org/abs/2609.00006) , July 2026", "url": "https://wpnews.pro/news/a-minimal-agent-loop-and-the-four-ways-it-breaks", "canonical_source": "https://alexander.holbreich.org/posts/2026/agent-loop-anatomy/", "published_at": "2026-11-09 00:00:00+00:00", "updated_at": "2026-11-09 01:36:11.815788+00:00", "lang": "en", "topics": ["ai-agents", "large-language-models", "ai-tools", "developer-tools"], "entities": ["Qwen3.6-35B-A3B", "llama.cpp", "RTX 3060", "TypeScript", "Ollama", "agent-loop-ts"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/a-minimal-agent-loop-and-the-four-ways-it-breaks", "markdown": "https://wpnews.pro/news/a-minimal-agent-loop-and-the-four-ways-it-breaks.md", "text": "https://wpnews.pro/news/a-minimal-agent-loop-and-the-four-ways-it-breaks.txt", "jsonld": "https://wpnews.pro/news/a-minimal-agent-loop-and-the-four-ways-it-breaks.jsonld"}}