← Back to DevBytes

AI Agent Infrastructure for Solo Developers: Zero to Production

What is AI Agent Infrastructure?

AI agent infrastructure is the backbone that enables autonomous AI agents to operate reliably in production environments. It's not just about building a prompt and calling an LLM — it's the entire technical stack that handles orchestration, state management, tool execution, error recovery, logging, and deployment. For solo developers, this infrastructure determines whether your agent is a fragile prototype or a production-grade system that can serve real users.

Think of it as the operating system for your AI agents. Just as a web application needs a server, database, and monitoring, an AI agent needs a runtime environment that manages:

Without dedicated infrastructure, a solo developer ends up writing ad-hoc glue code that works in demos but crumbles under real-world edge cases. With the right infrastructure, you can focus on your agent's unique capabilities while the framework handles the heavy lifting.

Why It Matters for Solo Developers

As a solo developer, your time is your scarcest resource. Building an AI agent from scratch means you'll spend 80% of your effort on infrastructure concerns — retry logic, state serialization, prompt management, error handling — and only 20% on the actual agent logic that differentiates your product. A solid infrastructure flips this ratio.

Here's what changes when you adopt a proper agent infrastructure:

The difference between a weekend hackathon project and a product people pay for often comes down to infrastructure. Let's build one.

Core Components: A Practical Breakdown

1. The Agent Runtime Loop

At the heart of every agent infrastructure is a runtime loop that cycles through reasoning, acting, and observing until a goal is met. Here's a minimal but production-ready implementation:

import asyncio
import json
from datetime import datetime
from typing import Any, Dict, List, Optional

class AgentRuntime:
    def __init__(
        self,
        llm_client,
        tools: List[Dict[str, Any]],
        max_iterations: int = 10,
        context_budget: int = 8000
    ):
        self.llm = llm_client
        self.tools = tools
        self.max_iterations = max_iterations
        self.context_budget = context_budget
        self.execution_log: List[Dict[str, Any]] = []

    async def run(self, task: str, memory: Optional[List[Dict]] = None) -> Dict[str, Any]:
        messages = self._build_system_prompt()
        if memory:
            messages.extend(memory)
        messages.append({"role": "user", "content": task})

        for iteration in range(self.max_iterations):
            # Log the iteration start
            step_log = {
                "iteration": iteration,
                "timestamp": datetime.utcnow().isoformat(),
                "messages_snapshot": len(messages)
            }

            # Call the LLM with tool definitions
            response = await self.llm.chat(
                messages=messages,
                tools=self.tools,
                tool_choice="auto"
            )

            message = response["choices"][0]["message"]
            step_log["llm_response"] = message
            messages.append(message)

            # Check if the agent wants to call tools
            if message.get("tool_calls"):
                for tool_call in message["tool_calls"]:
                    tool_result = await self._execute_tool(
                        tool_call["function"]["name"],
                        json.loads(tool_call["function"]["arguments"])
                    )
                    messages.append({
                        "role": "tool",
                        "tool_call_id": tool_call["id"],
                        "content": json.dumps(tool_result)
                    })
                    step_log["tool_results"] = tool_result

            # Check for final answer (no tool calls)
            elif message.get("content") and not message.get("tool_calls"):
                self.execution_log.append(step_log)
                return {
                    "status": "completed",
                    "answer": message["content"],
                    "iterations_used": iteration + 1,
                    "execution_log": self.execution_log
                }

            # Context budget check
            if self._estimate_tokens(messages) > self.context_budget:
                messages = await self._compress_context(messages)

            self.execution_log.append(step_log)

        return {
            "status": "max_iterations_reached",
            "answer": messages[-1].get("content", "No final answer produced"),
            "execution_log": self.execution_log
        }

    async def _execute_tool(self, name: str, args: Dict[str, Any]) -> Any:
        try:
            # Find the tool function by name
            tool_fn = self._tool_registry.get(name)
            if not tool_fn:
                return {"error": f"Tool '{name}' not found"}
            
            # Execute with timeout and retry
            result = await asyncio.wait_for(
                self._retry_wrapper(tool_fn, args),
                timeout=30.0
            )
            return {"success": True, "data": result}
        except asyncio.TimeoutError:
            return {"error": "Tool execution timed out"}
        except Exception as e:
            return {"error": str(e), "type": type(e).__name__}

    async def _retry_wrapper(self, fn, args, max_retries=3):
        for attempt in range(max_retries):
            try:
                return await fn(**args)
            except Exception as e:
                if attempt == max_retries - 1:
                    raise
                await asyncio.sleep(2 ** attempt)

    def _build_system_prompt(self) -> List[Dict]:
        return [{
            "role": "system",
            "content": (
                "You are an autonomous AI agent. Use the provided tools to accomplish the user's task. "
                "Plan your steps, execute tools, observe results, and adapt. "
                "When you have completed the task, provide a clear final answer without calling additional tools."
            )
        }]

    def _estimate_tokens(self, messages: List[Dict]) -> int:
        # Rough estimate: 1 token ≈ 4 characters for English text
        total_chars = sum(len(json.dumps(m)) for m in messages)
        return total_chars // 4

    async def _compress_context(self, messages: List[Dict]) -> List[Dict]:
        # Summarize older messages, keep recent ones intact
        if len(messages) <= 4:
            return messages
        
        older_messages = messages[:-4]
        recent_messages = messages[-4:]
        
        summary_prompt = (
            "Summarize the following conversation history concisely, "
            "preserving all key facts, decisions, and tool results:\n\n" +
            "\n".join([f"{m['role']}: {m.get('content', str(m.get('tool_calls', '')))}" 
                       for m in older_messages])
        )
        
        summary_response = await self.llm.chat(
            messages=[{"role": "user", "content": summary_prompt}],
            tools=[],
            tool_choice="none"
        )
        
        summary = summary_response["choices"][0]["message"]["content"]
        return [
            messages[0],  # Keep system prompt
            {"role": "assistant", "content": f"[Context Summary] {summary}"}
        ] + recent_messages

