Skip to content

Commit d20ce0d

Browse files
committed
feat: Refactor and update tool loading to support modules
1 parent 776fd93 commit d20ce0d

File tree

5 files changed

+273
-61
lines changed

5 files changed

+273
-61
lines changed

.github/workflows/test-lint.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ jobs:
6666
id: tests
6767
run: hatch test tests --cover
6868
continue-on-error: false
69+
70+
- name: Upload coverage reports to Codecov
71+
uses: codecov/codecov-action@v5
72+
with:
73+
token: ${{ secrets.CODECOV_TOKEN }}
6974
lint:
7075
name: Lint
7176
runs-on: ubuntu-latest

src/strands/tools/loader.py

Lines changed: 148 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
import os
66
import sys
77
import warnings
8+
from importlib.machinery import ModuleSpec
89
from pathlib import Path
10+
from posixpath import expanduser
11+
from types import ModuleType
912
from typing import List, cast
1013

1114
from ..types.tools import AgentTool
@@ -15,16 +18,151 @@
1518
logger = logging.getLogger(__name__)
1619

1720

21+
def load_tool_from_string(tool_string: str) -> List[AgentTool]:
22+
"""Load tools follows strands supported input string formats.
23+
24+
This function can load a tool based on a string in the following ways:
25+
1. Local file path to a module based tool: `./path/to/module/tool.py`
26+
2. Module import path
27+
2.1. Path to a module based tool: `strands_tools.file_read`
28+
2.2. Path to a module with multiple AgentTool instances (@tool decorated): `tests.fixtures.say_tool`
29+
2.3. Path to a module and a specific function: `tests.fixtures.say_tool:say`
30+
"""
31+
# Case 1: Local file path to a tool
32+
# Ex: ./path/to/my_cool_tool.py
33+
tool_path = expanduser(tool_string)
34+
if os.path.exists(tool_path):
35+
return load_tools_from_file_path(tool_path)
36+
37+
# Case 2: Module import path
38+
# Ex: test.fixtures.say_tool:say (Load specific @tool decorated function)
39+
# Ex: strands_tools.file_read (Load all @tool decorated functions, or module tool)
40+
return load_tools_from_module_path(tool_string)
41+
42+
43+
def load_tools_from_file_path(tool_path: str) -> List[AgentTool]:
44+
"""Load module from specified path, and then load tools from that module.
45+
46+
This function attempts to load the passed in path as a python module, and if it succeeds,
47+
then it tries to import strands tool(s) from that module.
48+
"""
49+
abs_path = str(Path(tool_path).resolve())
50+
logger.debug("tool_path=<%s> | loading python tool from path", abs_path)
51+
52+
# Load the module by spec
53+
54+
# Using this to determine the module name
55+
# ./path/to/my_cool_tool.py -> my_cool_tool
56+
module_name = os.path.basename(tool_path).split(".")[0]
57+
58+
# This function import a module based on its path, and gives it the provided name
59+
60+
spec: ModuleSpec = cast(ModuleSpec, importlib.util.spec_from_file_location(module_name, abs_path))
61+
if not spec:
62+
raise ImportError(f"Could not create spec for {module_name}")
63+
if not spec.loader:
64+
raise ImportError(f"No loader available for {module_name}")
65+
66+
module = importlib.util.module_from_spec(spec)
67+
# Load, or re-load, the module
68+
sys.modules[module_name] = module
69+
# Execute the module to run any top level code
70+
spec.loader.exec_module(module)
71+
72+
return load_tools_from_module(module, module_name)
73+
74+
75+
def load_tools_from_module_path(module_path: str) -> list[AgentTool]:
76+
"""Load strands tool from a module path.
77+
78+
Example module paths:
79+
my.module.path
80+
my.module.path:tool_name
81+
"""
82+
if ":" in module_path:
83+
module_name, tool_func_name = module_path.split(":")
84+
else:
85+
module_name, tool_func_name = (module_path, None)
86+
87+
try:
88+
module = importlib.import_module(module_name)
89+
except ModuleNotFoundError as e:
90+
raise AttributeError(f'Tool string: "{module_path}" is not a valid tool string.') from e
91+
92+
# If a ':' is present in the string, then its a targeted function in a module
93+
if tool_func_name:
94+
if tool_func_name in dir(module):
95+
target_tool = getattr(module, tool_func_name)
96+
if isinstance(target_tool, DecoratedFunctionTool):
97+
return [target_tool]
98+
99+
raise AttributeError(f"Tool {tool_func_name} not found in module {module_name}")
100+
101+
# Else, try to import all of the @tool decorated tools, or the module based tool
102+
module_name = module_path.split(".")[-1]
103+
return load_tools_from_module(module, module_name)
104+
105+
106+
def load_tools_from_module(module: ModuleType, module_name: str) -> list[AgentTool]:
107+
"""Load tools from a module.
108+
109+
First checks if the passed in module has instances of DecoratedToolFunction classes as atributes to the module.
110+
If so, then it returns them as a list of tools. If not, then it attempts to load the module as a module based too.
111+
"""
112+
logger.debug("tool_name=<%s>, module=<%s> | loading tools from module", module_name, module_name)
113+
114+
# Try and see if any of the attributes in the module are function-based tools decorated with @tool
115+
# This means that there may be more than one tool available in this module, so we load them all
116+
117+
function_tools: List[AgentTool] = []
118+
# Function tools will appear as attributes in the module
119+
for attr_name in dir(module):
120+
attr = getattr(module, attr_name)
121+
# Check if the module attribute is a DecoratedFunctiontool
122+
if isinstance(attr, DecoratedFunctionTool):
123+
logger.debug("tool_name=<%s>, module=<%s> | found function-based tool in module", attr_name, module_name)
124+
function_tools.append(cast(AgentTool, attr))
125+
126+
if function_tools:
127+
return function_tools
128+
129+
# Finally, if no DecoratedFunctionTools are found in the module, fall back
130+
# to module based tools, and search for TOOL_SPEC + function
131+
module_tool_name = module_name
132+
tool_spec = getattr(module, "TOOL_SPEC", None)
133+
if not tool_spec:
134+
raise AttributeError(
135+
f"The module {module_tool_name} is not a valid module for loading tools."
136+
"This module must contain @tool decorated function(s), or must be a module based tool."
137+
)
138+
139+
# If this is a module based tool, the module should have a function with the same name as the module itself
140+
if not hasattr(module, module_tool_name):
141+
raise AttributeError(f"Module-based tool {module_tool_name} missing function {module_tool_name}")
142+
143+
tool_func = getattr(module, module_tool_name)
144+
if not callable(tool_func):
145+
raise TypeError(f"Tool {module_tool_name} function is not callable")
146+
147+
return [PythonAgentTool(module_tool_name, tool_spec, tool_func)]
148+
149+
18150
class ToolLoader:
19151
"""Handles loading of tools from different sources."""
20152

