Skip to content

Commit a996ba3

Browse files
authored
Merge pull request #236 from AgentOps-AI/create_custom_tools
Create custom tools
2 parents c82a30f + c4d3037 commit a996ba3

File tree

12 files changed

+415
-30
lines changed

12 files changed

+415
-30
lines changed

agentstack/_tools/__init__.py

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,23 @@
77
import pydantic
88
from agentstack.exceptions import ValidationError
99
from agentstack.utils import get_package_path, open_json_file, term_color, snake_to_camel
10+
from agentstack import conf, log
1011

1112

1213
TOOLS_DIR: Path = get_package_path() / '_tools' # NOTE: if you change this dir, also update MANIFEST.in
1314
TOOLS_CONFIG_FILENAME: str = 'config.json'
1415

1516

17+
def _get_custom_tool_path(name: str) -> Path:
18+
"""Get the path to a custom tool."""
19+
return conf.PATH / 'src/tools' / name / TOOLS_CONFIG_FILENAME
20+
21+
22+
def _get_builtin_tool_path(name: str) -> Path:
23+
"""Get the path to a builtin tool."""
24+
return TOOLS_DIR / name / TOOLS_CONFIG_FILENAME
25+
26+
1627
class ToolConfig(pydantic.BaseModel):
1728
"""
1829
This represents the configuration data for a tool.
@@ -32,8 +43,14 @@ class ToolConfig(pydantic.BaseModel):
3243

3344
@classmethod
3445
def from_tool_name(cls, name: str) -> 'ToolConfig':
35-
path = TOOLS_DIR / name / TOOLS_CONFIG_FILENAME
36-
if not os.path.exists(path):
46+
# First check in the user's project directory for custom tools
47+
custom_path = _get_custom_tool_path(name)
48+
if custom_path.exists():
49+
return cls.from_json(custom_path)
50+
51+
# Then check in the package's tools directory
52+
path = _get_builtin_tool_path(name)
53+
if not path.exists():
3754
raise ValidationError(f'No known agentstack tool: {name}')
3855
return cls.from_json(path)
3956

@@ -48,6 +65,14 @@ def from_json(cls, path: Path) -> 'ToolConfig':
4865
error_str += f"{' '.join([str(loc) for loc in error['loc']])}: {error['msg']}\n"
4966
raise ValidationError(f"Error loading tool from {path}.\n{error_str}")
5067

68+
def write_to_file(self, filename: Path):
69+
"""Write the tool config to a json file."""
70+
if not filename.suffix == '.json':
71+
raise ValidationError(f"Filename must end with .json: {filename}")
72+
73+
with open(filename, 'w') as f:
74+
f.write(self.model_dump_json())
75+
5176
@property
5277
def type(self) -> type:
5378
"""
@@ -74,6 +99,12 @@ def not_implemented(*args, **kwargs):
7499
@property
75100
def module_name(self) -> str:
76101
"""Module name for the tool module."""
102+
# Check if this is a custom tool in the user's project
103+
custom_path = _get_custom_tool_path(self.name)
104+
if custom_path.exists():
105+
return f"src.tools.{self.name}"
106+
107+
# Otherwise, it's a package tool
77108
return f"agentstack._tools.{self.name}"
78109