2. Tool Registry with Validation

Tools are your agent's hands. A robust tool registry validates inputs before execution and provides clear error messages back to the LLM so it can self-correct:

from functools import wraps
from typing import Callable, get_type_hints
import inspect

class ToolRegistry:
    def __init__(self):
        self._tools: Dict[str, Callable] = {}
        self._schemas: Dict[str, Dict] = {}

    def register(self, name: str, description: str, parameters: Dict[str, Any]):
        """Decorator that registers a function as an agent tool with schema validation."""
        def decorator(fn: Callable):
            @wraps(fn)
            async def wrapper(**kwargs):
                # Validate required parameters
                required = [p for p, schema in parameters.get("properties", {}).items()
                           if p in parameters.get("required", [])]
                missing = [p for p in required if p not in kwargs]
                if missing:
                    raise ValueError(f"Missing required parameters: {missing}")
                
                # Type coercion and validation
                validated_args = {}
                type_hints = get_type_hints(fn)
                for param_name, param_schema in parameters.get("properties", {}).items():
                    if param_name in kwargs:
                        value = kwargs[param_name]
                        expected_type = param_schema.get("type")
                        if expected_type == "integer" and isinstance(value, float):
                            value = int(value)
                        elif expected_type == "number" and isinstance(value, str):
                            try:
                                value = float(value)
                            except ValueError:
                                raise TypeError(f"Parameter '{param_name}' must be a number")
                        validated_args[param_name] = value
                
                return await fn(**validated_args)
            
            # Store the tool
            self._tools[name] = wrapper
            self._schemas[name] = {
                "type": "function",
                "function": {
                    "name": name,
                    "description": description,
                    "parameters": parameters
                }
            }
            return wrapper
        return decorator

    def get_tool_schemas(self) -> List[Dict]:
        return list(self._schemas.values())

    def get_tool(self, name: str) -> Optional[Callable]:
        return self._tools.get(name)

