Tools
Tools are the primary way agents interact with the outside world and perform actions. By decorating methods with @tool, you give your agents capabilities like searching databases, calling APIs, processing files, or any other Python function you can write.
Why Use Tools?
LLMs are excellent at reasoning and generating text, but they can't directly interact with external systems. Tools bridge this gap by:
- Extending Capabilities: Give agents access to APIs, databases, file systems, and more
- Type Safety: Full parameter validation and type checking via Pydantic
- Dynamic Constraints: Use
refto constrain parameters to valid state values - Automatic Schema Generation: Tool definitions are automatically converted to LLM-compatible schemas
- Error Handling: Built-in error handling and result tracking
Basic Tool Declaration
The simplest tool is a method decorated with @tool that returns a string:
from pyagentic import BaseAgent, tool
class ResearchAgent(BaseAgent):
__system_message__ = "I help with research tasks"
@tool("Search for academic papers on a topic")
def search_papers(self, query: str) -> str:
# Your search logic here
results = search_database(query)
return f"Found {len(results)} papers about '{query}'"
Every tool must:
- Have a @tool decorator with a description
- Have type-annotated parameters
- Return a str (the result passed back to the LLM)
When the LLM decides to use this tool, PyAgentic will: 1. Extract the parameters from the LLM's tool call 2. Validate the parameter types 3. Execute your tool method 4. Return the string result to the LLM
Tool Parameters
Tools can accept any number of typed parameters. PyAgentic automatically generates the proper schema for the LLM:
class DataAgent(BaseAgent):
__system_message__ = "I analyze data"
@tool("Query database with filters")
def query_data(
self,
table: str,
limit: int,
filters: dict,
include_metadata: bool
) -> str:
results = db.query(table, limit=limit, filters=filters)
return f"Retrieved {len(results)} records from {table}"
Supported Parameter Types
PyAgentic supports a variety of parameter types:
| Type | Example | Description |
|---|---|---|
str |
query: str |
String values |
int |
count: int |
Integer numbers |
float |
threshold: float |
Floating point numbers |
bool |
include_all: bool |
Boolean true/false |
list[T] |
tags: list[str] |
Lists of primitives |
dict |
metadata: dict |
Dictionary/object |
BaseModel |
options: SearchOptions |
Custom Pydantic model |
list[BaseModel] |
items: list[Item] |
List of custom models |
Parameter Configuration with spec.Param
Use spec.Param() to add descriptions, defaults, and constraints to parameters:
from pyagentic import spec
class FileAgent(BaseAgent):
__system_message__ = "I manage files"
@tool("Read a file from the system")
def read_file(
self,
path: str = spec.Param(
description="Path to the file to read",
required=True
),
encoding: str = spec.Param(
description="Text encoding to use",
default="utf-8"
),
max_lines: int = spec.Param(
description="Maximum number of lines to read",
default=100
)
) -> str:
with open(path, encoding=encoding) as f:
lines = [f.readline() for _ in range(max_lines)]
return f"Read {len(lines)} lines from {path}"
spec.Param() Options
spec.Param(
description="Human-readable parameter description",
required=True, # Must be provided by LLM
default="value", # Default if not provided
default_factory=list, # Factory function for mutable defaults
values=["opt1", "opt2"] # Constrain to specific values (enum)
)
Custom Parameter Models
For complex structured input, use Pydantic BaseModel classes:
from pydantic import BaseModel, Field
class SearchOptions(BaseModel):
query: str = Field(..., description="Search query string")
max_results: int = Field(default=10, description="Maximum results")
filters: dict = Field(default_factory=dict, description="Additional filters")
case_sensitive: bool = Field(default=False, description="Case sensitive search")
class SearchAgent(BaseAgent):
__system_message__ = "I search databases"
@tool("Search with advanced options")
def search(self, options: SearchOptions) -> str:
# Access structured parameters
results = db.search(
options.query,
max_results=options.max_results,
filters=options.filters,
case_sensitive=options.case_sensitive
)
return f"Found {len(results)} results"
Benefits of custom parameter models: - Grouped Parameters: Organize related parameters logically - Validation: Pydantic validates all fields automatically - Reusability: Use the same model across multiple tools - Documentation: Field descriptions help the LLM understand parameters
Lists of Custom Models
Tools can accept lists of custom models for batch operations:
class Task(BaseModel):
title: str = Field(..., description="Task title")
priority: int = Field(default=1, description="Priority level 1-5")
tags: list[str] = Field(default_factory=list, description="Task tags")
class TaskAgent(BaseAgent):
__system_message__ = "I manage tasks"
@tool("Create multiple tasks at once")
def create_tasks(self, tasks: list[Task]) -> str:
for task in tasks:
db.create(task.title, task.priority, task.tags)
return f"Created {len(tasks)} tasks"
Dynamic Constraints with ref
The ref system allows you to constrain tool parameters to valid state values, preventing the LLM from hallucinating invalid options:
from pydantic import BaseModel, computed_field
from pyagentic import State, ref, spec
class FileSystemState(BaseModel):
current_directory: str = "/home"
available_files: list[str] = []
@computed_field
@property
def file_names(self) -> list[str]:
"""List of just the file names"""
return [f.split('/')[-1] for f in self.available_files]
class FileAgent(BaseAgent):
__system_message__ = "I manage files in the current directory"
filesystem: State[FileSystemState] = spec.State(default_factory=FileSystemState)
@tool("Open a file from the current directory")
def open_file(
self,
filename: str = spec.Param(
description="File to open",
values=ref.filesystem.file_names # LLM can ONLY choose from this list!
)
) -> str:
return f"Opened {filename}"
How ref Works
When you use ref.filesystem.file_names:
1. Declaration Time: A RefNode is created storing the path ['filesystem', 'file_names']
2. Instantiation Time: The tool definition stores this reference
3. Runtime: When generating the tool schema for the LLM:
- PyAgentic builds an agent reference dictionary from the current state
- The RefNode resolves by walking the path to get the actual value
- The resolved value is injected into the tool schema as the enum constraint
This means the LLM always sees the current, up-to-date list of valid options, and it's impossible for it to hallucinate an invalid filename.
Common ref Patterns
Constrain to state values:
@tool("Select a dataset")
def select(
self,
dataset: str = spec.Param(values=ref.datasets.available_names)
) -> str: ...
Use computed fields for dynamic lists:
class ProjectState(BaseModel):
projects: list[dict] = []
@computed_field
@property
def active_project_ids(self) -> list[str]:
return [p['id'] for p in self.projects if p['active']]
@tool("Archive a project")
def archive(
self,
project_id: str = spec.Param(values=ref.projects.active_project_ids)
) -> str: ...
Reference nested state:
@tool("Set user preference")
def set_preference(
self,
key: str = spec.Param(values=ref.config.user.valid_preference_keys)
) -> str: ...
Accessing Agent State in Tools
Tools have full access to agent state via self:
class ResearchAgent(BaseAgent):
__system_message__ = "I research papers"
paper_count: State[int] = spec.State(default=0)
current_topic: State[str] = spec.State(default="general")
@tool("Add a paper to the collection")
def add_paper(self, title: str, authors: str) -> str:
# Read state
topic = self.current_topic
# Modify state
self.paper_count += 1
# Use state in logic
db.save_paper(title, authors, topic)
return f"Added paper #{self.paper_count} about {topic}"
State modifications in tools persist across agent calls, enabling stateful workflows.
Async Tools
Tools can be async functions for I/O operations:
class APIAgent(BaseAgent):
__system_message__ = "I call external APIs"
@tool("Fetch data from external API")
async def fetch_data(self, endpoint: str) -> str:
async with httpx.AsyncClient() as client:
response = await client.get(f"https://api.example.com/{endpoint}")
data = response.json()
return f"Retrieved {len(data)} items from {endpoint}"
@tool("Search the web")
async def web_search(self, query: str, max_results: int = 5) -> str:
results = await async_web_search(query, limit=max_results)
return f"Found {len(results)} results for '{query}'"
PyAgentic automatically detects and handles async tools, awaiting them during execution.
Conditional Tools
Tools can be conditionally included based on state using the condition parameter:
class WorkflowAgent(BaseAgent):
__system_message__ = "I manage workflows"
workflow_stage: State[str] = spec.State(default="initial")
@tool(
"Start data processing",
condition=lambda self: self.workflow_stage == "ready"
)
def start_processing(self) -> str:
self.workflow_stage = "processing"
return "Started processing"
@tool(
"Finalize results",
condition=lambda self: self.workflow_stage == "processing"
)
def finalize(self) -> str:
self.workflow_stage = "complete"
return "Finalized results"
How condition Works
The condition parameter accepts a callable (typically a lambda function) that receives the agent instance (self) and returns a boolean:
- Evaluation Time: Conditions are evaluated each time the tool schema is generated for the LLM, which happens before every agent interaction
- Access to State: The condition function has full access to the agent's state via
self, allowing dynamic decisions based on current values - Dynamic Toolset: Only tools whose conditions evaluate to
Trueare included in the LLM's available toolset for that interaction
This mechanism allows you to create state-machine-like workflows where available tools change as the agent progresses through different states.
Tools can also be restricted to specific phases using the phases parameter for structured multi-stage workflows. See the phases documentation for more details.
Error Handling
When tools raise exceptions, PyAgentic catches them and returns an error message to the LLM:
@tool("Divide two numbers")
def divide(self, a: float, b: float) -> str:
if b == 0:
raise ValueError("Cannot divide by zero")
result = a / b
return f"{a} / {b} = {result}"
If the LLM calls this tool with b=0, PyAgentic will catch the exception and send a message like:
Tool `divide` failed: Cannot divide by zero. Please kindly state to the user that it failed, provide state, and ask if they want to try again.
The LLM can then inform the user and potentially retry with different parameters.
Custom Error Handling
For more control, catch exceptions yourself:
@tool("Process data file")
def process_file(self, path: str) -> str:
try:
with open(path) as f:
data = json.load(f)
results = process_data(data)
return f"Processed {len(results)} items from {path}"
except FileNotFoundError:
return f"Error: File '{path}' not found. Available files: {', '.join(self.available_files)}"
except json.JSONDecodeError:
return f"Error: File '{path}' is not valid JSON"
except Exception as e:
return f"Error processing file: {str(e)}"
Returning error messages as strings allows the LLM to handle errors gracefully and provide helpful feedback to users.