79110
@property
@@ -105,19 +136,36 @@ def get_all_tool_paths() -> list[Path]:
105136
Get all the paths to the tool configuration files.
106137
ie. agentstack/_tools/<tool_name>/
107138
Tools are identified by having a `config.json` file inside the _tools/<tool_name> directory.
139+
Also checks the user's project directory for custom tools.
108140
"""
109141
paths = []
142+
143+
# Get package tools
110144
for tool_dir in TOOLS_DIR.iterdir():
111145
if tool_dir.is_dir():
112146
config_path = tool_dir / TOOLS_CONFIG_FILENAME
113147
if config_path.exists():
114148
paths.append(tool_dir)
149+
150+
# Get custom tools from user's project if in a project directory
151+
if conf.PATH:
152+
custom_tools_dir = conf.PATH / 'src/tools'
153+
if custom_tools_dir.exists():
154+
for tool_dir in custom_tools_dir.iterdir():
155+
if tool_dir.is_dir():
156+
config_path = tool_dir / TOOLS_CONFIG_FILENAME
157+
if config_path.exists():
158+
paths.append(tool_dir)
159+
115160
return paths
116161

117162

118163
def get_all_tool_names() -> list[str]:
119-
return [path.stem for path in get_all_tool_paths()]
164+
"""Get names of all available tools, including custom tools."""
165+
return [path.name for path in get_all_tool_paths()]
120166

121167

122168
def get_all_tools() -> list[ToolConfig]:
123-
return [ToolConfig.from_tool_name(path) for path in get_all_tool_names()]
169+
"""Get all tool configs, including custom tools."""
170+
tool_names = get_all_tool_names()
171+
return [ToolConfig.from_tool_name(name) for name in tool_names]

agentstack/cli/__init__.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
from .cli import (
2-
configure_default_model,
3-
welcome_message,
4-
get_validated_input,
5-
parse_insertion_point,
6-
undo,
2+
configure_default_model,
3+
welcome_message,
4+
get_validated_input,
5+
parse_insertion_point,
6+
undo,
77
)
88
from .init import init_project
99
from .wizard import run_wizard
1010
from .run import run_project
11-
from .tools import list_tools, add_tool, remove_tool
11+
from .tools import list_tools, add_tool, remove_tool, create_tool
1212
from .tasks import add_task
1313
from .agents import add_agent
1414
from .templates import insert_template, export_template

agentstack/cli/init.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@ def init_project(
105105
if use_wizard:
106106
log.debug("Initializing new project with wizard.")
107107
template_data = run_wizard(slug_name)
108+
elif template == "empty":
109+
log.debug("Initializing new project with empty template.")
110+
template_data = TemplateConfig(
111+
name=slug_name,
112+
description="",
113+
framework=framework or frameworks.DEFAULT_FRAMEWORK,
114+
)
108115
elif template:
109116
log.debug(f"Initializing new project with template: {template}")
110117
template_data = TemplateConfig.from_user_input(template)

agentstack/cli/tools.py

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,32 @@
22
import itertools
33
import inquirer
44
from agentstack import conf
5-
from agentstack.utils import term_color
5+
from agentstack.utils import term_color, is_snake_case
66
from agentstack import generation
77
from agentstack import repo
88
from agentstack._tools import get_all_tools
99
from agentstack.agents import get_all_agents
10+
from pathlib import Path
11+
import sys
12+
import json
1013

1114

1215
def list_tools():
1316
"""
1417
List all available tools by category.
1518
"""
16-
tools = get_all_tools()
19+
tools = [t for t in get_all_tools() if t is not None] # Filter out None values
1720
categories = {}
18-
21+
custom_tools = []
22+
1923
# Group tools by category
2024
for tool in tools:
21-
if tool.category not in categories:
22-
categories[tool.category] = []
23-
categories[tool.category].append(tool)
25+
if tool.category == 'custom':
26+
custom_tools.append(tool)
27+
else:
28+
if tool.category not in categories:
29+
categories[tool.category] = []
30+
categories[tool.category].append(tool)
2431

2532
print("\n\nAvailable AgentStack Tools:")
2633
# Display tools by category
@@ -31,7 +38,16 @@ def list_tools():
3138
print(term_color(f"{tool.name}", 'blue'), end='')
3239
print(f": {tool.url if tool.url else 'AgentStack default tool'}")
3340

41+
# Display custom tools if any exist
42+
if custom_tools:
43+
print("\nCustom Tools:")
44+
for tool in custom_tools:
45+
print(" - ", end='')
46+
print(term_color(f"{tool.name}", 'blue'), end='')
47+
print(": Custom tool")
48+
3449
print("\n\n✨ Add a tool with: agentstack tools add <tool_name>")
50+
print(" Create a custom tool with: agentstack tools create <tool_name>")
3551
print(" https://docs.agentstack.sh/tools/core")
3652

3753

@@ -48,12 +64,16 @@ def add_tool(tool_name: Optional[str], agents=Optional[list[str]]):
4864
conf.assert_project()
4965

5066
if not tool_name:
67+
# Get all available tools including custom ones
68+
available_tools = [t for t in get_all_tools() if t is not None]
69+
tool_names = [t.name for t in available_tools]
70+
5171
# ask the user for the tool name
5272
tools_list = [
5373
inquirer.List(
5474
"tool_name",
5575
message="Select a tool to add to your project",
56-
choices=[tool.name for tool in get_all_tools()],
76+
choices=tool_names,
5777
)
5878
]
5979
try:
@@ -75,7 +95,7 @@ def add_tool(tool_name: Optional[str], agents=Optional[list[str]]):
7595
return # user cancelled the prompt
7696

7797
assert tool_name # appease type checker
78-
98+
7999
repo.commit_user_changes()
80100
with repo.Transaction() as commit:
81101
commit.add_message(f"Added tool {tool_name}")
@@ -87,9 +107,26 @@ def remove_tool(tool_name: str):
87107
Remove a tool from the user's project.
88108
"""
89109
conf.assert_project()
90-
110+
91111
repo.commit_user_changes()
92112
with repo.Transaction() as commit:
93113
commit.add_message(f"Removed tool {tool_name}")
94114
generation.remove_tool(tool_name)
95115

116+
117+
def create_tool(tool_name: str, agents=Optional[list[str]]):
118+
"""Create a new custom tool.
119+
Args:
120+
tool_name: Name of the tool to create (must be snake_case)
121+
agents: list of agents to make the tool available to
122+
"""
123+
if not is_snake_case(tool_name):
124+
raise Exception("Invalid tool name: must be snake_case")
125+
126+
# Check if tool already exists
127+
user_tools_dir = Path('src/tools').resolve()
128+
tool_path = conf.PATH / user_tools_dir / tool_name
129+
if tool_path.exists():
130+
raise Exception(f"Tool '{tool_name}' already exists.")
131+
132+
generation.create_tool(tool_name, agents=agents)