21153
@staticmethod
22154
def load_python_tools(tool_path: str, tool_name: str) -> List[AgentTool]:
23-
"""Load a Python tool module and return all discovered function-based tools as a list.
155+
"""DEPRECATED: Load a Python tool module and return all discovered function-based tools as a list.
24156
25157
This method always returns a list of AgentTool (possibly length 1). It is the
26158
canonical API for retrieving multiple tools from a single Python file.
27159
"""
160+
warnings.warn(
161+
"ToolLoader.load_python_tool is deprecated and will be removed in Strands SDK 2.0. "
162+
"Use the `load_tools_from_string` or `load_tools_from_module` methods instead.",
163+
DeprecationWarning,
164+
stacklevel=2,
165+
)
28166
try:
29167
# Support module:function style (e.g. package.module:function)
30168
if not os.path.exists(tool_path) and ":" in tool_path:
@@ -108,7 +246,7 @@ def load_python_tool(tool_path: str, tool_name: str) -> AgentTool:
108246
"""
109247
warnings.warn(
110248
"ToolLoader.load_python_tool is deprecated and will be removed in Strands SDK 2.0. "
111-
"Use ToolLoader.load_python_tools(...) which always returns a list of AgentTool.",
249+
"Use the `load_tools_from_string` or `load_tools_from_module` methods instead.",
112250
DeprecationWarning,
113251
stacklevel=2,
114252
)
@@ -127,7 +265,7 @@ def load_tool(cls, tool_path: str, tool_name: str) -> AgentTool:
127265
"""
128266
warnings.warn(
129267
"ToolLoader.load_tool is deprecated and will be removed in Strands SDK 2.0. "
130-
"Use ToolLoader.load_tools(...) which always returns a list of AgentTool.",
268+
"Use the `load_tools_from_string` or `load_tools_from_module` methods instead.",
131269
DeprecationWarning,
132270
stacklevel=2,
133271
)
@@ -140,7 +278,7 @@ def load_tool(cls, tool_path: str, tool_name: str) -> AgentTool:
140278

141279
@classmethod
142280
def load_tools(cls, tool_path: str, tool_name: str) -> list[AgentTool]:
143-
"""Load tools from a file based on its file extension.
281+
"""DEPRECATED: Load tools from a file based on its file extension.
144282
145283
Args:
146284
tool_path: Path to the tool file.
@@ -154,6 +292,12 @@ def load_tools(cls, tool_path: str, tool_name: str) -> list[AgentTool]:
154292
ValueError: If the tool file has an unsupported extension.
155293
Exception: For other errors during tool loading.
156294
"""
295+
warnings.warn(
296+
"ToolLoader.load_tools is deprecated and will be removed in Strands SDK 2.0. "
297+
"Use the `load_tools_from_string` or `load_tools_from_module` methods instead.",
298+
DeprecationWarning,
299+
stacklevel=2,
300+
)
157301
ext = Path(tool_path).suffix.lower()
158302
abs_path = str(Path(tool_path).resolve())
159303

