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:
Generates a schema from your function signature, type hints, and docstring
Sends the schema to the LLM so it knows what tools are available
Executes the function when the LLM requests to use it
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:
Type hints for all parameters
Return type annotation
Docstring explaining what the function does
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
Always include type hints for all parameters and return values
Write detailed docstrings that explain what, when, and how
Document parameter formats and valid values clearly
Return strings or JSON-serializable types only
Handle errors gracefully and return informative error messages
Test tools independently before using with agents
Use descriptive parameter names that explain their purpose
Document side effects prominently in the docstring
Provide examples in docstrings for complex tools
Keep tools focused - one tool should do one thing well
Next Steps
Learn about API Reference for detailed API reference
Explore Configure LLMClient for Different Providers to set up different LLM providers
Check out the Quick Start Guide for complete examples
Build a ReactAgent to chain multiple tool calls together