AI Agents · lesson 2 of 10
Intermediate
3Advanced
5Also part of AI Agents
7- + LLM Tool Use and Function Calling from LLM Engineering
- + Structured Outputs in Production from LLM Engineering
- + LLM Context Engineering from LLM Engineering
- + LLM Security Beyond Guardrails from LLM Engineering
- + MCP: Your First Server and Client from MCP (Model Context Protocol)
- + MCP Tools: Schemas, Results and Errors from MCP (Model Context Protocol)
- + Elicitation, Sampling and Roots (Multi Round-Trip Requests) from MCP (Model Context Protocol)
Also part of: LLM Engineering
Introduction to AI Agents
What makes an AI agent: tools, the ReAct loop of thought, action and observation, step caps that stop runaway loops, and memory that survives sessions.
By SkillByExample · Updated
What Is an AI Agent?
An AI agent is a program in which a language model decides what to do next, in a loop, until a goal is met. A single model call answers once. An agent:
- Reasons about the task and everything it has seen so far
- Acts, usually by asking your code to run a tool: a search, a calculation, a database query
- Observes the tool’s result, which is added to what it sees on the next step
- Repeats until it can give a final answer, or until your code stops it
The model never runs anything itself. It only produces text that says which tool to call with which arguments; your code runs the tool and decides whether to continue. That’s why the loop, the tools and the limits in this lesson are ordinary Python.
Every example below runs offline. The model’s part is played by think, a stand-in that chooses the next step the way a model would, by reading the task and the observations so far. It’s deterministic, so the output you get matches the output shown. No API key is needed. The final section shows how to replace think with a real model call.
Tools: What the Agent Can Do
Tools are ordinary functions. The agent can only do what its tools allow, so they’re where capability and safety are decided. Save this as tools.py.
import math
SEARCH_INDEX = { # sample figures, not live data
"tokyo": "Tokyo city: 13.96 million, metro: 37.4 million",
"new york": "New York City: 8.34 million, metro: 20.1 million",
}
def search(query):
for key, text in SEARCH_INDEX.items():
if key in query.lower():
return text
return f"no results for {query!r}"
def calculate(expression):
safe = {name: getattr(math, name) for name in ("sqrt", "log", "pi")} | {"round": round}
try:
return str(eval(expression, {"__builtins__": {}}, safe))
except Exception as e:
return f"error: {type(e).__name__}: {e}"
TOOLS = {"search": search, "calculate": calculate}
if __name__ == "__main__":
for name, arg in [("search", "Tokyo population"), ("search", "Paris population"),
("calculate", "round(37.4 / 20.1, 2)"), ("calculate", "__import__('os')")]:
print(f"{name}({arg!r}) -> {TOOLS[name](arg)}")
$ python tools.py
search('Tokyo population') -> Tokyo city: 13.96 million, metro: 37.4 million
search('Paris population') -> no results for 'Paris population'
calculate('round(37.4 / 20.1, 2)') -> 1.86
calculate("__import__('os')") -> error: NameError: name '__import__' is not defined
Every tool returns a string, including when it fails. search for Paris doesn’t raise; it returns “no results”, which the model can read and react to. calculate turns an attempt to import os into an error string because __builtins__ is empty. Tool failures should be observations the model can reason about, not exceptions that kill the loop. (Emptying __builtins__ is not a complete sandbox, as the Practice section shows.)
The ReAct Loop
ReAct (Reason + Act) is the core agent pattern: thought, action, observation, repeated. First the stand-in model, saved as model.py. Given the task and the history of steps, it returns a thought, an action and the action’s argument:
import re
def think(task, history):
"""Stands in for an LLM: picks the next step from the task and the observations so far."""
seen = " ".join(obs for _, _, _, obs in history)
for city in ("Tokyo", "New York", "Paris"):
if city in task and not re.search(rf"{city}[^,]*: [\d.]+ million", seen): # no figure yet
return f"I need {city}'s population.", "search", f"{city} population"
metros = re.findall(r"metro: ([\d.]+) million", seen)
if len(metros) == 2 and not any(action == "calculate" for _, action, _, _ in history):
return "I have both metro figures, so divide them.", "calculate", f"round({metros[0]} / {metros[1]}, 2)"
first, second = [arg.removesuffix(" population") for _, act, arg, _ in history if act == "search"]
return "I have the ratio.", "finish", f"{first}'s metro area is {history[-1][3]}x {second}'s."
The loop is the agent. Save it as agent.py:
from model import think
from tools import TOOLS
def run_agent(task, max_steps=8):
history = [] # (thought, action, argument, observation)
for step in range(1, max_steps + 1):
thought, action, argument = think(task, history)
print(f"Thought {step}: {thought}")
if action == "finish":
return argument
observation = TOOLS[action](argument)
print(f"Action {step}: {action}({argument!r})")
print(f"Observation {step}: {observation}")
history.append((thought, action, argument, observation))
return f"stopped: no answer after {max_steps} steps"
if __name__ == "__main__":
answer = run_agent("How many times bigger is the Tokyo metro area than New York's? "
"Give the ratio to 2 decimal places.")
print("Final answer:", answer)
$ python agent.py
Thought 1: I need Tokyo's population.
Action 1: search('Tokyo population')
Observation 1: Tokyo city: 13.96 million, metro: 37.4 million
Thought 2: I need New York's population.
Action 2: search('New York population')
Observation 2: New York City: 8.34 million, metro: 20.1 million
Thought 3: I have both metro figures, so divide them.
Action 3: calculate('round(37.4 / 20.1, 2)')
Observation 3: 1.86
Thought 4: I have the ratio.
Final answer: Tokyo's metro area is 1.86x New York's.
Nobody wrote “search twice, then calculate” in advance. On every step think sees the whole history and picks one action, the loop runs it, and the observation changes what the next step sees. Step 3 was only possible after steps 1 and 2 had put both metro figures into the history. Step 4 returned finish instead of a tool, which ends the loop. With a real model, the thoughts are generated text and vary from run to run, but the shape of the loop, and the trace it prints, stays the same. That trace is also how you debug an agent: when an answer is wrong, the thought or observation where it went wrong is on screen.
The Failure: An Agent That Never Stops
Ask about a city the search tool knows nothing about. Save this as runaway.py:
from agent import run_agent
from model import think
from tools import TOOLS
task = "How many times bigger is Paris than Tokyo?"
print("capped at 4 steps:", run_agent(task, max_steps=4))
def run_guarded(task, max_steps=8):
history, tried = [], set()
for step in range(1, max_steps + 1):
thought, action, argument = think(task, history)
if action == "finish":
return argument
if (action, argument) in tried: # same call, same input: same result
return f"stopped at step {step}: repeated {action}({argument!r}) - {history[-1][3]}"
tried.add((action, argument))
history.append((thought, action, argument, TOOLS[action](argument)))
return f"stopped: no answer after {max_steps} steps"
print("\nguarded:", run_guarded(task))
$ python runaway.py
Thought 1: I need Tokyo's population.
Action 1: search('Tokyo population')
Observation 1: Tokyo city: 13.96 million, metro: 37.4 million
Thought 2: I need Paris's population.
Action 2: search('Paris population')
Observation 2: no results for 'Paris population'
Thought 3: I need Paris's population.
Action 3: search('Paris population')
Observation 3: no results for 'Paris population'
Thought 4: I need Paris's population.
Action 4: search('Paris population')
Observation 4: no results for 'Paris population'
capped at 4 steps: stopped: no answer after 4 steps
guarded: stopped at step 3: repeated search('Paris population') - no results for 'Paris population'
The agent still needs Paris’s population, so it searches for it again, and again. Real models do this too, retrying a failed tool call with the same or trivially reworded arguments. Without max_steps this loop would run until you killed it, and with a real model every iteration is a paid API call. The step cap is the backstop: it always ends the run, here after 4 steps. The repeat check in run_guarded is cheaper and more informative. It stops at step 3, the first time the agent asks for a call it has already made, because the same call with the same input can only return the same result. It also says why it stopped, which is what a user or a log needs. Use both: the repeat check catches the common case early, and the cap catches everything else.
Memory That Survives the Session
A model remembers nothing between requests. Anything an agent “remembers” is text your code stored and puts back into the prompt. Save this as memory_agent.py; run stands in for the model call and can only answer from the prompt it builds.
import json, re
from pathlib import Path
class MemoryAgent:
"""An agent whose long-term memory is a JSON file, loaded into every prompt."""
def __init__(self, path):
self.path = Path(path)
self.memory = json.loads(self.path.read_text()) if self.path.exists() else {}
def remember(self, key, value):
self.memory[key] = value
self.path.write_text(json.dumps(self.memory))
def prompt(self, message): # what a real model would be sent
facts = "\n".join(f"- {k}: {v}" for k, v in self.memory.items())
return f"Known facts:\n{facts or '- none'}\n\nUser: {message}"
def run(self, message):
"""Stands in for an LLM call: saves stated facts, answers only from the prompt."""
if m := re.match(r"my (\w+) is (\w+)", message, re.IGNORECASE):
self.remember(m[1].lower(), m[2])
return f"Noted: your {m[1].lower()} is {m[2]}."
key = re.search(r"what is my (\w+)", message.lower())[1]
found = re.search(rf"- {key}: (\w+)", self.prompt(message))
return f"Your {key} is {found[1]}." if found else f"I don't know your {key}."
Two sessions on different days are two separate objects. Save this as sessions.py:
from pathlib import Path
from memory_agent import MemoryAgent
for f in ("memory.json", "guest.json"):
Path(f).unlink(missing_ok=True)
monday = MemoryAgent("memory.json")
print("monday :", monday.run("My name is Priya"))
print("monday :", monday.run("My city is Pune"))
tuesday = MemoryAgent("memory.json") # new object, same file: a new session
print("tuesday:", tuesday.run("What is my name?"))
print("tuesday:", tuesday.run("What is my employer?"))
guest = MemoryAgent("guest.json") # a different memory file
print("guest :", guest.run("What is my name?"))
print("\nprompt sent on tuesday:\n" + tuesday.prompt("What is my city?"))
$ python sessions.py
monday : Noted: your name is Priya.
monday : Noted: your city is Pune.
tuesday: Your name is Priya.
tuesday: I don't know your employer.
guest : I don't know your name.
prompt sent on tuesday:
Known facts:
- name: Priya
- city: Pune
User: What is my city?
Tuesday’s agent is a brand-new object, yet it knows the name, because it loaded memory.json and every prompt it builds starts with the known facts. It doesn’t know the employer because nobody ever stated one, and the guest agent, reading a different file, knows nothing about Priya at all. The last block is the whole trick: memory is the “Known facts” section your code writes into the prompt. A real model will often guess instead of saying “I don’t know” when a fact is missing, which is worse, so what goes into that section matters. The agent memory lesson covers what to store, summarize, pin and delete.
Wiring This to a Real Provider
This section is reference code only. Unlike every example above, it was not executed, has no output block, and needs your own API key and the provider’s SDK (pip install anthropic or pip install openai). It shows the shape of replacing the stand-in think with a real model: you send the task, the history and the tool definitions, and the model replies with either a tool call or a final answer. The loop, TOOLS, the step cap and the repeat check stay exactly as they are above. Check the provider’s current documentation for model names before running it.
Anthropic’s Messages API, with the client reading ANTHROPIC_API_KEY from the environment:
import anthropic
from tools import TOOLS # the same tool functions as above
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
TOOL_SPECS = [
{"name": "search", "description": "Look up population figures for a city.",
"input_schema": {"type": "object", "properties": {"query": {"type": "string"}},
"required": ["query"]}},
{"name": "calculate", "description": "Evaluate an arithmetic expression, e.g. 'round(37.4 / 20.1, 2)'.",
"input_schema": {"type": "object", "properties": {"expression": {"type": "string"}},
"required": ["expression"]}},
]
messages = [{"role": "user", "content": "How many times bigger is the Tokyo metro area than New York's?"}]
for step in range(8): # the same step cap as run_agent
response = client.messages.create(model="claude-opus-5-5", max_tokens=16000,
tools=TOOL_SPECS, messages=messages)
if response.stop_reason != "tool_use": # a final answer
print(next((b.text for b in response.content if b.type == "text"), ""))
break
messages.append({"role": "assistant", "content": response.content})
results = [{"type": "tool_result", "tool_use_id": block.id,
"content": TOOLS[block.name](*block.input.values())}
for block in response.content if block.type == "tool_use"]
messages.append({"role": "user", "content": results})
OpenAI’s Chat Completions API, with the client reading OPENAI_API_KEY:
import json
from openai import OpenAI
from tools import TOOLS
client = OpenAI() # reads OPENAI_API_KEY
TOOL_SPECS = [
{"type": "function", "function": {
"name": "search", "description": "Look up population figures for a city.",
"parameters": {"type": "object", "properties": {"query": {"type": "string"}},
"required": ["query"]}}},
{"type": "function", "function": {
"name": "calculate", "description": "Evaluate an arithmetic expression.",
"parameters": {"type": "object", "properties": {"expression": {"type": "string"}},
"required": ["expression"]}}},
]
messages = [{"role": "user", "content": "How many times bigger is the Tokyo metro area than New York's?"}]
for step in range(8):
response = client.chat.completions.create(model="YOUR_MODEL_NAME", tools=TOOL_SPECS,
messages=messages)
message = response.choices[0].message
if not message.tool_calls: # a final answer
print(message.content)
break
messages.append(message)
for call in message.tool_calls:
args = json.loads(call.function.arguments)
messages.append({"role": "tool", "tool_call_id": call.id,
"content": TOOLS[call.function.name](*args.values())})
The two APIs differ in names, not in shape. Anthropic returns tool_use content blocks and expects tool_result blocks in the next user message; OpenAI returns tool_calls on the message and expects one role: "tool" message per call. In both, every tool call must be answered with its result, matched by ID, before the model is called again. In both, a real model’s arguments are untrusted input, so validate them before running a tool, as the tool use lesson shows.
Practice
-
Add Paris to the search index (
"Paris city: 2.1 million, metro: 12.3 million", a sample figure) and run the Paris question again. Does the agent answer the question that was asked?Solution
from tools import SEARCH_INDEX from agent import run_agent SEARCH_INDEX["paris"] = "Paris city: 2.1 million, metro: 12.3 million" # sample figure answer = run_agent("How many times bigger is Paris than Tokyo?") print("Final answer:", answer)$ python prac_paris.py Thought 1: I need Tokyo's population. Action 1: search('Tokyo population') Observation 1: Tokyo city: 13.96 million, metro: 37.4 million Thought 2: I need Paris's population. Action 2: search('Paris population') Observation 2: Paris city: 2.1 million, metro: 12.3 million Thought 3: I have both metro figures, so divide them. Action 3: calculate('round(37.4 / 12.3, 2)') Observation 3: 3.04 Thought 4: I have the ratio. Final answer: Tokyo's metro area is 3.04x Paris's.It finishes now, and the final sentence is true, but it isn’t an answer to the question. The question asked how many times bigger Paris is than Tokyo, and the honest answer is that Paris is smaller, about a third of Tokyo’s size. The agent divided in the order it happened to search. The trace shows exactly where: step 3 put Tokyo first. Real agents make the same kind of mistake, which is why agent evaluations compare the final answer with the question and not just check that the run finished.
-
Add a
forgetmethod toMemoryAgentthat deletes one fact from the file. Forget the city, then check what a new session knows.Solution
import json from pathlib import Path from memory_agent import MemoryAgent class ForgetfulAgent(MemoryAgent): def forget(self, key): removed = self.memory.pop(key, None) self.path.write_text(json.dumps(self.memory)) return f"forgot {key}" if removed else f"nothing stored for {key}" Path("memory.json").unlink(missing_ok=True) agent = ForgetfulAgent("memory.json") agent.run("My name is Priya") agent.run("My city is Pune") print(agent.forget("city"), "|", agent.forget("employer")) print("new session:", ForgetfulAgent("memory.json").run("What is my city?")) print("file now:", Path("memory.json").read_text())$ python prac_forget.py forgot city | nothing stored for employer new session: I don't know your city. file now: {"name": "Priya"}Forgetting has to change the stored file, not just the object in memory, or the next session would load the fact again. The final line checks the file itself. Reporting “nothing stored” for a key that never existed is more honest than a silent success.
-
The
calculatetool blocksimport, but trycalculate("9 ** 9 ** 9")with a 5-second timeout. Then write a checked version that parses the expression withastand rejects anything outside a small allowed set.Solution
$ timeout 5 python -c "from tools import calculate; print(calculate(\"9 ** 9 ** 9\"))"; echo "exit status $?" exit status 124Exit status 124 means
timeoutkilled it:9 ** 9 ** 9is 9 to the power of 387,420,489, a number with about 370 million digits, and Python tries to compute it. An emptied__builtins__doesn’t prevent this, and a model can produce such an expression by accident. Check the syntax tree before evaluating:import ast from tools import calculate ALLOWED = (ast.Expression, ast.BinOp, ast.UnaryOp, ast.Constant, ast.Call, ast.Name, ast.Load, ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, ast.USub, ast.Mod, ast.FloorDiv) def small_number(node, limit): return isinstance(node, ast.Constant) and type(node.value) in (int, float) and abs(node.value) <= limit def calculate_checked(expression): try: tree = ast.parse(expression, mode="eval") except SyntaxError as e: return f"error: not an expression ({e.msg})" for node in ast.walk(tree): if not isinstance(node, ALLOWED): return f"error: {type(node).__name__} is not allowed" if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Pow): if not (small_number(node.left, 1e6) and small_number(node.right, 100)): return "error: ** needs plain numbers, exponent at most 100" return calculate(expression) for expr in ["round(37.4 / 20.1, 2)", "2 ** 10", "9 ** 9 ** 9", "(9 ** 99) ** 99", "(1).__class__", "2 ** 'x'", "2 +"]: print(f"{expr!r:24} -> {calculate_checked(expr)}")$ python prac_ast.py 'round(37.4 / 20.1, 2)' -> 1.86 '2 ** 10' -> 1024 '9 ** 9 ** 9' -> error: ** needs plain numbers, exponent at most 100 '(9 ** 99) ** 99' -> error: ** needs plain numbers, exponent at most 100 '(1).__class__' -> error: Attribute is not allowed "2 ** 'x'" -> error: ** needs plain numbers, exponent at most 100 '2 +' -> error: not an expression (invalid syntax)9 ** 9 ** 9groups as9 ** (9 ** 9), so its exponent is itself an expression and is rejected before anything is computed. Checking only the exponent isn’t enough:(9 ** 99) ** 99has a small exponent at every level but still builds an enormous number, so both sides of**must be plain, bounded numbers.(1).__class__is rejected because attribute access isn’t in the allowed set; attribute chains like this are how most escapes fromevalsandboxes begin. A syntax error and a string exponent come back as error strings too, so the tool keeps its rule of always returning a string. An allow-list of syntax is much stronger than trying to block dangerous strings, but it limits which syntax is allowed, not how much work an allowed expression does, and a long enough chain of large multiplications is still allowed. Keep a timeout, or run the tool in a subprocess, as the final backstop.