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:
.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:
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.
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:
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:
python probe.py
You should see an empty or None content and one tool call, shaped like this:
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.
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:
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:
[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:
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
ModuleNotFoundErrorfordotenvoropenai. 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, runSet-ExecutionPolicy -Scope Process -ExecutionPolicy Bypassfirst, or skip activation and call.venv\Scripts\python.exe probe.pydirectly. -
The run ends after one tool call. Check that your loop returns only when
message.tool_callsis empty, and that each tool result gets appended before the next request. -
A 400 error that mentions
tool_call_idor message order. The assistant message with the tool calls is missing from the history, or a result carries the wrong id. Printmessagesbefore the failing request and compare eachtool_call_idagainst 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
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.