# A small agent loop

Every coding agent is the same loop: send messages, run whatever tools the
model asks for, append the results, repeat until it stops asking. Here is that
loop with two real tools, in fifty lines. Nothing is elided.

```bash
pip install openai
export HYPHEN_API_KEY="sk-..."
python agent.py README.md
```

## agent.py

```python
"""Minimal file agent. Reads a file, summarises it, writes the summary out."""

import json
import os
import sys
from openai import OpenAI

client = OpenAI(
    base_url="https://api.hyphen-solution.com/v1",
    api_key=os.environ["HYPHEN_API_KEY"],
)


def read_file(path: str) -> str:
    with open(path, encoding="utf-8") as f:
        return f.read()[:200_000]


def write_file(path: str, content: str) -> str:
    with open(path, "w", encoding="utf-8") as f:
        f.write(content)
    return f"wrote {len(content)} chars to {path}"


IMPL = {"read_file": read_file, "write_file": write_file}

TOOLS = [
    {"type": "function", "function": {
        "name": "read_file",
        "description": "Read a UTF-8 text file from disk.",
        "parameters": {"type": "object",
                       "properties": {"path": {"type": "string"}},
                       "required": ["path"]}}},
    {"type": "function", "function": {
        "name": "write_file",
        "description": "Write UTF-8 text to a file on disk, overwriting it.",
        "parameters": {"type": "object",
                       "properties": {"path": {"type": "string"},
                                      "content": {"type": "string"}},
                       "required": ["path", "content"]}}},
]

target = sys.argv[1] if len(sys.argv) > 1 else "README.md"
messages = [
    {"role": "system", "content": "You are a file assistant. Use the tools. Stop when the job is done."},
    {"role": "user", "content": f"Read {target}, then write a five-bullet summary to SUMMARY.md."},
]

for step in range(10):
    resp = client.chat.completions.create(
        model="minimax-m3", messages=messages, tools=TOOLS, max_tokens=4000)
    msg = resp.choices[0].message
    calls = msg.tool_calls or []

    turn = {"role": "assistant", "content": msg.content or ""}
    if calls:
        turn["tool_calls"] = [
            {"id": c.id, "type": "function",
             "function": {"name": c.function.name, "arguments": c.function.arguments}}
            for c in calls
        ]
    messages.append(turn)

    if not calls:
        print(msg.content)
        break

    for c in calls:
        args = json.loads(c.function.arguments)
        print(f"[{step}] {c.function.name}({', '.join(args)})")
        try:
            result = str(IMPL[c.function.name](**args))
        except Exception as err:
            result = f"error: {err}"
        messages.append({"role": "tool", "tool_call_id": c.id, "content": result})
else:
    print("hit the step limit")
```

## What a run looks like

```
[0] read_file(path)
[1] write_file(path, content)
I read README.md and wrote a five-bullet summary to SUMMARY.md.
```

Then `SUMMARY.md` exists on disk with the bullets in it.

## The parts that matter

**The step limit is not optional.** `for step in range(10)` is what stops a
confused model from looping forever and eating your monthly budget. Python's
`for/else` runs the `else` branch only if the loop never broke, which is a
clean way to detect the runaway case.

**Tool errors go back to the model, not up the stack.** Catching the exception
and returning `f"error: {err}"` as the tool result lets the model recover. Pass
it a bad path and it will notice, apologise, and try the right one. Raise
instead and the whole run dies.

**The assistant turn is appended before the tool results.** Order matters. The
API needs the turn that requested the calls to sit immediately before the
messages that answer them.

**`max_tokens=4000`.** The M-series reason before emitting a tool call. Set
this low and you get an empty response with no call in it, which reads as the
agent silently doing nothing. See
[Choosing a model](/choosing-a-model#the-max_tokens-gotcha).

## Making it real

A few changes turn this from a demo into something you would actually run:

- **Sandbox the paths.** `read_file` and `write_file` will happily touch
  anything the process can reach. Resolve every path and reject anything
  outside a working directory.
- **Cap the context.** Every turn re-sends the entire message list, so a long
  run costs quadratically. Truncate old tool results once the transcript gets
  long. The `[:200_000]` slice on `read_file` is the crude version of this.
- **Stream the final answer.** Swap the last call for `stream=True` so the user
  sees output while it generates. See [Streaming chat](/recipes/streaming).
- **Handle the cap.** Wrap the `create` call in the retry-and-fall-back helper
  from [Handling the 429 cap](/recipes/handling-429).
- **Drop to `minimax-m2.5`** for cheap sub-tasks like classifying a file type.
  Keep `minimax-m3` for the planning turns.

## The Anthropic version

If your stack speaks Anthropic Messages, the loop is the same shape with
different block names. See
[Tool calling](/recipes/tool-calling#anthropic-surface-python) for the
`stop_reason == "tool_use"` form.

## Related

- [Tool calling](/recipes/tool-calling): the single round trip in detail.
- [Handling the 429 cap](/recipes/handling-429): keep long runs alive.
- [Connect your coding agent](/agents/claude-code): or just use an agent someone else wrote.
