← AI Agents Foundations
🤖 AI Agents

How to Build Your First AI Agent in Python

Source: mortalapps.com
TL;DR
  • 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.

  1. 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.
  2. 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.
  3. Tool Registry: A local Python dictionary mapping string identifiers (e.g., 'get_weather') to actual Python executable functions and their JSON schemas.
  4. 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.
  5. Agent Loop Controller: The orchestrator containing the while loop, 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:

  1. Call LLM: Send the current message list and tool schemas to the API.
  2. Check for Tool Calls: Inspect the message.tool_calls attribute of the response.
  3. Process Tool Calls (if present): Loop through each requested tool call, execute the local function, and append a tool message to the history.
  4. Process Final Response (if no tool calls): If tool_calls is 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

A complete, production-grade AI agent implemented in raw Python using the OpenAI SDK. It features a robust ReAct loop, tool registration, strict error handling, and iteration guards.
Python
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}")
Expected Output
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.

Key Takeaways

Building an agent in raw Python exposes the underlying mechanics of tool calling, state management, and error handling without framework magic.
The ReAct loop is a stateful, iterative process where the LLM decides to call tools or return a final answer based on the conversation history.
A robust agent loop must include a hard iteration limit to prevent infinite loops and runaway API costs.
Tool definitions are written as JSON Schemas, which the LLM uses to generate structured arguments for local function execution.
Exception handling is critical; both JSON parsing errors and tool execution failures must be caught and returned to the LLM as feedback.
Production agents require asynchronous execution, external state persistence (like Redis), and strict token/cost tracking per run.

Frequently Asked Questions

Why should I build an agent from scratch instead of using LangChain or CrewAI? +
Building from scratch eliminates unnecessary abstractions, reduces dependency overhead, simplifies debugging, and gives you complete control over state management, latency optimization, and error recovery.
How does the LLM know which tool to call? +
The LLM selects a tool by matching the user's request against the names and descriptions provided in the tool's JSON Schema. Clear, distinct tool descriptions are critical for accurate selection.
What happens if the LLM generates invalid arguments for a tool? +
Your code must catch the JSON decoding exception, format the error message as a string, and append it to the message history as a tool response. This prompts the LLM to self-correct and try again.
How do I prevent my agent from getting stuck in an infinite loop? +
Implement a strict iteration counter in your `while` loop. If the counter exceeds a set limit (e.g., 5 or 10 iterations) without reaching a final answer, terminate the execution and return an error.
Where should I store the agent's conversation state in production? +
Do not store state in-memory. Use a low-latency, external database like Redis or PostgreSQL to serialize and persist the message history across stateless application restarts.
Can I run multiple tool calls in parallel? +
Yes. The OpenAI API can return multiple tool calls in a single response. You can parse these calls and execute them concurrently using Python's `asyncio` or `concurrent.futures`.
How do I secure my tools from malicious user inputs? +
Never trust user inputs. Implement strict input validation inside your tools, enforce Role-Based Access Control (RBAC) to verify user permissions, and run any code execution tools in isolated sandboxes.
What temperature setting is best for agentic workflows? +
Set the temperature to 0.0. This ensures deterministic outputs, making tool calling highly reliable and reducing the chance of syntax errors in generated arguments.
How do I handle API rate limits (HTTP 429) during an agent run? +
Wrap your LLM API calls in a retry mechanism that uses exponential backoff with jitter, ensuring that temporary rate limits do not crash the active agent execution.