I ship AI agents at work and the part nobody warns you about is the tool layer. Most examples show a single function call. Real agents need to pick tools, call them, handle failures, and keep going. This post is about building that loop in Python without a heavy framework.
Why a hand rolled loop
Frameworks are nice but they hide the control flow. When something breaks at 2am you want to understand the loop. A small custom loop is easy to debug and easy to test. You keep full control of retries and logging.
What we need
The agent needs three things. A list of tools with schemas. A way to call the LLM. A loop that reads the model output and runs tools. We will use OpenAI compatible chat completions and Pydantic for tool definitions.
Defining tools
Each tool is a Python callable with a name, description, and JSON schema for arguments. I like a simple dataclass.
from dataclasses import dataclass
from typing import Callable, Any
@dataclass
class Tool:
name: str
description: str
parameters: dict
func: Callable[..., Any]A real tool can wrap an API or a database query. Here is a weather stub.
def get_weather(city: str) -> str:
# pretend this calls a real service
return f"It is 22C and sunny in {city}."
weather_tool = Tool(
name="get_weather",
description="Get current weather for a city",
parameters={
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
func=get_weather,
)Calling the model
We send the tools as function schemas. The model replies with a function call or normal text. I use the openai client but any compatible server works.
from openai import OpenAI
client = OpenAI()
def chat_with_tools(messages, tools):
return client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=[{"type": "function", "function": {
"name": t.name,
"description": t.description,
"parameters": t.parameters,
}} for t in tools],
tool_choice="auto",
)The agent loop
This is the core. We cap iterations to avoid infinite loops. If the model calls a tool we run it and feed the result back. If it returns text we stop.
from typing import List, Dict
def run_agent(user_input: str, tools: List[Tool], max_steps: int = 5):
tool_map = {t.name: t for t in tools}
messages: List[Dict] = [{"role": "user", "content": user_input}]
for step in range(max_steps):
resp = chat_with_tools(messages, tools)
msg = resp.choices[0].message
if msg.tool_calls:
messages.append(msg.model_dump())
for call in msg.tool_calls:
tool = tool_map[call.function.name]
args = json.loads(call.function.arguments)
try:
result = tool.func(**args)
except Exception as e:
result = f"Error: {e}"
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": str(result),
})
else:
return msg.content
return "Agent stopped without a final answer."Note the try except around the tool call. Tools fail in production. The model can often recover if you give it the error string.
Adding a simple calculator
One tool is boring. Let's add a safe calculator so the agent can do math.
def calc(expression: str) -> str:
allowed = set("0123456789+-*/(). ")
if not set(expression) <= allowed:
raise ValueError("Bad characters in expression")
return str(eval(expression, {"__builtins__": {}}, {}))
calc_tool = Tool(
name="calc",
description="Evaluate a math expression",
parameters={
"type": "object",
"properties": {"expression": {"type": "string"}},
"required": ["expression"],
},
func=calc,
)Running it
Wire the tools and ask a mixed question.
import json
tools = [weather_tool, calc_tool]
answer = run_agent("What is 15 times the temperature in Berlin if it is 20C there?", tools)
print(answer)The agent should call get_weather for Berlin, then calc with 15 * 20. You can print the messages list to see each step. That visibility is the main reason I avoid big frameworks.
Failure modes I have hit
The model sometimes calls a tool with the wrong argument type. JSON parsing fails. I now validate args with Pydantic before calling the function. Another issue is the model looping on the same wrong call. The step cap stops that. You can also track repeated tool calls and break early.
A small improvement
Add a short system prompt that tells the model to use tools when needed and to stop when done. Keep it one or two sentences. Long prompts confuse the loop.
When to use a framework
If you need streaming, parallel tools, or complex memory, a framework saves time. For a backend job or a CLI assistant, this loop is enough. I have run variants of it in production for months with low maintenance.
Building the loop yourself forces you to understand agent behavior. Start small, log every step, and add tools as needed. That approach has worked better for me than adopting a large library on day one.