# Usage example
tool_registry = ToolRegistry()

@tool_registry.register(
    name="search_knowledge_base",
    description="Search the company knowledge base for relevant information",
    parameters={
        "type": "object",
        "properties": {
            "query": {"type": "string", "description": "Search query string"},
            "max_results": {"type": "integer", "description": "Maximum number of results (default: 5)"}
        },
        "required": ["query"]
    }
)
async def search_knowledge_base(query: str, max_results: int = 5):
    # Simulate a vector database search
    await asyncio.sleep(0.5)
    return {
        "results": [
            {"title": "Result 1", "relevance": 0.95, "content": "..."},
            {"title": "Result 2", "relevance": 0.82, "content": "..."}
        ][:max_results],
        "total_found": 42
    }

3. Persistent Memory Layer

Stateless agents forget everything between sessions. A memory layer stores facts, user preferences, and past interactions so your agent builds relationships over time:

import sqlite3
import json
from datetime import datetime
from typing import List, Dict, Optional

class MemoryStore:
    def __init__(self, db_path: str = "agent_memory.db"):
        self.conn = sqlite3.connect(db_path)
        self.conn.row_factory = sqlite3.Row
        self._init_schema()

    def _init_schema(self):
        self.conn.executescript("""
            CREATE TABLE IF NOT EXISTS memories (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id TEXT NOT NULL,
                memory_type TEXT NOT NULL CHECK(memory_type IN ('fact', 'preference', 'conversation', 'decision')),
                content TEXT NOT NULL,
                importance REAL DEFAULT 0.5,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                last_accessed TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                access_count INTEGER DEFAULT 0
            );

            CREATE INDEX IF NOT EXISTS idx_memories_user 
                ON memories(user_id, memory_type);
            
            CREATE INDEX IF NOT EXISTS idx_memories_importance 
                ON memories(importance DESC);
            
            CREATE TABLE IF NOT EXISTS conversation_history (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id TEXT NOT NULL,
                session_id TEXT NOT NULL,
                messages TEXT NOT NULL,  -- JSON array of messages
                summary TEXT,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            );
        """)

    def store(self, user_id: str, content: str, memory_type: str = "fact", importance: float = 0.5):
        cursor = self.conn.execute(
            """INSERT INTO memories (user_id, memory_type, content, importance)
               VALUES (?, ?, ?, ?)""",
            (user_id, memory_type, content, importance)
        )
        self.conn.commit()
        return cursor.lastrowid

    def retrieve(self, user_id: str, query: str = None, memory_type: str = None, limit: int = 10) -> List[Dict]:
        sql = """SELECT * FROM memories WHERE user_id = ?"""
        params = [user_id]
        
        if memory_type:
            sql += " AND memory_type = ?"
            params.append(memory_type)
        
        if query:
            sql += " AND content LIKE ?"
            params.append(f"%{query}%")
        
        sql += " ORDER BY importance DESC, last_accessed DESC LIMIT ?"
        params.append(limit)
        
        cursor = self.conn.execute(sql, params)
        results = [dict(row) for row in cursor.fetchall()]
        
        # Update access metadata
        if results:
            ids = [r['id'] for r in results]
            self.conn.execute(
                f"""UPDATE memories 
                    SET last_accessed = CURRENT_TIMESTAMP, 
                        access_count = access_count + 1 
                    WHERE id IN ({','.join('?' * len(ids))})""",
                ids
            )
            self.conn.commit()
        
        return results

    def save_conversation(self, user_id: str, session_id: str, messages: List[Dict], summary: str = None):
        self.conn.execute(
            """INSERT INTO conversation_history (user_id, session_id, messages, summary)
               VALUES (?, ?, ?, ?)""",
            (user_id, session_id, json.dumps(messages), summary)
        )
        self.conn.commit()

    def load_conversation(self, session_id: str) -> Optional[Dict]:
        cursor = self.conn.execute(
            "SELECT * FROM conversation_history WHERE session_id = ? ORDER BY id DESC LIMIT 1",
            (session_id,)
        )
        row = cursor.fetchone()
        if row:
            result = dict(row)
            result['messages'] = json.loads(result['messages'])
            return result
        return None

    def consolidate(self, user_id: str):
        """Extract key facts from recent conversations and store as memories."""
        conversations = self.conn.execute(
            """SELECT messages FROM conversation_history 
               WHERE user_id = ? 
               ORDER BY created_at DESC LIMIT 5""",
            (user_id,)
        ).fetchall()
        
        # In production, you'd send this to an LLM for extraction
        # For now, we flag that consolidation is needed
        return len(conversations)

