ai-agentstutorialpythonlangchainopenai

Build Your First AI Agent from Scratch - Part 2: Creating the Basic Agent Structure

By AgentForge Hub1/8/202523 min read
Beginner
Build Your First AI Agent from Scratch - Part 2: Creating the Basic Agent Structure

Ad Space

Build Your First AI Agent from Scratch - Part 2: Creating the Basic Agent Structure

Welcome back to your AI agent journey! In Part 1, we built your agent’s home base — a complete development environment ready for action. Now, in Part 2, we step into the real building phase. We’re going to give your agent its bones — the foundational structure that will define how it thinks, talks, and keeps track of conversations. By the end of this part, your agent will be able to chat with you through a working interface, log its activity, and handle basic tasks like a pro.

What You'll Build in This Tutorial

By the end of this tutorial, you'll have:

  • âś… A structured AI agent class with proper initialization
  • âś… Basic conversation handling with OpenAI's API
  • âś… Comprehensive logging and error handling
  • âś… A command-line interface for testing your agent
  • âś… Configuration management system
  • âś… Unit tests for your agent functionality

Each step in this part builds directly on the last, so by the time we’re done, you’ll have a working agent you can interact with and expand in the next parts of the series.

Estimated Time: 25–30 minutes


Step 1: Creating the Configuration System

Before we can teach our agent to think, we need to make sure it knows who it is and how it should behave. That’s where configuration comes in. A good configuration system is like the agent’s personal blueprint — defining its personality, settings, and secret keys. Before we dive into building the AI’s brain, let’s set up this solid foundation. A proper configuration system is crucial because it:

  • Separates sensitive data (API keys) from code
  • Makes the agent configurable without code changes
  • Enables different settings for development vs production
  • Provides validation to catch configuration errors early

Understanding Configuration Architecture

Our configuration system will use a dataclass-based approach with environment variable loading. This pattern is widely used in production applications because it's:

  1. Type-safe - Python's type hints catch errors at development time
  2. Flexible - Easy to add new configuration options
  3. Secure - Sensitive data stays in environment variables
  4. Testable - Easy to mock configurations for testing

Create Configuration Module

Create src/utils/config.py:


"""
Configuration management for AI Agent

This module handles all configuration loading and validation for our AI agent.
It uses environment variables for security and flexibility, with sensible defaults.
"""

import os
from typing import Optional, Dict, Any
from dataclasses import dataclass
from dotenv import load_dotenv

# Load environment variables from .env file
# This must happen before we try to access any environment variables
load_dotenv()

@dataclass
class AgentConfig:
    """
    Configuration class for AI Agent
    
    This dataclass holds all configuration parameters for our agent.
    Using a dataclass provides:
    - Type hints for better IDE support and error catching
    - Automatic __init__ method generation
    - Built-in __repr__ for debugging
    - Immutable-by-default behavior
    """
    
    # OpenAI API Configuration
    # These settings control how we interact with OpenAI's API
    openai_api_key: str              # Your secret API key from OpenAI
    openai_model: str = "gpt-4"      # Which model to use (gpt-4, gpt-3.5-turbo, etc.)
    max_tokens: int = 1000           # Maximum response length
    temperature: float = 0.7         # Creativity level (0.0 = deterministic, 2.0 = very creative)
    
    # Agent Personality Configuration
    # These settings define your agent's identity and behavior
    agent_name: str = "AI Assistant"
    agent_description: str = "A helpful AI agent"
    system_prompt: str = "You are a helpful AI assistant."
    
    # Logging Configuration
    # Controls how much information is logged and in what format
    log_level: str = "INFO"
    log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
    
    # Development Settings
    debug: bool = False              # Enables extra logging and error details
    
    @classmethod
    def from_env(cls) -> 'AgentConfig':
        """
        Create configuration from environment variables
        
        This factory method reads environment variables and creates a properly
        configured AgentConfig instance. It handles type conversion and
        provides sensible defaults for optional settings.
        
        Returns:
            AgentConfig: Configured instance
            
        Raises:
            ValueError: If required environment variables are missing
        """
        
        # Check for required environment variables
        # The OpenAI API key is the only truly required configuration
        openai_api_key = os.getenv('OPENAI_API_KEY')
        if not openai_api_key:
            raise ValueError(
                "OPENAI_API_KEY environment variable is required. "
                "Get your API key from https://platform.openai.com/api-keys"
            )
        
        return cls(
            # OpenAI settings with type conversion and defaults
            openai_api_key=openai_api_key,
            openai_model=os.getenv('OPENAI_MODEL', 'gpt-4'),
            max_tokens=int(os.getenv('MAX_TOKENS', '1000')),
            temperature=float(os.getenv('TEMPERATURE', '0.7')),
            
            # Agent personality settings
            agent_name=os.getenv('AGENT_NAME', 'AI Assistant'),
            agent_description=os.getenv('AGENT_DESCRIPTION', 'A helpful AI agent'),
            system_prompt=os.getenv('SYSTEM_PROMPT', 'You are a helpful AI assistant.'),
            
            # Logging settings
            log_level=os.getenv('LOG_LEVEL', 'INFO'),
            
            # Development settings
            debug=os.getenv('DEBUG', 'false').lower() == 'true'
        )
    
    def validate(self) -> None:
        """
        Validate configuration values
        
        This method checks that all configuration values are valid and safe to use.
        It's called automatically when creating a config instance to catch
        configuration errors early, before they cause runtime problems.
        
        Raises:
            ValueError: If any configuration value is invalid
        """
        
        # Validate OpenAI API key format
        # All OpenAI API keys start with 'sk-'
        if not self.openai_api_key.startswith('sk-'):
            raise ValueError(
                "Invalid OpenAI API key format. Keys should start with 'sk-'"
            )
        
        # Validate token limits
        # Negative or zero tokens don't make sense
        if self.max_tokens <= 0:
            raise ValueError("max_tokens must be positive")
        
        # Validate temperature range
        # OpenAI's API accepts values between 0 and 2
        if not 0 <= self.temperature <= 2:
            raise ValueError(
                "temperature must be between 0 and 2. "
                "0 = deterministic, 1 = balanced, 2 = very creative"
            )
        
        # Validate log level
        # Only standard Python logging levels are supported
        valid_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
        if self.log_level not in valid_levels:
            raise ValueError(f"Invalid log level. Must be one of: {valid_levels}")

