# AI Agents with Function Calling: Build Tools LLMs Can Use

> Source: <https://dev.to/ipsphil_digital/ai-agents-with-function-calling-build-tools-llms-can-use-2m6d>
> Published: 2026-10-12 16:34:52+00:00

Create AI agents that can execute code, search the web, and call APIs — using function calling.

Function calling (tool use) transforms LLMs from text generators into agents that can **act**. This tutorial builds a complete agent framework from scratch.

LLMs output structured JSON to invoke predefined functions:

```
User: "What's the weather in Tokyo?"
LLM: {"tool": "get_weather", "args": {"city": "Tokyo"}}
System: [executes function, returns result]
LLM: "It's 22°C and sunny in Tokyo."
```

`requests` library

``` python
# tools.py
import os
import requests
import json
from typing import Dict, Any, List
from abc import ABC, abstractmethod

class Tool(ABC):
    @property
    @abstractmethod
    def name(self) -> str: ...

    @property
    @abstractmethod
    def description(self) -> str: ...

    @property
    @abstractmethod
    def parameters(self) -> Dict: ...

    @abstractmethod
    def execute(self, **kwargs) -> Any: ...

# --- Concrete Tools ---

class WebSearchTool(Tool):
    name = "web_search"
    description = "Search the web for current information"
    parameters = {
        "type": "object",
        "properties": {
            "query": {"type": "string", "description": "Search query"}
        },
        "required": ["query"]
    }

    def execute(self, query: str) -> str:
        # Using DuckDuckGo HTML scrape (free, no API key)
        url = "https://html.duckduckgo.com/html/"
        params = {"q": query}
        headers = {"User-Agent": "Mozilla/5.0"}

        try:
            response = requests.post(url, data=params, headers=headers, timeout=10)
            from bs4 import BeautifulSoup
            soup = BeautifulSoup(response.text, 'html.parser')
            results = soup.find_all('a', class_='result__snippet')
            snippets = [r.get_text()[:200] for r in results[:3]]
            return "\n".join(snippets) if snippets else "No results found"
        except Exception as e:
            return f"Search error: {e}"

class PythonExecutorTool(Tool):
    name = "execute_python"
    description = "Execute Python code and return output"
    parameters = {
        "type": "object",
        "properties": {
            "code": {"type": "string", "description": "Python code to execute"}
        },
        "required": ["code"]
    }

    def execute(self, code: str) -> str:
        import subprocess
        import tempfile
        import sys

        # Safety: only allow specific imports
        allowed_imports = {'json', 'math', 'random', 'datetime', 'statistics', 'itertools', 'collections'}

        with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
            f.write(code)
            temp_path = f.name

        try:
            result = subprocess.run(
                [sys.executable, temp_path],
                capture_output=True,
                text=True,
                timeout=10
            )
            os.unlink(temp_path)
            output = result.stdout
            if result.stderr:
                output += f"\nSTDERR: {result.stderr}"
            return output[:2000] if output else "(no output)"
        except subprocess.TimeoutExpired:
            os.unlink(temp_path)
            return "Error: Execution timeout (10s)"
        except Exception as e:
            return f"Error: {e}"

class CalculatorTool(Tool):
    name = "calculate"
    description = "Perform mathematical calculations"
    parameters = {
        "type": "object",
        "properties": {
            "expression": {"type": "string", "description": "Math expression to evaluate"}
        },
        "required": ["expression"]
    }

    def execute(self, expression: str) -> str:
        try:
            # Safe eval for math only
            allowed_names = {"abs", "round", "min", "max", "sum", "pow"}
            allowed_names.update({k: v for k, v in __import__('math').__dict__.items() if not k.startswith('_')})

            code = compile(expression, "<string>", "eval")
            for name in code.co_names:
                if name not in allowed_names:
                    return f"Error: '{name}' not allowed"

            result = eval(code, {"__builtins__": {}}, allowed_names)
            return str(result)
        except Exception as e:
            return f"Error: {e}"

class FileReadTool(Tool):
    name = "read_file"
    description = "Read contents of a file"
    parameters = {
        "type": "object",
        "properties": {
            "path": {"type": "string", "description": "File path to read"}
        },
        "required": ["path"]
    }

    def execute(self, path: str) -> str:
        try:
            with open(path, 'r') as f:
                return f.read()[:5000]
        except Exception as e:
            return f"Error reading file: {e}"

class FileWriteTool(Tool):
    name = "write_file"
    description = "Write content to a file"
    parameters = {
        "type": "object",
        "properties": {
            "path": {"type": "string", "description": "File path to write"},
            "content": {"type": "string", "description": "Content to write"}
        },
        "required": ["path", "content"]
    }

    def execute(self, path: str, content: str) -> str:
        try:
            os.makedirs(os.path.dirname(path) or '.', exist_ok=True)
            with open(path, 'w') as f:
                f.write(content)
            return f"File written: {path}"
        except Exception as e:
            return f"Error writing file: {e}"

# Registry
TOOLS = {
    "web_search": WebSearchTool(),
    "execute_python": PythonExecutorTool(),
    "calculate": CalculatorTool(),
    "read_file": FileReadTool(),
    "write_file": FileWriteTool()
}

def get_tool_schemas() -> List[Dict]:
    """Return OpenAI-compatible tool schemas."""
    return [
        {
            "type": "function",
            "function": {
                "name": tool.name,
                "description": tool.description,
                "parameters": tool.parameters
            }
        }
        for tool in TOOLS.values()
    ]

def execute_tool(name: str, args: Dict) -> str:
    if name not in TOOLS:
        return f"Error: Unknown tool '{name}'"
    try:
        return TOOLS[name].execute(**args)
    except Exception as e:
        return f"Tool error: {e}"
python
# agent.py
import os
import json
import requests
from typing import List, Dict, Any, Optional
from tools import get_tool_schemas, execute_tool

class Agent:
    def __init__(self, provider: str = "openrouter", model: str = None):
        self.provider = provider
        self.configs = {
            "openrouter": {
                "url": "https://openrouter.ai/api/v1/chat/completions",
                "key": os.getenv("OPENROUTER_API_KEY"),
                "model": model or "google/gemini-2.0-flash-exp:free"
            },
            "groq": {
                "url": "https://api.groq.com/openai/v1/chat/completions",
                "key": os.getenv("GROQ_API_KEY"),
                "model": model or "llama-3.1-70b-versatile"
            }
        }
        self.messages = []
        self.tools = get_tool_schemas()
        self.max_turns = 10

    def add_system_prompt(self, prompt: str):
        self.messages.insert(0, {"role": "system", "content": prompt})

    def run(self, user_input: str) -> str:
        self.messages.append({"role": "user", "content": user_input})

        for turn in range(self.max_turns):
            response = self._call_llm()

            if "tool_calls" in response:
                # Execute tools
                tool_results = []
                for tc in response["tool_calls"]:
                    name = tc["function"]["name"]
                    args = json.loads(tc["function"]["arguments"])
                    result = execute_tool(name, args)
                    tool_results.append({
                        "tool_call_id": tc["id"],
                        "role": "tool",
                        "name": name,
                        "content": result
                    })

                # Add tool results to conversation
                self.messages.append({"role": "assistant", "tool_calls": response["tool_calls"]})
                self.messages.extend(tool_results)

            else:
                # Final answer
                content = response.get("content", "")
                self.messages.append({"role": "assistant", "content": content})
                return content

        return "Max turns reached"

    def _call_llm(self) -> Dict:
        config = self.configs[self.provider]

        headers = {
            "Authorization": f"Bearer {config['key']}",
            "Content-Type": "application/json"
        }

        data = {
            "model": config["model"],
            "messages": self.messages,
            "tools": self.tools,
            "tool_choice": "auto",
            "temperature": 0.1,
            "max_tokens": 2000
        }

        response = requests.post(config["url"], headers=headers, json=data, timeout=60)
        response.raise_for_status()
        return response.json()["choices"][0]["message"]

    def run_stream(self, user_input: str):
        """Streaming version."""
        self.messages.append({"role": "user", "content": user_input})

        for turn in range(self.max_turns):
            # First, check if we need tools (non-streaming for tool calls)
            response = self._call_llm()

            if "tool_calls" in response:
                tool_results = []
                for tc in response["tool_calls"]:
                    name = tc["function"]["name"]
                    args = json.loads(tc["function"]["arguments"])
                    result = execute_tool(name, args)
                    tool_results.append({
                        "tool_call_id": tc["id"],
                        "role": "tool",
                        "name": name,
                        "content": result
                    })

                self.messages.append({"role": "assistant", "tool_calls": response["tool_calls"]})
                self.messages.extend(tool_results)
                continue

            # Stream final answer
            content = response.get("content", "")
            self.messages.append({"role": "assistant", "content": content})

            # Simulate streaming by yielding chunks
            for chunk in content.split():
                yield chunk + " "
            return
python
# main.py
import os
from agent import Agent

# Initialize
agent = Agent(provider="openrouter")  # or "groq"

# System prompt
agent.add_system_prompt("""You are a helpful AI assistant with access to tools.
Use tools when you need current information, calculations, or code execution.
Think step by step. Be concise.
Available tools: web_search, execute_python, calculate, read_file, write_file""")

# Example 1: Web search + calculation
print("=== Query 1 ===")
result = agent.run("What's the current population of Tokyo? Calculate 15% of it.")
print(result)

# Example 2: Code execution
print("\n=== Query 2 ===")
result = agent.run("""
Write a Python function to find the nth Fibonacci number, 
then compute the 20th Fibonacci number.
""")
print(result)

# Example 3: File operations
print("\n=== Query 3 ===")
result = agent.run("""
Create a file 'todo.txt' with these items:
1. Learn function calling
2. Build an agent
3. Deploy it
Then read it back.
""")
print(result)

# Example 4: Multi-step reasoning
print("\n=== Query 4 ===")
result = agent.run("""
Find the current price of Bitcoin, then calculate how much 
0.5 BTC would be worth in USD.
""")
print(result)
python
# agent_with_memory.py
import json
from agent import Agent

class PersistentAgent(Agent):
    def __init__(self, *args, memory_file: str = "agent_memory.json", **kwargs):
        super().__init__(*args, **kwargs)
        self.memory_file = memory_file
        self.load_memory()

    def load_memory(self):
        try:
            with open(self.memory_file, 'r') as f:
                data = json.load(f)
                self.messages = data.get("messages", [])
        except FileNotFoundError:
            pass

    def save_memory(self):
        with open(self.memory_file, 'w') as f:
            json.dump({"messages": self.messages[-20:]}, f)  # Keep last 20

    def run(self, user_input: str) -> str:
        result = super().run(user_input)
        self.save_memory()
        return result

# Usage
agent = PersistentAgent(memory_file="my_agent_memory.json")
print(agent.run("Remember my name is Alex"))
print(agent.run("What's my name?"))  # Will remember!
```

| Provider | Model | Function Calling | 
|---|---|---|
| OpenRouter | `google/gemini-2.0-flash-exp:free` | ✅ Excellent | 
| OpenRouter | `mistralai/mistral-7b-instruct:free` | ✅ Good | 
| Groq | `llama-3.1-70b-versatile` | ✅ Excellent | 
| Groq | `llama-3.1-8b-instant` | ✅ Good | 
| Together | `meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo` | ✅ Good | 

All components use free tiers. Function calling works on free models via OpenRouter and Groq.

You now have a working agent framework. Extend it with:

The pattern is always: define tool → register schema → execute → return result.

*Part of the AI Agents series on Dev.to*