4. Observability and Logging

When your agent does something unexpected at 3 AM, structured logs are your only lifeline. Here's a logging system that captures every decision point:

import logging
import structlog
from typing import Dict, Any
import uuid

# Configure structured logging
structlog.configure(
    processors=[
        structlog.stdlib.filter_by_level,
        structlog.stdlib.add_logger_name,
        structlog.stdlib.add_log_level,
        structlog.stdlib.PositionalArgumentsFormatter(),
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.StackInfoRenderer(),
        structlog.processors.format_exc_info,
        structlog.processors.JSONRenderer()
    ],
    context_class=dict,
    logger_factory=structlog.stdlib.LoggerFactory(),
    cache_logger_on_first_use=True,
)

logger = structlog.get_logger()

class AgentObserver:
    def __init__(self, agent_id: str):
        self.agent_id = agent_id
        self.trace_id = str(uuid.uuid4())
        self.step_counter = 0

    def log_llm_call(self, messages: List[Dict], response: Dict, latency_ms: float):
        self.step_counter += 1
        logger.info(
            "llm_call",
            agent_id=self.agent_id,
            trace_id=self.trace_id,
            step=self.step_counter,
            input_tokens=self._count_tokens(messages),
            output_tokens=self._count_tokens([response]),
            latency_ms=latency_ms,
            model=response.get("model", "unknown"),
            finish_reason=response["choices"][0].get("finish_reason")
        )

    def log_tool_execution(self, tool_name: str, args: Dict, result: Any, success: bool, latency_ms: float):
        self.step_counter += 1
        logger.info(
            "tool_execution",
            agent_id=self.agent_id,
            trace_id=self.trace_id,
            step=self.step_counter,
            tool_name=tool_name,
            arguments=args,
            success=success,
            latency_ms=latency_ms,
            result_preview=str(result)[:500] if success else str(result)
        )

    def log_error(self, error_type: str, message: str, context: Dict[str, Any]):
        logger.error(
            "agent_error",
            agent_id=self.agent_id,
            trace_id=self.trace_id,
            step=self.step_counter,
            error_type=error_type,
            error_message=message,
            context=context
        )

    def log_completion(self, result: Dict[str, Any], total_steps: int, total_cost: float):
        logger.info(
            "task_complete",
            agent_id=self.agent_id,
            trace_id=self.trace_id,
            total_steps=total_steps,
            total_cost_usd=total_cost,
            status=result.get("status"),
            answer_preview=str(result.get("answer", ""))[:200]
        )

    def _count_tokens(self, messages: List[Dict]) -> int:
        # In production, use a proper tokenizer (tiktoken or similar)
        total_chars = sum(len(str(m.get("content", ""))) for m in messages)
        return total_chars // 4  # Rough heuristic

Building Your First Agent: A Complete Example

Let's assemble all the components into a working agent that can research topics, write content, and save results. This is a production-ready foundation you can extend:

import asyncio
import os
from typing import Dict, Any, List

# Assume the classes above are imported
from agent_runtime import AgentRuntime, ToolRegistry, MemoryStore, AgentObserver

