diff --git a/agentstack/__init__.py b/agentstack/__init__.py index 269cf4ca..3f257228 100644 --- a/agentstack/__init__.py +++ b/agentstack/__init__.py @@ -5,38 +5,44 @@ end user inside their project. """ -from typing import Callable +from typing import Callable, TypeAlias from pathlib import Path from agentstack import conf from agentstack.utils import get_framework from agentstack.agents import get_agent, get_all_agents, get_all_agent_names from agentstack.tasks import get_task, get_all_tasks, get_all_task_names from agentstack.inputs import get_inputs +from agentstack import _tools +from agentstack._tools import get_tool from agentstack import frameworks ___all___ = [ - "conf", - "agent", - "task", - "tools", - "get_tags", - "get_framework", - "get_agent", + "conf", + "agent", + "task", + "tools", + "get_tags", + "get_framework", + "get_tool", + "get_agent", "get_all_agents", "get_all_agent_names", - "get_task", + "get_task", "get_all_tasks", "get_all_task_names", - "get_inputs", + "get_inputs", ] + def agent(func): """ - The `agent` decorator is used to mark a method that implements an Agent. + The `agent` decorator is used to mark a method that implements an Agent. """ + def wrap(*args, **kwargs): """Does not alter the function's behavior; this is just a marker.""" return func(*args, **kwargs) + return wrap @@ -44,9 +50,11 @@ def task(func): """ The `task` decorator is used to mark a method that implements a Task. """ + def wrap(*args, **kwargs): """Does not alter the function's behavior; this is just a marker.""" return func(*args, **kwargs) + return wrap @@ -57,16 +65,38 @@ def get_tags() -> list[str]: return ['agentstack', get_framework(), *conf.get_installed_tools()] -class ToolLoader: +class ToolsMetaclass(type): """ - Provides the public interface for accessing tools, wrapped in the - framework-specific callable format. + Metaclass for the public tools interface. - 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], ]` + Define methods here to expose in the public API. Using a metaclass let's us + use methods traditionally only available to instances on the class itself. """ - def __getitem__(self, tool_name: str) -> list[Callable]: + def __getitem__(cls, tool_name: str) -> list[Callable]: + """ + 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], ]` + """ return frameworks.get_tool_callables(tool_name) -tools = ToolLoader() + def get_permissions(cls, func: Callable) -> _tools.ToolPermission: + """ + Get the permissions for a tool function. + """ + return _tools.get_permissions(func) + + +class tools(metaclass=ToolsMetaclass): + """ + Provides the public interface for accessing `agentstack._tools` methods and + types that we explicitly expose. + + Access wrapped tools with `agentstack.tools[tool_name]` + + Access tool permissions with `agentstack.tools.get_permissions(func)` + + Access the tool Action type with `agentstack.tools.Action` + """ + + Action: TypeAlias = _tools.Action diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py index 36f2e71f..9629b838 100644 --- a/agentstack/_tools/__init__.py +++ b/agentstack/_tools/__init__.py @@ -1,17 +1,24 @@ -from typing import Optional, Protocol, runtime_checkable +from typing import Optional, Any, Callable, Protocol, runtime_checkable from types import ModuleType import os -import sys -from pathlib import Path from importlib import import_module +from functools import lru_cache +from pathlib import Path +import enum 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 +from agentstack.exceptions import ValidationError +from agentstack.utils import get_package_path, open_json_file, snake_to_camel +from agentstack import yaml TOOLS_DIR: Path = get_package_path() / '_tools' # NOTE: if you change this dir, also update MANIFEST.in TOOLS_CONFIG_FILENAME: str = 'config.json' +USER_TOOL_CONFIG_FILENAME: str = 'src/config/tools.yaml' + + +def _get_user_tool_config_path() -> Path: + return conf.PATH / USER_TOOL_CONFIG_FILENAME def _get_custom_tool_path(name: str) -> Path: @@ -24,16 +31,164 @@ def _get_builtin_tool_path(name: str) -> Path: return TOOLS_DIR / name / TOOLS_CONFIG_FILENAME +""" +Tool Authors +------------ +As a tool author, this should be as easy as possible to interact with: + +``` # config.json +{ + "name": "my_tool", + ... + "tools": { + "tool_function": { + "actions": ["read", "write"], + "allowed_dirs": ["/home/user/*"], + "allowed_extensions": ["*.txt"] + }, + ... + } +} +``` + +Permissions passed to the tool take info account the developer-defined defaults +and the preferences the user has overlaid on top of them. + +If a user has not overridden prefs, the tool will get a base set of permissions, +but the user's project will not have access to the function, so we're good. + +``` # my_tool.py +def tool_function() -> str: + permissions = tools.get_permissions(tool_function) + ... + + if permissions.READ: + ... + if permissions.WRITE: + ... + if permissions.EXECUTE: + ... + + # extra permission are ad-hoc + permissions.allowed_dirs # -> ['/home/user/*'] + permissions.allowed_extensions # -> ['*.txt'] + ... +``` + +`allowed_dirs` and `allowed_extensions` are optional, and up to the tool integrator to implement, +but in this case we're using patterns that are compatible with `fnmatch`. + +End Users +--------- +As a project user, this should be as easy as possible to interact with. +They should be able to inherit sane defaults from the tool author. +In order to explicitly include a function in the tools available to the user's agent +we do need the function to be listed in the config file. It would be nice if we +didn't have to list all of the permissions, however. Although, that would be an +easy way to allow them to override defaults. + +``` # src/config/tools.yaml +my_tool: + other_tool_function: ~ # (or empty) inherit defaults + tool_function: + actions: ['read', 'write'] + allowed_dirs: ['/home/user/*'] + allowed_extensions: ['*.txt'] +``` + +TODO How do we determine which agent is using the tools? Maybe we leave out agent-specific +configuration for tools for now and just have them at the application level? +``` # src/stack.py +... +@agentstack.agent +def get_agent() -> Agent: + return Agent( + tools=[*agentstack.tools['my_tool'], ] + ) +""" + + +class Action(enum.Enum): + READ = 'read' + WRITE = 'write' + DELETE = 'delete' + EXECUTE = 'execute' + + def __str__(self) -> str: + return self.value + + +class ToolPermission(pydantic.BaseModel): + """ + Indicate which permissions a tool has. + + This solves a few problems: + - Some tools expose a number of functions, which may overwhelm the context of an agent. + - Some tools interact with the system they are running on, and should be restricted to + specific directories, or specific operations. + - Some tools allow execution of code and should be restricted to specific features. + + Considerations: + - Users and the CLI will have to interact with this configuration format and it should be + easy to understand. + - Tools may need additional configuration to define what features are available. + + This is used by both the tool's included configuration and the user's configuration + to represent the permissions for a tool. + + TODO Tool configurations could be specific to an agent, not the whole project. + This does make the configuration format that much more complex and the way we + currently load tools into an agent does not have a marker for that. + """ + + actions: list[Action] + attributes: dict[str, Any] = pydantic.Field(default_factory=dict) + + def __init__(self, **data): + super().__init__(actions=data.pop('actions'), attributes=data) + + @property + def READ(self) -> bool: + """Is this tool allowed to read?""" + return Action.READ in self.actions + + @property + def WRITE(self) -> bool: + """Is this tool allowed to write?""" + return Action.WRITE in self.actions + + @property + def DELETE(self) -> bool: + """Is this tool allowed to delete?""" + return Action.DELETE in self.actions + + @property + def EXECUTE(self) -> bool: + """Is this tool allowed to execute?""" + return Action.EXECUTE in self.actions + + def __getattr__(self, name: str) -> Any: + """Get an attribute from the attributes dict.""" + return self.attributes.get(name, None) + + @pydantic.model_serializer + def ser_model(self) -> dict: + """Merge attributes into top level""" + return {**self.attributes, 'actions': self.actions} + + 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. + It parses and validates the `config.json` file and provides an interface for + interacting with the tool implementation. + User tool config data is incorporated to filter tools the user has allowed + into their project along with any permissions they have set. """ name: str category: str - tools: list[str] + tools: dict[str, ToolPermission] = pydantic.Field(default_factory=dict) url: Optional[str] = None cta: Optional[str] = None env: Optional[dict] = None @@ -56,6 +211,7 @@ def from_tool_name(cls, name: str) -> 'ToolConfig': @classmethod def from_json(cls, path: Path) -> 'ToolConfig': + """Load a tool's configuration from a path to a JSON file.""" data = open_json_file(path) try: return cls(**data) @@ -65,13 +221,63 @@ 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 add_tool(self, func_name: str, permissions: Optional[list[Action]] = None): + """Add a tool to the config. Provides default permissions if none are provided.""" + if func_name in self.tools: + raise ValidationError(f"Tool '{func_name}' already exists in config.") + if permissions is None: + permissions = list(Action) # all permissions + self.tools[func_name] = ToolPermission(actions=permissions) + 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()) + f.write(self.model_dump_json(indent=4)) + + @property + def allowed_tools(self) -> dict[str, ToolPermission]: + """Get the tools this project has access to.""" + try: + user_config = UserToolConfig(self.name) + except FileNotFoundError: + log.debug(f"User has no tools.yaml file; allowing all tools.") + return self.tools + + log.debug( + f"Excluding tools from {self.name} based on project permissions: " + f"{', '.join(self.tools.keys() - user_config.tools.keys()) or 'None'}\n" + f"Modify this behavior in 'src/config/tools.yaml'." + ) + + filtered_perms = {} + for func_name in user_config.tools: + # TODO what about orphaned tools in the user config + base_perms: Optional[ToolPermission] = self.tools.get(func_name) + assert base_perms, f"Tool config.json for '{self.name}' does not include '{func_name}'." + + _user_perms: Optional[ToolPermission] = user_config.tools[func_name] + if _user_perms is None: # `None` if user chooses to inherit all defaults + user_perms = {} + if isinstance(_user_perms, ToolPermission): + user_perms = _user_perms.model_dump() + assert user_perms is not None, f"User tool permission got unexpected type {type(_user_perms)}." + + all_perms = {**base_perms.model_dump(), **user_perms} + filtered_perms[func_name] = ToolPermission(**all_perms) + return filtered_perms + + @property + def tool_names(self) -> list[str]: + """Get the names of all tools.""" + return list(self.tools.keys()) + + @property + def allowed_tool_names(self) -> list[str]: + """Get the names of all tools this project has access to.""" + return list(self.allowed_tools.keys()) @property def type(self) -> type: @@ -82,7 +288,9 @@ def type(self) -> type: def method_stub(name: str): def not_implemented(*args, **kwargs): - raise NotImplementedError( + # this should never be called, but is here to indicate that the method + # is not implemented in the tool module if for some reason it is called. + raise NotImplementedError( # pragma: no cover 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})." ) @@ -91,7 +299,7 @@ def not_implemented(*args, **kwargs): # 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 + method_name: method_stub(method_name) for method_name in self.tool_names },) # fmt: on return runtime_checkable(type_) @@ -120,7 +328,7 @@ def module(self) -> ModuleType: 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"The tool's config.json file lists the following public methods: `{'`, `'.join(self.tool_names)}` " f"but only implements: '{'`, `'.join([m for m in dir(_module) if not m.startswith('_')])}`" ) except ModuleNotFoundError as e: @@ -131,6 +339,145 @@ def module(self) -> ModuleType: ) +class UserToolConfig(pydantic.BaseModel): + """ + Interface for reading a user's tool configuration from a project. + + Usage: + ``` + user_tool_config = UserToolConfig('tool_name') + tool = user_tool_config.tools['tool_function'] + tool.actions # -> [Actions.READ, ...] + tool.foobar # -> Any + ``` + Use it as a context manager to make and save edits: + ```python + with UserToolConfig('tool_name') as config: + # TODO `ToolPermission` might not be instantiated so this is a bad example + config.tools['tool_function'].actions = [Actions.READ, Actions.WRITE] + ``` + + Or, just make a tool available to the user: + ```python + with UserToolConfig('tool_name') as config: + config.add_tool(tool_config) + ``` + + Config Schema + ------------- + name: str + The name of the tool. + tools: dict[str, Optional[ToolPermission]] + A dictionary of tool names to permissions. Empty values inherit all from the tool's config.json. + """ + + name: str + tools: dict[str, Optional[ToolPermission]] = pydantic.Field(default_factory=dict) + + def __init__(self, tool_name: str): + filename = _get_user_tool_config_path() + try: + with open(filename, 'r') as f: + data = yaml.parser.load(f) or {} + data = data.get(tool_name, {}) or {} + super().__init__(**{'name': tool_name, 'tools': data}) + except yaml.YAMLError as e: + # TODO format MarkedYAMLError lines/messages + raise ValidationError(f"Error parsing tools file: {filename}\n{e}") + except pydantic.ValidationError as e: + error_str = "Error validating user 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 {tool_name} from {filename}.\n{error_str}") + + @classmethod + def exists(cls) -> bool: + """Check if a user tool config file exists.""" + return _get_user_tool_config_path().exists() + + @classmethod + def initialize(cls) -> None: + """ + Create a user tool config file if it does not exist and populate it with + all of the tools available to the user. This is used to bring an existing + project up to date with a UserToolConfig. + """ + from agentstack.frameworks import get_templates_path + + framework = conf.get_framework() + assert framework, "Not an agentstack project." + template_path = get_templates_path(framework) / USER_TOOL_CONFIG_FILENAME + filename = _get_user_tool_config_path() + + assert not filename.exists(), f"{filename} exists." + with open(filename, 'w') as f: + f.write(template_path.read_text()) + + for tool_name in conf.get_installed_tools(): + tool_config = get_tool(tool_name) + with cls(tool_name) as user_tool_config: + user_tool_config.add_stubs() + + @property + def tool_names(self) -> list[str]: + """Get the names of all tools in the user config.""" + return list(self.tools.keys()) + + def add_stubs(self) -> None: + """ + Add stubs for all tools in the user config to the tool config file. + This is used to bring an existing project up to date with a UserToolConfig. + """ + tool_config = get_tool(self.name) + self.tools = {key: None for key in tool_config.tool_names} + + def model_dump(self, *args, **kwargs) -> dict: + model_dump = super().model_dump(*args, **kwargs) + tool_name = model_dump.pop('name') # `name` is the key, so keep it out of the data + tool_data = model_dump.pop('tools') # `tools` as a key is implied + return {tool_name: tool_data} + + def write(self): + filename = _get_user_tool_config_path() + log.debug(f"Writing tool '{self.name}' to {filename}") + + with open(filename, 'r') as f: + data = yaml.parser.load(f) or {} + + # update just this tool + data.update(self.model_dump()) + + with open(filename, 'w') as f: + yaml.parser.dump(data, f) + + def __enter__(self) -> 'UserToolConfig': + return self + + def __exit__(self, *args): + self.write() + + +def get_permissions(func: Callable) -> ToolPermission: + """ + Get the permissions for use inside of a tool function. + We derive the tool name and function name from the function's module and name. + This takes the user's preferences into account. + """ + tool_name = func.__module__.split('.')[-1] + func_name = func.__name__ + log.debug(f"Getting permissions for `{tool_name}.{func_name}`") + return get_tool(tool_name).tools[func_name] + + +def get_tool(name: str) -> ToolConfig: + """ + Get the tool configuration for a given tool name. + """ + # TODO this is a candidate for caching + return ToolConfig.from_tool_name(name) + + +@lru_cache() # tool config paths do not change at runtime def get_all_tool_paths() -> list[Path]: """ Get all the paths to the tool configuration files. @@ -161,11 +508,14 @@ def get_all_tool_paths() -> list[Path]: def get_all_tool_names() -> list[str]: - """Get names of all available tools, including custom tools.""" - return [path.name for path in get_all_tool_paths()] + """ + Get the names of all bundled tools. + """ + return [path.stem for path in get_all_tool_paths()] def get_all_tools() -> list[ToolConfig]: - """Get all tool configs, including custom tools.""" - tool_names = get_all_tool_names() - return [ToolConfig.from_tool_name(name) for name in tool_names] + """ + Get configurations for all bundled tools. + """ + return [get_tool(name) for name in get_all_tool_names()] diff --git a/agentstack/_tools/agent_connect/config.json b/agentstack/_tools/agent_connect/config.json index 542c81e8..0e77ea35 100644 --- a/agentstack/_tools/agent_connect/config.json +++ b/agentstack/_tools/agent_connect/config.json @@ -13,5 +13,12 @@ "dependencies": [ "agent-connect>=0.3.0" ], - "tools": ["send_message", "receive_message"] + "tools": { + "send_message": { + "actions": ["write"] + }, + "receive_message": { + "actions": ["read"] + } + } } diff --git a/agentstack/_tools/agentql/config.json b/agentstack/_tools/agentql/config.json index 154be031..d36b993f 100644 --- a/agentstack/_tools/agentql/config.json +++ b/agentstack/_tools/agentql/config.json @@ -2,10 +2,13 @@ "name": "agentql", "url": "https://agentql.com/", "category": "web-retrieval", - "packages": [], + "cta": "Create your AgentQL API key at https://dev.agentql.com", "env": { - "AGENTQL_API_KEY": "..." + "AGENTQL_API_KEY": null }, - "tools": ["query_data"], - "cta": "Create your AgentQL API key at https://dev.agentql.com" + "tools": { + "query_data": { + "actions": ["read"] + } + } } diff --git a/agentstack/_tools/browserbase/config.json b/agentstack/_tools/browserbase/config.json index 01489d50..38b7f779 100644 --- a/agentstack/_tools/browserbase/config.json +++ b/agentstack/_tools/browserbase/config.json @@ -2,6 +2,7 @@ "name": "browserbase", "url": "https://github.com/browserbase/python-sdk", "category": "browsing", + "cta": "Create an API key at https://www.browserbase.com/", "env": { "BROWSERBASE_API_KEY": null, "BROWSERBASE_PROJECT_ID": null @@ -9,6 +10,9 @@ "dependencies": [ "browserbase>=1.0.5" ], - "tools": ["load_url"], - "cta": "Create an API key at https://www.browserbase.com/" + "tools": { + "load_url": { + "actions": ["read"] + } + } } \ No newline at end of file diff --git a/agentstack/_tools/code_interpreter/__init__.py b/agentstack/_tools/code_interpreter/__init__.py index 5b0c9958..329b262c 100644 --- a/agentstack/_tools/code_interpreter/__init__.py +++ b/agentstack/_tools/code_interpreter/__init__.py @@ -1,5 +1,6 @@ import os from agentstack.utils import get_package_path +from agentstack import tools import docker CONTAINER_NAME = "code-interpreter" @@ -60,6 +61,11 @@ def run_code(code: str, libraries_used: list[str]) -> str: 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. """ + permissions = tools.get_permissions(run_code) + + if not permissions.EXECUTE: + return "User has not granted EXECUTE permissions." + _verify_docker_image() container = _init_docker_container() diff --git a/agentstack/_tools/code_interpreter/config.json b/agentstack/_tools/code_interpreter/config.json index f164730f..ed3d52c9 100644 --- a/agentstack/_tools/code_interpreter/config.json +++ b/agentstack/_tools/code_interpreter/config.json @@ -8,5 +8,9 @@ "dependencies": [ "docker>=7.1.0" ], - "tools": ["run_code"] + "tools": { + "run_code": { + "actions": ["read", "write", "execute"] + } + } } \ No newline at end of file diff --git a/agentstack/_tools/composio/__init__.py b/agentstack/_tools/composio/__init__.py index 8b62d046..f574f489 100644 --- a/agentstack/_tools/composio/__init__.py +++ b/agentstack/_tools/composio/__init__.py @@ -2,7 +2,7 @@ import os from typing import Any, Dict, List, Optional - +from agentstack import tools from composio import Action, ComposioToolSet from composio.constants import DEFAULT_ENTITY_ID @@ -34,6 +34,10 @@ def execute_action( Returns: Dict containing the action result """ + permissions = tools.get_permissions(execute_action) + if not permissions.EXECUTE: + return {'error': "User has not granted execute permission."} + toolset = ComposioToolSet() action = Action(action_name) @@ -49,6 +53,10 @@ def execute_action( def get_action_schema(action_name: str) -> Dict[str, Any]: """Get the schema for a composio action.""" + permissions = tools.get_permissions(get_action_schema) + if not permissions.READ: + return {'error': "User has not granted read permission."} + toolset = ComposioToolSet() action = Action(action_name) (action_schema,) = toolset.get_action_schemas(actions=[action]) @@ -60,6 +68,10 @@ def find_actions_by_use_case( use_case: str, ) -> List[Dict[str, Any]]: """Find actions by use case.""" + permissions = tools.get_permissions(find_actions_by_use_case) + if not permissions.READ: + return [{'error': "User has not granted read permission."}] + toolset = ComposioToolSet() actions = toolset.find_actions_by_use_case(*apps, use_case=use_case) return [get_action_schema(action.name) for action in actions] @@ -70,6 +82,10 @@ def find_actions_by_tags( tags: List[str], ) -> List[Dict[str, Any]]: """Find actions by tags.""" + permissions = tools.get_permissions(find_actions_by_tags) + if not permissions.READ: + return [{'error': "User has not granted read permission."}] + 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 index e2b56f82..40c2fae9 100644 --- a/agentstack/_tools/composio/config.json +++ b/agentstack/_tools/composio/config.json @@ -2,17 +2,25 @@ "name": "composio", "url": "https://composio.dev/", "category": "unified-apis", + "cta": "!!! Composio provides 150+ tools. Additional setup is required in agentstack/tools/composio/__init__.py", "env": { "COMPOSIO_API_KEY": null }, - "tools": [ - "execute_action", - "get_action_schema", - "find_actions_by_use_case", - "find_actions_by_tags" - ], "dependencies": [ "composio-core>=0.6.0" ], - "cta": "!!! Composio provides 150+ tools. Additional setup is required in agentstack/tools/composio/__init__.py" + "tools": { + "execute_action": { + "actions": ["read", "write", "execute"] + }, + "get_action_schema": { + "actions": ["read"] + }, + "find_actions_by_use_case": { + "actions": ["read"] + }, + "find_actions_by_tags": { + "actions": ["read"] + } + } } diff --git a/agentstack/_tools/directory_search/__init__.py b/agentstack/_tools/directory_search/__init__.py index cf199910..bd2cee26 100644 --- a/agentstack/_tools/directory_search/__init__.py +++ b/agentstack/_tools/directory_search/__init__.py @@ -1,9 +1,15 @@ """Framework-agnostic directory search implementation using embedchain.""" - +import os from typing import Optional from pathlib import Path +from fnmatch import fnmatch +from agentstack import tools from embedchain.loaders.directory_loader import DirectoryLoader -import os + + +def _is_path_allowed(path: str, allowed_patterns: list[str]) -> bool: + """Check if the given path matches any of the allowed patterns.""" + return any(fnmatch(path, pattern) for pattern in allowed_patterns) def search_directory(directory: str, query: str) -> str: @@ -17,6 +23,17 @@ def search_directory(directory: str, query: str) -> str: Returns: str: Search results as a string """ + permissions = tools.get_permissions(search_directory) + if not permissions.READ: + return "User has not granted read permission." + + if permissions.allowed_dirs: + if not _is_path_allowed(directory, permissions.allowed_dirs): + return ( + f"Error: Access to directory {directory} is not allowed. " + f"Allowed directories: {permissions.allowed_dirs}" + ) + loader = DirectoryLoader(config=dict(recursive=True)) results = loader.search(directory, query) return str(results) @@ -36,6 +53,11 @@ def search_fixed_directory(query: str) -> str: Raises: ValueError: If DIRECTORY_SEARCH_TOOL_PATH environment variable is not set """ + + permissions = tools.get_permissions(search_fixed_directory) + if not permissions.READ: + return "User has not granted read permission." + directory = os.getenv('DIRECTORY_SEARCH_TOOL_PATH') if not directory: raise ValueError("DIRECTORY_SEARCH_TOOL_PATH environment variable not set") diff --git a/agentstack/_tools/directory_search/config.json b/agentstack/_tools/directory_search/config.json index 6cbf314e..6e916111 100644 --- a/agentstack/_tools/directory_search/config.json +++ b/agentstack/_tools/directory_search/config.json @@ -8,5 +8,13 @@ "dependencies": [ "embedchain>=0.1.0" ], - "tools": ["search_directory", "search_fixed_directory"] + "tools": { + "search_directory": { + "actions": ["read"], + "allowed_dirs": ["*"] + }, + "search_fixed_directory": { + "actions": ["read"] + } + } } diff --git a/agentstack/_tools/exa/__init__.py b/agentstack/_tools/exa/__init__.py index 19db5782..aba6c6b8 100644 --- a/agentstack/_tools/exa/__init__.py +++ b/agentstack/_tools/exa/__init__.py @@ -1,4 +1,5 @@ import os +from agentstack import tools from exa_py import Exa # Check out our docs for more info! https://docs.exa.ai/ @@ -14,8 +15,11 @@ def search_and_contents(question: str) -> str: Returns: Formatted string containing titles, URLs, and highlights from the search results """ + permissions = tools.get_permissions(search_and_contents) + if not permissions.READ: + return "User has not granted read permission." + exa = Exa(api_key=API_KEY) - response = exa.search_and_contents( question, type="neural", use_autoprompt=True, num_results=3, highlights=True ) diff --git a/agentstack/_tools/exa/config.json b/agentstack/_tools/exa/config.json index 4f6a4fbd..28ba07d5 100644 --- a/agentstack/_tools/exa/config.json +++ b/agentstack/_tools/exa/config.json @@ -2,12 +2,17 @@ "name": "exa", "url": "https://exa.ai", "category": "web-retrieval", + "cta": "Get your Exa API key at https://dashboard.exa.ai/api-keys", "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" + "tools": { + "search_and_contents": { + "actions": ["read"] + } + } + } \ No newline at end of file diff --git a/agentstack/_tools/file_read/__init__.py b/agentstack/_tools/file_read/__init__.py index 3fca8dcf..89cba427 100644 --- a/agentstack/_tools/file_read/__init__.py +++ b/agentstack/_tools/file_read/__init__.py @@ -4,30 +4,52 @@ from typing import Optional from pathlib import Path +from fnmatch import fnmatch +from agentstack import tools + + +def _is_path_allowed(path: str, allowed_patterns: list[str]) -> bool: + """Check if the given path matches any of the allowed patterns.""" + return any(fnmatch(path, pattern) for pattern in allowed_patterns) def read_file(file_path: str) -> str: - """Read contents of a file at the given path. + """ + Read the 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 + str: The contents of the file as a string or a description of the error """ - 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" + permissions = tools.get_permissions(read_file) + path = Path(file_path).resolve() + + if not permissions.READ: + return "User has not granted read permission." + + if permissions.allowed_dirs: + if not _is_path_allowed(str(path), permissions.allowed_dirs): + return ( + f"Error: Access to file {file_path} is not allowed. " + f"Allowed directories: {permissions.allowed_dirs}" + ) + if permissions.allowed_extensions: + if not _is_path_allowed(path.name, permissions.allowed_extensions): + return ( + f"Error: File extension of {file_path} is not allowed. " + f"Allowed extensions: {permissions.allowed_extensions}" + ) + + 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" + + try: with open(path, "r", encoding="utf-8") as file: return file.read() - except (FileNotFoundError, PermissionError, Exception) as e: + except 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 index 7454927e..a3115a0f 100644 --- a/agentstack/_tools/file_read/config.json +++ b/agentstack/_tools/file_read/config.json @@ -1,8 +1,14 @@ { "name": "file_read", "category": "computer-control", - "tools": ["read_file"], "description": "Read contents of files", "url": "https://docs.agentstack.sh/tools/tool/file_read", - "dependencies": [] + "dependencies": [], + "tools": { + "read_file": { + "actions": ["read"], + "allowed_dirs": ["*"], + "allowed_extensions": ["*"] + } + } } diff --git a/agentstack/_tools/firecrawl/__init__.py b/agentstack/_tools/firecrawl/__init__.py index bbda5381..333f57d9 100644 --- a/agentstack/_tools/firecrawl/__init__.py +++ b/agentstack/_tools/firecrawl/__init__.py @@ -1,4 +1,5 @@ import os +from agentstack import tools from firecrawl import FirecrawlApp from typing import List, Dict, Any, Optional app = FirecrawlApp(api_key=os.getenv('FIRECRAWL_API_KEY')) @@ -9,6 +10,10 @@ def web_scrape(url: str): Scrape a url and return markdown. Use this to read a singular page and web_crawl only if you need to read all other links as well. """ + permissions = tools.get_permissions(web_scrape) + if not permissions.READ: + return "User has not granted read permission." + scrape_result = app.scrape_url(url, params={'formats': ['markdown']}) return scrape_result @@ -23,6 +28,9 @@ def web_crawl(url: str): Crawl will ignore sublinks of a page if they arenโ€™t children of the url you provide. So, the website.com/other-parent/blog-1 wouldnโ€™t be returned if you crawled website.com/blogs/. """ + permissions = tools.get_permissions(web_crawl) + if not permissions.READ: + return "User has not granted read permission." crawl_status = app.crawl_url( url, params={'limit': 100, 'scrapeOptions': {'formats': ['markdown']}}, poll_interval=30 @@ -37,6 +45,10 @@ def retrieve_web_crawl(crawl_id: str): so be sure to only use this tool some time after initiating a crawl. The result will tell you if the crawl is finished. If it is not, wait some more time then try again. """ + permissions = tools.get_permissions(retrieve_web_crawl) + if not permissions.READ: + return "User has not granted read permission." + return app.check_crawl_status(crawl_id) @@ -51,6 +63,10 @@ def batch_scrape(urls: List[str], formats: List[str] = ['markdown', 'html']): Returns: Dictionary containing the batch scrape results """ + permissions = tools.get_permissions(batch_scrape) + if not permissions.READ: + return "User has not granted read permission." + batch_result = app.batch_scrape_urls(urls, {'formats': formats}) return batch_result @@ -66,6 +82,10 @@ def async_batch_scrape(urls: List[str], formats: List[str] = ['markdown', 'html' Returns: Dictionary containing the job ID and status URL """ + permissions = tools.get_permissions(async_batch_scrape) + if not permissions.READ: + return "User has not granted read permission." + batch_job = app.async_batch_scrape_urls(urls, {'formats': formats}) return batch_job @@ -80,6 +100,10 @@ def check_batch_status(job_id: str): Returns: Dictionary containing the current status and results if completed """ + permissions = tools.get_permissions(check_batch_status) + if not permissions.READ: + return "User has not granted read permission." + return app.check_batch_scrape_status(job_id) @@ -96,6 +120,10 @@ def extract_data(urls: List[str], schema: Optional[Dict[str, Any]] = None, promp Returns: Dictionary containing the extracted structured data """ + permissions = tools.get_permissions(extract_data) + if not permissions.READ: + return {'error': "User has not granted read permission."} + params: Dict[str, Any] = {} if prompt is not None: @@ -118,6 +146,10 @@ def map_website(url: str, search: Optional[str] = None): Returns: Dictionary containing the list of discovered URLs """ + permissions = tools.get_permissions(map_website) + if not permissions.READ: + return "User has not granted read permission." + params = {'search': search} if search else {} map_result = app.map_url(url, params) return map_result @@ -134,6 +166,10 @@ def batch_extract(urls: List[str], extract_params: Dict[str, Any]): Returns: Dictionary containing the extracted data from all URLs """ + permissions = tools.get_permissions(batch_extract) + if not permissions.READ: + return "User has not granted read permission." + params = { 'formats': ['extract'], 'extract': extract_params diff --git a/agentstack/_tools/firecrawl/config.json b/agentstack/_tools/firecrawl/config.json index 42c45756..7ee7328a 100644 --- a/agentstack/_tools/firecrawl/config.json +++ b/agentstack/_tools/firecrawl/config.json @@ -2,21 +2,37 @@ "name": "firecrawl", "url": "https://www.firecrawl.dev/", "category": "browsing", + "cta": "Create an API key at https://www.firecrawl.dev/", "env": { "FIRECRAWL_API_KEY": null }, "dependencies": [ "firecrawl-py>=1.6.4" ], - "tools": [ - "web_scrape", - "web_crawl", - "retrieve_web_crawl", - "batch_scrape", - "check_batch_status", - "extract_data", - "map_website", - "batch_extract" - ], - "cta": "Create an API key at https://www.firecrawl.dev/" + "tools": { + "web_scrape": { + "actions": ["read"] + }, + "web_crawl": { + "actions": ["read"] + }, + "retrieve_web_crawl": { + "actions": ["read"] + }, + "batch_scrape": { + "actions": ["read"] + }, + "check_batch_status": { + "actions": ["read"] + }, + "extract_data": { + "actions": ["read"] + }, + "map_website": { + "actions": ["read"] + }, + "batch_extract": { + "actions": ["read"] + } + } } \ No newline at end of file diff --git a/agentstack/_tools/ftp/__init__.py b/agentstack/_tools/ftp/__init__.py index 3248f551..87d29dc3 100644 --- a/agentstack/_tools/ftp/__init__.py +++ b/agentstack/_tools/ftp/__init__.py @@ -1,4 +1,5 @@ import os +from agentstack import tools from ftplib import FTP HOST = os.getenv('FTP_HOST') @@ -31,6 +32,10 @@ def upload_files(file_paths: list[str]): bool: True if all files were uploaded successfully, False otherwise. """ + permissions = tools.get_permissions(upload_files) + if not permissions.WRITE: + return "User has not granted write permissions." + assert HOST and USER and PASSWORD # appease type checker result = True diff --git a/agentstack/_tools/ftp/config.json b/agentstack/_tools/ftp/config.json index b60daa84..963132e8 100644 --- a/agentstack/_tools/ftp/config.json +++ b/agentstack/_tools/ftp/config.json @@ -1,11 +1,15 @@ { "name": "ftp", "category": "computer-control", + "cta": "Be sure to add your FTP credentials to .env", "env": { "FTP_HOST": null, "FTP_USER": null, "FTP_PASSWORD": null }, - "tools": ["upload_files"], - "cta": "Be sure to add your FTP credentials to .env" + "tools": { + "upload_files": { + "actions": ["read", "write"] + } + } } \ No newline at end of file diff --git a/agentstack/_tools/mem0/__init__.py b/agentstack/_tools/mem0/__init__.py index e969626a..4ee0e086 100644 --- a/agentstack/_tools/mem0/__init__.py +++ b/agentstack/_tools/mem0/__init__.py @@ -1,5 +1,6 @@ import os import json +from agentstack import tools from mem0 import MemoryClient # These functions can be extended by changing the user_id parameter @@ -19,6 +20,10 @@ def write_to_memory(user_message: str) -> str: Writes data to the memory store for a user. The tool will decide what specific information is important to store as memory. """ + permissions = tools.get_permissions(write_to_memory) + if not permissions.WRITE: + return "User has not granted write permission." + messages = [ {"role": "user", "content": user_message}, ] @@ -30,6 +35,10 @@ def read_from_memory(query: str) -> str: """ Reads memories related to user based on a query. """ + permission = tools.get_permissions(read_from_memory) + if not permission.READ: + return "User has not granted read permission." + memories = client.search(query=query, user_id='default') if memories: return "\n".join([mem['memory'] for mem in memories]) diff --git a/agentstack/_tools/mem0/config.json b/agentstack/_tools/mem0/config.json index 6ca85239..e3a1571a 100644 --- a/agentstack/_tools/mem0/config.json +++ b/agentstack/_tools/mem0/config.json @@ -2,12 +2,19 @@ "name": "mem0", "url": "https://github.com/mem0ai/mem0", "category": "storage", + "cta": "Create your mem0 API key at https://mem0.ai/", "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/" + "tools": { + "write_to_memory": { + "actions": ["write"] + }, + "read_from_memory": { + "actions": ["read"] + } + } } \ No newline at end of file diff --git a/agentstack/_tools/neon/__init__.py b/agentstack/_tools/neon/__init__.py index e2430a1e..5e40f4ca 100644 --- a/agentstack/_tools/neon/__init__.py +++ b/agentstack/_tools/neon/__init__.py @@ -1,4 +1,5 @@ import os +from agentstack import tools from neon_api import NeonAPI import psycopg2 from psycopg2.extras import RealDictCursor @@ -16,6 +17,10 @@ def create_database(project_name: str) -> str: Returns: the connection URI for the new project """ + permissions = tools.get_permissions(create_database) + if not permissions.WRITE: + return "User has not granted write permission." + try: project = neon_client.project_create(project={"name": project_name}).project connection_uri = neon_client.connection_uri( @@ -35,6 +40,10 @@ def execute_sql_ddl(connection_uri: str, command: str) -> str: Returns: the result of the DDL command """ + permissions = tools.get_permissions(execute_sql_ddl) + if not permissions.EXECUTE: + return "User has not granted execute permission." + conn = psycopg2.connect(connection_uri) cur = conn.cursor(cursor_factory=RealDictCursor) try: @@ -57,6 +66,14 @@ def run_sql_query(connection_uri: str, query: str) -> str: Returns: the result of the SQL query """ + permissions = tools.get_permissions(run_sql_query) + if 'INSERT' in query or 'UPDATE' in query or 'DELETE' in query: + if not permissions.WRITE: + return "User has not granted write permission." + + if not permissions.READ: + return "User has not granted read permission." + conn = psycopg2.connect(connection_uri) cur = conn.cursor(cursor_factory=RealDictCursor) try: diff --git a/agentstack/_tools/neon/config.json b/agentstack/_tools/neon/config.json index ed860324..c1d41469 100644 --- a/agentstack/_tools/neon/config.json +++ b/agentstack/_tools/neon/config.json @@ -2,6 +2,7 @@ "name": "neon", "category": "database", "url": "https://github.com/neondatabase/neon", + "cta": "Create an API key at https://www.neon.tech", "env": { "NEON_API_KEY": null }, @@ -9,6 +10,16 @@ "neon-api>=0.1.5", "psycopg2-binary==2.9.10" ], - "tools": ["create_database", "execute_sql_ddl", "run_sql_query"], - "cta": "Create an API key at https://www.neon.tech" + "tools": { + "create_database": { + "actions": ["write"] + }, + "execute_sql_ddl": { + "actions": ["execute"] + }, + "run_sql_query": { + "actions": ["read", "write"] + } + } + } \ No newline at end of file diff --git a/agentstack/_tools/open_interpreter/__init__.py b/agentstack/_tools/open_interpreter/__init__.py index 8d922d49..321635ff 100644 --- a/agentstack/_tools/open_interpreter/__init__.py +++ b/agentstack/_tools/open_interpreter/__init__.py @@ -1,4 +1,5 @@ import os +from agentstack import tools from interpreter import interpreter @@ -9,5 +10,9 @@ def execute_code(code: str): """A tool to execute code using Open Interpreter. Returns the output of the code.""" + permissions = tools.get_permissions(execute_code) + if not permissions.EXECUTE: + return "User has not granted execute permission." + result = interpreter.chat(f"execute this code with no changes: {code}") return result diff --git a/agentstack/_tools/open_interpreter/config.json b/agentstack/_tools/open_interpreter/config.json index 1e7e93a6..ad5caa6c 100644 --- a/agentstack/_tools/open_interpreter/config.json +++ b/agentstack/_tools/open_interpreter/config.json @@ -8,5 +8,9 @@ "dependencies": [ "open-interpreter>=0.3.7" ], - "tools": ["execute_code"] + "tools": { + "execute_code": { + "actions": ["execute"] + } + } } \ No newline at end of file diff --git a/agentstack/_tools/payman/__init__.py b/agentstack/_tools/payman/__init__.py index 16f5d8d2..b8f0a2fe 100644 --- a/agentstack/_tools/payman/__init__.py +++ b/agentstack/_tools/payman/__init__.py @@ -1,5 +1,6 @@ import os from typing import Dict, List, Optional, Union, Literal +from agentstack import tools from paymanai import Paymanai # Initialize Payman client @@ -33,6 +34,10 @@ def send_payment( Returns: Dictionary containing payment details """ + permissions = tools.get_permissions(send_payment) + if not permissions.WRITE: + return {"error": "User has not granted write permission."} + try: return client.payments.send_payment( amount_decimal=amount_decimal, @@ -61,6 +66,10 @@ def search_destinations( Returns: List of matching payment destinations with their IDs """ + permissions = tools.get_permissions(search_destinations) + if not permissions.READ: + return [{"error": "User has not granted read permission."}] + try: return client.payments.search_destinations( name=name, @@ -100,6 +109,10 @@ def create_payee( Returns: Dictionary containing the created payee details """ + permissions = tools.get_permissions(create_payee) + if not permissions.WRITE: + return {"error": "User has not granted write permission."} + try: params = { "type": type, @@ -149,6 +162,10 @@ def initiate_customer_deposit( Returns: Dictionary containing the checkout URL """ + permissions = tools.get_permissions(initiate_customer_deposit) + if not permissions.WRITE: + return {"error": "User has not granted write permission."} + try: response = client.payments.initiate_customer_deposit( amount_decimal=amount_decimal, @@ -177,6 +194,10 @@ def get_customer_balance( Returns: Dictionary containing balance information """ + permissions = tools.get_permissions(get_customer_balance) + if not permissions.READ: + return {"error": "User has not granted read permission."} + try: response = client.balances.get_customer_balance(customer_id, currency) return { @@ -199,6 +220,10 @@ def get_spendable_balance( Returns: Dictionary containing balance information """ + permissions = tools.get_permissions(get_spendable_balance) + if not permissions.READ: + return {"error": "User has not granted read permission."} + try: response = client.balances.get_spendable_balance(currency) return { diff --git a/agentstack/_tools/payman/config.json b/agentstack/_tools/payman/config.json index 10eee8d9..f4f94e4e 100644 --- a/agentstack/_tools/payman/config.json +++ b/agentstack/_tools/payman/config.json @@ -1,18 +1,31 @@ { "name": "payman", "category": "financial-infra", - "tools": [ - "send_payment", - "search_available_payees", - "add_payee", - "ask_for_money", - "get_balance" - ], "url": "https://www.paymanai.com", "cta": "Setup your Agents Payman account at https://app.paymanai.com", "env": { "PAYMAN_API_SECRET": null, "PAYMAN_ENVIRONMENT": null }, - "dependencies": ["paymanai>=2.1.0"] + "dependencies": ["paymanai>=2.1.0"], + "tools": { + "send_payment": { + "actions": ["write"] + }, + "search_destinations": { + "actions": ["read"] + }, + "create_payee": { + "actions": ["write"] + }, + "initiate_customer_deposit": { + "actions": ["write"] + }, + "get_customer_balance": { + "actions": ["read"] + }, + "get_spendable_balance": { + "actions": ["read"] + } + } } diff --git a/agentstack/_tools/perplexity/__init__.py b/agentstack/_tools/perplexity/__init__.py index 6422a648..3ace8ad9 100644 --- a/agentstack/_tools/perplexity/__init__.py +++ b/agentstack/_tools/perplexity/__init__.py @@ -1,5 +1,6 @@ import os import requests +from agentstack import tools url = "https://api.perplexity.ai/chat/completions" @@ -10,6 +11,9 @@ def query_perplexity(query: str): """ Use Perplexity to concisely search the internet and answer a query with up-to-date information. """ + permissions = tools.get_permissions(query_perplexity) + if not permissions.READ: + return "User has not granted read permission." payload = { "model": "llama-3.1-sonar-small-128k-online", diff --git a/agentstack/_tools/perplexity/config.json b/agentstack/_tools/perplexity/config.json index 43a80f45..62b0a194 100644 --- a/agentstack/_tools/perplexity/config.json +++ b/agentstack/_tools/perplexity/config.json @@ -8,5 +8,9 @@ "dependencies": [ "requests>=2.30" ], - "tools": ["query_perplexity"] + "tools": { + "query_perplexity": { + "actions": ["read"] + } + } } \ No newline at end of file diff --git a/agentstack/_tools/sql/__init__.py b/agentstack/_tools/sql/__init__.py index 292ff2af..32be2cc4 100644 --- a/agentstack/_tools/sql/__init__.py +++ b/agentstack/_tools/sql/__init__.py @@ -1,10 +1,12 @@ import os import psycopg2 -from typing import Dict, Any +from typing import Optional, Any +from agentstack import tools + connection = None -def _get_connection(): +def _get_connection() -> psycopg2.extensions.connection: """Get PostgreSQL database connection""" global connection @@ -19,11 +21,37 @@ def _get_connection(): return connection -def get_schema() -> Dict[str, Any]: + +def _get_query_action(connection: psycopg2.extensions.connection, query: str) -> Optional[tools.Action]: + """EXPLAIN the query and classify it as READ, WRITE, DELETE, or unknown""" + try: + connection = _get_connection() + with connection.cursor() as cursor: + cursor.execute(f"EXPLAIN {query}") + plan = cursor.fetchone()[0] + operation = plan.split()[0].upper() + + if operation in ('SELECT', 'WITH'): + return tools.Action.READ + elif operation in ('INSERT', 'UPDATE', 'MERGE'): + return tools.Action.WRITE + elif operation == 'DELETE': + return tools.Action.DELETE + + return None + except Exception as e: + return None + + +def get_schema() -> dict[str, Any]: """ Initialize connection and get database schema. Returns a dictionary containing the database schema. """ + permissions = tools.get_permissions(get_schema) + if not permissions.READ: + return {'error': 'User has not granted read permission.'} + try: conn = _get_connection() cursor = conn.cursor() @@ -58,8 +86,8 @@ def get_schema() -> Dict[str, Any]: return schema except Exception as e: - print(f"Error getting database schema: {str(e)}") - return {} + return {'error': str(e)} + def execute_query(query: str) -> list: """ @@ -69,10 +97,19 @@ def execute_query(query: str) -> list: Returns: List of query results """ + permissions = tools.get_permissions(execute_query) + try: conn = _get_connection() cursor = conn.cursor() + # ensure the user has granted permission for this action + action = _get_query_action(conn, query) + if not action in permissions.actions: + return [ + {'error': f'User has not granted {action} permission.'} + ] + # Execute the query cursor.execute(query) results = cursor.fetchall() @@ -82,5 +119,6 @@ def execute_query(query: str) -> list: return results except Exception as e: - print(f"Error executing query: {str(e)}") - return [] + return [ + {'error': str(e)} + ] diff --git a/agentstack/_tools/sql/config.json b/agentstack/_tools/sql/config.json index 67756cff..1a546665 100644 --- a/agentstack/_tools/sql/config.json +++ b/agentstack/_tools/sql/config.json @@ -12,9 +12,13 @@ "dependencies": [ "psycopg2-binary>=2.9.9" ], - "tools": [ - "get_schema", - "execute_query" - ], + "tools": { + "get_schema": { + "actions": ["read"] + }, + "execute_query": { + "actions": ["read", "write", "delete"] + } + }, "cta": "Set up your PostgreSQL connection variables in the environment file." } \ No newline at end of file diff --git a/agentstack/_tools/stripe/__init__.py b/agentstack/_tools/stripe/__init__.py index 9c428f83..145d22de 100644 --- a/agentstack/_tools/stripe/__init__.py +++ b/agentstack/_tools/stripe/__init__.py @@ -3,14 +3,8 @@ from stripe_agent_toolkit.configuration import Configuration, is_tool_allowed from stripe_agent_toolkit.api import StripeAPI from stripe_agent_toolkit.tools import tools +import agentstack -__all__ = [ - "create_payment_link", - "create_product", - "list_products", - "create_price", - "list_prices", -] STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY") @@ -19,19 +13,53 @@ "Stripe Secret Key not found. Did you set the STRIPE_SECRET_KEY in you project's .env file?" ) +tool_config = agentstack.get_tool('stripe') + + +def tool_can_read(tool_name: str) -> bool: + """Check if the tool can read a specific resource.""" + try: + return tool_config.tools[tool_name].READ + except KeyError: + return False + + +def tool_can_write(tool_name: str) -> bool: + """Check if the tool can write to a specific resource.""" + try: + return tool_config.tools[tool_name].WRITE + except KeyError: + return False + +# in order to leverage as much of the offerings of stripe-agent-toolkit as +# possible, we merge our configuration patterns with theirs _configuration = Configuration( { "actions": { + "balance": { + "read": tool_can_read('retrieve_balance'), + }, + "customers": { + "create": tool_can_write('create_customer'), + "read": tool_can_read('list_customers'), + }, + "invoices": { + "create": tool_can_write('create_invoice'), + "update": tool_can_write('finalize_invoice'), + }, + "invoice_items": { + "create": tool_can_write('create_invoice_item'), + }, "payment_links": { - "create": True, + "create": tool_can_write('create_payment_link'), }, "products": { - "create": True, - "read": True, + "create": tool_can_write('create_product'), + "read": tool_can_read('list_products'), }, "prices": { - "create": True, - "read": True, + "create": tool_can_write('create_price'), + "read": tool_can_read('list_prices'), }, } } @@ -44,6 +72,8 @@ def _create_tool_function(tool: dict) -> Callable: """Dynamically create a tool function based on the tool schema.""" + # stripe-agent-toolkit exposes tools as classes by default, this utilizes + # the typing and tooling in a functional way. # `tool` is not typed, but follows this schema: # { # "method": "create_customer", diff --git a/agentstack/_tools/stripe/config.json b/agentstack/_tools/stripe/config.json index 89b18366..33693654 100644 --- a/agentstack/_tools/stripe/config.json +++ b/agentstack/_tools/stripe/config.json @@ -2,6 +2,7 @@ "name": "stripe", "url": "https://github.com/stripe/agent-toolkit", "category": "application-specific", + "cta": "๐Ÿ”‘ Create your Stripe API key here: https://dashboard.stripe.com/account/apikeys", "env": { "STRIPE_SECRET_KEY": null }, @@ -9,12 +10,21 @@ "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" + "tools": { + "create_payment_link": { + "actions": ["write"] + }, + "create_product": { + "actions": ["write"] + }, + "list_products": { + "actions": ["read"] + }, + "create_price": { + "actions": ["write"] + }, + "list_prices": { + "actions": ["read"] + } + } } \ No newline at end of file diff --git a/agentstack/_tools/vision/__init__.py b/agentstack/_tools/vision/__init__.py index 8d67cfd4..023827cb 100644 --- a/agentstack/_tools/vision/__init__.py +++ b/agentstack/_tools/vision/__init__.py @@ -4,7 +4,10 @@ import base64 import tempfile import requests +from fnmatch import fnmatch import anthropic +from agentstack import tools + __all__ = ["analyze_image"] @@ -12,6 +15,7 @@ MODEL = os.getenv('VISION_MODEL', "claude-3-5-sonnet-20241022") MAX_TOKENS: int = int(os.getenv('VISION_MAX_TOKENS', 1024)) + MEDIA_TYPES = { "jpg": "image/jpeg", "jpeg": "image/jpeg", @@ -30,6 +34,11 @@ # 1:2 784x1568 px +def _is_path_allowed(path: str, allowed_patterns: list[str]) -> bool: + """Check if the given path matches any of the allowed patterns.""" + return any(fnmatch(path, pattern) for pattern in allowed_patterns) + + def _get_media_type(image_filename: str) -> Optional[str]: """Get the media type from an image filename.""" for ext, media_type in MEDIA_TYPES.items(): @@ -99,6 +108,10 @@ def analyze_image(image_path_or_url: str) -> str: Returns: str: Description of the image contents """ + permissions = tools.get_permissions(analyze_image) + if not permissions.READ: + return "User has not granted read permission." + if not image_path_or_url: return "Image Path or URL is required." @@ -107,5 +120,16 @@ def analyze_image(image_path_or_url: str) -> str: return f"Unsupported image type use {ALLOWED_MEDIA_TYPES}." if "http" in image_path_or_url: + if not permissions.allow_http: + return "User has not granted permission to access the internet." + return _analyze_web_image(image_path_or_url, media_type) + + if permissions.allowed_dirs: + if not _is_path_allowed(image_path_or_url, permissions.allowed_dirs): + return ( + f"Error: Access to file {image_path_or_url} is not allowed. " + f"Allowed directories: {permissions.allowed_dirs}" + ) + return _analyze_local_image(image_path_or_url, media_type) diff --git a/agentstack/_tools/vision/config.json b/agentstack/_tools/vision/config.json index 0852aa20..237f4fb8 100644 --- a/agentstack/_tools/vision/config.json +++ b/agentstack/_tools/vision/config.json @@ -11,5 +11,11 @@ "anthropic>=0.45.2", "requests>=2.31.0" ], - "tools": ["analyze_image"] + "tools": { + "analyze_image": { + "actions": ["read"], + "allow_http": true, + "allowed_dirs": ["*"] + } + } } diff --git a/agentstack/_tools/weaviate/__init__.py b/agentstack/_tools/weaviate/__init__.py index 8f38de1c..4f91926f 100644 --- a/agentstack/_tools/weaviate/__init__.py +++ b/agentstack/_tools/weaviate/__init__.py @@ -2,6 +2,7 @@ import json import weaviate from typing import Optional +from agentstack import tools from weaviate.classes.config import Configure from weaviate.classes.init import Auth @@ -45,6 +46,10 @@ def search_collection( Returns: str: JSON string containing search results """ + permissions = tools.get_permissions(search_collection) + if not permissions.READ: + return "User has not granted read permission." + headers = {"X-OpenAI-Api-Key": openai_key} vectorizer = Configure.Vectorizer.text2vec_openai(model=model) @@ -85,6 +90,10 @@ def create_collection( Returns: str: Success message """ + permissions = tools.get_permissions(create_collection) + if not permissions.WRITE: + return "User has not granted write permission." + headers = {"X-OpenAI-Api-Key": openai_key} vectorizer = Configure.Vectorizer.text2vec_openai(model=model) diff --git a/agentstack/_tools/weaviate/config.json b/agentstack/_tools/weaviate/config.json index 1323a10f..fc2c1086 100644 --- a/agentstack/_tools/weaviate/config.json +++ b/agentstack/_tools/weaviate/config.json @@ -2,6 +2,7 @@ "name": "weaviate", "url": "https://github.com/weaviate/weaviate-python-client", "category": "vector-store", + "cta": "๐Ÿ”— Create your Weaviate cluster here: https://console.weaviate.cloud/", "env": { "WEAVIATE_URL": null, "WEAVIATE_API_KEY": null, @@ -11,9 +12,12 @@ "weaviate-client>=3.0.0", "openai>=1.0.0" ], - "tools": [ - "search_collection", - "create_collection" - ], - "cta": "๐Ÿ”— Create your Weaviate cluster here: https://console.weaviate.cloud/" + "tools": { + "search_collection": { + "actions": ["read"] + }, + "create_collection": { + "actions": ["write"] + } + } } diff --git a/agentstack/agents.py b/agentstack/agents.py index a2661d19..5eb3ac4b 100644 --- a/agentstack/agents.py +++ b/agentstack/agents.py @@ -2,19 +2,15 @@ import os from pathlib import Path import pydantic -from ruamel.yaml import YAML, YAMLError -from ruamel.yaml.scalarstring import FoldedScalarString from agentstack import conf, log from agentstack.exceptions import ValidationError +from agentstack import yaml from agentstack.providers import parse_provider_model AGENTS_FILENAME: Path = Path("src/config/agents.yaml") AGENTS_PROMPT_TPL: str = "You are {role}. {backstory}\nYour personal goal is: {goal}" -yaml = YAML() -yaml.preserve_quotes = True # Preserve quotes in existing data - class AgentConfig(pydantic.BaseModel): """ @@ -57,10 +53,10 @@ def __init__(self, name: str): try: with open(filename, 'r') as f: - data = yaml.load(f) or {} + data = yaml.parser.load(f) or {} data = data.get(name, {}) or {} super().__init__(**{**{'name': name}, **data}) - except YAMLError as e: + except yaml.YAMLError as e: # TODO format MarkedYAMLError lines/messages raise ValidationError(f"Error parsing agents file: {filename}\n{e}") except pydantic.ValidationError as e: @@ -93,7 +89,7 @@ def model_dump(self, *args, **kwargs) -> dict: dump.pop('name') # name is the key, so keep it out of the data # format these as FoldedScalarStrings for key in ('role', 'goal', 'backstory'): - dump[key] = FoldedScalarString(dump.get(key) or "") + dump[key] = yaml.FoldedScalarString(dump.get(key) or "") return {self.name: dump} def write(self): @@ -101,12 +97,12 @@ def write(self): filename = conf.PATH / AGENTS_FILENAME with open(filename, 'r') as f: - data = yaml.load(f) or {} + data = yaml.parser.load(f) or {} data.update(self.model_dump()) with open(filename, 'w') as f: - yaml.dump(data, f) + yaml.parser.dump(data, f) def __enter__(self) -> 'AgentConfig': return self @@ -121,7 +117,7 @@ def get_all_agent_names() -> list[str]: log.debug(f"Project does not have an {AGENTS_FILENAME} file.") return [] with open(filename, 'r') as f: - data = yaml.load(f) or {} + data = yaml.parser.load(f) or {} return list(data.keys()) diff --git a/agentstack/frameworks/__init__.py b/agentstack/frameworks/__init__.py index e9cc6494..94eed277 100644 --- a/agentstack/frameworks/__init__.py +++ b/agentstack/frameworks/__init__.py @@ -8,12 +8,12 @@ from agentstack import conf from agentstack.exceptions import ValidationError from agentstack.generation import InsertionPoint -from agentstack.utils import get_framework +from agentstack.utils import get_framework, get_package_path from agentstack import packaging from agentstack.generation import asttools from agentstack.agents import AgentConfig, get_all_agent_names from agentstack.tasks import TaskConfig, get_all_task_names -from agentstack._tools import ToolConfig +from agentstack._tools import get_tool, ToolConfig from agentstack import graph @@ -309,6 +309,14 @@ def get_entrypoint_path(framework: str) -> Path: return conf.PATH / module.ENTRYPOINT +def get_templates_path(framework: str) -> Path: + """ + Get the path to the templates for a framework. + """ + path = get_package_path() / 'frameworks/templates' / framework + return path / "{{cookiecutter.project_metadata.project_slug}}" + + def validate_project(): """ Validate that the user's project is ready to run. @@ -383,6 +391,7 @@ def remove_tool(tool: ToolConfig, agent_name: str): def get_tool_callables(tool_name: str) -> list[Callable]: """ Get a tool by name and return it as a list of framework-native callables. + This will only return the functions that the user has allowed in tools.yaml. """ # TODO: remove after agentops fixes their issue @@ -410,8 +419,9 @@ def wrapped_method(*args, **kwargs): return wrapped_method tool_funcs = [] - tool_config = ToolConfig.from_tool_name(tool_name) - for tool_func_name in tool_config.tools: + tool_config = get_tool(tool_name) + # `allowed_tools` takes the the project's permissions into account + for tool_func_name in tool_config.allowed_tools: tool_func = getattr(tool_config.module, tool_func_name) assert callable(tool_func), f"Tool function {tool_func_name} is not callable." diff --git a/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/config/tools.yaml b/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/config/tools.yaml new file mode 100644 index 00000000..54069d5b --- /dev/null +++ b/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/config/tools.yaml @@ -0,0 +1,37 @@ +# Project Tool Configuration +# -------------------------- +# This file controls how tools are made available to your project. +# +# To make a tool's function available to your agents, just include an entry +# in the tool's section. Tools will be automatically added to this list as you +# add them to your project. Remove any tools you don't need to keep your +# agents focused on the task at hand (you can also just comment them out). +# +# ``` +# tool_name: +# function_name: ~ +# ``` +# +# All tools support an `actions` attribute, which controls the actions the tool +# is allowed to perform. Available options are: [`read`, `write`, `execute`] +# though not all tools support all actions. +# +# ``` +# tool_name: +# function_name: +# actions: ['read', 'write'] +# ``` +# +# You can also override permissions defined by the tool, like paths it is able to +# access, or file types it is able to read/write. It is up to the tool author +# to define what options are available, so check the documentation for the tool +# you are using. https://docs.agentstack.sh/tools +# +# ``` +# tool_name: +# function_name: +# actions: ['read', 'write'] +# allowed_dirs: ['/home/user/*'] +# allowed_extensions: ['*.txt', '*.md'] +# ``` +_preserve_comments_: diff --git a/agentstack/frameworks/templates/langgraph/{{cookiecutter.project_metadata.project_slug}}/src/config/tools.yaml b/agentstack/frameworks/templates/langgraph/{{cookiecutter.project_metadata.project_slug}}/src/config/tools.yaml new file mode 100644 index 00000000..54069d5b --- /dev/null +++ b/agentstack/frameworks/templates/langgraph/{{cookiecutter.project_metadata.project_slug}}/src/config/tools.yaml @@ -0,0 +1,37 @@ +# Project Tool Configuration +# -------------------------- +# This file controls how tools are made available to your project. +# +# To make a tool's function available to your agents, just include an entry +# in the tool's section. Tools will be automatically added to this list as you +# add them to your project. Remove any tools you don't need to keep your +# agents focused on the task at hand (you can also just comment them out). +# +# ``` +# tool_name: +# function_name: ~ +# ``` +# +# All tools support an `actions` attribute, which controls the actions the tool +# is allowed to perform. Available options are: [`read`, `write`, `execute`] +# though not all tools support all actions. +# +# ``` +# tool_name: +# function_name: +# actions: ['read', 'write'] +# ``` +# +# You can also override permissions defined by the tool, like paths it is able to +# access, or file types it is able to read/write. It is up to the tool author +# to define what options are available, so check the documentation for the tool +# you are using. https://docs.agentstack.sh/tools +# +# ``` +# tool_name: +# function_name: +# actions: ['read', 'write'] +# allowed_dirs: ['/home/user/*'] +# allowed_extensions: ['*.txt', '*.md'] +# ``` +_preserve_comments_: diff --git a/agentstack/frameworks/templates/llamaindex/{{cookiecutter.project_metadata.project_slug}}/src/config/tools.yaml b/agentstack/frameworks/templates/llamaindex/{{cookiecutter.project_metadata.project_slug}}/src/config/tools.yaml new file mode 100644 index 00000000..54069d5b --- /dev/null +++ b/agentstack/frameworks/templates/llamaindex/{{cookiecutter.project_metadata.project_slug}}/src/config/tools.yaml @@ -0,0 +1,37 @@ +# Project Tool Configuration +# -------------------------- +# This file controls how tools are made available to your project. +# +# To make a tool's function available to your agents, just include an entry +# in the tool's section. Tools will be automatically added to this list as you +# add them to your project. Remove any tools you don't need to keep your +# agents focused on the task at hand (you can also just comment them out). +# +# ``` +# tool_name: +# function_name: ~ +# ``` +# +# All tools support an `actions` attribute, which controls the actions the tool +# is allowed to perform. Available options are: [`read`, `write`, `execute`] +# though not all tools support all actions. +# +# ``` +# tool_name: +# function_name: +# actions: ['read', 'write'] +# ``` +# +# You can also override permissions defined by the tool, like paths it is able to +# access, or file types it is able to read/write. It is up to the tool author +# to define what options are available, so check the documentation for the tool +# you are using. https://docs.agentstack.sh/tools +# +# ``` +# tool_name: +# function_name: +# actions: ['read', 'write'] +# allowed_dirs: ['/home/user/*'] +# allowed_extensions: ['*.txt', '*.md'] +# ``` +_preserve_comments_: diff --git a/agentstack/frameworks/templates/openai_swarm/{{cookiecutter.project_metadata.project_slug}}/src/config/tools.yaml b/agentstack/frameworks/templates/openai_swarm/{{cookiecutter.project_metadata.project_slug}}/src/config/tools.yaml new file mode 100644 index 00000000..54069d5b --- /dev/null +++ b/agentstack/frameworks/templates/openai_swarm/{{cookiecutter.project_metadata.project_slug}}/src/config/tools.yaml @@ -0,0 +1,37 @@ +# Project Tool Configuration +# -------------------------- +# This file controls how tools are made available to your project. +# +# To make a tool's function available to your agents, just include an entry +# in the tool's section. Tools will be automatically added to this list as you +# add them to your project. Remove any tools you don't need to keep your +# agents focused on the task at hand (you can also just comment them out). +# +# ``` +# tool_name: +# function_name: ~ +# ``` +# +# All tools support an `actions` attribute, which controls the actions the tool +# is allowed to perform. Available options are: [`read`, `write`, `execute`] +# though not all tools support all actions. +# +# ``` +# tool_name: +# function_name: +# actions: ['read', 'write'] +# ``` +# +# You can also override permissions defined by the tool, like paths it is able to +# access, or file types it is able to read/write. It is up to the tool author +# to define what options are available, so check the documentation for the tool +# you are using. https://docs.agentstack.sh/tools +# +# ``` +# tool_name: +# function_name: +# actions: ['read', 'write'] +# allowed_dirs: ['/home/user/*'] +# allowed_extensions: ['*.txt', '*.md'] +# ``` +_preserve_comments_: diff --git a/agentstack/generation/tool_generation.py b/agentstack/generation/tool_generation.py index e0ea41ac..64e8e0c2 100644 --- a/agentstack/generation/tool_generation.py +++ b/agentstack/generation/tool_generation.py @@ -8,7 +8,7 @@ from agentstack import frameworks from agentstack import packaging from agentstack.utils import term_color -from agentstack._tools import ToolConfig +from agentstack._tools import Action, ToolPermission, ToolConfig, UserToolConfig from agentstack.generation import asttools from agentstack.generation.files import EnvFile @@ -32,6 +32,14 @@ def add_tool(name: str, agents: Optional[list[str]] = []): for var, value in tool.env.items(): env.append_if_new(var, value) + # create config/tools.yaml if it doesn't exist + # this is for migrating older projects + if not UserToolConfig.exists(): + UserToolConfig.initialize() + # add stubs to UserToolConfig + with UserToolConfig(name) as user_tool_config: + user_tool_config.add_stubs() + if tool.post_install: os.system(tool.post_install) @@ -88,8 +96,8 @@ def {tool_name}_tool(value: str) -> str: tool_config = ToolConfig( name=tool_name, category="custom", - tools=[f'{tool_name}_tool', ], ) + tool_config.add_tool(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 diff --git a/agentstack/inputs.py b/agentstack/inputs.py index bc3b51b6..6d00d6c5 100644 --- a/agentstack/inputs.py +++ b/agentstack/inputs.py @@ -1,17 +1,13 @@ from typing import Optional import os from pathlib import Path -from ruamel.yaml import YAML, YAMLError -from ruamel.yaml.scalarstring import FoldedScalarString from agentstack import conf, log from agentstack.exceptions import ValidationError +from agentstack import yaml INPUTS_FILENAME: Path = Path("src/config/inputs.yaml") -yaml = YAML() -yaml.preserve_quotes = True # Preserve quotes in existing data - # run_inputs are set at the beginning of the run and are not saved run_inputs: dict[str, str] = {} @@ -38,8 +34,8 @@ def __init__(self): try: with open(filename, 'r') as f: - self._attributes = yaml.load(f) or {} - except YAMLError as e: + self._attributes = yaml.parser.load(f) or {} + except yaml.YAMLError as e: # TODO format MarkedYAMLError lines/messages raise ValidationError(f"Error parsing inputs file: {filename}\n{e}") @@ -58,13 +54,13 @@ def to_dict(self) -> dict[str, str]: def model_dump(self) -> dict: dump = {} for key, value in self._attributes.items(): - dump[key] = FoldedScalarString(value) + dump[key] = yaml.FoldedScalarString(value) return dump def write(self): log.debug(f"Writing inputs to {INPUTS_FILENAME}") with open(conf.PATH / INPUTS_FILENAME, 'w') as f: - yaml.dump(self.model_dump(), f) + yaml.parser.dump(self.model_dump(), f) def __enter__(self) -> 'InputsConfig': return self diff --git a/agentstack/tasks.py b/agentstack/tasks.py index 7a5c6e3a..a6ea631a 100644 --- a/agentstack/tasks.py +++ b/agentstack/tasks.py @@ -2,10 +2,9 @@ import os from pathlib import Path import pydantic -from ruamel.yaml import YAML, YAMLError -from ruamel.yaml.scalarstring import FoldedScalarString from agentstack import conf, log from agentstack.exceptions import ValidationError +from agentstack import yaml TASKS_FILENAME: Path = Path("src/config/tasks.yaml") @@ -14,9 +13,6 @@ "\nCurrent Task: {description}\n\nBegin! This is VERY important to you, use the " "tools available and give your best Final Answer, your job depends on it!\n\nThought:") -yaml = YAML() -yaml.preserve_quotes = True # Preserve quotes in existing data - class TaskConfig(pydantic.BaseModel): """ @@ -55,10 +51,10 @@ def __init__(self, name: str): try: with open(filename, 'r') as f: - data = yaml.load(f) or {} + data = yaml.parser.load(f) or {} data = data.get(name, {}) or {} super().__init__(**{**{'name': name}, **data}) - except YAMLError as e: + except yaml.YAMLError as e: # TODO format MarkedYAMLError lines/messages raise ValidationError(f"Error parsing tasks file: {filename}\n{e}") except pydantic.ValidationError as e: @@ -80,7 +76,7 @@ def model_dump(self, *args, **kwargs) -> dict: dump.pop('name') # name is the key, so keep it out of the data # format these as FoldedScalarStrings for key in ('description', 'expected_output', 'agent'): - dump[key] = FoldedScalarString(dump.get(key) or "") + dump[key] = yaml.FoldedScalarString(dump.get(key) or "") return {self.name: dump} def write(self): @@ -88,12 +84,12 @@ def write(self): filename = conf.PATH / TASKS_FILENAME with open(filename, 'r') as f: - data = yaml.load(f) or {} + data = yaml.parser.load(f) or {} data.update(self.model_dump()) with open(filename, 'w') as f: - yaml.dump(data, f) + yaml.parser.dump(data, f) def __enter__(self) -> 'TaskConfig': return self @@ -108,7 +104,7 @@ def get_all_task_names() -> list[str]: log.debug(f"Project does not have an {TASKS_FILENAME} file.") return [] with open(filename, 'r') as f: - data = yaml.load(f) or {} + data = yaml.parser.load(f) or {} return list(data.keys()) diff --git a/agentstack/yaml.py b/agentstack/yaml.py new file mode 100644 index 00000000..cae59b4d --- /dev/null +++ b/agentstack/yaml.py @@ -0,0 +1,20 @@ +from ruamel.yaml import YAML, YAMLError +from ruamel.yaml.scalarstring import FoldedScalarString + + +__all__ = ( + 'parser', + 'YAMLError', + 'FoldedScalarString', +) + +def _represent_none_as_tilde(self, data) -> None: + return self.represent_scalar('tag:yaml.org,2002:null', '~') + + +parser: YAML = YAML() +parser.preserve_quotes = True # Preserve quotes in existing data + +# this affects all instances, so putting it here to make that obvious +parser.representer.add_representer(type(None), _represent_none_as_tilde) + diff --git a/docs/llms.txt b/docs/llms.txt index fa23ea5c..e904cfa5 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -1,73 +1,3 @@ -## introduction.mdx - ---- -title: Introduction -description: 'The easiest way to start your agent project' -icon: 'hand-point-up' ---- - -AgentStack Logo -AgentStack Logo - -AgentStack is a valuable developer tool for quickly scaffolding agent projects. - -_Think `create-next-app` for Agents._ - -### Features of AgentStack -- Instant project setup with `agentstack init` -- Useful CLI commands for generating new agents and tasks in the development cycle -- A myriad of pre-built tools for Agents - -## What is _the agent stack_ -The agent stack is the list of tools that are collectively the _agent stack_. - -This is similar to the tech stack of a web app. An agent's tech stack is comprised of the following: - -Agent Stack Example - -Whether a project is built with AgentStack or not, the concept of the agent stack remains the same. - -## What is **AgentStack** -Our project is called **AgentStack** because it's the easiest way to quickly scaffold your agent stack! With a couple CLI commands, you can create a near-production ready agent! - -## First Steps - - - - Install the AgentStack CLI - - - A quickstart guide to using the CLI - - - High level overview of AgentStack - ![thumbnail](https://cdn.loom.com/sessions/thumbnails/b87b6a42d99c435a9ee328bf3e57a594-c297554684e16934-full-play.gif) - - - Build a simple web scraper agent - ![thumbnail](https://cdn.loom.com/sessions/thumbnails/68d796b13cd94647bd1d7fae12b2358e-5d62273c24a53191-full-play.gif) - - - ## installation.mdx --- @@ -180,128 +110,263 @@ To generate a new task, run `agentstack generate task ` - [More Info] -## contributing/adding-tools.mdx +## introduction.mdx --- -title: 'Adding Tools' -description: 'Contribute your own Agent tool to the ecosystem' +title: Introduction +description: 'The easiest way to start your agent project' +icon: 'hand-point-up' --- -If you're reading this section, you probably have a product that AI agents can use as a tool. We're glad you're here! - -Adding tools is easy once you understand the project structure. A few things need to be done for a tool to be considered completely supported: +AgentStack Logo +AgentStack Logo - - - - 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/_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. - - - Manually test your tool integration by running `agentstack tools add ` and ensure it behaves as expected. - This must be done within an AgentStack project. To create your test project, run `agentstack init test_proj`, then `cd` into the project and try adding your tool. - - - - +AgentStack is a valuable developer tool for quickly scaffolding agent projects. -# Tool Config -- `name` (str) - Name of your tool -- `category` (str) - Category your tool belongs in -- `tools` (List[str]) - The exported functions within your tool file -- `url` (str) - URL to where developers can learn more about your tool -- `tools_bundled` (bool) - True if the tool file exports a list of tools -- `cta` (str) - Call To Action printed in the terminal after install -- `env` (dict) - Key: Environment variable name; Value: default value -- `packages` (List[str]) - Python packages to be installed to support your tool -- `post_install` (str) - A script to be run after install of your tool -- `post_remove` (str) - A script to be run after removal of your tool +_Think `create-next-app` for Agents._ -## contributing/how-to-contribute.mdx +### Features of AgentStack +- Instant project setup with `agentstack init` +- Useful CLI commands for generating new agents and tasks in the development cycle +- A myriad of pre-built tools for Agents ---- -title: 'How To Contribute' -description: 'Contribute your own Agent tool to the ecosystem' ---- +## What is _the agent stack_ +The agent stack is the list of tools that are collectively the _agent stack_. -First of all, __thank you__ for your interest in contributing to AgentStack! Even the smallest contributions help a _ton_. +This is similar to the tech stack of a web app. An agent's tech stack is comprised of the following: -Our vision is to build the de facto CLI for quickly spinning up an AI Agent project. We want to be the [create-react-app](https://create-react-app.dev/) of agents. Our inspiration also includes the oh-so-convenient [Angular CLI](https://v17.angular.io/cli). +Agent Stack Example -## How to Help +Whether a project is built with AgentStack or not, the concept of the agent stack remains the same. -Grab an issue from the [issues tab](https://github.com/AgentOps-AI/AgentStack/issues)! Plenty are labelled "Good First Issue". Fork the repo and create a PR when ready! +## What is **AgentStack** +Our project is called **AgentStack** because it's the easiest way to quickly scaffold your agent stack! With a couple CLI commands, you can create a near-production ready agent! -The best place to engage in conversation about your contribution is in the Issue chat or on our [Discord](https://discord.gg/JdWkh9tgTQ). +## First Steps -## Setup + + + Install the AgentStack CLI + + + A quickstart guide to using the CLI + + + High level overview of AgentStack + ![thumbnail](https://cdn.loom.com/sessions/thumbnails/b87b6a42d99c435a9ee328bf3e57a594-c297554684e16934-full-play.gif) + + + Build a simple web scraper agent + ![thumbnail](https://cdn.loom.com/sessions/thumbnails/68d796b13cd94647bd1d7fae12b2358e-5d62273c24a53191-full-play.gif) + + -1. `git clone https://github.com/AgentOps-AI/AgentStack.git` - `cd AgentStack` -2. `uv pip install -e ".[dev,test]` - - This will install the CLI locally and in editable mode so you can use `agentstack ` to test your latest changes - - Note that after you initialize a project, it will install it's own version of `agentstack` in the project's - virtual environment. To use your local version, run `uv pip install -e "../AgentStack/.[]"` to get - your development version inside of the project, too. +## cli-reference/cli.mdx -## Project Structure +--- +title: 'CLI Reference' +description: 'Everything to do with the CLI' +--- -A detailed overview of the project structure is available at [Project Structure](https://docs.agentstack.sh/contributing/project-structure). +It all starts with calling +```bash +$ agentstack +``` +### Shortcut Aliases +Many top-level AgentStack commands can be invoked using a single-letter prefix to save keystrokes. These are indicated +in the command's documentation here after a `|` character. Run `agentstack help` for the full list. -## Before Making a Pull Request +### Global Flags +These flags work with all commands: -Make sure tests pass, type checking is correct, and ensure your code is formatted correctly. +`--debug` - Print a full traceback when an error is encountered. This also enables printing additional debug information +from within AgentStack useful for development and debugging. -1. `tox -m quick` - - This will run tests for Python version 3.12 only. You can run tests on all supported versions with `tox`. -2. `mypy agentstack` - - Please resolve all type checking errors before marking your PR as ready for review. -3. `ruff` - - We use `ruff` to ensure consistency in our codebase. +`--path=` - Set the working directory of the current AgentStack project. By default `agentstack` works inside of the +current directory and looks for an `agentstack.json` file there. By passing a path to this flag you can work on a project +from outside of it's directory. -## Tests +`--version` - Prints the current version and exits. -We're actively working toward increasing our test coverage. Make sure to review the `codecov` output of your -tests to ensure your contribution is well tested. We use `tox` to run our tests, which sets up individual -environments for each framework and Python version we support. Tests are run when a PR is pushed to, and -contributions without passing tests will not be merged. -You can test a specific Python version and framework by running: `tox -e py312-`, but keep in mind -that the coverage report will be incomplete. +## `$ agentstack init` +This initializes a new AgentStack project. +```bash +agentstack init +``` -## contributing/project-structure.mdx +`slug_name` is the name of your project, and will be created as a directory to initialize your project inside. When the +default arguments are passed, a starter project template will be used, which adds a single agent, a single task and +demonstrates the use of a tool. ---- -title: 'Project Structure' -description: 'Concepts and Structure of AgentStack' ---- +### Init Creates a Virtual Environment +AgentStack creates a new directory, initializes a new virtual environment, installs dependencies, and populates the project +structure. After `init` completes, `cd` into the directory, activate the virtual environment with `source .venv/bin/activate`. +Virtual environments and package management are handled by the `uv` package manager. -> This document is a work-in-progress as we build to version 0.3 and helps -define the structure of the project that we are aiming to create. +### Initializing with the Wizard +You can pass the `--wizard` flag to `agentstack init` to use an interactive project configuration wizard. -AgentStack is a framework-agnostic toolkit for bootstrapping and managing -AI agents. Out of the box it has support for a number of tools and generates -code to get your project off the ground and deployed to a production environment. -It also aims to provide robust tooling for running and managing agents including -logging, debugging, deployment, and observability via [AgentOps](https://www.agentops.ai/). +### Initializing from a Template +You can also pass a `--template=` argument to `agentstack init` which will pre-populate your project with functionality +from a built-in template, or one found on the internet. A `template_name` can be one of three identifiers: -Developers with limited agent experience should be able to get an agentic -workflow up and running in a matter of minutes. Developers with more experience -should be able to leverage the tools provided by AgentStack to create more -complex workflows and deploy them to production with ease. +- A built-in AgentStack template (see the `templates` directory in the AgentStack repo for bundled templates). +- A template file from the internet; pass the full https URL of the template. +- A local template file; pass an absolute or relative path. -# Concepts -## Projects -A project is a user's implementation of AgentStack that is used to implement -and agentic workflow. This is a directory the `agentstack` shell command is +## `$ agentstack run` +This runs your AgentStack project. +```bash +agentstack run +``` + +Environment variables will be loaded from `~/.env` and from the `.env` file inside your project directory. Make sure you +have enabled your project's `venv` before executing to include all dependencies required. + +### Overriding Inputs +Your project defines Inputs which are used to customize the Agent and Task prompts for a specific task. In cases where +using the `inputs.yaml` file to populate data is not flexible enough, `run` can accept value overrides for all defined +inputs. Use `--input-=` to pass data which will only be used on this run. + +For example, if you have a key in your `inputs.yaml` file named `topic` and want to override it for this run, you would +use the following command: + +```bash +agentstack run --input-topic=Sports +``` + +### Running other project commands +By default, `run` will call the `main()` function inside your project's `main.py` file. You can pass alternate function +names to run with `--function=`. + + +## Generate +Code generation commands for automatically creating new agents or tasks. + +### `$ agentstack generate agent | agentstack g a` +Generate a new agent +- `agent_name` (required | str) - the name of the agent +- `--role` (optional | str) - Prompt parameter: The role of the agent +- `--goal` (optional | str) - Prompt parameter: The goal of the agent +- `--backstory` (optional | str) - Prompt parameter: The backstory of the agent +- `--llm` (optional | `/`) - Which model to use for this agent + +#### Default LLM +All arguments to generate a new Agent are optional. A default LLM can be configured in `agentstack.json`under the +`default_model` setting to populate a provider/model. If you are generating an agent in a project which does not have +a default model set, you will be prompted to configure one. + +#### Example +```bash Generate Agent +agentstack generate agent script_writer +``` + +### `$ agentstack generate task | agentstack g t` +Generate a new task +- `task_name` (required | str) - the name of the task +- `--description` (optional | str) - Prompt parameter: Explain the task in detail +- `--expected_output` (optional | str) - What is the expected output from the agent (ex: data in json format) +- `--agent` (optional | str) - The name of the agent of which to assign the task to (when using Crew in sequential mode) + +#### Example +```bash Generate Task +agentstack g t gen_script --description "Write a short film script about secret agents" +``` + +## Tools +Tools are what make AgentStack powerful. Adding and removing Tools from Agents is easy with this command. + +### `$ agentstack tools list | agentstack t l` +Lists all tools available in AgentStack. + +### `$ agentstack tools add | agentstack t a` +Shows an interactive interface for selecting which Tool to add and which Agents to add it to. + +#### Add a Tool to all Agents +When a tool_name is provided it will be made available to all Agents in the project. +```bash +$ agentstack tools add +``` + +#### Add a Tool to a single Agent +When an agent_name is provided, the tool will be made available to only that agent. +```bash +$ agentstack tools add --agent= +``` + +#### Add a Tool to multiple Agents +When a comma-separated list of Agents is passed, the tool will be made available to those agents. +```bash +$ agentstack tools add --agents=,, +``` + +### `$ agentstack tools remove ` +Removes a tool from all Agents in the project. + + +## Templates +Projects can be exported into a template to facilitate sharing configurations. Re-initialize a project from a template +with `agentstack init --template=`. + +### `$ agentstack export ` +The current project will be written to a JSON template at the provided filename. + +## `$ agentstack update` +Check for updates and allow the user to install the latest release of AgentStack. + +## `$ agentstack login` +Authenticate with [agentstack.sh](https://agentstack.sh) for hosted integrations. + + + +## contributing/project-structure.mdx + +--- +title: 'Project Structure' +description: 'Concepts and Structure of AgentStack' +--- + +> This document is a work-in-progress as we build to version 0.3 and helps +define the structure of the project that we are aiming to create. + +AgentStack is a framework-agnostic toolkit for bootstrapping and managing +AI agents. Out of the box it has support for a number of tools and generates +code to get your project off the ground and deployed to a production environment. +It also aims to provide robust tooling for running and managing agents including +logging, debugging, deployment, and observability via [AgentOps](https://www.agentops.ai/). + +Developers with limited agent experience should be able to get an agentic +workflow up and running in a matter of minutes. Developers with more experience +should be able to leverage the tools provided by AgentStack to create more +complex workflows and deploy them to production with ease. + +# Concepts + +## Projects +A project is a user's implementation of AgentStack that is used to implement +and agentic workflow. This is a directory the `agentstack` shell command is executed from. ## Frameworks @@ -705,143 +770,118 @@ to OpenAI Swarms is contained in this package. as a framework. -## templates/system_analyzer.mdx +## contributing/how-to-contribute.mdx --- -title: 'System Analyzer' -description: 'Inspect a project directory and improve it' +title: 'How To Contribute' +description: 'Contribute your own Agent tool to the ecosystem' --- -[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/system_analyzer.json) +First of all, __thank you__ for your interest in contributing to AgentStack! Even the smallest contributions help a _ton_. -```bash -agentstack init --template=system_analyzer -``` +Our vision is to build the de facto CLI for quickly spinning up an AI Agent project. We want to be the [create-react-app](https://create-react-app.dev/) of agents. Our inspiration also includes the oh-so-convenient [Angular CLI](https://v17.angular.io/cli). -# Purpose +## How to Help -This agent will accept a query as a string, use Perplexity to research it. Another agent will take the data gathered and perform an analysis focused on answering the query. +Grab an issue from the [issues tab](https://github.com/AgentOps-AI/AgentStack/issues)! Plenty are labelled "Good First Issue". Fork the repo and create a PR when ready! -# Inputs +The best place to engage in conversation about your contribution is in the Issue chat or on our [Discord](https://discord.gg/JdWkh9tgTQ). -`system_path` (str): the absolute path to +## Setup -## templates/researcher.mdx +1. `git clone https://github.com/AgentOps-AI/AgentStack.git` + `cd AgentStack` +2. `uv pip install -e ".[dev,test]` + - This will install the CLI locally and in editable mode so you can use `agentstack ` to test your latest changes + - Note that after you initialize a project, it will install it's own version of `agentstack` in the project's + virtual environment. To use your local version, run `uv pip install -e "../AgentStack/.[]"` to get + your development version inside of the project, too. ---- -title: 'Researcher' -description: 'Research and report result from a query' ---- +## Project Structure -[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/research.json) +A detailed overview of the project structure is available at [Project Structure](https://docs.agentstack.sh/contributing/project-structure). -```bash -agentstack init --template=research -``` -# Purpose +## Before Making a Pull Request -This agent will accept a query as a string, use Perplexity to research it. Another agent will take the data gathered and perform an analysis focused on answering the query. +Make sure tests pass, type checking is correct, and ensure your code is formatted correctly. -# Inputs +1. `tox -m quick` + - This will run tests for Python version 3.12 only. You can run tests on all supported versions with `tox`. +2. `mypy agentstack` + - Please resolve all type checking errors before marking your PR as ready for review. +3. `ruff` + - We use `ruff` to ensure consistency in our codebase. -`query` (str): the query for the agent to research and report on +## Tests -## templates/templates.mdx +We're actively working toward increasing our test coverage. Make sure to review the `codecov` output of your +tests to ensure your contribution is well tested. We use `tox` to run our tests, which sets up individual +environments for each framework and Python version we support. Tests are run when a PR is pushed to, and +contributions without passing tests will not be merged. + +You can test a specific Python version and framework by running: `tox -e py312-`, but keep in mind +that the coverage report will be incomplete. + +## contributing/adding-tools.mdx --- -title: 'Templates' -description: 'Default AgentStack templates' +title: 'Adding Tools' +description: 'Contribute your own Agent tool to the ecosystem' --- -_Templates are a really powerful tool within AgentStack!_ +If you're reading this section, you probably have a product that AI agents can use as a tool. We're glad you're here! -# Start a new project with a template -Initializing a new project with AgentStack involves adding just one argument: -```bash -agentstack init --template= -``` +Adding tools is easy once you understand the project structure. A few things need to be done for a tool to be considered completely supported: -Templates can also be passed as a URL. The URL should serve a valid json AgentStack template. + + + - 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/_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. + + + Manually test your tool integration by running `agentstack tools add ` and ensure it behaves as expected. + This must be done within an AgentStack project. To create your test project, run `agentstack init test_proj`, then `cd` into the project and try adding your tool. + + + + -## Start Easier -If you're struggling to get started with a project in AgentStack, a great way to better understand what to do is to start with a template! +# Tool Config +- `name` (str) - Name of your tool +- `category` (str) - Category your tool belongs in +- `tools` (List[str]) - The exported functions within your tool file +- `url` (str) - URL to where developers can learn more about your tool +- `tools_bundled` (bool) - True if the tool file exports a list of tools +- `cta` (str) - Call To Action printed in the terminal after install +- `env` (dict) - Key: Environment variable name; Value: default value +- `packages` (List[str]) - Python packages to be installed to support your tool +- `post_install` (str) - A script to be run after install of your tool +- `post_remove` (str) - A script to be run after removal of your tool -## Churn Faster -Many contractors that build agent systems have a tried and true prompting method that they want to replicate more quickly. -By creating your own template, you can quickly start projects that adhere to your design. +## frameworks/list.mdx -## For Content Creators -Have a tutorial you've created using AgentStack? Make your project available as a quickstart with templates. +--- +title: Frameworks +description: 'Supported frameworks in AgentStack' +icon: 'ship' +--- -# Built-In Templates +These are documentation links to the frameworks supported directly by AgentStack. -The following templates are built into the AgentStack project. Template contributions are welcome! +To start a project with one of these frameworks, use +```bash +agentstack init --framework +``` - - - Research and report result from a query - - - Research a topic and create content on it - - - Inspect a project directory and improve it - - - -## templates/community.mdx - ---- -title: 'Community Templates' -description: 'Extending templating outside what is in the repo' ---- - -The easiest way to create your own templates right now is to host them online. - -```bash -agentstack init --template= -``` - -Much more community template support coming soon! - -## templates/content_creator.mdx - ---- -title: 'Content Creator' -description: 'Research a topic and create content on it' ---- - -[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/content_creator.json) - -## frameworks/list.mdx - ---- -title: Frameworks -description: 'Supported frameworks in AgentStack' -icon: 'ship' ---- - -These are documentation links to the frameworks supported directly by AgentStack. - -To start a project with one of these frameworks, use -```bash -agentstack init --framework -``` - -## Framework Docs +## Framework Docs --framework -## tools/package-structure.mdx - - -## 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. - - - -## tools/core.mdx +## templates/content_creator.mdx --- -title: 'Core Tools' -description: 'AgentStack tools that are not third-party integrations' +title: 'Content Creator' +description: 'Research a topic and create content on it' --- -## File System - -- [Directory Search](/tools/tool/dir_search) -- [File Read](/tools/tool/file_read) -- [FTP](/tools/tool/ftp) - -## Code Execution - -- [Code Interpreter](/tools/tool/code-interpreter) - -## Data Input -- [Vision](/tools/tool/vision) - - - - Third party tools from the Agent Community - - +[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/content_creator.json) -## tools/community.mdx +## templates/templates.mdx --- -title: 'Community Tools' -description: 'AgentStack tools from community contributors' +title: 'Templates' +description: 'Default AgentStack templates' --- -## Web Retrieval -- [AgentQL](/tools/tool/agentql) - -## Browsing - -[//]: # (- [Browserbase](/tools/tool/browserbase)) -- [Firecrawl](/tools/tool/firecrawl) - -## Search -- [Perplexity](/tools/tool/perplexity) - -## Memory / State +_Templates are a really powerful tool within AgentStack!_ -- [Mem0](/tools/tool/mem0) +# Start a new project with a template +Initializing a new project with AgentStack involves adding just one argument: +```bash +agentstack init --template= +``` -## Database Tools -- [Neon](/tools/tool/neon) +Templates can also be passed as a URL. The URL should serve a valid json AgentStack template. -## Code Execution +## Start Easier +If you're struggling to get started with a project in AgentStack, a great way to better understand what to do is to start with a template! -- [Open Interpreter](/tools/tool/open-interpreter) +## Churn Faster +Many contractors that build agent systems have a tried and true prompting method that they want to replicate more quickly. +By creating your own template, you can quickly start projects that adhere to your design. -## Unified API +## For Content Creators +Have a tutorial you've created using AgentStack? Make your project available as a quickstart with templates. -- [Composio](/tools/tool/composio) +# Built-In Templates -## Network Protocols -- [Agent Connect](/tools/tool/agent-connect) +The following templates are built into the AgentStack project. Template contributions are welcome! -## Application Specific -- [Stripe](/tools/tool/stripe) -- [Payman](/tools/tool/payman) - + - Default tools in AgentStack + Research and report result from a query + + + Research a topic and create content on it + + + Inspect a project directory and improve it -## tools/tools.mdx - ---- -title: 'Tools' -description: 'Giving your agents tools should be easy' ---- - -## Installation - -Once you find the right tool for your use-case, install it with simply -```bash -agentstack tools add -``` - -You can also specify a tool, and one or more agents to install it to: -```bash -agentstack tools add --agents=, -``` - - - Add your own tool to the AgentStack repo [here](/contributing/adding-tools)! - - -## snippets/snippet-intro.mdx - -One of the core principles of software development is DRY (Don't Repeat -Yourself). This is a principle that apply to documentation as -well. If you find yourself repeating the same content in multiple places, you -should consider creating a custom snippet to keep your content in sync. - - -## cli-reference/cli.mdx +## templates/system_analyzer.mdx --- -title: 'CLI Reference' -description: 'Everything to do with the CLI' +title: 'System Analyzer' +description: 'Inspect a project directory and improve it' --- -It all starts with calling -```bash -$ agentstack -``` - -### Shortcut Aliases -Many top-level AgentStack commands can be invoked using a single-letter prefix to save keystrokes. These are indicated -in the command's documentation here after a `|` character. Run `agentstack help` for the full list. - -### Global Flags -These flags work with all commands: - -`--debug` - Print a full traceback when an error is encountered. This also enables printing additional debug information -from within AgentStack useful for development and debugging. - -`--path=` - Set the working directory of the current AgentStack project. By default `agentstack` works inside of the -current directory and looks for an `agentstack.json` file there. By passing a path to this flag you can work on a project -from outside of it's directory. - -`--version` - Prints the current version and exits. - - -## `$ agentstack init` -This initializes a new AgentStack project. -```bash -agentstack init -``` - -`slug_name` is the name of your project, and will be created as a directory to initialize your project inside. When the -default arguments are passed, a starter project template will be used, which adds a single agent, a single task and -demonstrates the use of a tool. - -### Init Creates a Virtual Environment -AgentStack creates a new directory, initializes a new virtual environment, installs dependencies, and populates the project -structure. After `init` completes, `cd` into the directory, activate the virtual environment with `source .venv/bin/activate`. -Virtual environments and package management are handled by the `uv` package manager. - -### Initializing with the Wizard -You can pass the `--wizard` flag to `agentstack init` to use an interactive project configuration wizard. - -### Initializing from a Template -You can also pass a `--template=` argument to `agentstack init` which will pre-populate your project with functionality -from a built-in template, or one found on the internet. A `template_name` can be one of three identifiers: - -- A built-in AgentStack template (see the `templates` directory in the AgentStack repo for bundled templates). -- A template file from the internet; pass the full https URL of the template. -- A local template file; pass an absolute or relative path. - - -## `$ agentstack run` -This runs your AgentStack project. -```bash -agentstack run -``` - -Environment variables will be loaded from `~/.env` and from the `.env` file inside your project directory. Make sure you -have enabled your project's `venv` before executing to include all dependencies required. - -### Overriding Inputs -Your project defines Inputs which are used to customize the Agent and Task prompts for a specific task. In cases where -using the `inputs.yaml` file to populate data is not flexible enough, `run` can accept value overrides for all defined -inputs. Use `--input-=` to pass data which will only be used on this run. - -For example, if you have a key in your `inputs.yaml` file named `topic` and want to override it for this run, you would -use the following command: - -```bash -agentstack run --input-topic=Sports -``` - -### Running other project commands -By default, `run` will call the `main()` function inside your project's `main.py` file. You can pass alternate function -names to run with `--function=`. - - -## Generate -Code generation commands for automatically creating new agents or tasks. - -### `$ agentstack generate agent | agentstack g a` -Generate a new agent -- `agent_name` (required | str) - the name of the agent -- `--role` (optional | str) - Prompt parameter: The role of the agent -- `--goal` (optional | str) - Prompt parameter: The goal of the agent -- `--backstory` (optional | str) - Prompt parameter: The backstory of the agent -- `--llm` (optional | `/`) - Which model to use for this agent - -#### Default LLM -All arguments to generate a new Agent are optional. A default LLM can be configured in `agentstack.json`under the -`default_model` setting to populate a provider/model. If you are generating an agent in a project which does not have -a default model set, you will be prompted to configure one. - -#### Example -```bash Generate Agent -agentstack generate agent script_writer -``` - -### `$ agentstack generate task | agentstack g t` -Generate a new task -- `task_name` (required | str) - the name of the task -- `--description` (optional | str) - Prompt parameter: Explain the task in detail -- `--expected_output` (optional | str) - What is the expected output from the agent (ex: data in json format) -- `--agent` (optional | str) - The name of the agent of which to assign the task to (when using Crew in sequential mode) +[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/system_analyzer.json) -#### Example -```bash Generate Task -agentstack g t gen_script --description "Write a short film script about secret agents" +```bash +agentstack init --template=system_analyzer ``` -## Tools -Tools are what make AgentStack powerful. Adding and removing Tools from Agents is easy with this command. +# Purpose -### `$ agentstack tools list | agentstack t l` -Lists all tools available in AgentStack. +This agent will accept a query as a string, use Perplexity to research it. Another agent will take the data gathered and perform an analysis focused on answering the query. -### `$ agentstack tools add | agentstack t a` -Shows an interactive interface for selecting which Tool to add and which Agents to add it to. +# Inputs -#### Add a Tool to all Agents -When a tool_name is provided it will be made available to all Agents in the project. -```bash -$ agentstack tools add -``` +`system_path` (str): the absolute path to -#### Add a Tool to a single Agent -When an agent_name is provided, the tool will be made available to only that agent. -```bash -$ agentstack tools add --agent= -``` +## templates/researcher.mdx + +--- +title: 'Researcher' +description: 'Research and report result from a query' +--- + +[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/research.json) -#### Add a Tool to multiple Agents -When a comma-separated list of Agents is passed, the tool will be made available to those agents. ```bash -$ agentstack tools add --agents=,, +agentstack init --template=research ``` -### `$ agentstack tools remove ` -Removes a tool from all Agents in the project. +# Purpose +This agent will accept a query as a string, use Perplexity to research it. Another agent will take the data gathered and perform an analysis focused on answering the query. -## Templates -Projects can be exported into a template to facilitate sharing configurations. Re-initialize a project from a template -with `agentstack init --template=`. +# Inputs -### `$ agentstack export ` -The current project will be written to a JSON template at the provided filename. +`query` (str): the query for the agent to research and report on -## `$ agentstack update` -Check for updates and allow the user to install the latest release of AgentStack. +## templates/community.mdx -## `$ agentstack login` -Authenticate with [agentstack.sh](https://agentstack.sh) for hosted integrations. +--- +title: 'Community Templates' +description: 'Extending templating outside what is in the repo' +--- + +The easiest way to create your own templates right now is to host them online. + +```bash +agentstack init --template= +``` + +Much more community template support coming soon! + +## snippets/snippet-intro.mdx +One of the core principles of software development is DRY (Don't Repeat +Yourself). This is a principle that apply to documentation as +well. If you find yourself repeating the same content in multiple places, you +should consider creating a custom snippet to keep your content in sync. ## essentials/agentops.mdx @@ -1248,6 +1094,38 @@ To get started, create an [AgentOps account](https://agentops.ai/?=agentstack). For feature requests or bug reports, please reach out to the AgentOps team on the [AgentOps Repo](https://github.com/AgentOps-AI/agentops). +## essentials/generating-tasks.mdx + +--- +title: 'Generating Tasks' +description: 'CLI command to add a task to your project' +--- + +To generate a new task for your project, run: + +```bash +agentstack generate task +``` + +This command will modify two files, your agent file (`crew.py`/`graph.py`) and `agents.yaml`. + +## your agent file + +This is the file that declares each of your agents and tasks. It's the core of your AgentStack project and how AgentStack configures your framework. +- Crew projects have `crew.py` +- LangGraph projects have `graph.py` + +## agents.yaml + +This is your prompt file. Any prompt engineering is abstracted to here for non-technical ease. + +Each task has two prompt params: +- Description +- Expected Output + +And one configuration param: +- Agent - If operating in Sequential mode, this tells the Crew which agent should accomplish the task + ## essentials/generating-agents.mdx --- @@ -1284,35 +1162,160 @@ And one configuration param: Ex: `openai/gpt-4o` -## essentials/generating-tasks.mdx +## tools/core.mdx --- -title: 'Generating Tasks' -description: 'CLI command to add a task to your project' +title: 'Core Tools' +description: 'AgentStack tools that are not third-party integrations' --- -To generate a new task for your project, run: +## File System + +- [Directory Search](/tools/tool/dir_search) +- [File Read](/tools/tool/file_read) +- [FTP](/tools/tool/ftp) + +## Code Execution + +- [Code Interpreter](/tools/tool/code-interpreter) + +## Input +- [Vision](/tools/tool/vision) + +## Data +- [SQL](/tools/tool/sql) + + + + Third party tools from the Agent Community + + + +## tools/tools.mdx + +--- +title: 'Tools' +description: 'Giving your agents tools should be easy' +--- +## Installation + +Once you find the right tool for your use-case, install it with simply ```bash -agentstack generate task +agentstack tools add ``` -This command will modify two files, your agent file (`crew.py`/`graph.py`) and `agents.yaml`. +You can also specify a tool, and one or more agents to install it to: +```bash +agentstack tools add --agents=, +``` -## your agent file + + Add your own tool to the AgentStack repo [here](/contributing/adding-tools)! + -This is the file that declares each of your agents and tasks. It's the core of your AgentStack project and how AgentStack configures your framework. -- Crew projects have `crew.py` -- LangGraph projects have `graph.py` +## tools/package-structure.mdx -## agents.yaml -This is your prompt file. Any prompt engineering is abstracted to here for non-technical ease. +## Tool Configuration +Each tool gets a directory inside `agentstack/_tools/` where the tool's +source code and configuration will be stored. -Each task has two prompt params: -- Description -- Expected Output +The directory should contain the following files: -And one configuration param: -- Agent - If operating in Sequential mode, this tells the Crew which agent should accomplish the task +`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. + + + +## tools/community.mdx + +--- +title: 'Community Tools' +description: 'AgentStack tools from community contributors' +--- + +## Web Retrieval +- [AgentQL](/tools/tool/agentql) + +## Browsing + +[//]: # (- [Browserbase](/tools/tool/browserbase)) +- [Firecrawl](/tools/tool/firecrawl) + +## Search +- [Perplexity](/tools/tool/perplexity) + +## Memory / State + +- [Mem0](/tools/tool/mem0) + +## Database Tools +- [Neon](/tools/tool/neon) + +## Code Execution + +- [Open Interpreter](/tools/tool/open-interpreter) + +## Unified API + +- [Composio](/tools/tool/composio) + +## Network Protocols +- [Agent Connect](/tools/tool/agent-connect) + +## Application Specific +- [Stripe](/tools/tool/stripe) +- [Payman](/tools/tool/payman) + + + Default tools in AgentStack + + diff --git a/docs/tools/package-structure.mdx b/docs/tools/package-structure.mdx index 57ceb905..370aaacf 100644 --- a/docs/tools/package-structure.mdx +++ b/docs/tools/package-structure.mdx @@ -13,7 +13,7 @@ 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 +are simple packages which expose functions; when a tool is loaded into a user's project, it will be wrapped in the framework-specific tool format by AgentStack. @@ -23,13 +23,13 @@ project, it will be wrapped in the framework-specific tool format by AgentStack. ### `name` (string) [required] The name of the tool in snake_case. This is used to identify the tool in the system. +### `category` (string) [required] +The category of the tool. This is used to group tools together in the CLI. + ### `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. @@ -43,6 +43,49 @@ set to `null` which adds it to the project's `.env` file as a comment. 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. +### `tools` (list[dict[str, dict]]) [required] +List of public functions that are exposed by the tool as keys and permissions as +a dictionary. + +#### `tools..actions` (list['read', 'write', 'execute']) [required] +At a minimum, each tool must have a single action of `read`, `write`, or `execute`. + +```json + "tools": { + "analyze_image": { + "actions": ["read"] + } + } +``` + +#### `tools..*` (str) [optional] +You can also pass additional parameters to be made available as permissions to +the tool. These can be any valid JSON value. + +```json + "tools": { + "analyze_image": { + "actions": ["read"], + "allow_http": true, + "allowed_dirs": ["*"] + } + } +``` + +Permissions in Tool Implementation +---------------------------------- + +In the tool implementation, you can access the `actions` and other parameters +using `agentstack.tools.get_permissions()`. + +```python +def analyze_image(path: str) -> str: + ... + permissions = tools.get_permissions(analyze_image) + if not permissions.READ: + return "User has not granted read permission." + if not permissions.allow_http: + return "User has not granted permission to access the internet." + ... +``` \ No newline at end of file diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/malformed.yaml b/tests/fixtures/malformed.yaml new file mode 100644 index 00000000..9ad04841 --- /dev/null +++ b/tests/fixtures/malformed.yaml @@ -0,0 +1,4 @@ +// malformed yaml format for testing +malformed_yaml: + ----- key: value + - key2: value2 \ No newline at end of file diff --git a/tests/fixtures/test_tool.py b/tests/fixtures/test_tool.py new file mode 100644 index 00000000..0fa6446d --- /dev/null +++ b/tests/fixtures/test_tool.py @@ -0,0 +1,5 @@ + + +def tool1(): + pass + diff --git a/tests/fixtures/tool_config_custom.json b/tests/fixtures/tool_config_custom.json index 15769bd0..180c873a 100644 --- a/tests/fixtures/tool_config_custom.json +++ b/tests/fixtures/tool_config_custom.json @@ -1,5 +1,12 @@ { "name": "my_custom_tool", "category": "custom", - "tools": ["tool1", "tool2"] + "tools": { + "tool1": { + "actions": ["read", "write"] + }, + "tool2": { + "actions": ["read"] + } + } } \ No newline at end of file diff --git a/tests/fixtures/tool_config_invalid.json b/tests/fixtures/tool_config_invalid.json new file mode 100644 index 00000000..486d52a0 --- /dev/null +++ b/tests/fixtures/tool_config_invalid.json @@ -0,0 +1,5 @@ +{ + "name": "tool_name", + "category": [], + "tools": false +} \ No newline at end of file diff --git a/tests/fixtures/tool_config_max.json b/tests/fixtures/tool_config_max.json index 1ec8b0fc..dcc3f08f 100644 --- a/tests/fixtures/tool_config_max.json +++ b/tests/fixtures/tool_config_max.json @@ -1,7 +1,6 @@ { "name": "tool_name", "category": "category", - "tools": ["tool1", "tool2"], "url": "https://example.com", "cta": "Click me!", "env": { @@ -13,5 +12,17 @@ "dependency2>=2.0.0" ], "post_install": "install.sh", - "post_remove": "remove.sh" + "post_remove": "remove.sh", + "tools": { + "tool1": { + "actions": ["read", "write"], + "additional_property": "value" + }, + "tool2": { + "actions": ["read"] + }, + "tool3": { + "actions": ["write"] + } + } } \ No newline at end of file diff --git a/tests/fixtures/tool_config_min.json b/tests/fixtures/tool_config_min.json index a57f2233..b5778d91 100644 --- a/tests/fixtures/tool_config_min.json +++ b/tests/fixtures/tool_config_min.json @@ -1,5 +1,12 @@ { "name": "tool_name", "category": "category", - "tools": ["tool1", "tool2"] + "tools": { + "tool1": { + "actions": ["read", "write"] + }, + "tool2": { + "actions": ["read"] + } + } } \ No newline at end of file diff --git a/tests/fixtures/tools.yaml b/tests/fixtures/tools.yaml new file mode 100644 index 00000000..5f397ad0 --- /dev/null +++ b/tests/fixtures/tools.yaml @@ -0,0 +1,4 @@ +tool_name: + tool1: + actions: ['execute'] + tool2: ~ \ No newline at end of file diff --git a/tests/fixtures/tools_invalid.yaml b/tests/fixtures/tools_invalid.yaml new file mode 100644 index 00000000..350c268a --- /dev/null +++ b/tests/fixtures/tools_invalid.yaml @@ -0,0 +1,4 @@ +tool_name: + tool1: + actions: False + tool2: ['fooo'] \ No newline at end of file diff --git a/tests/test_agents_config.py b/tests/test_agents_config.py index 2b5a9780..26f48238 100644 --- a/tests/test_agents_config.py +++ b/tests/test_agents_config.py @@ -92,7 +92,7 @@ def test_write_none_values(self): role: > goal: > backstory: > - llm: + llm: ~ """ ) diff --git a/tests/test_frameworks.py b/tests/test_frameworks.py index 96d4edf8..65379374 100644 --- a/tests/test_frameworks.py +++ b/tests/test_frameworks.py @@ -8,7 +8,7 @@ from agentstack.conf import ConfigFile, set_path from agentstack.exceptions import ValidationError from agentstack import frameworks -from agentstack._tools import ToolConfig, get_all_tools +from agentstack._tools import ToolConfig, ToolPermission, get_all_tools from agentstack.agents import AGENTS_FILENAME, AgentConfig from agentstack.tasks import TASKS_FILENAME, TaskConfig from agentstack import graph @@ -61,10 +61,10 @@ def _get_test_task_alternate(self) -> TaskConfig: return TaskConfig('task_name_two') def _get_test_tool(self) -> ToolConfig: - return ToolConfig(name='test_tool', category='test', tools=['test_tool']) + return ToolConfig(name='test_tool', category='test', tools={'test_tool': {'actions': ['read']}}) def _get_test_tool_alternate(self) -> ToolConfig: - return ToolConfig(name='test_tool_alt', category='test', tools=['test_tool_alt']) + return ToolConfig(name='test_tool_alt', category='test', tools={'test_tool_alt': {'actions': ['write']}}) def test_get_framework_module(self): module = frameworks.get_framework_module(self.framework) diff --git a/tests/test_generation_tool.py b/tests/test_generation_tool.py index 243a5f72..04cce6bf 100644 --- a/tests/test_generation_tool.py +++ b/tests/test_generation_tool.py @@ -9,7 +9,7 @@ from agentstack.conf import ConfigFile, set_path from agentstack import frameworks -from agentstack._tools import get_all_tools, ToolConfig +from agentstack._tools import get_all_tools, ToolConfig, USER_TOOL_CONFIG_FILENAME from agentstack.generation.tool_generation import ( add_tool, create_tool, @@ -27,10 +27,8 @@ def setUp(self): self.project_dir = BASE_PATH / 'tmp' / self.framework / 'tool_generation' self.tools_dir = self.project_dir / 'src' / 'tools' - os.makedirs(self.project_dir, exist_ok=True) - os.makedirs(self.project_dir / 'src', exist_ok=True) - os.makedirs(self.project_dir / 'src' / 'tools', exist_ok=True) - os.makedirs(self.tools_dir, exist_ok=True) + os.makedirs(self.project_dir / 'src/config') + os.makedirs(self.tools_dir) (self.project_dir / 'src' / '__init__.py').touch() # set the framework in agentstack.json @@ -57,6 +55,8 @@ def test_add_tool(self): # TODO verify tool is added to all agents (this is covered in test_frameworks.py) # assert 'agent_connect' in entrypoint_src assert 'agent_connect' in open(self.project_dir / 'agentstack.json').read() + # generation handles creating the user's tools config + assert (self.project_dir / USER_TOOL_CONFIG_FILENAME).exists() def test_remove_tool(self): tool_conf = ToolConfig.from_tool_name('agent_connect') @@ -98,7 +98,7 @@ def test_create_tool_basic(self): config = json.loads(config_file.read_text()) self.assertEqual(config["name"], tool_name) self.assertEqual(config["category"], "custom") - self.assertEqual(config["tools"], [f"{tool_name}_tool"]) + #self.assertEqual(config["tools"], [f"{tool_name}_tool"]) def test_create_tool_specific_agents(self): """Test tool creation with specific agents""" @@ -107,6 +107,7 @@ def test_create_tool_specific_agents(self): create_tool( tool_name=tool_name, + # TODO this doesn't reference any agents ) # Assert directory and files were created diff --git a/tests/test_tool_config.py b/tests/test_tool_config.py index bd287ba8..e4b9e93f 100644 --- a/tests/test_tool_config.py +++ b/tests/test_tool_config.py @@ -1,23 +1,39 @@ import os +import shutil import json -import unittest import re from pathlib import Path -import shutil +import unittest +from unittest.mock import MagicMock, PropertyMock, patch +from parameterized import parameterized from agentstack import conf from agentstack.exceptions import ValidationError -from agentstack._tools import ToolConfig, get_all_tool_paths, get_all_tool_names +from agentstack._tools import ( + ToolConfig, + get_tool, + get_all_tools, + get_all_tool_paths, + get_all_tool_names, + UserToolConfig, + get_permissions, + ToolPermission, + Action, +) BASE_PATH = Path(__file__).parent class ToolConfigTest(unittest.TestCase): def setUp(self): - self.project_dir = BASE_PATH / 'tmp' / 'tool_config' + self.framework = os.getenv('TEST_FRAMEWORK') + self.project_dir = BASE_PATH / 'tmp' / self.framework / 'test_tool_config' os.makedirs(self.project_dir) - os.makedirs(self.project_dir / 'src') - os.makedirs(self.project_dir / 'src' / 'tools') + os.makedirs(self.project_dir / 'src/config') + + shutil.copy(BASE_PATH / "fixtures/agentstack.json", self.project_dir / "agentstack.json") conf.set_path(self.project_dir) - + with conf.ConfigFile() as config: + config.framework = self.framework + def tearDown(self): shutil.rmtree(self.project_dir) @@ -25,7 +41,8 @@ def test_minimal_json(self): config = ToolConfig.from_json(BASE_PATH / "fixtures/tool_config_min.json") assert config.name == "tool_name" assert config.category == "category" - assert config.tools == ["tool1", "tool2"] + assert config.tool_names == ["tool1", "tool2"] + # TODO test config.tools assert config.url is None assert config.cta is None assert config.env is None @@ -36,7 +53,8 @@ def test_maximal_json(self): config = ToolConfig.from_json(BASE_PATH / "fixtures/tool_config_max.json") assert config.name == "tool_name" assert config.category == "category" - assert config.tools == ["tool1", "tool2"] + assert config.tool_names == ["tool1", "tool2", "tool3"] + # TODO test config.tools assert config.url == "https://example.com" assert config.cta == "Click me!" assert config.env == {"ENV_VAR1": "value1", "ENV_VAR2": "value2"} @@ -73,32 +91,121 @@ def test_dependency_versions(self): "All dependencies must include version specifications." ) - def test_all_json_configs_from_tool_name(self): - for tool_name in get_all_tool_names(): - config = ToolConfig.from_tool_name(tool_name) - assert config.name == tool_name - # We can assume that pydantic validation caught any other issues + @parameterized.expand([(x, ) for x in get_all_tools()]) + def test_all_tools(self, config: ToolConfig): + assert isinstance(config, ToolConfig) + # We can assume that pydantic validation caught any other issues + + def test_load_invalid_tool(self): + with self.assertRaises(ValidationError): + ToolConfig.from_tool_name("invalid_tool") + + def test_load_invalid_config(self): + with self.assertRaises(ValidationError): + ToolConfig.from_json(BASE_PATH / "fixtures/tool_config_invalid.json") + + @parameterized.expand([(x, ) for x in get_all_tool_paths()]) + def test_all_json_configs_from_tool_path(self, path): + try: + 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" + ) - def test_all_json_configs_from_tool_path(self): - for path in get_all_tool_paths(): - try: - 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" - ) + assert config.name == path.stem - assert config.name == path.stem + @patch('agentstack._tools.ToolConfig.module_name', new_callable=PropertyMock) + def test_config_module_missing_function(self, mock_module_name): + mock_module_name.return_value = 'tests.fixtures.test_tool' + with self.assertRaises(ValidationError): + config = ToolConfig.from_json(BASE_PATH / "fixtures/tool_config_min.json") + config.module + + @patch('agentstack._tools.ToolConfig.module_name', new_callable=PropertyMock) + def test_config_module_missing_import(self, mock_module_name): + mock_module_name.return_value = 'invalid' + with self.assertRaises(ValidationError): + config = ToolConfig.from_json(BASE_PATH / "fixtures/tool_config_min.json") + config.module + + @parameterized.expand([(x, ) for x in get_all_tool_names()]) + def test_user_tool_config_uninitialized(self, tool_name): + with self.assertRaises(FileNotFoundError): + UserToolConfig(tool_name) + + def test_user_tool_config_initialize(self): + test_tools = get_all_tools()[:3] # just a few + with conf.ConfigFile() as config: + config.tools = [tool.name for tool in test_tools] + + assert not UserToolConfig.exists() + UserToolConfig.initialize() + + assert UserToolConfig.exists() + for tool in test_tools: + user_conf = UserToolConfig(tool.name) + assert user_conf.tools.keys() == tool.tools.keys() + assert user_conf.tools.keys() == tool.allowed_tools.keys() + assert user_conf.tool_names == tool.tool_names + assert user_conf.tool_names == tool.allowed_tool_names + + def test_user_tool_config_customize(self): + shutil.copy(BASE_PATH / "fixtures/tools.yaml", self.project_dir / "src/config/tools.yaml") + test_tool = ToolConfig.from_json(BASE_PATH / "fixtures/tool_config_max.json") + user_conf = UserToolConfig(test_tool.name) + + # tool has `tool1`, `tool2`, `tool3` + # user has `tool1`, `tool2` + assert user_conf.tool_names == test_tool.allowed_tool_names + assert user_conf.tool_names != test_tool.tool_names + assert user_conf.tools['tool1'].actions == [Action.EXECUTE] + assert user_conf.tools['tool2'] is None + + assert test_tool.allowed_tools['tool1'].actions == [Action.EXECUTE] + assert test_tool.allowed_tools['tool1'].additional_property == "value" + assert test_tool.allowed_tools['tool2'].actions == [Action.READ] + assert not hasattr(test_tool.allowed_tools, 'tool3') + + @patch('agentstack._tools._get_user_tool_config_path') + def test_load_invalid_user_config(self, mock_get_user_tool_config_path): + mock_get_user_tool_config_path.return_value = BASE_PATH / "fixtures/tools_invalid.yaml" + with self.assertRaises(ValidationError): + UserToolConfig('tool_name') + + @patch('agentstack._tools._get_user_tool_config_path') + def test_load_malformed_user_config(self, mock_get_user_tool_config_path): + mock_get_user_tool_config_path.return_value = BASE_PATH / "fixtures/malformed.yaml" + with self.assertRaises(ValidationError): + UserToolConfig('tool_name') + + def test_tool_permission_rwed(self): + tool_permission = ToolPermission(actions=['read', 'write', 'execute', 'delete']) + assert tool_permission.READ + assert tool_permission.WRITE + assert tool_permission.EXECUTE + assert tool_permission.DELETE + + def test_tool_permission_attrs(self): + tool_permission = ToolPermission(actions=['read'], foo='bar', baz='qux') + assert tool_permission.foo == 'bar' + assert tool_permission.baz == 'qux' + assert tool_permission.undefined is None + + def test_get_permissions(self): + from agentstack._tools.file_read import read_file + permissions = get_permissions(read_file) + assert isinstance(permissions, ToolPermission) def test_tool_missing(self): with self.assertRaises(ValidationError): ToolConfig.from_tool_name("non_existent_tool") def test_from_custom_path(self): - os.mkdir(self.project_dir / "src/tools/my_custom_tool") + os.makedirs(self.project_dir / "src/tools/my_custom_tool") shutil.copy(BASE_PATH / "fixtures/tool_config_custom.json", self.project_dir / "src/tools/my_custom_tool/config.json") config = ToolConfig.from_tool_name("my_custom_tool") - assert config.module_name == "src.tools.my_custom_tool" \ No newline at end of file + assert config.module_name == "src.tools.my_custom_tool"