Base agent
4. Agent loop
View this stage's code on GitHub →A single round trip can't do "read a file and fix a bug in it" — that needs the model to see a tool's result and decide what to do next. The loop is what makes that possible: keep a running messages list, append the assistant's reply (including any tool calls) and one role: tool message per call executed, then call the API again with the growing conversation. It stops when the model answers without requesting a tool, or after MAX_TURNS as a guard against a runaway loop that never converges.
Your task
- Keep a
messageslist across turns instead of a single round trip. - Each turn: append the assistant's reply (its
tool_callsincluded) tomessages, then append onerole: toolmessage per call executed, tagged with itstool_call_id. - Repeat until the model replies with no tool calls; print that final answer. Add a
MAX_TURNSguard so a model that never stops calling tools can't loop forever.
The solution
Try the task above first. When you want to compare, this is exactly what changed since stage 3 (just diff 3 4 shows the same).
Show the solutionHide the solution +51 −25 lines
main.py+51 −25
⋯ """Stage 3: the Write toolStage 4: the agent loop Same pattern as Read: advertise the spec, dispatch by name when the modelcalls it. Still one round trip, still no loop — just a second tool.Up to now every run was one round trip: ask, maybe run one tool, exit.That breaks for multi-step tasks ("read a file and fix any bugs") becausethe model never gets to see a tool's result and react to it. The fix: keep `messages` around across turns, keep calling the API, andonly stop when the model answers with plain text (no more tool_calls).Each tool call's result is appended as its own `role: "tool"` message,tagged with that call's `tool_call_id` so the model knows which resultanswers which request.""" import argparse⋯ from openai import OpenAIAPI_KEY = os.getenv("OPENROUTER_API_KEY")BASE_URL = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")MODEL = os.getenv("MODEL", "anthropic/claude-haiku-4.5")MAX_TURNS = 20 TOOLS = [ {⋯ def Write(file_path, content): return f"Wrote to {file_path}" def execute_tool(name, arguments): if name == "Read": return Read(arguments["file_path"]) if name == "Write": return Write(arguments["file_path"], arguments["content"]) raise RuntimeError(f"unknown tool: {name}") def main(): parser = argparse.ArgumentParser(description="petite-harness: a tiny AI coding assistant") parser.add_argument("-p", "--prompt", required=True, help="the task to ask the model")⋯ def main(): client = OpenAI(api_key=API_KEY, base_url=BASE_URL) print(f"[main] sending prompt to {MODEL}", file=sys.stderr) messages = [{"role": "user", "content": args.prompt}] for turn in range(1, MAX_TURNS + 1): print(f"[agent] turn {turn}: calling model with {len(messages)} message(s)", file=sys.stderr) response = client.chat.completions.create( model=MODEL, messages=messages, tools=TOOLS, ) if not response.choices: raise RuntimeError("no choices in response") response = client.chat.completions.create( model=MODEL, messages=[{"role": "user", "content": args.prompt}], tools=TOOLS, ) message = response.choices[0].message messages.append(message.model_dump()) if not response.choices: raise RuntimeError("no choices in response") tool_calls = message.tool_calls message = response.choices[0].message if not tool_calls: print(message.content) return if message.tool_calls: call = message.tool_calls[0] print(f"[main] model requested tool call: {call.function.name}", file=sys.stderr) print(f"[agent] turn {turn}: {len(tool_calls)} tool call(s) requested", file=sys.stderr) arguments = json.loads(call.function.arguments) for call in tool_calls: arguments = json.loads(call.function.arguments) print(f"[agent] executing {call.function.name}({arguments})", file=sys.stderr) if call.function.name == "Read": result = Read(arguments["file_path"]) elif call.function.name == "Write": result = Write(arguments["file_path"], arguments["content"]) else: raise RuntimeError(f"unknown tool: {call.function.name}") result = execute_tool(call.function.name, arguments) print(result) return messages.append( { "role": "tool", "tool_call_id": call.id, "content": result, } ) print(message.content) raise RuntimeError(f"exceeded {MAX_TURNS} turns without a final answer") if __name__ == "__main__":
Try it
Run this stage's own code:
just stage 4
cd .stages/04
echo "carrots_per_day = 6 # the rabbit's diet" > rabbit.py
just run "How many carrots does the rabbit eat per day, according to rabbit.py? Respond with only a number."
Expect: 6 — this needs two turns (Read, then answer), proving the loop actually loops
.stages/04 is a git worktree: your checkout stays on main, so
just keeps working there. Compare with the previous stage with just diff 3 4,
or browse it on GitHub;
just clean-stages removes the worktrees.
Where this fits
The whole agent; this stage builds the highlighted part. Click any part to jump to its stage.