Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ agents:
researcher:
model: default
system_prompt: "You research topics."
tools: [strands_tools:http_request]
tools: [strands_tools.http_request]

writer:
model: default
Expand Down
4 changes: 2 additions & 2 deletions docs/configuration/Chapter_05.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ agents:
- ./utils/ # All @tool functions from all .py files in dir
- my_package.tools # All @tool functions from an installed module
- my_package.tools:special_function # One specific function from a module
- strands_tools:http_request # A tool from strands' built-in tools
- strands_tools.http_request # A tool from strands' built-in tools
system_prompt: "You analyze text using your tools."
```

Expand Down Expand Up @@ -97,7 +97,7 @@ tools/
> **Tips & Tricks**
>
> - Organize tools in a directory when you have many of them. One file per domain: `tools/math.py`, `tools/text.py`, `tools/database.py`.
> - The `strands_tools` package has built-in tools like `http_request`, `file_read`, `shell` — use them with `strands_tools:http_request`.
> - The `strands_tools` package has built-in tools like `http_request`, `file_read`, `shell` — use them with `strands_tools.http_request`.
> - Each agent gets its own copy of tools. Two agents referencing the same file get independent tool instances.
> - 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.
> - 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.
Expand Down
20 changes: 18 additions & 2 deletions src/strands_compose/tools/loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from typing import Any

from strands.tools.decorator import tool
from strands.tools.loader import load_tools_from_module as _strands_load_tools_from_module
from strands.types.tools import AgentTool

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


def load_tools_from_module(module_path: str) -> list[AgentTool]:
"""Load all @tool decorated functions from a Python module.
"""Load tools from a Python module.

First scans for ``@tool``-decorated functions. If none are found,
falls back to the strands module-based tool pattern (``TOOL_SPEC`` dict
+ a function named after the module). This ensures compatibility with
tools like ``strands_tools.http_request`` that use the legacy pattern.

Args:
module_path: Dotted import path (e.g., "my_package.tools").
Expand All @@ -108,9 +114,19 @@ def load_tools_from_module(module_path: str) -> list[AgentTool]:

Raises:
ImportError: If the module cannot be imported.
AttributeError: If the module contains neither ``@tool``-decorated
functions nor a valid ``TOOL_SPEC`` + module-named function.
"""
module = importlib.import_module(module_path)
return _collect_tools(module)
tools = _collect_tools(module)
if tools:
return tools

# Fallback: delegate to strands for TOOL_SPEC-based (module) tools.
# Strands raises AttributeError if the module has neither @tool functions
# nor a valid TOOL_SPEC + module-named function.
module_name = module_path.split(".")[-1]
return _strands_load_tools_from_module(module, module_name)


def load_tool_function(spec: str) -> AgentTool:
Expand Down
4 changes: 3 additions & 1 deletion tests/unit/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ def test_resolve_file_with_function(self, tools_dir, monkeypatch):
tools = resolve_tool_spec(f"{file_path}:greet")
assert len(tools) == 1

def test_resolve_file_colon_plain_function_autowraps(self, plain_tools_file, caplog, monkeypatch):
def test_resolve_file_colon_plain_function_autowraps(
self, plain_tools_file, caplog, monkeypatch
):
"""Plain function named explicitly via file colon spec is auto-wrapped.

The function must become an AgentTool and a warning must be logged
Expand Down
94 changes: 87 additions & 7 deletions tests/unit/test_tools_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,19 @@ def greet(name: str) -> str:
finally:
sys.modules.pop("_test_module_with_tools", None)

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

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

@tool
Expand All @@ -58,7 +58,8 @@ def _private_tool(x: int) -> int:
sys.modules["_test_module_private"] = mod
try:
tools = load_tools_from_module("_test_module_private")
assert tools == []
assert len(tools) == 1
assert tools[0].tool_name == "_private_tool"
finally:
sys.modules.pop("_test_module_private", None)

Expand Down Expand Up @@ -90,3 +91,82 @@ def tool_b(y: str) -> str:
assert "tool_b" in names
finally:
sys.modules.pop("_test_module_multi", None)

def test_tool_spec_module_fallback(self):
"""Module with TOOL_SPEC + same-name function is loaded via strands fallback."""
mod = types.ModuleType("_test_module_spec")
setattr(
mod,
"TOOL_SPEC",
{
"name": "_test_module_spec",
"description": "A test module-based tool",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
},
"required": ["query"],
}
},
},
)

def _test_module_spec(tool_use, **kwargs):
return {"status": "success", "content": [{"text": "ok"}], "toolUseId": "t1"}

setattr(mod, "_test_module_spec", _test_module_spec)
sys.modules["_test_module_spec"] = mod
try:
tools = load_tools_from_module("_test_module_spec")
assert len(tools) == 1
assert tools[0].tool_name == "_test_module_spec"
finally:
sys.modules.pop("_test_module_spec", None)

def test_tool_spec_fallback_missing_function_raises(self):
"""Module with TOOL_SPEC but no matching function raises AttributeError."""
mod = types.ModuleType("_test_module_no_func")
setattr(
mod,
"TOOL_SPEC",
{
"name": "_test_module_no_func",
"description": "Missing function",
"inputSchema": {"json": {"type": "object", "properties": {}}},
},
)
sys.modules["_test_module_no_func"] = mod
try:
with pytest.raises(AttributeError):
load_tools_from_module("_test_module_no_func")
finally:
sys.modules.pop("_test_module_no_func", None)

def test_decorated_tools_take_priority_over_tool_spec(self):
"""When both @tool functions and TOOL_SPEC exist, @tool wins."""
mod = types.ModuleType("_test_module_both")

@tool
def my_tool(x: int) -> int:
"""A decorated tool."""
return x

setattr(mod, "my_tool", my_tool)
setattr(
mod,
"TOOL_SPEC",
{
"name": "_test_module_both",
"description": "Should be ignored",
"inputSchema": {"json": {"type": "object", "properties": {}}},
},
)
sys.modules["_test_module_both"] = mod
try:
tools = load_tools_from_module("_test_module_both")
assert len(tools) == 1
assert tools[0].tool_name == "my_tool"
finally:
sys.modules.pop("_test_module_both", None)
Loading