Building Tool-Using Agents with Function Calling
Tool Use Architecture â Complete Flow
What is Tool Use and Function Calling?
Tool use enables LLMs to interact with external systems by invoking functions defined by developers. Instead of relying solely on the model's internal knowledge, tool-using agents can fetch real-time data, execute calculations, query databases, and perform actions in the real world.
OpenAI's function calling API provides a structured interface: developers define tool schemas in JSON Schema format, and the model outputs structured JSON indicating which tool to call with what arguments. The application then executes the tool and returns results to the model.
This pattern separates concerns cleanly: the LLM handles reasoning and decision-making while the application layer handles execution, validation, and error handling. Tools are the bridge between language understanding and real-world action.
Why This Matters
Without tool use, an LLM is a closed book â it can only answer from its training data. With tools, it becomes an open system that can:
- Access real-time information (stock prices, weather, news)
- Perform precise calculations (no arithmetic hallucination)
- Interact with external services (send emails, update databases)
- Execute code in sandboxed environments
Real-world analogy: Think of the LLM as a brain and tools as hands. The brain decides what to do, but hands actually do it. Without hands, the brain can only think. With hands, it can act on the world.
Key Components of Tool Use
| Component | Purpose | Example |
|---|---|---|
| Tool Schema | JSON Schema definition of tool interface | {"type": "function", "function": {...}} |
| Tool Call | LLM output specifying tool + arguments | {"name": "search", "args": {"q": "AI"}} |
| Tool Result | Output returned after execution | "Results: [article1, article2]" |
| Tool Registry | Central catalog of available tools | ToolRegistry class with validation |
| Error Handler | Manages failures and retries | Exponential backoff with circuit breaker |
| Result Aggregator | Combines parallel results | Merges into LLM context |
Function Calling vs Prompt Engineering
| Approach | Capability | Reliability | Safety | Token Cost |
|---|---|---|---|---|
| Prompt Engineering | Guide text output | Variable | High | Low |
| Function Calling | Structured tool invocation | High | Requires validation | Medium |
| Hybrid | Reasoning + tools | Highest | Medium (needs guardrails) | Higher |
Key insight: Function calling uses constrained decoding, which forces the LLM to output valid JSON matching the tool schema. This is far more reliable than prompt engineering, where the model might hallucinate tool names or arguments.
Project Overview
We will build a production-grade tool-using agent that:
- Registers multiple tools with typed schemas and auto-generated descriptions
- Handles parallel tool calls (multiple tools per LLM response)
- Validates inputs and outputs against schemas before execution
- Implements retry logic with exponential backoff for transient failures
- Logs all tool invocations for debugging and auditing
- Supports both sync and async tool implementations
Expected outcome: A reusable tool registry and executor framework you can embed in any agent.
Difficulty: Advanced (requires understanding of JSON Schema, async Python, OpenAI API patterns)
Tools & Setup
| Tool | Version | Purpose |
|---|---|---|
| Python | 3.11+ | Core language with async support |
| OpenAI | 1.0+ | Function calling API |
| Pydantic | 2.0+ | Schema validation |
| httpx | 0.27+ | HTTP requests |
| tenacity | 8.0+ | Retry logic |
Step 1: Environment Setup
python -m venv venv
source venv/bin/activate
pip install openai pydantic httpx tenacity
export OPENAI_API_KEY="sk-your-key"
Step 2: Project Structure
tool_agent/
__init__.py
registry.py # Tool registration and schema generation
executor.py # Tool execution with retries
validator.py # Input/output validation
agent.py # Main agent with tool calling
tools/ # Built-in tool implementations
web.py
calculator.py
database.py
tests/
test_registry.py
test_executor.py
test_agent.py
main.py
Step 3: Tool Registry with Auto-Schema
# registry.py
from __future__ import annotations
import json
import inspect
import logging
from typing import Any, Callable, get_type_hints
from pydantic import BaseModel, create_model
from pydantic.fields import FieldInfo
logger = logging.getLogger(__name__)
class ToolSchema(BaseModel):
"""Schema for a registered tool."""
name: str
description: str
parameters: dict[str, Any]
func: Callable[..., Any]
is_async: bool = False
timeout: float = 30.0
max_retries: int = 3
tags: list[str] = []
class Config:
arbitrary_types_allowed = True
class ToolRegistry:
"""
Central registry for tool management.
Supports decorator-based registration, auto-schema generation
from type hints, and OpenAI-compatible format export.
Usage:
registry = ToolRegistry()
@registry.register(name="search", description="Search the web")
async def web_search(query: str, num_results: int = 5) -> str:
...
"""
def __init__(self) -> None:
self._tools: dict[str, ToolSchema] = {}
logger.debug("Initialized empty ToolRegistry")
def register(
self,
name: str | None = None,
description: str | None = None,
timeout: float = 30.0,
max_retries: int = 3,
tags: list[str] | None = None,
) -> Callable:
"""
Decorator to register a function as a tool.
Args:
name: Tool name (defaults to function name)
description: Tool description (defaults to docstring)
timeout: Maximum execution time in seconds
max_retries: Number of retry attempts on failure
tags: Optional tags for categorization
Returns:
Decorated function
"""
def decorator(func: Callable) -> Callable:
tool_name = name or func.__name__
tool_desc = description or func.__doc__ or f"Execute {tool_name}"
is_coro = inspect.iscoroutinefunction(func)
schema = self._generate_schema(func)
self._tools[tool_name] = ToolSchema(
name=tool_name,
description=tool_desc,
parameters=schema,
func=func,
is_async=is_coro,
timeout=timeout,
max_retries=max_retries,
tags=tags or [],
)
logger.info(
f"Registered tool: {tool_name} "
f"(async={is_coro}, timeout={timeout}s)"
)
return func
return decorator
def register_function(
self,
func: Callable,
name: str | None = None,
description: str | None = None,
timeout: float = 30.0,
max_retries: int = 3,
tags: list[str] | None = None,
) -> None:
"""Register a function programmatically (non-decorator)."""
tool_name = name or func.__name__
tool_desc = description or func.__doc__ or f"Execute {tool_name}"
is_coro = inspect.iscoroutinefunction(func)
schema = self._generate_schema(func)
self._tools[tool_name] = ToolSchema(
name=tool_name,
description=tool_desc,
parameters=schema,
func=func,
is_async=is_coro,
timeout=timeout,
max_retries=max_retries,
tags=tags or [],
)
logger.info(f"Registered tool via function: {tool_name}")
def _generate_schema(self, func: Callable) -> dict[str, Any]:
"""
Auto-generate JSON Schema from function signature and type hints.
Uses Pydantic to create a model from the function parameters,
then converts to JSON Schema format.
"""
sig = inspect.signature(func)
hints = get_type_hints(func)
fields = {}
for param_name, param in sig.parameters.items():
if param_name == "self":
continue
param_type = hints.get(param_name, str)
# Handle default values
if param.default is not inspect.Parameter.empty:
fields[param_name] = (param_type, FieldInfo(default=param.default))
else:
fields[param_name] = (param_type, FieldInfo(...))
# Create Pydantic model
model = create_model(
f"{func.__name__}Args",
**fields,
)
return model.model_json_schema()
def _python_type_to_json(self, python_type) -> str:
"""Convert Python type to JSON Schema type string."""
type_map = {
str: "string",
int: "integer",
float: "number",
bool: "boolean",
list: "array",
dict: "object",
}
return type_map.get(python_type, "string")
def get_openai_tools(self) -> list[dict[str, Any]]:
"""Export tools in OpenAI function calling format."""
tools = []
for tool in self._tools.values():
tools.append({
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
},
})
return tools
def get_tool(self, name: str) -> ToolSchema | None:
"""Retrieve a tool by name."""
return self._tools.get(name)
def list_tools(self) -> list[str]:
"""List all registered tool names."""
return list(self._tools.keys())
def get_tools_by_tag(self, tag: str) -> list[ToolSchema]:
"""Get all tools matching a specific tag."""
return [t for t in self._tools.values() if tag in t.tags]
Step 4: Tool Executor with Retries
# executor.py
from __future__ import annotations
import asyncio
import json
import time
import logging
from typing import Any
from dataclasses import dataclass, field
from registry import ToolRegistry, ToolSchema
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
)
logger = logging.getLogger(__name__)
@dataclass
class ToolExecutionResult:
"""Result of a single tool execution."""
tool_name: str
arguments: dict[str, Any]
result: str
success: bool
duration_ms: float
error: str | None = None
retries_used: int = 0
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"tool_name": self.tool_name,
"arguments": self.arguments,
"result": self.result,
"success": self.success,
"duration_ms": self.duration_ms,
"error": self.error,
"retries_used": self.retries_used,
}
@dataclass
class ExecutionStats:
"""Statistics for tool execution tracking."""
total: int = 0
successes: int = 0
failures: int = 0
avg_duration_ms: float = 0.0
tool_counts: dict[str, int] = field(default_factory=dict)
tool_latencies: dict[str, list[float]] = field(default_factory=dict)
def record(self, result: ToolExecutionResult) -> None:
"""Record an execution result."""
self.total += 1
if result.success:
self.successes += 1
else:
self.failures += 1
# Update averages
self.avg_duration_ms = (
(self.avg_duration_ms * (self.total - 1) + result.duration_ms)
/ self.total
)
# Track per-tool stats
self.tool_counts[result.tool_name] = (
self.tool_counts.get(result.tool_name, 0) + 1
)
if result.tool_name not in self.tool_latencies:
self.tool_latencies[result.tool_name] = []
self.tool_latencies[result.tool_name].append(result.duration_ms)
class ToolExecutor:
"""
Executes tools with retry logic, timeout handling, and logging.
Features:
- Parallel execution of independent tool calls
- Exponential backoff for retries
- Timeout protection
- Comprehensive execution logging
- Per-tool performance statistics
"""
def __init__(self, registry: ToolRegistry) -> None:
self.registry = registry
self.execution_log: list[ToolExecutionResult] = []
self.stats = ExecutionStats()
logger.info("ToolExecutor initialized")
async def execute(
self,
tool_name: str,
arguments: dict[str, Any],
dry_run: bool = False,
) -> ToolExecutionResult:
"""
Execute a single tool call.
Args:
tool_name: Name of the tool to execute
arguments: Arguments to pass to the tool
dry_run: If True, validate but don't execute
Returns:
ToolExecutionResult with success status and output
"""
tool = self.registry.get_tool(tool_name)
if not tool:
logger.warning(f"Unknown tool requested: {tool_name}")
return ToolExecutionResult(
tool_name=tool_name,
arguments=arguments,
result=f"Error: Unknown tool '{tool_name}'",
success=False,
duration_ms=0,
error=f"Tool '{tool_name}' not found in registry",
)
if dry_run:
return ToolExecutionResult(
tool_name=tool_name,
arguments=arguments,
result="[DRY RUN] Tool validated successfully",
success=True,
duration_ms=0,
)
start_time = time.monotonic()
retries_used = 0
try:
if tool.is_async:
result = await self._execute_async(tool, arguments)
else:
loop = asyncio.get_event_loop()
result = await asyncio.wait_for(
loop.run_in_executor(None, lambda: tool.func(**arguments)),
timeout=tool.timeout,
)
duration = (time.monotonic() - start_time) * 1000
execution = ToolExecutionResult(
tool_name=tool_name,
arguments=arguments,
result=str(result),
success=True,
duration_ms=duration,
retries_used=retries_used,
)
except asyncio.TimeoutError:
duration = (time.monotonic() - start_time) * 1000
error_msg = f"Tool '{tool_name}' timed out after {tool.timeout}s"
logger.error(error_msg)
execution = ToolExecutionResult(
tool_name=tool_name,
arguments=arguments,
result=f"Error: {error_msg}",
success=False,
duration_ms=duration,
error="Timeout",
retries_used=retries_used,
)
except Exception as e:
duration = (time.monotonic() - start_time) * 1000
error_msg = f"Tool '{tool_name}' failed: {str(e)}"
logger.error(error_msg, exc_info=True)
execution = ToolExecutionResult(
tool_name=tool_name,
arguments=arguments,
result=f"Error: {str(e)}",
success=False,
duration_ms=duration,
error=str(e),
retries_used=retries_used,
)
self.execution_log.append(execution)
self.stats.record(execution)
return execution
async def _execute_async(
self, tool: ToolSchema, arguments: dict[str, Any]
) -> str:
"""Execute async tool with retry logic."""
retries_used = 0
@retry(
stop=stop_after_attempt(tool.max_retries),
wait=wait_exponential(multiplier=1, min=0.5, max=10),
retry=retry_if_exception_type((ConnectionError, TimeoutError)),
)
async def _inner():
nonlocal retries_used
retries_used += 1
return await tool.func(**arguments)
return await _inner()
async def execute_parallel(
self, calls: list[dict[str, Any]]
) -> list[ToolExecutionResult]:
"""
Execute multiple tool calls in parallel.
Args:
calls: List of {"tool": name, "arguments": args} dicts
Returns:
List of ToolExecutionResults in same order as calls
"""
tasks = [
self.execute(call["tool"], call["arguments"])
for call in calls
]
results = await asyncio.gather(*tasks)
logger.info(
f"Parallel execution completed: {len(results)} tools, "
f"{sum(1 for r in results if r.success)}/{len(results)} successful"
)
return results
def get_stats(self) -> dict[str, Any]:
"""Get execution statistics."""
return {
"total": self.stats.total,
"successes": self.stats.successes,
"failures": self.stats.failures,
"success_rate": (
self.stats.successes / self.stats.total * 100
if self.stats.total > 0 else 0
),
"avg_duration_ms": round(self.stats.avg_duration_ms, 2),
"tool_counts": self.stats.tool_counts,
}
Step 5: Complete Agent with Tool Calling
# agent.py
from __future__ import annotations
import json
import logging
from openai import AsyncOpenAI
from registry import ToolRegistry
from executor import ToolExecutor
logger = logging.getLogger(__name__)
TOOL_AGENT_SYSTEM = """You are a helpful assistant with access to tools.
When you need to use a tool, call the appropriate function.
You can call multiple tools in parallel when needed.
Always explain your reasoning before and after tool calls.
If a tool fails, try an alternative approach.
Be concise in your responses after tool execution."""
MAX_TOOL_ITERATIONS = 10 # Prevent infinite loops
class ToolUsingAgent:
"""
Production-grade tool-using agent with OpenAI function calling.
Features:
- Automatic tool registration via decorator
- Parallel tool execution
- Error recovery and retry logic
- Conversation history management
- Token usage tracking
"""
def __init__(
self,
model: str = "gpt-4-turbo-preview",
temperature: float = 0.0,
max_iterations: int = MAX_TOOL_ITERATIONS,
):
self.client = AsyncOpenAI()
self.model = model
self.temperature = temperature
self.max_iterations = max_iterations
self.registry = ToolRegistry()
self.executor = ToolExecutor(self.registry)
self.messages: list[dict] = []
self.total_tokens_used = 0
logger.info(f"Initialized ToolUsingAgent with model={model}")
def register(
self, name: str = None, description: str = None, **kwargs
):
"""Decorator to register a tool with this agent."""
return self.registry.register(
name=name, description=description, **kwargs
)
async def chat(self, user_input: str) -> str:
"""
Process a user message and return response.
May involve multiple tool call iterations.
Args:
user_input: User's message
Returns:
Assistant's final response text
"""
self.messages.append({"role": "user", "content": user_input})
for iteration in range(self.max_iterations):
tools = self.registry.get_openai_tools()
response = await self.client.chat.completions.create(
model=self.model,
messages=self.messages,
tools=tools if tools else None,
temperature=self.temperature,
)
# Track token usage
if response.usage:
self.total_tokens_used += response.usage.total_tokens
choice = response.choices[0]
message = choice.message
# Handle tool calls
if message.tool_calls:
logger.info(
f"Tool calls requested: "
f"{[tc.function.name for tc in message.tool_calls]}"
)
# Add assistant message with tool calls
self.messages.append({
"role": "assistant",
"content": message.content,
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in message.tool_calls
],
})
# Parse and execute tool calls
calls = []
for tc in message.tool_calls:
try:
args = json.loads(tc.function.arguments)
except json.JSONDecodeError as e:
logger.warning(
f"Failed to parse args for {tc.function.name}: {e}"
)
args = {}
calls.append({
"tool": tc.function.name,
"arguments": args,
})
# Execute all tools in parallel
results = await self.executor.execute_parallel(calls)
# Add tool results to messages
for tc, result in zip(message.tool_calls, results):
self.messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result.result,
})
elif message.content:
# Final text response
self.messages.append({
"role": "assistant",
"content": message.content,
})
return message.content
# Safety fallback
logger.warning("Max iterations reached")
return "I apologize, but I'm having trouble completing this request. Could you please rephrase?"
def get_stats(self) -> dict:
"""Get agent performance statistics."""
return {
**self.executor.get_stats(),
"total_tokens_used": self.total_tokens_used,
"messages_in_history": len(self.messages),
}
def clear_history(self) -> None:
"""Clear conversation history."""
self.messages = []
logger.info("Conversation history cleared")
Step 6: Built-in Tools
# tools/web.py
import httpx
import logging
from registry import ToolRegistry
logger = logging.getLogger(__name__)
registry = ToolRegistry()
@registry.register(
name="web_search",
description="Search the web for current information on any topic",
timeout=15.0,
max_retries=2,
tags=["search", "web"],
)
async def web_search(query: str, num_results: int = 5) -> str:
"""
Search the web for information.
Args:
query: Search query string
num_results: Number of results to return (default: 5)
Returns:
Formatted search results
"""
async with httpx.AsyncClient() as client:
try:
response = await client.get(
"https://api.searchapi.io/api/v1/search",
params={"q": query, "engine": "google"},
timeout=10.0,
)
response.raise_for_status()
data = response.json()
results = data.get("organic_results", [])[:num_results]
if not results:
return f"No results found for: {query}"
formatted = []
for i, r in enumerate(results, 1):
title = r.get("title", "No title")
snippet = r.get("snippet", "No snippet")
link = r.get("link", "")
formatted.append(f"{i}. {title}\n {snippet}\n {link}")
return "\n\n".join(formatted)
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error during web search: {e}")
return f"Search failed: HTTP {e.response.status_code}"
except httpx.TimeoutException:
return "Search timed out. Please try a simpler query."
except Exception as e:
logger.error(f"Unexpected error during web search: {e}")
return f"Search failed: {str(e)}"
# tools/calculator.py
import math
import logging
from registry import ToolRegistry
logger = logging.getLogger(__name__)
calc_registry = ToolRegistry()
SAFE_FUNCTIONS = {
"sqrt": math.sqrt,
"log": math.log,
"log10": math.log10,
"sin": math.sin,
"cos": math.cos,
"tan": math.tan,
"pi": math.pi,
"e": math.e,
"abs": abs,
"round": round,
"floor": math.floor,
"ceil": math.ceil,
"pow": pow,
}
@calc_registry.register(
name="calculate",
description="Evaluate mathematical expressions safely. Supports basic arithmetic, trigonometry, logarithms, and more.",
timeout=5.0,
max_retries=1,
tags=["math", "calculation"],
)
def calculate(expression: str) -> str:
"""
Safely evaluate a mathematical expression.
Args:
expression: Mathematical expression to evaluate
Examples: "2 + 2", "sqrt(144)", "sin(pi/2)"
Returns:
Result of the expression as a string
"""
try:
# Security: Only allow safe functions
result = eval(expression, {"__builtins__": {}}, SAFE_FUNCTIONS)
return str(result)
except ZeroDivisionError:
return "Error: Division by zero"
except ValueError as e:
return f"Error: Invalid value - {str(e)}"
except SyntaxError:
return f"Error: Invalid expression syntax"
except Exception as e:
logger.error(f"Calculation error: {e}")
return f"Error: Could not evaluate expression"
Step 7: Testing & Evaluation
# tests/test_tools.py
import pytest
import asyncio
from registry import ToolRegistry
from executor import ToolExecutor, ToolExecutionResult
@pytest.fixture
def registry():
"""Create a test registry with sample tools."""
reg = ToolRegistry()
@reg.register(name="add", description="Add two numbers")
def add(a: int, b: int) -> int:
return a + b
@reg.register(name="multiply", description="Multiply two numbers")
def multiply(a: int, b: int) -> int:
return a * b
@reg.register(name="failing_tool", description="Always fails")
def failing_tool() -> str:
raise ValueError("Intentional failure")
return reg
@pytest.fixture
def executor(registry):
"""Create a test executor."""
return ToolExecutor(registry)
def test_tool_registration(registry):
"""Test that tools are registered correctly."""
assert "add" in registry.list_tools()
tools = registry.get_openai_tools()
assert len(tools) == 3
assert tools[0]["function"]["name"] == "add"
def test_schema_generation(registry):
"""Test auto-generated schema matches expected format."""
tools = registry.get_openai_tools()
schema = tools[0]["function"]["parameters"]
assert schema["type"] == "object"
assert "properties" in schema
assert "required" in schema
@pytest.mark.asyncio
async def test_tool_execution(executor):
"""Test successful tool execution."""
result = await executor.execute("add", {"a": 2, "b": 3})
assert result.success
assert result.result == "5"
assert result.duration_ms > 0
@pytest.mark.asyncio
async def test_unknown_tool(executor):
"""Test handling of unknown tool names."""
result = await executor.execute("nonexistent", {})
assert not result.success
assert "not found" in result.error
@pytest.mark.asyncio
async def test_tool_failure(executor):
"""Test handling of tool execution failures."""
result = await executor.execute("failing_tool", {})
assert not result.success
assert "Intentional failure" in result.error
@pytest.mark.asyncio
async def test_parallel_execution(executor):
"""Test parallel execution of multiple tools."""
calls = [
{"tool": "add", "arguments": {"a": 1, "b": 2}},
{"tool": "multiply", "arguments": {"a": 3, "b": 4}},
]
results = await executor.execute_parallel(calls)
assert len(results) == 2
assert all(r.success for r in results)
def test_stats(executor):
"""Test statistics tracking."""
stats = executor.get_stats()
assert stats["total"] == 0
assert stats["success_rate"] == 0
Mathematical Foundation
Tool Selection Probability:
Where each parameter means:
- â the -th available tool
- â the user's input and conversation context
- â the set of available tool schemas
Intuition: The LLM evaluates which tool best matches the query based on tool descriptions and parameters. Parallel calls occur when the query requires independent information from multiple sources.
Parallel Execution Time:
Intuition: Parallel tools complete in the time of the slowest tool plus minimal orchestration overhead. This gives significant speedup over sequential execution.
Retry Success Probability:
Where is per-attempt success probability and is max retries.
Intuition: With p=0.8 and k=3 retries, success probability is 99.2%. Exponential backoff prevents thundering herd problems.
Token Cost Model:
Where:
- â tokens for tool schema definitions
- â number of tool invocations
- â tokens in the tool call JSON
- â tokens in the tool result
Performance Metrics
| Metric | Value | Notes |
|---|---|---|
| Parallel Call Speedup | 2-5x | vs sequential execution |
| Schema Validation Time | <1ms | Pydantic overhead |
| Retry Success Rate | 85%+ | For transient failures |
| Tool Accuracy | 90%+ | With proper descriptions |
| Avg Response Latency | 2-6s | GPT-4 with tools |
| Cost per Tool Call | $0.01-0.05 | GPT-4 pricing |
| Max Reliable Tools | 10-15 | Beyond this, selection degrades |
Real-World Examples
Example 1: Research Assistant
A research agent that searches the web, calculates statistics, and compiles findings:
agent = ToolUsingAgent(model="gpt-4-turbo-preview")
@agent.register(name="search_arxiv", description="Search arXiv papers")
async def search_arxiv(query: str, max_results: int = 5) -> str:
# Implementation...
pass
@agent.register(name="calculate_stats", description="Calculate statistics")
def calculate_stats(numbers: list[float]) -> str:
import statistics
return f"Mean: {statistics.mean(numbers)}, Std: {statistics.stdev(numbers)}"
response = await agent.chat(
"Find 5 recent papers on transformer efficiency and calculate "
"the average citation count"
)
Example 2: Data Pipeline Agent
An agent that queries databases and generates reports:
@agent.register(name="query_db", description="Execute SQL query")
async def query_db(query: str, database: str = "production") -> str:
# Execute query with timeout and logging
pass
@agent.register(name="generate_chart", description="Create visualization")
def generate_chart(data: str, chart_type: str = "bar") -> str:
# Generate chart from data
pass
Common Pitfalls & Solutions
| Pitfall | Solution |
|---|---|
| Schema mismatch | Validate tool inputs with Pydantic before execution |
| Tool hallucination | Only call tools from the registry, never invented names |
| Token waste | Cache tool schemas, minimize re-sending context |
| Race conditions | Use async locks for shared resources |
| Silent failures | Log all tool calls with success/failure status |
| Over-tooling | Limit to 10-15 tools to avoid confusion |
| Large outputs | Truncate or summarize tool results before adding to context |
| Circular dependencies | Detect and prevent tools that call each other |
| Infinite loops | Set max iteration limit on the agent loop |
Security Considerations
Critical security measures for production tool-using agents:
- Input Sanitization: Validate and sanitize all tool arguments to prevent injection attacks
- Permission Scoping: Tools should have minimal required permissions (principle of least privilege)
- Rate Limiting: Prevent abuse of expensive tools (API calls, database queries)
- Output Sanitization: Filter tool outputs before adding to LLM context
- Audit Logging: Record all tool invocations for security review
- Sandboxing: Execute untrusted tools in isolated environments
- Human Approval: Require confirmation for destructive actions (file deletion, purchases)
- Secrets Management: Never log API keys or credentials
- Timeout Enforcement: Prevent hanging tools from blocking the agent
# Example: Human-in-the-loop for destructive actions
@registry.register(
name="delete_file",
description="Delete a file from the system",
requires_approval=True, # Flag for human approval
)
def delete_file(path: str) -> str:
# Check if approval is granted
if not check_approval("delete_file", {"path": path}):
return "Error: Human approval required for file deletion"
# Proceed with deletion
os.remove(path)
return f"Deleted: {path}"
Summary with Key Takeaways
- Tool schemas must be clear and well-documented for reliable LLM tool selection
- Parallel execution significantly reduces latency for independent tool calls
- Retry logic with exponential backoff handles transient failures gracefully
- Always validate tool inputs against schemas before execution
- Comprehensive logging is essential for debugging tool-using agents
- Limit the number of tools to avoid confusing the LLM
- Tool results should be truncated or summarized to control token costs
- Implement security measures for all tool executions in production
Interview Questions
1. What is the difference between function calling and prompt-based tool use?
Answer: Function calling uses OpenAI's structured API to define tools with JSON Schema, returning structured tool_calls in the response. The model outputs a specific JSON format with tool name and arguments. Prompt-based tool use embeds tool descriptions in the system prompt and relies on the model to output tool calls in a specific text format (e.g., Action: search(query)). Function calling is more reliable because it uses constrained decoding, while prompt-based approaches are more flexible but less consistent. Function calling also separates tool execution from LLM output, improving safety.
# Function calling approach
response = await client.chat.completions.create(
model="gpt-4-turbo-preview",
messages=messages,
tools=[{
"type": "function",
"function": {
"name": "search",
"parameters": {"type": "object", "properties": {"q": {"type": "string"}}}
}
}]
)
# Returns structured tool_calls
# Prompt-based approach
messages.append({"role": "system", "content": "Use Action: search(q=...)"})
# Returns text that may or may not match expected format
2. How does parallel tool execution work in OpenAI's API?
Answer: When the LLM determines multiple tools are needed, it returns multiple tool_calls in a single response. The application collects all tool calls, executes them concurrently using asyncio.gather(), then returns all results in a single batch to the LLM. This reduces latency from to where is each tool's execution time. The LLM then synthesizes all results into a coherent response. Parallel execution is particularly useful for independent information retrieval tasks.
# Parallel execution example
if message.tool_calls:
# Execute all tools in parallel
calls = [{"tool": tc.function.name, "arguments": json.loads(tc.function.arguments)}
for tc in message.tool_calls]
results = await executor.execute_parallel(calls)
# Return all results at once
for tc, result in zip(message.tool_calls, results):
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result.result})
3. What are the key considerations for tool schema design?
Answer: Key considerations: 1) Clear descriptions â The tool description must explain when and how to use it, 2) Parameter naming â Use descriptive parameter names that match common conventions, 3) Type accuracy â Correct JSON Schema types prevent runtime errors, 4) Required fields â Mark truly required parameters to avoid incomplete calls, 5) Examples â Include example values in descriptions for complex parameters, 6) Error handling â Design tools to return informative error messages rather than crashing. Poor schema design leads to incorrect tool selection and failed calls.
4. How do you handle tool failures gracefully?
Answer: Layered approach: 1) Input validation â Validate arguments against schema before execution, 2) Timeout protection â Set appropriate timeouts to prevent hanging, 3) Retry with backoff â Use exponential backoff for transient failures (network errors, rate limits), 4) Fallback tools â Register alternative tools for critical operations, 5) Error propagation â Return structured error messages to the LLM so it can reason about alternatives, 6) Circuit breaker â Stop calling tools that consistently fail. The key insight is that the LLM can often recover from tool failures if given clear error information.
# Example: Graceful error handling with fallback
try:
result = await primary_search(query)
except SearchError:
# Fallback to alternative search
result = await backup_search(query)
logger.warning(f"Primary search failed, used backup for: {query}")
5. What is the token cost implication of tool use?
Answer: Tool use increases token consumption in three ways: 1) Tool schemas â Each tool definition adds tokens to the system prompt (typically 50-200 tokens per tool), 2) Tool calls â The LLM's structured output includes tool name and arguments, 3) Tool results â Each tool's output is added to the context. For a conversation with tool calls, total tokens grow as . Mitigation strategies include: caching frequent results, summarizing large outputs, limiting result sizes, and using cheaper models for simple tool calls.
6. How would you implement tool versioning?
Answer: Versioning strategies: 1) Name-based versioning â search_v2 as a separate tool, 2) Parameter-based â Add optional version parameter, 3) Registry-based â ToolRegistry maintains version metadata and handles routing. For backward compatibility, maintain old versions while adding new ones. Use semantic versioning for tool schemas. Deprecation warnings can be added to tool descriptions. The agent should prefer the latest version unless explicitly specified. For critical systems, A/B test new tool versions before full rollout.
7. What security considerations are important for tool-using agents?
Answer: Critical security measures: 1) Input sanitization â Validate and sanitize all tool arguments to prevent injection attacks, 2) Permission scoping â Tools should have minimal required permissions (principle of least privilege), 3) Rate limiting â Prevent abuse of expensive tools, 4) Output sanitization â Filter tool outputs before adding to LLM context, 5) Audit logging â Record all tool invocations for security review, 6) Sandboxing â Execute untrusted tools in isolated environments, 7) Human approval â Require confirmation for destructive actions (file deletion, purchases).
8. How do you evaluate tool-using agent performance?
Answer: Key metrics: 1) Tool selection accuracy â % of queries where correct tool(s) were chosen, 2) Argument accuracy â % of tool calls with correct arguments, 3) Task completion rate â % of queries answered successfully, 4) Latency â End-to-end response time including tool execution, 5) Cost efficiency â Tokens used per successful query, 6) Error recovery rate â % of tool failures that the agent recovers from, 7) Parallel efficiency â Speedup from parallel vs sequential tool calls. Use benchmarks like GAIA, ToolBench, or custom test suites with known correct tool calls.
KnowledgeCheck
-
What format does OpenAI's function calling API use to define tool schemas?
- a) YAML
- b) JSON Schema
- c) XML Schema
- d) Protocol Buffers
-
What is the benefit of parallel tool execution?
- a) Uses fewer tokens
- b) Reduces latency to the slowest tool time
- c) Increases accuracy
- d) Simplifies error handling
-
Why is exponential backoff important for tool retries?
- a) It makes retries faster
- b) It prevents overwhelming the service during outages
- c) It reduces token usage
- d) It improves LLM reasoning
-
What happens when a tool call fails in a well-designed agent?
- a) The agent crashes
- b) The error is returned to the LLM which reasons about alternatives
- c) The agent ignores the error
- d) The user is asked to fix the tool
-
How should large tool outputs be handled?
- a) Added to context in full
- b) Truncated or summarized before adding to context
- c) Discarded completely
- d) Stored in a separate database
-
What is the recommended maximum number of tools for reliable selection?
- a) 3-5
- b) 10-15
- c) 25-30
- d) Unlimited
Answers: 1-b, 2-b, 3-b, 4-b, 5-b, 6-b