Lab 1 — Build a bounded toy agent loop
Objective
By the end of this lab you will have a runnable Python module, agent.py, that
is a complete agent loop with nothing hidden: two pure-Python tools (a safe
calculator and a small lookup table), a dispatcher that runs only registered
tools and refuses everything else, a deterministic policy that decides the next
action from memory, and an observe-decide-act loop bounded by both an iteration
cap and an explicit stop condition. It uses only the Python standard library, so
it makes no network calls, needs no API key, and costs nothing to run. Because
the policy is a fixed rule set rather than an LLM, every run is identical — which
is exactly what you want when you are learning to read a loop.
Skills exercised:
- Writing pure, bounded tools and dispatching to them safely
- Threading state through a scratchpad memory across loop iterations
- Enforcing bounded autonomy with an iteration cap and an explicit stop
- Reading an agent trace of decisions, actions, and observations
This agent has no model in it. In a real agent the "decide" step asks an LLM what to do next; here that step is a deterministic function, so the loop is reproducible and safe to run offline. Everything else — the tools, the dispatcher, the memory, the bounds — is exactly what a real agent uses.
Prerequisites and setup
Prerequisites: the three lessons in this course — What is an AI agent?, Tools and tool-calling, and Memory and planning. The lab assembles all three into one loop.
Setup: you need Python 3.10 or newer. There are no packages to install —
everything uses the standard library (ast and operator). Verify your version
and create a working folder.
- macOS / Linux
- Windows (PowerShell)
python3 --version
mkdir aa101-agent-lab && cd aa101-agent-lab
python --version
mkdir aa101-agent-lab; cd aa101-agent-lab
Expected output from the version command is a line like Python 3.11.6. If the
command is not found, install Python from python.org before continuing. In the
steps below, use python3 on macOS/Linux and python on Windows.
Every step adds to the one file agent.py in this folder. If you close your
terminal and come back, re-run cd aa101-agent-lab to return to it; your file
is still there, and you can continue from the step you were on.
Step 1 — Write two pure-Python tools
Goal: build the two actions the agent will be allowed to take — a safe calculator and a constant lookup — as ordinary, side-effect-free functions.
Create agent.py with this content. The calculator parses an arithmetic string
with the standard-library ast module and evaluates only a fixed set of
operators, so it can never run arbitrary code — a bounded tool by construction.
import ast
import operator
# ---- Step 1: two pure-Python tools -------------------------------------
_ALLOWED_BINOPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
}
def calculator(expression):
"""Evaluate an arithmetic expression over + - * / ** and numbers only."""
def _eval(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in _ALLOWED_BINOPS:
return _ALLOWED_BINOPS[type(node.op)](_eval(node.left), _eval(node.right))
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
return -_eval(node.operand)
raise ValueError(f"unsupported expression: {expression!r}")
return _eval(ast.parse(expression, mode="eval").body)
CONSTANTS = {"pi": 3.14159, "e": 2.71828, "gravity": 9.81, "days_per_year": 365}
def lookup(key):
"""Return a constant from the lookup table, or raise if it is unknown."""
if key not in CONSTANTS:
raise KeyError(f"unknown constant: {key!r}")
return CONSTANTS[key]
Checkpoint: exercise both tools directly.
python3 -c "import agent; print(agent.calculator('2 + 3 * 4')); print(agent.lookup('pi'))"
You should see the calculator respect operator precedence and the lookup return the constant:
14
3.14159
If you get 14 and 3.14159, both tools work. A ModuleNotFoundError means you
are not in the aa101-agent-lab folder where agent.py lives.
Step 2 — Register the tools and dispatch safely
Goal: put the tools behind a registry and a single dispatcher, so the agent can run only registered tools with well-formed arguments — bounded actions in code.
Add the tool registry and the act dispatcher to agent.py:
# ---- Step 2: tool registry + bounded action dispatcher -----------------
TOOLS = {
"calculator": {
"description": "Evaluate an arithmetic expression string.",
"arg_name": "expression",
"run": calculator,
},
"lookup": {
"description": "Fetch a named constant from the lookup table.",
"arg_name": "key",
"run": lookup,
},
}
def act(action):
"""Execute exactly one registered tool call and return an observation."""
name = action["tool"]
if name not in TOOLS:
return {"ok": False, "error": f"unknown tool: {name!r}"}
if not isinstance(action.get("arg"), (str, int, float)):
return {"ok": False, "error": "arg must be a string or number"}
try:
return {"ok": True, "result": TOOLS[name]["run"](action["arg"])}
except Exception as exc:
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
The dispatcher is the whole security boundary from Lesson 2 in miniature: an unregistered tool is refused, a malformed argument is refused, and a tool that raises is caught and reported instead of crashing the loop.
Checkpoint: dispatch one valid call and one call to a tool that does not exist.
python3 -c "import agent; print(agent.act({'tool':'lookup','arg':'gravity'})); print(agent.act({'tool':'weather','arg':'x'}))"
You should see the first succeed and the second be refused, not executed:
{'ok': True, 'result': 9.81}
{'ok': False, 'error': "unknown tool: 'weather'"}
If the unknown tool returns anything other than ok: False, re-check that act
tests name not in TOOLS before running anything.
Step 3 — Write the deterministic policy
Goal: write the "decide" step — the function that reads the goal and memory and chooses the next action. This stands in for what an LLM would do in a real agent.
Add the decide policy to agent.py. It decomposes "area of a circle" into two
steps: first look up pi, then compute the area, then stop.
# ---- Step 3: deterministic policy (observe -> decide) ------------------
def decide(task, memory):
"""Choose the next action from the goal and current memory.
Returns a call action or a stop action. A real agent would ask an LLM
here; this fixed rule set keeps the loop reproducible and offline.
"""
if "pi" not in memory:
return {"type": "call", "tool": "lookup", "arg": "pi"}
if "area" not in memory:
radius = task["radius"]
return {
"type": "call",
"tool": "calculator",
"arg": f"{memory['pi']} * {radius} * {radius}",
}
return {"type": "stop", "reason": "goal reached: area computed"}
The policy is a pure function of the observation: given empty memory it plans a
lookup; given memory that already holds pi it plans the area calculation; given
both it stops. Nothing is hidden — the returned action is the entire decision.
Checkpoint: call the policy with two different memory states.
python3 -c "import agent; print(agent.decide({'radius':5}, {})); print(agent.decide({'radius':5}, {'pi':3.14159}))"
You should see the plan change with memory — first a lookup, then a calculation:
{'type': 'call', 'tool': 'lookup', 'arg': 'pi'}
{'type': 'call', 'tool': 'calculator', 'arg': '3.14159 * 5 * 5'}
If both calls return the same action, check that decide reads memory rather
than ignoring it.
Step 4 — Close the loop with bounds
Goal: assemble the observe-decide-act loop, bounded by a hard iteration cap and ended by the policy's explicit stop, writing every observation into memory.
Add the iteration cap and the run_agent loop to agent.py:
# ---- Step 4: the agent loop (bounded, explicit stop) -------------------
MAX_ITERATIONS = 5
def run_agent(task, policy=decide):
"""Observe -> decide -> act -> observe, with a hard iteration cap."""
memory = {}
trace = []
for step in range(1, MAX_ITERATIONS + 1):
action = policy(task, memory)
if action["type"] == "stop":
trace.append({"step": step, "action": action})
return {"memory": memory, "trace": trace, "stopped": "goal"}
observation = act(action)
if observation["ok"] and action["tool"] == "lookup":
memory["pi"] = observation["result"]
elif observation["ok"] and action["tool"] == "calculator":
memory["area"] = observation["result"]
trace.append({"step": step, "action": action, "observation": observation})
return {"memory": memory, "trace": trace, "stopped": "max_iterations"}
Two bounds share the loop, as promised across the lessons. The policy's stop
action is the explicit stop condition — the normal exit when the goal is met. The
MAX_ITERATIONS cap is the safety net that ends the loop even if the stop never
fires, so a broken policy cannot spin forever.
Checkpoint: run the agent on the circle task.
python3 -c "import agent; r=agent.run_agent({'goal':'area_of_circle','radius':5}); print(r['stopped'], r['memory']['area'], len(r['trace']))"
You should see the loop reach the goal in three passes and compute the area:
goal 78.53975 3
The three trace entries are: look up pi, compute the area, then stop. If
stopped is max_iterations, the policy never returned a stop action — re-check
Step 3.
Validation
Verify the whole agent against the objective: a bounded loop that reaches its
goal, refuses unregistered tools, and terminates even when the stop condition
never fires. Add this block to the end of agent.py. It runs the normal task,
then runs a deliberately "stuck" policy that never stops, to prove the iteration
cap bounds the loop on its own.
if __name__ == "__main__":
result = run_agent({"goal": "area_of_circle", "radius": 5})
for entry in result["trace"]:
print(entry)
print("memory:", result["memory"], "stopped:", result["stopped"])
stuck = lambda task, memory: {"type": "call", "tool": "lookup", "arg": "pi"}
capped = run_agent({"goal": "loops_forever"}, policy=stuck)
checks = (
abs(result["memory"]["area"] - 78.53975) < 1e-9,
result["stopped"] == "goal",
len(result["trace"]) <= MAX_ITERATIONS,
act({"tool": "weather", "arg": "London"})["ok"] is False,
capped["stopped"] == "max_iterations",
len(capped["trace"]) == MAX_ITERATIONS,
)
print("checks:", checks)
if all(checks):
print("PASSED - toy agent loop complete")
else:
print("FAILED - re-check the functions above")
Run the module:
python3 agent.py
You should see the trace, then all six checks pass, then the success line:
{'step': 1, 'action': {'type': 'call', 'tool': 'lookup', 'arg': 'pi'}, 'observation': {'ok': True, 'result': 3.14159}}
{'step': 2, 'action': {'type': 'call', 'tool': 'calculator', 'arg': '3.14159 * 5 * 5'}, 'observation': {'ok': True, 'result': 78.53975}}
{'step': 3, 'action': {'type': 'stop', 'reason': 'goal reached: area computed'}}
memory: {'pi': 3.14159, 'area': 78.53975} stopped: goal
checks: (True, True, True, True, True, True)
PASSED - toy agent loop complete
Checkpoint: the command prints PASSED - toy agent loop complete. If it
prints FAILED, read the checks tuple: each False maps to one line in the
block above — the area value, the explicit stop, the cap, the unknown-tool
refusal, or the stuck-policy termination — so you know which part to revisit.
Teardown
Nothing is billable — this lab runs entirely on your machine and makes no
network calls. Keep the aa101-agent-lab folder if you want agent.py as a
reference for AA-201, where a framework will run this same loop for you;
otherwise delete it with rm -rf aa101-agent-lab (macOS/Linux) or
Remove-Item -Recurse aa101-agent-lab (Windows).
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
ModuleNotFoundError: No module named 'agent' | Running from the wrong folder | cd into aa101-agent-lab, where agent.py lives |
Step 1 calculator returns 20 for 2 + 3 * 4 | Operator precedence not preserved | Use the ast-based _eval exactly; do not split the string by hand |
ValueError: unsupported expression | Passed a non-arithmetic string to calculator | The calculator only accepts numbers and + - * / **, by design |
| Step 2 unknown tool runs instead of being refused | act runs before checking the registry | Test name not in TOOLS and return early before calling any tool |
Step 4 stopped is max_iterations on the circle task | Policy never returns a stop action | Confirm Step 3 returns {"type": "stop", ...} once area is in memory |
Validation prints FAILED | One check in the tuple is False | Match the False position to its line in the validation block and revisit |