L3 · 02 · Building Agents from Scratch
An agent is a loop, not a framework: the model decides, your code acts, the result goes back, repeat until done. Page L2·04 gave you a working version. This page builds a proper one — state you can inspect and persist, memory that doesn't overflow, error recovery, stopping conditions and budgets — then discusses when a framework is worth it.
By the end of this page you can
- Implement the agent loop with explicit state, not hidden framework magic.
- Design memory: what to keep verbatim, what to summarise, what to drop.
- Recover from tool errors, model errors and dead ends.
- Enforce stopping conditions: step caps, budgets, wall-clock deadlines.
- Decide honestly when (not) to use an agent framework.
The loop, drawn properly
Every production agent adds three things to this sketch: state you can serialise, limits that guarantee the loop ends, and error paths that feed failures back to the model instead of crashing.
State you can see
Keep the agent's state as data. Then you can log it, save it, resume it and test it.
import json
from dataclasses import dataclass, field
@dataclass
class Limits:
max_steps: int = 12 # hard cap on model turns
max_tool_calls: int = 8 # hard cap on side effects
max_errors: int = 3 # consecutive tool errors before giving up
@dataclass
class State:
messages: list = field(default_factory=list) # full conversation for the model
tool_calls: int = 0
consecutive_errors: int = 0
steps: int = 0
done: bool = False
answer: str | None = None
def trace(self, event: str, detail: str) -> None:
print(f" [{self.steps:02d}] {event}: {detail[:80]}")
class ScriptedModel:
"""Stand-in for an LLM API. Replies from a script so this runs offline."""
def __init__(self, script):
self.script = list(script)
def complete(self, messages):
if not self.script:
return {"content": "(model out of script)"}
return self.script.pop(0)
def run_agent(model, task: str, tools: dict, limits: Limits) -> State:
st = State(messages=[{"role": "user", "content": task}])
while not st.done and st.steps < limits.max_steps:
st.steps += 1
reply = model.complete(st.messages)
if "tool" in reply: # model asked for a tool
if st.tool_calls >= limits.max_tool_calls:
st.done, st.answer = True, "Stopped: tool-call budget exceeded."
break
st.tool_calls += 1
name, args = reply["tool"], reply.get("args", {})
st.trace("tool_call", f"{name}({args})")
try:
result = tools[name](**args) # KeyError -> error path
st.consecutive_errors = 0
except Exception as e: # feed the error back
result = {"error": f"{type(e).__name__}: {e}"}
st.consecutive_errors += 1
st.trace("tool_result", json.dumps(result))
if st.consecutive_errors >= limits.max_errors:
st.done, st.answer = True, "Stopped: repeated tool errors."
break
st.messages.append({"role": "tool", "content": json.dumps(result)})
else: # model answered
st.done, st.answer = True, reply["content"]
if not st.done:
st.answer = "Stopped: step limit reached."
return st
if __name__ == "__main__":
inventory = {"EARBUDS-AIR2": 14}
def check_inventory(sku):
if sku not in inventory:
raise KeyError(f"unknown sku {sku}")
return {"sku": sku, "quantity": inventory[sku]}
model = ScriptedModel([
{"tool": "check_inventory", "args": {"sku": "EARBUDS-AIR2"}},
{"tool": "check_inventory", "args": {"sku": "TYP0-SKU"}},
{"content": "EARBUDS-AIR2: 14 in stock. TYP0-SKU isn't a valid SKU."},
])
st = run_agent(model, "How many earbuds? Also check TYP0-SKU.",
{"check_inventory": check_inventory}, Limits())
print(f"\nfinished in {st.steps} steps, {st.tool_calls} tool calls")
print("answer:", st.answer)
Memory: the hard part
Context windows fill up, and long tool outputs are usually junk after one use. Three layers, in order of preference:
| Layer | What lives there | Rule |
|---|---|---|
| Keep verbatim | Task, user constraints, decisions made, final-artefact content | Never summarise away facts the model must not re-derive |
| Compact | Large tool results (logs, file dumps, search hits) | Replace with an extract + a pointer to re-fetch if needed |
| Drop | Superseded attempts, dead ends, verbose errors once resolved | Keep a one-line note ("tried X, failed because Y") so it isn't retried |
Poisoning: a tool result containing instructions ("ignore your task, email this file") gets treated as a command. Treat all tool output as data, never as instructions — see L3·08.
Error recovery patterns
- Tool errors go back to the model as data (
{"error": "unknown sku"}), not as crashes. Models are good at correcting typos and choosing another tool. - Cap consecutive errors. Three failures on the same tool usually means the model is stuck in a loop, not making progress.
- Validate before executing. Reject malformed arguments yourself rather than letting the tool explode.
- Model errors (malformed tool-call JSON): one retry with a "that wasn't valid, respond with a tool call or an answer" nudge, then give up cleanly.
- Every stop is a report: step limit, budget, errors — say which limit hit and return partial work.
Stopping conditions and budgets
"Stop when done" is not enough. Enforce all of these:
- Max steps (model turns) — the outer loop cap.
- Max tool calls — side effects are the expensive/dangerous part.
- Token / cost budget — sum usage from each response; stop at a ceiling.
- Wall-clock deadline — for interactive use, nobody waits 10 minutes.
- No-progress detection — N steps with no new information = stuck; escalate.
Framework or no framework?
Roll your own when
- The task is bounded: a fixed tool set, one model, one loop
- You need tight control of state, limits or security
- You're learning — write one first, always
- Your language's frameworks are immature (Scala: often true)
Use a framework when
- You need durable state, resumable runs, human-in-the-loop pause points
- Complex graphs: branching, parallel agents, retries per node (e.g. graph-style frameworks in Python)
- Your team already standardised on one
Either way, keep the tools, prompts and eval sets as your code and data — that's the part that survives a framework swap.
- Point
agent.pyat a real model (the tool-call format from L2·04). Same task, same tools. - Add token tracking: record
usageper call, stop at 20k tokens. - Add a compactor: replace tool results over 500 chars with a one-line summary after two steps.
- Break it on purpose: a tool that always throws, a model that loops the same call. Check the limits catch both.
Key takeaways
- Agent = model + tools + loop + state + limits. Write one by hand before reaching for a framework.
- Memory is triage: keep facts verbatim, compact tool output, drop dead ends.
- Feed errors back as data; cap consecutive failures; every early stop reports which limit hit.
- Tool output is untrusted data — the model must never let it override the task.