def get_config() -> AgentConfig:
    """
    Get validated configuration instance
    
    This is the main function you'll use to get configuration in your code.
    It creates a config instance from environment variables and validates it.
    
    Returns:
        AgentConfig: A validated configuration instance
        
    Raises:
        ValueError: If configuration is invalid
    """
    config = AgentConfig.from_env()
    config.validate()
    return config

Why This Configuration Design?

Let's break down the key design decisions:

  1. Dataclass over regular class: Provides automatic __init__, __repr__, and type hints
  2. Environment variables: Keeps secrets out of code and allows easy deployment configuration
  3. Factory method pattern: from_env() encapsulates the complexity of loading from environment
  4. Validation method: Catches configuration errors early, with helpful error messages
  5. Type hints: Enables better IDE support and catches type-related bugs
  6. Sensible defaults: Most settings have defaults, only API key is required

Update Environment Variables

Update your .env file with additional configuration:

# OpenAI Configuration
OPENAI_API_KEY=your_openai_api_key_here
OPENAI_MODEL=gpt-4
MAX_TOKENS=1000
TEMPERATURE=0.7

# Agent Configuration
AGENT_NAME=MyFirstAgent
AGENT_DESCRIPTION=A helpful AI agent built from scratch
SYSTEM_PROMPT=You are a helpful AI assistant that can engage in conversations and help users with various tasks. Be friendly, informative, and concise in your responses.

# Development Settings
DEBUG=true
LOG_LEVEL=INFO


Step 2: Setting Up Logging

Now that our agent knows its identity and configuration, we need a way to listen in on its thoughts and actions. This is where logging comes in. Logging acts as the agent’s journal — recording what happens, when it happens, and how it reacts. It’s essential for any production application, especially AI agents that make external API calls and handle user interactions. A good logging system helps you:

  • Debug issues when things go wrong
  • Monitor performance and API usage
  • Track user interactions for analytics
  • Audit system behavior for security
  • Understand usage patterns to improve your agent

Why Rich Logging?

We'll use the Rich library for beautiful, structured logging that includes:

  • Colored output for different log levels
  • Syntax highlighting for code and data structures
  • Rich formatting with tables, panels, and progress bars
  • Traceback enhancement for better error debugging
  • Time stamps and file locations for context

Understanding Our Logging Architecture

Our logging system uses a singleton pattern with multiple handlers:

  1. Console Handler - Pretty output during development
  2. File Handler - Persistent logs for production
  3. Level-based filtering - Control verbosity
  4. Centralized configuration - One place to manage all logging

Create Logging Module

Create src/utils/logger.py:


"""
Logging configuration for AI Agent
"""

import logging
import sys
from typing import Optional
from pathlib import Path
from rich.logging import RichHandler
from rich.console import Console

