Building LLM Agents with Tools: From Simple Loops to Production Systems

Introduction: LLM agents extend language models beyond text generation into autonomous action. By connecting LLMs to tools—web search, code execution, APIs, databases—agents can gather information, perform calculations, and interact with external systems. This guide covers building tool-using agents from scratch: defining tools with schemas, implementing the reasoning loop, handling tool execution, managing conversation state, and building production-ready agent systems. These patterns form the foundation for building AI assistants that can actually do things, not just talk about them.

LLM Agents with Tools
LLM Agents: Reasoning Engine Connected to Tool Registry

Defining Tools

from dataclasses import dataclass
from typing import Callable, Any
import json
import inspect

@dataclass
class Tool:
    """A tool that an agent can use."""
    
    name: str
    description: str
    parameters: dict  # JSON Schema
    function: Callable[..., Any]
    
    def to_openai_schema(self) -> dict:
        """Convert to OpenAI function calling format."""
        return {
            "type": "function",
            "function": {
                "name": self.name,
                "description": self.description,
                "parameters": self.parameters
            }
        }
    
    def execute(self, **kwargs) -> Any:
        """Execute the tool with given arguments."""
        return self.function(**kwargs)

def tool(name: str = None, description: str = None):
    """Decorator to create a tool from a function."""
    
    def decorator(func: Callable) -> Tool:
        tool_name = name or func.__name__
        tool_desc = description or func.__doc__ or ""
        
        # Extract parameters from type hints
        sig = inspect.signature(func)
        hints = func.__annotations__
        
        properties = {}
        required = []
        
        for param_name, param in sig.parameters.items():
            if param_name == 'return':
                continue
            
            param_type = hints.get(param_name, str)
            
            # Map Python types to JSON Schema
            type_map = {
                str: "string",
                int: "integer",
                float: "number",
                bool: "boolean",
                list: "array",
                dict: "object"
            }
            
            properties[param_name] = {
                "type": type_map.get(param_type, "string"),
                "description": f"Parameter: {param_name}"
            }
            
            if param.default == inspect.Parameter.empty:
                required.append(param_name)
        
        parameters = {
            "type": "object",
            "properties": properties,
            "required": required
        }
        
        return Tool(
            name=tool_name,
            description=tool_desc,
            parameters=parameters,
            function=func
        )
    
    return decorator

# Define tools using decorator
@tool(name="calculator", description="Perform mathematical calculations")
def calculate(expression: str) -> str:
    """Evaluate a mathematical expression."""
    try:
        # Safe evaluation of math expressions
        allowed = set('0123456789+-*/.() ')
        if not all(c in allowed for c in expression):
            return "Error: Invalid characters in expression"
        result = eval(expression)
        return str(result)
    except Exception as e:
        return f"Error: {e}"

@tool(name="web_search", description="Search the web for information")
def search_web(query: str, num_results: int = 5) -> str:
    """Search the web and return results."""
    # In production, use actual search API
    return f"Search results for '{query}': [simulated results]"

@tool(name="get_weather", description="Get current weather for a location")
def get_weather(location: str) -> str:
    """Get weather information."""
    # In production, use weather API
    return f"Weather in {location}: 72°F, Sunny"

Basic Agent Loop

from openai import OpenAI
from typing import Optional

client = OpenAI()

