Skip to content

Commit 983e4d5

Browse files
author
galuszkm
committed
fix(tools): support legacy strands module-based tool pattern
- Update tool loader to fall back to strands module pattern when no @tool-decorated functions found - Import strands load_tools_from_module for TOOL_SPEC-based tool compatibility - Add test coverage for TOOL_SPEC module pattern, private tools, and error cases
1 parent e442059 commit 983e4d5

5 files changed

Lines changed: 111 additions & 13 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ agents:
3636
researcher:
3737
model: default
3838
system_prompt: "You research topics."
39-
tools: [strands_tools:http_request]
39+
tools: [strands_tools.http_request]
4040

4141
writer:
4242
model: default

docs/configuration/Chapter_05.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ agents:
1616
- ./utils/ # All @tool functions from all .py files in dir
1717
- my_package.tools # All @tool functions from an installed module
1818
- my_package.tools:special_function # One specific function from a module
19-
- strands_tools:http_request # A tool from strands' built-in tools
19+
- strands_tools.http_request # A tool from strands' built-in tools
2020
system_prompt: "You analyze text using your tools."
2121
```
2222
@@ -97,7 +97,7 @@ tools/
9797
> **Tips & Tricks**
9898
>
9999
> - Organize tools in a directory when you have many of them. One file per domain: `tools/math.py`, `tools/text.py`, `tools/database.py`.
100-
> - The `strands_tools` package has built-in tools like `http_request`, `file_read`, `shell` — use them with `strands_tools:http_request`.
100+
> - The `strands_tools` package has built-in tools like `http_request`, `file_read`, `shell` — use them with `strands_tools.http_request`.
101101
> - Each agent gets its own copy of tools. Two agents referencing the same file get independent tool instances.
102102
> - Tool function docstrings are sent to the LLM as the tool description. Write good docstrings — they directly affect how well the model uses your tools.
103103
> - Type hints on tool parameters become the JSON schema the LLM sees. Use `str`, `int`, `float`, `bool`, `list[str]`, etc. The more specific your types, the better the LLM calls your tools.

src/strands_compose/tools/loaders.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from typing import Any
1919

2020
from strands.tools.decorator import tool
21+
from strands.tools.loader import load_tools_from_module as _strands_load_tools_from_module
2122
from strands.types.tools import AgentTool
2223

2324
from ..utils import load_module_from_file
@@ -98,7 +99,12 @@ def load_tools_from_file(path: str | Path) -> list[AgentTool]:
9899

99100

100101
def load_tools_from_module(module_path: str) -> list[AgentTool]:
101-
"""Load all @tool decorated functions from a Python module.
102+
"""Load tools from a Python module.
103+
104+
First scans for ``@tool``-decorated functions. If none are found,
105+
falls back to the strands module-based tool pattern (``TOOL_SPEC`` dict
106+
+ a function named after the module). This ensures compatibility with
107+
tools like ``strands_tools.http_request`` that use the legacy pattern.
102108
103109
Args:
104110
module_path: Dotted import path (e.g., "my_package.tools").
@@ -108,9 +114,19 @@ def load_tools_from_module(module_path: str) -> list[AgentTool]:
108114
109115
Raises:
110116
ImportError: If the module cannot be imported.
117+
AttributeError: If the module contains neither ``@tool``-decorated
118+
functions nor a valid ``TOOL_SPEC`` + module-named function.
111119
"""
112120
module = importlib.import_module(module_path)
113-
return _collect_tools(module)
121+
tools = _collect_tools(module)
122+
if tools:
123+
return tools
124+
125+
# Fallback: delegate to strands for TOOL_SPEC-based (module) tools.
126+
# Strands raises AttributeError if the module has neither @tool functions
127+
# nor a valid TOOL_SPEC + module-named function.
128+
module_name = module_path.split(".")[-1]
129+
return _strands_load_tools_from_module(module, module_name)
114130

115131

116132
def load_tool_function(spec: str) -> AgentTool:

tests/unit/test_tools.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ def test_resolve_file_with_function(self, tools_dir, monkeypatch):
8888
tools = resolve_tool_spec(f"{file_path}:greet")
8989
assert len(tools) == 1
9090

91-
def test_resolve_file_colon_plain_function_autowraps(self, plain_tools_file, caplog, monkeypatch):
91+
def test_resolve_file_colon_plain_function_autowraps(
92+
self, plain_tools_file, caplog, monkeypatch
93+
):
9294
"""Plain function named explicitly via file colon spec is auto-wrapped.
9395
9496
The function must become an AgentTool and a warning must be logged

tests/unit/test_tools_module.py

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,19 @@ def greet(name: str) -> str:
3434
finally:
3535
sys.modules.pop("_test_module_with_tools", None)
3636

37-
def test_ignores_plain_functions(self):
38-
"""Plain (undecorated) public functions must NOT be collected."""
37+
def test_plain_functions_without_tool_spec_raises(self):
38+
"""Module with only plain functions and no TOOL_SPEC raises AttributeError."""
3939
mod = types.ModuleType("_test_module_plain")
4040
setattr(mod, "plain_func", lambda: None)
4141
sys.modules["_test_module_plain"] = mod
4242
try:
43-
tools = load_tools_from_module("_test_module_plain")
44-
assert tools == []
43+
with pytest.raises(AttributeError, match="not a valid module"):
44+
load_tools_from_module("_test_module_plain")
4545
finally:
4646
sys.modules.pop("_test_module_plain", None)
4747

48-
def test_ignores_private_attributes(self):
49-
"""Underscore-prefixed attributes should be skipped."""
48+
def test_private_only_module_falls_back_to_strands(self):
49+
"""Module with only _-prefixed @tool functions falls back to strands which finds them."""
5050
mod = types.ModuleType("_test_module_private")
5151

5252
@tool
@@ -58,7 +58,8 @@ def _private_tool(x: int) -> int:
5858
sys.modules["_test_module_private"] = mod
5959
try:
6060
tools = load_tools_from_module("_test_module_private")
61-
assert tools == []
61+
assert len(tools) == 1
62+
assert tools[0].tool_name == "_private_tool"
6263
finally:
6364
sys.modules.pop("_test_module_private", None)
6465

@@ -90,3 +91,82 @@ def tool_b(y: str) -> str:
9091
assert "tool_b" in names
9192
finally:
9293
sys.modules.pop("_test_module_multi", None)
94+
95+
def test_tool_spec_module_fallback(self):
96+
"""Module with TOOL_SPEC + same-name function is loaded via strands fallback."""
97+
mod = types.ModuleType("_test_module_spec")
98+
setattr(
99+
mod,
100+
"TOOL_SPEC",
101+
{
102+
"name": "_test_module_spec",
103+
"description": "A test module-based tool",
104+
"inputSchema": {
105+
"json": {
106+
"type": "object",
107+
"properties": {
108+
"query": {"type": "string", "description": "Search query"},
109+
},
110+
"required": ["query"],
111+
}
112+
},
113+
},
114+
)
115+
116+
def _test_module_spec(tool_use, **kwargs):
117+
return {"status": "success", "content": [{"text": "ok"}], "toolUseId": "t1"}
118+
119+
setattr(mod, "_test_module_spec", _test_module_spec)
120+
sys.modules["_test_module_spec"] = mod
121+
try:
122+
tools = load_tools_from_module("_test_module_spec")
123+
assert len(tools) == 1
124+
assert tools[0].tool_name == "_test_module_spec"
125+
finally:
126+
sys.modules.pop("_test_module_spec", None)
127+
128+
def test_tool_spec_fallback_missing_function_raises(self):
129+
"""Module with TOOL_SPEC but no matching function raises AttributeError."""
130+
mod = types.ModuleType("_test_module_no_func")
131+
setattr(
132+
mod,
133+
"TOOL_SPEC",
134+
{
135+
"name": "_test_module_no_func",
136+
"description": "Missing function",
137+
"inputSchema": {"json": {"type": "object", "properties": {}}},
138+
},
139+
)
140+
sys.modules["_test_module_no_func"] = mod
141+
try:
142+
with pytest.raises(AttributeError):
143+
load_tools_from_module("_test_module_no_func")
144+
finally:
145+
sys.modules.pop("_test_module_no_func", None)
146+
147+
def test_decorated_tools_take_priority_over_tool_spec(self):
148+
"""When both @tool functions and TOOL_SPEC exist, @tool wins."""
149+
mod = types.ModuleType("_test_module_both")
150+
151+
@tool
152+
def my_tool(x: int) -> int:
153+
"""A decorated tool."""
154+
return x
155+
156+
setattr(mod, "my_tool", my_tool)
157+
setattr(
158+
mod,
159+
"TOOL_SPEC",
160+
{
161+
"name": "_test_module_both",
162+
"description": "Should be ignored",
163+
"inputSchema": {"json": {"type": "object", "properties": {}}},
164+
},
165+
)
166+
sys.modules["_test_module_both"] = mod
167+
try:
168+
tools = load_tools_from_module("_test_module_both")
169+
assert len(tools) == 1
170+
assert tools[0].tool_name == "my_tool"
171+
finally:
172+
sys.modules.pop("_test_module_both", None)

0 commit comments

Comments
 (0)