Cavin

Phase 1: The bare tool loop

AI Agents / 2026-09-18

Goal: the model calls a small set of tools, sees the results, and keeps going until it has an answer. This loop is the mechanism of an agent harness, and phases 2 through 5 all wrap around it.

Think of the loop as a conversation with a colleague who can't touch your computer. The model asks you to run a command, you run it and read back the output, and it asks for the next one. The model only produces text, so a tool call is a request written as JSON, and running it is your code's job.

Before step 1, open a terminal in the project folder and activate the virtual environment from phase 0, since every new terminal starts without it. The prompt shows (.venv) when it's on:

PowerShell
.venv\Scripts\activate

On macOS or Linux, use source .venv/bin/activate.

1. Extend backend.py to accept tools

Phase 0's complete function takes a string and returns a string. A tool loop needs to send a full message history plus the tool definitions, and it needs the whole reply back, since the reply may hold tool calls instead of text. Add a second function to backend.py and leave complete alone, so python backend.py still works. Put it above the if __name__ block:

Pythonbackend.py
def chat(messages: list, tools: list | None = None, backend: str = "groq"):
    if backend == "groq":
        client = OpenAI(
            api_key=os.environ["GROQ_API_KEY"],
            base_url="https://api.groq.com/openai/v1",
        )
        kwargs = {"model": "openai/gpt-oss-120b", "messages": messages}
        if tools:
            kwargs["tools"] = tools
        return client.chat.completions.create(**kwargs).choices[0].message

    raise ValueError(f"unknown backend: {backend}")

chat returns the whole message object instead of its text, and it leaves tools out of the request when there are none, so plain calls behave as they did in phase 0.

2. Define the tools

Create tools.py. Each tool is a plain Python function plus a JSON schema the model reads to learn what the tool does and what arguments it takes. The description text matters, because it's all the model knows about the tool.

Pythontools.py
import subprocess
from pathlib import Path

MAX_CHARS = 4000

def read_file(path: str) -> str:
    text = Path(path).read_text(encoding="utf-8", errors="replace")
    return text[:MAX_CHARS]

def list_dir(path: str = ".") -> str:
    entries = sorted(Path(path).iterdir())
    return "\n".join(p.name + ("/" if p.is_dir() else "") for p in entries)

def run_shell(command: str) -> str:
    result = subprocess.run(
        command, shell=True, capture_output=True, text=True, errors="replace"
    )
    return (result.stdout + result.stderr)[:MAX_CHARS]

REGISTRY = {
    "read_file": read_file,
    "list_dir": list_dir,
    "run_shell": run_shell,
}

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "Read a text file and return its contents.",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "File path, relative to the project folder."}
                },
                "required": ["path"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "list_dir",
            "description": "List the files and folders in a directory. Folders end with a slash.",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "Directory path. Defaults to the project folder."}
                },
                "required": [],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "run_shell",
            "description": "Run a shell command and return its output.",
            "parameters": {
                "type": "object",
                "properties": {
                    "command": {"type": "string", "description": "The command to run."}
                },
                "required": ["command"],
            },
        },
    },
]

Every result is cut to 4,000 characters so one large file can't flood the conversation. run_shell has no restrictions on purpose. Phase 2 adds approval, a timeout, and an allowlist. Until then, keep your prompts to reading and listing, and don't ask it to change or delete anything.

3. Look at a raw tool call

Before writing the loop, look at what the model sends back when it wants a tool. Create probe.py:

Pythonprobe.py
from backend import chat
from tools import TOOLS

message = chat(
    [{"role": "user", "content": "What files are in the current directory?"}],
    tools=TOOLS,
)

print("content:", message.content)
print("tool_calls:", message.tool_calls)

Run it:

Bash
python probe.py

You should see an empty or None content and one tool call, shaped like this:

Output
content: None
tool_calls: [... id='call_...', function=Function(arguments='{"path":"."}', name='list_dir') ...]

The model ran nothing. It returned a request with a tool name, an id, and arguments as a JSON string. Your code parses the string with json.loads, runs the function, and sends the result back tagged with the same id.

4. Write the loop

Create harness.py. Two rules trip up most first attempts. The assistant message that holds the tool calls goes into the history before the tool results, and each result carries the tool_call_id of the call it answers. The code below builds the assistant message by hand with only the fields the API needs. Copying the SDK's message object into the history can send extra fields such as reasoning, and Groq can reject those with a 400 error.