def setup_logger(
    name: str,
    level: str = "INFO",
    log_file: Optional[str] = None,
    use_rich: bool = True
) -> logging.Logger:
    """
    Set up a logger with optional file output and rich formatting
    
    Args:
        name: Logger name
        level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
        log_file: Optional file path for log output
        use_rich: Whether to use rich formatting for console output
    
    Returns:
        Configured logger instance
    """
    
    logger = logging.getLogger(name)
    logger.setLevel(getattr(logging, level.upper()))
    
    # Clear existing handlers
    logger.handlers.clear()
    
    # Console handler
    if use_rich:
        console = Console()
        console_handler = RichHandler(
            console=console,
            show_time=True,
            show_path=True,
            markup=True
        )
        console_format = "%(message)s"
    else:
        console_handler = logging.StreamHandler(sys.stdout)
        console_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
    
    console_handler.setLevel(getattr(logging, level.upper()))
    console_formatter = logging.Formatter(console_format)
    console_handler.setFormatter(console_formatter)
    logger.addHandler(console_handler)
    
    # File handler (optional)
    if log_file:
        log_path = Path(log_file)
        log_path.parent.mkdir(parents=True, exist_ok=True)
        
        file_handler = logging.FileHandler(log_file)
        file_handler.setLevel(logging.DEBUG)  # Always log everything to file
        file_format = "%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s"
        file_formatter = logging.Formatter(file_format)
        file_handler.setFormatter(file_formatter)
        logger.addHandler(file_handler)
    
    return logger

class AgentLogger:
    """Centralized logger for the AI Agent"""
    
    _instance = None
    _logger = None
    
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance
    
    def __init__(self):
        if self._logger is None:
            self._logger = setup_logger(
                name="AIAgent",
                level="INFO",
                log_file="logs/agent.log",
                use_rich=True
            )
    
    @property
    def logger(self) -> logging.Logger:
        return self._logger
    
    def set_level(self, level: str):
        """Change logging level"""
        self._logger.setLevel(getattr(logging, level.upper()))
        for handler in self._logger.handlers:
            if isinstance(handler, logging.StreamHandler):
                handler.setLevel(getattr(logging, level.upper()))

# Convenience function
def get_logger() -> logging.Logger:
    """Get the agent logger instance"""
    return AgentLogger().logger


Step 3: Creating the Core Agent Class

With configuration and logging in place, we can finally bring our agent to life. This is where we build the heart of our AI agent — the BaseAgent class. This is the part of the journey where the abstract planning turns into real functionality: the agent will now be able to hold conversations, call the OpenAI API, and keep track of what’s been said. This is where the magic happens! This class will:

  • Manage conversations with multiple users simultaneously
  • Handle OpenAI API integration with proper error handling
  • Maintain conversation context and message history
  • Provide both sync and async interfaces for flexibility
  • Track statistics and agent state

Understanding Agent Architecture

Our agent uses several key design patterns:

  1. Message-based Communication - Each interaction is a structured message with metadata
  2. Conversation Context Management - Each conversation has its own isolated state
  3. Stateful Design - The agent remembers previous interactions within conversations
  4. Error Recovery - Graceful handling of API failures and network issues
  5. Extensible Structure - Easy to subclass and add new capabilities

Data Models First

Let's start by understanding the data structures that power our agent:

Message Model

Each message in our system is represented by a Message object that contains:

  • Role: Who sent it (user, assistant, system)
  • Content: The actual text
  • Timestamp: When it was created
  • Unique ID: For tracking and referencing
  • Metadata: Additional context (optional)

Conversation Context

A ConversationContext groups related messages together and provides:

  • Conversation Management: Add/retrieve messages
  • Format Conversion: Convert to OpenAI API format
  • History Management: Access recent messages
  • State Tracking: Created/updated timestamps

Create Base Agent Class

Create src/agents/base_agent.py:


"""
Base AI Agent implementation
"""

import asyncio
from typing import List, Dict, Any, Optional, AsyncGenerator
from dataclasses import dataclass
from datetime import datetime
import uuid

from openai import OpenAI, AsyncOpenAI
from openai.types.chat import ChatCompletion

from ..utils.config import AgentConfig
from ..utils.logger import get_logger

@dataclass
class Message:
    """Represents a conversation message"""
    role: str  # 'user', 'assistant', 'system'
    content: str
    timestamp: datetime
    message_id: str
    metadata_json: Optional[Dict[str, Any]] = None
    
    def to_dict(self) -> Dict[str, Any]:
        """Convert message to dictionary format"""
        return {
            "role": self.role,
            "content": self.content,
            "timestamp": self.timestamp.isoformat(),
            "message_id": self.message_id,
            "metadata_json": self.metadata_json or {}
        }
    
    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> 'Message':
        """Create message from dictionary"""
        return cls(
            role=data["role"],
            content=data["content"],
            timestamp=datetime.fromisoformat(data["timestamp"]),
            message_id=data["message_id"],
            metadata_json =data.get("metadata_json")
        )

