Skip to main content

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
info

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.

python3 --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.

note

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

SymptomLikely causeFix
ModuleNotFoundError: No module named 'agent'Running from the wrong foldercd into aa101-agent-lab, where agent.py lives
Step 1 calculator returns 20 for 2 + 3 * 4Operator precedence not preservedUse the ast-based _eval exactly; do not split the string by hand
ValueError: unsupported expressionPassed a non-arithmetic string to calculatorThe calculator only accepts numbers and + - * / **, by design
Step 2 unknown tool runs instead of being refusedact runs before checking the registryTest name not in TOOLS and return early before calling any tool
Step 4 stopped is max_iterations on the circle taskPolicy never returns a stop actionConfirm Step 3 returns {"type": "stop", ...} once area is in memory
Validation prints FAILEDOne check in the tuple is FalseMatch the False position to its line in the validation block and revisit