Lab 5 — A Minimal Agent Loop (from scratch)¶
⏱️ ~30 min
An LLM that decides which tool to call, observes the result, and loops until done. Uses OpenAI function calling.
Requires an OpenAI API key — these labs use OpenAI embeddings / function-calling specifically. (Labs 1–3 work with OpenAI or Anthropic.)
In [ ]:
Copied!
!pip install -q openai
import os, getpass, json
if not os.environ.get("OPENAI_API_KEY"):
os.environ["OPENAI_API_KEY"] = getpass.getpass("OpenAI API key: ")
from openai import OpenAI
client = OpenAI()
!pip install -q openai
import os, getpass, json
if not os.environ.get("OPENAI_API_KEY"):
os.environ["OPENAI_API_KEY"] = getpass.getpass("OpenAI API key: ")
from openai import OpenAI
client = OpenAI()
1. Define two tools (plain Python functions)¶
In [ ]:
Copied!
def web_search(query: str) -> str:
"""Fake search — returns canned facts so the lab is deterministic and free."""
facts = {
"eiffel tower height": "The Eiffel Tower is 330 meters tall.",
"speed of light": "The speed of light is 299,792,458 meters per second.",
}
for key, val in facts.items():
if key in query.lower():
return val
return "No results found."
import ast, operator
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.Pow: operator.pow, ast.Mod: operator.mod,
ast.USub: operator.neg}
def _safe_arith(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 _OPS:
return _OPS[type(node.op)](_safe_arith(node.left), _safe_arith(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_safe_arith(node.operand))
raise ValueError("unsupported expression")
def calculator(expression: str) -> str:
"""Safely evaluate a simple arithmetic expression.
NOTE: never call Python eval() on model-generated text — that's how you get
code injection. We parse a tiny arithmetic grammar with ast instead."""
try:
return str(_safe_arith(ast.parse(expression, mode="eval").body))
except Exception as e:
return f"Error: {e}"
def web_search(query: str) -> str:
"""Fake search — returns canned facts so the lab is deterministic and free."""
facts = {
"eiffel tower height": "The Eiffel Tower is 330 meters tall.",
"speed of light": "The speed of light is 299,792,458 meters per second.",
}
for key, val in facts.items():
if key in query.lower():
return val
return "No results found."
import ast, operator
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.Pow: operator.pow, ast.Mod: operator.mod,
ast.USub: operator.neg}
def _safe_arith(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 _OPS:
return _OPS[type(node.op)](_safe_arith(node.left), _safe_arith(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_safe_arith(node.operand))
raise ValueError("unsupported expression")
def calculator(expression: str) -> str:
"""Safely evaluate a simple arithmetic expression.
NOTE: never call Python eval() on model-generated text — that's how you get
code injection. We parse a tiny arithmetic grammar with ast instead."""
try:
return str(_safe_arith(ast.parse(expression, mode="eval").body))
except Exception as e:
return f"Error: {e}"
2. Describe the tools to the model (the function-calling schema)¶
In [ ]:
Copied!
tools = [
{"type": "function", "function": {
"name": "web_search",
"description": "Search the web for a fact.",
"parameters": {"type": "object",
"properties": {"query": {"type": "string"}}, "required": ["query"]}}},
{"type": "function", "function": {
"name": "calculator",
"description": "Evaluate an arithmetic expression like '330 * 2'.",
"parameters": {"type": "object",
"properties": {"expression": {"type": "string"}}, "required": ["expression"]}}},
]
TOOL_FNS = {"web_search": web_search, "calculator": calculator}
tools = [
{"type": "function", "function": {
"name": "web_search",
"description": "Search the web for a fact.",
"parameters": {"type": "object",
"properties": {"query": {"type": "string"}}, "required": ["query"]}}},
{"type": "function", "function": {
"name": "calculator",
"description": "Evaluate an arithmetic expression like '330 * 2'.",
"parameters": {"type": "object",
"properties": {"expression": {"type": "string"}}, "required": ["expression"]}}},
]
TOOL_FNS = {"web_search": web_search, "calculator": calculator}
3. The agent loop: plan → act → observe → repeat¶
In [ ]:
Copied!
def run_agent(question, max_steps=5):
messages = [
{"role": "system", "content":
"You are a helpful agent. Use tools when needed. Think step by step."},
{"role": "user", "content": question},
]
for step in range(max_steps):
resp = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=tools)
msg = resp.choices[0].message
if not msg.tool_calls:
print(f"[step {step}] FINAL: {msg.content}")
return msg.content
messages.append(msg)
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
result = TOOL_FNS[call.function.name](**args)
print(f"[step {step}] tool={call.function.name} args={args} -> {result}")
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
return "Stopped: max steps reached."
def run_agent(question, max_steps=5):
messages = [
{"role": "system", "content":
"You are a helpful agent. Use tools when needed. Think step by step."},
{"role": "user", "content": question},
]
for step in range(max_steps):
resp = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=tools)
msg = resp.choices[0].message
if not msg.tool_calls:
print(f"[step {step}] FINAL: {msg.content}")
return msg.content
messages.append(msg)
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
result = TOOL_FNS[call.function.name](**args)
print(f"[step {step}] tool={call.function.name} args={args} -> {result}")
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
return "Stopped: max steps reached."
4. Run it — a task needing search THEN math¶
In [ ]:
Copied!
run_agent("How tall is the Eiffel Tower in meters, and what is that doubled?")
run_agent("How tall is the Eiffel Tower in meters, and what is that doubled?")
Takeaway: the loop is the whole idea — the model picks a tool, you run it, feed the
result back, and repeat until it answers. max_steps guards against infinite loops.
✅ Did it work?¶
- You should see the model call
web_searchfor the Eiffel Tower height, thencalculatorto double it (~660 meters). (Occasionally a model does the doubling from memory and skips the calculator — re-run, or make the maths harder to force the tool call.) - The loop stopped on its own once the model produced a FINAL answer, rather than running until
max_steps.