@dataclass
class ConversationContext:
    """Manages conversation state and history"""
    conversation_id: str
    messages: List[Message]
    created_at: datetime
    updated_at: datetime
    metadata_json: Dict[str, Any]
    
    def add_message(self, role: str, content: str, metadata_json: Optional[Dict[str, Any]] = None) -> Message:
        """Add a new message to the conversation"""
        message = Message(
            role=role,
            content=content,
            timestamp=datetime.now(),
            message_id=str(uuid.uuid4()),
            metadata_json =metadata_json
        )
        self.messages.append(message)
        self.updated_at = datetime.now()
        return message
    
    def get_openai_messages(self) -> List[Dict[str, str]]:
        """Convert messages to OpenAI API format"""
        return [{"role": msg.role, "content": msg.content} for msg in self.messages]
    
    def get_recent_messages(self, limit: int = 10) -> List[Message]:
        """Get the most recent messages"""
        return self.messages[-limit:] if len(self.messages) > limit else self.messages

class BaseAgent:
    """
    Base AI Agent class that handles conversations with OpenAI's API
    """
    
    def __init__(self, config: AgentConfig):
        """
        Initialize the AI Agent
        
        Args:
            config: Agent configuration object
        """
        self.config = config
        self.logger = get_logger()
        
        # Initialize OpenAI clients
        self.client = OpenAI(api_key=config.openai_api_key)
        self.async_client = AsyncOpenAI(api_key=config.openai_api_key)
        
        # Agent state
        self.conversations: Dict[str, ConversationContext] = {}
        self.is_initialized = False
        
        self.logger.info(f"Initialized {config.agent_name}")
    
    def initialize(self) -> None:
        """Initialize the agent (can be overridden by subclasses)"""
        if self.is_initialized:
            return
        
        self.logger.info("Initializing agent...")
        
        # Test API connection
        try:
            response = self.client.chat.completions.create(
                model="gpt-3.5-turbo",
                messages=[{"role": "user", "content": "Hello"}],
                max_tokens=10
            )
            self.logger.info("âś… OpenAI API connection successful")
        except Exception as e:
            self.logger.error(f"❌ Failed to connect to OpenAI API: {e}")
            raise
        
        self.is_initialized = True
        self.logger.info("Agent initialization complete")
    
    def create_conversation(self, conversation_id: Optional[str] = None) -> str:
        """
        Create a new conversation context
        
        Args:
            conversation_id: Optional custom conversation ID
            
        Returns:
            The conversation ID
        """
        if conversation_id is None:
            conversation_id = str(uuid.uuid4())
        
        context = ConversationContext(
            conversation_id=conversation_id,
            messages=[],
            created_at=datetime.now(),
            updated_at=datetime.now(),
            metadata_json ={}
        )
        
        # Add system message
        context.add_message("system", self.config.system_prompt)
        
        self.conversations[conversation_id] = context
        self.logger.info(f"Created conversation: {conversation_id}")
        
        return conversation_id
    
    def get_conversation(self, conversation_id: str) -> Optional[ConversationContext]:
        """Get conversation context by ID"""
        return self.conversations.get(conversation_id)
    
    def chat(self, message: str, conversation_id: Optional[str] = None) -> str:
        """
        Send a message to the agent and get a response
        
        Args:
            message: User message
            conversation_id: Optional conversation ID (creates new if None)
            
        Returns:
            Agent's response
        """
        if not self.is_initialized:
            self.initialize()
        
        # Create or get conversation
        if conversation_id is None:
            conversation_id = self.create_conversation()
        
        context = self.get_conversation(conversation_id)
        if context is None:
            raise ValueError(f"Conversation {conversation_id} not found")
        
        # Add user message
        context.add_message("user", message)
        
        try:
            # Get response from OpenAI
            response = self._get_completion(context)
            
            # Add assistant response
            context.add_message("assistant", response)
            
            self.logger.info(f"Conversation {conversation_id}: User -> Agent")
            return response
            
        except Exception as e:
            self.logger.error(f"Error in chat: {e}")
            error_response = "I apologize, but I encountered an error processing your request. Please try again."
            context.add_message("assistant", error_response)
            return error_response
    
    async def chat_async(self, message: str, conversation_id: Optional[str] = None) -> str:
        """
        Async version of chat method
        
        Args:
            message: User message
            conversation_id: Optional conversation ID
            
        Returns:
            Agent's response
        """
        if not self.is_initialized:
            self.initialize()
        
        # Create or get conversation
        if conversation_id is None:
            conversation_id = self.create_conversation()
        
        context = self.get_conversation(conversation_id)
        if context is None:
            raise ValueError(f"Conversation {conversation_id} not found")
        
        # Add user message
        context.add_message("user", message)
        
        try:
            # Get response from OpenAI
            response = await self._get_completion_async(context)
            
            # Add assistant response
            context.add_message("assistant", response)
            
            self.logger.info(f"Conversation {conversation_id}: User -> Agent (async)")
            return response
            
        except Exception as e:
            self.logger.error(f"Error in async chat: {e}")
            error_response = "I apologize, but I encountered an error processing your request. Please try again."
            context.add_message("assistant", error_response)
            return error_response
    
    def _get_completion(self, context: ConversationContext) -> str:
        """
        Get completion from OpenAI API
        
        Args:
            context: Conversation context
            
        Returns:
            Generated response
        """
        messages = context.get_openai_messages()
        
        self.logger.debug(f"Sending {len(messages)} messages to OpenAI")
        
        response = self.client.chat.completions.create(
            model=self.config.openai_model,
            messages=messages,
            max_tokens=self.config.max_tokens,
            temperature=self.config.temperature
        )
        
        return response.choices[0].message.content.strip()
    
    async def _get_completion_async(self, context: ConversationContext) -> str:
        """
        Async version of _get_completion
        
        Args:
            context: Conversation context
            
        Returns:
            Generated response
        """
        messages = context.get_openai_messages()
        
        self.logger.debug(f"Sending {len(messages)} messages to OpenAI (async)")
        
        response = await self.async_client.chat.completions.create(
            model=self.config.openai_model,
            messages=messages,
            max_tokens=self.config.max_tokens,
            temperature=self.config.temperature
        )
        
        return response.choices[0].message.content.strip()
    
    def get_conversation_history(self, conversation_id: str) -> List[Dict[str, Any]]:
        """
        Get conversation history as a list of dictionaries
        
        Args:
            conversation_id: Conversation ID
            
        Returns:
            List of message dictionaries
        """
        context = self.get_conversation(conversation_id)
        if context is None:
            return []
        
        return [msg.to_dict() for msg in context.messages if msg.role != "system"]
    
    def clear_conversation(self, conversation_id: str) -> bool:
        """
        Clear a conversation
        
        Args:
            conversation_id: Conversation ID to clear
            
        Returns:
            True if conversation was found and cleared
        """
        if conversation_id in self.conversations:
            del self.conversations[conversation_id]
            self.logger.info(f"Cleared conversation: {conversation_id}")
            return True
        return False
    
    def get_stats(self) -> Dict[str, Any]:
        """Get agent statistics"""
        total_conversations = len(self.conversations)
        total_messages = sum(len(conv.messages) for conv in self.conversations.values())
        
        return {
            "agent_name": self.config.agent_name,
            "total_conversations": total_conversations,
            "total_messages": total_messages,
            "model": self.config.openai_model,
            "is_initialized": self.is_initialized
        }