class SimpleAgent:
    """Basic agent with tool-calling capability."""
    
    def __init__(self, tools: list[Tool], model: str = "gpt-4o"):
        self.tools = {t.name: t for t in tools}
        self.model = model
        self.messages = []
    
    def _get_tool_schemas(self) -> list[dict]:
        """Get OpenAI-compatible tool schemas."""
        return [t.to_openai_schema() for t in self.tools.values()]
    
    def _execute_tool(self, name: str, arguments: dict) -> str:
        """Execute a tool and return result."""
        if name not in self.tools:
            return f"Error: Unknown tool '{name}'"
        
        try:
            result = self.tools[name].execute(**arguments)
            return str(result)
        except Exception as e:
            return f"Error executing {name}: {e}"
    
    def run(self, user_message: str, max_iterations: int = 10) -> str:
        """Run the agent loop."""
        
        self.messages.append({"role": "user", "content": user_message})
        
        for _ in range(max_iterations):
            response = client.chat.completions.create(
                model=self.model,
                messages=self.messages,
                tools=self._get_tool_schemas(),
                tool_choice="auto"
            )
            
            message = response.choices[0].message
            self.messages.append(message)
            
            # Check if we're done (no tool calls)
            if not message.tool_calls:
                return message.content
            
            # Execute each tool call
            for tool_call in message.tool_calls:
                name = tool_call.function.name
                args = json.loads(tool_call.function.arguments)
                
                result = self._execute_tool(name, args)
                
                self.messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": result
                })
        
        return "Max iterations reached"

# Usage
agent = SimpleAgent([calculate, search_web, get_weather])

response = agent.run("What's 15% of 847, and what's the weather in Seattle?")
print(response)

ReAct Agent Pattern

from dataclasses import dataclass
from enum import Enum

class ActionType(str, Enum):
    THINK = "think"
    ACT = "act"
    OBSERVE = "observe"
    FINISH = "finish"

@dataclass
class AgentStep:
    action_type: ActionType
    content: str
    tool_name: Optional[str] = None
    tool_args: Optional[dict] = None

class ReActAgent:
    """Agent using Reasoning + Acting pattern."""
    
    SYSTEM_PROMPT = """You are a helpful assistant that can use tools to answer questions.

For each step, you must:
1. THINK: Reason about what to do next
2. ACT: Choose a tool to use (or FINISH if done)
3. OBSERVE: See the result

Available tools:
{tools}

Format your response as:
THINK: [your reasoning]
ACT: [tool_name](arg1="value1", arg2="value2")
or
THINK: [your reasoning]
FINISH: [your final answer]"""
    
    def __init__(self, tools: list[Tool], model: str = "gpt-4o"):
        self.tools = {t.name: t for t in tools}
        self.model = model
        self.trace: list[AgentStep] = []
    
    def _format_tools(self) -> str:
        """Format tool descriptions for prompt."""
        lines = []
        for tool in self.tools.values():
            params = ", ".join(
                f"{k}: {v['type']}"
                for k, v in tool.parameters.get('properties', {}).items()
            )
            lines.append(f"- {tool.name}({params}): {tool.description}")
        return "\n".join(lines)
    
    def _parse_response(self, text: str) -> AgentStep:
        """Parse agent response into structured step."""
        
        lines = text.strip().split('\n')
        think_content = ""
        
        for line in lines:
            if line.startswith("THINK:"):
                think_content = line[6:].strip()
            
            elif line.startswith("ACT:"):
                action = line[4:].strip()
                # Parse tool call: tool_name(arg1="val1", ...)
                if '(' in action:
                    name = action[:action.index('(')]
                    args_str = action[action.index('(')+1:action.rindex(')')]
                    
                    # Simple arg parsing
                    args = {}
                    for part in args_str.split(','):
                        if '=' in part:
                            k, v = part.split('=', 1)
                            args[k.strip()] = v.strip().strip('"\'')
                    
                    return AgentStep(
                        action_type=ActionType.ACT,
                        content=think_content,
                        tool_name=name,
                        tool_args=args
                    )
            
            elif line.startswith("FINISH:"):
                return AgentStep(
                    action_type=ActionType.FINISH,
                    content=line[7:].strip()
                )
        
        return AgentStep(action_type=ActionType.THINK, content=think_content)
    
    def run(self, query: str, max_steps: int = 10) -> str:
        """Run the ReAct loop."""
        
        system = self.SYSTEM_PROMPT.format(tools=self._format_tools())
        messages = [
            {"role": "system", "content": system},
            {"role": "user", "content": query}
        ]
        
        for step_num in range(max_steps):
            response = client.chat.completions.create(
                model=self.model,
                messages=messages
            )
            
            text = response.choices[0].message.content
            step = self._parse_response(text)
            self.trace.append(step)
            
            if step.action_type == ActionType.FINISH:
                return step.content
            
            if step.action_type == ActionType.ACT and step.tool_name:
                # Execute tool
                if step.tool_name in self.tools:
                    result = self.tools[step.tool_name].execute(**step.tool_args)
                else:
                    result = f"Unknown tool: {step.tool_name}"
                
                # Add observation
                observation = f"OBSERVE: {result}"
                self.trace.append(AgentStep(
                    action_type=ActionType.OBSERVE,
                    content=result
                ))
                
                messages.append({"role": "assistant", "content": text})
                messages.append({"role": "user", "content": observation})
        
        return "Max steps reached without conclusion"

