Build Your Own Agent Loop — The Minimal Shape of Tool Calling
At the center of every AI agent is a single while loop. We build it from scratch without a framework — the shape of JSON function calls, what ReAct actually left behind, and the stopping conditions where nearly every incident originates.
Key points
- The core of an agent is a while loop: ask the model, run the tool, append the result to the history.
- On every turn the model reads the whole conversation from a cold start and emits one next move; only the messages array has continuity.
- A tool's description and its return text are API documentation written for the model, not for a human.
- There are four stopping conditions: natural finish, step limit, token budget, and no-progress detection.
- Every turn re-sends the entire history, so cost grows with the square of the steps; going from 10 steps to 20 costs roughly four times as much.
ReAct: Synergizing Reasoning and Acting in Language Models
Primary source — what this article is built on
undefined2022-10-06→undefined2026-08-273y 11mo later
ReAct: Synergizing Reasoning and Acting in Language ModelsShunyu Yao, Jeffrey Zhao, Dian Yu et al. · 2022-10-06 · v3arXiv:2210.03629Paper page·PDFundefined
While large language models (LLMs) have demonstrated impressive capabilities across tasks in language understanding and interactive decision making, their abilities for reasoning (e.g. chain-of-thought prompting) and acting (e.g. action plan generation) have primarily been studied as separate topics. In this paper, we explore the use of LLMs to generate both reasoning traces and task-specific actions in an interleaved manner, allowing for greater synergy between the two: reasoning traces help the model induce, track, and update action plans as well as handle exceptions, while actions allow it to interface with external sources, such as knowledge bases or environments, to gather additional information. We apply our approach, named ReAct, to a diverse set of language and decision making tasks and demonstrate its effectiveness over state-of-the-art baselines, as well as improved human interpretability and trustworthiness over methods without reasoning or acting components. Concretely, on question answering (HotpotQA) and fact verification (Fever), ReAct overcomes issues of hallucination and error propagation prevalent in chain-of-thought reasoning by interacting with a simple Wikipedia API, and generates human-like task-solving trajectories that are more interpretable than baselines without reasoning traces. On two interactive decision making benchmarks (ALFWorld and WebShop), ReAct outperforms imitation and reinforcement learning methods by an absolute success rate of 34% and 10% respectively, while being prompted with only one or two in-context examples. Project site with code: https://react-lm.github.io
The assistant on the phone
Picture asking a very knowledgeable assistant for help — over the phone. They have no hands and no eyes in your room; all they get is your voice. So they say "could you read out what's in that folder?", you read it out, and based on what they hear they tell you what to do next. A few rounds of that, and the job is done.
That is what we currently call an AI agent. A language model is a function from strings to strings. It cannot open a file or send an email. Something outside the model always does the actual work. The core value an agent framework provides is a dozen lines that automate this back-and-forth.
This article builds that loop without a framework. The goal isn't working code you can copy — it's being able to say which line, if you delete it, breaks what.
It's a while loop
Here's the conclusion up front. This is the whole thing:
messages = [{"role": "user", "content": task}]
while True:
reply = model(messages, tools=TOOLS) # 1. let it think
messages.append(assistant_turn(reply)) # 2. append its turn
calls = tool_calls_in(reply)
if not calls: # 3. no tool = done
return text_of(reply)
results = [run(c) for c in calls] # 4. we do the work
messages.append(user_turn(results)) # 5. append the results
The word "agent" deserves to lose some of its magic right here. The model is not persistently pursuing anything. On every turn it reads the whole conversation from a cold start, emits exactly one next move, and stops. The thing with continuity is the messages array; nothing carries over on the model's side.
That asymmetry decides most of the design questions that follow. Because the history is the only memory, anything you didn't write into it doesn't exist for the model, every extra step inflates the input, and a failure will repeat unless you write down why it failed.
There are only four parts: the tool list, the model call, the dispatcher (the table mapping tool names to real functions), and the stopping conditions.
How to describe a tool
Handing a model a tool is, in practice, writing a function signature in JSON.
{
"name": "read_file",
"description": "Read one text file in the repository. Paths are relative to the repo root. Not for binary files. If you only need to check existence, use list_dir instead.",
"input_schema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "e.g. src/main.py" }
},
"required": ["path"]
}
}
Nearly everyone makes the same mistake once: writing description for a human. The reader here is the model, not a colleague. A clever summary matters far less than "when not to use this", a concrete example of the argument format, and what comes back on failure. It is not unusual for one added sentence to change behavior outright.
Whether the returned JSON is syntactically intact is largely solvable with constrained decoding — zeroing out tokens the grammar forbids before they're sampled. But valid syntax and valid arguments are different things: a nonexistent path arrives as a perfectly well-typed string all the time. That boundary is covered in Structured Output and Constrained Decoding.
Temperature — the parameter governing sampling spread — is usually set lower for an agent loop than for prose. A wobble in the spelling of a tool name means calling a function that doesn't exist, and if the model decides differently every time from the same state, debugging stops working.
That does not make temperature 0 automatically safe. Identical history yields identical output, so once the loop enters a failing state it cannot climb out on its own. Keep it low (roughly 0 to 0.3) and handle stuck states with stopping conditions instead — that's the division of labor in practice.
Making it run
Now the pseudocode in the shape of a real API. Below is Claude's Messages API; other vendors' function calling differs in naming, not in structure.
import json
TOOLS = [READ_FILE_SCHEMA, LIST_DIR_SCHEMA] # the JSON above
IMPL = {"read_file": read_file, "list_dir": list_dir} # name -> real function
def run_tool(name, args):
try:
return {"ok": True, "out": IMPL[name](**args)}
except KeyError:
return {"ok": False, "err": f"unknown tool: {name}. available: {list(IMPL)}"}
except Exception as e:
return {"ok": False, "err": f"{type(e).__name__}: {e}"}
def agent(task, max_steps=12):
messages = [{"role": "user", "content": task}]
for step in range(max_steps):
reply = client.messages.create(
model=MODEL, tools=TOOLS, messages=messages,
max_tokens=1024, temperature=0,
)
messages.append({"role": "assistant", "content": reply.content})
calls = [b for b in reply.content if b.type == "tool_use"]
if not calls: # natural finish
return "".join(b.text for b in reply.content if b.type == "text")
results = []
for c in calls: # return every call
r = run_tool(c.name, c.input)
results.append({
"type": "tool_result",
"tool_use_id": c.id, # the pairing id
"content": json.dumps(r, ensure_ascii=False)[:4000],
"is_error": not r["ok"],
})
messages.append({"role": "user", "content": results})
return "stopped: step limit reached"
Under forty lines, and it genuinely handles work at the level of "read this file and summarize it". Three details break it if you drop them.
Don't break the id pairing. A model can call several tools in one turn. If the preceding assistant turn holds three tool_use blocks, the next user turn needs three matching tool_result blocks; miss one and the request fails validation.
Always truncate the return value. That's the [:4000] above. If list_dir returns a hundred thousand lines, all of it rides in the next turn's input — and every turn after that. Letting results run free inside the loop is the single most common cause of cost incidents.
Don't swallow exceptions — let the model read them. is_error exists not to prevent failure but to turn failure into material for the next move.
What ReAct left behind
The ancestor of this loop is ReAct (Yao et al., 2022). Function-calling APIs didn't exist yet, so you taught the format Thought: / Action: / Observation: in the prompt and parsed the model's output with regexes. APIs absorbed that grubby part; what survived is one idea — make it state a reason before it acts.
Not reasoning alone, not acting alone, but alternating: that was ReAct's claim. Even with structured tool use, adding "before calling a tool, write one sentence on why you're choosing it" to the system prompt measurably reduces irrelevant tool picks. A walk through the paper itself is in LLM Agents from Scratch.
Four stopping conditions, not one
Almost every agent incident traces back to not stopping. One condition isn't enough.
- Natural finish — it returned text without calling a tool. The happy path
- Step limit —
max_steps. Write it first; writing it later is too late - Token budget — cut off when cumulative input plus output crosses a threshold
- No-progress detection — stop when the same
(name, args)repeats, or after k consecutive errors
You need the fourth. Re-reading the same directory forever is a genuinely common behavior, and a step limit alone means you pay all the way to the limit before it halts. Remembering hashes of the last few calls — about ten lines — prevents it.
And what a step count hides is that cost is not proportional to steps. Every turn re-sends the entire history, so total tokens sent grows with the square of the step count.
In words: let be the tokens sent on the first turn and the average tokens each round-trip adds to the history; then , the total billed input tokens over N steps, grows as N squared. Going from 10 steps to 20 costs roughly four times as much, not twice. Prompt caching makes re-sending the unchanging prefix cheaper — it does not remove the squared term.
For long loops you'll need history compression — folding old tool_result blocks into summaries, or keeping only the last N. At that point it stops being a question about the loop and becomes a question about memory design.
Return failures to the model
An agent's apparent intelligence comes less from the model than from how tool return values are designed.
- Bad:
Error - Good:
path 'src/mian.py' not found. did you mean: src/main.py
With the second, the model fixes the typo next turn and recovers on its own. With the first it either repeats the same call or gives up and asserts, falsely, that the file doesn't exist. Return, in one line, whatever lets it choose the next move — that's the bar when writing a tool. Dumping a raw stack trace is counterproductive: long, and the only useful information sits in the last line.
What this looks like in practice
The people who write this loop by hand are application engineers bolting AI onto internal tools, SREs automating first-response triage, and data folks automating analysis. The highest-leverage habit is to write these forty lines once before adopting a framework. After that you know which layer to look at when something jams.
The parameters you'll touch exist in every implementation under different names: max_steps / max_iterations / recursion_limit (the same thing), temperature, tool_choice (auto, forced, or disabled — useful for forcing a specific tool on the first move only), whether parallel calls are allowed, and the timeout and truncation length for tool execution.
The traps that turn into incidents:
- Running overnight with no ceiling. Drop
max_stepsduring development and the bill spikes. Write the step limit and token budget before the feature - Dropped
tool_resultblocks. You forget one of several parallel calls and the next request errors. You will hit this the day you enable parallel tool use - Guarding permissions with the prompt. The model cannot judge whether it is allowed to call something. Irreversible actions — delete, send, charge — need human approval outside the loop. Prohibitions in a system prompt are not a security control. The trust-boundary view is in MCP and Tool Protocols
- Too many tools. Selection accuracy drops as options grow. Five tools with subcommands beat twenty flat ones
- Logging only successes. How many steps it took, which tool failed how often, what fraction of runs got cut off — without those three you cannot improve anything later
In reviews and interviews the questions are usually: how does this agent stop, what is the worst-case cost per task, and what does a failed tool call return to the model? The line between a working demo and an operable system is whether those have immediate answers.
Takeaways
- The core is a while loop: ask the model, run the tool, append the result. Frameworks are the equipment around it
- The model has no memory; only the
messagesarray carries continuity. What you didn't write doesn't exist - A tool's
descriptionand its return text are API documentation written for the model - Four stopping conditions: natural finish, step limit, token budget, no progress. Cost grows with the square of the steps
Next: how to fold that history down so the loop can run for a long time.
Comments
Sign in to comment