Step 4: Creating a Command-Line Interface

Now that our agent can think and respond, it’s time to give it a voice — and for you to have a way to talk back. We’ll build a simple Command-Line Interface (CLI) so you can have live conversations with your agent, test its skills, and watch its responses in real time.

Create CLI Module

Create src/cli.py:


"""
Command-line interface for AI Agent
"""

import sys
import asyncio
from typing import Optional
import argparse
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt
from rich.table import Table
from rich.markdown import Markdown

from .agents.base_agent import BaseAgent
from .utils.config import get_config
from .utils.logger import get_logger, AgentLogger

class AgentCLI:
    """Command-line interface for the AI Agent"""
    
    def __init__(self):
        self.console = Console()
        self.config = get_config()
        self.agent = BaseAgent(self.config)
        self.logger = get_logger()
        self.current_conversation_id: Optional[str] = None
    
    def display_welcome(self):
        """Display welcome message"""
        welcome_text = f"""
# Welcome to {self.config.agent_name}!

{self.config.agent_description}

**Available Commands:**
- `chat <message>` - Send a message to the agent
- `history` - View conversation history
- `new` - Start a new conversation
- `stats` - Show agent statistics
- `help` - Show this help message
- `quit` or `exit` - Exit the application

**Quick Start:**
Just type your message and press Enter to start chatting!
        """
        
        self.console.print(Panel(
            Markdown(welcome_text),
            title="AI Agent CLI",
            border_style="blue"
        ))
    
    def display_stats(self):
        """Display agent statistics"""
        stats = self.agent.get_stats()
        
        table = Table(title="Agent Statistics")
        table.add_column("Metric", style="cyan")
        table.add_column("Value", style="green")
        
        for key, value in stats.items():
            table.add_row(key.replace("_", " ").title(), str(value))
        
        self.console.print(table)
    
    def display_history(self):
        """Display conversation history"""
        if not self.current_conversation_id:
            self.console.print("[yellow]No active conversation[/yellow]")
            return
        
        history = self.agent.get_conversation_history(self.current_conversation_id)
        
        if not history:
            self.console.print("[yellow]No messages in current conversation[/yellow]")
            return
        
        self.console.print(f"\n[bold]Conversation History ({self.current_conversation_id[:8]}...)[/bold]")
        
        for msg in history:
            role = msg["role"]
            content = msg["content"]
            timestamp = msg["timestamp"]
            
            if role == "user":
                self.console.print(f"\n[bold blue]You ({timestamp}):[/bold blue]")
                self.console.print(content)
            elif role == "assistant":
                self.console.print(f"\n[bold green]Agent ({timestamp}):[/bold green]")
                self.console.print(content)
    
    def process_command(self, user_input: str) -> bool:
        """
        Process user command
        
        Args:
            user_input: User input string
            
        Returns:
            False if should exit, True otherwise
        """
        user_input = user_input.strip()
        
        if not user_input:
            return True
        
        # Handle exit commands
        if user_input.lower() in ['quit', 'exit', 'q']:
            return False
        
        # Handle special commands
        if user_input.lower() == 'help':
            self.display_welcome()
            return True
        
        if user_input.lower() == 'stats':
            self.display_stats()
            return True
        
        if user_input.lower() == 'history':
            self.display_history()
            return True
        
        if user_input.lower() == 'new':
            self.current_conversation_id = None
            self.console.print("[green]Started new conversation[/green]")
            return True
        
        # Handle chat command or direct message
        if user_input.lower().startswith('chat '):
            message = user_input[5:]  # Remove 'chat ' prefix
        else:
            message = user_input
        
        # Send message to agent
        try:
            with self.console.status("[bold green]Agent is thinking..."):
                response = self.agent.chat(message, self.current_conversation_id)
                
                # Update current conversation ID if it was None
                if self.current_conversation_id is None:
                    # Get the most recent conversation ID
                    if self.agent.conversations:
                        self.current_conversation_id = list(self.agent.conversations.keys())[-1]
            
            # Display response
            self.console.print(f"\n[bold green]{self.config.agent_name}:[/bold green]")
            self.console.print(response)
            self.console.print()
            
        except Exception as e:
            self.console.print(f"[red]Error: {e}[/red]")
            self.logger.error(f"CLI error: {e}")
        
        return True
    
    def run(self):
        """Run the CLI interface"""
        try:
            # Initialize agent
            self.agent.initialize()
            
            # Display welcome message
            self.display_welcome()
            
            # Main interaction loop
            while True:
                try:
                    user_input = Prompt.ask("\n[bold cyan]You[/bold cyan]")
                    
                    if not self.process_command(user_input):
                        break
                        
                except KeyboardInterrupt:
                    self.console.print("\n[yellow]Goodbye![/yellow]")
                    break
                except EOFError:
                    break
        
        except Exception as e:
            self.console.print(f"[red]Fatal error: {e}[/red]")
            self.logger.error(f"Fatal CLI error: {e}")
            sys.exit(1)