# Usage
react_agent = ReActAgent([calculate, search_web, get_weather])
result = react_agent.run("Calculate 20% tip on a $85 bill")

# View reasoning trace
for step in react_agent.trace:
    print(f"{step.action_type.value}: {step.content}")

Tool Registry

from typing import Dict, List, Optional
import importlib
import yaml

class ToolRegistry:
    """Central registry for agent tools."""
    
    def __init__(self):
        self._tools: Dict[str, Tool] = {}
        self._categories: Dict[str, List[str]] = {}
    
    def register(self, tool: Tool, category: str = "general"):
        """Register a tool."""
        self._tools[tool.name] = tool
        
        if category not in self._categories:
            self._categories[category] = []
        self._categories[category].append(tool.name)
    
    def get(self, name: str) -> Optional[Tool]:
        """Get a tool by name."""
        return self._tools.get(name)
    
    def get_by_category(self, category: str) -> List[Tool]:
        """Get all tools in a category."""
        names = self._categories.get(category, [])
        return [self._tools[n] for n in names if n in self._tools]
    
    def list_all(self) -> List[Tool]:
        """List all registered tools."""
        return list(self._tools.values())
    
    def load_from_config(self, config_path: str):
        """Load tools from YAML config."""
        with open(config_path) as f:
            config = yaml.safe_load(f)
        
        for tool_config in config.get('tools', []):
            # Dynamic import of tool function
            module_path = tool_config['module']
            func_name = tool_config['function']
            
            module = importlib.import_module(module_path)
            func = getattr(module, func_name)
            
            tool = Tool(
                name=tool_config['name'],
                description=tool_config['description'],
                parameters=tool_config['parameters'],
                function=func
            )
            
            self.register(tool, tool_config.get('category', 'general'))

# Global registry
registry = ToolRegistry()

# Register tools
registry.register(calculate, "math")
registry.register(search_web, "search")
registry.register(get_weather, "utilities")

# Use in agent
math_tools = registry.get_by_category("math")
agent = SimpleAgent(math_tools)

Async Tool Execution

import asyncio
from typing import Coroutine

class AsyncTool(Tool):
    """Tool with async execution support."""
    
    async def execute_async(self, **kwargs) -> Any:
        """Execute tool asynchronously."""
        if asyncio.iscoroutinefunction(self.function):
            return await self.function(**kwargs)
        else:
            # Run sync function in thread pool
            loop = asyncio.get_event_loop()
            return await loop.run_in_executor(None, lambda: self.function(**kwargs))

