AI Agents with Function Calling: Build Tools LLMs Can Use
AI Agents with Function Calling: Build Tools LLMs Can Use Create AI agents that can execute code, search the web, and call APIs — using function calling. Introduction Function calling (tool use) transforms
AI Agents with Function Calling: Build Tools LLMs Can Use
Create AI agents that can execute code, search the web, and call APIs — using function calling.
Introduction
Function calling (tool use) transforms LLMs from text generators into agents that can act. This tutorial builds a complete agent framework from scratch.
What is Function Calling?
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."
Prerequisites
- Python 3.10+
- OpenRouter/Groq API key (supports function calling)
-
requestslibrary
Step 1: Define Tools
# 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}"
Step 2: Agent Core
# 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
Step 3: Usage Examples
# 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)
Advanced: Agent with Memory
# 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!
Function Calling Models (Free Tier)
| 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 |
Best Practices
- Clear tool descriptions — LLM reads these to decide when to call
- Strict parameters — Use JSON Schema validation
- Error handling — Tools should return strings, never raise
- Limit turns — Prevent infinite loops (max 10)
- Sanitize inputs — Especially for code execution
Cost: $0/month
All components use free tiers. Function calling works on free models via OpenRouter and Groq.
Conclusion
You now have a working agent framework. Extend it with:
- Database tools (SQL queries)
- API integrations (Slack, GitHub, Notion)
- Browser automation (Playwright)
- Image generation (DALL-E, Stable Diffusion)
- Vector search (your RAG system!)
The pattern is always: define tool → register schema → execute → return result.
Part of the AI Agents series on Dev.to
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.