{"slug": "ai-agents-with-function-calling-build-tools-llms-can-use", "title": "AI Agents with Function Calling: Build Tools LLMs Can Use", "summary": "A developer published a from-scratch framework for building LLM agents using function calling, in which the model emits structured JSON to invoke predefined tools. The implementation defines an abstract Tool class with name, description, parameters and execute members, plus concrete tools for DuckDuckGo web search, sandboxed Python execution with an import allowlist and a 10-second subprocess timeout, and a math-only calculator.", "body_md": "Create AI agents that can execute code, search the web, and call APIs — using function calling.\n\nFunction calling (tool use) transforms LLMs from text generators into agents that can **act**. This tutorial builds a complete agent framework from scratch.\n\nLLMs output structured JSON to invoke predefined functions:\n\n```\nUser: \"What's the weather in Tokyo?\"\nLLM: {\"tool\": \"get_weather\", \"args\": {\"city\": \"Tokyo\"}}\nSystem: [executes function, returns result]\nLLM: \"It's 22°C and sunny in Tokyo.\"\n```\n\n`requests` library\n\n``` python\n# tools.py\nimport os\nimport requests\nimport json\nfrom typing import Dict, Any, List\nfrom abc import ABC, abstractmethod\n\nclass Tool(ABC):\n    @property\n    @abstractmethod\n    def name(self) -> str: ...\n\n    @property\n    @abstractmethod\n    def description(self) -> str: ...\n\n    @property\n    @abstractmethod\n    def parameters(self) -> Dict: ...\n\n    @abstractmethod\n    def execute(self, **kwargs) -> Any: ...\n\n# --- Concrete Tools ---\n\nclass WebSearchTool(Tool):\n    name = \"web_search\"\n    description = \"Search the web for current information\"\n    parameters = {\n        \"type\": \"object\",\n        \"properties\": {\n            \"query\": {\"type\": \"string\", \"description\": \"Search query\"}\n        },\n        \"required\": [\"query\"]\n    }\n\n    def execute(self, query: str) -> str:\n        # Using DuckDuckGo HTML scrape (free, no API key)\n        url = \"https://html.duckduckgo.com/html/\"\n        params = {\"q\": query}\n        headers = {\"User-Agent\": \"Mozilla/5.0\"}\n\n        try:\n            response = requests.post(url, data=params, headers=headers, timeout=10)\n            from bs4 import BeautifulSoup\n            soup = BeautifulSoup(response.text, 'html.parser')\n            results = soup.find_all('a', class_='result__snippet')\n            snippets = [r.get_text()[:200] for r in results[:3]]\n            return \"\\n\".join(snippets) if snippets else \"No results found\"\n        except Exception as e:\n            return f\"Search error: {e}\"\n\nclass PythonExecutorTool(Tool):\n    name = \"execute_python\"\n    description = \"Execute Python code and return output\"\n    parameters = {\n        \"type\": \"object\",\n        \"properties\": {\n            \"code\": {\"type\": \"string\", \"description\": \"Python code to execute\"}\n        },\n        \"required\": [\"code\"]\n    }\n\n    def execute(self, code: str) -> str:\n        import subprocess\n        import tempfile\n        import sys\n\n        # Safety: only allow specific imports\n        allowed_imports = {'json', 'math', 'random', 'datetime', 'statistics', 'itertools', 'collections'}\n\n        with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:\n            f.write(code)\n            temp_path = f.name\n\n        try:\n            result = subprocess.run(\n                [sys.executable, temp_path],\n                capture_output=True,\n                text=True,\n                timeout=10\n            )\n            os.unlink(temp_path)\n            output = result.stdout\n            if result.stderr:\n                output += f\"\\nSTDERR: {result.stderr}\"\n            return output[:2000] if output else \"(no output)\"\n        except subprocess.TimeoutExpired:\n            os.unlink(temp_path)\n            return \"Error: Execution timeout (10s)\"\n        except Exception as e:\n            return f\"Error: {e}\"\n\nclass CalculatorTool(Tool):\n    name = \"calculate\"\n    description = \"Perform mathematical calculations\"\n    parameters = {\n        \"type\": \"object\",\n        \"properties\": {\n            \"expression\": {\"type\": \"string\", \"description\": \"Math expression to evaluate\"}\n        },\n        \"required\": [\"expression\"]\n    }\n\n    def execute(self, expression: str) -> str:\n        try:\n            # Safe eval for math only\n            allowed_names = {\"abs\", \"round\", \"min\", \"max\", \"sum\", \"pow\"}\n            allowed_names.update({k: v for k, v in __import__('math').__dict__.items() if not k.startswith('_')})\n\n            code = compile(expression, \"<string>\", \"eval\")\n            for name in code.co_names:\n                if name not in allowed_names:\n                    return f\"Error: '{name}' not allowed\"\n\n            result = eval(code, {\"__builtins__\": {}}, allowed_names)\n            return str(result)\n        except Exception as e:\n            return f\"Error: {e}\"\n\nclass FileReadTool(Tool):\n    name = \"read_file\"\n    description = \"Read contents of a file\"\n    parameters = {\n        \"type\": \"object\",\n        \"properties\": {\n            \"path\": {\"type\": \"string\", \"description\": \"File path to read\"}\n        },\n        \"required\": [\"path\"]\n    }\n\n    def execute(self, path: str) -> str:\n        try:\n            with open(path, 'r') as f:\n                return f.read()[:5000]\n        except Exception as e:\n            return f\"Error reading file: {e}\"\n\nclass FileWriteTool(Tool):\n    name = \"write_file\"\n    description = \"Write content to a file\"\n    parameters = {\n        \"type\": \"object\",\n        \"properties\": {\n            \"path\": {\"type\": \"string\", \"description\": \"File path to write\"},\n            \"content\": {\"type\": \"string\", \"description\": \"Content to write\"}\n        },\n        \"required\": [\"path\", \"content\"]\n    }\n\n    def execute(self, path: str, content: str) -> str:\n        try:\n            os.makedirs(os.path.dirname(path) or '.', exist_ok=True)\n            with open(path, 'w') as f:\n                f.write(content)\n            return f\"File written: {path}\"\n        except Exception as e:\n            return f\"Error writing file: {e}\"\n\n# Registry\nTOOLS = {\n    \"web_search\": WebSearchTool(),\n    \"execute_python\": PythonExecutorTool(),\n    \"calculate\": CalculatorTool(),\n    \"read_file\": FileReadTool(),\n    \"write_file\": FileWriteTool()\n}\n\ndef get_tool_schemas() -> List[Dict]:\n    \"\"\"Return OpenAI-compatible tool schemas.\"\"\"\n    return [\n        {\n            \"type\": \"function\",\n            \"function\": {\n                \"name\": tool.name,\n                \"description\": tool.description,\n                \"parameters\": tool.parameters\n            }\n        }\n        for tool in TOOLS.values()\n    ]\n\ndef execute_tool(name: str, args: Dict) -> str:\n    if name not in TOOLS:\n        return f\"Error: Unknown tool '{name}'\"\n    try:\n        return TOOLS[name].execute(**args)\n    except Exception as e:\n        return f\"Tool error: {e}\"\npython\n# agent.py\nimport os\nimport json\nimport requests\nfrom typing import List, Dict, Any, Optional\nfrom tools import get_tool_schemas, execute_tool\n\nclass Agent:\n    def __init__(self, provider: str = \"openrouter\", model: str = None):\n        self.provider = provider\n        self.configs = {\n            \"openrouter\": {\n                \"url\": \"https://openrouter.ai/api/v1/chat/completions\",\n                \"key\": os.getenv(\"OPENROUTER_API_KEY\"),\n                \"model\": model or \"google/gemini-2.0-flash-exp:free\"\n            },\n            \"groq\": {\n                \"url\": \"https://api.groq.com/openai/v1/chat/completions\",\n                \"key\": os.getenv(\"GROQ_API_KEY\"),\n                \"model\": model or \"llama-3.1-70b-versatile\"\n            }\n        }\n        self.messages = []\n        self.tools = get_tool_schemas()\n        self.max_turns = 10\n\n    def add_system_prompt(self, prompt: str):\n        self.messages.insert(0, {\"role\": \"system\", \"content\": prompt})\n\n    def run(self, user_input: str) -> str:\n        self.messages.append({\"role\": \"user\", \"content\": user_input})\n\n        for turn in range(self.max_turns):\n            response = self._call_llm()\n\n            if \"tool_calls\" in response:\n                # Execute tools\n                tool_results = []\n                for tc in response[\"tool_calls\"]:\n                    name = tc[\"function\"][\"name\"]\n                    args = json.loads(tc[\"function\"][\"arguments\"])\n                    result = execute_tool(name, args)\n                    tool_results.append({\n                        \"tool_call_id\": tc[\"id\"],\n                        \"role\": \"tool\",\n                        \"name\": name,\n                        \"content\": result\n                    })\n\n                # Add tool results to conversation\n                self.messages.append({\"role\": \"assistant\", \"tool_calls\": response[\"tool_calls\"]})\n                self.messages.extend(tool_results)\n\n            else:\n                # Final answer\n                content = response.get(\"content\", \"\")\n                self.messages.append({\"role\": \"assistant\", \"content\": content})\n                return content\n\n        return \"Max turns reached\"\n\n    def _call_llm(self) -> Dict:\n        config = self.configs[self.provider]\n\n        headers = {\n            \"Authorization\": f\"Bearer {config['key']}\",\n            \"Content-Type\": \"application/json\"\n        }\n\n        data = {\n            \"model\": config[\"model\"],\n            \"messages\": self.messages,\n            \"tools\": self.tools,\n            \"tool_choice\": \"auto\",\n            \"temperature\": 0.1,\n            \"max_tokens\": 2000\n        }\n\n        response = requests.post(config[\"url\"], headers=headers, json=data, timeout=60)\n        response.raise_for_status()\n        return response.json()[\"choices\"][0][\"message\"]\n\n    def run_stream(self, user_input: str):\n        \"\"\"Streaming version.\"\"\"\n        self.messages.append({\"role\": \"user\", \"content\": user_input})\n\n        for turn in range(self.max_turns):\n            # First, check if we need tools (non-streaming for tool calls)\n            response = self._call_llm()\n\n            if \"tool_calls\" in response:\n                tool_results = []\n                for tc in response[\"tool_calls\"]:\n                    name = tc[\"function\"][\"name\"]\n                    args = json.loads(tc[\"function\"][\"arguments\"])\n                    result = execute_tool(name, args)\n                    tool_results.append({\n                        \"tool_call_id\": tc[\"id\"],\n                        \"role\": \"tool\",\n                        \"name\": name,\n                        \"content\": result\n                    })\n\n                self.messages.append({\"role\": \"assistant\", \"tool_calls\": response[\"tool_calls\"]})\n                self.messages.extend(tool_results)\n                continue\n\n            # Stream final answer\n            content = response.get(\"content\", \"\")\n            self.messages.append({\"role\": \"assistant\", \"content\": content})\n\n            # Simulate streaming by yielding chunks\n            for chunk in content.split():\n                yield chunk + \" \"\n            return\npython\n# main.py\nimport os\nfrom agent import Agent\n\n# Initialize\nagent = Agent(provider=\"openrouter\")  # or \"groq\"\n\n# System prompt\nagent.add_system_prompt(\"\"\"You are a helpful AI assistant with access to tools.\nUse tools when you need current information, calculations, or code execution.\nThink step by step. Be concise.\nAvailable tools: web_search, execute_python, calculate, read_file, write_file\"\"\")\n\n# Example 1: Web search + calculation\nprint(\"=== Query 1 ===\")\nresult = agent.run(\"What's the current population of Tokyo? Calculate 15% of it.\")\nprint(result)\n\n# Example 2: Code execution\nprint(\"\\n=== Query 2 ===\")\nresult = agent.run(\"\"\"\nWrite a Python function to find the nth Fibonacci number, \nthen compute the 20th Fibonacci number.\n\"\"\")\nprint(result)\n\n# Example 3: File operations\nprint(\"\\n=== Query 3 ===\")\nresult = agent.run(\"\"\"\nCreate a file 'todo.txt' with these items:\n1. Learn function calling\n2. Build an agent\n3. Deploy it\nThen read it back.\n\"\"\")\nprint(result)\n\n# Example 4: Multi-step reasoning\nprint(\"\\n=== Query 4 ===\")\nresult = agent.run(\"\"\"\nFind the current price of Bitcoin, then calculate how much \n0.5 BTC would be worth in USD.\n\"\"\")\nprint(result)\npython\n# agent_with_memory.py\nimport json\nfrom agent import Agent\n\nclass PersistentAgent(Agent):\n    def __init__(self, *args, memory_file: str = \"agent_memory.json\", **kwargs):\n        super().__init__(*args, **kwargs)\n        self.memory_file = memory_file\n        self.load_memory()\n\n    def load_memory(self):\n        try:\n            with open(self.memory_file, 'r') as f:\n                data = json.load(f)\n                self.messages = data.get(\"messages\", [])\n        except FileNotFoundError:\n            pass\n\n    def save_memory(self):\n        with open(self.memory_file, 'w') as f:\n            json.dump({\"messages\": self.messages[-20:]}, f)  # Keep last 20\n\n    def run(self, user_input: str) -> str:\n        result = super().run(user_input)\n        self.save_memory()\n        return result\n\n# Usage\nagent = PersistentAgent(memory_file=\"my_agent_memory.json\")\nprint(agent.run(\"Remember my name is Alex\"))\nprint(agent.run(\"What's my name?\"))  # Will remember!\n```\n\n| Provider | Model | Function Calling | \n|---|---|---|\n| OpenRouter | `google/gemini-2.0-flash-exp:free` | ✅ Excellent | \n| OpenRouter | `mistralai/mistral-7b-instruct:free` | ✅ Good | \n| Groq | `llama-3.1-70b-versatile` | ✅ Excellent | \n| Groq | `llama-3.1-8b-instant` | ✅ Good | \n| Together | `meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo` | ✅ Good | \n\nAll components use free tiers. Function calling works on free models via OpenRouter and Groq.\n\nYou now have a working agent framework. Extend it with:\n\nThe pattern is always: define tool → register schema → execute → return result.\n\n*Part of the AI Agents series on Dev.to*", "url": "https://wpnews.pro/news/ai-agents-with-function-calling-build-tools-llms-can-use", "canonical_source": "https://dev.to/ipsphil_digital/ai-agents-with-function-calling-build-tools-llms-can-use-2m6d", "published_at": "2026-10-12 16:34:52+00:00", "updated_at": "2026-10-12 16:50:22.710643+00:00", "lang": "en", "topics": ["ai-agents", "large-language-models", "ai-tools", "developer-tools"], "entities": ["DuckDuckGo"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/ai-agents-with-function-calling-build-tools-llms-can-use", "markdown": "https://wpnews.pro/news/ai-agents-with-function-calling-build-tools-llms-can-use.md", "text": "https://wpnews.pro/news/ai-agents-with-function-calling-build-tools-llms-can-use.txt", "jsonld": "https://wpnews.pro/news/ai-agents-with-function-calling-build-tools-llms-can-use.jsonld"}}