Tool Calling with Python Functions

This guide covers everything you need to know about creating and using tools in python-agents. Tools are Python functions that your AI agent can call to perform actions, retrieve information, or process data.

Understanding Tool Calling

When you register a Python function as a tool, python-agents automatically:

  1. Generates a schema from your function signature, type hints, and docstring

  2. Sends the schema to the LLM so it knows what tools are available

  3. Executes the function when the LLM requests to use it

  4. Returns the result back to the LLM to inform its next steps

The quality of your tool’s docstring and type hints directly impacts how well the LLM understands and uses your tools.

Basic Tool Creation

Simple Tool Example

Here’s a minimal tool that the LLM can call:

from python_agents.client import LLMClient
import asyncio

def get_current_time() -> str:
    """Get the current time.

    Returns:
        The current time as a string
    """
    from datetime import datetime
    return datetime.now().strftime("%H:%M:%S")

async def main():
    client = LLMClient("openai/gpt-4-turbo")
    client.add_tool(get_current_time)

    response = await client.invoke("What time is it?")
    print(response.message.content)

asyncio.run(main())

Key Requirements

Every tool function must have:

  1. Type hints for all parameters

  2. Return type annotation

  3. Docstring explaining what the function does

  4. Parameter descriptions in the docstring

Type Hints and Annotations

Supported Parameter Types

python-agents supports JSON-serializable types for tool parameters:

def example_tool(
    text: str,           # String parameter
    count: int,          # Integer parameter
    price: float,        # Float parameter
    enabled: bool,       # Boolean parameter
    tags: list[str],     # List of strings
    metadata: dict,      # Dictionary
) -> str:
    """Example showing all supported parameter types."""
    return "Success"

Warning

Complex objects, custom classes, and non-JSON-serializable types are not supported as parameters. The LLM can only pass basic JSON types.

Optional Parameters

Use default values to make parameters optional:

def search_products(
    query: str,
    category: str = "all",
    max_results: int = 10,
    include_sold_out: bool = False
) -> str:
    """Search for products in the catalog.

    Args:
        query: Search query string
        category: Product category to search in. Defaults to "all"
        max_results: Maximum number of results to return. Defaults to 10
        include_sold_out: Whether to include out-of-stock items. Defaults to False

    Returns:
        JSON string containing search results
    """
    # Implementation here
    results = []
    return str(results)

The LLM can call this tool with just the required query parameter, or include optional parameters as needed.

Return Types

Tools should return JSON-serializable types. The result is automatically stringified before being sent back to the LLM:

