Why write the loop yourself
Part 10 looked at when to reach for a framework and when plain code is enough. This article picks plain code on purpose, so you can see what a framework would otherwise do for you behind the scenes. Even if your next project uses LangGraph, a vendor SDK, or something else entirely, every one of those tools is running a loop that looks a lot like the one you are about to write by hand: send messages to a model, read its reply, call a tool if it asked for one, feed the result back in, and repeat until the model is done or you decide to stop it.
By the end of this article you will have a single Python script, no agent library involved, that can: answer a question using three read-only tools, pause and ask your permission before doing anything with a real side effect, stop itself after a fixed number of turns, and keep working when a tool call fails or the model asks for something that does not exist. That is a small but complete agent in the sense defined in part 1: it observes, thinks, and acts in a loop, choosing its own next step rather than following a path you wrote in advance.
This article assumes you have read parts 1 through 10, especially part 2 (the observe, think, act loop), part 3 (tools and function calling), part 8 (errors, retries and stopping conditions) and part 9 (human in the loop). Those articles explained the ideas; this one turns them into working code and refers back to them rather than re-teaching them.
What you need before you start
- Python 3.9 or newer, with a terminal and a text editor.
- An account with an LLM provider whose API supports tool calling. The code below uses OpenAI's API, but the pattern applies with small syntax changes to Anthropic's API, Google's API, or a locally hosted model served through an OpenAI-compatible endpoint such as Ollama.
- The openai Python package, version 1.x. The interface changed completely at version 1.0 (the old openai.ChatCompletion.create style is gone), so if you have an older install, remove it first.
- A small amount of API budget. Every run in this article uses only a handful of short messages, which typically costs a fraction of a cent to a few cents depending on the model you pick.
Install the client library and set your API key as an environment variable rather than writing it into the script:
pip install "openai>=1.0,<2.0"
export OPENAI_API_KEY="sk-your-key-here"
On Windows PowerShell the second line would be $env:OPENAI_API_KEY = "sk-your-key-here" instead. Keeping the key out of the script means you will not accidentally commit it to version control or paste it somewhere it should not be. As for which model to use, pick a small, inexpensive one that your provider lists as supporting tool calling; model names and what is available on a given account change over time, so check your provider's current model list and pricing before you run anything below rather than trusting a name printed in this article.
Step 1: the smallest possible call
Before touching tools, confirm the basic plumbing works: one message in, one message out.
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY from the environment
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Say hello in five words or fewer."}],
)
print(response.choices[0].message.content)
OpenAI() builds a client that picks up the key from the environment variable you set earlier. chat.completions.create sends a list of messages, here just one user turn, and returns a response object. The text you actually want is nested two levels down, at response.choices[0].message.content. Run this file and you should see a short greeting, something like "Hello there, nice to meet you." The exact wording will differ every time you run it, since the model is not deterministic by default. If this fails, the three most common causes are a missing or wrong API key, no network access, or a model name your account cannot use. Everything else in this article is control flow wrapped around this one call, so it is worth getting it working before moving on.
Step 2: giving the agent tools
Describing a tool to the model
As part 3 covered, a tool is just a regular function, but the model cannot read your Python code, so you describe the function's name, purpose and arguments as a small JSON schema. The model reads that schema, decides when it wants to use the tool, and replies with the arguments it wants to pass. Your code is still the only thing that ever actually runs the function; the model only ever asks.
We will give this agent three read-only tools to start: a calculator that evaluates a basic arithmetic expression, a clock that returns the current time, and a lookup tool backed by a small fixed dictionary standing in for a knowledge base. The dictionary is deliberately about a fictional company, Acme Robotics, invented for this exercise, so the example runs offline, gives the same answer every time, and cannot be mistaken for real information.
Writing the tool functions
The tempting way to write a calculator tool is eval(expression). Do not do that: eval() runs arbitrary Python, and expression is text the model generated, which you should not trust any more than text typed by a stranger on the internet. Instead, parse the expression into a syntax tree with the standard library's ast module and only evaluate nodes that are numbers or basic arithmetic operators. Anything else, including attempts to call functions or import modules, fails before it can do anything.
import ast
import operator
_ALLOWED_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.USub: operator.neg,
}
def _safe_eval(node):
if isinstance(node, ast.Constant):
if isinstance(node.value, (int, float)):
return node.value
raise ValueError("only numbers are allowed")
if isinstance(node, ast.BinOp) and type(node.op) in _ALLOWED_OPS:
left = _safe_eval(node.left)
right = _safe_eval(node.right)
return _ALLOWED_OPS[type(node.op)](left, right)
if isinstance(node, ast.UnaryOp) and type(node.op) in _ALLOWED_OPS:
return _ALLOWED_OPS[type(node.op)](_safe_eval(node.operand))
raise ValueError(f"unsupported expression: {ast.dump(node)}")
def calculator(expression: str) -> dict:
try:
tree = ast.parse(expression, mode="eval")
result = _safe_eval(tree.body)
return {"result": result}
except Exception as exc:
return {"error": f"could not evaluate '{expression}': {exc}"}
ast.parse(expression, mode="eval") parses the string as a single expression, not a whole program, so statements like assignments, imports, or function calls are rejected at the parsing stage. _safe_eval then walks the resulting tree recursively and only evaluates node types on an explicit whitelist: numeric constants, binary operations using one of the five operators in _ALLOWED_OPS, and a unary minus. Anything outside that whitelist, such as a function call node, raises ValueError instead of running. The outer calculator function catches every exception and returns a plain dictionary with an "error" key rather than letting the exception escape, which matters once this function is called from inside a loop that should never crash because of bad input.
from datetime import datetime
def get_current_time() -> dict:
return {"current_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
_KNOWLEDGE_BASE = {
"founding year": "Acme Robotics (a fictional company used for this exercise) was founded in 2017.",
"office hours": "Acme Robotics support is staffed from 09:00 to 17:00 UTC on weekdays.",
"ceo": "The fictional CEO of Acme Robotics in this exercise is Jordan Lee.",
}
def lookup_fact(topic: str) -> dict:
key = topic.strip().lower()
if key in _KNOWLEDGE_BASE:
return {"fact": _KNOWLEDGE_BASE[key]}
return {"error": f"no entry for '{topic}'. Known topics: {list(_KNOWLEDGE_BASE)}"}
get_current_time needs no arguments and simply reads the system clock. lookup_fact stands in for a real search or retrieval tool; in a production system this might call a search API or query a vector store, but for learning the loop mechanics, a small dictionary is enough and keeps the example deterministic. Notice that lookup_fact also returns an error-shaped dictionary rather than raising when the topic is not found, and it even tells the model which topics do exist, giving it a chance to try again with a better argument.
The tool registry and schemas
Every agent built this way needs two parallel structures: a list of JSON schemas that gets sent to the model so it knows what tools exist, and a dictionary mapping tool names to the actual Python functions, kept entirely in your code and never sent anywhere. Part 3 pointed out that keeping these two in sync by hand is a common source of bugs; in a small script like this one, keeping them next to each other in the same file makes it easy to check at a glance that every schema has a matching function and vice versa.
MODEL = "gpt-4o-mini"
TOOLS = [
{
"type": "function",
"function": {
"name": "calculator",
"description": (
"Evaluate a basic arithmetic expression with +, -, *, /, ** "
"and parentheses. Do not use this for anything other than arithmetic."
),
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "e.g. '(3 + 4) * 2'",
}
},
"required": ["expression"],
},
},
},
{
"type": "function",
"function": {
"name": "get_current_time",
"description": "Return the current local date and time.",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "lookup_fact",
"description": (
"Look up a fact about the fictional company Acme Robotics, "
"used only for this exercise. Topics include 'founding year', "
"'office hours' and 'ceo'."
),
"parameters": {
"type": "object",
"properties": {"topic": {"type": "string"}},
"required": ["topic"],
},
},
},
]
TOOL_FUNCTIONS = {
"calculator": calculator,
"get_current_time": get_current_time,
"lookup_fact": lookup_fact,
}
The "parameters" field in each schema is plain JSON Schema: "type": "object" with a "properties" dictionary describing each argument, and a "required" list naming which ones are mandatory. The model leans on the "description" fields to decide when and how to call a tool, so vague descriptions lead to vague behaviour, such as the model calling the calculator on something that is not arithmetic, or guessing an argument name. Being explicit, as in the calculator's description above, costs nothing and saves debugging time later.
Step 3: the loop itself
Executing a tool safely
Part 8 made the general point that you should never assume a tool call will succeed. The model might ask for a tool name that does not exist, send arguments that fail to parse as JSON, or send valid JSON with values your function cannot handle. The run_tool function below guards against all three by always returning a dictionary, whether the call succeeded or not, instead of letting an exception crash the script.
def run_tool(name: str, arguments: dict) -> dict:
if name not in TOOL_FUNCTIONS:
return {"error": f"unknown tool '{name}'"}
try:
return TOOL_FUNCTIONS[name](**arguments)
except Exception as exc:
return {"error": f"tool '{name}' raised an exception: {exc}"}
If the model asks for a tool that is not in TOOL_FUNCTIONS, run_tool returns an error dictionary instead of raising a KeyError that would stop the whole script. That matters because this situation is survivable: a dictionary with an "error" key is still valid content to feed back to the model, which can read the message and try something else, exactly the kind of self-correction described in part 8. The broad except Exception around the actual call catches anything from a missing required argument to a type mismatch, and turns it into the same kind of recoverable message.
The observe, think, act loop in code
Part 2 named the three repeating steps of an agent loop: observe, think, act. Mapped onto this code, "think" is the call to client.chat.completions.create, "act" is the call to run_tool, and "observe" is appending the tool's result back into the messages list before the next think step runs.
import json
SYSTEM_PROMPT = (
"You are a helpful assistant with access to tools. Use calculator for "
"arithmetic, get_current_time for the current time, and lookup_fact for "
"facts about Acme Robotics. Only call a tool when you actually need it. "
"When you have the final answer, reply in plain text without calling a tool."
)
MAX_ITERATIONS = 6
def run_agent(user_message: str) -> str:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_message},
]
for turn in range(1, MAX_ITERATIONS + 1):
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
tool_choice="auto",
)
message = response.choices[0].message
if message.tool_calls:
messages.append({
"role": "assistant",
"content": message.content,
"tool_calls": [tc.model_dump() for tc in message.tool_calls],
})
for tool_call in message.tool_calls:
name = tool_call.function.name
try:
arguments = json.loads(tool_call.function.arguments)
except json.JSONDecodeError:
arguments = {}
print(f"[turn {turn}] calling {name}({arguments})")
result = run_tool(name, arguments)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result),
})
else:
return message.content
return "Stopped after reaching the maximum number of turns without a final answer."
The outer for loop bounds how many times the model gets to think before the script gives up, which is the simplest possible stopping condition from part 8: a hard ceiling on turns regardless of what is happening. Each iteration makes one model call with the full message history so far, then checks a single thing: did the model ask for any tool calls? If message.tool_calls is empty, the model considered its answer complete, and the function returns message.content directly, often ending the loop well before MAX_ITERATIONS is reached. If message.tool_calls is not empty, the assistant's own turn is appended to history first, using tc.model_dump() to turn each tool call object into a plain dictionary matching the shape the API expects to see again. Then an inner for loop runs over every tool call the model asked for in that single turn, since one turn can request more than one tool at once, for example asking for the current time and a fact lookup together. Each tool's result is appended as a "tool" role message carrying the matching tool_call_id, so the model can tell which result answers which request. Only after all tool calls from that turn have been answered does the outer loop go back to the top and call the model again, now with the fresh results included, letting it decide whether it has enough to answer or needs to call something else.
Running it
if __name__ == "__main__":
question = (
"What is 18 times 24? Then tell me what time it is right now, "
"and look up who the CEO of Acme Robotics is."
)
answer = run_agent(question)
print("\nFinal answer:\n", answer)
Running this file prints the [turn N] lines as each tool call happens, followed by a final answer. A typical run looks something like the lines below, though the exact wording, the number of turns, and whether the model asks for all three tools in a single turn or spreads them across several turns will vary between runs and between models, since nothing in the instructions forces it to batch them.
[turn 1] calling calculator({'expression': '18 * 24'})
[turn 1] calling get_current_time({})
[turn 1] calling lookup_fact({'topic': 'ceo'})
Final answer:
18 times 24 is 432. The current time is 2024-06-12 14:03:51. The CEO of
Acme Robotics, a fictional company used in this exercise, is Jordan Lee.
Step 4: a tool that needs permission
Part 9 made the case that not every tool should run automatically. Read-only tools like a calculator or a lookup can run freely, because getting them wrong costs nothing: the worst case is an error message the model can react to. A tool with a side effect outside your program, such as sending an email, making a purchase, deleting a file or posting a public message, is different. Undoing it later can be expensive or impossible, so it should pause and ask a human before anything happens.
We will add two more tools. draft_email only composes text in memory and is safe to let the model use freely. send_email is where the side effect happens, simulated here by appending to a list, and we will gate it behind a yes or no prompt in the terminal.
def draft_email(to: str, subject: str, body: str) -> dict:
return {"to": to, "subject": subject, "body": body, "status": "drafted"}
_SENT_EMAILS = []
def send_email(to: str, subject: str, body: str) -> dict:
_SENT_EMAILS.append({"to": to, "subject": subject, "body": body})
return {"status": "sent", "to": to}
TOOLS += [
{
"type": "function",
"function": {
"name": "draft_email",
"description": "Compose an email without sending it.",
"parameters": {
"type": "object",
"properties": {
"to": {"type": "string"},
"subject": {"type": "string"},
"body": {"type": "string"},
},
"required": ["to", "subject", "body"],
},
},
},
{
"type": "function",
"function": {
"name": "send_email",
"description": "Actually send a previously drafted email. This has a real side effect.",
"parameters": {
"type": "object",
"properties": {
"to": {"type": "string"},
"subject": {"type": "string"},
"body": {"type": "string"},
},
"required": ["to", "subject", "body"],
},
},
},
]
TOOL_FUNCTIONS["draft_email"] = draft_email
TOOL_FUNCTIONS["send_email"] = send_email
SENSITIVE_TOOLS = {"send_email"}
def run_tool(name: str, arguments: dict) -> dict:
if name not in TOOL_FUNCTIONS:
return {"error": f"unknown tool '{name}'"}
if name in SENSITIVE_TOOLS:
print(f"\nThe agent wants to call '{name}' with arguments: {arguments}")
approval = input("Allow this action? [y/N] ").strip().lower()
if approval != "y":
return {"error": "the user declined this action"}
try:
return TOOL_FUNCTIONS[name](**arguments)
except Exception as exc:
return {"error": f"tool '{name}' raised an exception: {exc}"}
run_tool now checks membership in SENSITIVE_TOOLS before doing anything else. If the requested tool is in that set, it prints exactly what the model wants to do and the arguments it chose, then blocks on input() until you type y or anything else. If you do not approve, the function still returns a dictionary, this time with an error saying the user declined, rather than silently doing nothing. That matters because the model needs some response to continue reasoning; a mysteriously empty or missing result could lead it to call the same tool again, assuming something went wrong rather than that it was refused on purpose.
Try it with a request that needs both the drafting and sending tools:
if __name__ == "__main__":
request = (
"Draft an email to jordan@acme-robotics.example with subject "
"'Kickoff time' telling them the project starts at the current time, "
"then send it."
)
answer = run_agent(request)
print("\nFinal answer:\n", answer)
draft_email runs without any prompt, since it is not in SENSITIVE_TOOLS. Just before send_email runs, you will see something like this in the terminal, and the script will wait for your answer before continuing:
The agent wants to call 'send_email' with arguments: {'to': 'jordan@acme-robotics.example', 'subject': 'Kickoff time', 'body': 'Hi Jordan, the project starts at 2024-06-12 14:03:51.'}
Allow this action? [y/N] y
Final answer:
I've sent the email to Jordan with the kickoff time included.
If you type anything other than y, run_tool returns {"error": "the user declined this action"} instead, which becomes the content of the next "tool" message. The model then has to work with that, and a reasonable final answer in that case might be something like "I was not able to send the email because you did not approve it. Here is the draft in case you want to send it yourself." What it actually says will vary, but the pattern, pausing for a real decision rather than guessing, is the point.
Step 5: when things go wrong
Bad or missing arguments
json.loads(tool_call.function.arguments) can fail if the model produces malformed JSON, which is rare but does happen, especially with smaller or heavily quantized models. The loop wraps that call in try/except and falls back to an empty dictionary rather than crashing. An empty dictionary can itself cause the actual function call to fail, for example a calculator with no expression argument raising a TypeError for a missing required parameter, but run_tool's own try/except around TOOL_FUNCTIONS[name](**arguments) catches that too and turns it into an ordinary error message. The model reads that message on the next turn and usually retries with corrected or complete arguments, the same self-correction behaviour described in part 8.
Unknown tool names
With tool_choice="auto" the model is restricted to the tools you declared in TOOLS, so it should not normally invent a name that does not exist. The more realistic way this happens is a mismatch you introduce yourself: you add a new entry to TOOLS but forget to register the matching function in TOOL_FUNCTIONS, or you rename a function without updating its schema. run_tool's first check, name not in TOOL_FUNCTIONS, catches that mismatch and reports it as a normal error rather than raising a KeyError that would end the script. It is worth keeping these two structures next to each other in the file specifically so this kind of drift is easy to spot by eye.
Stopping conditions in more depth
MAX_ITERATIONS = 6 is a blunt instrument. It stops a runaway loop, but it also stops a legitimate task that genuinely needs a seventh tool call, returning the generic fallback message instead of an answer. Part 8 listed several stopping conditions beyond a flat turn count, and a few are worth sketching here:
- A maximum wall-clock time per request, checked with time.monotonic() at the top of the loop, useful when a single slow tool or a slow model response matters more than the number of turns.
- A running total of tokens or estimated cost, read from response.usage on each call and summed, useful when the risk you care about is the bill rather than the time taken.
- A check for repeated identical tool calls, comparing the current call's name and arguments against the previous one, which catches a model stuck retrying something that keeps failing in the same way.
- An explicit 'done' signal from the model itself, for example a final_answer tool it is asked to call instead of simply stopping tool calls, which makes the end of the loop a decision you can see rather than an absence you infer.
Which combination to use depends on the risk of the task. A read-only assistant that only looks things up can afford a generous turn limit, since the worst case is a slow or slightly expensive answer. An agent that can spend money or send messages, like the one in this article, should have a tighter limit and more of the checks above, because the worst case is worse.
Step 6: keeping context under control
Part 7 covered context engineering in general; here is the smallest version of it that fits this script. Every tool result gets appended to messages, and the entire list is resent on every single turn, so a conversation with many tool calls grows the message history, and the token cost of every subsequent call, quite quickly. For a short agent run like the ones above this barely matters, but it is worth seeing the fix before it becomes a problem in a longer-running agent.
def trim_messages(messages, keep_last=12):
system = messages[0]
rest = messages[1:]
if len(rest) > keep_last:
rest = rest[-keep_last:]
return [system] + rest
This keeps the system message, which carries the agent's instructions, plus only the most recent keep_last messages, dropping older ones outright. Call it right before each model request inside run_agent, replacing messages=messages in the chat.completions.create call with messages=trim_messages(messages). This is blunt truncation, not summarization: if a detail mentioned twenty messages ago falls outside the window, the model genuinely has no way to know it anymore. A more careful version, closer to the notes approach described in part 6, would summarize dropped messages into a short line before discarding them rather than deleting them outright. For a short tool-using agent that answers one question per run, simple truncation like this is usually enough, since the whole exchange rarely grows past a handful of turns; for anything longer-lived, revisit part 6 and part 7 before relying on truncation alone.
Watching a full run end to end
Putting every piece together, here is one request that touches all of it: two read-only tools, a draft, and a send gated behind approval, so you can see calculation, lookup, drafting and the human checkpoint happen in a single run. The question is deliberately a bit long so that it needs several tool calls in sequence rather than just one.
question = (
"Work out 7 times 6, then look up the Acme Robotics office hours, "
"then draft an email to the ceo at jordan@acme-robotics.example telling "
"them the number and the office hours, and send it once drafted."
)
answer = run_agent(question)
print("\nFinal answer:\n", answer)
A plausible, illustrative transcript for this run looks like the one below. Your own run will differ in wording and possibly in how the tool calls are grouped across turns, since that grouping is the model's choice, not something the script controls directly.
[turn 1] calling calculator({'expression': '7 * 6'})
[turn 1] calling lookup_fact({'topic': 'office hours'})
[turn 2] calling draft_email({'to': 'jordan@acme-robotics.example', 'subject': 'Numbers and hours', 'body': '7 times 6 is 42. Our office hours are 09:00 to 17:00 UTC on weekdays.'})
The agent wants to call 'send_email' with arguments: {'to': 'jordan@acme-robotics.example', 'subject': 'Numbers and hours', 'body': '7 times 6 is 42. Our office hours are 09:00 to 17:00 UTC on weekdays.'}
Allow this action? [y/N] y
Final answer:
Done. I calculated 7 times 6 as 42, looked up our office hours (09:00 to
17:00 UTC on weekdays), drafted an email with both, and sent it to the CEO.
Notice the shape of the turns here. Turn 1 batches the two read-only tool calls together, because nothing about the second depends on the result of the first. Turn 2 waits until after both results are known before drafting the email, because the email body needs both numbers. The approval prompt for send_email happens outside the model's control entirely, inside run_tool, which is exactly where part 9 says that kind of checkpoint belongs: not something the model decides to ask for, but something your code enforces regardless of what the model wants.
What this script does not have, on purpose
- Persistence across separate runs. There is no memory file or database here, so every call to run_agent starts cold with just a system prompt and the new user message. Part 6 covers how you would add short-term and long-term memory on top of this loop.
- An explicit upfront plan. The model decides what to do one step at a time, reacting to each tool result as it arrives, rather than writing out a multi-step plan before acting. Part 5 covered when writing a plan first is worth the extra complexity, typically when steps depend on each other in ways that are hard to discover through trial and error.
- Automatic retries with backoff on network errors or rate limits from the provider itself. A script you actually run against real traffic should wrap client.chat.completions.create in its own retry logic; this article leaves that out so the loop stays readable, but leaving it out of anything you rely on would be a mistake.
- Tracing, structured logging, or any user interface. Print statements are enough to learn how the loop behaves, not enough to operate or debug it once something more complex than this is running regularly.
None of this is a flaw in the approach; it is the honest size of the problem this particular script solves. Every item on that list is also exactly what a framework, as discussed in part 10, would hand you for free, at the cost of learning its abstractions. Having built the loop by hand, you are now in a much better position to judge whether that trade is worth it for a given project, because you know precisely what you would be buying.
Common mistakes when you build this yourself
- No ceiling on iterations. Without a MAX_ITERATIONS-style limit, a model that gets confused can call tools in a loop indefinitely, and you only notice when the bill arrives or the process hangs.
- Using eval() instead of a restricted parser for anything that touches model-generated text. eval() can run arbitrary code; always parse and whitelist, as the calculator function above does with the ast module.
- Letting a tool call raise an uncaught exception. One bad argument then crashes the entire script instead of becoming a message the model can react to. Wrap every tool call in try/except, as run_tool does here.
- Forgetting to append the assistant's tool-calling turn to the message history before appending the tool results. Skipping that step leaves the next request missing the context of why those results exist, and can cause the model to repeat the same call.
- Skipping the human approval gate 'just for this test run'. It is easy to comment out the input() call while debugging and forget to restore it. Keep sensitive tools behind the gate in every environment, not only in production, since a test run that sends a real email is still a real email.
- Trusting arguments at face value once they pass the JSON Schema check. The model can pass a well-formed but wrong value, such as a negative quantity or a malformed address; a schema only checks shape, not meaning, so validate inside the tool function itself.
- Letting the message history grow without bound across a long session. Costs rise, and past a point, older and possibly irrelevant turns can crowd out the current question. Trim or summarize, as in step 6.
- Treating 'the model stopped calling tools' as proof the answer is correct. The loop ending only means the model decided it was done, not that it was right. This script, like any agent, still needs the kind of evaluation and checking covered elsewhere in this path.
The complete script
Here is everything from the steps above assembled into one file, read top to bottom. It has no dependency beyond the openai package and the Python standard library.
import ast
import json
import operator
from datetime import datetime
from openai import OpenAI
client = OpenAI()
MODEL = "gpt-4o-mini"
# --- tool implementations -------------------------------------------------
_ALLOWED_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.USub: operator.neg,
}
def _safe_eval(node):
if isinstance(node, ast.Constant):
if isinstance(node.value, (int, float)):
return node.value
raise ValueError("only numbers are allowed")
if isinstance(node, ast.BinOp) and type(node.op) in _ALLOWED_OPS:
return _ALLOWED_OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in _ALLOWED_OPS:
return _ALLOWED_OPS[type(node.op)](_safe_eval(node.operand))
raise ValueError(f"unsupported expression: {ast.dump(node)}")
def calculator(expression: str) -> dict:
try:
tree = ast.parse(expression, mode="eval")
return {"result": _safe_eval(tree.body)}
except Exception as exc:
return {"error": f"could not evaluate '{expression}': {exc}"}
def get_current_time() -> dict:
return {"current_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
_KNOWLEDGE_BASE = {
"founding year": "Acme Robotics (a fictional company used for this exercise) was founded in 2017.",
"office hours": "Acme Robotics support is staffed from 09:00 to 17:00 UTC on weekdays.",
"ceo": "The fictional CEO of Acme Robotics in this exercise is Jordan Lee.",
}
def lookup_fact(topic: str) -> dict:
key = topic.strip().lower()
if key in _KNOWLEDGE_BASE:
return {"fact": _KNOWLEDGE_BASE[key]}
return {"error": f"no entry for '{topic}'. Known topics: {list(_KNOWLEDGE_BASE)}"}
def draft_email(to: str, subject: str, body: str) -> dict:
return {"to": to, "subject": subject, "body": body, "status": "drafted"}
_SENT_EMAILS = []
def send_email(to: str, subject: str, body: str) -> dict:
_SENT_EMAILS.append({"to": to, "subject": subject, "body": body})
return {"status": "sent", "to": to}
# --- schemas and registry --------------------------------------------------
TOOLS = [
{"type": "function", "function": {
"name": "calculator",
"description": "Evaluate a basic arithmetic expression with +, -, *, /, ** and parentheses.",
"parameters": {"type": "object", "properties": {
"expression": {"type": "string", "description": "e.g. '(3 + 4) * 2'"}},
"required": ["expression"]},
}},
{"type": "function", "function": {
"name": "get_current_time",
"description": "Return the current local date and time.",
"parameters": {"type": "object", "properties": {}},
}},
{"type": "function", "function": {
"name": "lookup_fact",
"description": "Look up a fact about the fictional company Acme Robotics. Topics: 'founding year', 'office hours', 'ceo'.",
"parameters": {"type": "object", "properties": {"topic": {"type": "string"}},
"required": ["topic"]},
}},
{"type": "function", "function": {
"name": "draft_email",
"description": "Compose an email without sending it.",
"parameters": {"type": "object", "properties": {
"to": {"type": "string"}, "subject": {"type": "string"}, "body": {"type": "string"}},
"required": ["to", "subject", "body"]},
}},
{"type": "function", "function": {
"name": "send_email",
"description": "Actually send a previously drafted email. This has a real side effect.",
"parameters": {"type": "object", "properties": {
"to": {"type": "string"}, "subject": {"type": "string"}, "body": {"type": "string"}},
"required": ["to", "subject", "body"]},
}},
]
TOOL_FUNCTIONS = {
"calculator": calculator,
"get_current_time": get_current_time,
"lookup_fact": lookup_fact,
"draft_email": draft_email,
"send_email": send_email,
}
SENSITIVE_TOOLS = {"send_email"}
def run_tool(name: str, arguments: dict) -> dict:
if name not in TOOL_FUNCTIONS:
return {"error": f"unknown tool '{name}'"}
if name in SENSITIVE_TOOLS:
print(f"\nThe agent wants to call '{name}' with arguments: {arguments}")
if input("Allow this action? [y/N] ").strip().lower() != "y":
return {"error": "the user declined this action"}
try:
return TOOL_FUNCTIONS[name](**arguments)
except Exception as exc:
return {"error": f"tool '{name}' raised an exception: {exc}"}
# --- the loop ---------------------------------------------------------------
SYSTEM_PROMPT = (
"You are a helpful assistant with access to tools. Use calculator for "
"arithmetic, get_current_time for the current time, lookup_fact for facts "
"about Acme Robotics, draft_email to compose an email and send_email to "
"actually send one. Only call a tool when you need it. When you have the "
"final answer, reply in plain text without calling a tool."
)
MAX_ITERATIONS = 6
def trim_messages(messages, keep_last=12):
system, rest = messages[0], messages[1:]
if len(rest) > keep_last:
rest = rest[-keep_last:]
return [system] + rest
def run_agent(user_message: str) -> str:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_message},
]
for turn in range(1, MAX_ITERATIONS + 1):
response = client.chat.completions.create(
model=MODEL,
messages=trim_messages(messages),
tools=TOOLS,
tool_choice="auto",
)
message = response.choices[0].message
if message.tool_calls:
messages.append({
"role": "assistant",
"content": message.content,
"tool_calls": [tc.model_dump() for tc in message.tool_calls],
})
for tool_call in message.tool_calls:
name = tool_call.function.name
try:
arguments = json.loads(tool_call.function.arguments)
except json.JSONDecodeError:
arguments = {}
print(f"[turn {turn}] calling {name}({arguments})")
result = run_tool(name, arguments)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result),
})
else:
return message.content
return "Stopped after reaching the maximum number of turns without a final answer."
if __name__ == "__main__":
question = (
"What is 18 times 24? Then tell me what time it is right now, "
"and look up who the CEO of Acme Robotics is."
)
print("\nFinal answer:\n", run_agent(question))
Roughly a hundred and twenty lines, and every behaviour described in this article is in there somewhere: the loop structure from part 2, the tool schemas from part 3, the error handling from part 8, and the approval gate from part 9. Nothing is hidden behind an import you have not read.
Summary and what's next
You built a working agent in plain Python: a loop that sends messages to a model, reads whether it asked for a tool, runs that tool from a small registry, feeds the result back, and repeats until the model is done or a turn limit is reached. Along the way you added error handling so a bad argument or an unknown tool name never crashes the script, a human approval gate so an action with a real side effect always pauses for a yes or no, and a simple truncation step to keep the message history from growing without bound. None of it came from a framework; all of it is ordinary control flow you can read top to bottom and change with confidence, because you know exactly what every line does.
That is also the real value of this exercise even if you choose a framework for your next project: you now know what a framework's agent loop is doing underneath its abstractions, which makes it far easier to debug when something goes wrong and far easier to judge whether its defaults match the stopping conditions, error handling and approval gates you actually need, as discussed in part 10.
Part 12 takes this same pattern and builds a real small project on top of it: a research assistant that searches, reads, and cites its sources, replacing the toy tools used here to practice the mechanics with tools that do genuine work.
Comments (0)
No comments yet. Be the first to share your thoughts.