How to Build Your First AI Agent in Python
Source: mortalapps.com- A step-by-step guide to constructing an autonomous agent using raw Python and the OpenAI SDK without high-level framework abstractions.
- Solves the 'black box' problem of agent frameworks by exposing the core loop, tool calling, and state management directly.
- Crucial for production engineering to understand execution flow, token overhead, and error propagation before adopting orchestrators.
- Delivers a fully functional, self-correcting agent that executes local tools, handles API failures, and manages conversation state.
Why This Matters
Learning how to build an ai agent python developers can easily maintain is the foundational milestone for any AI engineer. While high-level frameworks like LangChain, CrewAI, or AutoGen offer rapid prototyping capabilities, they introduce significant architectural abstractions that obscure the underlying mechanics of tool calling, state management, and error recovery. In production environments, these abstractions often manifest as hard-to-debug latency spikes, unhandled token limits, and unpredictable loop behaviors. By building an agent using raw Python and the native OpenAI SDK, engineers gain direct visibility into the execution lifecycle. This low-level approach was invented to demystify how LLMs interact with external systems. Without this fundamental understanding, production systems risk catastrophic failures such as infinite execution loops, unmonitored token consumption, and silent failures during tool execution. Choosing a framework-free implementation is highly recommended when building deterministic workflows, optimizing execution latency, or deploying to highly constrained runtime environments where minimal dependencies are required. It establishes a clear mental model of the ReAct (Reasoning and Acting) loop, enabling developers to write precise error-handling routines, implement custom state persistence, and accurately track token costs per run. This knowledge acts as a prerequisite for evaluating when to adopt or bypass complex agentic frameworks.
Core Concepts
To build an agent from scratch, you must master five core concepts that govern the lifecycle of any autonomous LLM-based system:
- The ReAct (Reasoning and Acting) Loop: The execution cycle where the LLM alternates between reasoning (thinking about what to do) and acting (calling a tool or returning a final answer).
- Tool Definition (JSON Schema): The structured metadata passed to the LLM that describes a function's name, purpose, and parameter types, allowing the model to generate valid arguments.
- Tool Call Execution: The process of parsing the LLM's structured output, matching it to a local Python function, executing that function, and formatting the output for the model.
- Message History (State): An append-only list of messages (system, user, assistant, and tool) that maintains context across multiple iterations of the loop.
- System Prompt Boundaries: The foundational instructions that define the agent's persona, guardrails, execution steps, and termination criteria.
| Concept | Responsibility | Failure Mode if Missing |
|---|---|---|
| Message History | Preserves context and tool execution results | Model forgets previous steps, leading to infinite loops |
| Tool Registry | Maps LLM tool-call names to executable Python functions | Model requests a tool that the application cannot execute |
| Loop Guard | Limits the maximum number of iterations | Infinite execution loops consuming unlimited API tokens |
How It Works
The raw Python agent operates on a synchronous, stateful execution loop. The workflow is entirely deterministic outside of the LLM's generation step.
Phase 1: Initialization
Execution begins by instantiating the OpenAI client and defining the conversation state. The state is initialized as a list containing the system prompt and the user's initial query. The system prompt instructs the model on how to use the provided tools and how to format its reasoning.
Phase 2: The LLM Query
The application enters a bounded loop. In each iteration, the application sends the entire message history along with the list of tool definitions to the OpenAI Chat Completions API. The model evaluates the state and decides whether it needs to call a tool to answer the query or if it can provide a final answer directly.
Phase 3: Parsing and Routing
If the model decides to call a tool, it returns a response with a tool_calls payload containing the function name and JSON-formatted arguments. The application intercepts this response, appends it to the message history, and parses the arguments. If the tool name exists in the local tool registry, the application executes the corresponding Python function with the parsed arguments.
Phase 4: State Update and Re-evaluation
The output of the Python function is captured, wrapped in a message with the role tool, and appended to the message history. The loop then restarts, sending the updated history (now containing the tool's output) back to the LLM. The model reads this output and determines if it has enough information to formulate the final response. If it does, it returns a text answer without any tool calls, which breaks the loop and returns the result to the user.
Failure Paths
If the LLM generates invalid JSON for tool arguments, the parsing step fails. The application must catch this exception, format the error message as a tool response, and send it back to the LLM to request a self-correction. If a tool execution fails due to external factors (e.g., network timeout), the error is caught, formatted, and sent back to the LLM as a tool result so the model can attempt an alternative path.
Architecture
The architecture of a framework-free Python agent consists of five tightly coupled components operating in a closed loop. Execution starts when the User Input is received and ends when the Final Answer is returned.
- Conversation State (Message Store): An in-memory list that acts as the single source of truth. It stores the sequence of System, User, Assistant, and Tool messages.
- LLM Client (OpenAI API): The reasoning engine. It receives the Conversation State and Tool Definitions, returning either a text response or a structured Tool Call request.
- Tool Registry: A local Python dictionary mapping string identifiers (e.g., 'get_weather') to actual Python executable functions and their JSON schemas.
- Local Tool Executor: The component that parses the LLM's tool call request, extracts the arguments, executes the mapped function from the Tool Registry, and handles runtime exceptions.
- Agent Loop Controller: The orchestrator containing the
whileloop, iteration counter, and guardrails. It manages the flow of data between the LLM Client, the Tool Executor, and the Conversation State, enforcing maximum iteration limits to prevent infinite loops.
Designing the Tool Registry and JSON Schemas
To allow an LLM to interact with your Python environment, you must describe your functions using JSON Schema. This schema tells the model what the function does and what arguments it expects. The OpenAI API uses these schemas to constrain its output generation.
Here is how a standard Python function is translated into a tool definition:
def calculate_percentage(part: float, total: float) -> float:
if total == 0:
raise ValueError("Total cannot be zero.")
return (part / total) * 100
The corresponding JSON schema passed to the OpenAI SDK must look like this:
{
"type": "function",
"function": {
"name": "calculate_percentage",
"description": "Calculates the percentage of a part relative to a total.",
"parameters": {
"type": "object",
"properties": {
"part": {
"type": "number",
"description": "The numerator value."
},
"total": {
"type": "number",
"description": "The denominator value."
}
},
"required": ["part", "total"]
}
}
}
In a production system, maintaining these schemas manually is error-prone. You can automate this using Pydantic or type hints, but understanding the raw JSON structure is critical because it is what actually travels over the wire to the model endpoint.
The Mechanics of the ReAct Loop
The core of any agent is the execution loop. In raw Python, this is implemented using a while loop guarded by a maximum iteration counter. Without this guard, a model that gets confused or encounters a persistent error will repeatedly call tools, rapidly exhausting your API quota.
During each iteration, the loop performs the following operations:
- Call LLM: Send the current message list and tool schemas to the API.
- Check for Tool Calls: Inspect the
message.tool_callsattribute of the response. - Process Tool Calls (if present): Loop through each requested tool call, execute the local function, and append a
toolmessage to the history. - Process Final Response (if no tool calls): If
tool_callsis empty, the model has completed its task. Return the text content and terminate the loop.
Handling JSON Parsing and Execution Failures
When building an agent without a framework, you must write explicit exception handling for two distinct failure modes: model-generation errors and tool-execution errors.
Model-Generation Errors
Sometimes, the LLM will attempt to call a tool but generate invalid JSON for the arguments, or it will call a tool that does not exist in your registry. Your code must catch these errors:
try:
args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError as e:
# Feed the JSON error back to the model so it can self-correct
error_message = f"Failed to parse arguments: {str(e)}. Please provide valid JSON."
append_tool_error_to_state(tool_call.id, error_message)
continue
Tool-Execution Errors
If the local Python function throws an exception (e.g., a database connection timeout), you should not let the entire agent crash. Instead, catch the exception, format it as a string, and send it back to the LLM. This allows the model to reason about the failure and potentially try a different approach or report the error gracefully to the user.
Managing State and Context Window Limits
Every turn in the agent loop appends at least two messages to the history: the assistant's tool call request and the tool's execution result. As the loop runs, the message history grows rapidly. This causes two problems: increased latency and context window exhaustion.
To mitigate this in a raw Python implementation, you must implement a context management strategy. The simplest approach is a sliding window that keeps the system prompt and the last $N$ messages, or a summarization step that condenses older tool executions once the token count exceeds a specific threshold.
Code Example
import os
import json
import logging
from typing import Dict, Any, List, Callable
from openai import OpenAI
# Configure logging for production observability
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Initialize the OpenAI client using environment variables
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
raise ValueError("CRITICAL: OPENAI_API_KEY environment variable is not set.")
client = OpenAI(api_key=api_key)
# Define local tools
def get_user_balance(user_id: str) -> str:
"""Mock database lookup for user balance."""
db = {
"usr_90210": {"balance": 1500.50, "currency": "USD"},
"usr_44321": {"balance": 45.00, "currency": "USD"}
}
user_data = db.get(user_id)
if not user_data:
raise ValueError(f"User {user_id} not found in database.")
return json.dumps(user_data)
# Tool registry mapping names to executable functions
TOOL_REGISTRY: Dict[str, Callable] = {
"get_user_balance": get_user_balance
}
# Tool definitions matching OpenAI's JSON Schema format
TOOLS_SCHEMA = [
{
"type": "function",
"function": {
"name": "get_user_balance",
"description": "Retrieves the current account balance and currency for a given user ID.",
"parameters": {
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "The unique identifier of the user (e.g., usr_90210)."
}
},
"required": ["user_id"]
}
}
}
]
def run_agent(user_prompt: str, max_iterations: int = 5) -> str:
# Initialize conversation state with system instructions and user query
messages: List[Dict[str, Any]] = [
{
"role": "system",
"content": (
"You are a precise financial assistant agent. Use the provided tools to answer "
"user questions. Always verify user IDs before reporting balances. If a tool "
"returns an error, explain the issue clearly to the user."
)
},
{"role": "user", "content": user_prompt}
]
iteration = 0
while iteration < max_iterations:
iteration += 1
logger.info(f"Starting agent loop iteration {iteration}/{max_iterations}")
try:
# Query the LLM with current state and available tools
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=TOOLS_SCHEMA,
tool_choice="auto",
temperature=0.0 # Low temperature for deterministic tool calling
)
except Exception as e:
logger.error(f"OpenAI API call failed: {str(e)}")
return f"Error: Failed to communicate with the reasoning engine. Details: {str(e)}"
response_message = response.choices[0].message
tool_calls = response_message.tool_calls
# If no tool calls are requested, the agent has finished its reasoning
if not tool_calls:
logger.info("Agent reached final answer.")
return response_message.content or "No response generated."
# Append the assistant's response (containing tool calls) to preserve state
messages.append(response_message)
# Process each tool call requested by the model
for tool_call in tool_calls:
tool_name = tool_call.function.name
tool_id = tool_call.id
logger.info(f"Model requested tool call: {tool_name} with ID: {tool_id}")
if tool_name not in TOOL_REGISTRY:
error_msg = f"Error: Tool '{tool_name}' is not registered in this system."
logger.warning(error_msg)
messages.append({
"role": "tool",
"tool_call_id": tool_id,
"content": error_msg
})
continue
# Parse arguments and execute the tool
try:
tool_args = json.loads(tool_call.function.arguments)
tool_function = TOOL_REGISTRY[tool_name]
# Execute the local Python function
tool_result = tool_function(**tool_args)
logger.info(f"Tool {tool_name} executed successfully.")
except json.JSONDecodeError as je:
tool_result = f"Error: Failed to parse arguments as valid JSON. Details: {str(je)}"
logger.error(tool_result)
except Exception as te:
tool_result = f"Error during execution of {tool_name}: {str(te)}"
logger.error(tool_result)
# Append the tool execution result to the conversation state
messages.append({
"role": "tool",
"tool_call_id": tool_id,
"content": tool_result
})
logger.warning("Agent terminated due to reaching maximum iteration limit.")
return "Error: The agent could not resolve your request within the maximum execution steps."
if __name__ == "__main__":
# Test case 1: Successful tool execution
query_1 = "Can you check the balance for user usr_90210?"
print(f"Query: {query_1}")
result_1 = run_agent(query_1)
print(f"Result: {result_1}
")
# Test case 2: Handling a tool execution error gracefully
query_2 = "Check the balance for user usr_99999."
print(f"Query: {query_2}")
result_2 = run_agent(query_2)
print(f"Result: {result_2}")
Query: Can you check the balance for user usr_90210? Starting agent loop iteration 1/5 Model requested tool call: get_user_balance with ID: call_abc123 Tool get_user_balance executed successfully. Starting agent loop iteration 2/5 Agent reached final answer. Result: The balance for user usr_90210 is $1,500.50 USD. Query: Check the balance for user usr_99999. Starting agent loop iteration 1/5 Model requested tool call: get_user_balance with ID: call_xyz789 Error during execution of get_user_balance: User usr_99999 not found in database. Starting agent loop iteration 2/5 Agent reached final answer. Result: I encountered an error while trying to retrieve the balance: User usr_99999 was not found in the database.