agentstack/frameworks/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ def add_agent(self, agent: 'AgentConfig', position: Optional[InsertionPoint] = N
7777
"""
7878
Add an agent to the user's project.
7979
"""
80-
...
8180

8281
def add_task(self, task: 'TaskConfig', position: Optional[InsertionPoint] = None) -> None:
8382
"""

agentstack/generation/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from enum import Enum
22
from .agent_generation import add_agent
33
from .task_generation import add_task
4-
from .tool_generation import add_tool, remove_tool
4+
from .tool_generation import add_tool, create_tool, remove_tool
55
from .files import EnvFile, ProjectFile
66

77

agentstack/generation/tool_generation.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import json
12
import os, sys
3+
from pathlib import Path
24
from typing import Optional
35
from agentstack import conf, log
46
from agentstack.conf import ConfigFile
@@ -47,6 +49,58 @@ def add_tool(name: str, agents: Optional[list[str]] = []):
4749
log.notify(f'🪩 {tool.cta}')
4850

4951

52+
def create_tool(tool_name: str, agents: Optional[list[str]] = []):
53+
"""Create a new custom tool.
54+
55+
Args:
56+
tool_name: Name of the tool to create (must be snake_case)
57+
agents: List of agents to make tool available to
58+
"""
59+
60+
# Check if tool already exists
61+
user_tools_dir = conf.PATH / "src/tools"
62+
tool_path = user_tools_dir / tool_name
63+
if tool_path.exists():
64+
raise Exception(f"Tool '{tool_name}' already exists.")
65+
66+
# Create tool directory
67+
tool_path.mkdir(parents=True, exist_ok=False)
68+
69+
# Create __init__.py with basic function template
70+
init_file = tool_path / '__init__.py'
71+
init_content = f'''
72+
73+
def {tool_name}_tool(value: str) -> str:
74+
"""
75+
Define your tool's functionality here.
76+
77+
Args:
78+
value: Input to process (should be typed in function definition)
79+
80+
Returns:
81+
str: Result of the tool's operation
82+
"""
83+
# Add your tool's logic here
84+
return value
85+
'''
86+
init_file.write_text(init_content)
87+
88+
tool_config = ToolConfig(
89+
name=tool_name,
90+
category="custom",
91+
tools=[f'{tool_name}_tool', ],
92+
)
93+
tool_config.write_to_file(tool_path / 'config.json')
94+
95+
# Edit the framework entrypoint file to include the tool in the agent definition
96+
if not agents: # If no agents are specified, add the tool to all agents
97+
agents = frameworks.get_agent_method_names()
98+
for agent_name in agents:
99+
frameworks.add_tool(tool_config, agent_name)
100+
101+
log.success(f"🔨 Tool '{tool_name}' has been created successfully in {user_tools_dir}.")
102+
103+
50104
def remove_tool(name: str, agents: Optional[list[str]] = []):
51105
agentstack_config = ConfigFile()
52106

agentstack/main.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
add_task,
1414
run_project,
1515
export_template,
16-
undo,
16+
undo,
17+
export_template,
18+
create_tool,
1719
)
1820
from agentstack.telemetry import track_cli_command, update_telemetry
1921
from agentstack.utils import get_version, term_color
@@ -37,7 +39,7 @@ def _main():
3739
action="store_true",
3840
)
3941
global_parser.add_argument(
40-
"--no-git",
42+
"--no-git",
4143
help="Disable automatic git commits of changes to your project.",
4244
dest="no_git",
4345
action="store_true",
@@ -144,6 +146,14 @@ def _main():
144146
)
145147
tools_add_parser.add_argument("--agent", help="Name of agent to add this tool to")
146148

149+
# 'new' command under 'tools'
150+
tools_new_parser = tools_subparsers.add_parser(
151+
"new", aliases=["n"], help="Create a new custom tool", parents=[global_parser]
152+
)
153+
tools_new_parser.add_argument("name", help="Name of the tool to create")
154+
tools_new_parser.add_argument("--agents", help="Name of agents to add this tool to, comma separated")
155+
tools_new_parser.add_argument("--agent", help="Name of agent to add this tool to")
156+
147157
# 'remove' command under 'tools'
148158
tools_remove_parser = tools_subparsers.add_parser(
149159
"remove", aliases=["r"], help="Remove a tool", parents=[global_parser]
@@ -196,6 +206,10 @@ def _main():
196206
agents = [args.agent] if args.agent else None
197207
agents = args.agents.split(",") if args.agents else agents
198208
add_tool(args.name, agents)
209+
elif args.tools_command in ["new", "n"]:
210+
agents = [args.agent] if args.agent else None
211+
agents = args.agents.split(",") if args.agents else agents
212+
create_tool(args.name, agents)
199213
elif args.tools_command in ["remove", "r"]:
200214
remove_tool(args.name)
201215
else:
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "my_custom_tool",
3+
"category": "custom",
4+
"tools": ["tool1", "tool2"]
5+
}

0 commit comments

Comments
 (0)