def main():
    """Main entry point for CLI"""
    parser = argparse.ArgumentParser(description="AI Agent Command Line Interface")
    parser.add_argument(
        "--debug",
        action="store_true",
        help="Enable debug logging"
    )
    parser.add_argument(
        "--log-level",
        choices=["DEBUG", "INFO", "WARNING", "ERROR"],
        default="INFO",
        help="Set logging level"
    )
    
    args = parser.parse_args()
    
    # Set logging level
    if args.debug:
        AgentLogger().set_level("DEBUG")
    else:
        AgentLogger().set_level(args.log_level)
    
    # Run CLI
    cli = AgentCLI()
    cli.run()

if __name__ == "__main__":
    main()


Step 5: Creating Unit Tests

Before we move on, we need to make sure our agent’s foundations are rock solid. That’s where testing comes in. We’ll write unit tests to make sure every part of our agent works exactly as intended — because a smart agent that breaks unexpectedly isn’t very helpful.

Create Test Configuration

Create tests/conftest.py:


"""
Test configuration and fixtures
"""

import pytest
import os
from unittest.mock import Mock, patch
from src.utils.config import AgentConfig
from src.agents.base_agent import BaseAgent

@pytest.fixture
def mock_config():
    """Mock configuration for testing"""
    return AgentConfig(
        openai_api_key="sk-test-key-123",
        openai_model="gpt-3.5-turbo",
        max_tokens=100,
        temperature=0.7,
        agent_name="Test Agent",
        agent_description="A test agent",
        system_prompt="You are a test assistant.",
        log_level="DEBUG",
        debug=True
    )

@pytest.fixture
def mock_openai_response():
    """Mock OpenAI API response"""
    mock_response = Mock()
    mock_response.choices = [Mock()]
    mock_response.choices[0].message.content = "This is a test response."
    return mock_response

