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
56 changes: 52 additions & 4 deletions agentstack/_tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)

Expand All @@ -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:
"""
Expand All @@ -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
Expand Down Expand Up @@ -105,19 +136,36 @@ def get_all_tool_paths() -> list[Path]:
Get all the paths to the tool configuration files.
ie. agentstack/_tools/<tool_name>/
Tools are identified by having a `config.json` file inside the _tools/<tool_name> 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]
12 changes: 6 additions & 6 deletions agentstack/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 7 additions & 0 deletions agentstack/cli/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
55 changes: 46 additions & 9 deletions agentstack/cli/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <tool_name>")
print(" Create a custom tool with: agentstack tools create <tool_name>")
print(" https://docs.agentstack.sh/tools/core")


Expand All @@ -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:
Expand All @@ -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}")
Expand All @@ -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)
1 change: 0 additions & 1 deletion agentstack/frameworks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down
2 changes: 1 addition & 1 deletion agentstack/generation/__init__.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down
54 changes: 54 additions & 0 deletions agentstack/generation/tool_generation.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()

Expand Down
18 changes: 16 additions & 2 deletions agentstack/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/tool_config_custom.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "my_custom_tool",
"category": "custom",
"tools": ["tool1", "tool2"]
}
Loading