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

  1. Keep a messages list across turns instead of a single round trip.
  2. Each turn: append the assistant's reply (its tool_calls included) to messages, then append one role: tool message per call executed, tagged with its tool_call_id.
  3. Repeat until the model replies with no tool calls; print that final answer. Add a MAX_TURNS guard 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.

← 3. Write tool 5. Bash tool →