class AsyncAgent:
    """Agent with parallel tool execution."""
    
    def __init__(self, tools: list[AsyncTool], model: str = "gpt-4o"):
        self.tools = {t.name: t for t in tools}
        self.model = model
    
    async def _execute_tools_parallel(
        self,
        tool_calls: list
    ) -> list[dict]:
        """Execute multiple tool calls in parallel."""
        
        async def execute_one(tool_call):
            name = tool_call.function.name
            args = json.loads(tool_call.function.arguments)
            
            if name in self.tools:
                result = await self.tools[name].execute_async(**args)
            else:
                result = f"Unknown tool: {name}"
            
            return {
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": str(result)
            }
        
        results = await asyncio.gather(*[
            execute_one(tc) for tc in tool_calls
        ])
        
        return list(results)
    
    async def run(self, user_message: str, max_iterations: int = 10) -> str:
        """Run agent with async tool execution."""
        
        messages = [{"role": "user", "content": user_message}]
        
        for _ in range(max_iterations):
            response = client.chat.completions.create(
                model=self.model,
                messages=messages,
                tools=[t.to_openai_schema() for t in self.tools.values()],
                tool_choice="auto"
            )
            
            message = response.choices[0].message
            messages.append(message)
            
            if not message.tool_calls:
                return message.content
            
            # Execute all tool calls in parallel
            tool_results = await self._execute_tools_parallel(message.tool_calls)
            messages.extend(tool_results)
        
        return "Max iterations reached"

# Usage
async def main():
    agent = AsyncAgent([
        AsyncTool("search1", "Search source 1", {}, search_web),
        AsyncTool("search2", "Search source 2", {}, search_web),
    ])
    
    # Both searches run in parallel
    result = await agent.run("Compare info from source 1 and source 2 about Python")
    print(result)

asyncio.run(main())

Production Agent Service

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional
import uuid

app = FastAPI()

class AgentRequest(BaseModel):
    message: str
    session_id: Optional[str] = None
    tools: list[str] = []  # Tool names to enable

class AgentResponse(BaseModel):
    response: str
    session_id: str
    tool_calls: list[dict]
    tokens_used: int

# Session storage (use Redis in production)
sessions: dict[str, list] = {}

@app.post("/agent/chat", response_model=AgentResponse)
async def chat(request: AgentRequest):
    """Chat with the agent."""
    
    # Get or create session
    session_id = request.session_id or str(uuid.uuid4())
    messages = sessions.get(session_id, [])
    
    # Get enabled tools
    enabled_tools = [
        registry.get(name)
        for name in request.tools
        if registry.get(name)
    ]
    
    # Add user message
    messages.append({"role": "user", "content": request.message})
    
    # Run agent
    tool_calls_made = []
    
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=messages,
        tools=[t.to_openai_schema() for t in enabled_tools] if enabled_tools else None
    )
    
    message = response.choices[0].message
    
    # Handle tool calls
    if message.tool_calls:
        messages.append(message)
        
        for tc in message.tool_calls:
            name = tc.function.name
            args = json.loads(tc.function.arguments)
            
            tool = registry.get(name)
            if tool:
                result = tool.execute(**args)
                tool_calls_made.append({
                    "tool": name,
                    "args": args,
                    "result": str(result)[:500]
                })
                
                messages.append({
                    "role": "tool",
                    "tool_call_id": tc.id,
                    "content": str(result)
                })
        
        # Get final response
        response = client.chat.completions.create(
            model="gpt-4o",
            messages=messages
        )
        message = response.choices[0].message
    
    messages.append({"role": "assistant", "content": message.content})
    sessions[session_id] = messages
    
    return AgentResponse(
        response=message.content,
        session_id=session_id,
        tool_calls=tool_calls_made,
        tokens_used=response.usage.total_tokens
    )

@app.get("/tools")
async def list_tools():
    """List available tools."""
    return {
        "tools": [
            {"name": t.name, "description": t.description}
            for t in registry.list_all()
        ]
    }

References

Conclusion

Tool-using agents transform LLMs from text generators into capable assistants that can take action. Start with well-defined tools—clear names, descriptions, and parameter schemas help the model choose correctly. Implement the basic agent loop first, then add sophistication with ReAct-style reasoning traces for transparency. Use a tool registry for clean organization and dynamic tool loading. For production, add async execution for parallel tool calls, session management for multi-turn conversations, and proper error handling. The combination of LLM reasoning and tool execution creates agents that can research, calculate, query APIs, and accomplish real tasks autonomously.


Discover more from Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.