@pytest.fixture
def agent(mock_config):
    """Create agent instance for testing"""
    with patch('src.agents.base_agent.OpenAI'), \
         patch('src.agents.base_agent.AsyncOpenAI'):
        return BaseAgent(mock_config)

Create Agent Tests

Create tests/test_base_agent.py:


"""
Tests for BaseAgent class
"""

import pytest
from unittest.mock import Mock, patch, MagicMock
from datetime import datetime

from src.agents.base_agent import BaseAgent, Message, ConversationContext
from src.utils.config import AgentConfig

class TestMessage:
    """Test Message class"""
    
    def test_message_creation(self):
        """Test message creation"""
        msg = Message(
            role="user",
            content="Hello",
            timestamp=datetime.now(),
            message_id="test-123"
        )
        
        assert msg.role == "user"
        assert msg.content == "Hello"
        assert msg.message_id == "test-123"
    
    def test_message_to_dict(self):
        """Test message serialization"""
        timestamp = datetime.now()
        msg = Message(
            role="user",
            content="Hello",
            timestamp=timestamp,
            message_id="test-123",
            metadata_json ={"test": "value"}
        )
        
        data = msg.to_dict()
        
        assert data["role"] == "user"
        assert data["content"] == "Hello"
        assert data["message_id"] == "test-123"
        assert data["metadata_json"]["test"] == "value"
    
    def test_message_from_dict(self):
        """Test message deserialization"""
        timestamp = datetime.now()
        data = {
            "role": "assistant",
            "content": "Hi there!",
            "timestamp": timestamp.isoformat(),
            "message_id": "test-456",
            "metadata_json": {"test": "value"}
        }
        
        msg = Message.from_dict(data)
        
        assert msg.role == "assistant"
        assert msg.content == "Hi there!"
        assert msg.message_id == "test-456"
        assert msg.metadata_json["test"] == "value"

class TestConversationContext:
    """Test ConversationContext class"""
    
    def test_add_message(self):
        """Test adding messages to conversation"""
        context = ConversationContext(
            conversation_id="test-conv",
            messages=[],
            created_at=datetime.now(),
            updated_at=datetime.now(),
            metadata_json ={}
        )
        
        msg = context.add_message("user", "Hello")
        
        assert len(context.messages) == 1
        assert msg.role == "user"
        assert msg.content == "Hello"
        assert msg.message_id is not None
    
    def test_get_openai_messages(self):
        """Test OpenAI message format conversion"""
        context = ConversationContext(
            conversation_id="test-conv",
            messages=[],
            created_at=datetime.now(),
            updated_at=datetime.now(),
            metadata_json ={}
        )
        
        context.add_message("system", "You are helpful")
        context.add_message("user", "Hello")
        context.add_message("assistant", "Hi there!")
        
        openai_msgs = context.get_openai_messages()
        
        assert len(openai_msgs) == 3
        assert openai_msgs[0] == {"role": "system", "content": "You are helpful"}
        assert openai_msgs[1] == {"role": "user", "content": "Hello"}
        assert openai_msgs[2] == {"role": "assistant", "content": "Hi there!"}

class TestBaseAgent:
    """Test BaseAgent class"""
    
    @patch('src.agents.base_agent.OpenAI')
    @patch('src.agents.base_agent.AsyncOpenAI')
    def test_agent_initialization(self, mock_async_openai, mock_openai, mock_config):
        """Test agent initialization"""
        agent = BaseAgent(mock_config)
        
        assert agent.config == mock_config
        assert not agent.is_initialized
        assert len(agent.conversations) == 0
    
    @patch('src.agents.base_agent.OpenAI')
    @patch('src.agents.base_agent.AsyncOpenAI')
    def test_create_conversation(self, mock_async_openai, mock_openai, mock_config):
        """Test conversation creation"""
        agent = BaseAgent(mock_config)
        
        conv_id = agent.create_conversation()
        
        assert conv_id in agent.conversations
        context = agent.get_conversation(conv_id)
        assert context is not None
        assert len(context.messages) == 1  # System message
        assert context.messages[0].role == "system"
    
    @patch('src.agents.base_agent.OpenAI')
    @patch('src.agents.base_agent.AsyncOpenAI')
    def test_chat_functionality(self, mock_async_openai, mock_openai, mock_config, mock_openai_response):
        """Test basic chat functionality"""
        # Setup mocks
        mock_client = Mock()
        mock_client.chat.completions.create.return_value = mock_openai_response
        mock_openai.return_value = mock_client
        
        agent = BaseAgent(mock_config)
        
        # Test chat
        response = agent.chat("Hello, how are you?")
        
        assert response == "This is a test response."
        assert len(agent.conversations) == 1
        
        # Verify API was called
        mock_client.chat.completions.create.assert_called_once()
    
    @patch('src.agents.base_agent.OpenAI')
    @patch('src.agents.base_agent.AsyncOpenAI')
    def test_get_stats(self, mock_async_openai, mock_openai, mock_config):
        """Test agent statistics"""
        agent = BaseAgent(mock_config)
        
        stats = agent.get_stats()
        
        assert stats["agent_name"] == mock_config.agent_name
        assert stats["total_conversations"] == 0
        assert stats["total_messages"] == 0
        assert stats["model"] == mock_config.openai_model
        assert not stats["is_initialized"]