Pythonharness.py
import json
import sys

from backend import chat
from tools import REGISTRY, TOOLS

MAX_STEPS = 10

def run(task: str) -> str:
    messages = [{"role": "user", "content": task}]

    for step in range(MAX_STEPS):
        message = chat(messages, tools=TOOLS)

        if not message.tool_calls:
            return message.content

        messages.append(
            {
                "role": "assistant",
                "content": message.content,
                "tool_calls": [
                    {
                        "id": call.id,
                        "type": "function",
                        "function": {
                            "name": call.function.name,
                            "arguments": call.function.arguments,
                        },
                    }
                    for call in message.tool_calls
                ],
            }
        )

        for call in message.tool_calls:
            name = call.function.name
            print(f"[{step + 1}] {name} {call.function.arguments}")
            try:
                args = json.loads(call.function.arguments)
                result = REGISTRY[name](**args)
            except Exception as error:
                result = f"error: {error}"
            messages.append(
                {"role": "tool", "tool_call_id": call.id, "content": result}
            )

    return "stopped: hit the step limit"

if __name__ == "__main__":
    print(run(sys.argv[1]))

The loop ends when the model replies without a tool call, and that reply is the answer. MAX_STEPS stops a confused model from looping forever. A failed tool call goes back to the model as text instead of crashing the run, so the model can read the error and try something else.

5. Run it

Give it the task from the plan:

Bash
python harness.py "List the files in this directory and tell me what this project does."

You should see one line per tool call, then the answer. Your calls and wording will differ from run to run:

Output
[1] list_dir {"path":"."}
[2] read_file {"path":"backend.py"}
This project is a small Python harness that sends prompts to Groq...

On Windows, run_shell goes through cmd, so a command like ls fails. The model sees the error text and usually retries with dir, which shows why errors go back into the conversation as results.

Now run a task that forces a chain:

Bash
python harness.py "List the files here, then read backend.py and tell me which model it calls."

That prompt needs at least two calls in a row, one to list and one to read, with no input from you in between.

Done when: the model chains at least two tool calls in a row without you intervening.

6. Troubleshooting

If both runs worked, skip to step 7. Otherwise, match the symptom:

  • A ModuleNotFoundError for dotenv or openai. The virtual environment isn't active in this terminal, so Python is looking outside it. Activate it as described before step 1 and run the script again. If PowerShell blocks the activation script, run Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass first, or skip activation and call .venv\Scripts\python.exe probe.py directly.

  • The run ends after one tool call. Check that your loop returns only when message.tool_calls is empty, and that each tool result gets appended before the next request.

  • A 400 error that mentions tool_call_id or message order. The assistant message with the tool calls is missing from the history, or a result carries the wrong id. Print messages before the failing request and compare each tool_call_id against the assistant's call ids.

  • A 400 error that names an unsupported property such as reasoning. The assistant message has extra fields. Build it by hand as in step 4.

  • A 400 error with tool_use_failed. The model wrote a malformed tool call and Groq rejected it before your code saw it. Running the task again usually works, and phase 5 comes back to this. If it happens often, wrap the request in a retry:

    Pythonharness.py
    from openai import BadRequestError
    
    # in harness.py, inside run(), replace the single chat call:
    for attempt in range(3):
        try:
            message = chat(messages, tools=TOOLS)
            break
        except BadRequestError as error:
            print(f"retrying after 400: {error}")
    else:
        return "stopped: the model kept producing bad tool calls"
    
  • The run prints stopped: hit the step limit. Read the printed calls. The model is usually repeating one call because the result is an error it can't act on, or because a file came back cut off at 4,000 characters. Rewrite the tool description or the error text and try again.

Stuck?

Ask an agent something like: "my tool loop stops after one call instead of continuing, here's my loop code, what's wrong with the termination condition?" Paste the loop and the last few entries of messages.

7. Commit and push

Bash
git add .
git commit -m "phase 1: bare tool loop with read_file, list_dir, run_shell"
git tag v0.2-loop
git push origin main --tags

This tag follows phase 0's v0.1-hello. run_shell is still unrestricted at v0.2-loop, so the history shows the harness before and after phase 2 adds its guardrails.