Skip to content

Commit c154a32

Browse files
ratishratish
authored andcommitted
fix(tools): resolve mypy content type issue; loader improvements
1 parent 151aa3c commit c154a32

File tree

4 files changed

+60
-62
lines changed

4 files changed

+60
-62
lines changed

src/strands/tools/loader.py

Lines changed: 47 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
import logging
55
import os
66
import sys
7+
import warnings
78
from pathlib import Path
8-
from typing import List, Union, cast
9+
from typing import List, cast
910

1011
from ..types.tools import AgentTool
1112
from .decorator import DecoratedFunctionTool
@@ -18,62 +19,42 @@ class ToolLoader:
1819
"""Handles loading of tools from different sources."""
1920

2021
@staticmethod
21-
def load_python_tool(tool_path: str, tool_name: str) -> Union[AgentTool, List[AgentTool]]:
22-
"""Load a Python tool module.
22+
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.
2324
24-
Args:
25-
tool_path: Path to the Python tool file.
26-
tool_name: Name of the tool.
27-
28-
Returns:
29-
A single AgentTool or a list of AgentTool instances when multiple function-based tools
30-
are defined in the module.
31-
32-
33-
Raises:
34-
AttributeError: If required attributes are missing from the tool module.
35-
ImportError: If there are issues importing the tool module.
36-
TypeError: If the tool function is not callable.
37-
ValueError: If function in module is not a valid tool.
38-
Exception: For other errors during tool loading.
25+
This method always returns a list of AgentTool (possibly length 1). It is the
26+
canonical API for retrieving multiple tools from a single Python file.
3927
"""
4028
try:
41-
# Check if tool_path is in the format "package.module:function"; but keep in mind windows whose file path
42-
# could have a ':' so also ensure that it's not a file
29+
# Support module:function style (e.g. package.module:function)
4330
if not os.path.exists(tool_path) and ":" in tool_path:
4431
module_path, function_name = tool_path.rsplit(":", 1)
4532
logger.debug("tool_name=<%s>, module_path=<%s> | importing tool from path", function_name, module_path)
4633

4734
try:
48-
# Import the module
4935
module = __import__(module_path, fromlist=["*"])
50-
51-
# Get the function
52-
if not hasattr(module, function_name):
53-
raise AttributeError(f"Module {module_path} has no function named {function_name}")
54-
55-
func = getattr(module, function_name)
56-
57-
if isinstance(func, DecoratedFunctionTool):
58-
logger.debug(
59-
"tool_name=<%s>, module_path=<%s> | found function-based tool", function_name, module_path
60-
)
61-
# mypy has problems converting between DecoratedFunctionTool <-> AgentTool
62-
return cast(AgentTool, func)
63-
else:
64-
raise ValueError(
65-
f"Function {function_name} in {module_path} is not a valid tool (missing @tool decorator)"
66-
)
67-
6836
except ImportError as e:
6937
raise ImportError(f"Failed to import module {module_path}: {str(e)}") from e
7038

39+
if not hasattr(module, function_name):
40+
raise AttributeError(f"Module {module_path} has no function named {function_name}")
41+
42+
func = getattr(module, function_name)
43+
if isinstance(func, DecoratedFunctionTool):
44+
logger.debug(
45+
"tool_name=<%s>, module_path=<%s> | found function-based tool", function_name, module_path
46+
)
47+
return [cast(AgentTool, func)]
48+
else:
49+
raise ValueError(
50+
f"Function {function_name} in {module_path} is not a valid tool (missing @tool decorator)"
51+
)
52+
7153
# Normal file-based tool loading
7254
abs_path = str(Path(tool_path).resolve())
73-
7455
logger.debug("tool_path=<%s> | loading python tool from path", abs_path)
7556

