🎉 75% of content is free forever — Unlock Premium from $10/mo →
CW
đŸ’ŧ Servicesâ„šī¸ Aboutâœ‰ī¸ ContactView Pricing Plansfrom $10

Building Tool-Using Agents with Function Calling

AI AgentsTool Use and Function CallingđŸŸĸ Free Lesson

Advertisement

Building Tool-Using Agents with Function Calling

Tool Use Architecture — Complete Flow

Tool Use & Function Calling — Complete Production FlowUSER INPUTNatural Language Query + Tool Schema Definitions (JSON Schema)LLM with Tool SchemasReceives tool definitions in JSON Schema formatEvaluates query against available tool descriptionsDecides single or parallel tool callsReturns tool_calls JSON with function name + argsCan skip tools and answer directlyTool CallNeeded?YesNoDirect Text ResponseTool Router & ValidatorValidates tool name exists in registryValidates arguments against JSON SchemaRoutes to correct executorHandles parallel dispatchTool ExecutorRuns tool function with validated argsCaptures stdout/stderrApplies timeout limitsReturns structured resultError HandlerRetry with exponential backoffFallback tool selectionGraceful error propagationCircuit breaker patternError info → LLM reasons about alternativesAPI ToolHTTP requestsREST/GraphQLDatabase ToolSQL queriesData retrievalFile ToolRead/write opsFile systemCalculatorMath expressionsSafe evalWeb SearchLive informationCurrent eventsRESULT AGGREGATORCombines tool results into context for next LLM iterationCycle repeats: LLM receives tool results, decides whether to call more tools or generate final answerFeedback Loop

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

ComponentPurposeExample
Tool SchemaJSON Schema definition of tool interface{"type": "function", "function": {...}}
Tool CallLLM output specifying tool + arguments{"name": "search", "args": {"q": "AI"}}
Tool ResultOutput returned after execution"Results: [article1, article2]"
Tool RegistryCentral catalog of available toolsToolRegistry class with validation
Error HandlerManages failures and retriesExponential backoff with circuit breaker
Result AggregatorCombines parallel resultsMerges into LLM context

Function Calling vs Prompt Engineering

ApproachCapabilityReliabilitySafetyToken Cost
Prompt EngineeringGuide text outputVariableHighLow
Function CallingStructured tool invocationHighRequires validationMedium
HybridReasoning + toolsHighestMedium (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

ToolVersionPurpose
Python3.11+Core language with async support
OpenAI1.0+Function calling API
Pydantic2.0+Schema validation
httpx0.27+HTTP requests
tenacity8.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

Architecture Diagram
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

MetricValueNotes
Parallel Call Speedup2-5xvs sequential execution
Schema Validation Time<1msPydantic overhead
Retry Success Rate85%+For transient failures
Tool Accuracy90%+With proper descriptions
Avg Response Latency2-6sGPT-4 with tools
Cost per Tool Call$0.01-0.05GPT-4 pricing
Max Reliable Tools10-15Beyond 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

PitfallSolution
Schema mismatchValidate tool inputs with Pydantic before execution
Tool hallucinationOnly call tools from the registry, never invented names
Token wasteCache tool schemas, minimize re-sending context
Race conditionsUse async locks for shared resources
Silent failuresLog all tool calls with success/failure status
Over-toolingLimit to 10-15 tools to avoid confusion
Large outputsTruncate or summarize tool results before adding to context
Circular dependenciesDetect and prevent tools that call each other
Infinite loopsSet max iteration limit on the agent loop

Security Considerations

Critical security measures for production tool-using agents:

  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 (API calls, database queries)
  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. Secrets Management: Never log API keys or credentials
  9. 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

  1. What format does OpenAI's function calling API use to define tool schemas?

    • a) YAML
    • b) JSON Schema
    • c) XML Schema
    • d) Protocol Buffers
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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

Need Expert AI Agents Help?

Get personalized tutoring, project support, or professional consulting.

Advertisement