async def main():
    # Initialize infrastructure
    memory = MemoryStore("my_agent_memory.db")
    observer = AgentObserver(agent_id="research-assistant-01")
    registry = ToolRegistry()

    # Define tools
    @registry.register(
        name="web_search",
        description="Search the web for current information on any topic",
        parameters={
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "What to search for"},
                "num_results": {"type": "integer", "description": "Number of results (1-10)"}
            },
            "required": ["query"]
        }
    )
    async def web_search(query: str, num_results: int = 5):
        # In production, call SerpAPI, Google API, or Brave Search
        await asyncio.sleep(1.0)
        return {
            "results": [
                {"title": f"Result for {query}", "url": "https://example.com", "snippet": "..."}
                for _ in range(min(num_results, 5))
            ]
        }

    @registry.register(
        name="save_document",
        description="Save a document to the user's workspace",
        parameters={
            "type": "object",
            "properties": {
                "title": {"type": "string", "description": "Document title"},
                "content": {"type": "string", "description": "Document content"},
                "format": {"type": "string", "enum": ["markdown", "text", "html"]}
            },
            "required": ["title", "content"]
        }
    )
    async def save_document(title: str, content: str, format: str = "markdown"):
        # In production, save to S3, filesystem, or database
        filename = f"{title.lower().replace(' ', '_')}.{format}"
        await asyncio.sleep(0.3)
        return {"saved": True, "filename": filename, "size_bytes": len(content)}

    # Initialize the runtime with an LLM client
    # In production, use OpenAI, Anthropic, or an open-source model
    from openai import AsyncOpenAI
    
    llm_client = AsyncOpenAI(api_key=os.environ["OPENAI_API_KEY"])
    
    # Wrap the client to match our expected interface
    class LLMWrapper:
        def __init__(self, client):
            self.client = client
        
        async def chat(self, messages, tools=None, tool_choice="auto"):
            kwargs = {
                "model": "gpt-4-turbo",
                "messages": messages,
                "temperature": 0.7,
            }
            if tools:
                kwargs["tools"] = tools
                kwargs["tool_choice"] = tool_choice
            
            start = asyncio.get_event_loop().time()
            response = await self.client.chat.completions.create(**kwargs)
            latency = (asyncio.get_event_loop().time() - start) * 1000
            
            # Convert to dict format our runtime expects
            return {
                "choices": [{
                    "index": 0,
                    "message": {
                        "role": "assistant",
                        "content": response.choices[0].message.content,
                        "tool_calls": [
                            {
                                "id": tc.id,
                                "function": {
                                    "name": tc.function.name,
                                    "arguments": tc.function.arguments
                                }
                            } for tc in (response.choices[0].message.tool_calls or [])
                        ] if response.choices[0].message.tool_calls else None
                    },
                    "finish_reason": response.choices[0].finish_reason
                }],
                "model": response.model,
                "usage": {
                    "prompt_tokens": response.usage.prompt_tokens,
                    "completion_tokens": response.usage.completion_tokens
                }
            }

    llm = LLMWrapper(llm_client)
    
    # Create the agent runtime
    agent = AgentRuntime(
        llm_client=llm,
        tools=registry.get_tool_schemas(),
        max_iterations=15,
        context_budget=16000
    )
    agent._tool_registry = registry._tools  # Wire up tool execution

    # Load user context from memory
    user_id = "user_123"
    past_memories = memory.retrieve(user_id, limit=5)
    memory_context = [{
        "role": "system",
        "content": f"Relevant past context: {json.dumps(past_memories)}"
    }] if past_memories else []

    # Run the agent
    task = "Research the latest developments in quantum computing and write a brief summary document"
    
    observer.log_llm_call(
        [{"role": "user", "content": task}],
        {"choices": [{"finish_reason": "started"}]},
        0
    )
    
    result = await agent.run(task, memory=memory_context)
    
    observer.log_completion(result, result.get("iterations_used", 0), 0.05)
    
    # Save conversation to memory
    if result["status"] == "completed":
        memory.store(user_id, f"Researched quantum computing: {result['answer'][:100]}", "fact", 0.8)
    
    print(f"Agent completed in {result['iterations_used']} steps")
    print(f"Answer: {result['answer']}")