def calculate(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b  # Returns integer, stringified to "42"

def get_user_info(user_id: str) -> dict:
    """Get user information."""
    return {"id": user_id, "name": "Alice"}  # Returns dict, stringified

def list_files(directory: str) -> list[str]:
    """List files in directory."""
    return ["file1.txt", "file2.py"]  # Returns list, stringified

Documenting Tools

The Importance of Good Docstrings

The LLM relies entirely on your docstring to understand:

  • What the tool does

  • When to use it

  • What parameters it needs

  • What it returns

A well-documented tool is used correctly. A poorly documented tool is used incorrectly or not at all.

Docstring Format

Use Google-style or reStructuredText docstrings with clear sections:

def send_email(
    recipient: str,
    subject: str,
    body: str,
    cc: list[str] = None,
    priority: str = "normal"
) -> bool:
    """Send an email message.

    This function sends an email to the specified recipient with the given
    subject and body. It supports CC recipients and priority levels.

    Args:
        recipient: Email address of the primary recipient
        subject: Email subject line
        body: Email body content (plain text)
        cc: List of CC email addresses. Optional.
        priority: Priority level - "low", "normal", or "high". Defaults to "normal"

    Returns:
        True if email was sent successfully, False otherwise

    Note:
        The email is sent asynchronously. This function returns immediately
        after queuing the email for delivery.
    """
    # Implementation
    return True

Best Practices for Docstrings

DO:

  • Be specific and descriptive

  • Explain the purpose clearly

  • Document all parameters with their expected format

  • Mention valid values for string parameters

  • Include examples when behavior might be unclear

  • Note any side effects or important limitations

DON’T:

  • Be vague (“does stuff”, “processes data”)

  • Skip parameter descriptions

  • Assume the LLM knows your domain-specific terms

  • Forget to document default values

Example Comparison

Bad docstring:

def process_order(order_id: str, action: str) -> str:
    """Process an order."""
    pass

Problems: What does “process” mean? What actions are valid? What does it return?

Good docstring:

def process_order(order_id: str, action: str) -> str:
    """Perform an action on an existing order.

    This function allows you to modify or query existing orders in the system.

    Args:
        order_id: The unique order identifier (format: ORD-XXXXX)
        action: Action to perform. Valid values:
            - "cancel": Cancel the order
            - "ship": Mark order as shipped
            - "refund": Process a refund
            - "status": Get current order status

    Returns:
        Success message for cancel/ship/refund actions, or order status
        information for status action. Returns error message if order_id
        is invalid or action fails.
    """
    pass

Advanced Tool Patterns

Tools That Return Structured Data

When returning complex data, use JSON format and document the structure:

import json

def get_weather(location: str, units: str = "celsius") -> str:
    """Get current weather information for a location.

    Args:
        location: City name or "City, Country" format
        units: Temperature units - "celsius" or "fahrenheit". Defaults to "celsius"

    Returns:
        JSON string with weather data containing:
        - temperature (float): Current temperature in specified units
        - conditions (str): Weather conditions (e.g., "sunny", "rainy")
        - humidity (int): Humidity percentage (0-100)
        - wind_speed (float): Wind speed in km/h or mph

    Example return value:
        {"temperature": 22.5, "conditions": "partly cloudy",
         "humidity": 65, "wind_speed": 12.3}
    """
    weather_data = {
        "temperature": 22.5,
        "conditions": "partly cloudy",
        "humidity": 65,
        "wind_speed": 12.3
    }
    return json.dumps(weather_data)

Tools with External Dependencies

Tools can use external libraries and APIs:

import os
import requests

def search_wikipedia(query: str, max_results: int = 3) -> str:
    """Search Wikipedia and return article summaries.

    Uses the Wikipedia API to search for articles matching the query
    and returns brief summaries of the top results.

    Args:
        query: Search terms to look up on Wikipedia
        max_results: Maximum number of results to return (1-10). Defaults to 3

    Returns:
        Formatted string with article titles and summaries, separated by newlines.
        Returns "No results found" if query doesn't match any articles.
    """
    url = "https://en.wikipedia.org/api/rest_v1/page/summary/"
    results = []

    # Implementation using requests library
    response = requests.get(f"{url}{query}")
    if response.status_code == 200:
        data = response.json()
        results.append(f"{data['title']}: {data['extract']}")

    return "\n\n".join(results) if results else "No results found"

Error Handling in Tools

Tools should handle errors gracefully and return informative messages:

def divide_numbers(a: float, b: float) -> str:
    """Divide two numbers.

    Args:
        a: The dividend (number to be divided)
        b: The divisor (number to divide by)

    Returns:
        The result of a divided by b, or an error message if division
        is not possible (e.g., division by zero).
    """
    try:
        if b == 0:
            return "Error: Cannot divide by zero"
        result = a / b
        return f"Result: {result}"
    except Exception as e:
        return f"Error performing division: {str(e)}"

Note

Return error messages as strings rather than raising exceptions. This allows the LLM to see what went wrong and potentially retry or take a different approach.

Tools with Side Effects

Document side effects clearly so the LLM understands the impact:

def create_database_user(
    username: str,
    email: str,
    role: str = "user"
) -> str:
    """Create a new user account in the database.

    **WARNING**: This function has side effects - it modifies the database.

    Args:
        username: Unique username (3-20 characters, alphanumeric and underscore only)
        email: User's email address (must be valid email format)
        role: User role - "user", "admin", or "moderator". Defaults to "user"

    Returns:
        Success message with user ID if created, or error message if username/email
        already exists or validation fails.

    Side Effects:
        - Creates a new row in the users table
        - Sends a welcome email to the provided email address
        - Logs the creation event in the audit log
    """
    # Implementation
    return f"User {username} created with ID: 12345"

Complete Example: Building a File System Agent

Here’s a complete example showing multiple tools working together:

import os
from pathlib import Path
from python_agents.client import LLMClient
from python_agents.agents import ReactAgent
import asyncio

def list_directory(path: str = ".") -> str:
    """List files and directories in the specified path.

    Args:
        path: Directory path to list. Defaults to current directory (".")

    Returns:
        Formatted list of files and directories with type indicators.
        Directories are marked with [DIR], files show their size in bytes.
        Returns error message if path doesn't exist or isn't accessible.
    """
    try:
        items = []
        for item in Path(path).iterdir():
            if item.is_dir():
                items.append(f"[DIR] {item.name}")
            else:
                size = item.stat().st_size
                items.append(f"[FILE] {item.name} ({size} bytes)")
        return "\n".join(items) if items else "Directory is empty"
    except Exception as e:
        return f"Error: {str(e)}"

def read_file(filepath: str, max_lines: int = 100) -> str:
    """Read and return the contents of a text file.

    Args:
        filepath: Path to the file to read
        max_lines: Maximum number of lines to read. Defaults to 100.
                  Use this to avoid reading huge files entirely.

    Returns:
        File contents as a string, limited to max_lines.
        Returns error message if file doesn't exist, isn't readable,
        or isn't a text file.
    """
    try:
        with open(filepath, 'r') as f:
            lines = [f.readline() for _ in range(max_lines)]
            content = ''.join(lines)
            return content if content else "File is empty"
    except UnicodeDecodeError:
        return "Error: File is not a text file"
    except Exception as e:
        return f"Error reading file: {str(e)}"

def search_files(directory: str, pattern: str) -> str:
    """Search for files matching a pattern in a directory.

    Args:
        directory: Directory path to search in
        pattern: Filename pattern to match (e.g., "*.txt", "test_*.py")
                Supports wildcards: * (any characters) and ? (single character)

    Returns:
        List of matching file paths, one per line.
        Returns "No files found" if no matches.
        Returns error message if directory is invalid.
    """
    try:
        matches = list(Path(directory).glob(pattern))
        if matches:
            return "\n".join(str(m) for m in matches)
        return "No files found matching pattern"
    except Exception as e:
        return f"Error searching files: {str(e)}"

def get_file_info(filepath: str) -> str:
    """Get detailed information about a file.

    Args:
        filepath: Path to the file

    Returns:
        JSON string containing file metadata:
        - size: File size in bytes
        - created: Creation timestamp
        - modified: Last modification timestamp
        - is_directory: Boolean indicating if path is a directory
        - extension: File extension (empty string for directories)
    """
    try:
        path = Path(filepath)
        stat = path.stat()
        info = {
            "size": stat.st_size,
            "created": stat.st_ctime,
            "modified": stat.st_mtime,
            "is_directory": path.is_dir(),
            "extension": path.suffix
        }
        import json
        return json.dumps(info, indent=2)
    except Exception as e:
        return f"Error getting file info: {str(e)}"

async def main():
    # Create client and add all tools
    client = LLMClient("openai/gpt-4-turbo")
    client.add_tool(list_directory)
    client.add_tool(read_file)
    client.add_tool(search_files)
    client.add_tool(get_file_info)

    # Create agent
    agent = ReactAgent(client, max_iterations=10)

    # Run complex task that requires multiple tools
    result = await agent.run(
        "Find all Python files in the current directory, then read the contents "
        "of the largest one and tell me what it does.",
        verbose=True
    )

    print("\n" + "="*50)
    print("FINAL RESULT:")
    print("="*50)
    print(result)

if __name__ == "__main__":
    asyncio.run(main())

Testing Your Tools

Test Tools Independently

Before using tools with an agent, test them directly:

# Test the function works correctly
def test_calculator():
    result = calculator("add", 5, 3)
    assert result == 8, f"Expected 8, got {result}"

    result = calculator("divide", 10, 2)
    assert result == 5, f"Expected 5, got {result}"

    # Test error handling
    result = calculator("divide", 10, 0)
    assert "error" in result.lower(), "Should return error for division by zero"

    print("All tests passed!")

test_calculator()

Test with LLMClient

Test how the LLM uses your tools:

async def test_tool_with_llm():
    client = LLMClient("openai/gpt-4-turbo")
    client.add_tool(calculator)

    # Test that LLM correctly calls the tool
    response = await client.invoke("What is 25 multiplied by 4?")
    print(response.message.content)

    # Verify the answer is correct
    assert "100" in response.message.content

asyncio.run(test_tool_with_llm())

Common Pitfalls and Solutions

Pitfall 1: Missing Type Hints

Problem:

def bad_tool(query, max_results):  # No type hints!
    """Search for items."""
    return []

Solution:

def good_tool(query: str, max_results: int) -> list[str]:
    """Search for items."""
    return []

Pitfall 2: Vague Parameter Names

Problem:

def process(data: str, flag: bool) -> str:
    """Process data."""  # What is 'data'? What does 'flag' do?
    pass

Solution:

def format_address(
    address_string: str,
    include_country: bool
) -> str:
    """Format a postal address for display.

    Args:
        address_string: Raw address text with comma-separated components
        include_country: If True, includes country name in formatted output
    """
    pass

Pitfall 3: Returning Complex Objects

Problem:

def get_user(user_id: str) -> User:  # Custom class not JSON-serializable
    """Get user object."""
    return User(id=user_id, name="Alice")

Solution:

def get_user(user_id: str) -> dict:
    """Get user information as a dictionary.

    Returns:
        Dictionary with keys: id, name, email, created_at
    """
    return {
        "id": user_id,
        "name": "Alice",
        "email": "alice@example.com",
        "created_at": "2024-01-01"
    }

Pitfall 4: Silent Failures

Problem:

def delete_file(filepath: str) -> bool:
    """Delete a file."""
    try:
        os.remove(filepath)
        return True
    except:
        return False  # LLM doesn't know WHY it failed

Solution:

def delete_file(filepath: str) -> str:
    """Delete a file from the filesystem.

    Args:
        filepath: Path to the file to delete

    Returns:
        Success message if deleted, or specific error message explaining
        why deletion failed (e.g., file not found, permission denied).
    """
    try:
        os.remove(filepath)
        return f"Successfully deleted {filepath}"
    except FileNotFoundError:
        return f"Error: File {filepath} does not exist"
    except PermissionError:
        return f"Error: Permission denied to delete {filepath}"
    except Exception as e:
        return f"Error deleting file: {str(e)}"

Best Practices Summary

  1. Always include type hints for all parameters and return values

  2. Write detailed docstrings that explain what, when, and how

  3. Document parameter formats and valid values clearly

  4. Return strings or JSON-serializable types only

  5. Handle errors gracefully and return informative error messages

  6. Test tools independently before using with agents

  7. Use descriptive parameter names that explain their purpose

  8. Document side effects prominently in the docstring

  9. Provide examples in docstrings for complex tools

  10. Keep tools focused - one tool should do one thing well

Next Steps