Introduction to AI Agents
AI agents are autonomous systems that use large language models as reasoning engines to perceive their environment, make decisions, and take actions to achieve goals. Unlike simple chatbots that respond to individual queries, agents maintain state, use tools, and execute multi-step plans. This paradigm shift—from LLMs as text generators to LLMs as decision-makers—enables systems that can browse the web, write and execute code, manage files, and interact with APIs. This section introduces agent architectures, tool use, and the foundational patterns for building autonomous AI systems.
What Are AI Agents?
Agents combine LLM reasoning with action capabilities:
from typing import List, Dict, Optional, Any, Callable, Tuple
from dataclasses import dataclass, field
from abc import ABC, abstractmethod
from enum import Enum
import json
import re
import time
class AgentRole(Enum):
"""Common agent roles."""
ASSISTANT = "assistant"
RESEARCHER = "researcher"
CODER = "coder"
ANALYST = "analyst"
PLANNER = "planner"
@dataclass
class AgentConfig:
"""Configuration for an AI agent."""
name: str = "Agent"
role: AgentRole = AgentRole.ASSISTANT
model: str = "gpt-4-turbo"
temperature: float = 0.1
max_iterations: int = 10
max_tokens: int = 4096
tools: List['Tool'] = field(default_factory=list)
system_prompt: Optional[str] = None
@dataclass
class AgentState:
"""Mutable state maintained by agent."""
messages: List[Dict[str, str]] = field(default_factory=list)
tool_results: List[Dict[str, Any]] = field(default_factory=list)
iteration: int = 0
completed: bool = False
final_answer: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class AgentAction:
"""Action selected by agent."""
tool_name: str
tool_input: Dict[str, Any]
reasoning: str
@dataclass
class AgentObservation:
"""Observation from tool execution."""
tool_name: str
result: Any
success: bool
error: Optional[str] = None
class AIAgent:
"""
Base AI Agent implementation.
Core agent loop:
1. Observe: Receive input/feedback
2. Think: Reason about current state and goals
3. Act: Select and execute tool OR provide final answer
4. Repeat until task complete or max iterations
Key capabilities:
- Tool use for interacting with environment
- Memory for maintaining context
- Planning for multi-step tasks
- Self-correction based on feedback
"""
def __init__(
self,
llm: 'LLM',
config: AgentConfig
):
self.llm = llm
self.config = config
self.tools = {tool.name: tool for tool in config.tools}
self.state = AgentState()
def run(self, task: str) -> str:
"""
Execute agent on a task.
Args:
task: The task/goal for the agent
Returns:
Final answer or result
"""
# Initialize
self.state = AgentState()
self._add_message("user", task)
# Agent loop
while not self.state.completed and self.state.iteration < self.config.max_iterations:
self.state.iteration += 1
# Think: Get next action from LLM
action = self._think()
if action is None:
# Agent decided to give final answer
break
# Act: Execute the tool
observation = self._act(action)
# Update state with observation
self._observe(observation)
return self.state.final_answer or "Task could not be completed."
def _think(self) -> Optional[AgentAction]:
"""
Reasoning step: Decide what to do next.
Returns:
AgentAction if tool should be called, None if ready to answer
"""
# Build prompt with current state
prompt = self._build_prompt()
# Get LLM response
response = self.llm.generate(
prompt,
temperature=self.config.temperature,
max_tokens=self.config.max_tokens
)
# Parse response
return self._parse_response(response)
def _act(self, action: AgentAction) -> AgentObservation:
"""
Action step: Execute the selected tool.
"""
tool = self.tools.get(action.tool_name)
if tool is None:
return AgentObservation(
tool_name=action.tool_name,
result=None,
success=False,
error=f"Unknown tool: {action.tool_name}"
)
try:
result = tool.execute(**action.tool_input)
return AgentObservation(
tool_name=action.tool_name,
result=result,
success=True
)
except Exception as e:
return AgentObservation(
tool_name=action.tool_name,
result=None,
success=False,
error=str(e)
)
def _observe(self, observation: AgentObservation) -> None:
"""
Update state with observation from tool execution.
"""
self.state.tool_results.append({
'tool': observation.tool_name,
'result': observation.result,
'success': observation.success,
'error': observation.error
})
# Add to message history
if observation.success:
content = f"Tool '{observation.tool_name}' returned:\n{observation.result}"
else:
content = f"Tool '{observation.tool_name}' failed:\n{observation.error}"
self._add_message("tool", content)
def _build_prompt(self) -> str:
"""Build prompt with system message, tools, and history."""
system = self.config.system_prompt or self._default_system_prompt()
# Add tool descriptions
tool_desc = self._format_tools()
messages = "\n".join([
f"{m['role'].upper()}: {m['content']}"
for m in self.state.messages
])
return f"""{system}
Available Tools:
{tool_desc}
Conversation History:
{messages}
Based on the above, decide your next action.
If you need to use a tool, respond with:
THOUGHT: [your reasoning]
ACTION: [tool_name]
ACTION_INPUT: [json input for tool]
If you have enough information to answer, respond with:
THOUGHT: [your reasoning]
FINAL_ANSWER: [your complete answer]
"""
def _default_system_prompt(self) -> str:
return f"""You are {self.config.name}, an AI {self.config.role.value} agent.
Your goal is to help users by breaking down complex tasks, using tools when needed,
and providing accurate, helpful responses.
Guidelines:
1. Think step-by-step about how to accomplish the task
2. Use tools to gather information or take actions
3. If a tool fails, try an alternative approach
4. Provide clear, well-reasoned final answers
5. Acknowledge when you cannot complete a task"""
def _format_tools(self) -> str:
"""Format tool descriptions for prompt."""
descriptions = []
for tool in self.tools.values():
desc = f"- {tool.name}: {tool.description}"
if tool.parameters:
params = json.dumps(tool.parameters, indent=2)
desc += f"\n Parameters: {params}"
descriptions.append(desc)
return "\n".join(descriptions)
def _parse_response(self, response: str) -> Optional[AgentAction]:
"""Parse LLM response into action or final answer."""
# Check for final answer
if "FINAL_ANSWER:" in response:
answer_match = re.search(r'FINAL_ANSWER:\s*(.*)', response, re.DOTALL)
if answer_match:
self.state.final_answer = answer_match.group(1).strip()
self.state.completed = True
return None
# Parse action
thought_match = re.search(r'THOUGHT:\s*(.*?)(?=ACTION:|$)', response, re.DOTALL)
action_match = re.search(r'ACTION:\s*(\w+)', response)
input_match = re.search(r'ACTION_INPUT:\s*(\{.*?\}|\[.*?\]|".*?"|\S+)', response, re.DOTALL)
if action_match:
reasoning = thought_match.group(1).strip() if thought_match else ""
tool_name = action_match.group(1)
try:
tool_input = json.loads(input_match.group(1)) if input_match else {}
except json.JSONDecodeError:
tool_input = {"input": input_match.group(1) if input_match else ""}
return AgentAction(
tool_name=tool_name,
tool_input=tool_input,
reasoning=reasoning
)
return None
def _add_message(self, role: str, content: str) -> None:
"""Add message to conversation history."""
self.state.messages.append({
'role': role,
'content': content
})
def agent_vs_chatbot():
"""Compare agents vs traditional chatbots."""
comparison = {
'Chatbot': {
'interaction': 'Single turn Q&A',
'state': 'Stateless or limited context',
'capabilities': 'Text generation only',
'autonomy': 'Reactive (responds to prompts)',
'tools': 'None',
'use_cases': 'Customer support, FAQ, simple queries'
},
'AI Agent': {
'interaction': 'Multi-turn task execution',
'state': 'Maintains memory and context',
'capabilities': 'Reasoning + tool use + actions',
'autonomy': 'Proactive (pursues goals)',
'tools': 'Web search, code execution, APIs, files',
'use_cases': 'Research, coding, data analysis, automation'
}
}
print("Chatbot vs AI Agent Comparison:")
print("=" * 60)
for system, attrs in comparison.items():
print(f"\n{system}:")
for k, v in attrs.items():
print(f" {k}: {v}")
agent_vs_chatbot()Tool Use and Function Calling
Tools extend agent capabilities beyond text generation:
class Tool(ABC):
"""Abstract base class for agent tools."""
@property
@abstractmethod
def name(self) -> str:
"""Tool name for agent to reference."""
pass
@property
@abstractmethod
def description(self) -> str:
"""Description of what the tool does."""
pass
@property
def parameters(self) -> Dict[str, Any]:
"""JSON schema for tool parameters."""
return {}
@abstractmethod
def execute(self, **kwargs) -> Any:
"""Execute the tool with given parameters."""
pass
class WebSearchTool(Tool):
"""Tool for searching the web."""
@property
def name(self) -> str:
return "web_search"
@property
def description(self) -> str:
return "Search the web for information. Use for current events, facts, or research."
@property
def parameters(self) -> Dict[str, Any]:
return {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query"
},
"num_results": {
"type": "integer",
"description": "Number of results to return",
"default": 5
}
},
"required": ["query"]
}
def execute(self, query: str, num_results: int = 5) -> str:
"""Execute web search."""
# In practice, call search API (Google, Bing, etc.)
# Simulated response for demonstration
results = [
{"title": f"Result {i+1} for '{query}'", "snippet": f"Information about {query}..."}
for i in range(num_results)
]
return json.dumps(results, indent=2)
class PythonREPLTool(Tool):
"""Tool for executing Python code."""
@property
def name(self) -> str:
return "python_repl"
@property
def description(self) -> str:
return "Execute Python code. Use for calculations, data processing, or analysis."
@property
def parameters(self) -> Dict[str, Any]:
return {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Python code to execute"
}
},
"required": ["code"]
}
def execute(self, code: str) -> str:
"""Execute Python code safely."""
import io
import sys
from contextlib import redirect_stdout, redirect_stderr
# Capture output
stdout_capture = io.StringIO()
stderr_capture = io.StringIO()
local_vars = {}
try:
with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
exec(code, {"__builtins__": __builtins__}, local_vars)
output = stdout_capture.getvalue()
errors = stderr_capture.getvalue()
result = output if output else "Code executed successfully."
if errors:
result += f"\nWarnings/Errors:\n{errors}"
return result
except Exception as e:
return f"Error executing code: {str(e)}"
class FileReadTool(Tool):
"""Tool for reading files."""
def __init__(self, allowed_paths: List[str] = None):
self.allowed_paths = allowed_paths or []
@property
def name(self) -> str:
return "read_file"
@property
def description(self) -> str:
return "Read contents of a file. Use for accessing documents, code, or data files."
@property
def parameters(self) -> Dict[str, Any]:
return {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to the file to read"
}
},
"required": ["file_path"]
}
def execute(self, file_path: str) -> str:
"""Read file contents."""
# Security check
if self.allowed_paths:
is_allowed = any(
file_path.startswith(path)
for path in self.allowed_paths
)
if not is_allowed:
return f"Access denied: {file_path} is not in allowed paths"
try:
with open(file_path, 'r') as f:
content = f.read()
return content[:10000] # Limit output size
except FileNotFoundError:
return f"File not found: {file_path}"
except Exception as e:
return f"Error reading file: {str(e)}"
class FileWriteTool(Tool):
"""Tool for writing files."""
def __init__(self, allowed_paths: List[str] = None):
self.allowed_paths = allowed_paths or []
@property
def name(self) -> str:
return "write_file"
@property
def description(self) -> str:
return "Write content to a file. Use for saving results, code, or documents."
@property
def parameters(self) -> Dict[str, Any]:
return {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to write the file"
},
"content": {
"type": "string",
"description": "Content to write to the file"
}
},
"required": ["file_path", "content"]
}
def execute(self, file_path: str, content: str) -> str:
"""Write content to file."""
try:
with open(file_path, 'w') as f:
f.write(content)
return f"Successfully wrote {len(content)} characters to {file_path}"
except Exception as e:
return f"Error writing file: {str(e)}"
class APICallTool(Tool):
"""Tool for making API calls."""
@property
def name(self) -> str:
return "api_call"
@property
def description(self) -> str:
return "Make HTTP API calls. Use for fetching data from web services."
@property
def parameters(self) -> Dict[str, Any]:
return {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The API endpoint URL"
},
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE"],
"default": "GET"
},
"headers": {
"type": "object",
"description": "HTTP headers"
},
"body": {
"type": "object",
"description": "Request body for POST/PUT"
}
},
"required": ["url"]
}
def execute(
self,
url: str,
method: str = "GET",
headers: Dict = None,
body: Dict = None
) -> str:
"""Make API call."""
import requests
try:
response = requests.request(
method=method,
url=url,
headers=headers or {},
json=body,
timeout=30
)
return json.dumps({
"status_code": response.status_code,
"body": response.text[:5000]
}, indent=2)
except Exception as e:
return f"API call failed: {str(e)}"
class ToolRegistry:
"""Registry for managing available tools."""
def __init__(self):
self.tools: Dict[str, Tool] = {}
def register(self, tool: Tool) -> None:
"""Register a tool."""
self.tools[tool.name] = tool
def get(self, name: str) -> Optional[Tool]:
"""Get tool by name."""
return self.tools.get(name)
def list_tools(self) -> List[Dict[str, Any]]:
"""List all registered tools."""
return [
{
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters
}
for tool in self.tools.values()
]
def to_openai_format(self) -> List[Dict[str, Any]]:
"""Convert tools to OpenAI function calling format."""
return [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters
}
}
for tool in self.tools.values()
]The ReAct Pattern
ReAct combines Reasoning and Acting in an interleaved manner:
class ReActAgent(AIAgent):
"""
ReAct Agent: Reasoning + Acting.
ReAct interleaves reasoning traces with actions:
- Thought: Verbal reasoning about current situation
- Action: Tool call to interact with environment
- Observation: Result from tool execution
This pattern improves:
- Interpretability (can see reasoning)
- Error recovery (can reason about failures)
- Task decomposition (think through steps)
"""
def _build_prompt(self) -> str:
"""Build ReAct-style prompt."""
system = """You are a ReAct agent that solves tasks by interleaving thinking and acting.
For each step, you will:
1. THOUGHT: Reason about the current situation and what to do next
2. ACTION: Choose a tool to use (or FINISH if done)
3. Observe the result and continue
Format your response as:
THOUGHT: [your reasoning about the current state and next step]
ACTION: [tool_name]
ACTION_INPUT: [JSON parameters for the tool]
Or if you have the final answer:
THOUGHT: [your reasoning]
ACTION: FINISH
ACTION_INPUT: {"answer": "[your final answer]"}
"""
# Format tool descriptions
tools_desc = "Available tools:\n"
for tool in self.tools.values():
tools_desc += f"- {tool.name}: {tool.description}\n"
tools_desc += "- FINISH: Use when you have the final answer\n"
# Format history as thought-action-observation traces
history = self._format_react_history()
return f"""{system}
{tools_desc}
Task: {self.state.messages[0]['content'] if self.state.messages else 'No task specified'}
{history}
Continue with your next thought and action:"""
def _format_react_history(self) -> str:
"""Format history as ReAct traces."""
traces = []
# Skip first message (the task)
messages = self.state.messages[1:]
i = 0
while i < len(messages):
msg = messages[i]
if msg['role'] == 'assistant':
# This contains thought and action
traces.append(msg['content'])
elif msg['role'] == 'tool':
# This is the observation
traces.append(f"OBSERVATION: {msg['content']}")
i += 1
return "\n\n".join(traces)
def _parse_response(self, response: str) -> Optional[AgentAction]:
"""Parse ReAct response."""
# Extract thought
thought_match = re.search(r'THOUGHT:\s*(.*?)(?=ACTION:|$)', response, re.DOTALL)
thought = thought_match.group(1).strip() if thought_match else ""
# Extract action
action_match = re.search(r'ACTION:\s*(\w+)', response)
if not action_match:
return None
action_name = action_match.group(1)
# Check for FINISH
if action_name.upper() == "FINISH":
input_match = re.search(r'ACTION_INPUT:\s*(\{.*\})', response, re.DOTALL)
if input_match:
try:
data = json.loads(input_match.group(1))
self.state.final_answer = data.get('answer', str(data))
except:
self.state.final_answer = response
self.state.completed = True
return None
# Extract action input
input_match = re.search(r'ACTION_INPUT:\s*(\{.*?\})', response, re.DOTALL)
try:
action_input = json.loads(input_match.group(1)) if input_match else {}
except json.JSONDecodeError:
action_input = {}
# Store the thought-action in messages
self._add_message("assistant", response)
return AgentAction(
tool_name=action_name,
tool_input=action_input,
reasoning=thought
)
class ReActWithSelfReflection(ReActAgent):
"""
ReAct with self-reflection on failures.
Adds reflection step when tools fail or results are unexpected.
"""
def _observe(self, observation: AgentObservation) -> None:
"""Add reflection on failures."""
super()._observe(observation)
if not observation.success:
# Add reflection prompt
reflection = self._reflect_on_failure(observation)
self._add_message("system", f"REFLECTION: {reflection}")
def _reflect_on_failure(self, observation: AgentObservation) -> str:
"""Generate reflection on failed action."""
prompt = f"""The tool '{observation.tool_name}' failed with error: {observation.error}
Reflect on:
1. Why might this have failed?
2. What alternative approach could work?
3. Is there missing information needed?
Brief reflection:"""
return self.llm.generate(prompt, max_tokens=200)
def demonstrate_react():
"""Demonstrate ReAct pattern execution."""
example_trace = """
Task: What is the population of France and how does it compare to Germany?
THOUGHT: I need to find the population of France first. Let me search for this information.
ACTION: web_search
ACTION_INPUT: {"query": "population of France 2024"}
OBSERVATION: [{"title": "France Population", "snippet": "France has a population of approximately 68 million people as of 2024..."}]
THOUGHT: France has about 68 million people. Now I need to find Germany's population to compare.
ACTION: web_search
ACTION_INPUT: {"query": "population of Germany 2024"}
OBSERVATION: [{"title": "Germany Population", "snippet": "Germany is the most populous EU country with about 84 million inhabitants..."}]
THOUGHT: I now have both populations. France has 68 million and Germany has 84 million. I can calculate the difference and provide the comparison.
ACTION: FINISH
ACTION_INPUT: {"answer": "France has a population of approximately 68 million people, while Germany has about 84 million. Germany is more populous by about 16 million people, making it roughly 24% larger in population. Germany is the most populous country in the European Union."}
"""
print("ReAct Pattern Example:")
print("=" * 60)
print(example_trace)
demonstrate_react()Agent Memory
Memory systems enable agents to learn and maintain context:
class AgentMemory(ABC):
"""Abstract base class for agent memory."""
@abstractmethod
def add(self, content: str, metadata: Dict[str, Any] = None) -> None:
"""Add item to memory."""
pass
@abstractmethod
def search(self, query: str, top_k: int = 5) -> List[Dict[str, Any]]:
"""Search memory for relevant items."""
pass
@abstractmethod
def get_recent(self, n: int = 10) -> List[Dict[str, Any]]:
"""Get most recent memory items."""
pass
class ConversationMemory(AgentMemory):
"""
Simple conversation memory with sliding window.
Maintains recent messages within token limit.
"""
def __init__(self, max_messages: int = 50, max_tokens: int = 4000):
self.max_messages = max_messages
self.max_tokens = max_tokens
self.messages: List[Dict[str, Any]] = []
def add(self, content: str, metadata: Dict[str, Any] = None) -> None:
"""Add message to memory."""
self.messages.append({
'content': content,
'metadata': metadata or {},
'timestamp': time.time()
})
# Trim if over limit
while len(self.messages) > self.max_messages:
self.messages.pop(0)
def search(self, query: str, top_k: int = 5) -> List[Dict[str, Any]]:
"""Simple keyword search in memory."""
query_lower = query.lower()
scored = []
for msg in self.messages:
content_lower = msg['content'].lower()
# Simple relevance: count query words in content
score = sum(1 for word in query_lower.split() if word in content_lower)
if score > 0:
scored.append((msg, score))
scored.sort(key=lambda x: x[1], reverse=True)
return [msg for msg, _ in scored[:top_k]]
def get_recent(self, n: int = 10) -> List[Dict[str, Any]]:
"""Get n most recent messages."""
return self.messages[-n:]
def get_formatted(self) -> str:
"""Get formatted memory for prompt."""
return "\n".join([
f"[{msg['metadata'].get('role', 'unknown')}]: {msg['content']}"
for msg in self.messages
])
class SemanticMemory(AgentMemory):
"""
Semantic memory using embeddings for retrieval.
Stores experiences and retrieves by semantic similarity.
"""
def __init__(self, embedding_model, vector_store):
self.embedding_model = embedding_model
self.vector_store = vector_store
self.memories: List[Dict[str, Any]] = []
def add(self, content: str, metadata: Dict[str, Any] = None) -> None:
"""Add memory with embedding."""
embedding = self.embedding_model.embed(content)
memory = {
'content': content,
'metadata': metadata or {},
'timestamp': time.time(),
'embedding': embedding
}
self.memories.append(memory)
self.vector_store.add([memory])
def search(self, query: str, top_k: int = 5) -> List[Dict[str, Any]]:
"""Search memories by semantic similarity."""
query_embedding = self.embedding_model.embed(query)
results = self.vector_store.search(query_embedding, top_k=top_k)
return [
{
'content': r.document.content,
'metadata': r.document.metadata,
'relevance': r.score
}
for r in results
]
def get_recent(self, n: int = 10) -> List[Dict[str, Any]]:
"""Get recent memories."""
return self.memories[-n:]
class EpisodicMemory(AgentMemory):
"""
Episodic memory for storing complete task episodes.
Enables learning from past experiences and few-shot examples.
"""
def __init__(self, max_episodes: int = 100):
self.max_episodes = max_episodes
self.episodes: List[Dict[str, Any]] = []
def add_episode(
self,
task: str,
actions: List[AgentAction],
observations: List[AgentObservation],
outcome: str,
success: bool
) -> None:
"""Add complete task episode."""
episode = {
'task': task,
'actions': [
{'tool': a.tool_name, 'input': a.tool_input, 'reasoning': a.reasoning}
for a in actions
],
'observations': [
{'tool': o.tool_name, 'result': o.result, 'success': o.success}
for o in observations
],
'outcome': outcome,
'success': success,
'timestamp': time.time()
}
self.episodes.append(episode)
# Trim old episodes
while len(self.episodes) > self.max_episodes:
self.episodes.pop(0)
def add(self, content: str, metadata: Dict[str, Any] = None) -> None:
"""Add simple memory item."""
pass # Episodic memory uses add_episode
def search(self, query: str, top_k: int = 5) -> List[Dict[str, Any]]:
"""Find similar past episodes."""
query_lower = query.lower()
scored = []
for episode in self.episodes:
task_lower = episode['task'].lower()
score = sum(1 for word in query_lower.split() if word in task_lower)
if score > 0:
scored.append((episode, score))
scored.sort(key=lambda x: x[1], reverse=True)
return [ep for ep, _ in scored[:top_k]]
def get_recent(self, n: int = 10) -> List[Dict[str, Any]]:
"""Get recent episodes."""
return self.episodes[-n:]
def get_successful_examples(self, task_type: str, n: int = 3) -> List[Dict[str, Any]]:
"""Get successful episodes similar to task type."""
similar = self.search(task_type, top_k=n * 2)
successful = [ep for ep in similar if ep.get('success', False)]
return successful[:n]Key Takeaways
AI agents represent a paradigm shift from LLMs as text generators to LLMs as autonomous decision-makers. Core components include: (1) the agent loop that cycles through thinking, acting, and observing, (2) tools that extend capabilities beyond text generation to web search, code execution, file operations, and API calls, (3) the ReAct pattern that interleaves reasoning traces with actions for interpretable, robust behavior, and (4) memory systems that maintain context across interactions. Effective agents require careful prompt engineering to guide reasoning, robust tool implementations with proper error handling, and safety measures to prevent unintended actions. The key insight is that LLMs provide the reasoning engine while tools provide the interface to the world—combining these enables systems that can accomplish complex, multi-step tasks autonomously.