diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py index a9382780..36f2e71f 100644 --- a/agentstack/_tools/__init__.py +++ b/agentstack/_tools/__init__.py @@ -7,12 +7,23 @@ import pydantic from agentstack.exceptions import ValidationError from agentstack.utils import get_package_path, open_json_file, term_color, snake_to_camel +from agentstack import conf, log TOOLS_DIR: Path = get_package_path() / '_tools' # NOTE: if you change this dir, also update MANIFEST.in TOOLS_CONFIG_FILENAME: str = 'config.json' +def _get_custom_tool_path(name: str) -> Path: + """Get the path to a custom tool.""" + return conf.PATH / 'src/tools' / name / TOOLS_CONFIG_FILENAME + + +def _get_builtin_tool_path(name: str) -> Path: + """Get the path to a builtin tool.""" + return TOOLS_DIR / name / TOOLS_CONFIG_FILENAME + + class ToolConfig(pydantic.BaseModel): """ This represents the configuration data for a tool. @@ -32,8 +43,14 @@ class ToolConfig(pydantic.BaseModel): @classmethod def from_tool_name(cls, name: str) -> 'ToolConfig': - path = TOOLS_DIR / name / TOOLS_CONFIG_FILENAME - if not os.path.exists(path): + # First check in the user's project directory for custom tools + custom_path = _get_custom_tool_path(name) + if custom_path.exists(): + return cls.from_json(custom_path) + + # Then check in the package's tools directory + path = _get_builtin_tool_path(name) + if not path.exists(): raise ValidationError(f'No known agentstack tool: {name}') return cls.from_json(path) @@ -48,6 +65,14 @@ def from_json(cls, path: Path) -> 'ToolConfig': error_str += f"{' '.join([str(loc) for loc in error['loc']])}: {error['msg']}\n" raise ValidationError(f"Error loading tool from {path}.\n{error_str}") + def write_to_file(self, filename: Path): + """Write the tool config to a json file.""" + if not filename.suffix == '.json': + raise ValidationError(f"Filename must end with .json: {filename}") + + with open(filename, 'w') as f: + f.write(self.model_dump_json()) + @property def type(self) -> type: """ @@ -74,6 +99,12 @@ def not_implemented(*args, **kwargs): @property def module_name(self) -> str: """Module name for the tool module.""" + # Check if this is a custom tool in the user's project + custom_path = _get_custom_tool_path(self.name) + if custom_path.exists(): + return f"src.tools.{self.name}" + + # Otherwise, it's a package tool return f"agentstack._tools.{self.name}" @property @@ -105,19 +136,36 @@ def get_all_tool_paths() -> list[Path]: Get all the paths to the tool configuration files. ie. agentstack/_tools// Tools are identified by having a `config.json` file inside the _tools/ directory. + Also checks the user's project directory for custom tools. """ paths = [] + + # Get package tools for tool_dir in TOOLS_DIR.iterdir(): if tool_dir.is_dir(): config_path = tool_dir / TOOLS_CONFIG_FILENAME if config_path.exists(): paths.append(tool_dir) + + # Get custom tools from user's project if in a project directory + if conf.PATH: + custom_tools_dir = conf.PATH / 'src/tools' + if custom_tools_dir.exists(): + for tool_dir in custom_tools_dir.iterdir(): + if tool_dir.is_dir(): + config_path = tool_dir / TOOLS_CONFIG_FILENAME + if config_path.exists(): + paths.append(tool_dir) + return paths def get_all_tool_names() -> list[str]: - return [path.stem for path in get_all_tool_paths()] + """Get names of all available tools, including custom tools.""" + return [path.name for path in get_all_tool_paths()] def get_all_tools() -> list[ToolConfig]: - return [ToolConfig.from_tool_name(path) for path in get_all_tool_names()] + """Get all tool configs, including custom tools.""" + tool_names = get_all_tool_names() + return [ToolConfig.from_tool_name(name) for name in tool_names] diff --git a/agentstack/cli/__init__.py b/agentstack/cli/__init__.py index c7318bca..6a19e239 100644 --- a/agentstack/cli/__init__.py +++ b/agentstack/cli/__init__.py @@ -1,14 +1,14 @@ from .cli import ( - configure_default_model, - welcome_message, - get_validated_input, - parse_insertion_point, - undo, + configure_default_model, + welcome_message, + get_validated_input, + parse_insertion_point, + undo, ) from .init import init_project from .wizard import run_wizard from .run import run_project -from .tools import list_tools, add_tool, remove_tool +from .tools import list_tools, add_tool, remove_tool, create_tool from .tasks import add_task from .agents import add_agent from .templates import insert_template, export_template diff --git a/agentstack/cli/init.py b/agentstack/cli/init.py index 758db9b8..b215ea23 100644 --- a/agentstack/cli/init.py +++ b/agentstack/cli/init.py @@ -105,6 +105,13 @@ def init_project( if use_wizard: log.debug("Initializing new project with wizard.") template_data = run_wizard(slug_name) + elif template == "empty": + log.debug("Initializing new project with empty template.") + template_data = TemplateConfig( + name=slug_name, + description="", + framework=framework or frameworks.DEFAULT_FRAMEWORK, + ) elif template: log.debug(f"Initializing new project with template: {template}") template_data = TemplateConfig.from_user_input(template) diff --git a/agentstack/cli/tools.py b/agentstack/cli/tools.py index 4ad552ff..cd3ab798 100644 --- a/agentstack/cli/tools.py +++ b/agentstack/cli/tools.py @@ -2,25 +2,32 @@ import itertools import inquirer from agentstack import conf -from agentstack.utils import term_color +from agentstack.utils import term_color, is_snake_case from agentstack import generation from agentstack import repo from agentstack._tools import get_all_tools from agentstack.agents import get_all_agents +from pathlib import Path +import sys +import json def list_tools(): """ List all available tools by category. """ - tools = get_all_tools() + tools = [t for t in get_all_tools() if t is not None] # Filter out None values categories = {} - + custom_tools = [] + # Group tools by category for tool in tools: - if tool.category not in categories: - categories[tool.category] = [] - categories[tool.category].append(tool) + if tool.category == 'custom': + custom_tools.append(tool) + else: + if tool.category not in categories: + categories[tool.category] = [] + categories[tool.category].append(tool) print("\n\nAvailable AgentStack Tools:") # Display tools by category @@ -31,7 +38,16 @@ def list_tools(): print(term_color(f"{tool.name}", 'blue'), end='') print(f": {tool.url if tool.url else 'AgentStack default tool'}") + # Display custom tools if any exist + if custom_tools: + print("\nCustom Tools:") + for tool in custom_tools: + print(" - ", end='') + print(term_color(f"{tool.name}", 'blue'), end='') + print(": Custom tool") + print("\n\n✨ Add a tool with: agentstack tools add ") + print(" Create a custom tool with: agentstack tools create ") print(" https://docs.agentstack.sh/tools/core") @@ -48,12 +64,16 @@ def add_tool(tool_name: Optional[str], agents=Optional[list[str]]): conf.assert_project() if not tool_name: + # Get all available tools including custom ones + available_tools = [t for t in get_all_tools() if t is not None] + tool_names = [t.name for t in available_tools] + # ask the user for the tool name tools_list = [ inquirer.List( "tool_name", message="Select a tool to add to your project", - choices=[tool.name for tool in get_all_tools()], + choices=tool_names, ) ] try: @@ -75,7 +95,7 @@ def add_tool(tool_name: Optional[str], agents=Optional[list[str]]): return # user cancelled the prompt assert tool_name # appease type checker - + repo.commit_user_changes() with repo.Transaction() as commit: commit.add_message(f"Added tool {tool_name}") @@ -87,9 +107,26 @@ def remove_tool(tool_name: str): Remove a tool from the user's project. """ conf.assert_project() - + repo.commit_user_changes() with repo.Transaction() as commit: commit.add_message(f"Removed tool {tool_name}") generation.remove_tool(tool_name) + +def create_tool(tool_name: str, agents=Optional[list[str]]): + """Create a new custom tool. + Args: + tool_name: Name of the tool to create (must be snake_case) + agents: list of agents to make the tool available to + """ + if not is_snake_case(tool_name): + raise Exception("Invalid tool name: must be snake_case") + + # Check if tool already exists + user_tools_dir = Path('src/tools').resolve() + tool_path = conf.PATH / user_tools_dir / tool_name + if tool_path.exists(): + raise Exception(f"Tool '{tool_name}' already exists.") + + generation.create_tool(tool_name, agents=agents) diff --git a/agentstack/frameworks/__init__.py b/agentstack/frameworks/__init__.py index 8c45e41d..e9cc6494 100644 --- a/agentstack/frameworks/__init__.py +++ b/agentstack/frameworks/__init__.py @@ -77,7 +77,6 @@ def add_agent(self, agent: 'AgentConfig', position: Optional[InsertionPoint] = N """ Add an agent to the user's project. """ - ... def add_task(self, task: 'TaskConfig', position: Optional[InsertionPoint] = None) -> None: """ diff --git a/agentstack/generation/__init__.py b/agentstack/generation/__init__.py index ff4fba02..e8884551 100644 --- a/agentstack/generation/__init__.py +++ b/agentstack/generation/__init__.py @@ -1,7 +1,7 @@ from enum import Enum from .agent_generation import add_agent from .task_generation import add_task -from .tool_generation import add_tool, remove_tool +from .tool_generation import add_tool, create_tool, remove_tool from .files import EnvFile, ProjectFile diff --git a/agentstack/generation/tool_generation.py b/agentstack/generation/tool_generation.py index b2dcfa44..e0ea41ac 100644 --- a/agentstack/generation/tool_generation.py +++ b/agentstack/generation/tool_generation.py @@ -1,4 +1,6 @@ +import json import os, sys +from pathlib import Path from typing import Optional from agentstack import conf, log from agentstack.conf import ConfigFile @@ -47,6 +49,58 @@ def add_tool(name: str, agents: Optional[list[str]] = []): log.notify(f'🪩 {tool.cta}') +def create_tool(tool_name: str, agents: Optional[list[str]] = []): + """Create a new custom tool. + + Args: + tool_name: Name of the tool to create (must be snake_case) + agents: List of agents to make tool available to + """ + + # Check if tool already exists + user_tools_dir = conf.PATH / "src/tools" + tool_path = user_tools_dir / tool_name + if tool_path.exists(): + raise Exception(f"Tool '{tool_name}' already exists.") + + # Create tool directory + tool_path.mkdir(parents=True, exist_ok=False) + + # Create __init__.py with basic function template + init_file = tool_path / '__init__.py' + init_content = f''' + +def {tool_name}_tool(value: str) -> str: + """ + Define your tool's functionality here. + + Args: + value: Input to process (should be typed in function definition) + + Returns: + str: Result of the tool's operation + """ + # Add your tool's logic here + return value +''' + init_file.write_text(init_content) + + tool_config = ToolConfig( + name=tool_name, + category="custom", + tools=[f'{tool_name}_tool', ], + ) + tool_config.write_to_file(tool_path / 'config.json') + + # Edit the framework entrypoint file to include the tool in the agent definition + if not agents: # If no agents are specified, add the tool to all agents + agents = frameworks.get_agent_method_names() + for agent_name in agents: + frameworks.add_tool(tool_config, agent_name) + + log.success(f"🔨 Tool '{tool_name}' has been created successfully in {user_tools_dir}.") + + def remove_tool(name: str, agents: Optional[list[str]] = []): agentstack_config = ConfigFile() diff --git a/agentstack/main.py b/agentstack/main.py index 3be23413..89fe9f90 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -13,7 +13,9 @@ add_task, run_project, export_template, - undo, + undo, + export_template, + create_tool, ) from agentstack.telemetry import track_cli_command, update_telemetry from agentstack.utils import get_version, term_color @@ -37,7 +39,7 @@ def _main(): action="store_true", ) global_parser.add_argument( - "--no-git", + "--no-git", help="Disable automatic git commits of changes to your project.", dest="no_git", action="store_true", @@ -144,6 +146,14 @@ def _main(): ) tools_add_parser.add_argument("--agent", help="Name of agent to add this tool to") + # 'new' command under 'tools' + tools_new_parser = tools_subparsers.add_parser( + "new", aliases=["n"], help="Create a new custom tool", parents=[global_parser] + ) + tools_new_parser.add_argument("name", help="Name of the tool to create") + tools_new_parser.add_argument("--agents", help="Name of agents to add this tool to, comma separated") + tools_new_parser.add_argument("--agent", help="Name of agent to add this tool to") + # 'remove' command under 'tools' tools_remove_parser = tools_subparsers.add_parser( "remove", aliases=["r"], help="Remove a tool", parents=[global_parser] @@ -196,6 +206,10 @@ def _main(): agents = [args.agent] if args.agent else None agents = args.agents.split(",") if args.agents else agents add_tool(args.name, agents) + elif args.tools_command in ["new", "n"]: + agents = [args.agent] if args.agent else None + agents = args.agents.split(",") if args.agents else agents + create_tool(args.name, agents) elif args.tools_command in ["remove", "r"]: remove_tool(args.name) else: diff --git a/tests/fixtures/tool_config_custom.json b/tests/fixtures/tool_config_custom.json new file mode 100644 index 00000000..15769bd0 --- /dev/null +++ b/tests/fixtures/tool_config_custom.json @@ -0,0 +1,5 @@ +{ + "name": "my_custom_tool", + "category": "custom", + "tools": ["tool1", "tool2"] +} \ No newline at end of file diff --git a/tests/test_cli_tools.py b/tests/test_cli_tools.py index 5237aeff..34b262b0 100644 --- a/tests/test_cli_tools.py +++ b/tests/test_cli_tools.py @@ -13,8 +13,8 @@ BASE_PATH = Path(__file__).parent +TEMPLATE_NAME = "empty" -# TODO parameterized framework class CLIToolsTest(unittest.TestCase): def setUp(self): self.framework = os.getenv('TEST_FRAMEWORK') @@ -29,7 +29,7 @@ def tearDown(self): @unittest.skip("Dependency resolution issue") def test_add_tool(self, tool_name): """Test the adding every tool to a project.""" - result = run_cli('init', f"{tool_name}_project") + result = run_cli('init', f"{tool_name}_project", "--template", TEMPLATE_NAME) self.assertEqual(result.returncode, 0) os.chdir(self.project_dir / f"{tool_name}_project") result = run_cli('generate', 'agent', 'test_agent', '--llm', 'opeenai/gpt-4o') @@ -63,4 +63,85 @@ def test_get_validated_input(self): # Test snake_case validation with patch('inquirer.text', return_value='test_case'): result = get_validated_input("Test message", snake_case=True) - self.assertEqual(result, 'test_case') \ No newline at end of file + self.assertEqual(result, 'test_case') + + def test_create_tool_basic(self): + """Test creating a new custom tool via CLI""" + # Initialize a project first + result = run_cli('init', "test_project", "--template", TEMPLATE_NAME) + self.assertEqual(result.returncode, 0) + os.chdir(self.project_dir / "test_project") + + # Create an agent to test with + result = run_cli('generate', 'agent', 'test_agent', '--llm', 'openai/gpt-4') + self.assertEqual(result.returncode, 0) + + # Create a new tool + result = run_cli('tools', 'new', 'test_tool') + self.assertEqual(result.returncode, 0) + + # Verify tool directory and files were created + tool_path = self.project_dir / "test_project" / 'src/tools/test_tool' + self.assertTrue(tool_path.exists()) + self.assertTrue((tool_path / '__init__.py').exists()) + self.assertTrue((tool_path / 'config.json').exists()) + + def test_create_tool_with_agents(self): + """Test creating a new custom tool with specific agents via CLI""" + # Initialize project and create multiple agents + result = run_cli('init', "test_project", "--template", TEMPLATE_NAME) + self.assertEqual(result.returncode, 0) + os.chdir(self.project_dir / "test_project") + + run_cli('generate', 'agent', 'agent1', '--llm', 'openai/gpt-4') + run_cli('generate', 'agent', 'agent2', '--llm', 'openai/gpt-4') + + # Create tool with specific agent + result = run_cli('tools', 'new', 'test_tool', '--agents', 'agent1') + self.assertEqual(result.returncode, 0) + + # Verify tool was created + tool_path = self.project_dir / "test_project" / 'src/tools/test_tool' + self.assertTrue(tool_path.exists()) + + def test_create_tool_existing(self): + """Test creating a tool that already exists""" + # Initialize project + result = run_cli('init', "test_project", "--template", TEMPLATE_NAME) + self.assertEqual(result.returncode, 0) + os.chdir(self.project_dir / "test_project") + + # Create agent + run_cli('generate', 'agent', 'test_agent', '--llm', 'openai/gpt-4') + + # Create tool first time + result = run_cli('tools', 'new', 'test_tool') + self.assertEqual(result.returncode, 0) + + # Try to create same tool again + result = run_cli('tools', 'new', 'test_tool') + self.assertNotEqual(result.returncode, 0) # Should fail + self.assertIn("already exists", result.stderr) + + def test_create_tool_invalid_name(self): + """Test creating a tool with invalid name formats""" + # Initialize project + result = run_cli('init', "test_project", "--template", TEMPLATE_NAME) + self.assertEqual(result.returncode, 0) + os.chdir(self.project_dir / "test_project") + + # Create agent + run_cli('generate', 'agent', 'test_agent', '--llm', 'openai/gpt-4') + + # Test various invalid names + invalid_names = ['TestTool', 'test-tool', 'test tool'] + for name in invalid_names: + result = run_cli('tools', 'new', name) + self.assertNotEqual(result.returncode, 0) + self.assertIn("must be snake_case", result.stderr) + + def test_create_tool_no_project(self): + """Test creating a tool outside a project directory""" + # Try to create tool without initializing project + result = run_cli('tools', 'new', 'test_tool') + self.assertNotEqual(result.returncode, 0) \ No newline at end of file diff --git a/tests/test_generation_tool.py b/tests/test_generation_tool.py index 9b3b9a8c..243a5f72 100644 --- a/tests/test_generation_tool.py +++ b/tests/test_generation_tool.py @@ -4,11 +4,17 @@ import unittest from parameterized import parameterized_class import ast +import json +from unittest.mock import patch from agentstack.conf import ConfigFile, set_path from agentstack import frameworks from agentstack._tools import get_all_tools, ToolConfig -from agentstack.generation.tool_generation import add_tool, remove_tool +from agentstack.generation.tool_generation import ( + add_tool, + create_tool, + remove_tool, +) BASE_PATH = Path(__file__).parent @@ -19,10 +25,12 @@ class TestGenerationTool(unittest.TestCase): def setUp(self): self.framework = os.getenv('TEST_FRAMEWORK') self.project_dir = BASE_PATH / 'tmp' / self.framework / 'tool_generation' + self.tools_dir = self.project_dir / 'src' / 'tools' - os.makedirs(self.project_dir) - os.makedirs(self.project_dir / 'src') - os.makedirs(self.project_dir / 'src' / 'tools') + os.makedirs(self.project_dir, exist_ok=True) + os.makedirs(self.project_dir / 'src', exist_ok=True) + os.makedirs(self.project_dir / 'src' / 'tools', exist_ok=True) + os.makedirs(self.tools_dir, exist_ok=True) (self.project_dir / 'src' / '__init__.py').touch() # set the framework in agentstack.json @@ -62,3 +70,93 @@ def test_remove_tool(self): # TODO verify tool is removed from all agents (this is covered in test_frameworks.py) # assert 'agent_connect' not in entrypoint_src assert 'agent_connect' not in open(self.project_dir / 'agentstack.json').read() + + def test_create_tool_basic(self): + """Test basic tool creation with default parameters""" + tool_name = "test_tool" + tool_path = self.tools_dir / tool_name + + # Execute + create_tool( + tool_name=tool_name, + ) + + # Assert directory was created + self.assertTrue(tool_path.exists()) + self.assertTrue(tool_path.is_dir()) + + # Assert __init__.py was created with correct content + init_file = tool_path / "__init__.py" + self.assertTrue(init_file.exists()) + init_content = init_file.read_text() + self.assertIn(f"def {tool_name}_tool", init_content) + self.assertIn('"""', init_content) # Check docstring exists + + # Assert config.json was created with correct content + config_file = tool_path / "config.json" + self.assertTrue(config_file.exists()) + config = json.loads(config_file.read_text()) + self.assertEqual(config["name"], tool_name) + self.assertEqual(config["category"], "custom") + self.assertEqual(config["tools"], [f"{tool_name}_tool"]) + + def test_create_tool_specific_agents(self): + """Test tool creation with specific agents""" + tool_name = "test_tool" + tool_path = self.tools_dir / tool_name + + create_tool( + tool_name=tool_name, + ) + + # Assert directory and files were created + self.assertTrue(tool_path.exists()) + self.assertTrue((tool_path / "__init__.py").exists()) + self.assertTrue((tool_path / "config.json").exists()) + + # Verify tool was added only to specified agent in entrypoint + entrypoint_src = open(frameworks.get_entrypoint_path(self.framework)).read() + ast.parse(entrypoint_src) # validate syntax + + def test_create_tool_directory_exists(self): + """Test tool creation fails when directory already exists""" + tool_name = "test_tool_directory_exists" + tool_path = self.tools_dir / tool_name + + # Create the directory first + tool_path.mkdir(parents=True) + + # Assert raises error when trying to create tool in existing directory + with self.assertRaises(Exception): + create_tool( + tool_name=tool_name, + ) + + @patch('agentstack.generation.tool_generation.log.success') + def test_create_tool_success_logging(self, mock_log_success): + """Test success logging message""" + tool_name = "test_tool" + + create_tool( + tool_name=tool_name, + ) + + mock_log_success.assert_called_once() + log_message = mock_log_success.call_args[0][0] + self.assertIn(tool_name, log_message) + self.assertIn(str(self.tools_dir), log_message) + + + def test_create_tool(self): + create_tool('my_custom_tool') + + tool_path = self.project_dir / 'src/tools/my_custom_tool' + entrypoint_path = frameworks.get_entrypoint_path(self.framework) + entrypoint_src = open(entrypoint_path).read() + ast.parse(entrypoint_src) + + assert 'my_custom_tool' in entrypoint_src + assert (tool_path / '__init__.py').exists() + assert (tool_path / 'config.json').exists() + ToolConfig.from_tool_name('my_custom_tool') + diff --git a/tests/test_tool_config.py b/tests/test_tool_config.py index bf187e44..bd287ba8 100644 --- a/tests/test_tool_config.py +++ b/tests/test_tool_config.py @@ -1,12 +1,26 @@ +import os import json import unittest import re from pathlib import Path +import shutil +from agentstack import conf +from agentstack.exceptions import ValidationError from agentstack._tools import ToolConfig, get_all_tool_paths, get_all_tool_names BASE_PATH = Path(__file__).parent class ToolConfigTest(unittest.TestCase): + def setUp(self): + self.project_dir = BASE_PATH / 'tmp' / 'tool_config' + os.makedirs(self.project_dir) + os.makedirs(self.project_dir / 'src') + os.makedirs(self.project_dir / 'src' / 'tools') + conf.set_path(self.project_dir) + + def tearDown(self): + shutil.rmtree(self.project_dir) + def test_minimal_json(self): config = ToolConfig.from_json(BASE_PATH / "fixtures/tool_config_min.json") assert config.name == "tool_name" @@ -29,6 +43,22 @@ def test_maximal_json(self): assert config.post_install == "install.sh" assert config.post_remove == "remove.sh" + def test_invalid_json(self): + with self.assertRaises(ValidationError): + ToolConfig.from_json(BASE_PATH / "fixtures/agentstack.json") + + def test_write_to_file(self): + config = ToolConfig.from_json(BASE_PATH / "fixtures/tool_config_min.json") + config.write_to_file(self.project_dir / "config.json") + assert (self.project_dir / "config.json").exists() + read_config = ToolConfig.from_json(self.project_dir / "config.json") + assert read_config == config + + def test_write_to_file_invalid_suffix(self): + config = ToolConfig.from_json(BASE_PATH / "fixtures/tool_config_min.json") + with self.assertRaises(ValidationError): + config.write_to_file(self.project_dir / "config.txt") + def test_dependency_versions(self): """Test that all dependencies specify a version constraint.""" for tool_name in get_all_tool_names(): @@ -60,3 +90,15 @@ def test_all_json_configs_from_tool_path(self): ) assert config.name == path.stem + + def test_tool_missing(self): + with self.assertRaises(ValidationError): + ToolConfig.from_tool_name("non_existent_tool") + + def test_from_custom_path(self): + os.mkdir(self.project_dir / "src/tools/my_custom_tool") + shutil.copy(BASE_PATH / "fixtures/tool_config_custom.json", + self.project_dir / "src/tools/my_custom_tool/config.json") + + config = ToolConfig.from_tool_name("my_custom_tool") + assert config.module_name == "src.tools.my_custom_tool" \ No newline at end of file