if __name__ == "__main__":
    asyncio.run(main())

Deploying to Production

Moving from local development to production requires containerization, API exposure, and proper environment management. Here's a production-ready deployment setup:

# Dockerfile
FROM python:3.11-slim

WORKDIR /app

# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy agent code
COPY agent_runtime/ ./agent_runtime/
COPY tools/ ./tools/
COPY main.py .

# Create non-root user for security
RUN useradd -m -s /bin/bash agent && chown -R agent:agent /app
USER agent

# Health check endpoint
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s \
  CMD curl -f http://localhost:8080/health || exit 1

EXPOSE 8080

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

Now the FastAPI application that wraps your agent as a REST API:

# main.py - Production API Server
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any
import asyncio
import uuid

app = FastAPI(title="AI Agent API", version="1.0.0")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

# In-memory task tracking (use Redis/DB in production)
task_store: Dict[str, Dict[str, Any]] = {}

class TaskRequest(BaseModel):
    task: str = Field(..., description="The task for the agent to perform")
    user_id: str = Field(..., description="Unique user identifier")
    context: Optional[Dict[str, Any]] = Field(default={})
    max_iterations: Optional[int] = Field(default=15, ge=1, le=50)

class TaskResponse(BaseModel):
    task_id: str
    status: str
    message: str

class TaskResult(BaseModel):
    task_id: str
    status: str
    answer: Optional[str]
    iterations_used: int
    execution_log: Optional[list]

@app.on_event("startup")
async def startup():
    # Initialize agent infrastructure
    # In production, load configuration from environment variables
    app.state.agent = None  # Will be initialized lazily per request or pooled
    app.state.memory = MemoryStore("/data/agent_memory.db")

@app.post("/agent/run", response_model=TaskResponse)
async def run_agent_task(request: TaskRequest, background_tasks: BackgroundTasks):
    task_id = str(uuid.uuid4())
    task_store[task_id] = {"status": "pending", "created_at": datetime.utcnow().isoformat()}
    
    background_tasks.add_task(execute_agent_task, task_id, request)
    
    return TaskResponse(
        task_id=task_id,
        status="accepted",
        message="Task queued for execution"
    )

async def execute_agent_task(task_id: str, request: TaskRequest):
    try:
        task_store[task_id]["status"] = "running"
        
        # Initialize agent (in production, use a pool)
        agent = await initialize_agent()
        
        result = await agent.run(request.task)
        
        task_store[task_id].update({
            "status": "completed",
            "answer": result.get("answer"),
            "iterations_used": result.get("iterations_used", 0),
            "execution_log": result.get("execution_log", [])
        })
    except Exception as e:
        task_store[task_id].update({
            "status": "failed",
            "error": str(e)
        })

@app.get("/agent/result/{task_id}", response_model=TaskResult)
async def get_task_result(task_id: str):
    if task_id not in task_store:
        raise HTTPException(status_code=404, detail="Task not found")
    
    task_data = task_store[task_id]
    return TaskResult(
        task_id=task_id,
        status=task_data["status"],
        answer=task_data.get("answer"),
        iterations_used=task_data.get("iterations_used", 0),
        execution_log=task_data.get("execution_log")
    )

@app.get("/health")
async def health_check():
    return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()}

# WebSocket endpoint for real-time agent streaming
from fastapi import WebSocket

@app.websocket("/agent/stream/{user_id}")
async def agent_stream(websocket: WebSocket, user_id: str):
    await websocket.accept()
    try:
        while True:
            task = await websocket.receive_text()
            agent = await initialize_agent()
            
            # Stream each step
            # This requires modifying AgentRuntime to yield intermediate steps
            await websocket.send_json({"type": "status", "message": "Agent started"})
            
            result = await agent.run(task)
            
            await websocket.send_json({
                "type": "complete",
                "answer": result.get("answer"),
                "steps": result.get("iterations_used")
            })
    except Exception as e:
        await websocket.send_json({"type": "error", "message": str(e)})
    finally:
        await websocket.close()

