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.

Level 360 minTrack 3Last verified 2026-10

By the end of this page you can

The loop, drawn properly

User task Modelreply or tool call Your coderun tool, validate Done? no: tool result appended to messages, loop again

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:

LayerWhat lives thereRule
Keep verbatimTask, user constraints, decisions made, final-artefact contentNever summarise away facts the model must not re-derive
CompactLarge tool results (logs, file dumps, search hits)Replace with an extract + a pointer to re-fetch if needed
DropSuperseded attempts, dead ends, verbose errors once resolvedKeep a one-line note ("tried X, failed because Y") so it isn't retried
Two failure modes Overflow: the loop silently truncates and the model forgets the task. Track token use and compact before the limit.
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

Stopping conditions and budgets

"Stop when done" is not enough. Enforce all of these:

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.

Try it (60 min)
  1. Point agent.py at a real model (the tool-call format from L2·04). Same task, same tools.
  2. Add token tracking: record usage per call, stop at 20k tokens.
  3. Add a compactor: replace tool results over 500 chars with a one-line summary after two steps.
  4. Break it on purpose: a tool that always throws, a model that loops the same call. Check the limits catch both.

Key takeaways