Step 6: Running Your Agent

Everything is now in place — the agent has a personality, a brain, a way to talk to you, and a log of its actions. The final step in this part is to test everything we’ve built and see our creation in action.

Create Main Entry Point

Create main.py in your project root:


#!/usr/bin/env python3
"""
Main entry point for AI Agent
"""

import sys
from pathlib import Path

# Add src to Python path
sys.path.insert(0, str(Path(__file__).parent / "src"))

from src.cli import main

if __name__ == "__main__":
    main()

Test Your Agent

  1. Run the verification script:

    python test_setup.py
    
  2. Run the unit tests:

    pytest tests/ -v
    
  3. Start the CLI interface:

    python main.py
    
  4. Test basic conversation:

    You: Hello! How are you today?
    Agent: Hello! I'm doing well, thank you for asking. I'm here and ready to help you with any questions or tasks you might have. How are you doing today?
    
    You: What can you help me with?
    Agent: I can help you with a wide variety of tasks! Here are some examples:
    - Answering questions on various topics
    - Helping with writing and editing
    - Explaining concepts and providing information
    - Assisting with problem-solving
    - And much more!
    
    What would you like to work on today?
    

Troubleshooting Common Issues

Issue: Import errors

Solution: Make sure your Python path includes the src directory:

export PYTHONPATH="${PYTHONPATH}:$(pwd)/src"

Issue: OpenAI API errors

Solution: Check your API key and credits:

# Test API connection
python -c "
from src.utils.config import get_config
from openai import OpenAI
config = get_config()
client = OpenAI(api_key=config.openai_api_key)
print('API connection successful!')
"

Issue: Rich formatting not working

Solution: Install rich with color support:

pip install rich[color]


What You've Accomplished

Congratulations! You've built a solid foundation for your AI agent:

  • âś… Configuration System - Flexible, environment-based configuration
  • âś… Logging Framework - Rich, structured logging with file output
  • âś… Core Agent Class - Conversation handling with OpenAI integration
  • âś… CLI Interface - Interactive testing environment
  • âś… Unit Tests - Comprehensive test coverage
  • âś… Error Handling - Robust error management and recovery

Key Features Implemented:

  1. Conversation Management - Multiple concurrent conversations
  2. Message History - Persistent conversation context
  3. Async Support - Both sync and async API calls
  4. Rich CLI - Beautiful command-line interface
  5. Comprehensive Logging - Debug and production logging
  6. Configuration Validation - Environment variable validation
  7. Unit Testing - Test-driven development approach

What's Next?

You’ve now built the skeleton and basic systems of your AI agent — and you’ve spoken to it through the CLI! The next big leap is teaching it to remember past conversations so it can feel truly intelligent.

In Part 3: Adding Memory and Context Handling, you'll learn:

  • Implementing persistent conversation memory
  • Context window management for long conversations
  • Message summarization and compression
  • Conversation persistence to files/databases
  • Advanced context retrieval strategies

Quick Reference Commands

# Run your agent
python main.py

# Run tests
pytest tests/ -v

# Run with debug logging
python main.py --debug

# Check agent stats
# (Use 'stats' command in CLI)


Additional Resources

Ready to add memory and context handling to your agent? Continue to Part 3: Adding Memory and Context Handling to make your agent even smarter!


Ad Space

Recommended Tools & Resources

* This section contains affiliate links. We may earn a commission when you purchase through these links at no additional cost to you.

OpenAI API

AI Platform

Access GPT-4 and other powerful AI models for your agent development.

Pay-per-use

LangChain Plus

Framework

Advanced framework for building applications with large language models.

Free + Paid

Pinecone Vector Database

Database

High-performance vector database for AI applications and semantic search.

Free tier available

AI Agent Development Course

Education

Complete course on building production-ready AI agents from scratch.

$199

đź’ˇ Pro Tip

Start with the free tiers of these tools to experiment, then upgrade as your AI agent projects grow. Most successful developers use a combination of 2-3 core tools rather than trying everything at once.

🚀 Join the AgentForge Community

Get weekly insights, tutorials, and the latest AI agent developments delivered to your inbox.

No spam, ever. Unsubscribe at any time.

Loading conversations...