src/strands/tools/registry.py

Lines changed: 85 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import logging
99
import os
1010
import sys
11+
import warnings
1112
from importlib import import_module, util
1213
from os.path import expanduser
1314
from pathlib import Path
@@ -18,6 +19,7 @@
1819
from strands.tools.decorator import DecoratedFunctionTool
1920

2021
from ..types.tools import AgentTool, ToolSpec
22+
from .loader import load_tool_from_string, load_tools_from_module
2123
from .tools import PythonAgentTool, normalize_schema, normalize_tool_spec
2224

2325
logger = logging.getLogger(__name__)
@@ -36,77 +38,97 @@ def __init__(self) -> None:
3638
self.tool_config: Optional[Dict[str, Any]] = None
3739

3840
def process_tools(self, tools: List[Any]) -> List[str]:
39-
"""Process tools list that can contain tool names, paths, imported modules, or functions.
41+
"""Process tools list.
42+
43+
Process list of tools that can contain local file path string, module import path string,
44+
imported modules,@tool decorated functions, or instances of AgentTool
4045
4146
Args:
4247
tools: List of tool specifications.
4348
Can be:
49+
1. Local file path to a module based tool: `./path/to/module/tool.py`
50+
2. Module import path
51+
2.1. Path to a module based tool: `strands_tools.file_read`
52+
2.2. Path to a module with multiple AgentTool instances (@tool decorated): `tests.fixtures.say_tool`
53+
2.3. Path to a module and a specific function: `tests.fixtures.say_tool:say`
54+
3. A module for a module based tool
55+
4. Instances of AgentTool (@tool decorated functions)
56+
5. Dictionaries with name/path keys (deprecated)
4457
45-
- String tool names (e.g., "calculator")
46-
- File paths (e.g., "/path/to/tool.py")
47-
- Imported Python modules (e.g., a module object)
48-
- Functions decorated with @tool
49-
- Dictionaries with name/path keys
50-
- Instance of an AgentTool
5158
5259
Returns:
5360
List of tool names that were processed.
5461
"""
5562
tool_names = []
5663

5764
def add_tool(tool: Any) -> None:
58-
# Case 1: String file path
59-
if isinstance(tool, str):
60-
# Extract tool name from path
61-
tool_name = os.path.basename(tool).split(".")[0]
62-
self.load_tool_from_filepath(tool_name=tool_name, tool_path=tool)
63-
tool_names.append(tool_name)
64-
65-
# Case 2: Dictionary with name and path
66-
elif isinstance(tool, dict) and "name" in tool and "path" in tool:
67-
self.load_tool_from_filepath(tool_name=tool["name"], tool_path=tool["path"])
68-
tool_names.append(tool["name"])
69-
70-
# Case 3: Dictionary with path only
71-
elif isinstance(tool, dict) and "path" in tool:
72-
tool_name = os.path.basename(tool["path"]).split(".")[0]
73-
self.load_tool_from_filepath(tool_name=tool_name, tool_path=tool["path"])
74-
tool_names.append(tool_name)
75-
76-
# Case 4: Imported Python module
77-
elif hasattr(tool, "__file__") and inspect.ismodule(tool):
78-
# Get the module file path
79-
module_path = tool.__file__
80-
# Extract the tool name from the module name
81-
tool_name = tool.__name__.split(".")[-1]
82-
83-
# Check for TOOL_SPEC in module to validate it's a Strands tool
84-
if hasattr(tool, "TOOL_SPEC") and hasattr(tool, tool_name) and module_path:
85-
self.load_tool_from_filepath(tool_name=tool_name, tool_path=module_path)
86-
tool_names.append(tool_name)
65+
try:
66+
# String based tool
67+
# Can be a file path, a module path, or a module path with a targeted function. Examples:
68+
# './path/to/tool.py'
69+
# 'my.module.tool'
70+
# 'my.module.tool:tool_name'
71+
if isinstance(tool, str):
72+
tools = load_tool_from_string(tool)
73+
for a_tool in tools:
74+
a_tool.mark_dynamic()
75+
self.register_tool(a_tool)
76+
tool_names.append(a_tool.tool_name)
77+
78+
# Dictionary with path only
79+
elif isinstance(tool, dict) and "path" in tool:
80+
tools = load_tool_from_string(tool["path"])
81+
82+
for a_tool in tools:
83+
a_tool.mark_dynamic()
84+
self.register_tool(a_tool)
85+
tool_names.append(a_tool.tool_name)
86+
87+
# Dictionary with name and path
88+
elif isinstance(tool, dict) and "name" in tool and "path" in tool:
89+
tools = load_tool_from_string(tool["path"])
90+
91+
tool_found = False
92+
for a_tool in tools:
93+
if a_tool.tool_name == tool["name"]:
94+
a_tool.mark_dynamic()
95+
self.register_tool(a_tool)
96+
tool_names.append(a_tool.tool_name)
97+
tool_found = True
98+
99+
if not tool_found:
100+
raise ValueError(f"Failed to load tool {tool}")
101+
102+
# Imported Python module
103+
elif hasattr(tool, "__file__") and inspect.ismodule(tool):
104+
print(tool)
105+
# Extract the tool name from the module name
106+
module_tool_name = tool.__name__.split(".")[-1]
107+
108+
tools = load_tools_from_module(tool, module_tool_name)
109+
for a_tool in tools:
110+
self.register_tool(a_tool)
111+
tool_names.append(a_tool.tool_name)
112+
113+
# Case 5: AgentTools (which also covers @tool)
114+
elif isinstance(tool, AgentTool):
115+
self.register_tool(tool)
116+
tool_names.append(tool.tool_name)
117+
118+
# Case 6: Nested iterable (list, tuple, etc.) - add each sub-tool
119+
elif isinstance(tool, Iterable) and not isinstance(tool, (str, bytes, bytearray)):
120+
for t in tool:
121+
add_tool(t)
87122
else:
88-
function_tools = self._scan_module_for_tools(tool)
89-
for function_tool in function_tools:
90-
self.register_tool(function_tool)
91-
tool_names.append(function_tool.tool_name)
92-
93-
if not function_tools:
94-
logger.warning("tool_name=<%s>, module_path=<%s> | invalid agent tool", tool_name, module_path)
95-
96-
# Case 5: AgentTools (which also covers @tool)
97-
elif isinstance(tool, AgentTool):
98-
self.register_tool(tool)
99-
tool_names.append(tool.tool_name)
100-
# Case 6: Nested iterable (list, tuple, etc.) - add each sub-tool
101-
elif isinstance(tool, Iterable) and not isinstance(tool, (str, bytes, bytearray)):
102-
for t in tool:
103-
add_tool(t)
104-
else:
105-
logger.warning("tool=<%s> | unrecognized tool specification", tool)
123+
logger.warning("tool=<%s> | unrecognized tool specification", tool)
106124

107-
for a_tool in tools:
108-
add_tool(a_tool)
125+
except Exception as e:
126+
exception_str = str(e)
127+
logger.exception("tool_name=<%s> | failed to load tool", tool)
128+
raise ValueError(f"Failed to load tool {tool}: {exception_str}") from e
109129

130+
for tool in tools:
131+
add_tool(tool)
110132
return tool_names
111133

112134
def load_tool_from_filepath(self, tool_name: str, tool_path: str) -> None:
@@ -120,6 +142,13 @@ def load_tool_from_filepath(self, tool_name: str, tool_path: str) -> None:
120142
FileNotFoundError: If the tool file is not found.
121143
ValueError: If the tool cannot be loaded.
122144
"""
145+
warnings.warn(
146+
"load_tool_from_filepath is deprecated and will be removed in Strands SDK 2.0. "
147+
"`process_tools` automatically handles loading tools from a filepath.",
148+
DeprecationWarning,
149+
stacklevel=2,
150+
)
151+
123152
from .loader import ToolLoader
124153

125154
try:

0 commit comments

Comments
 (0)