Best Practices for Solo Developers

Start with the Simplest Agent That Works

Resist the urge to build a generalized agent framework from day one. Start with a single tool, a single use case, and a hardcoded system prompt. Get it working end-to-end for one real user before abstracting anything. The fastest path to production is a focused agent that does one thing exceptionally well.

Treat Your System Prompt Like Production Code

Version control your prompts. Test them with a suite of representative inputs. Monitor how changes affect completion quality and token usage. A one-word change in a system prompt can increase costs by 30% or break a critical reasoning path. Store prompts in separate files, not buried in your Python code.

# prompts/research_agent/v1.system.txt
You are a precise research assistant. Follow these rules strictly:

1. For any research task, first use web_search to gather current information
2. Analyze results critically — note dates, sources, and potential biases
3. If information is conflicting, report all sides and explain the disagreement
4. When writing documents, use clear structure: introduction, body, conclusion
5. Never fabricate information. If you cannot find something, say so honestly
6. Keep responses concise but thorough — prefer quality over quantity

# prompts/research_agent/v2.system.txt
You are a precise research assistant with enhanced reasoning...

# In your code:
def load_prompt(version: str = "v1") -> str:
    prompt_path = Path(f"prompts/research_agent/{version}.system.txt")
    if not prompt_path.exists():
        raise ValueError(f"Prompt version {version} not found")
    return prompt_path.read_text()

Implement Graceful Degradation

External APIs will fail. LLM providers will rate-limit you. Your agent must continue operating at reduced capability rather than crashing entirely. Implement fallback chains: if the primary search API fails, try a backup; if the LLM times out, retry with a simpler model; if context overflows, compress aggressively rather than dropping messages.

class ResilientToolExecutor:
    def __init__(self, primary_tools: Dict, fallback_tools: Dict = None):
        self.primary = primary_tools
        self.fallback = fallback_tools or {}
        self.circuit_breaker = {name: {"failures": 0, "open_until": None} 
                                for name in primary_tools}

    async def execute(self, tool_name: str, args: Dict) -> Any:
        # Check circuit breaker
        breaker = self.circuit_breaker.get(tool_name)
        if breaker and breaker["open_until"]:
            if datetime.utcnow() < breaker["open_until"]:
                # Circuit is open — try fallback
                if tool_name in self.fallback:
                    return await self._try_tool(self.fallback[tool_name], args)
                raise Exception(f"Circuit open for {tool_name}, no fallback available")

        # Try primary tool
        try:
            result = await self._try_tool(self.primary[tool_name], args)
            breaker["failures"] = 0  # Reset on success
            return result
        except Exception as e:
            breaker["failures"] += 1
            if breaker["failures"] >= 3:
                breaker["open_until"] = datetime.utcnow() + timedelta(seconds=30)
            
            # Try fallback
            if tool_name in self.fallback:
                return await self._try_tool(self.fallback[tool_name], args)
            raise

    async def _try_tool(self, tool_fn, args, timeout=10):
        return await asyncio.wait_for(tool_fn(**args), timeout=timeout)

Test Your Agent Like You'd Test Software

Write unit tests for individual tools, integration tests for tool chains, and end-to-end tests for complete tasks. Use a deterministic LLM mock for reproducible tests. Test edge cases: empty search results, malformed API responses, tasks that require zero tools, and tasks that exceed the iteration budget.

import pytest
from unittest.mock import AsyncMock

@pytest.fixture
def mock_llm():
    client = AsyncMock()
    client.chat.return_value = {
        "choices": [{
            "index": 0,
            "message": {
                "role": "assistant",
                "content": "Here is the research summary you requested...",
                "tool_calls": None
            },
            "fin

— Ad —

Google AdSense will appear here after approval

← Back to all articles