Building a Minimal Agent Harness in Python From Scratch
You can build agent harness Python code in about 40 lines. This copy-paste guide takes you from a working loop to a debuggable, timeout-safe harness.
import json, time
def run_harness(task, tools, model, max_steps=10, step_timeout=30):
messages = [{"role": "user", "content": task}]
for step in range(max_steps):
reply = model.complete(messages, tools=list(tools))
if reply.stop_reason == "end_turn":
return reply.text
for call in reply.tool_calls:
fn = tools.get(call.name)
if fn is None:
result = f"ERROR: no tool named '{call.name}'"
else:
start = time.time()
try:
result = str(fn(**call.args))[:6000]
except Exception as e:
result = f"ERROR: {type(e).__name__}: {e}"
if time.time() - start > step_timeout:
result += "\n[warning: tool exceeded soft timeout]"
messages.append({"role": "tool", "tool_call_id": call.id,
"content": result})
return "Stopped: reached max_steps"How much code does it actually take to build an agent harness in Python from scratch? You've probably asked yourself exactly that, right after staring at a 2,000-line framework and wondering if you really need all of it. The honest answer: a working loop is about 40 lines. A good one — with timeouts, output caps, and a debug mode — is maybe 80. This guide is the copy-paste path to both. We'll build agent harness Python code you fully own, then make it survivable.
Quick-Start (Copy This Right Now)
Here's the whole thing. Paste it, wire in your model client, and you have a running agent:
[object Object], json, time
,[object Object], ,[object Object],(,[object Object],):
messages = [{,[object Object],: ,[object Object],, ,[object Object],: task}]
,[object Object], step ,[object Object], ,[object Object],(max_steps):
reply = model.complete(messages, tools=,[object Object],(tools))
,[object Object], reply.stop_reason == ,[object Object],:
,[object Object], reply.text
,[object Object], call ,[object Object], reply.tool_calls:
fn = tools.get(call.name)
,[object Object], fn ,[object Object], ,[object Object],:
result = ,[object Object],
,[object Object],:
start = time.time()
,[object Object],:
result = ,[object Object],(fn(**call.args))[:,[object Object],]
,[object Object], Exception ,[object Object], e:
result = ,[object Object],
,[object Object], time.time() - start > step_timeout:
result += ,[object Object],
messages.append({,[object Object],: ,[object Object],, ,[object Object],: call.,[object Object],,
,[object Object],: result})
,[object Object], ,[object Object],What this does: runs the model, executes whichever tool it requests, caps the output at 6,000 characters, catches exceptions as readable messages, and stops after ten steps. That's a real agent. Everything below makes it better, not just bigger.
Understanding the Variables
Four knobs control almost all of your harness's behavior, and it pays to understand each before you tune it.
- — the hard ceiling on model↔tool round trips. Set it to your task's realistic worst case plus a small buffer. Ten is fine for focused tasks; a coding agent editing many files might need thirty.
max_steps - — how long a single tool may run before you flag it. This is your defense against a shell command that hangs on a prompt or a network call that never returns.
step_timeout - — a plain dict mapping names to functions. Keeping it a dict (not a class hierarchy) is deliberate: it's the simplest thing that works, and it makes routing a one-line lookup.
tools - The output cap () — the least glamorous knob and the most important. It's what stops one noisy tool from evicting your task from the context window.
[:6000]
⚡ Pro tip: Make the output cap a token budget, not a character count, once you're past the prototype. Characters are a rough proxy; tokens are what the model's context window actually spends. A quick
len(encoding.encode(text))Build an Agent Harness Python Loop, Step by Step
The quick-start works, but it's a black box while it runs. To build agent harness Python code you can actually debug, restructure the loop as a generator that yields each step. This one change is the highest-value idea in this guide:
[object Object], ,[object Object],(,[object Object],):
messages = [{,[object Object],: ,[object Object],, ,[object Object],: task}]
,[object Object], step ,[object Object], ,[object Object],(max_steps):
reply = model.complete(messages, tools=,[object Object],(tools))
,[object Object], {,[object Object],: step, ,[object Object],: ,[object Object],, ,[object Object],: reply}
,[object Object], reply.stop_reason == ,[object Object],:
,[object Object],
,[object Object], call ,[object Object], reply.tool_calls:
fn = tools.get(call.name)
result = (,[object Object], ,[object Object], fn ,[object Object], ,[object Object],
,[object Object], safe_call(fn, call.args))
,[object Object], {,[object Object],: step, ,[object Object],: ,[object Object],,
,[object Object],: call.name, ,[object Object],: call.args, ,[object Object],: result}
messages.append({,[object Object],: ,[object Object],, ,[object Object],: call.,[object Object],,
,[object Object],: result})What this does: instead of running silently to completion, it yields every model reply and every tool result as it happens. Now the same core loop can be driven three ways without a rewrite — printed to a terminal, streamed to a web UI, or asserted against in a test — because the caller decides what to do with each yielded step.
Here's the caller for a command-line run:
[object Object], event ,[object Object], harness_steps(task, tools, model):
,[object Object], event[,[object Object],] == ,[object Object],:
,[object Object],(,[object Object],)What this does: prints a live trace of every tool call and a preview of its result. When your agent does something weird, you see the exact step it went sideways instead of guessing from a final answer.
The same generator makes testing almost trivial, which is the part that pays off longest. Because each step is yielded as plain data, a test can drive the loop with a fake model that returns scripted tool calls, then assert on the exact sequence of tools the harness invoked:
[object Object], ,[object Object],():
fake = ScriptedModel([call(,[object Object],), call(,[object Object],), done()])
events = ,[object Object],(harness_steps(,[object Object],, tools, fake))
tool_order = [e[,[object Object],] ,[object Object], e ,[object Object], events ,[object Object], e[,[object Object],] == ,[object Object],]
,[object Object], tool_order == [,[object Object],, ,[object Object],]What this does: it feeds the harness a scripted model and checks that the agent read the file before writing it — a real behavioral guarantee, tested without a single live API call. Deterministic tests over agent behavior, not just model output, are what let you refactor the harness later without fear.
⚡ Pro tip: Add a
dry_run=True"[dry run: not executed]"Pro-Level Variations
Three upgrades turn the toy into something you'd put in front of users:
Structured tool schemas. Don't parse the model's text output with regex — that's the road to pain. Use your provider's native tool-calling API so arguments arrive as validated JSON. The parse step should be "read the structured field," never "guess from prose."
A stop tool. Give the model an explicit
finish(answer)stop_reasonPer-tool timeouts with real cancellation. The soft timeout above only warns. For a hanging tool, wrap execution in a thread with a real join deadline so a stuck call can't freeze the whole agent.
Three teams putting these to work:
- A data engineer at a logistics firm builds a harness with and
run_sql. The generator pattern lets them replay a failed nightly run event-by-event to find the malformed query.send_alert - A security analyst uses the flag to watch an incident-response agent's planned actions — isolate host, pull logs, revoke keys — before ever letting it execute against production.
dry_run - A product manager who codes on the side wires the same generator into a small Streamlit app, streaming each step to a browser, reusing the exact loop from their CLI prototype.
harness_steps
Adding Just Enough Persistence
The one thing a bare harness lacks that you'll miss first is memory across runs. You don't need a full checkpointing system for this — a few lines of JSON persistence carry you a surprisingly long way:
[object Object], json, os
,[object Object], ,[object Object],(,[object Object],):
,[object Object], ,[object Object],(,[object Object],, ,[object Object],) ,[object Object], f:
json.dump(messages, f)
,[object Object], ,[object Object],(,[object Object],):
path = ,[object Object],
,[object Object], json.load(,[object Object],(path)) ,[object Object], os.path.exists(path) ,[object Object], ,[object Object],What this does: it dumps the full message history to disk after each step and reloads it if a run with that ID already exists. If your process dies at step 8, you restart and resume from step 8 instead of paying for the first seven steps again. It's not the durable, transactional persistence a framework gives you — but for a script you run yourself, it's most of the value at a fraction of the cost.
The key is where you call
save_statemessages.append⚡ Pro tip: Key your run files by a hash of the task text, not a timestamp. Re-running the identical task then resumes the previous attempt instead of starting fresh — which is exactly what you want while debugging, and easy to disable in production by adding the timestamp back.
Troubleshooting Common Issues
⚠️ Common mistake: Parsing tool calls out of the model's free text with a regex. It works in the demo and breaks the moment the model phrases things differently, nests quotes, or emits valid-looking-but-wrong JSON. Always use native structured tool calling. If your provider doesn't offer it, prompt the model to return strict JSON and validate it against a schema — then feed a clear error back on failure rather than silently guessing.
Other issues you'll hit and their fixes:
- The agent loops on the same tool. Your output cap is probably hiding the result the model needs to move on. Raise the cap for that tool, or summarize instead of truncating.
- hit constantly. Either the ceiling is too low for the task, or the model lacks a tool it needs and is flailing. Check the trace before raising the cap.
max_steps - Arguments arrive as strings that should be ints. Validate and coerce in the executor, and return a specific error the model can correct, like .
ERROR: 'limit' must be an integer, got '10 rows' - The agent finishes without doing the work. It calls too early, reporting success on a task it never completed. Add a lightweight completion check — did the file actually change, did the row actually insert — and reject a premature finish with a note about what's still undone. Trusting the model's own "I'm done" without verifying it is the single most common way a harness ships confident half-finished work.
finish()
Your Turn
You now have three things: a 40-line quick-start, an 80-line survivable version, and a generator pattern that lets one loop power a CLI, a UI, and your test suite. Start with the quick-start against a real task today. Add the output cap the first time a tool floods your context. Switch to the generator the first time you can't figure out why the agent did something. Each upgrade earns its place through a specific pain — don't add them speculatively.
The pieces that vary most as you iterate aren't the loop — it's the system prompt and the tool descriptions you feed it. Every time you build agent harness Python code for a new task, you rewrite those, and losing the good versions to a closed editor tab hurts. Keeping them in a prompt library like PromptABCD, versioned and tagged by which harness they belong to, means your next build starts from a working baseline. The loop is reusable code; treat your prompts the same way.
Continue Reading
Save the prompts from this post
PromptABCD is a free prompt manager. Paste, organize, and reuse your best AI prompts — no more hunting through chat history.