76-
# First load the module to get TOOL_SPEC and check for Lambda deployment
57+
# Load the module by spec
7758
spec = importlib.util.spec_from_file_location(tool_name, abs_path)
7859
if not spec:
7960
raise ImportError(f"Could not create spec for {tool_name}")
@@ -84,32 +65,26 @@ def load_python_tool(tool_path: str, tool_name: str) -> Union[AgentTool, List[Ag
8465
sys.modules[tool_name] = module
8566
spec.loader.exec_module(module)
8667

87-
# First, check for function-based tools with @tool decorator
68+
# Collect function-based tools decorated with @tool
8869
function_tools: List[AgentTool] = []
8970
for attr_name in dir(module):
9071
attr = getattr(module, attr_name)
9172
if isinstance(attr, DecoratedFunctionTool):
9273
logger.debug(
9374
"tool_name=<%s>, tool_path=<%s> | found function-based tool in path", attr_name, tool_path
9475
)
95-
# Cast as AgentTool for mypy
9676
function_tools.append(cast(AgentTool, attr))
9777

98-
# If any function-based tools found, return them.
9978
if function_tools:
100-
# Backwards compatibility: return single tool if only one found
101-
if len(function_tools) == 1:
102-
return function_tools[0]
10379
return function_tools
10480

105-
# If no function-based tools found, fall back to traditional module-level tool
81+
# Fall back to module-level TOOL_SPEC + function
10682
tool_spec = getattr(module, "TOOL_SPEC", None)
10783
if not tool_spec:
10884
raise AttributeError(
10985
f"Tool {tool_name} missing TOOL_SPEC (neither at module level nor as a decorated function)"
11086
)
11187

112-
# Standard local tool loading
11388
tool_func_name = tool_name
11489
if not hasattr(module, tool_func_name):
11590
raise AttributeError(f"Tool {tool_name} missing function {tool_func_name}")
@@ -118,22 +93,41 @@ def load_python_tool(tool_path: str, tool_name: str) -> Union[AgentTool, List[Ag
11893
if not callable(tool_func):
11994
raise TypeError(f"Tool {tool_name} function is not callable")
12095

121-
return PythonAgentTool(tool_name, tool_spec, tool_func)
96+
return [PythonAgentTool(tool_name, tool_spec, tool_func)]
12297

12398
except Exception:
124-
logger.exception("tool_name=<%s>, sys_path=<%s> | failed to load python tool", tool_name, sys.path)
99+
logger.exception("tool_name=<%s>, sys_path=<%s> | failed to load python tool(s)", tool_name, sys.path)
125100
raise
126101

102+
@staticmethod
103+
def load_python_tool(tool_path: str, tool_name: str) -> AgentTool:
104+
"""DEPRECATED: Load a Python tool module and return a single AgentTool for backwards compatibility.
105+
106+
Use `load_python_tools` to retrieve all tools defined in a .py file (returns a list).
107+
This function will emit a `DeprecationWarning` and return the first discovered tool.
108+
"""
109+
warnings.warn(
110+
"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.",
112+
DeprecationWarning,
113+
stacklevel=2,
114+
)
115+
116+
tools = ToolLoader.load_python_tools(tool_path, tool_name)
117+
if not tools:
118+
raise RuntimeError(f"No tools found in {tool_path} for {tool_name}")
119+
return tools[0]
120+
127121
@classmethod
128-
def load_tool(cls, tool_path: str, tool_name: str) -> Union[AgentTool, List[AgentTool]]:
122+
def load_tool(cls, tool_path: str, tool_name: str) -> AgentTool:
129123
"""Load a tool based on its file extension.
130124
131125
Args:
132126
tool_path: Path to the tool file.
133127
tool_name: Name of the tool.
134128
135129
Returns:
136-
A single Tool instance or a list of Tool instances.
130+
A single Tool instance.
137131
138132
Raises:
139133
FileNotFoundError: If the tool file does not exist.

src/strands/tools/mcp/mcp_client.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@
2424
from mcp.types import ImageContent as MCPImageContent
2525
from mcp.types import TextContent as MCPTextContent
2626

27+
from strands.types.tools import ToolResultContent, ToolResultStatus
28+
2729
from ...types import PaginatedList
2830
from ...types.exceptions import MCPClientInitializationError
2931
from ...types.media import ImageFormat
30-
from ...types.tools import ToolResultContent, ToolResultStatus
3132
from .mcp_agent_tool import MCPAgentTool
3233
from .mcp_instrumentation import mcp_instrumentation
3334
from .mcp_types import MCPToolResult, MCPTransport
@@ -318,19 +319,22 @@ def _handle_tool_result(self, tool_use_id: str, call_tool_result: MCPCallToolRes
318319
"""
319320
self._log_debug_with_thread("received tool result with %d content items", len(call_tool_result.content))
320321

321-
mapped_content = [
322-
mapped_content
322+
# Build a typed list of ToolResultContent. Use a clearer local name to avoid shadowing
323+
# and annotate the result for mypy so it knows the intended element type.
324+
mapped_contents: list[ToolResultContent] = [
325+
mc
323326
for content in call_tool_result.content
324-
if (mapped_content := self._map_mcp_content_to_tool_result_content(content)) is not None
327+
if (mc := self._map_mcp_content_to_tool_result_content(content)) is not None
325328
]
326329

327330
status: ToolResultStatus = "error" if call_tool_result.isError else "success"
328331
self._log_debug_with_thread("tool execution completed with status: %s", status)
329332
result = MCPToolResult(
330333
status=status,
331334
toolUseId=tool_use_id,
332-
content=mapped_content,
335+
content=mapped_contents,
333336
)
337+
334338
if call_tool_result.structuredContent:
335339
result["structuredContent"] = call_tool_result.structuredContent
336340

src/strands/tools/registry.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,9 @@ def register_tool(self, tool: AgentTool) -> None:
193193

194194
# Check duplicate tool name, throw on duplicate tool names except if hot_reloading is enabled
195195
if tool.tool_name in self.registry and not tool.supports_hot_reload:
196-
raise ValueError(
197-
f"Tool name '{tool.tool_name}' already exists. Cannot register tools with exact same name."
198-
)
196+
raise ValueError(
197+
f"Tool name '{tool.tool_name}' already exists. Cannot register tools with exact same name."
198+
)
199199

200200
# Check for normalized name conflicts (- vs _)
201201
if self.registry.get(tool.tool_name) is None:

tests/strands/tools/test_loader.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ def bravo():
258258
)
259259
def test_load_python_tool_path_multiple_function_based(tool_path):
260260
# load_python_tool returns a list when multiple decorated tools are present
261-
loaded = ToolLoader.load_python_tool(tool_path, "alpha")
261+
loaded = ToolLoader.load_python_tools(tool_path, "alpha")
262262
assert isinstance(loaded, list)
263263
assert len(loaded) == 2
264264
assert all(isinstance(t, DecoratedFunctionTool) for t in loaded)

0 commit comments

Comments
 (0)