Dynamic Tools Module
June 14, 2026 · View on GitHub
Import: from selectools.tools import ToolLoader
Stability: stable
from selectools import Agent, LocalProvider, tool
from selectools.tools import ToolLoader
@tool(description="Greet a user")
def greet(name: str) -> str:
return f"Hello, {name}!"
agent = Agent(tools=[greet], provider=LocalProvider())
# Add a new tool at runtime
@tool(description="Say goodbye")
def farewell(name: str) -> str:
return f"Goodbye, {name}!"
agent.add_tool(farewell)
result = agent.run("Greet Alice")
print(result.content)
!!! tip "See Also"
- Tools -- tool definition and @tool decorator
- Toolbox -- 56 pre-built tools for common tasks
File: src/selectools/tools/loader.py, src/selectools/agent/core.py
Classes: ToolLoader (loader), Agent (dynamic tool methods)
Imports: ToolLoader, Tool, tool from selectools.tools; Agent, AgentConfig from selectools
Table of Contents
- Overview
- Architecture
- Quick Start
- ToolLoader
- Agent Dynamic Methods
- Plugin System Pattern
- Hot-Reload Pattern
- Integration with ToolRegistry
- Error Handling
- Best Practices
- Troubleshooting
- Further Reading
Overview
Dynamic tool loading enables agents to discover, load, and manage tools at runtime—without restarting the application. This supports:
- Plugin Systems: Load tools from third-party or user-provided modules
- Hot-Reload: Update tool implementations during development without restarting
- A/B Testing: Swap tool sets dynamically to compare behavior
- Conditional Tool Loading: Load tools based on environment, permissions, or feature flags
Why It Matters
| Use Case | Without Dynamic Loading | With Dynamic Loading |
|---|---|---|
| New plugin | Restart app, redeploy | Load module, call agent.add_tools() |
| Fix tool bug | Restart app | Call ToolLoader.reload_file(), agent.replace_tool() |
| Experiment | Deploy different builds | Swap tools at runtime via replace_tool() |
Core Components
ToolLoader # Discover and load @tool-decorated functions from modules/files/dirs
Agent # add_tool, add_tools, remove_tool, replace_tool — all rebuild system prompt
Architecture
graph TD
A["Plugin Source<br/>Directory / File / Module"] -->|"ToolLoader.from_directory()<br/>from_file() / from_module()"| B["ToolLoader<br/>Returns List of Tool"]
B -->|"agent.add_tools()<br/>add_tool() / replace_tool()"| C["Agent<br/>Updates tools, rebuilds system prompt"]
C --> D["LLM Sees New Tools"]
Quick Start
File-Based Plugin Loading
from selectools.tools import ToolLoader, Tool, tool
from selectools import Agent, AgentConfig
from selectools.providers.openai_provider import OpenAIProvider
# Define a minimal tool in a file (or use existing plugin)
# plugins/greeting.py:
# from selectools.tools import tool
# @tool(description="Greet a user by name")
# def greet(name: str) -> str:
# return f"Hello, {name}!"
# Load tools from plugin directory
tools = ToolLoader.from_directory("./plugins/")
agent = Agent(
tools=tools,
provider=OpenAIProvider(),
config=AgentConfig(model="gpt-4o-mini"),
)
response = agent.run([Message(role=Role.USER, content="Greet Alice")])
Module-Based Loading
tools = ToolLoader.from_module("myapp.tools.search")
agent = Agent(tools=tools, provider=provider)
Add Tool at Runtime
@tool(description="Get current time")
def get_time() -> str:
from datetime import datetime
return datetime.now().isoformat()
agent.add_tool(get_time)
# System prompt rebuilt — LLM can call get_time immediately
ToolLoader
ToolLoader discovers and loads Tool instances (functions decorated with @tool) from Python modules, files, or directories.
Import
from selectools.tools import ToolLoader, Tool, tool
Methods
ToolLoader.from_module(module_path: str) -> List[Tool]
Import a dotted module path and return all Tool objects found.
| Argument | Type | Description |
|---|---|---|
module_path | str | Dotted Python module path, e.g. "myapp.tools" |
Returns: List of Tool instances discovered in the module.
Raises: ImportError if the module cannot be imported.
tools = ToolLoader.from_module("myproject.tools.search")
ToolLoader.from_file(file_path: str) -> List[Tool]
Load a single .py file and return all Tool objects found. The file is imported as a module under the _selectools_dynamic_ namespace.
| Argument | Type | Description |
|---|---|---|
file_path | str | Absolute or relative path to a .py file |
Returns: List of Tool instances discovered in the file.
Raises:
FileNotFoundErrorif the file does not existValueErrorif the path is not a.pyfileImportErrorif the file cannot be imported
tools = ToolLoader.from_file("/abs/path/to/search_tools.py")
# Module registered as: _selectools_dynamic_.search_tools
ToolLoader.from_directory(directory, *, recursive=False, exclude=None) -> List[Tool]
Discover and load Tool objects from all .py files in a directory.
| Argument | Type | Default | Description |
|---|---|---|---|
directory | str | — | Path to the directory to scan |
recursive | bool | False | If True, also scan subdirectories |
exclude | Sequence[str] | None | Optional sequence of filenames to skip |
Behavior:
- Files whose names start with
_are skipped by default - Uses
**/*.pywhenrecursive=True, else*.py - Skips files in
exclude - On per-file import error: skips file and continues (no exception raised)
Returns: List of Tool instances discovered across all loaded files.
Raises: FileNotFoundError if the directory does not exist.
tools = ToolLoader.from_directory("./plugins/")
tools = ToolLoader.from_directory("./plugins/", recursive=True)
tools = ToolLoader.from_directory("./plugins/", exclude=["deprecated.py", "test_tools.py"])
ToolLoader.reload_module(module_path: str) -> List[Tool]
Re-import a module and return freshly loaded Tool objects. Useful for hot-reloading tools without restarting.
| Argument | Type | Description |
|---|---|---|
module_path | str | Dotted Python module path to reload |
Returns: List of Tool instances from the reloaded module.
Raises: ImportError if the module cannot be reloaded.
Behavior: If the module is not in sys.modules, falls back to from_module().
tools = ToolLoader.reload_module("myapp.tools.search")
ToolLoader.reload_file(file_path: str) -> List[Tool]
Re-import a Python file and return freshly loaded Tool objects. Useful for hot-reloading plugin files after edits.
| Argument | Type | Description |
|---|---|---|
file_path | str | Path to the .py file to reload |
Returns: List of Tool instances from the reloaded file.
Behavior: Removes the module from sys.modules (if present) so the next import is fresh.
tools = ToolLoader.reload_file("/path/to/plugins/search.py")
Namespace Convention
Files loaded via from_file() or from_directory() are imported as:
_selectools_dynamic_.<filename_stem>
Example: /path/to/search_tools.py → _selectools_dynamic_.search_tools
This avoids conflicts with application module names.
Agent Dynamic Methods
All dynamic tool methods rebuild the system prompt so the LLM immediately sees updated tool schemas on the next call.
agent.add_tool(tool: Tool) -> None
Add a single tool at runtime.
| Argument | Type | Description |
|---|---|---|
tool | Tool | Tool instance to add |
Raises: ValueError if a tool with the same name already exists (suggests replace_tool()).
agent.add_tool(new_tool)
agent.add_tools(tools: List[Tool]) -> None
Add multiple tools at runtime in a batch.
| Argument | Type | Description |
|---|---|---|
tools | List[Tool] | List of Tool instances to add |
Raises: ValueError if any tool name already exists.
agent.add_tools([tool_a, tool_b, tool_c])
agent.remove_tool(tool_name: str) -> Tool
Remove a tool by name.
| Argument | Type | Description |
|---|---|---|
tool_name | str | Name of the tool to remove |
Returns: The removed Tool instance.
Raises:
KeyErrorif no tool with that name exists
Removing the last tool is allowed — the agent becomes a pure conversational agent (no tools).
removed = agent.remove_tool("deprecated_search")
agent.replace_tool(tool: Tool) -> Optional[Tool]
Replace an existing tool with an updated version, or add the tool if no tool with that name exists.
| Argument | Type | Description |
|---|---|---|
tool | Tool | The new Tool instance |
Returns: The old Tool instance that was replaced, or None if the tool was newly added.
old_tool = agent.replace_tool(updated_search_tool)
# old_tool is the previous tool, or None if it was new
System Prompt Rebuild
Every dynamic method calls:
self._system_prompt = self.prompt_builder.build(self.tools)
So the next provider.complete() or provider.acomplete() call uses the updated tool schemas.
Plugin System Pattern
Directory-Based Plugin Architecture
myapp/
├── main.py
└── plugins/
├── search.py # @tool def search(...)
├── weather.py # @tool def weather(...)
├── calculator.py # @tool def add(...), multiply(...)
└── _internal.py # Skipped (starts with _)
main.py:
from pathlib import Path
from selectools.tools import ToolLoader, tool
from selectools import Agent, AgentConfig
from selectools.providers.openai_provider import OpenAIProvider
from selectools.types import Message, Role
plugins_dir = Path(__file__).parent / "plugins"
tools = ToolLoader.from_directory(str(plugins_dir))
agent = Agent(
tools=tools,
provider=OpenAIProvider(),
config=AgentConfig(model="gpt-4o-mini"),
)
response = agent.run([Message(role=Role.USER, content="Search for Python and add 2+3")])
Hot-Reload Pattern
Watch a file for changes, reload it, and replace tools in the agent.
import time
from pathlib import Path
from selectools.tools import ToolLoader, tool
from selectools import Agent, AgentConfig
def watch_and_reload(agent: Agent, file_path: str, tool_names: list[str]) -> None:
"""Reload file and replace tools in agent when file changes."""
path = Path(file_path)
last_mtime = 0.0
while True:
try:
mtime = path.stat().st_mtime
if mtime > last_mtime:
last_mtime = mtime
tools = ToolLoader.reload_file(str(path))
for t in tools:
if t.name in tool_names:
agent.replace_tool(t)
print(f"Reloaded tool: {t.name}")
except Exception as e:
print(f"Reload error: {e}")
time.sleep(1.0)
Usage:
# In development: run watch_and_reload in a background thread
import threading
watch_thread = threading.Thread(
target=watch_and_reload,
args=(agent, "./plugins/search.py", ["search"]),
daemon=True,
)
watch_thread.start()
Integration with ToolRegistry
ToolLoader and ToolRegistry serve different roles:
| Feature | ToolLoader | ToolRegistry |
|---|---|---|
| Purpose | Load tools from modules/files/dirs | Organize tools defined in code |
| Discovery | File system, import path | Decorator @registry.tool() |
| Hot-reload | Yes (reload_file, reload_module) | No (registry is static) |
Combined Pattern
Load from plugins, then register in a registry for filtering:
from selectools.tools import ToolLoader, ToolRegistry, tool
# Load from plugin directory
plugin_tools = ToolLoader.from_directory("./plugins/")
# Also use registry for in-code tools
registry = ToolRegistry()
@registry.tool(description="Echo")
def echo(text: str) -> str:
return text
# Merge and pass to agent
all_tools = plugin_tools + registry.all()
agent = Agent(tools=all_tools, provider=provider)
Using ToolLoader with Registry.all()
registry = ToolRegistry()
# ... register tools ...
# Add plugins on top of registry
plugin_tools = ToolLoader.from_directory("./plugins/")
agent = Agent(
tools=registry.all() + plugin_tools,
provider=provider,
)
Error Handling
Duplicate Tool Names
agent.add_tool(search_tool)
agent.add_tool(search_tool) # ValueError: Tool 'search' already exists. Use replace_tool() to update it.
Fix: Use agent.replace_tool(search_tool) to update.
Missing Files
ToolLoader.from_file("/nonexistent/path.py") # FileNotFoundError: Tool file not found: ...
ToolLoader.from_directory("/nonexistent/") # FileNotFoundError: Tool directory not found: ...
Fix: Ensure paths exist and are correct.
Invalid Modules
ToolLoader.from_module("not.installed.module") # ImportError
ToolLoader.from_file("syntax_error.py") # ImportError (or SyntaxError)
Fix: Install dependencies or fix module syntax.
Removing the Last Tool
agent = Agent(tools=[only_tool], provider=provider)
agent.remove_tool("only_tool") # OK — agent becomes a pure chat agent (no tools)
Removing every tool is allowed; the agent then runs as a tool-less conversational agent.
Directory Import Failures
from_directory() catches per-file import errors and continues:
# plugins/good.py loads OK
# plugins/bad.py raises ImportError → skipped, no exception propagated
tools = ToolLoader.from_directory("./plugins/") # Returns tools from good.py only
Best Practices
1. Organize Plugins by Domain
plugins/
├── search/
│ ├── web_search.py
│ └── docs_search.py
├── data/
│ ├── db_query.py
│ └── csv_export.py
└── utils/
└── calculator.py
Load with recursive=True:
tools = ToolLoader.from_directory("./plugins/", recursive=True)
2. Use Private Files for Internals
Prefix with _ to skip during discovery:
plugins/
├── search.py # Loaded
├── _helpers.py # Skipped
└── _config.py # Skipped
3. Use replace_tool for Hot-Reload
Avoid removing then adding; use replace_tool() to keep tool order and avoid edge cases:
# Preferred
new_tools = ToolLoader.reload_file("./plugins/search.py")
for t in new_tools:
agent.replace_tool(t)
4. Validate After Load
tools = ToolLoader.from_directory("./plugins/")
assert len(tools) > 0, "No tools loaded from plugins"
# Optionally check for expected tool names
names = {t.name for t in tools}
assert "search" in names, "Missing expected 'search' tool"
5. Use exclude for Unwanted Files
tools = ToolLoader.from_directory(
"./plugins/",
exclude=["legacy_tools.py", "experimental.py"]
)
Troubleshooting
Tools Not Appearing in Agent
Symptom: LLM doesn't seem to know about new tools.
Causes:
- Tools were added but system prompt wasn't rebuilt (ensure you use
add_tool/add_tools/replace_tool, not direct list mutation) - Cache: if using
AgentConfig(cache=...), cached responses won't reflect new tools until cache key changes
Fix: Dynamic methods already rebuild the prompt. If using cache, consider invalidating or using a cache that incorporates tool set in the key.
Import Error When Loading File
Symptom: ImportError when calling from_file() or from_directory().
Causes:
- Missing dependencies in the plugin file
- Syntax errors in the file
- Circular imports
Fix: Run the plugin module directly to reproduce the error:
python -c "import importlib.util; spec = importlib.util.spec_from_file_location('test', 'plugins/search.py'); m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)"
Duplicate Tool Names Across Plugins
Symptom: ValueError: Tool 'X' already exists when calling add_tools().
Cause: Multiple plugin files define tools with the same name.
Fix:
- Rename tools to be unique (e.g.
web_search,docs_search) - Load files separately and merge manually, resolving duplicates
- Use
replace_tool()if the latter definition should override
Further Reading
- Tools Module — Tool definition,
@tooldecorator,ToolRegistry - Agent Module — Agent loop, configuration, hooks
- Prompt Module — How tool schemas are formatted in the system prompt
Next Steps: Define tools with the @tool decorator as described in the Tools Module.
Related Examples
| # | Script | Description |
|---|---|---|
| 13 | 13_dynamic_tools.py | Dynamic tool loading and hot-reload |
| 03 | 03_toolbox.py | Pre-built toolbox usage |
| 27 | 27_tool_policy.py | Tool policy (allow/review/deny) |
| 65 | 65_tool_composition.py | Tool composition with @compose |