Build an AI agent loop in Python: a practical lab
A Python agent loop connects a planner, a controlled tool dispatcher, recorded observations and a stopping rule. In this lab, a deterministic planner makes those mechanics reproducible without an API key. To add a language model, replace the planning function through a documented model interface while keeping execution checks in the application.

You need Python 3 and basic familiarity with functions and dictionaries. The downloadable lab uses only the standard library, an invented lesson catalogue and console output. It makes no network requests and does not modify files. Study the trace before changing the example.
Key ideas
- Start with one lookup tool and invented records.
- Validate the proposed tool name and argument shape before execution.
- Represent an absent record explicitly.
- Keep the step limit outside the planning function.
Prepare the input and acceptance check
Save the downloadable example as ai-agent-lab.py and run it with python3 ai-agent-lab.py. The known key 'ai' should produce the stored lesson record after one lookup and a final decision. The trace should show the lookup name, its validated input and its result. A missing key must finish with an unknown status rather than a generated title. Those expected outcomes are part of this teaching fixture; they are not a benchmark for language models.
Understand the deterministic planner
The planning function first requests a lookup. After an observation is available, it proposes a final result drawn from that observation. This is a simulator for studying the execution loop. Its decisions are fixed by code, so it does not demonstrate model reasoning or language understanding. The benefit is reproducibility: you can change the dispatcher or state and observe the consequence without an API response varying between runs.
Keep the dispatcher narrow
The tool registry contains only lookup. The dispatcher checks that the action has the expected type, the name is registered and the arguments contain one permitted key. The lookup validates that its input is a short string. Never replace this dispatcher with a function that executes model-generated Python or shell commands. A future model can propose a structured lookup, but the registry and checks should still decide what actually runs.
Inspect missing records and limits
Read the returned status as well as the answer. 'done' means a stored record was returned; 'unknown' means the catalogue had no such record; 'stopped' means the configured step budget was exhausted. Try a one-step budget: the lookup occurs, but there is no remaining planning step to finish. That outcome should stay stopped. It illustrates why reaching a limit must not be presented as successful completion just because a tool returned something.
Python lab
"""VITON13 SCHOOL: a deterministic teaching loop, without a model or network."""
import json
CATALOGUE = {
"ai": {"title": "Applied AI", "topics": ["goals", "tools", "checks"]},
"design": {"title": "Design", "topics": ["brief", "prototype", "review"]},
}
def lookup(key):
if not isinstance(key, str) or not 0 < len(key) <= 32:
raise ValueError("key must be a string of 1-32 characters")
return CATALOGUE.get(key)
TOOLS = {"lookup": lookup}
def plan(state):
"""A simulator: replace this proposal function to study model integration."""
if not state["observations"]:
return {"type": "tool", "name": "lookup", "args": {"key": state["key"]}}
return {"type": "final", "answer": state["observations"][-1]["result"]}
def run(key, planner=plan, max_steps=3):
if type(max_steps) is not int or max_steps < 1:
raise ValueError("max_steps must be a positive integer")
state = {"key": key, "observations": []}
for _ in range(max_steps):
action = planner(state)
if not isinstance(action, dict):
raise ValueError("proposal must be an object")
if action.get("type") == "final":
if not state["observations"]:
raise ValueError("final result requires an observation")
result = state["observations"][-1]["result"]
if action.get("answer") != result:
raise ValueError("final answer must match the retrieved record")
return {
"status": "done" if result is not None else "unknown",
"answer": result, "trace": state["observations"],
}
if action.get("type") != "tool" or action.get("name") not in TOOLS:
raise ValueError("tool is not allowed")
args = action.get("args")
if not isinstance(args, dict) or set(args) != {"key"}:
raise ValueError("lookup requires exactly one key argument")
if args["key"] != state["key"]:
raise ValueError("lookup is outside the requested key")
result = TOOLS[action["name"]](**args)
state["observations"].append({
"tool": action["name"], "input": dict(args), "result": result,
})
return {"status": "stopped", "answer": None, "trace": state["observations"]}
if __name__ == "__main__":
print(json.dumps(run("ai"), indent=2))
Download the Python example ↓Connect a model after the boundaries work
Replace the planning function with an adapter that returns the same action structure using your model provider's documented tool interface. Pass the goal and relevant observations, validate the returned proposal and keep tool execution outside the model. Test wrong tool names, malformed arguments, a missing record and an unsupported final answer. The adapter also needs handling for provider errors and usage limits. Our offline lab provides the control-flow foundation; that combined model integration requires its own verification.
In everyday language
The lab is a model workshop for the machinery. A fixed planner stands in for the part that chooses an action. You can watch the tool, notebook and stopping rule work before connecting a language model that may propose different actions.
Try it yourself
Run the known key, an unknown key and a one-step budget. Then substitute a planner that requests an unregistered tool. Predict each outcome before running it and compare the result with your prediction.
Expected result
One sourced result, one unknown result, one stopped result and one rejected proposal. The rejected tool must never execute.
Check your answer: What changes when you connect a language model?
The proposal-generation step changes. Tool permissions, input validation, state management and stopping limits still belong to application code.
Questions
Does this example require a paid API?
No. The teaching example uses a deterministic planning function and a local dictionary. It requires no account or API key. A language-model adapter is a separate extension, whose access requirements and costs depend on the provider you choose and whose behaviour must be tested.
Can I use the lab as a production agent?
The lab teaches the loop with invented records. A production application needs its actual model integration, authentication, error recovery, access controls, observability and task-specific checks. Build those deliberately and test them with representative cases instead of treating a successful classroom run as deployment evidence.
Run the small example, inspect every status and test a forbidden proposal. Preserve those checks when you add a model.
Sources and further reading
- Python documentation — Control flow ↗Sources checked:
- Python documentation — Data structures ↗Sources checked:
- Anthropic — Writing effective tools for agents ↗Sources checked: