diff --git a/MANIFEST.in b/MANIFEST.in index 44aa8e5a..1f7cc4f8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,3 @@ recursive-include agentstack/templates * -recursive-include agentstack/tools * +recursive-include agentstack/_tools * include agentstack.json .env .env.example \ No newline at end of file diff --git a/agentstack/__init__.py b/agentstack/__init__.py index e8addfb9..6fb354f7 100644 --- a/agentstack/__init__.py +++ b/agentstack/__init__.py @@ -1,19 +1,23 @@ """ -This it the beginning of the agentstack public API. +This it the beginning of the agentstack public API. Methods that have been imported into this file are expected to be used by the -end user inside of their project. +end user inside of their project. """ + +from typing import Callable from pathlib import Path from agentstack import conf from agentstack.utils import get_framework from agentstack.inputs import get_inputs +from agentstack import frameworks ___all___ = [ - "conf", - "get_tags", - "get_framework", - "get_inputs", + "conf", + "tools", + "get_tags", + "get_framework", + "get_inputs", ] @@ -23,3 +27,18 @@ def get_tags() -> list[str]: """ return ['agentstack', get_framework(), *conf.get_installed_tools()] + +class ToolLoader: + """ + Provides the public interface for accessing tools, wrapped in the + framework-specific callable format. + + Get a tool's callables by name with `agentstack.tools[tool_name]` + Include them in your agent's tool list with `tools = [*agentstack.tools[tool_name], ]` + """ + + def __getitem__(self, tool_name: str) -> list[Callable]: + return frameworks.get_tool_callables(tool_name) + + +tools = ToolLoader() diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py new file mode 100644 index 00000000..bfde9b18 --- /dev/null +++ b/agentstack/_tools/__init__.py @@ -0,0 +1,125 @@ +from typing import Optional, Protocol, runtime_checkable +from types import ModuleType +import os +import sys +from pathlib import Path +from importlib import import_module +import pydantic +from agentstack.exceptions import ValidationError +from agentstack.utils import get_package_path, open_json_file, term_color, snake_to_camel + + +TOOLS_DIR: Path = get_package_path() / '_tools' # NOTE: if you change this dir, also update MANIFEST.in +TOOLS_CONFIG_FILENAME: str = 'config.json' + + +class ToolConfig(pydantic.BaseModel): + """ + This represents the configuration data for a tool. + It parses and validates the `config.json` file and provides a dynamic + interface for interacting with the tool implementation. + """ + + name: str + category: str + tools: list[str] + url: Optional[str] = None + cta: Optional[str] = None + env: Optional[dict] = None + dependencies: Optional[list[str]] = None + post_install: Optional[str] = None + post_remove: Optional[str] = None + + @classmethod + def from_tool_name(cls, name: str) -> 'ToolConfig': + path = TOOLS_DIR / name / TOOLS_CONFIG_FILENAME + if not os.path.exists(path): # TODO raise exceptions and handle message/exit in cli + print(term_color(f'No known agentstack tool: {name}', 'red')) + sys.exit(1) + return cls.from_json(path) + + @classmethod + def from_json(cls, path: Path) -> 'ToolConfig': + data = open_json_file(path) + try: + return cls(**data) + except pydantic.ValidationError as e: + # TODO raise exceptions and handle message/exit in cli + print(term_color(f"Error validating tool config JSON: \n{path}", 'red')) + for error in e.errors(): + print(f"{' '.join([str(loc) for loc in error['loc']])}: {error['msg']}") + sys.exit(1) + + @property + def type(self) -> type: + """ + Dynamically generate a type for the tool module. + ie. indicate what methods it's importable module should have. + """ + + def method_stub(name: str): + def not_implemented(*args, **kwargs): + raise NotImplementedError( + f"Method '{name}' is configured in config.json for tool '{self.name}'" + f"but has not been implemented in the tool module ({self.module_name})." + ) + + return not_implemented + + # fmt: off + type_ = type(f'{snake_to_camel(self.name)}Module', (Protocol,), { # type: ignore[arg-type] + method_name: method_stub(method_name) for method_name in self.tools + },) + # fmt: on + return runtime_checkable(type_) + + @property + def module_name(self) -> str: + """Module name for the tool module.""" + return f"agentstack._tools.{self.name}" + + @property + def module(self) -> ModuleType: + """ + Import the tool module and validate that it implements the required methods. + Returns the imported module ready for direct use. + """ + try: + _module = import_module(self.module_name) + assert isinstance(_module, self.type) + return _module + except AssertionError as e: + raise ValidationError( + f"Tool module `{self.module_name}` does not match the expected implementation. \n" + f"The tool's config.json file lists the following public methods: `{'`, `'.join(self.tools)}` " + f"but only implements: '{'`, `'.join([m for m in dir(_module) if not m.startswith('_')])}`" + ) + except ModuleNotFoundError as e: + raise ValidationError( + f"Could not import tool module: {self.module_name}\n" + f"Are you sure you have installed the tool? (agentstack tools add {self.name})\n" + f"ModuleNotFoundError: {e}" + ) + + +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. + """ + paths = [] + 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) + return paths + + +def get_all_tool_names() -> list[str]: + return [path.stem 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()] diff --git a/agentstack/templates/crewai/tools/agent_connect_tool.py b/agentstack/_tools/agent_connect/__init__.py similarity index 67% rename from agentstack/templates/crewai/tools/agent_connect_tool.py rename to agentstack/_tools/agent_connect/__init__.py index f90a49c6..31f0caa1 100644 --- a/agentstack/templates/crewai/tools/agent_connect_tool.py +++ b/agentstack/_tools/agent_connect/__init__.py @@ -1,30 +1,38 @@ -from crewai_tools import tool -from dotenv import load_dotenv import os - -from agent_connect.simple_node import SimpleNode import json +from agent_connect.simple_node import SimpleNode -load_dotenv() # An HTTP and WS service will be started in agent-connect # It can be an IP address or a domain name -host_domain = os.getenv("HOST_DOMAIN") +host_domain = os.getenv("AGENT_CONNECT_HOST_DOMAIN") # Host port, default is 80 -host_port = os.getenv("HOST_PORT") +host_port = os.getenv("AGENT_CONNECT_HOST_PORT") # WS path, default is /ws -host_ws_path = os.getenv("HOST_WS_PATH") +host_ws_path = os.getenv("AGENT_CONNECT_HOST_WS_PATH") # Path to store DID document -did_document_path = os.getenv("DID_DOCUMENT_PATH") +did_document_path = os.getenv("AGENT_CONNECT_DID_DOCUMENT_PATH") # SSL certificate path, if using HTTPS, certificate and key need to be provided -ssl_cert_path = os.getenv("SSL_CERT_PATH") -ssl_key_path = os.getenv("SSL_KEY_PATH") +ssl_cert_path = os.getenv("AGENT_CONNECT_SSL_CERT_PATH") +ssl_key_path = os.getenv("AGENT_CONNECT_SSL_KEY_PATH") + +if not host_domain: + raise Exception( + "Host domain has not been provided.\n" + "Did you set the AGENT_CONNECT_HOST_DOMAIN in you project's .env file?" + ) + +if not did_document_path: + raise Exception( + "DID document path has not been provided.\n" + "Did you set the AGENT_CONNECT_DID_DOCUMENT_PATH in you project's .env file?" + ) def generate_did_info(node: SimpleNode, did_document_path: str) -> None: """ Generate or load DID information for a node. - + Args: node: SimpleNode instance did_document_path: Path to store/load DID document @@ -33,35 +41,33 @@ def generate_did_info(node: SimpleNode, did_document_path: str) -> None: print(f"Loading existing DID information from {did_document_path}") with open(did_document_path, "r") as f: did_info = json.load(f) - node.set_did_info( - did_info["private_key_pem"], - did_info["did"], - did_info["did_document_json"] - ) + node.set_did_info(did_info["private_key_pem"], did_info["did"], did_info["did_document_json"]) else: print("Generating new DID information") private_key_pem, did, did_document_json = node.generate_did_document() node.set_did_info(private_key_pem, did, did_document_json) - + # Save DID information - os.makedirs(os.path.dirname(did_document_path), exist_ok=True) + if os.path.dirname(did_document_path): # allow saving to current directory + os.makedirs(os.path.dirname(did_document_path), exist_ok=True) with open(did_document_path, "w") as f: - json.dump({ - "private_key_pem": private_key_pem, - "did": did, - "did_document_json": did_document_json - }, f, indent=2) + json.dump( + {"private_key_pem": private_key_pem, "did": did, "did_document_json": did_document_json}, + f, + indent=2, + ) print(f"DID information saved to {did_document_path}") + agent_connect_simple_node = SimpleNode(host_domain, host_port, host_ws_path) generate_did_info(agent_connect_simple_node, did_document_path) agent_connect_simple_node.run() -@tool("Send Message to Agent by DID") + async def send_message(message: str, destination_did: str) -> bool: """ Send a message through agent-connect node. - + Args: message: Message content to be sent destination_did: DID of the recipient agent @@ -76,11 +82,11 @@ async def send_message(message: str, destination_did: str) -> bool: print(f"Failed to send message: {e}") return False -@tool("Receive Message from Agent") + async def receive_message() -> tuple[str, str]: """ Receive message from agent-connect node. - + Returns: tuple[str, str]: Sender DID and received message content, empty string if no message or error occurred """ diff --git a/agentstack/_tools/agent_connect/config.json b/agentstack/_tools/agent_connect/config.json new file mode 100644 index 00000000..542c81e8 --- /dev/null +++ b/agentstack/_tools/agent_connect/config.json @@ -0,0 +1,17 @@ +{ + "name": "agent_connect", + "url": "https://github.com/chgaowei/AgentConnect", + "category": "network-protocols", + "env": { + "AGENT_CONNECT_HOST_DOMAIN": null, + "AGENT_CONNECT_HOST_PORT": 80, + "AGENT_CONNECT_HOST_WS_PATH": "/ws", + "AGENT_CONNECT_DID_DOCUMENT_PATH": "data/agent_connect_did.json", + "AGENT_CONNECT_SSL_CERT_PATH": null, + "AGENT_CONNECT_SSL_KEY_PATH": null + }, + "dependencies": [ + "agent-connect>=0.3.0" + ], + "tools": ["send_message", "receive_message"] +} diff --git a/agentstack/_tools/browserbase/__init__.py b/agentstack/_tools/browserbase/__init__.py new file mode 100644 index 00000000..7edac526 --- /dev/null +++ b/agentstack/_tools/browserbase/__init__.py @@ -0,0 +1,30 @@ +import os +from typing import Optional, Any +from browserbase import Browserbase + + +BROWSERBASE_API_KEY = os.getenv("BROWSERBASE_API_KEY") +BROWSERBASE_PROJECT_ID = os.getenv("BROWSERBASE_PROJECT_ID") + +client = Browserbase(BROWSERBASE_API_KEY, BROWSERBASE_PROJECT_ID) + + +# TODO can we define a type for the return value? +def load_url( + url: str, + text_content: Optional[bool] = True, + session_id: Optional[str] = None, + proxy: Optional[bool] = None, +) -> Any: + """ + Load a URL in a headless browser and return the page content. + + Args: + url: URL to load + text_content: Return text content if True, otherwise return raw content + session_id: Session ID to use for the browser + proxy: Use a proxy for the browser + Returns: + Any: Page content + """ + return client.load_url(url) diff --git a/agentstack/tools/browserbase.json b/agentstack/_tools/browserbase/config.json similarity index 77% rename from agentstack/tools/browserbase.json rename to agentstack/_tools/browserbase/config.json index 6e442262..01489d50 100644 --- a/agentstack/tools/browserbase.json +++ b/agentstack/_tools/browserbase/config.json @@ -2,11 +2,13 @@ "name": "browserbase", "url": "https://github.com/browserbase/python-sdk", "category": "browsing", - "packages": ["browserbase", "playwright"], "env": { "BROWSERBASE_API_KEY": null, "BROWSERBASE_PROJECT_ID": null }, - "tools": ["browserbase"], + "dependencies": [ + "browserbase>=1.0.5" + ], + "tools": ["load_url"], "cta": "Create an API key at https://www.browserbase.com/" } \ No newline at end of file diff --git a/agentstack/_tools/code_interpreter/Dockerfile b/agentstack/_tools/code_interpreter/Dockerfile new file mode 100644 index 00000000..680d690a --- /dev/null +++ b/agentstack/_tools/code_interpreter/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.12-alpine + +RUN pip install requests beautifulsoup4 + +# Set the working directory +WORKDIR /workspace \ No newline at end of file diff --git a/agentstack/_tools/code_interpreter/__init__.py b/agentstack/_tools/code_interpreter/__init__.py new file mode 100644 index 00000000..5b0c9958 --- /dev/null +++ b/agentstack/_tools/code_interpreter/__init__.py @@ -0,0 +1,73 @@ +import os +from agentstack.utils import get_package_path +import docker + +CONTAINER_NAME = "code-interpreter" +DEFAULT_IMAGE_TAG = os.getenv("CODE_INTERPRETER_DEFAULT_IMAGE_TAG", "code-interpreter:latest") +DOCKERFILE_PATH = os.getenv("CODE_INTERPRETER_DOCKERFILE_PATH", get_package_path() / "tools/code_interpreter") + +client = docker.from_env() + + +def _verify_docker_image() -> None: + try: + client.images.get(DEFAULT_IMAGE_TAG) + except docker.errors.ImageNotFound: + if not os.path.exists(DOCKERFILE_PATH): + raise Exception( + ( + "Dockerfile path has not been provided.\n" + "Did you set the DOCKERFILE_PATH in you project's .env file?" + ) + ) + + client.images.build( + path=DOCKERFILE_PATH, + tag=DEFAULT_IMAGE_TAG, + rm=True, + ) + + +def _init_docker_container() -> docker.models.containers.Container: + current_path = os.getcwd() + client = docker.from_env() + + # kill container if it's already running + try: + existing_container = client.containers.get(CONTAINER_NAME) + existing_container.stop() + existing_container.remove() + except docker.errors.NotFound: + pass + + return client.containers.run( + DEFAULT_IMAGE_TAG, + detach=True, + tty=True, + working_dir="/workspace", + name=CONTAINER_NAME, + volumes={current_path: {"bind": "/workspace", "mode": "rw"}}, # type: ignore + ) + + +def run_code(code: str, libraries_used: list[str]) -> str: + """ + Run the code in a Docker container using Python 3. + + The container will be built and started, the code will be executed, and the container will be stopped. + + Args: + code: The code to be executed. ALWAYS PRINT the final result and the output of the code. + libraries_used: A list of libraries to be installed in the container before running the code. + """ + _verify_docker_image() + container = _init_docker_container() + + for library in libraries_used: + container.exec_run(f"pip install {library}") + + result = container.exec_run(f'python3 -c "{code}"') + container.stop() + container.remove() + + return f"exit code: {result.exit_code}\n" f"{result.output.decode('utf-8')}" diff --git a/agentstack/_tools/code_interpreter/config.json b/agentstack/_tools/code_interpreter/config.json new file mode 100644 index 00000000..f164730f --- /dev/null +++ b/agentstack/_tools/code_interpreter/config.json @@ -0,0 +1,12 @@ +{ + "name": "code_interpreter", + "category": "code-execution", + "env": { + "CODE_INTERPRETER_DEFAULT_IMAGE_TAG": null, + "CODE_INTERPRETER_DOCKERFILE_PATH": null + }, + "dependencies": [ + "docker>=7.1.0" + ], + "tools": ["run_code"] +} \ No newline at end of file diff --git a/agentstack/_tools/composio/__init__.py b/agentstack/_tools/composio/__init__.py new file mode 100644 index 00000000..8b62d046 --- /dev/null +++ b/agentstack/_tools/composio/__init__.py @@ -0,0 +1,75 @@ +"""Framework-agnostic implementation of composio tools.""" + +import os +from typing import Any, Dict, List, Optional + +from composio import Action, ComposioToolSet +from composio.constants import DEFAULT_ENTITY_ID + + +def _check_connected_account(app: str, toolset: ComposioToolSet) -> None: + """Check if connected account exists for the app.""" + connections = toolset.client.connected_accounts.get() + if app not in [connection.appUniqueId for connection in connections]: + raise RuntimeError( + f"No connected account found for app `{app}`; " f"Run `composio add {app}` to fix this" + ) + + +def execute_action( + action_name: str, + params: Dict[str, Any], + entity_id: Optional[str] = None, + no_auth: bool = False, +) -> Dict[str, Any]: + """ + Execute a composio action with given parameters. + + Args: + action_name: Name of the action to execute + params: Parameters for the action + entity_id: Optional entity ID (defaults to DEFAULT_ENTITY_ID) + no_auth: Whether the action requires authentication + + Returns: + Dict containing the action result + """ + toolset = ComposioToolSet() + action = Action(action_name) + + if not no_auth: + _check_connected_account(action.app, toolset) + + return toolset.execute_action( + action=action, + params=params, + entity_id=entity_id or DEFAULT_ENTITY_ID, + ) + + +def get_action_schema(action_name: str) -> Dict[str, Any]: + """Get the schema for a composio action.""" + toolset = ComposioToolSet() + action = Action(action_name) + (action_schema,) = toolset.get_action_schemas(actions=[action]) + return action_schema.model_dump(exclude_none=True) + + +def find_actions_by_use_case( + *apps: str, + use_case: str, +) -> List[Dict[str, Any]]: + """Find actions by use case.""" + toolset = ComposioToolSet() + actions = toolset.find_actions_by_use_case(*apps, use_case=use_case) + return [get_action_schema(action.name) for action in actions] + + +def find_actions_by_tags( + *apps: str, + tags: List[str], +) -> List[Dict[str, Any]]: + """Find actions by tags.""" + toolset = ComposioToolSet() + actions = toolset.find_actions_by_tags(*apps, tags=tags) + return [get_action_schema(action.name) for action in actions] diff --git a/agentstack/_tools/composio/config.json b/agentstack/_tools/composio/config.json new file mode 100644 index 00000000..cc6bbe7d --- /dev/null +++ b/agentstack/_tools/composio/config.json @@ -0,0 +1,18 @@ +{ + "name": "composio", + "url": "https://composio.dev/", + "category": "unified-apis", + "env": { + "COMPOSIO_API_KEY": null + }, + "tools": [ + "execute_action", + "get_action_schema", + "find_actions_by_use_case", + "find_actions_by_tags" + ], + "dependencies": [ + "composio>=1.0.0" + ], + "cta": "!!! Composio provides 150+ tools. Additional setup is required in agentstack/tools/composio/__init__.py" +} diff --git a/agentstack/_tools/directory_search/__init__.py b/agentstack/_tools/directory_search/__init__.py new file mode 100644 index 00000000..cf199910 --- /dev/null +++ b/agentstack/_tools/directory_search/__init__.py @@ -0,0 +1,42 @@ +"""Framework-agnostic directory search implementation using embedchain.""" + +from typing import Optional +from pathlib import Path +from embedchain.loaders.directory_loader import DirectoryLoader +import os + + +def search_directory(directory: str, query: str) -> str: + """ + Search through files in a directory using embedchain's DirectoryLoader. + + Args: + directory: Path to directory to search + query: Search query to find relevant content + + Returns: + str: Search results as a string + """ + loader = DirectoryLoader(config=dict(recursive=True)) + results = loader.search(directory, query) + return str(results) + + +def search_fixed_directory(query: str) -> str: + """ + Search through files in a preconfigured directory using embedchain's DirectoryLoader. + Uses DIRECTORY_SEARCH_TOOL_PATH environment variable. + + Args: + query: Search query to find relevant content + + Returns: + str: Search results as a string + + Raises: + ValueError: If DIRECTORY_SEARCH_TOOL_PATH environment variable is not set + """ + directory = os.getenv('DIRECTORY_SEARCH_TOOL_PATH') + if not directory: + raise ValueError("DIRECTORY_SEARCH_TOOL_PATH environment variable not set") + return search_directory(directory, query) diff --git a/agentstack/_tools/directory_search/config.json b/agentstack/_tools/directory_search/config.json new file mode 100644 index 00000000..6cbf314e --- /dev/null +++ b/agentstack/_tools/directory_search/config.json @@ -0,0 +1,12 @@ +{ + "name": "directory_search", + "category": "computer-control", + "description": "Search through files in a directory using embedchain's DirectoryLoader", + "env": { + "DIRECTORY_SEARCH_TOOL_PATH": null + }, + "dependencies": [ + "embedchain>=0.1.0" + ], + "tools": ["search_directory", "search_fixed_directory"] +} diff --git a/agentstack/_tools/exa/__init__.py b/agentstack/_tools/exa/__init__.py new file mode 100644 index 00000000..19db5782 --- /dev/null +++ b/agentstack/_tools/exa/__init__.py @@ -0,0 +1,32 @@ +import os +from exa_py import Exa + +# Check out our docs for more info! https://docs.exa.ai/ + +API_KEY = os.getenv('EXA_API_KEY') + + +def search_and_contents(question: str) -> str: + """ + Tool using Exa's Python SDK to run semantic search and return result highlights. + Args: + question: The search query or question to find information about + Returns: + Formatted string containing titles, URLs, and highlights from the search results + """ + exa = Exa(api_key=API_KEY) + + response = exa.search_and_contents( + question, type="neural", use_autoprompt=True, num_results=3, highlights=True + ) + + parsedResult = ''.join( + [ + f'{eachResult.title}' + f'{eachResult.url}' + f'{"".join(eachResult.highlights)}' + for (idx, eachResult) in enumerate(response.results) + ] + ) + + return parsedResult diff --git a/agentstack/tools/exa.json b/agentstack/_tools/exa/config.json similarity index 83% rename from agentstack/tools/exa.json rename to agentstack/_tools/exa/config.json index d8bc4679..4f6a4fbd 100644 --- a/agentstack/tools/exa.json +++ b/agentstack/_tools/exa/config.json @@ -2,10 +2,12 @@ "name": "exa", "url": "https://exa.ai", "category": "web-retrieval", - "packages": ["exa_py"], "env": { "EXA_API_KEY": null }, + "dependencies": [ + "exa-py>=1.7.0" + ], "tools": ["search_and_contents"], "cta": "Get your Exa API key at https://dashboard.exa.ai/api-keys" } \ No newline at end of file diff --git a/agentstack/_tools/example_config.json b/agentstack/_tools/example_config.json new file mode 100644 index 00000000..31bdbd08 --- /dev/null +++ b/agentstack/_tools/example_config.json @@ -0,0 +1,14 @@ +{ + "name": "example_tool", + "url": "https://www.youtube.com/watch?v=xvFZjo5PgG0", + "category": "example_category", + "env": { + "ENV_VAR_1": "foo", + "ENV_VAR_2": "bar" + }, + "dependencies": [ + "dep1>=0.4.20" + ], + "tools": ["function_to_be_exposed", "function_2_to_be_exposed"], + "cta": "A string to be printed after tool install instructing the user on next steps (often a link to implementation docs)" +} diff --git a/agentstack/_tools/file_read/__init__.py b/agentstack/_tools/file_read/__init__.py new file mode 100644 index 00000000..3fca8dcf --- /dev/null +++ b/agentstack/_tools/file_read/__init__.py @@ -0,0 +1,33 @@ +""" +Framework-agnostic implementation of file reading functionality. +""" + +from typing import Optional +from pathlib import Path + + +def read_file(file_path: str) -> str: + """Read contents of a file at the given path. + + Args: + file_path: Path to the file to read + + Returns: + str: The contents of the file as a string + + Raises: + FileNotFoundError: If the file does not exist + PermissionError: If the file cannot be accessed + Exception: For other file reading errors + """ + try: + path = Path(file_path).resolve() + if not path.exists(): + return f"Error: File not found at path {file_path}" + if not path.is_file(): + return f"Error: Path {file_path} is not a file" + + with open(path, "r", encoding="utf-8") as file: + return file.read() + except (FileNotFoundError, PermissionError, Exception) as e: + return f"Failed to read file {file_path}. Error: {str(e)}" diff --git a/agentstack/_tools/file_read/config.json b/agentstack/_tools/file_read/config.json new file mode 100644 index 00000000..1d3118ac --- /dev/null +++ b/agentstack/_tools/file_read/config.json @@ -0,0 +1,8 @@ +{ + "name": "file_read", + "category": "computer-control", + "tools": ["read_file"], + "description": "Read contents of files", + "url": "https://github.com/AgentOps-AI/AgentStack/tree/main/agentstack/tools/file_read", + "dependencies": [] +} diff --git a/agentstack/templates/crewai/tools/firecrawl_tool.py b/agentstack/_tools/firecrawl/__init__.py similarity index 87% rename from agentstack/templates/crewai/tools/firecrawl_tool.py rename to agentstack/_tools/firecrawl/__init__.py index 65c66c24..1f912b31 100644 --- a/agentstack/templates/crewai/tools/firecrawl_tool.py +++ b/agentstack/_tools/firecrawl/__init__.py @@ -1,11 +1,9 @@ -from crewai_tools import tool -from firecrawl import FirecrawlApp import os +from firecrawl import FirecrawlApp app = FirecrawlApp(api_key=os.getenv('FIRECRAWL_API_KEY')) -@tool def web_scrape(url: str): """ Scrape a url and return markdown. Use this to read a singular page and web_crawl only if you @@ -15,7 +13,6 @@ def web_scrape(url: str): return scrape_result -@tool def web_crawl(url: str): """ Scrape a url and crawl through other links from that page, scraping their contents. @@ -28,18 +25,12 @@ def web_crawl(url: str): """ crawl_status = app.crawl_url( - url, - params={ - 'limit': 100, - 'scrapeOptions': {'formats': ['markdown']} - }, - poll_interval=30 + url, params={'limit': 100, 'scrapeOptions': {'formats': ['markdown']}}, poll_interval=30 ) return crawl_status -@tool def retrieve_web_crawl(crawl_id: str): """ Retrieve the results of a previously started web crawl. Crawls take time to process @@ -47,4 +38,3 @@ def retrieve_web_crawl(crawl_id: str): will tell you if the crawl is finished. If it is not, wait some more time then try again. """ return app.check_crawl_status(crawl_id) - diff --git a/agentstack/tools/firecrawl.json b/agentstack/_tools/firecrawl/config.json similarity index 83% rename from agentstack/tools/firecrawl.json rename to agentstack/_tools/firecrawl/config.json index e85bca63..5dcf2748 100644 --- a/agentstack/tools/firecrawl.json +++ b/agentstack/_tools/firecrawl/config.json @@ -2,10 +2,12 @@ "name": "firecrawl", "url": "https://www.firecrawl.dev/", "category": "browsing", - "packages": ["firecrawl-py"], "env": { "FIRECRAWL_API_KEY": null }, + "dependencies": [ + "firecrawl-py>=1.6.4" + ], "tools": ["web_scrape", "web_crawl", "retrieve_web_crawl"], "cta": "Create an API key at https://www.firecrawl.dev/" } \ No newline at end of file diff --git a/agentstack/_tools/ftp/__init__.py b/agentstack/_tools/ftp/__init__.py new file mode 100644 index 00000000..3248f551 --- /dev/null +++ b/agentstack/_tools/ftp/__init__.py @@ -0,0 +1,59 @@ +import os +from ftplib import FTP + +HOST = os.getenv('FTP_HOST') +USER = os.getenv('FTP_USER') +PASSWORD = os.getenv("FTP_PASSWORD") +PATH = '/' + + +if not HOST: + raise Exception( + "Host domain has not been provided.\n Did you set the FTP_HOST in you project's .env file?" + ) + +if not USER: + raise Exception("User has not been provided.\n Did you set the FTP_USER in you project's .env file?") + +if not PASSWORD: + raise Exception( + "Password has not been provided.\n Did you set the FTP_PASSWORD in you project's .env file?" + ) + + +def upload_files(file_paths: list[str]): + """ + Upload a list of files to the FTP server. + + Args: + file_paths: A list of file paths to upload to the FTP server. + Returns: + bool: True if all files were uploaded successfully, False otherwise. + """ + + assert HOST and USER and PASSWORD # appease type checker + + result = True + # Loop through each file path in the list + for file_path in file_paths: + # Establish FTP connection + with FTP(HOST) as ftp: + try: + # Login to the server + ftp.login(user=USER, passwd=PASSWORD) + print(f"Connected to FTP server: {HOST}") + + # Change to the desired directory on the server + ftp.cwd(PATH) + + # Open the file in binary mode for reading + with open(file_path, 'rb') as file: + # Upload the file + ftp.storbinary(f'STOR {file_path}', file) + print(f"Successfully uploaded {file_path} to {PATH}") + + except Exception as e: + print(f"An error occurred: {e}") + result = False + + return result diff --git a/agentstack/tools/ftp.json b/agentstack/_tools/ftp/config.json similarity index 92% rename from agentstack/tools/ftp.json rename to agentstack/_tools/ftp/config.json index 81a41fe8..b60daa84 100644 --- a/agentstack/tools/ftp.json +++ b/agentstack/_tools/ftp/config.json @@ -1,7 +1,6 @@ { "name": "ftp", "category": "computer-control", - "packages": [], "env": { "FTP_HOST": null, "FTP_USER": null, diff --git a/agentstack/templates/crewai/tools/mem0_tool.py b/agentstack/_tools/mem0/__init__.py similarity index 89% rename from agentstack/templates/crewai/tools/mem0_tool.py rename to agentstack/_tools/mem0/__init__.py index 44a7d93a..e969626a 100644 --- a/agentstack/templates/crewai/tools/mem0_tool.py +++ b/agentstack/_tools/mem0/__init__.py @@ -1,15 +1,11 @@ +import os import json - -from crewai_tools import tool from mem0 import MemoryClient -from dotenv import load_dotenv -import os - -load_dotenv() # These functions can be extended by changing the user_id parameter # Memories are sorted by user_id + MEM0_API_KEY = os.getenv('MEM0_API_KEY') client = MemoryClient(api_key=MEM0_API_KEY) @@ -17,7 +13,7 @@ # "Potato is a vegetable" is not a memory # "My favorite food is potatoes" IS a memory -@tool("Write to Memory") + def write_to_memory(user_message: str) -> str: """ Writes data to the memory store for a user. The tool will decide what @@ -30,7 +26,6 @@ def write_to_memory(user_message: str) -> str: return json.dumps(result) -@tool("Read from Memory") def read_from_memory(query: str) -> str: """ Reads memories related to user based on a query. diff --git a/agentstack/tools/mem0.json b/agentstack/_tools/mem0/config.json similarity index 83% rename from agentstack/tools/mem0.json rename to agentstack/_tools/mem0/config.json index 919121b5..6ca85239 100644 --- a/agentstack/tools/mem0.json +++ b/agentstack/_tools/mem0/config.json @@ -2,10 +2,12 @@ "name": "mem0", "url": "https://github.com/mem0ai/mem0", "category": "storage", - "packages": ["mem0ai"], "env": { "MEM0_API_KEY": null }, + "dependencies": [ + "mem0ai>=0.1.35" + ], "tools": ["write_to_memory", "read_from_memory"], "cta": "Create your mem0 API key at https://mem0.ai/" } \ No newline at end of file diff --git a/agentstack/templates/crewai/tools/neon_tool.py b/agentstack/_tools/neon/__init__.py similarity index 93% rename from agentstack/templates/crewai/tools/neon_tool.py rename to agentstack/_tools/neon/__init__.py index a00e907d..e2430a1e 100644 --- a/agentstack/templates/crewai/tools/neon_tool.py +++ b/agentstack/_tools/neon/__init__.py @@ -1,17 +1,13 @@ import os -from crewai_tools import tool -from dotenv import load_dotenv from neon_api import NeonAPI import psycopg2 from psycopg2.extras import RealDictCursor -load_dotenv() NEON_API_KEY = os.getenv("NEON_API_KEY") neon_client = NeonAPI(api_key=NEON_API_KEY) -@tool("Create Neon Project and Database") def create_database(project_name: str) -> str: """ Creates a new Neon project. (this takes less than 500ms) @@ -30,7 +26,6 @@ def create_database(project_name: str) -> str: return f"Failed to create project: {str(e)}" -@tool("Execute SQL DDL") def execute_sql_ddl(connection_uri: str, command: str) -> str: """ Inserts data into a specified Neon database. @@ -53,7 +48,6 @@ def execute_sql_ddl(connection_uri: str, command: str) -> str: return f"Command succeeded" -@tool("Execute SQL DML") def run_sql_query(connection_uri: str, query: str) -> str: """ Inserts data into a specified Neon database. diff --git a/agentstack/tools/neon.json b/agentstack/_tools/neon/config.json similarity index 77% rename from agentstack/tools/neon.json rename to agentstack/_tools/neon/config.json index aa3ebc91..e5c59384 100644 --- a/agentstack/tools/neon.json +++ b/agentstack/_tools/neon/config.json @@ -2,10 +2,13 @@ "name": "neon", "category": "database", "url": "https://github.com/neondatabase/neon", - "packages": ["neon-api", "psycopg2-binary"], "env": { "NEON_API_KEY": null }, + "dependencies": [ + "neon-api>=0.1.5", + "psycopg2-binary" + ], "tools": ["create_database", "execute_sql_ddl", "run_sql_query"], "cta": "Create an API key at https://www.neon.tech" } \ No newline at end of file diff --git a/agentstack/templates/crewai/tools/open_interpreter_tool.py b/agentstack/_tools/open_interpreter/__init__.py similarity index 80% rename from agentstack/templates/crewai/tools/open_interpreter_tool.py rename to agentstack/_tools/open_interpreter/__init__.py index 55425b5f..8d922d49 100644 --- a/agentstack/templates/crewai/tools/open_interpreter_tool.py +++ b/agentstack/_tools/open_interpreter/__init__.py @@ -1,13 +1,12 @@ +import os from interpreter import interpreter -from crewai_tools import tool # 1. Configuration and Tools interpreter.auto_run = True -interpreter.llm.model = "gpt-4o" +interpreter.llm.model = os.getenv("OPEN_INTERPRETER_LLM_MODEL") -@tool def execute_code(code: str): """A tool to execute code using Open Interpreter. Returns the output of the code.""" result = interpreter.chat(f"execute this code with no changes: {code}") diff --git a/agentstack/tools/open_interpreter.json b/agentstack/_tools/open_interpreter/config.json similarity index 57% rename from agentstack/tools/open_interpreter.json rename to agentstack/_tools/open_interpreter/config.json index 238d035d..1e7e93a6 100644 --- a/agentstack/tools/open_interpreter.json +++ b/agentstack/_tools/open_interpreter/config.json @@ -2,6 +2,11 @@ "name": "open_interpreter", "url": "https://github.com/OpenInterpreter/open-interpreter", "category": "code-execution", - "packages": ["open-interpreter"], + "env": { + "OPEN_INTERPRETER_LLM_MODEL": "gpt-4o" + }, + "dependencies": [ + "open-interpreter>=0.3.7" + ], "tools": ["execute_code"] } \ No newline at end of file diff --git a/agentstack/templates/crewai/tools/perplexity_tool.py b/agentstack/_tools/perplexity/__init__.py similarity index 70% rename from agentstack/templates/crewai/tools/perplexity_tool.py rename to agentstack/_tools/perplexity/__init__.py index a5a361b5..6422a648 100644 --- a/agentstack/templates/crewai/tools/perplexity_tool.py +++ b/agentstack/_tools/perplexity/__init__.py @@ -1,15 +1,11 @@ import os - import requests -from crewai_tools import tool -from dotenv import load_dotenv -load_dotenv() url = "https://api.perplexity.ai/chat/completions" api_key = os.getenv("PERPLEXITY_API_KEY") -@tool + def query_perplexity(query: str): """ Use Perplexity to concisely search the internet and answer a query with up-to-date information. @@ -18,14 +14,8 @@ def query_perplexity(query: str): payload = { "model": "llama-3.1-sonar-small-128k-online", "messages": [ - { - "role": "system", - "content": "Be precise and concise." - }, - { - "role": "user", - "content": query - } + {"role": "system", "content": "Be precise and concise."}, + {"role": "user", "content": query}, ], # "max_tokens": "Optional", "temperature": 0.2, @@ -38,12 +28,9 @@ def query_perplexity(query: str): "top_k": 0, "stream": False, "presence_penalty": 0, - "frequency_penalty": 1 - } - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json" + "frequency_penalty": 1, } + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} response = requests.request("POST", url, json=payload, headers=headers) if response.status_code == 200 and response.text: diff --git a/agentstack/tools/perplexity.json b/agentstack/_tools/perplexity/config.json similarity index 78% rename from agentstack/tools/perplexity.json rename to agentstack/_tools/perplexity/config.json index 90630723..43a80f45 100644 --- a/agentstack/tools/perplexity.json +++ b/agentstack/_tools/perplexity/config.json @@ -5,5 +5,8 @@ "env": { "PERPLEXITY_API_KEY": null }, + "dependencies": [ + "requests>=2.30" + ], "tools": ["query_perplexity"] } \ No newline at end of file diff --git a/agentstack/_tools/stripe/__init__.py b/agentstack/_tools/stripe/__init__.py new file mode 100644 index 00000000..9c428f83 --- /dev/null +++ b/agentstack/_tools/stripe/__init__.py @@ -0,0 +1,79 @@ +from typing import Callable, Optional +import os, sys +from stripe_agent_toolkit.configuration import Configuration, is_tool_allowed +from stripe_agent_toolkit.api import StripeAPI +from stripe_agent_toolkit.tools import tools + +__all__ = [ + "create_payment_link", + "create_product", + "list_products", + "create_price", + "list_prices", +] + +STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY") + +if not STRIPE_SECRET_KEY: + raise Exception( + "Stripe Secret Key not found. Did you set the STRIPE_SECRET_KEY in you project's .env file?" + ) + +_configuration = Configuration( + { + "actions": { + "payment_links": { + "create": True, + }, + "products": { + "create": True, + "read": True, + }, + "prices": { + "create": True, + "read": True, + }, + } + } +) +client = StripeAPI( + secret_key=STRIPE_SECRET_KEY, + context=_configuration.get('context') or None, +) + + +def _create_tool_function(tool: dict) -> Callable: + """Dynamically create a tool function based on the tool schema.""" + # `tool` is not typed, but follows this schema: + # { + # "method": "create_customer", + # "name": "Create Customer", + # "description": CREATE_CUSTOMER_PROMPT, + # "args_schema": CreateCustomer, + # "actions": { + # "customers": { + # "create": True, + # } + # }, + # } + schema = tool['args_schema'] + + def func(**kwargs) -> str: + validated_data = schema(**kwargs) + return client.run(tool['method'], **validated_data.dict(exclude_unset=True)) + + func.__name__ = tool['method'] + func.__doc__ = f"{tool['name']}: \n{tool['description']}" + func.__annotations__ = { + 'return': str, + **{name: field.annotation for name, field in schema.model_fields.items()}, + } + return func + + +# Dynamically create tool functions based on the configuration and add them to the module. +for tool in tools: + if not is_tool_allowed(tool, _configuration): + continue + + setattr(sys.modules[__name__], tool['method'], _create_tool_function(tool)) diff --git a/agentstack/tools/stripe.json b/agentstack/_tools/stripe/config.json similarity index 55% rename from agentstack/tools/stripe.json rename to agentstack/_tools/stripe/config.json index 91a73aae..89b18366 100644 --- a/agentstack/tools/stripe.json +++ b/agentstack/_tools/stripe/config.json @@ -2,11 +2,19 @@ "name": "stripe", "url": "https://github.com/stripe/agent-toolkit", "category": "application-specific", - "packages": ["stripe-agent-toolkit", "stripe"], "env": { "STRIPE_SECRET_KEY": null }, - "tools_bundled": true, - "tools": ["stripe_tools"], + "dependencies": [ + "stripe-agent-toolkit==0.2.0", + "stripe>=11.0.0" + ], + "tools": [ + "create_payment_link", + "create_product", + "list_products", + "create_price", + "list_prices" + ], "cta": "🔑 Create your Stripe API key here: https://dashboard.stripe.com/account/apikeys" } \ No newline at end of file diff --git a/agentstack/_tools/vision/__init__.py b/agentstack/_tools/vision/__init__.py new file mode 100644 index 00000000..e491153a --- /dev/null +++ b/agentstack/_tools/vision/__init__.py @@ -0,0 +1,70 @@ +"""Vision tool for analyzing images using OpenAI's Vision API.""" + +import base64 +from typing import Optional +import requests +from openai import OpenAI + +__all__ = ["analyze_image"] + + +def analyze_image(image_path_url: str) -> str: + """ + Analyze an image using OpenAI's Vision API. + + Args: + image_path_url: Local path or URL to the image + + Returns: + str: Description of the image contents + """ + client = OpenAI() + + if not image_path_url: + return "Image Path or URL is required." + + if "http" in image_path_url: + return _analyze_web_image(client, image_path_url) + return _analyze_local_image(client, image_path_url) + + +def _analyze_web_image(client: OpenAI, image_path_url: str) -> str: + response = client.chat.completions.create( + model="gpt-4-vision-preview", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + {"type": "image_url", "image_url": {"url": image_path_url}}, + ], + } + ], + max_tokens=300, + ) + return response.choices[0].message.content # type: ignore[return-value] + + +def _analyze_local_image(client: OpenAI, image_path: str) -> str: + base64_image = _encode_image(image_path) + headers = {"Content-Type": "application/json", "Authorization": f"Bearer {client.api_key}"} + payload = { + "model": "gpt-4-vision-preview", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}, + ], + } + ], + "max_tokens": 300, + } + response = requests.post("https://api.openai.com/v1/chat/completions", headers=headers, json=payload) + return response.json()["choices"][0]["message"]["content"] + + +def _encode_image(image_path: str) -> str: + with open(image_path, "rb") as image_file: + return base64.b64encode(image_file.read()).decode("utf-8") diff --git a/agentstack/_tools/vision/config.json b/agentstack/_tools/vision/config.json new file mode 100644 index 00000000..37963f0d --- /dev/null +++ b/agentstack/_tools/vision/config.json @@ -0,0 +1,12 @@ +{ + "name": "vision", + "category": "image-analysis", + "env": { + "OPENAI_API_KEY": null + }, + "dependencies": [ + "openai>=1.0.0", + "requests>=2.31.0" + ], + "tools": ["analyze_image"] +} diff --git a/agentstack/cli/__init__.py b/agentstack/cli/__init__.py index d4d83bbc..98d3604f 100644 --- a/agentstack/cli/__init__.py +++ b/agentstack/cli/__init__.py @@ -1,4 +1,4 @@ from .cli import init_project_builder, configure_default_model, export_template, welcome_message from .init import init_project from .tools import list_tools, add_tool -from .run import run_project \ No newline at end of file +from .run import run_project diff --git a/agentstack/cli/tools.py b/agentstack/cli/tools.py index 2ea7adb5..6b892857 100644 --- a/agentstack/cli/tools.py +++ b/agentstack/cli/tools.py @@ -3,7 +3,7 @@ import inquirer from agentstack.utils import term_color from agentstack import generation -from agentstack.tools import get_all_tools +from agentstack._tools import get_all_tools from agentstack.agents import get_all_agents diff --git a/agentstack/frameworks/__init__.py b/agentstack/frameworks/__init__.py index 0da933ab..8ca3d560 100644 --- a/agentstack/frameworks/__init__.py +++ b/agentstack/frameworks/__init__.py @@ -1,22 +1,26 @@ -from typing import Optional, Protocol +from typing import Optional, Protocol, Callable from types import ModuleType from importlib import import_module from pathlib import Path from agentstack import conf from agentstack.exceptions import ValidationError from agentstack.utils import get_framework -from agentstack.tools import ToolConfig +from agentstack._tools import ToolConfig from agentstack.agents import AgentConfig from agentstack.tasks import TaskConfig CREWAI = 'crewai' -SUPPORTED_FRAMEWORKS = [CREWAI, ] +SUPPORTED_FRAMEWORKS = [ + CREWAI, +] + class FrameworkModule(Protocol): """ Protocol spec for a framework implementation module. """ + ENTRYPOINT: Path """ Relative path to the entrypoint file for the framework in the user's project. @@ -48,6 +52,12 @@ def remove_tool(self, tool: ToolConfig, agent_name: str) -> None: """ ... + def get_tool_callables(self, tool_name: str) -> list[Callable]: + """ + Get a tool by name and return it as a list of framework-native callables. + """ + ... + def get_agent_names(self) -> list[str]: """ Get a list of agent names in the user's project. @@ -88,59 +98,74 @@ def get_framework_module(framework: str) -> FrameworkModule: except ImportError: raise Exception(f"Framework {framework} could not be imported.") + def get_entrypoint_path(framework: str) -> Path: """ Get the path to the entrypoint file for a framework. """ return conf.PATH / get_framework_module(framework).ENTRYPOINT + def validate_project(): """ Validate that the user's project is ready to run. """ return get_framework_module(get_framework()).validate_project() + def add_tool(tool: ToolConfig, agent_name: str): """ - Add a tool to the user's project. + Add a tool to the user's project. The tool will have aready been installed in the user's application and have all dependencies installed. We're just handling code generation here. """ return get_framework_module(get_framework()).add_tool(tool, agent_name) + def remove_tool(tool: ToolConfig, agent_name: str): """ Remove a tool from the user's project. """ return get_framework_module(get_framework()).remove_tool(tool, agent_name) + +def get_tool_callables(tool_name: str) -> list[Callable]: + """ + Get a tool by name and return it as a list of framework-native callables. + """ + return get_framework_module(get_framework()).get_tool_callables(tool_name) + + def get_agent_names() -> list[str]: """ Get a list of agent names in the user's project. """ return get_framework_module(get_framework()).get_agent_names() + def get_agent_tool_names(agent_name: str) -> list[str]: """ Get a list of tool names in the user's project. """ return get_framework_module(get_framework()).get_agent_tool_names(agent_name) + def add_agent(agent: AgentConfig): """ Add an agent to the user's project. """ return get_framework_module(get_framework()).add_agent(agent) + def add_task(task: TaskConfig): """ Add a task to the user's project. """ return get_framework_module(get_framework()).add_task(task) + def get_task_names() -> list[str]: """ Get a list of task names in the user's project. """ return get_framework_module(get_framework()).get_task_names() - diff --git a/agentstack/frameworks/crewai.py b/agentstack/frameworks/crewai.py index 69d21fb7..911bb728 100644 --- a/agentstack/frameworks/crewai.py +++ b/agentstack/frameworks/crewai.py @@ -1,13 +1,17 @@ -from typing import Optional, Any +from typing import Optional, Any, Callable from pathlib import Path import ast from agentstack import conf from agentstack.exceptions import ValidationError -from agentstack.tools import ToolConfig +from agentstack._tools import ToolConfig from agentstack.tasks import TaskConfig from agentstack.agents import AgentConfig from agentstack.generation import asttools +try: + from crewai.tools import tool as _crewai_tool_decorator +except ImportError: + raise ValidationError("Could not import `crewai`. Is this an AgentStack CrewAI project?") ENTRYPOINT: Path = Path('src/crew.py') @@ -113,7 +117,7 @@ def get_agent_tools(self, agent_name: str) -> ast.List: """ Get the tools used by an agent as AST nodes. - Tool definitons are inside of the methods marked with an `@agent` decorator. + Tool definitions are inside of the methods marked with an `@agent` decorator. The method returns a new class instance with the tools as a list of callables under the kwarg `tools`. """ @@ -140,11 +144,49 @@ def get_agent_tools(self, agent_name: str) -> ast.List: return tools_kwarg.value + def get_agent_tool_nodes(self, agent_name: str) -> list[ast.Starred]: + """ + Get a list of all ast nodes that define agentstack tools used by the agent. + """ + tool_nodes: list[ast.Starred] = [] + agent_tools_node = self.get_agent_tools(agent_name) + for node in agent_tools_node.elts: + try: + # we need to find nodes that look like: + # `*agentstack.tools['tool_name']` + assert isinstance(node, ast.Starred) + assert isinstance(node.value, ast.Subscript) + assert isinstance(node.value.slice, ast.Constant) + name_node = node.value.value + assert isinstance(name_node, ast.Attribute) + assert isinstance(name_node.value, ast.Name) + assert name_node.value.id == 'agentstack' + assert name_node.attr == 'tools' + + # This is a starred subscript node referencing agentstack.tools with + # a string slice, so it must be an agentstack tool + tool_nodes.append(node) + except AssertionError: + continue # not a matched node; that's ok + return tool_nodes + + def get_agent_tool_names(self, agent_name: str) -> list[str]: + """ + Get a list of all tools used by the agent. + + Tools are identified by the item name of an `agentstack.tools` attribute node. + """ + tool_names: list[str] = [] + for node in self.get_agent_tool_nodes(agent_name): + # ignore type checking here since `get_agent_tool_nodes` is exhaustive + tool_names.append(node.value.slice.value) # type: ignore[attr-defined] + return tool_names + def add_agent_tools(self, agent_name: str, tool: ToolConfig): """ Add new tools to be used by an agent. - Tool definitons are inside of the methods marked with an `@agent` decorator. + Tool definitions are inside of the methods marked with an `@agent` decorator. The method returns a new class instance with the tools as a list of callables under the kwarg `tools`. """ @@ -156,21 +198,17 @@ def add_agent_tools(self, agent_name: str, tool: ToolConfig): existing_elts: list[ast.expr] = existing_node.elts new_tool_nodes: list[ast.expr] = [] - for tool_name in tool.tools: - # TODO there is definitely a better way to do this. We can't use - # a `set` becasue the ast nodes are unique objects. - _found = False - for elt in existing_elts: - if str(asttools.get_node_value(elt)) == tool_name: - _found = True - break # skip if the tool is already in the list - - if not _found: - # This prefixes the tool name with the 'tools' module - node: ast.expr = asttools.create_attribute('tools', tool_name) - if tool.tools_bundled: # Splat the variable if it's bundled - node = ast.Starred(value=node, ctx=ast.Load()) - existing_elts.append(node) + if not tool.name in self.get_agent_tool_names(agent_name): + # we need to create a node that looks like: + # `*agentstack.tools['tool_name']` + # we always get a list of callables from the `agentstack.tools` module, + # so we need to wrap the node in a `Starred` node to unpack it. + node = ast.Subscript( + value=asttools.create_attribute('agentstack', 'tools'), + slice=ast.Constant(tool.name), + ctx=ast.Load(), + ) + existing_elts.append(ast.Starred(value=node, ctx=ast.Load())) new_node = ast.List(elts=existing_elts, ctx=ast.Load()) start, end = self.get_node_range(existing_node) @@ -184,19 +222,12 @@ def remove_agent_tools(self, agent_name: str, tool: ToolConfig): start, end = self.get_node_range(existing_node) # modify the existing node to remove any matching tools - for tool_name in tool.tools: - for node in existing_node.elts: - if isinstance(node, ast.Starred): - if isinstance(node.value, ast.Attribute): - attr_name = node.value.attr - else: - continue # not an attribute node - elif isinstance(node, ast.Attribute): - attr_name = node.attr - else: - continue # not an attribute node - if attr_name == tool_name: - existing_node.elts.remove(node) + # we're referencing the internal node list from two directions here, + # so it's important that the node tree doesn't get re-parsed in between + for node in self.get_agent_tool_nodes(agent_name): + # ignore type checking here since `get_agent_tool_nodes` is exhaustive + if tool.name == node.value.slice.value: # type: ignore[attr-defined] + existing_node.elts.remove(node) self.edit_node_range(start, end, existing_node) @@ -204,7 +235,7 @@ def remove_agent_tools(self, agent_name: str, tool: ToolConfig): def validate_project() -> None: """ Validate that a CrewAI project is ready to run. - Raises an `agentstack.VaidationError` if the project is not valid. + Raises an `agentstack.ValidationError` if the project is not valid. """ try: crew_file = CrewFile(conf.PATH / ENTRYPOINT) @@ -267,8 +298,7 @@ def get_agent_tool_names(agent_name: str) -> list[Any]: Get a list of tools used by an agent. """ with CrewFile(conf.PATH / ENTRYPOINT) as crew_file: - tools = crew_file.get_agent_tools(agent_name) - return [asttools.get_node_value(node) for node in tools.elts] + return crew_file.get_agent_tool_names(agent_name) def add_agent(agent: AgentConfig) -> None: @@ -294,3 +324,20 @@ def remove_tool(tool: ToolConfig, agent_name: str): """ with CrewFile(conf.PATH / ENTRYPOINT) as crew_file: crew_file.remove_agent_tools(agent_name, tool) + + +def get_tool_callables(tool_name: str) -> list[Callable]: + """ + Get a tool implementations for use directly by a CrewAI agent. + """ + tool_funcs = [] + tool_config = ToolConfig.from_tool_name(tool_name) + for tool_func_name in tool_config.tools: + tool_func = getattr(tool_config.module, tool_func_name) + + assert callable(tool_func), f"Tool function {tool_func_name} is not callable." + assert tool_func.__doc__, f"Tool function {tool_func_name} is missing a docstring." + + # apply the CrewAI tool decorator to the tool function + tool_funcs.append(_crewai_tool_decorator(tool_func)) + return tool_funcs diff --git a/agentstack/generation/__init__.py b/agentstack/generation/__init__.py index 477b8999..ad0b3c90 100644 --- a/agentstack/generation/__init__.py +++ b/agentstack/generation/__init__.py @@ -1,4 +1,4 @@ from .agent_generation import add_agent from .task_generation import add_task from .tool_generation import add_tool, remove_tool -from .files import EnvFile, ProjectFile \ No newline at end of file +from .files import EnvFile, ProjectFile diff --git a/agentstack/generation/files.py b/agentstack/generation/files.py index 678a16f5..058a3d33 100644 --- a/agentstack/generation/files.py +++ b/agentstack/generation/files.py @@ -1,4 +1,5 @@ from typing import Optional, Union +import string import os, sys import string from pathlib import Path diff --git a/agentstack/generation/tool_generation.py b/agentstack/generation/tool_generation.py index 11eac3fe..2076e4ee 100644 --- a/agentstack/generation/tool_generation.py +++ b/agentstack/generation/tool_generation.py @@ -1,83 +1,16 @@ -import os -import sys +import os, sys from typing import Optional -from pathlib import Path -import shutil -import ast - -from agentstack import conf -from agentstack import log +from agentstack import conf, log from agentstack.conf import ConfigFile from agentstack.exceptions import ValidationError from agentstack import frameworks from agentstack import packaging -from agentstack.tools import ToolConfig +from agentstack.utils import term_color +from agentstack._tools import ToolConfig from agentstack.generation import asttools from agentstack.generation.files import EnvFile -# This is the filename of the location of tool imports in the user's project. -TOOLS_INIT_FILENAME: Path = Path("src/tools/__init__.py") - - -class ToolsInitFile(asttools.File): - """ - Modifiable AST representation of the tools init file. - - Use it as a context manager to make and save edits: - ```python - with ToolsInitFile(filename) as tools_init: - tools_init.add_import_for_tool(...) - ``` - """ - - def get_import_for_tool(self, tool: ToolConfig) -> Optional[ast.ImportFrom]: - """ - Get the import statement for a tool. - raises a ValidationError if the tool is imported multiple times. - """ - all_imports = asttools.get_all_imports(self.tree) - tool_imports = [i for i in all_imports if tool.module_name == i.module] - - if len(tool_imports) > 1: - raise ValidationError(f"Multiple imports for tool {tool.name} found in {self.filename}") - - try: - return tool_imports[0] - except IndexError: - return None - - def add_import_for_tool(self, tool: ToolConfig, framework: str): - """ - Add an import for a tool. - raises a ValidationError if the tool is already imported. - """ - tool_import = self.get_import_for_tool(tool) - if tool_import: - raise ValidationError(f"Tool {tool.name} already imported in {self.filename}") - - try: - last_import = asttools.get_all_imports(self.tree)[-1] - start, end = self.get_node_range(last_import) - except IndexError: - start, end = 0, 0 # No imports in the file - - import_statement = tool.get_import_statement(framework) - self.edit_node_range(end, end, f"\n{import_statement}") - - def remove_import_for_tool(self, tool: ToolConfig, framework: str): - """ - Remove an import for a tool. - raises a ValidationError if the tool is not imported. - """ - tool_import = self.get_import_for_tool(tool) - if not tool_import: - raise ValidationError(f"Tool {tool.name} not imported in {self.filename}") - - start, end = self.get_node_range(tool_import) - self.edit_node_range(start, end, "") - - def add_tool(tool_name: str, agents: Optional[list[str]] = []): agentstack_config = ConfigFile() tool = ToolConfig.from_tool_name(tool_name) @@ -85,19 +18,8 @@ def add_tool(tool_name: str, agents: Optional[list[str]] = []): if tool_name in agentstack_config.tools: log.notify(f'Tool {tool_name} is already installed') else: # handle install - tool_file_path = tool.get_impl_file_path(agentstack_config.framework) - - if tool.packages: - packaging.install(' '.join(tool.packages)) - - # Move tool from package to project - shutil.copy(tool_file_path, conf.PATH / f'src/tools/{tool.module_name}.py') - - try: # Edit the user's project tool init file to include the tool - with ToolsInitFile(conf.PATH / TOOLS_INIT_FILENAME) as tools_init: - tools_init.add_import_for_tool(tool, agentstack_config.framework) - except ValidationError as e: - log.error(f"Error adding tool:\n{e}") + if tool.dependencies: + packaging.install(' '.join(tool.dependencies)) if tool.env: # add environment variables which don't exist with EnvFile() as env: @@ -131,21 +53,11 @@ def remove_tool(tool_name: str, agents: Optional[list[str]] = []): if tool_name not in agentstack_config.tools: raise ValidationError(f'Tool {tool_name} is not installed') + # TODO ensure other agents are not using the tool tool = ToolConfig.from_tool_name(tool_name) - if tool.packages: - packaging.remove(' '.join(tool.packages)) - - # TODO ensure that other agents in the project are not using the tool. - try: - os.remove(conf.PATH / f'src/tools/{tool.module_name}.py') - except FileNotFoundError: - log.warning(f'"src/tools/{tool.module_name}.py" not found') - - try: # Edit the user's project tool init file to exclude the tool - with ToolsInitFile(conf.PATH / TOOLS_INIT_FILENAME) as tools_init: - tools_init.remove_import_for_tool(tool, agentstack_config.framework) - except ValidationError as e: # continue with removal - log.error(f"Error removing tool {tool_name} from `tools/__init__.py`:\n{e}") + if tool.dependencies: + for dependency in tool.dependencies: + packaging.remove(dependency) # Edit the framework entrypoint file to exclude the tool in the agent definition if not agents: # If no agents are specified, remove the tool from all agents diff --git a/agentstack/packaging.py b/agentstack/packaging.py index 2b1104bf..e45de5c6 100644 --- a/agentstack/packaging.py +++ b/agentstack/packaging.py @@ -4,6 +4,7 @@ import re import subprocess import select +from packaging.requirements import Requirement from agentstack import conf, log @@ -56,6 +57,8 @@ def on_error(line: str): def remove(package: str): """Uninstall a package with `uv`.""" + # If `package` has been provided with a version, it will be stripped. + requirement = Requirement(package) # TODO it may be worth considering removing unused sub-dependencies as well def on_progress(line: str): @@ -66,7 +69,7 @@ def on_error(line: str): log.error(f"uv: [error]\n {line.strip()}") _wrap_command_with_callbacks( - [get_uv_bin(), 'remove', '--python', '.venv/bin/python', package], + [get_uv_bin(), 'remove', '--python', '.venv/bin/python', requirement.name], on_progress=on_progress, on_error=on_error, ) diff --git a/agentstack/templates/crewai/tools/browserbase_tool.py b/agentstack/templates/crewai/tools/browserbase_tool.py deleted file mode 100644 index d92f07a0..00000000 --- a/agentstack/templates/crewai/tools/browserbase_tool.py +++ /dev/null @@ -1,3 +0,0 @@ -from crewai_tools import BrowserbaseLoadTool - -browserbase = BrowserbaseLoadTool(text_content=True) \ No newline at end of file diff --git a/agentstack/templates/crewai/tools/code_interpreter_tool.py b/agentstack/templates/crewai/tools/code_interpreter_tool.py deleted file mode 100644 index f56f6497..00000000 --- a/agentstack/templates/crewai/tools/code_interpreter_tool.py +++ /dev/null @@ -1,3 +0,0 @@ -from crewai_tools import CodeInterpreterTool - -code_interpreter = CodeInterpreterTool() \ No newline at end of file diff --git a/agentstack/templates/crewai/tools/composio_tool.py b/agentstack/templates/crewai/tools/composio_tool.py deleted file mode 100644 index fde2fc88..00000000 --- a/agentstack/templates/crewai/tools/composio_tool.py +++ /dev/null @@ -1,8 +0,0 @@ -from composio_crewai import ComposioToolSet, App - -composio_tools = ComposioToolSet().get_tools(apps=[App.CODEINTERPRETER]) -# composio_tools = ComposioToolSet().get_tools(apps=[App.GITHUB]) -# etc - -# change App.CODEINTERPRETER to be the app you want to use -# For more info on tool selection, see https://docs.agentstack.sh/tools/tool/composio diff --git a/agentstack/templates/crewai/tools/directory_search_tool.py b/agentstack/templates/crewai/tools/directory_search_tool.py deleted file mode 100644 index ef15362a..00000000 --- a/agentstack/templates/crewai/tools/directory_search_tool.py +++ /dev/null @@ -1,25 +0,0 @@ -from crewai_tools import DirectorySearchTool - -# dir_search_tool = DirectorySearchTool( -# config=dict( -# llm=dict( -# provider="ollama", -# config=dict( -# model="llama2", -# # temperature=0.5, -# # top_p=1, -# # stream=true, -# ), -# ), -# embedder=dict( -# provider="google", -# config=dict( -# model="models/embedding-001", -# task_type="retrieval_document", -# # title="Embeddings", -# ), -# ), -# ) -# ) - -dir_search_tool = DirectorySearchTool() \ No newline at end of file diff --git a/agentstack/templates/crewai/tools/exa_tool.py b/agentstack/templates/crewai/tools/exa_tool.py deleted file mode 100644 index 9eb2e194..00000000 --- a/agentstack/templates/crewai/tools/exa_tool.py +++ /dev/null @@ -1,35 +0,0 @@ -from crewai_tools import tool -from exa_py import Exa -from dotenv import load_dotenv -import os - -load_dotenv() - -# Check out our docs for more info! https://docs.exa.ai/ -@tool("Exa search and get contents") -def search_and_contents(question: str) -> str: - """ - Tool using Exa's Python SDK to run semantic search and return result highlights. - Args: - question: The search query or question to find information about - Returns: - Formatted string containing titles, URLs, and highlights from the search results - """ - exa = Exa(api_key=os.getenv('EXA_API_KEY')) - - response = exa.search_and_contents( - question, - type="neural", - use_autoprompt=True, - num_results=3, - highlights=True - ) - - parsedResult = ''.join([ - f'{eachResult.title}' - f'{eachResult.url}' - f'{"".join(eachResult.highlights)}' - for (idx, eachResult) in enumerate(response.results) - ]) - - return parsedResult diff --git a/agentstack/templates/crewai/tools/file_read_tool.py b/agentstack/templates/crewai/tools/file_read_tool.py deleted file mode 100644 index 2f6b5bb2..00000000 --- a/agentstack/templates/crewai/tools/file_read_tool.py +++ /dev/null @@ -1,3 +0,0 @@ -from crewai_tools import FileReadTool - -file_read_tool = FileReadTool() \ No newline at end of file diff --git a/agentstack/templates/crewai/tools/ftp_tool.py b/agentstack/templates/crewai/tools/ftp_tool.py deleted file mode 100644 index cb80f0ff..00000000 --- a/agentstack/templates/crewai/tools/ftp_tool.py +++ /dev/null @@ -1,42 +0,0 @@ -from ftplib import FTP -from crewai_tools import tool -from dotenv import load_dotenv -import os - -load_dotenv() - -# FTP server details -ftp_host = os.getenv('FTP_HOST') -ftp_user = os.getenv('FTP_USER') -ftp_p = os.getenv("FTP_PASSWORD") -ftp_path = '/' - - -@tool -def upload_files(file_paths: list[str]): - """Upload a list of files to the FTP server.""" - - result = True - # Loop through each file path in the list - for file_path in file_paths: - # Establish FTP connection - with FTP(ftp_host) as ftp: - try: - # Login to the server - ftp.login(user=ftp_user, passwd=ftp_p) - print(f"Connected to FTP server: {ftp_host}") - - # Change to the desired directory on the server - ftp.cwd(ftp_path) - - # Open the file in binary mode for reading - with open(file_path, 'rb') as file: - # Upload the file - ftp.storbinary(f'STOR {file_path}', file) - print(f"Successfully uploaded {file_path} to {ftp_path}") - - except Exception as e: - print(f"An error occurred: {e}") - result = False - - return result diff --git a/agentstack/templates/crewai/tools/stripe_tool.py b/agentstack/templates/crewai/tools/stripe_tool.py deleted file mode 100644 index 864ea104..00000000 --- a/agentstack/templates/crewai/tools/stripe_tool.py +++ /dev/null @@ -1,24 +0,0 @@ -import os -from stripe_agent_toolkit.crewai.toolkit import StripeAgentToolkit -from dotenv import load_dotenv -load_dotenv() - -stripe_tools = StripeAgentToolkit( - secret_key=os.getenv("STRIPE_SECRET_KEY"), - configuration={ - "actions": { - "payment_links": { - "create": True, - "read": True, - "update": False - }, - "products": { - "create": True, - "update": True - }, - "prices": { - "create": True, - "update": True - }, - } - }).get_tools() diff --git a/agentstack/templates/crewai/tools/vision_tool.py b/agentstack/templates/crewai/tools/vision_tool.py deleted file mode 100644 index 927ca440..00000000 --- a/agentstack/templates/crewai/tools/vision_tool.py +++ /dev/null @@ -1,3 +0,0 @@ -from crewai_tools import VisionTool - -vision_tool = VisionTool() \ No newline at end of file diff --git a/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/crew.py b/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/crew.py index 4c0b2555..7e5ca83b 100644 --- a/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/crew.py +++ b/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/crew.py @@ -1,6 +1,6 @@ from crewai import Agent, Crew, Process, Task from crewai.project import CrewBase, agent, crew, task -import tools +import agentstack @CrewBase class {{cookiecutter.project_metadata.project_name|replace('-', '')|replace('_', '')|capitalize}}Crew(): diff --git a/agentstack/tools.py b/agentstack/tools.py index 1feec0af..e69de29b 100644 --- a/agentstack/tools.py +++ b/agentstack/tools.py @@ -1,70 +0,0 @@ -from typing import Optional -import os -import sys -from pathlib import Path -import pydantic -from agentstack.exceptions import ValidationError -from agentstack.utils import get_package_path, open_json_file, term_color - - -class ToolConfig(pydantic.BaseModel): - """ - This represents the configuration data for a tool. - It parses and validates the `config.json` file for a tool. - """ - - name: str - category: str - tools: list[str] - url: Optional[str] = None - tools_bundled: bool = False - cta: Optional[str] = None - env: Optional[dict] = None - packages: Optional[list[str]] = None - post_install: Optional[str] = None - post_remove: Optional[str] = None - - @classmethod - def from_tool_name(cls, name: str) -> 'ToolConfig': - path = get_package_path() / f'tools/{name}.json' - if not os.path.exists(path): - raise ValidationError(f'No known agentstack tool: {name}') - return cls.from_json(path) - - @classmethod - def from_json(cls, path: Path) -> 'ToolConfig': - data = open_json_file(path) - try: - return cls(**data) - except pydantic.ValidationError as e: - error_str = "Error validating tool config:\n" - for error in e.errors(): - 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}") - - @property - def module_name(self) -> str: - return f"{self.name}_tool" - - def get_import_statement(self, framework: str) -> str: - return f"from .{self.module_name} import {', '.join(self.tools)}" - - def get_impl_file_path(self, framework: str) -> Path: - return get_package_path() / f'templates/{framework}/tools/{self.module_name}.py' - - -def get_all_tool_paths() -> list[Path]: - paths = [] - tools_dir = get_package_path() / 'tools' - for file in tools_dir.iterdir(): - if file.is_file() and file.suffix == '.json': - paths.append(file) - return paths - - -def get_all_tool_names() -> list[str]: - return [path.stem for path in get_all_tool_paths()] - - -def get_all_tools() -> list[ToolConfig]: - return [ToolConfig.from_json(path) for path in get_all_tool_paths()] diff --git a/agentstack/tools/agent_connect.json b/agentstack/tools/agent_connect.json deleted file mode 100644 index e0f41702..00000000 --- a/agentstack/tools/agent_connect.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "agent_connect", - "url": "https://github.com/chgaowei/AgentConnect", - "category": "network-protocols", - "packages": ["agent-connect"], - "env": { - "HOST_DOMAIN": null, - "HOST_PORT": 80, - "HOST_WS_PATH": "/ws", - "DID_DOCUMENT_PATH": null, - "SSL_CERT_PATH": null, - "SSL_KEY_PATH": null - }, - "tools": ["send_message", "receive_message"] -} diff --git a/agentstack/tools/code_interpreter.json b/agentstack/tools/code_interpreter.json deleted file mode 100644 index d3de4a94..00000000 --- a/agentstack/tools/code_interpreter.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "code_interpreter", - "category": "code-execution", - "tools": ["code_interpreter"] -} \ No newline at end of file diff --git a/agentstack/tools/composio.json b/agentstack/tools/composio.json deleted file mode 100644 index 8af447ee..00000000 --- a/agentstack/tools/composio.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "composio", - "url": "https://composio.dev/", - "category": "unified-apis", - "packages": ["composio-crewai"], - "env": { - "COMPOSIO_API_KEY": null - }, - "tools": ["composio_tools"], - "tools_bundled": true, - "cta": "!!! Composio provides 150+ tools. Additional setup is required in src/tools/composio_tool.py" -} \ No newline at end of file diff --git a/agentstack/tools/directory_search.json b/agentstack/tools/directory_search.json deleted file mode 100644 index b412d03b..00000000 --- a/agentstack/tools/directory_search.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "directory_search", - "url": "https://github.com/crewAIInc/crewAI-tools/tree/main/crewai_tools/tools/directory_search_tool", - "category": "computer-control", - "tools": ["dir_search_tool"] -} \ No newline at end of file diff --git a/agentstack/tools/file_read.json b/agentstack/tools/file_read.json deleted file mode 100644 index f1b072eb..00000000 --- a/agentstack/tools/file_read.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "file_read", - "url": "https://github.com/crewAIInc/crewAI-tools/tree/main/crewai_tools/tools/file_read_tool", - "category": "computer-control", - "tools": ["file_read_tool"] -} \ No newline at end of file diff --git a/agentstack/tools/vision.json b/agentstack/tools/vision.json deleted file mode 100644 index e26e4e43..00000000 --- a/agentstack/tools/vision.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "vision", - "category": "vision", - "tools": ["vision_tool"] -} \ No newline at end of file diff --git a/agentstack/tools/~README.md b/agentstack/tools/~README.md deleted file mode 100644 index 9fedae09..00000000 --- a/agentstack/tools/~README.md +++ /dev/null @@ -1,35 +0,0 @@ -Tool Configuration Files -======================== -Tools are configured for installation & removal using JSON files in this directory. - -## Parameters - -### `name` (string) [required] -The name of the tool in snake_case. This is used to identify the tool in the system. - -### `tools` (list) [required] -List of public methods that are accessible in the tool implementation. - -### `tools_bundled` (boolean) [optional] -Indicates that the tool file exports a `list` of tools. Specify the variable name -of the list in the `tools` field. - -### `cta` (string) [optional] -String to print in the terminal when the tool is installed that provides a call to action. - -### `env` (list[dict(str, Any)]) [optional] -Definitions for environment variables that will be appended to the local `.env` file. -This is a list of key-value pairs ie. `[{"ENV_VAR": "value"}, ...]`. -In cases where the user is expected to provide a value, the value is `"..."`. - -### `packages` (list) [optional] -A list of package names to install. These are the names of the packages that will -be installed and removed by the package manager. - -### `post_install` (string) [optional] -Shell command that will be executed after packages have been installed and environment -variables have been set. - -### `post_remove` (string) [optional] -Shell command that will be executed after the tool has been removed. - diff --git a/docs/contributing/adding-tools.mdx b/docs/contributing/adding-tools.mdx index 53c42b94..040949f0 100644 --- a/docs/contributing/adding-tools.mdx +++ b/docs/contributing/adding-tools.mdx @@ -9,13 +9,14 @@ Adding tools is easy once you understand the project structure. A few things nee - - Create a new tool config at `agentstack/tools/.json` + - Create a new tool config at `agentstack/_tools//config.json` - As an example, look at our [tool config fixture](https://github.com/AgentOps-AI/AgentStack/blob/main/tests/fixtures/tool_config_max.json) - AgentStack uses this to know what code to insert where. Follow the structure to add your tool. - - - In `agentstack/templates//tools`, you'll see other implementations of tools. - - Build your tool implementation for that framework. This file will be inserted in the user's project. + + - In `agentstack/_tools`, you'll see other implementations of tools. + - Create a file `agentstack/_tools//__init__.py`, + - Build your tool implementation simply as python functions in this file. The functions that are to be exposed to the agent as a *tool* should contain detailed docstrings and have typed parameters. - The tools that are exported from this file should be listed in the tool's config json. diff --git a/docs/tools/package-structure.mdx b/docs/tools/package-structure.mdx new file mode 100644 index 00000000..57ceb905 --- /dev/null +++ b/docs/tools/package-structure.mdx @@ -0,0 +1,48 @@ + +## Tool Configuration +Each tool gets a directory inside `agentstack/_tools/` where the tool's +source code and configuration will be stored. + +The directory should contain the following files: + +`config.json` +------------- +This contains the configuration for the tool for use by AgentStack, including +metadata, dependencies, configuration & functions exposed by the tool. + +`__init__.py` +--------- +Python package which contains the framework-agnostic tool implementation. Tools +are simple packages which exponse functions; when a tool is loaded into a user's +project, it will be wrapped in the framework-specific tool format by AgentStack. + + +`config.json` Format +-------------------- + +### `name` (string) [required] +The name of the tool in snake_case. This is used to identify the tool in the system. + +### `url` (string) [optional] +The URL of the tool's repository. This is provided to the user to allow them to +learn more about the tool. + +### `category` (string) [required] +The category of the tool. This is used to group tools together in the CLI. + +### `cta` (string) [optional] +String to print in the terminal when the tool is installed that provides a call to action. + +### `env` (list[dict(str, Any)]) [optional] +Definitions for environment variables that will be appended to the local `.env` file. +This is a list of key-value pairs ie. `[{"ENV_VAR": "value"}, ...]`. +In cases where the user is expected to provide their own information, the value is +set to `null` which adds it to the project's `.env` file as a comment. + +### `dependencies` (list[str]) [optional] +List of dependencies that will be installed in the user's project. It is +encouraged that versions are specified, which use the `package>=version` format. + +### `tools` (list[str]) [required] +List of public functions that are accessible in the tool implementation. + diff --git a/pyproject.toml b/pyproject.toml index e1ecc0a9..ab43a5d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,9 @@ crewai = [ "crewai==0.83.0", "crewai-tools==0.14.0", ] - +all = [ + "agentstack[dev,test,crewai]", +] [tool.setuptools.package-data] agentstack = ["templates/**/*"] @@ -67,8 +69,7 @@ exclude = [ "dist", "*.egg-info", "agentstack/templates/", - "examples", - "__init__.py" + "examples" ] line-length = 110 diff --git a/tests/fixtures/tool_config_max.json b/tests/fixtures/tool_config_max.json index fbbbd5a5..1ec8b0fc 100644 --- a/tests/fixtures/tool_config_max.json +++ b/tests/fixtures/tool_config_max.json @@ -3,13 +3,15 @@ "category": "category", "tools": ["tool1", "tool2"], "url": "https://example.com", - "tools_bundled": true, "cta": "Click me!", "env": { "ENV_VAR1": "value1", "ENV_VAR2": "value2" }, - "packages": ["package1", "package2"], + "dependencies": [ + "dependency1>=1.0.0", + "dependency2>=2.0.0" + ], "post_install": "install.sh", "post_remove": "remove.sh" } \ No newline at end of file diff --git a/tests/test_cli_tools.py b/tests/test_cli_tools.py index 9443639c..f7a01367 100644 --- a/tests/test_cli_tools.py +++ b/tests/test_cli_tools.py @@ -4,13 +4,12 @@ from parameterized import parameterized from pathlib import Path import shutil -from agentstack.tools import get_all_tool_names +from agentstack._tools import get_all_tool_names from cli_test_utils import run_cli from agentstack.utils import validator_not_empty from agentstack.cli.cli import get_validated_input from unittest.mock import patch from inquirer.errors import ValidationError -from agentstack.utils import validator_not_empty BASE_PATH = Path(__file__).parent diff --git a/tests/test_frameworks.py b/tests/test_frameworks.py index 4b8e3cf3..0a87a077 100644 --- a/tests/test_frameworks.py +++ b/tests/test_frameworks.py @@ -7,7 +7,7 @@ from agentstack.conf import ConfigFile, set_path from agentstack.exceptions import ValidationError from agentstack import frameworks -from agentstack.tools import ToolConfig +from agentstack._tools import ToolConfig BASE_PATH = Path(__file__).parent @@ -15,9 +15,10 @@ @parameterized_class([{"framework": framework} for framework in frameworks.SUPPORTED_FRAMEWORKS]) class TestFrameworks(unittest.TestCase): def setUp(self): - self.project_dir = BASE_PATH / 'tmp' / self.framework + self.project_dir = BASE_PATH / 'tmp' / self.framework / 'test_frameworks' os.makedirs(self.project_dir) + os.chdir(self.project_dir) # importing the crewai module requires us to be in a working directory os.makedirs(self.project_dir / 'src') os.makedirs(self.project_dir / 'src' / 'tools') @@ -45,10 +46,8 @@ def _populate_max_entrypoint(self): def _get_test_tool(self) -> ToolConfig: return ToolConfig(name='test_tool', category='test', tools=['test_tool']) - def _get_test_tool_starred(self) -> ToolConfig: - return ToolConfig( - name='test_tool_star', category='test', tools=['test_tool_star'], tools_bundled=True - ) + def _get_test_tool_alternate(self) -> ToolConfig: + return ToolConfig(name='test_tool_alt', category='test', tools=['test_tool_alt']) def test_get_framework_module(self): module = frameworks.get_framework_module(self.framework) @@ -73,14 +72,7 @@ def test_add_tool(self): entrypoint_src = open(frameworks.get_entrypoint_path(self.framework)).read() # TODO these asserts are not framework agnostic - assert 'tools=[tools.test_tool' in entrypoint_src - - def test_add_tool_starred(self): - self._populate_max_entrypoint() - frameworks.add_tool(self._get_test_tool_starred(), 'test_agent') - - entrypoint_src = open(frameworks.get_entrypoint_path(self.framework)).read() - assert 'tools=[*tools.test_tool_star' in entrypoint_src + assert "tools=[*agentstack.tools['test_tool']" in entrypoint_src def test_add_tool_invalid(self): self._populate_min_entrypoint() @@ -93,33 +85,25 @@ def test_remove_tool(self): frameworks.remove_tool(self._get_test_tool(), 'test_agent') entrypoint_src = open(frameworks.get_entrypoint_path(self.framework)).read() - assert 'tools=[tools.test_tool' not in entrypoint_src - - def test_remove_tool_starred(self): - self._populate_max_entrypoint() - frameworks.add_tool(self._get_test_tool_starred(), 'test_agent') - frameworks.remove_tool(self._get_test_tool_starred(), 'test_agent') - - entrypoint_src = open(frameworks.get_entrypoint_path(self.framework)).read() - assert 'tools=[*tools.test_tool_star' not in entrypoint_src + assert "tools=[*agentstack.tools['test_tool']" not in entrypoint_src def test_add_multiple_tools(self): self._populate_max_entrypoint() frameworks.add_tool(self._get_test_tool(), 'test_agent') - frameworks.add_tool(self._get_test_tool_starred(), 'test_agent') + frameworks.add_tool(self._get_test_tool_alternate(), 'test_agent') entrypoint_src = open(frameworks.get_entrypoint_path(self.framework)).read() assert ( # ordering is not guaranteed - 'tools=[tools.test_tool, *tools.test_tool_star' in entrypoint_src - or 'tools=[*tools.test_tool_star, tools.test_tool' in entrypoint_src + "tools=[*agentstack.tools['test_tool'], *agentstack.tools['test_tool_alt']" in entrypoint_src + or "tools=[*agentstack.tools['test_tool_alt'], *agentstack.tools['test_tool']" in entrypoint_src ) def test_remove_one_tool_of_multiple(self): self._populate_max_entrypoint() frameworks.add_tool(self._get_test_tool(), 'test_agent') - frameworks.add_tool(self._get_test_tool_starred(), 'test_agent') + frameworks.add_tool(self._get_test_tool_alternate(), 'test_agent') frameworks.remove_tool(self._get_test_tool(), 'test_agent') entrypoint_src = open(frameworks.get_entrypoint_path(self.framework)).read() - assert 'tools=[tools.test_tool' not in entrypoint_src - assert 'tools=[*tools.test_tool_star' in entrypoint_src + assert "*agentstack.tools['test_tool']" not in entrypoint_src + assert "tools=[*agentstack.tools['test_tool_alt']" in entrypoint_src diff --git a/tests/test_generation_tool.py b/tests/test_generation_tool.py index 779f7f91..23247d09 100644 --- a/tests/test_generation_tool.py +++ b/tests/test_generation_tool.py @@ -7,8 +7,8 @@ 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, TOOLS_INIT_FILENAME +from agentstack._tools import get_all_tools, ToolConfig +from agentstack.generation.tool_generation import add_tool, remove_tool BASE_PATH = Path(__file__).parent @@ -24,7 +24,6 @@ def setUp(self): os.makedirs(self.project_dir / 'src') os.makedirs(self.project_dir / 'src' / 'tools') (self.project_dir / 'src' / '__init__.py').touch() - (self.project_dir / TOOLS_INIT_FILENAME).touch() # set the framework in agentstack.json shutil.copy(BASE_PATH / 'fixtures' / 'agentstack.json', self.project_dir / 'agentstack.json') @@ -45,13 +44,10 @@ def test_add_tool(self): entrypoint_path = frameworks.get_entrypoint_path(self.framework) entrypoint_src = open(entrypoint_path).read() - ast.parse(entrypoint_src) - tools_init_src = open(self.project_dir / TOOLS_INIT_FILENAME).read() + ast.parse(entrypoint_src) # validate syntax # TODO verify tool is added to all agents (this is covered in test_frameworks.py) # assert 'agent_connect' in entrypoint_src - assert f'from .{tool_conf.module_name} import' in tools_init_src - assert (self.project_dir / 'src' / 'tools' / f'{tool_conf.module_name}.py').exists() assert 'agent_connect' in open(self.project_dir / 'agentstack.json').read() def test_remove_tool(self): @@ -61,11 +57,8 @@ def test_remove_tool(self): entrypoint_path = frameworks.get_entrypoint_path(self.framework) entrypoint_src = open(entrypoint_path).read() - ast.parse(entrypoint_src) - tools_init_src = open(self.project_dir / TOOLS_INIT_FILENAME).read() + ast.parse(entrypoint_src) # validate syntax # TODO verify tool is removed from all agents (this is covered in test_frameworks.py) # assert 'agent_connect' not in entrypoint_src - assert f'from .{tool_conf.module_name} import' not in tools_init_src - assert not (self.project_dir / 'src' / 'tools' / f'{tool_conf.module_name}.py').exists() assert 'agent_connect' not in open(self.project_dir / 'agentstack.json').read() diff --git a/tests/test_tool_config.py b/tests/test_tool_config.py index 5a8aad31..f86823d4 100644 --- a/tests/test_tool_config.py +++ b/tests/test_tool_config.py @@ -1,7 +1,7 @@ import json import unittest from pathlib import Path -from agentstack.tools import ToolConfig, get_all_tool_paths, get_all_tool_names +from agentstack._tools import ToolConfig, get_all_tool_paths, get_all_tool_names BASE_PATH = Path(__file__).parent @@ -13,10 +13,8 @@ def test_minimal_json(self): assert config.category == "category" assert config.tools == ["tool1", "tool2"] assert config.url is None - assert config.tools_bundled is False assert config.cta is None assert config.env is None - assert config.packages is None assert config.post_install is None assert config.post_remove is None @@ -26,10 +24,8 @@ def test_maximal_json(self): assert config.category == "category" assert config.tools == ["tool1", "tool2"] assert config.url == "https://example.com" - assert config.tools_bundled is True assert config.cta == "Click me!" assert config.env == {"ENV_VAR1": "value1", "ENV_VAR2": "value2"} - assert config.packages == ["package1", "package2"] assert config.post_install == "install.sh" assert config.post_remove == "remove.sh" @@ -42,7 +38,7 @@ def test_all_json_configs_from_tool_name(self): def test_all_json_configs_from_tool_path(self): for path in get_all_tool_paths(): try: - config = ToolConfig.from_json(path) + config = ToolConfig.from_json(f"{path}/config.json") except json.decoder.JSONDecodeError: raise Exception( f"Failed to decode tool json at {path}. Does your tool config fit the required formatting? https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/tools/~README.md" diff --git a/tests/test_tool_generation_init.py b/tests/test_tool_generation_init.py deleted file mode 100644 index 8eb0b65a..00000000 --- a/tests/test_tool_generation_init.py +++ /dev/null @@ -1,82 +0,0 @@ -import os, sys -from pathlib import Path -import shutil -import unittest -from parameterized import parameterized_class - -from agentstack import conf -from agentstack.conf import ConfigFile -from agentstack.exceptions import ValidationError -from agentstack import frameworks -from agentstack.tools import ToolConfig -from agentstack.generation.tool_generation import ToolsInitFile, TOOLS_INIT_FILENAME - - -BASE_PATH = Path(__file__).parent - - -@parameterized_class([{"framework": framework} for framework in frameworks.SUPPORTED_FRAMEWORKS]) -class TestToolGenerationInit(unittest.TestCase): - def setUp(self): - self.project_dir = BASE_PATH / 'tmp' / 'tool_generation_init' - os.makedirs(self.project_dir) - os.makedirs(self.project_dir / 'src') - os.makedirs(self.project_dir / 'src' / 'tools') - (self.project_dir / 'src' / '__init__.py').touch() - (self.project_dir / 'src' / 'tools' / '__init__.py').touch() - shutil.copy(BASE_PATH / 'fixtures' / 'agentstack.json', self.project_dir / 'agentstack.json') - - conf.set_path(self.project_dir) - with ConfigFile() as config: - config.framework = self.framework - - def tearDown(self): - shutil.rmtree(self.project_dir) - - def _get_test_tool(self) -> ToolConfig: - return ToolConfig(name='test_tool', category='test', tools=['test_tool']) - - def _get_test_tool_alt(self) -> ToolConfig: - return ToolConfig(name='test_tool_alt', category='test', tools=['test_tool_alt']) - - def test_tools_init_file(self): - tools_init = ToolsInitFile(conf.PATH / TOOLS_INIT_FILENAME) - # file is empty - assert tools_init.get_import_for_tool(self._get_test_tool()) == None - - def test_tools_init_file_missing(self): - with self.assertRaises(ValidationError) as context: - tools_init = ToolsInitFile(conf.PATH / 'missing') - - def test_tools_init_file_add_import(self): - tool = self._get_test_tool() - with ToolsInitFile(conf.PATH / TOOLS_INIT_FILENAME) as tools_init: - tools_init.add_import_for_tool(tool, self.framework) - - tool_init_src = open(self.project_dir / TOOLS_INIT_FILENAME).read() - assert tool.get_import_statement(self.framework) in tool_init_src - - def test_tools_init_file_add_import_multiple(self): - tool = self._get_test_tool() - tool_alt = self._get_test_tool_alt() - with ToolsInitFile(conf.PATH / TOOLS_INIT_FILENAME) as tools_init: - tools_init.add_import_for_tool(tool, self.framework) - - with ToolsInitFile(conf.PATH / TOOLS_INIT_FILENAME) as tools_init: - tools_init.add_import_for_tool(tool_alt, self.framework) - - # Should not be able to re-add a tool import - with self.assertRaises(ValidationError) as context: - with ToolsInitFile(conf.PATH / TOOLS_INIT_FILENAME) as tools_init: - tools_init.add_import_for_tool(tool, self.framework) - - tool_init_src = open(self.project_dir / TOOLS_INIT_FILENAME).read() - assert tool.get_import_statement(self.framework) in tool_init_src - assert tool_alt.get_import_statement(self.framework) in tool_init_src - # TODO this might be a little too strict - assert ( - tool_init_src - == """ -from .test_tool_tool import test_tool -from .test_tool_alt_tool import test_tool_alt""" - ) diff --git a/tox.ini b/tox.ini index dd39747c..4d905e52 100644 --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,7 @@ envlist = py310,py311,py312 [testenv] +extras = all # install dependencies for all frameworks, too deps = pytest parameterized