From 7107694e6ac058d05646b655f6af87c65170e632 Mon Sep 17 00:00:00 2001 From: ratish Date: Tue, 26 Aug 2025 00:51:43 +0530 Subject: [PATCH 1/5] fix(tools/loader): load and register all decorated @tool functions from file path - Collect all DecoratedFunctionTool objects when loading a .py file and return list when multiple exist - Normalize loader results and register each AgentTool separately in registry - Add normalize_loaded_tools helper and test for multiple decorated tools --- src/strands/tools/loader.py | 26 ++++++++++++++++++-------- src/strands/tools/registry.py | 11 ++++++----- src/strands/tools/tools.py | 17 ++++++++++++----- tests/strands/tools/test_loader.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 18 deletions(-) diff --git a/src/strands/tools/loader.py b/src/strands/tools/loader.py index 56433324e..bbabbab4f 100644 --- a/src/strands/tools/loader.py +++ b/src/strands/tools/loader.py @@ -5,7 +5,7 @@ import os import sys from pathlib import Path -from typing import cast +from typing import List, Union, cast from ..types.tools import AgentTool from .decorator import DecoratedFunctionTool @@ -18,7 +18,7 @@ class ToolLoader: """Handles loading of tools from different sources.""" @staticmethod - def load_python_tool(tool_path: str, tool_name: str) -> AgentTool: + def load_python_tool(tool_path: str, tool_name: str) -> Union[AgentTool, List[AgentTool]]: """Load a Python tool module. Args: @@ -26,7 +26,9 @@ def load_python_tool(tool_path: str, tool_name: str) -> AgentTool: tool_name: Name of the tool. Returns: - Tool instance. + A single AgentTool or a list of AgentTool instances when multiple function-based tools + are defined in the module. + Raises: AttributeError: If required attributes are missing from the tool module. @@ -37,7 +39,7 @@ def load_python_tool(tool_path: str, tool_name: str) -> AgentTool: """ try: # Check if tool_path is in the format "package.module:function"; but keep in mind windows whose file path - # could have a colon so also ensure that it's not a file + # could have a ':' so also ensure that it's not a file if not os.path.exists(tool_path) and ":" in tool_path: module_path, function_name = tool_path.rsplit(":", 1) logger.debug("tool_name=<%s>, module_path=<%s> | importing tool from path", function_name, module_path) @@ -83,14 +85,22 @@ def load_python_tool(tool_path: str, tool_name: str) -> AgentTool: spec.loader.exec_module(module) # First, check for function-based tools with @tool decorator + function_tools: List[AgentTool] = [] for attr_name in dir(module): attr = getattr(module, attr_name) if isinstance(attr, DecoratedFunctionTool): logger.debug( "tool_name=<%s>, tool_path=<%s> | found function-based tool in path", attr_name, tool_path ) - # mypy has problems converting between DecoratedFunctionTool <-> AgentTool - return cast(AgentTool, attr) + # Cast as AgentTool for mypy + function_tools.append(cast(AgentTool, attr)) + + # If any function-based tools found, return them. + if function_tools: + # Backwards compatibility: return single tool if only one found + if len(function_tools) == 1: + return function_tools[0] + return function_tools # If no function-based tools found, fall back to traditional module-level tool tool_spec = getattr(module, "TOOL_SPEC", None) @@ -115,7 +125,7 @@ def load_python_tool(tool_path: str, tool_name: str) -> AgentTool: raise @classmethod - def load_tool(cls, tool_path: str, tool_name: str) -> AgentTool: + def load_tool(cls, tool_path: str, tool_name: str) -> Union[AgentTool, List[AgentTool]]: """Load a tool based on its file extension. Args: @@ -123,7 +133,7 @@ def load_tool(cls, tool_path: str, tool_name: str) -> AgentTool: tool_name: Name of the tool. Returns: - Tool instance. + A single Tool instance or a list of Tool instances. Raises: FileNotFoundError: If the tool file does not exist. diff --git a/src/strands/tools/registry.py b/src/strands/tools/registry.py index 471472a64..1f1980559 100644 --- a/src/strands/tools/registry.py +++ b/src/strands/tools/registry.py @@ -18,7 +18,7 @@ from strands.tools.decorator import DecoratedFunctionTool from ..types.tools import AgentTool, ToolSpec -from .tools import PythonAgentTool, normalize_schema, normalize_tool_spec +from .tools import PythonAgentTool, normalize_loaded_tools, normalize_schema, normalize_tool_spec logger = logging.getLogger(__name__) @@ -128,10 +128,11 @@ def load_tool_from_filepath(self, tool_name: str, tool_path: str) -> None: raise FileNotFoundError(f"Tool file not found: {tool_path}") loaded_tool = ToolLoader.load_tool(tool_path, tool_name) - loaded_tool.mark_dynamic() - - # Because we're explicitly registering the tool we don't need an allowlist - self.register_tool(loaded_tool) + # normalize_loaded_tools handles single tool or list of tools + for t in normalize_loaded_tools(loaded_tool): + t.mark_dynamic() + # Because we're explicitly registering the tool we don't need an allowlist + self.register_tool(t) except Exception as e: exception_str = str(e) logger.exception("tool_name=<%s> | failed to load tool", tool_name) diff --git a/src/strands/tools/tools.py b/src/strands/tools/tools.py index 9e1c0e608..810e82666 100644 --- a/src/strands/tools/tools.py +++ b/src/strands/tools/tools.py @@ -8,7 +8,7 @@ import inspect import logging import re -from typing import Any +from typing import Any, List, Union from typing_extensions import override @@ -33,22 +33,22 @@ def validate_tool_use(tool: ToolUse) -> None: validate_tool_use_name(tool) -def validate_tool_use_name(tool: ToolUse) -> None: +def validate_tool_use_name(tool_use: ToolUse) -> None: """Validate the name of a tool use. Args: - tool: The tool use to validate. + tool_use: The tool use to validate. Raises: InvalidToolUseNameException: If the tool name is invalid. """ # We need to fix some typing here, because we don't actually expect a ToolUse, but dict[str, Any] - if "name" not in tool: + if "name" not in tool_use: message = "tool name missing" # type: ignore[unreachable] logger.warning(message) raise InvalidToolUseNameException(message) - tool_name = tool["name"] + tool_name = tool_use["name"] tool_name_pattern = r"^[a-zA-Z0-9_\-]{1,}$" tool_name_max_length = 64 valid_name_pattern = bool(re.match(tool_name_pattern, tool_name)) @@ -146,6 +146,13 @@ def normalize_tool_spec(tool_spec: ToolSpec) -> ToolSpec: return normalized +def normalize_loaded_tools(loaded: Union[AgentTool, List[AgentTool]]) -> List[AgentTool]: + """Normalize ToolLoader.load_tool return value to always be a list of AgentTool.""" + if isinstance(loaded, list): + return loaded + return [loaded] + + class PythonAgentTool(AgentTool): """Tool implementation for Python-based tools. diff --git a/tests/strands/tools/test_loader.py b/tests/strands/tools/test_loader.py index c1b4d7040..29bfd78a3 100644 --- a/tests/strands/tools/test_loader.py +++ b/tests/strands/tools/test_loader.py @@ -235,3 +235,32 @@ def no_spec(): def test_load_tool_no_spec(tool_path): with pytest.raises(AttributeError, match="Tool no_spec missing TOOL_SPEC"): ToolLoader.load_tool(tool_path, "no_spec") + + +@pytest.mark.parametrize( + "tool_path", + [ + textwrap.dedent( + """ + import strands + + @strands.tools.tool + def alpha(): + return "alpha" + + @strands.tools.tool + def bravo(): + return "bravo" + """ + ) + ], + indirect=True, +) +def test_load_python_tool_path_multiple_function_based(tool_path): + # load_python_tool returns a list when multiple decorated tools are present + loaded = ToolLoader.load_python_tool(tool_path, "alpha") + assert isinstance(loaded, list) + assert len(loaded) == 2 + assert all(isinstance(t, DecoratedFunctionTool) for t in loaded) + names = {getattr(t, "tool_name", getattr(t, "name", None)) for t in loaded} + assert "alpha" in names and "bravo" in names From 52c7eccbb587c799cd3a9a83b7e4491d5223158f Mon Sep 17 00:00:00 2001 From: ratish Date: Sat, 30 Aug 2025 22:02:35 +0530 Subject: [PATCH 2/5] fix(tools): resolve mypy content type issue; loader improvements --- src/strands/tools/loader.py | 100 +++++++++++++--------------- src/strands/tools/mcp/mcp_client.py | 14 ++-- tests/strands/tools/test_loader.py | 2 +- 3 files changed, 57 insertions(+), 59 deletions(-) diff --git a/src/strands/tools/loader.py b/src/strands/tools/loader.py index bbabbab4f..0fde1b964 100644 --- a/src/strands/tools/loader.py +++ b/src/strands/tools/loader.py @@ -4,8 +4,9 @@ import logging import os import sys +import warnings from pathlib import Path -from typing import List, Union, cast +from typing import List, cast from ..types.tools import AgentTool from .decorator import DecoratedFunctionTool @@ -18,62 +19,42 @@ class ToolLoader: """Handles loading of tools from different sources.""" @staticmethod - def load_python_tool(tool_path: str, tool_name: str) -> Union[AgentTool, List[AgentTool]]: - """Load a Python tool module. + def load_python_tools(tool_path: str, tool_name: str) -> List[AgentTool]: + """Load a Python tool module and return all discovered function-based tools as a list. - Args: - tool_path: Path to the Python tool file. - tool_name: Name of the tool. - - Returns: - A single AgentTool or a list of AgentTool instances when multiple function-based tools - are defined in the module. - - - Raises: - AttributeError: If required attributes are missing from the tool module. - ImportError: If there are issues importing the tool module. - TypeError: If the tool function is not callable. - ValueError: If function in module is not a valid tool. - Exception: For other errors during tool loading. + This method always returns a list of AgentTool (possibly length 1). It is the + canonical API for retrieving multiple tools from a single Python file. """ try: - # Check if tool_path is in the format "package.module:function"; but keep in mind windows whose file path - # could have a ':' so also ensure that it's not a file + # Support module:function style (e.g. package.module:function) if not os.path.exists(tool_path) and ":" in tool_path: module_path, function_name = tool_path.rsplit(":", 1) logger.debug("tool_name=<%s>, module_path=<%s> | importing tool from path", function_name, module_path) try: - # Import the module module = __import__(module_path, fromlist=["*"]) - - # Get the function - if not hasattr(module, function_name): - raise AttributeError(f"Module {module_path} has no function named {function_name}") - - func = getattr(module, function_name) - - if isinstance(func, DecoratedFunctionTool): - logger.debug( - "tool_name=<%s>, module_path=<%s> | found function-based tool", function_name, module_path - ) - # mypy has problems converting between DecoratedFunctionTool <-> AgentTool - return cast(AgentTool, func) - else: - raise ValueError( - f"Function {function_name} in {module_path} is not a valid tool (missing @tool decorator)" - ) - except ImportError as e: raise ImportError(f"Failed to import module {module_path}: {str(e)}") from e + if not hasattr(module, function_name): + raise AttributeError(f"Module {module_path} has no function named {function_name}") + + func = getattr(module, function_name) + if isinstance(func, DecoratedFunctionTool): + logger.debug( + "tool_name=<%s>, module_path=<%s> | found function-based tool", function_name, module_path + ) + return [cast(AgentTool, func)] + else: + raise ValueError( + f"Function {function_name} in {module_path} is not a valid tool (missing @tool decorator)" + ) + # Normal file-based tool loading abs_path = str(Path(tool_path).resolve()) - logger.debug("tool_path=<%s> | loading python tool from path", abs_path) - # First load the module to get TOOL_SPEC and check for Lambda deployment + # Load the module by spec spec = importlib.util.spec_from_file_location(tool_name, abs_path) if not spec: raise ImportError(f"Could not create spec for {tool_name}") @@ -84,7 +65,7 @@ def load_python_tool(tool_path: str, tool_name: str) -> Union[AgentTool, List[Ag sys.modules[tool_name] = module spec.loader.exec_module(module) - # First, check for function-based tools with @tool decorator + # Collect function-based tools decorated with @tool function_tools: List[AgentTool] = [] for attr_name in dir(module): attr = getattr(module, attr_name) @@ -92,24 +73,18 @@ def load_python_tool(tool_path: str, tool_name: str) -> Union[AgentTool, List[Ag logger.debug( "tool_name=<%s>, tool_path=<%s> | found function-based tool in path", attr_name, tool_path ) - # Cast as AgentTool for mypy function_tools.append(cast(AgentTool, attr)) - # If any function-based tools found, return them. if function_tools: - # Backwards compatibility: return single tool if only one found - if len(function_tools) == 1: - return function_tools[0] return function_tools - # If no function-based tools found, fall back to traditional module-level tool + # Fall back to module-level TOOL_SPEC + function tool_spec = getattr(module, "TOOL_SPEC", None) if not tool_spec: raise AttributeError( f"Tool {tool_name} missing TOOL_SPEC (neither at module level nor as a decorated function)" ) - # Standard local tool loading tool_func_name = tool_name if not hasattr(module, tool_func_name): raise AttributeError(f"Tool {tool_name} missing function {tool_func_name}") @@ -118,14 +93,33 @@ def load_python_tool(tool_path: str, tool_name: str) -> Union[AgentTool, List[Ag if not callable(tool_func): raise TypeError(f"Tool {tool_name} function is not callable") - return PythonAgentTool(tool_name, tool_spec, tool_func) + return [PythonAgentTool(tool_name, tool_spec, tool_func)] except Exception: - logger.exception("tool_name=<%s>, sys_path=<%s> | failed to load python tool", tool_name, sys.path) + logger.exception("tool_name=<%s>, sys_path=<%s> | failed to load python tool(s)", tool_name, sys.path) raise + @staticmethod + def load_python_tool(tool_path: str, tool_name: str) -> AgentTool: + """DEPRECATED: Load a Python tool module and return a single AgentTool for backwards compatibility. + + Use `load_python_tools` to retrieve all tools defined in a .py file (returns a list). + This function will emit a `DeprecationWarning` and return the first discovered tool. + """ + warnings.warn( + "ToolLoader.load_python_tool is deprecated and will be removed in Strands SDK 2.0. " + "Use ToolLoader.load_python_tools(...) which always returns a list of AgentTool.", + DeprecationWarning, + stacklevel=2, + ) + + tools = ToolLoader.load_python_tools(tool_path, tool_name) + if not tools: + raise RuntimeError(f"No tools found in {tool_path} for {tool_name}") + return tools[0] + @classmethod - def load_tool(cls, tool_path: str, tool_name: str) -> Union[AgentTool, List[AgentTool]]: + def load_tool(cls, tool_path: str, tool_name: str) -> AgentTool: """Load a tool based on its file extension. Args: @@ -133,7 +127,7 @@ def load_tool(cls, tool_path: str, tool_name: str) -> Union[AgentTool, List[Agen tool_name: Name of the tool. Returns: - A single Tool instance or a list of Tool instances. + A single Tool instance. Raises: FileNotFoundError: If the tool file does not exist. diff --git a/src/strands/tools/mcp/mcp_client.py b/src/strands/tools/mcp/mcp_client.py index 7cb03e46f..855af2b23 100644 --- a/src/strands/tools/mcp/mcp_client.py +++ b/src/strands/tools/mcp/mcp_client.py @@ -24,10 +24,11 @@ from mcp.types import ImageContent as MCPImageContent from mcp.types import TextContent as MCPTextContent +from strands.types.tools import ToolResultContent, ToolResultStatus + from ...types import PaginatedList from ...types.exceptions import MCPClientInitializationError from ...types.media import ImageFormat -from ...types.tools import ToolResultContent, ToolResultStatus from .mcp_agent_tool import MCPAgentTool from .mcp_instrumentation import mcp_instrumentation from .mcp_types import MCPToolResult, MCPTransport @@ -318,10 +319,12 @@ def _handle_tool_result(self, tool_use_id: str, call_tool_result: MCPCallToolRes """ self._log_debug_with_thread("received tool result with %d content items", len(call_tool_result.content)) - mapped_content = [ - mapped_content + # Build a typed list of ToolResultContent. Use a clearer local name to avoid shadowing + # and annotate the result for mypy so it knows the intended element type. + mapped_contents: list[ToolResultContent] = [ + mc for content in call_tool_result.content - if (mapped_content := self._map_mcp_content_to_tool_result_content(content)) is not None + if (mc := self._map_mcp_content_to_tool_result_content(content)) is not None ] status: ToolResultStatus = "error" if call_tool_result.isError else "success" @@ -329,8 +332,9 @@ def _handle_tool_result(self, tool_use_id: str, call_tool_result: MCPCallToolRes result = MCPToolResult( status=status, toolUseId=tool_use_id, - content=mapped_content, + content=mapped_contents, ) + if call_tool_result.structuredContent: result["structuredContent"] = call_tool_result.structuredContent diff --git a/tests/strands/tools/test_loader.py b/tests/strands/tools/test_loader.py index 29bfd78a3..efc5f4e35 100644 --- a/tests/strands/tools/test_loader.py +++ b/tests/strands/tools/test_loader.py @@ -258,7 +258,7 @@ def bravo(): ) def test_load_python_tool_path_multiple_function_based(tool_path): # load_python_tool returns a list when multiple decorated tools are present - loaded = ToolLoader.load_python_tool(tool_path, "alpha") + loaded = ToolLoader.load_python_tools(tool_path, "alpha") assert isinstance(loaded, list) assert len(loaded) == 2 assert all(isinstance(t, DecoratedFunctionTool) for t in loaded) From 15088259832580f83084c79ba9d925fb351f016d Mon Sep 17 00:00:00 2001 From: ratish Date: Wed, 3 Sep 2025 23:06:27 +0530 Subject: [PATCH 3/5] fix(mcp): restore relative import for ToolResultContent to satisfy mypy/hooks --- src/strands/tools/mcp/mcp_client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/strands/tools/mcp/mcp_client.py b/src/strands/tools/mcp/mcp_client.py index 855af2b23..5d9dd0b0f 100644 --- a/src/strands/tools/mcp/mcp_client.py +++ b/src/strands/tools/mcp/mcp_client.py @@ -24,11 +24,10 @@ from mcp.types import ImageContent as MCPImageContent from mcp.types import TextContent as MCPTextContent -from strands.types.tools import ToolResultContent, ToolResultStatus - from ...types import PaginatedList from ...types.exceptions import MCPClientInitializationError from ...types.media import ImageFormat +from ...types.tools import ToolResultContent, ToolResultStatus from .mcp_agent_tool import MCPAgentTool from .mcp_instrumentation import mcp_instrumentation from .mcp_types import MCPToolResult, MCPTransport From 0a6ab1139d1cd6e1e5e38fba30cda5078f553ff2 Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Mon, 8 Sep 2025 11:28:29 -0400 Subject: [PATCH 4/5] fix(tools/loader): Add load_tools override and switch to using it everywhere For consistency/backwards comptability --- src/strands/tools/loader.py | 24 +++++++++++- src/strands/tools/registry.py | 7 ++-- src/strands/tools/tools.py | 9 +---- tests/strands/tools/test_loader.py | 60 ++++++++++++++++++++++++++---- 4 files changed, 79 insertions(+), 21 deletions(-) diff --git a/src/strands/tools/loader.py b/src/strands/tools/loader.py index 0fde1b964..5935077db 100644 --- a/src/strands/tools/loader.py +++ b/src/strands/tools/loader.py @@ -120,7 +120,27 @@ def load_python_tool(tool_path: str, tool_name: str) -> AgentTool: @classmethod def load_tool(cls, tool_path: str, tool_name: str) -> AgentTool: - """Load a tool based on its file extension. + """DEPRECATED: Load a single tool based on its file extension for backwards compatibility. + + Use `load_tools` to retrieve all tools defined in a file (returns a list). + This function will emit a `DeprecationWarning` and return the first discovered tool. + """ + warnings.warn( + "ToolLoader.load_tool is deprecated and will be removed in Strands SDK 2.0. " + "Use ToolLoader.load_tools(...) which always returns a list of AgentTool.", + DeprecationWarning, + stacklevel=2, + ) + + tools = ToolLoader.load_tools(tool_path, tool_name) + if not tools: + raise RuntimeError(f"No tools found in {tool_path} for {tool_name}") + + return tools[0] + + @classmethod + def load_tools(cls, tool_path: str, tool_name: str) -> list[AgentTool]: + """Load tools from a file based on its file extension. Args: tool_path: Path to the tool file. @@ -142,7 +162,7 @@ def load_tool(cls, tool_path: str, tool_name: str) -> AgentTool: try: if ext == ".py": - return cls.load_python_tool(abs_path, tool_name) + return cls.load_python_tools(abs_path, tool_name) else: raise ValueError(f"Unsupported tool file type: {ext}") except Exception: diff --git a/src/strands/tools/registry.py b/src/strands/tools/registry.py index 1f1980559..0660337a2 100644 --- a/src/strands/tools/registry.py +++ b/src/strands/tools/registry.py @@ -18,7 +18,7 @@ from strands.tools.decorator import DecoratedFunctionTool from ..types.tools import AgentTool, ToolSpec -from .tools import PythonAgentTool, normalize_loaded_tools, normalize_schema, normalize_tool_spec +from .tools import PythonAgentTool, normalize_schema, normalize_tool_spec logger = logging.getLogger(__name__) @@ -127,9 +127,8 @@ def load_tool_from_filepath(self, tool_name: str, tool_path: str) -> None: if not os.path.exists(tool_path): raise FileNotFoundError(f"Tool file not found: {tool_path}") - loaded_tool = ToolLoader.load_tool(tool_path, tool_name) - # normalize_loaded_tools handles single tool or list of tools - for t in normalize_loaded_tools(loaded_tool): + loaded_tools = ToolLoader.load_tools(tool_path, tool_name) + for t in loaded_tools: t.mark_dynamic() # Because we're explicitly registering the tool we don't need an allowlist self.register_tool(t) diff --git a/src/strands/tools/tools.py b/src/strands/tools/tools.py index 810e82666..a17ed32e8 100644 --- a/src/strands/tools/tools.py +++ b/src/strands/tools/tools.py @@ -8,7 +8,7 @@ import inspect import logging import re -from typing import Any, List, Union +from typing import Any from typing_extensions import override @@ -146,13 +146,6 @@ def normalize_tool_spec(tool_spec: ToolSpec) -> ToolSpec: return normalized -def normalize_loaded_tools(loaded: Union[AgentTool, List[AgentTool]]) -> List[AgentTool]: - """Normalize ToolLoader.load_tool return value to always be a list of AgentTool.""" - if isinstance(loaded, list): - return loaded - return [loaded] - - class PythonAgentTool(AgentTool): """Tool implementation for Python-based tools. diff --git a/tests/strands/tools/test_loader.py b/tests/strands/tools/test_loader.py index efc5f4e35..6b86d00ee 100644 --- a/tests/strands/tools/test_loader.py +++ b/tests/strands/tools/test_loader.py @@ -236,6 +236,15 @@ def test_load_tool_no_spec(tool_path): with pytest.raises(AttributeError, match="Tool no_spec missing TOOL_SPEC"): ToolLoader.load_tool(tool_path, "no_spec") + with pytest.raises(AttributeError, match="Tool no_spec missing TOOL_SPEC"): + ToolLoader.load_tools(tool_path, "no_spec") + + with pytest.raises(AttributeError, match="Tool no_spec missing TOOL_SPEC"): + ToolLoader.load_python_tool(tool_path, "no_spec") + + with pytest.raises(AttributeError, match="Tool no_spec missing TOOL_SPEC"): + ToolLoader.load_python_tools(tool_path, "no_spec") + @pytest.mark.parametrize( "tool_path", @@ -257,10 +266,47 @@ def bravo(): indirect=True, ) def test_load_python_tool_path_multiple_function_based(tool_path): - # load_python_tool returns a list when multiple decorated tools are present - loaded = ToolLoader.load_python_tools(tool_path, "alpha") - assert isinstance(loaded, list) - assert len(loaded) == 2 - assert all(isinstance(t, DecoratedFunctionTool) for t in loaded) - names = {getattr(t, "tool_name", getattr(t, "name", None)) for t in loaded} - assert "alpha" in names and "bravo" in names + # load_python_tools, load_tools returns a list when multiple decorated tools are present + loaded_python_tools = ToolLoader.load_python_tools(tool_path, "alpha") + + assert isinstance(loaded_python_tools, list) + assert len(loaded_python_tools) == 2 + assert all(isinstance(t, DecoratedFunctionTool) for t in loaded_python_tools) + names = {t.tool_name for t in loaded_python_tools} + assert names == {"alpha", "bravo"} + + loaded_tools = ToolLoader.load_tools(tool_path, "alpha") + + assert isinstance(loaded_tools, list) + assert len(loaded_tools) == 2 + assert all(isinstance(t, DecoratedFunctionTool) for t in loaded_tools) + names = {t.tool_name for t in loaded_tools} + assert names == {"alpha", "bravo"} + + +@pytest.mark.parametrize( + "tool_path", + [ + textwrap.dedent( + """ + import strands + + @strands.tools.tool + def alpha(): + return "alpha" + + @strands.tools.tool + def bravo(): + return "bravo" + """ + ) + ], + indirect=True, +) +def test_load_tool_path_returns_single_tool(tool_path): + # loaded_python_tool and loaded_tool returns single item + loaded_python_tool = ToolLoader.load_python_tool(tool_path, "alpha") + loaded_tool = ToolLoader.load_tool(tool_path, "alpha") + + assert loaded_python_tool.tool_name == "alpha" + assert loaded_tool.tool_name == "alpha" From 0576836478243a7a70371c6694b0b99d9650d891 Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Mon, 8 Sep 2025 14:21:40 -0400 Subject: [PATCH 5/5] chore: Rename parameter back to `tool` --- src/strands/tools/tools.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/strands/tools/tools.py b/src/strands/tools/tools.py index a17ed32e8..9e1c0e608 100644 --- a/src/strands/tools/tools.py +++ b/src/strands/tools/tools.py @@ -33,22 +33,22 @@ def validate_tool_use(tool: ToolUse) -> None: validate_tool_use_name(tool) -def validate_tool_use_name(tool_use: ToolUse) -> None: +def validate_tool_use_name(tool: ToolUse) -> None: """Validate the name of a tool use. Args: - tool_use: The tool use to validate. + tool: The tool use to validate. Raises: InvalidToolUseNameException: If the tool name is invalid. """ # We need to fix some typing here, because we don't actually expect a ToolUse, but dict[str, Any] - if "name" not in tool_use: + if "name" not in tool: message = "tool name missing" # type: ignore[unreachable] logger.warning(message) raise InvalidToolUseNameException(message) - tool_name = tool_use["name"] + tool_name = tool["name"] tool_name_pattern = r"^[a-zA-Z0-9_\-]{1,}$" tool_name_max_length = 64 valid_name_pattern = bool(re.match(tool_name_pattern, tool_name))