From 3064aebc450fcb854c70b4905812f53332fedd32 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Wed, 5 Feb 2025 11:07:48 -0800 Subject: [PATCH 01/24] Thoughts on tool permissions. --- agentstack/_tools/__init__.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py index a9382780..42e55dcb 100644 --- a/agentstack/_tools/__init__.py +++ b/agentstack/_tools/__init__.py @@ -1,5 +1,6 @@ from typing import Optional, Protocol, runtime_checkable from types import ModuleType +import enum import os import sys from pathlib import Path @@ -13,6 +14,38 @@ TOOLS_CONFIG_FILENAME: str = 'config.json' +class ToolPermission(pydantic.BaseModel): + """ + Control which features of a tool are available to an agent. + + 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. + + TODO + - Determine where and how we want to store this data. (conf/tools.yaml in the user's project?) + - Tool configurations should be specific to an agent, not the whole project. + - If we do implement read/write/execute rules we need some way to mark tool functions as being relevant. + - Do we write a config file to the users project that lists all permissions as allowed by default and + instruct users to modify it? + - Do we need to support modification of these rules via the CLI? + """ + class Action(enum.Enum): + READ = 'READ' + WRITE = 'WRITE' + EXECUTE = 'EXECUTE' + + function_name: str + action: Action + + class ToolConfig(pydantic.BaseModel): """ This represents the configuration data for a tool. From b5eeba8222fdc76e8f184080309d603a09439dae Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Thu, 6 Feb 2025 10:22:53 -0800 Subject: [PATCH 02/24] More thoughts on tool permissions. --- agentstack/__init__.py | 8 ++++ agentstack/_tools/__init__.py | 81 ++++++++++++++++++++++++++++++++--- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/agentstack/__init__.py b/agentstack/__init__.py index 269cf4ca..32790315 100644 --- a/agentstack/__init__.py +++ b/agentstack/__init__.py @@ -12,6 +12,7 @@ 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 import frameworks ___all___ = [ @@ -69,4 +70,11 @@ class ToolLoader: def __getitem__(self, tool_name: str) -> list[Callable]: return frameworks.get_tool_callables(tool_name) + def performs_actions(self, *args, **kwargs) -> Callable: + # in order to add a method to `agentstack.tools` it has to be aliased here + # not sure internal tools decorators need to be part of the public API, + # but there should be a clean way to reference the decorator inside a tool + # implementation if we do go forward with decorators. + return _tools.performs_actions(*args, **kwargs) + tools = ToolLoader() diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py index 42e55dcb..368a327b 100644 --- a/agentstack/_tools/__init__.py +++ b/agentstack/_tools/__init__.py @@ -1,4 +1,4 @@ -from typing import Optional, Protocol, runtime_checkable +from typing import Optional, Callable, Protocol, runtime_checkable from types import ModuleType import enum import os @@ -7,13 +7,85 @@ from importlib import import_module import pydantic from agentstack.exceptions import ValidationError -from agentstack.utils import get_package_path, open_json_file, term_color, snake_to_camel +from agentstack.utils import get_package_path, open_json_file, snake_to_camel TOOLS_DIR: Path = get_package_path() / '_tools' # NOTE: if you change this dir, also update MANIFEST.in TOOLS_CONFIG_FILENAME: str = 'config.json' +class Action(enum.Enum): + READ = 'read' + WRITE = 'write' + EXECUTE = 'execute' + + +def performs_actions(*actions: str) -> Callable: + """ + Decorator to note the Actions a tool exposes. + + Use inside of a tool implementation to indicate which actions the function performs. + + ``` + from agentstack import tools + + @tools.performs_actions('read', 'write') + def my_tool_function(key: str, value: str): + ... + ``` + + I also wonder about passing more configuration information to a tools in a semi-standardized way: + ``` + @tools.performs_read(allowed_dirs=['/home/user/*'], allowed_extensions=['*.txt']) + def my_tool_function(key: str, value: str, **kwargs): + allowed_dirs = kwargs.get('allowed_dirs', ['/']) + allowed_extensions = kwargs.get('allowed_extensions', ['*.*']) + # it will always be up to the integrator to actually enforce these rules + ... + + ALLOWED_SH_COMMANDS = ['ls', 'cat', ...] + + @tools.performs_exec(language='sh', allowed_commands=ALLOWED_SH_COMMANDS) + def my_tool_function(key: str, value: str, **kwargs): + ... + ``` + + Later, the user needs to be able to override all of these rules in their project. + - Remember, each agent can have different rules. + - Would be great if this could inherit from a shared base. + + # project/src/config/tools.yaml + defaults: + tool_name: + function_name: &tool_name__function_name + read: + allowed_dirs: ['/home/user/*'] + allowed_extensions: ['*.txt'] + write: + allowed_dirs: ['/home/user/*'] + allowed_extensions: ['*.txt'] + agent_name: + tool_name: + function_name: << *tool_name__function_name + ... + + I don't love that... + """ + + try: + _actions: list[Action] = [] + for action_name in actions: + _actions.append(Action(action_name)) + except ValueError as e: + raise ValidationError(f"Invalid action name '{action_name}' passed to `tools.performs_actions`") + + def decorator(func): + func.__tool_actions = _actions + return func + + return decorator + + class ToolPermission(pydantic.BaseModel): """ Control which features of a tool are available to an agent. @@ -37,10 +109,7 @@ class ToolPermission(pydantic.BaseModel): instruct users to modify it? - Do we need to support modification of these rules via the CLI? """ - class Action(enum.Enum): - READ = 'READ' - WRITE = 'WRITE' - EXECUTE = 'EXECUTE' + function_name: str action: Action From 15a4a1ae9c8ff084b92c1615779eb23d68c607a3 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Thu, 6 Feb 2025 15:47:38 -0800 Subject: [PATCH 03/24] More think. --- agentstack/_tools/__init__.py | 207 ++++++++++++++++++++++------------ 1 file changed, 132 insertions(+), 75 deletions(-) diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py index 368a327b..9c728f21 100644 --- a/agentstack/_tools/__init__.py +++ b/agentstack/_tools/__init__.py @@ -1,4 +1,4 @@ -from typing import Optional, Callable, Protocol, runtime_checkable +from typing import Optional, Any, Callable, Protocol, runtime_checkable from types import ModuleType import enum import os @@ -14,105 +14,146 @@ TOOLS_CONFIG_FILENAME: str = 'config.json' -class Action(enum.Enum): - READ = 'read' - WRITE = 'write' - EXECUTE = 'execute' +""" +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"] + }, + ... + } +} +``` -def performs_actions(*actions: str) -> Callable: - """ - Decorator to note the Actions a tool exposes. - - Use inside of a tool implementation to indicate which actions the function performs. - - ``` - from agentstack import tools +``` # my_tool.py +def tool_function(vars_need_to_be_preserved_for_llms) -> str: + permissions = tools.get_permissions(tool_function) + ... - @tools.performs_actions('read', 'write') - def my_tool_function(key: str, value: str): + if permissions.READ: ... - ``` - - I also wonder about passing more configuration information to a tools in a semi-standardized way: - ``` - @tools.performs_read(allowed_dirs=['/home/user/*'], allowed_extensions=['*.txt']) - def my_tool_function(key: str, value: str, **kwargs): - allowed_dirs = kwargs.get('allowed_dirs', ['/']) - allowed_extensions = kwargs.get('allowed_extensions', ['*.*']) - # it will always be up to the integrator to actually enforce these rules + if permissions.WRITE: ... - - ALLOWED_SH_COMMANDS = ['ls', 'cat', ...] - - @tools.performs_exec(language='sh', allowed_commands=ALLOWED_SH_COMMANDS) - def my_tool_function(key: str, value: str, **kwargs): + if permissions.EXECUTE: ... - ``` - - Later, the user needs to be able to override all of these rules in their project. - - Remember, each agent can have different rules. - - Would be great if this could inherit from a shared base. - - # project/src/config/tools.yaml - defaults: - tool_name: - function_name: &tool_name__function_name - read: - allowed_dirs: ['/home/user/*'] - allowed_extensions: ['*.txt'] - write: - allowed_dirs: ['/home/user/*'] - allowed_extensions: ['*.txt'] - agent_name: - tool_name: - function_name: << *tool_name__function_name + + permissions.allowed_dirs # -> ['/home/user/*'] + permissions.allowed_extensions # -> ['*.txt'] ... - - I don't love that... - """ +``` - try: - _actions: list[Action] = [] - for action_name in actions: - _actions.append(Action(action_name)) - except ValueError as e: - raise ValidationError(f"Invalid action name '{action_name}' passed to `tools.performs_actions`") - - def decorator(func): - func.__tool_actions = _actions - return func +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: ~ + tool_function: + actions: ['read', 'write'] + allowed_dirs: ['/home/user/*'] + allowed_extensions: ['*.txt'] +... +``` - return decorator +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' + EXECUTE = 'execute' class ToolPermission(pydantic.BaseModel): """ - Control which features of a tool are available to an agent. - + Control which features of a tool are available to an agent. + 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. + 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. - + TODO - - Determine where and how we want to store this data. (conf/tools.yaml in the user's project?) - - Tool configurations should be specific to an agent, not the whole project. - - If we do implement read/write/execute rules we need some way to mark tool functions as being relevant. - - Do we write a config file to the users project that lists all permissions as allowed by default and - instruct users to modify it? + - Tool configurations should be specific to an agent, not the whole project. - Do we need to support modification of these rules via the CLI? """ - + tool_name: str function_name: str - action: Action + tool_config: ToolConfig + _actions: list[Action] + _attributes: dict[str, Any] + + def __init__(self, tool_name: str, function_name: str): + self.tool_name = tool_name + self.function_name = function_name + self.tool_config = ToolConfig.from_tool_name(self.tool_name) + + try: + config = tool_config[self.function_name] + self._actions = config.pop('actions', []) + self._attributes = config + except KeyError: + raise ValidationError(f"Function '{self.function_name}' not found in tool '{self.tool_name}'") + + # TODO we have loaded the default tool config for the actions, now we need to overlay the user's preferences. + + @property + def READ(self) -> bool: + return Action.READ in self._actions + + @property + def WRITE(self) -> bool: + return Action.WRITE in self._actions + + @property + def EXECUTE(self) -> bool: + return Action.EXECUTE in self._actions + + def __getattr__(self, item): + """ + Allow access to other variables defined in the tool config. + """ + if item in self._attributes: + return self._attributes[item] + raise AttributeError(f"{item} not found in `ToolPermission`") + + +def get_permissions(func: Callable) -> ToolPermission: + """ + Get the permissions for a tool function. + We derive the tool name and function name from the function's module and name. + """ + return ToolPermission( + tool_name=function.__module__.split('.')[-1], + function_name=function.__name__, + ) class ToolConfig(pydantic.BaseModel): @@ -124,7 +165,7 @@ class ToolConfig(pydantic.BaseModel): name: str category: str - tools: list[str] + tools: list[dict[str, Any]] url: Optional[str] = None cta: Optional[str] = None env: Optional[dict] = None @@ -132,6 +173,21 @@ class ToolConfig(pydantic.BaseModel): post_install: Optional[str] = None post_remove: Optional[str] = None + @pydantic.validator('tools') + def validate_tools(cls, value): + """ + Validate that each tool is a dict and has an 'actions' key which lists 'read', 'write', and/or 'execute'. + """ + for tool in value: + if not isinstance(tool, dict): + raise pydantic.ValidationError(f"tools.{tool} is not a dict.") + if 'actions' not in tool: + raise pydantic.ValidationError(f"tools.{tool} does not have a key: 'actions'.") + for action in tool['actions']: + if action not in Action.__members__.values(): + raise pydantic.ValidationError(f"tools.{tool} has an invalid action: {action}.") + return value + @classmethod def from_tool_name(cls, name: str) -> 'ToolConfig': path = TOOLS_DIR / name / TOOLS_CONFIG_FILENAME @@ -221,5 +277,6 @@ def get_all_tool_names() -> list[str]: return [path.stem for path in get_all_tool_paths()] +# TODO tool configs don't get modified at runtime, so we can safely cache them. def get_all_tools() -> list[ToolConfig]: return [ToolConfig.from_tool_name(path) for path in get_all_tool_names()] From 36609cc65839c749e514a8e52888894c3fead4a8 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Thu, 6 Feb 2025 17:07:58 -0800 Subject: [PATCH 04/24] Implement tool permissions on file_read tool. Start user yaml file format. --- agentstack/__init__.py | 12 +-- agentstack/_tools/__init__.py | 94 +++++++++++++++++----- agentstack/_tools/example_user_config.yaml | 5 ++ agentstack/_tools/file_read/__init__.py | 50 ++++++++---- agentstack/_tools/file_read/config.json | 10 ++- 5 files changed, 130 insertions(+), 41 deletions(-) create mode 100644 agentstack/_tools/example_user_config.yaml diff --git a/agentstack/__init__.py b/agentstack/__init__.py index 32790315..4baea23a 100644 --- a/agentstack/__init__.py +++ b/agentstack/__init__.py @@ -70,11 +70,11 @@ class ToolLoader: def __getitem__(self, tool_name: str) -> list[Callable]: return frameworks.get_tool_callables(tool_name) - def performs_actions(self, *args, **kwargs) -> Callable: - # in order to add a method to `agentstack.tools` it has to be aliased here - # not sure internal tools decorators need to be part of the public API, - # but there should be a clean way to reference the decorator inside a tool - # implementation if we do go forward with decorators. - return _tools.performs_actions(*args, **kwargs) + def get_permissions(self, func: Callable) -> _tools.ToolPermission: + """ + Get the permissions for a tool function. + """ + # aliased here to expose in the public API + return _tools.get_permissions(func) tools = ToolLoader() diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py index 9c728f21..fbe95555 100644 --- a/agentstack/_tools/__init__.py +++ b/agentstack/_tools/__init__.py @@ -6,13 +6,17 @@ from pathlib import Path from importlib import import_module import pydantic +from ruamel.yaml import YAML, YAMLError from agentstack.exceptions import ValidationError from agentstack.utils import get_package_path, open_json_file, snake_to_camel TOOLS_DIR: Path = get_package_path() / '_tools' # NOTE: if you change this dir, also update MANIFEST.in TOOLS_CONFIG_FILENAME: str = 'config.json' +USER_TOOL_CONFIG_FILENAME: Path = Path('src/config/tools.yaml') +yaml = YAML() +yaml.preserve_quotes = True # Preserve quotes in existing data """ As a tool author, this should be as easy as possible to interact with: @@ -49,6 +53,9 @@ def tool_function(vars_need_to_be_preserved_for_llms) -> str: ... ``` +`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`. + 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 @@ -58,12 +65,11 @@ def tool_function(vars_need_to_be_preserved_for_llms) -> str: ``` # src/config/tools.yaml my_tool: - other_tool_function: ~ + 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 @@ -106,7 +112,7 @@ class ToolPermission(pydantic.BaseModel): tool_name: str function_name: str - tool_config: ToolConfig + tool_config: 'ToolConfig' _actions: list[Action] _attributes: dict[str, Any] @@ -116,7 +122,7 @@ def __init__(self, tool_name: str, function_name: str): self.tool_config = ToolConfig.from_tool_name(self.tool_name) try: - config = tool_config[self.function_name] + config = self.tool_config.tools[self.function_name] self._actions = config.pop('actions', []) self._attributes = config except KeyError: @@ -139,21 +145,11 @@ def EXECUTE(self) -> bool: def __getattr__(self, item): """ Allow access to other variables defined in the tool config. + If the variable is not defined, return None. """ if item in self._attributes: return self._attributes[item] - raise AttributeError(f"{item} not found in `ToolPermission`") - - -def get_permissions(func: Callable) -> ToolPermission: - """ - Get the permissions for a tool function. - We derive the tool name and function name from the function's module and name. - """ - return ToolPermission( - tool_name=function.__module__.split('.')[-1], - function_name=function.__name__, - ) + return None class ToolConfig(pydantic.BaseModel): @@ -165,7 +161,7 @@ class ToolConfig(pydantic.BaseModel): name: str category: str - tools: list[dict[str, Any]] + tools: dict[str, Any] url: Optional[str] = None cta: Optional[str] = None env: Optional[dict] = None @@ -183,6 +179,8 @@ def validate_tools(cls, value): raise pydantic.ValidationError(f"tools.{tool} is not a dict.") if 'actions' not in tool: raise pydantic.ValidationError(f"tools.{tool} does not have a key: 'actions'.") + if not isinstance(tool['actions'], list): + raise pydantic.ValidationError(f"tools.{tool}.actions is not a list.") for action in tool['actions']: if action not in Action.__members__.values(): raise pydantic.ValidationError(f"tools.{tool} has an invalid action: {action}.") @@ -206,6 +204,10 @@ 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}") + @property + def tool_names(self) -> list[str]: + return list(self.tools.keys()) + @property def type(self) -> type: """ @@ -224,7 +226,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_) @@ -247,7 +249,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: @@ -258,6 +260,60 @@ def module(self) -> ModuleType: ) +class UserToolConfig(pydantic.BaseModel): + """ + Interface for reading a user's tool configuration from a project. + + Config Schema + ------------- + name: str + The name of the agent; used for lookup. + role: str + The role of the agent. + goal: str + The goal of the agent. + backstory: str + The backstory of the agent. + llm: str + The model this agent should use. + Adheres to the format set by the framework. + """ + + name: str + role: str = "" + goal: str = "" + backstory: str = "" + llm: str = "" + + def __init__(self, name: str): + filename = conf.PATH / AGENTS_FILENAME + + try: + with open(filename, 'r') as f: + data = yaml.load(f) or {} + data = data.get(name, {}) or {} + super().__init__(**{**{'name': name}, **data}) + except YAMLError as e: + # TODO format MarkedYAMLError lines/messages + raise ValidationError(f"Error parsing agents file: {filename}\n{e}") + except pydantic.ValidationError as e: + error_str = "Error validating tool config:\n" + for error in e.errors(): + error_str += f"{' '.join([str(loc) for loc in error['loc']])}: {error['msg']}\n" + raise ValidationError(f"Error loading tool {name} from {filename}.\n{error_str}") + + +def get_permissions(func: Callable) -> ToolPermission: + """ + Get the permissions for a tool function. + We derive the tool name and function name from the function's module and name. + """ + return ToolPermission( + tool_name=function.__module__.split('.')[-1], + function_name=function.__name__, + ) + + def get_all_tool_paths() -> list[Path]: """ Get all the paths to the tool configuration files. diff --git a/agentstack/_tools/example_user_config.yaml b/agentstack/_tools/example_user_config.yaml new file mode 100644 index 00000000..434e33b5 --- /dev/null +++ b/agentstack/_tools/example_user_config.yaml @@ -0,0 +1,5 @@ +file_read: + read_file: + actions: ['read'] + allowed_dirs: ['*'] + allowed_extensions: ['*'] diff --git a/agentstack/_tools/file_read/__init__.py b/agentstack/_tools/file_read/__init__.py index 3fca8dcf..3b9ba117 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: Path, allowed_patterns: list[str]) -> bool: + """Check if the given path matches any of the allowed patterns.""" + return any(fnmatch(str(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(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 1d3118ac..b0c745cd 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://github.com/AgentOps-AI/AgentStack/tree/main/agentstack/tools/file_read", - "dependencies": [] + "dependencies": [], + "tools": { + "read_file": { + "actions": ["read"], + "allowed_dirs": ["*"], + "allowed_extensions": ["*"] + } + } } From f0a52a7904b579f6e5b187add2718b68581f9589 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 7 Feb 2025 12:42:43 -0800 Subject: [PATCH 05/24] User tool configs now merge with tool integrator configs to present an accurate list of tools and permissions in the user's project. --- agentstack/__init__.py | 7 +- agentstack/_tools/__init__.py | 248 ++++++++++++++------- agentstack/_tools/example_user_config.yaml | 13 +- agentstack/_tools/file_read/__init__.py | 6 +- agentstack/frameworks/__init__.py | 5 +- 5 files changed, 184 insertions(+), 95 deletions(-) diff --git a/agentstack/__init__.py b/agentstack/__init__.py index 4baea23a..16618b0f 100644 --- a/agentstack/__init__.py +++ b/agentstack/__init__.py @@ -62,12 +62,13 @@ class ToolLoader: """ Provides the public interface for accessing tools, wrapped in the framework-specific callable format. - - Get a tool's callables by name with `agentstack.tools[tool_name]` - Include them in your agent's tool list with `tools = [*agentstack.tools[tool_name], ]` """ def __getitem__(self, tool_name: str) -> list[Callable]: + """ + 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) def get_permissions(self, func: Callable) -> _tools.ToolPermission: diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py index fbe95555..f6595897 100644 --- a/agentstack/_tools/__init__.py +++ b/agentstack/_tools/__init__.py @@ -1,12 +1,14 @@ from typing import Optional, Any, Callable, Protocol, runtime_checkable from types import ModuleType -import enum 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 ruamel.yaml import YAML, YAMLError +from ruamel.yaml.scalarstring import ScalarString +from agentstack import conf, log from agentstack.exceptions import ValidationError from agentstack.utils import get_package_path, open_json_file, snake_to_camel @@ -19,6 +21,8 @@ yaml.preserve_quotes = True # Preserve quotes in existing data """ +Tool Authors +------------ As a tool author, this should be as easy as possible to interact with: ``` # config.json @@ -36,6 +40,12 @@ } ``` +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(vars_need_to_be_preserved_for_llms) -> str: permissions = tools.get_permissions(tool_function) @@ -48,6 +58,7 @@ def tool_function(vars_need_to_be_preserved_for_llms) -> str: if permissions.EXECUTE: ... + # extra permission are ad-hoc permissions.allowed_dirs # -> ['/home/user/*'] permissions.allowed_extensions # -> ['*.txt'] ... @@ -56,6 +67,8 @@ def tool_function(vars_need_to_be_preserved_for_llms) -> str: `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 @@ -92,7 +105,7 @@ class Action(enum.Enum): class ToolPermission(pydantic.BaseModel): """ - Control which features of a tool are available to an agent. + Control which features of a tool are available to an project. This solves a few problems: - Some tools expose a number of functions, which may overwhelm the context of an agent. @@ -105,51 +118,38 @@ class ToolPermission(pydantic.BaseModel): easy to understand. - Tools may need additional configuration to define what features are available. - TODO - - Tool configurations should be specific to an agent, not the whole project. - - Do we need to support modification of these rules via the CLI? - """ - - tool_name: str - function_name: str - tool_config: 'ToolConfig' - _actions: list[Action] - _attributes: dict[str, Any] + This is used by both the tool's included configuration and the user's configuration + to represent the permissions for a tool. - def __init__(self, tool_name: str, function_name: str): - self.tool_name = tool_name - self.function_name = function_name - self.tool_config = ToolConfig.from_tool_name(self.tool_name) + 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. + """ - try: - config = self.tool_config.tools[self.function_name] - self._actions = config.pop('actions', []) - self._attributes = config - except KeyError: - raise ValidationError(f"Function '{self.function_name}' not found in tool '{self.tool_name}'") + actions: list[Action] + model_config = pydantic.ConfigDict(extra='allow') # allow extra fields - # TODO we have loaded the default tool config for the actions, now we need to overlay the user's preferences. + def __init__(self, **data): + super().__init__(actions=data.pop('actions', []), **data) @property def READ(self) -> bool: - return Action.READ in self._actions + """Is this tool allowed to read?""" + return Action.READ in self.actions @property def WRITE(self) -> bool: - return Action.WRITE in self._actions + """Is this tool allowed to write?""" + return Action.WRITE in self.actions @property def EXECUTE(self) -> bool: - return Action.EXECUTE in self._actions - - def __getattr__(self, item): - """ - Allow access to other variables defined in the tool config. - If the variable is not defined, return None. - """ - if item in self._attributes: - return self._attributes[item] - return None + """Is this tool allowed to execute?""" + return Action.EXECUTE in self.actions + + def __getattr__(self, name: str) -> Any: + """Developer-defined extra fields are accessible as attributes.""" + return getattr(self.__dict__, name, None) class ToolConfig(pydantic.BaseModel): @@ -157,11 +157,13 @@ 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. + User tool config data is incorporated to return a list of tools the user has + allowed into their project with any permissions they have set. """ name: str category: str - tools: dict[str, Any] + tools: dict[str, ToolPermission] url: Optional[str] = None cta: Optional[str] = None env: Optional[dict] = None @@ -169,25 +171,35 @@ class ToolConfig(pydantic.BaseModel): post_install: Optional[str] = None post_remove: Optional[str] = None - @pydantic.validator('tools') - def validate_tools(cls, value): - """ - Validate that each tool is a dict and has an 'actions' key which lists 'read', 'write', and/or 'execute'. - """ - for tool in value: - if not isinstance(tool, dict): - raise pydantic.ValidationError(f"tools.{tool} is not a dict.") - if 'actions' not in tool: - raise pydantic.ValidationError(f"tools.{tool} does not have a key: 'actions'.") - if not isinstance(tool['actions'], list): - raise pydantic.ValidationError(f"tools.{tool}.actions is not a list.") - for action in tool['actions']: - if action not in Action.__members__.values(): - raise pydantic.ValidationError(f"tools.{tool} has an invalid action: {action}.") - return value + @pydantic.model_validator(mode='before') + def filter_tools(cls, data: dict) -> dict: + """Include only tool functions that are explicitly allowed by the user. """ + tool_name = data['name'] + user_tools_config = UserToolConfig(tool_name) + + for func_name in user_tools_config.tools: + base_config: dict = data['tools'].get(func_name, {}) + assert base_config, f"Tool config.json for '{tool_name}' does not include '{func_name}'." + + user_config: Optional[ToolPermission] = user_tools_config.tools.get(func_name) + # `user_config` can be None if the user chooses to inherit all defaults + if user_config is None: + _user_config = {} + # `user_config` can also be an instance of `ToolPermission` + if isinstance(user_config, ToolPermission): + _user_config = user_config.model_dump() + assert _user_config, f"User tool config got unexpected type {type(user_config)}." + + # combine user and base config and overwrite in the data + data['tools'][func_name] = ToolPermission(**{ + **base_config, + **_user_config, + }) + return data @classmethod def from_tool_name(cls, name: str) -> 'ToolConfig': + """Load a tool's configuration by name.""" path = TOOLS_DIR / name / TOOLS_CONFIG_FILENAME if not os.path.exists(path): raise ValidationError(f'No known agentstack tool: {name}') @@ -195,6 +207,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) @@ -206,6 +219,7 @@ def from_json(cls, path: Path) -> 'ToolConfig': @property def tool_names(self) -> list[str]: + """Get the names of all tools in this tool module.""" return list(self.tools.keys()) @property @@ -217,6 +231,8 @@ def type(self) -> type: def method_stub(name: str): def not_implemented(*args, **kwargs): + # 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( 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})." @@ -264,61 +280,120 @@ 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: + config.tools['tool_function'].actions = [Actions.READ, Actions.WRITE] + ``` + Config Schema ------------- name: str - The name of the agent; used for lookup. - role: str - The role of the agent. - goal: str - The goal of the agent. - backstory: str - The backstory of the agent. - llm: str - The model this agent should use. - Adheres to the format set by the framework. + 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 - role: str = "" - goal: str = "" - backstory: str = "" - llm: str = "" + tools: dict[str, Optional[ToolPermission]] = pydantic.Field(default_factory=dict) - def __init__(self, name: str): - filename = conf.PATH / AGENTS_FILENAME + def __init__(self, tool_name: str): + filename = conf.PATH / USER_TOOL_CONFIG_FILENAME try: with open(filename, 'r') as f: data = yaml.load(f) or {} - data = data.get(name, {}) or {} - super().__init__(**{**{'name': name}, **data}) + data = data.get(tool_name, {}) or {} + super().__init__(**{ + 'name': tool_name, + 'tools': data, + }) + except FileNotFoundError: + # initialize an empty config + # TODO we need to bring existing projects up-to-date + super().__init__(**{ + 'name': tool_name, + 'tools': {}, + }) except YAMLError as e: # TODO format MarkedYAMLError lines/messages - raise ValidationError(f"Error parsing agents file: {filename}\n{e}") + raise ValidationError(f"Error parsing tools file: {filename}\n{e}") except pydantic.ValidationError as e: error_str = "Error validating tool config:\n" for error in e.errors(): error_str += f"{' '.join([str(loc) for loc in error['loc']])}: {error['msg']}\n" - raise ValidationError(f"Error loading tool {name} from {filename}.\n{error_str}") + raise ValidationError(f"Error loading tool {tool_name} from {filename}.\n{error_str}") + + 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 + if not tool_data: # empty configs get marked with `~` + tool_data = ScalarString('~') + + return {tool_name: tool_data} + + def write(self): + log.debug(f"Writing tool {self.name} to {USER_TOOL_CONFIG_FILENAME}") + filename = conf.PATH / USER_TOOL_CONFIG_FILENAME + + with open(filename, 'r') as f: + data = yaml.load(f) or {} + + # update just this tool + data.update(self.model_dump()) + + with open(filename, 'w') as f: + yaml.dump(data, f) + + def __enter__(self) -> 'UserToolConfig': + return self + + def __exit__(self, *args): + self.write() + + +def _initialize_user_tool_config() -> None: + """ + Create a user tool config file if it does not exist and populate it with + all of the tools available to the user. + """ + pass # TODO def get_permissions(func: Callable) -> ToolPermission: """ - Get the permissions for a tool function. + 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. """ - return ToolPermission( - tool_name=function.__module__.split('.')[-1], - function_name=function.__name__, - ) + 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. - ie. agentstack/_tools// - Tools are identified by having a `config.json` file inside the _tools/ directory. + Get all the paths to all bundled tools. (ie. agentstack/_tools//) + Tools are identified by having a `config.json` file inside the tool directory. """ paths = [] for tool_dir in TOOLS_DIR.iterdir(): @@ -330,9 +405,14 @@ def get_all_tool_paths() -> list[Path]: def get_all_tool_names() -> list[str]: + """ + Get the names of all bundled tools. + """ return [path.stem for path in get_all_tool_paths()] -# TODO tool configs don't get modified at runtime, so we can safely cache them. def get_all_tools() -> list[ToolConfig]: - return [ToolConfig.from_tool_name(path) for path in get_all_tool_names()] + """ + Get configurations for all bundled tools. + """ + return [get_tool(name) for name in get_all_tool_names()] diff --git a/agentstack/_tools/example_user_config.yaml b/agentstack/_tools/example_user_config.yaml index 434e33b5..f9aef83a 100644 --- a/agentstack/_tools/example_user_config.yaml +++ b/agentstack/_tools/example_user_config.yaml @@ -1,5 +1,12 @@ +# tools.yaml +# This file controls the tools available to your project, and allows you to +# customize the permissions of each tool. + + + file_read: read_file: - actions: ['read'] - allowed_dirs: ['*'] - allowed_extensions: ['*'] + actions: ['read', 'write'] + allowed_dirs: ['/home/tcdent/*'] + allowed_extensions: ['*.txt', '*.md'] + other_function: ~ # inherit defaults \ No newline at end of file diff --git a/agentstack/_tools/file_read/__init__.py b/agentstack/_tools/file_read/__init__.py index 3b9ba117..89cba427 100644 --- a/agentstack/_tools/file_read/__init__.py +++ b/agentstack/_tools/file_read/__init__.py @@ -8,9 +8,9 @@ from agentstack import tools -def _is_path_allowed(path: Path, allowed_patterns: list[str]) -> bool: +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(str(path), pattern) for pattern in allowed_patterns) + return any(fnmatch(path, pattern) for pattern in allowed_patterns) def read_file(file_path: str) -> str: @@ -30,7 +30,7 @@ def read_file(file_path: str) -> str: return "User has not granted read permission." if permissions.allowed_dirs: - if not _is_path_allowed(path, 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}" diff --git a/agentstack/frameworks/__init__.py b/agentstack/frameworks/__init__.py index 3cbd1ee1..c7b9636d 100644 --- a/agentstack/frameworks/__init__.py +++ b/agentstack/frameworks/__init__.py @@ -7,7 +7,7 @@ from agentstack.utils import get_framework 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 if TYPE_CHECKING: @@ -175,6 +175,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 # wrap method with agentops tool event @@ -199,7 +200,7 @@ def wrapped_method(*args, **kwargs): return wrapped_method tool_funcs = [] - tool_config = ToolConfig.from_tool_name(tool_name) + tool_config = get_tool(tool_name) for tool_func_name in tool_config.tools: tool_func = getattr(tool_config.module, tool_func_name) From eb599e3310b5aea46bb3961e165be8153ea5612c Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 7 Feb 2025 15:43:11 -0800 Subject: [PATCH 06/24] Finalize tool config inheritance. --- agentstack/_tools/__init__.py | 153 +++++++++++++-------- agentstack/_tools/example_user_config.yaml | 41 +++++- 2 files changed, 129 insertions(+), 65 deletions(-) diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py index f6595897..89ce1f6c 100644 --- a/agentstack/_tools/__init__.py +++ b/agentstack/_tools/__init__.py @@ -15,7 +15,11 @@ 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: Path = Path('src/config/tools.yaml') + + +def _get_user_tool_config_path() -> Path: + return conf.PATH / 'src/config/tools.yaml' + yaml = YAML() yaml.preserve_quotes = True # Preserve quotes in existing data @@ -47,7 +51,7 @@ but the user's project will not have access to the function, so we're good. ``` # my_tool.py -def tool_function(vars_need_to_be_preserved_for_llms) -> str: +def tool_function() -> str: permissions = tools.get_permissions(tool_function) ... @@ -105,32 +109,32 @@ class Action(enum.Enum): class ToolPermission(pydantic.BaseModel): """ - Control which features of a tool are available to an project. + 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. + 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. + 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. + 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] model_config = pydantic.ConfigDict(extra='allow') # allow extra fields def __init__(self, **data): - super().__init__(actions=data.pop('actions', []), **data) + super().__init__(actions=data.pop('actions'), **data) @property def READ(self) -> bool: @@ -146,7 +150,7 @@ def WRITE(self) -> bool: def EXECUTE(self) -> bool: """Is this tool allowed to execute?""" return Action.EXECUTE in self.actions - + def __getattr__(self, name: str) -> Any: """Developer-defined extra fields are accessible as attributes.""" return getattr(self.__dict__, name, None) @@ -155,10 +159,10 @@ def __getattr__(self, name: str) -> Any: 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. - User tool config data is incorporated to return a list of tools the user has - allowed into their project with any permissions they have set. + 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 @@ -172,29 +176,43 @@ class ToolConfig(pydantic.BaseModel): post_remove: Optional[str] = None @pydantic.model_validator(mode='before') + @classmethod def filter_tools(cls, data: dict) -> dict: - """Include only tool functions that are explicitly allowed by the user. """ + """Include only tool functions that are explicitly allowed by the user.""" tool_name = data['name'] - user_tools_config = UserToolConfig(tool_name) - - for func_name in user_tools_config.tools: - base_config: dict = data['tools'].get(func_name, {}) - assert base_config, f"Tool config.json for '{tool_name}' does not include '{func_name}'." - - user_config: Optional[ToolPermission] = user_tools_config.tools.get(func_name) - # `user_config` can be None if the user chooses to inherit all defaults - if user_config is None: - _user_config = {} - # `user_config` can also be an instance of `ToolPermission` - if isinstance(user_config, ToolPermission): - _user_config = user_config.model_dump() - assert _user_config, f"User tool config got unexpected type {type(user_config)}." - - # combine user and base config and overwrite in the data - data['tools'][func_name] = ToolPermission(**{ - **base_config, - **_user_config, - }) + tool_data = data['tools'] + + try: + user_config = UserToolConfig(tool_name) + except FileNotFoundError: + return data # if the user has no config, allow all tools. + + log.debug( + f"Excluding tools from {tool_name} based on project permissions: " + f"{', '.join(tool_data.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: + base_perms: dict = tool_data.get(func_name, {}) + assert base_perms, f"Tool config.json for '{tool_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)}." + + filtered_perms[func_name] = ToolPermission( + **{ + **base_perms, + **user_perms, + } + ) + + data['tools'] = filtered_perms return data @classmethod @@ -219,7 +237,7 @@ def from_json(cls, path: Path) -> 'ToolConfig': @property def tool_names(self) -> list[str]: - """Get the names of all tools in this tool module.""" + """Get the names of all tools this project has access to.""" return list(self.tools.keys()) @property @@ -278,7 +296,7 @@ def module(self) -> ModuleType: class UserToolConfig(pydantic.BaseModel): """ - Interface for reading a user's tool configuration from a project. + Interface for reading a user's tool configuration from a project. Usage: ``` @@ -290,13 +308,20 @@ class UserToolConfig(pydantic.BaseModel): Use it as a context manager to make and save edits: ```python with UserToolConfig('tool_name') as config: + # TODO ToolPermission might not be instantiated 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. + 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. """ @@ -305,23 +330,17 @@ class UserToolConfig(pydantic.BaseModel): tools: dict[str, Optional[ToolPermission]] = pydantic.Field(default_factory=dict) def __init__(self, tool_name: str): - filename = conf.PATH / USER_TOOL_CONFIG_FILENAME - + filename = _get_user_tool_config_path() try: with open(filename, 'r') as f: data = yaml.load(f) or {} data = data.get(tool_name, {}) or {} - super().__init__(**{ - 'name': tool_name, - 'tools': data, - }) - except FileNotFoundError: - # initialize an empty config - # TODO we need to bring existing projects up-to-date - super().__init__(**{ - 'name': tool_name, - 'tools': {}, - }) + super().__init__( + **{ + 'name': tool_name, + 'tools': data, + } + ) except YAMLError as e: # TODO format MarkedYAMLError lines/messages raise ValidationError(f"Error parsing tools file: {filename}\n{e}") @@ -331,22 +350,29 @@ def __init__(self, tool_name: str): 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}") + def add_tool(self, tool_config: ToolConfig) -> None: + """Add stubs for a tool to the user's configuration.""" + self.tools.update({tool_name: None for tool_name 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 if not tool_data: # empty configs get marked with `~` tool_data = ScalarString('~') - + return {tool_name: tool_data} def write(self): - log.debug(f"Writing tool {self.name} to {USER_TOOL_CONFIG_FILENAME}") - filename = conf.PATH / USER_TOOL_CONFIG_FILENAME + filename = _get_user_tool_config_path() + log.debug(f"Writing tool '{self.name}' to {filename}") - with open(filename, 'r') as f: - data = yaml.load(f) or {} + try: + with open(filename, 'r') as f: + data = yaml.load(f) or {} + except FileNotFoundError: + data = {} # update just this tool data.update(self.model_dump()) @@ -363,10 +389,17 @@ def __exit__(self, *args): def _initialize_user_tool_config() -> None: """ - Create a user tool config file if it does not exist and populate it with - all of the tools available to the user. + 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. """ - pass # TODO + # TODO there is documentation in the example project file for this, which we + # should include in old projects, too. + + for tool_name in conf.get_installed_tools(): + tool_config = get_tool(tool_name) + with UserToolConfig(tool_name) as user_tool_config: + user_tool_config.add_tool(tool_config) def get_permissions(func: Callable) -> ToolPermission: diff --git a/agentstack/_tools/example_user_config.yaml b/agentstack/_tools/example_user_config.yaml index f9aef83a..9b68586f 100644 --- a/agentstack/_tools/example_user_config.yaml +++ b/agentstack/_tools/example_user_config.yaml @@ -1,8 +1,39 @@ -# tools.yaml -# This file controls the tools available to your project, and allows you to -# customize the permissions of each tool. - - +# 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'] +# ``` file_read: read_file: From 70991d671188ed01a88432386a41ee93f534271f Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 7 Feb 2025 15:43:46 -0800 Subject: [PATCH 07/24] Update configs for all tools. --- agentstack/_tools/agent_connect/config.json | 9 +++++- agentstack/_tools/agentql/config.json | 11 ++++--- agentstack/_tools/browserbase/config.json | 8 +++-- .../_tools/code_interpreter/__init__.py | 6 ++++ .../_tools/code_interpreter/config.json | 6 +++- agentstack/_tools/composio/__init__.py | 18 +++++++++++- agentstack/_tools/composio/config.json | 22 +++++++++----- .../_tools/directory_search/__init__.py | 26 +++++++++++++++-- .../_tools/directory_search/config.json | 10 ++++++- agentstack/_tools/exa/__init__.py | 6 +++- agentstack/_tools/exa/config.json | 9 ++++-- agentstack/_tools/firecrawl/__init__.py | 12 ++++++++ agentstack/_tools/firecrawl/config.json | 14 +++++++-- agentstack/_tools/ftp/__init__.py | 5 ++++ agentstack/_tools/ftp/config.json | 8 +++-- agentstack/_tools/mem0/__init__.py | 9 ++++++ agentstack/_tools/mem0/config.json | 11 +++++-- agentstack/_tools/neon/__init__.py | 17 +++++++++++ agentstack/_tools/neon/config.json | 15 ++++++++-- .../_tools/open_interpreter/__init__.py | 5 ++++ .../_tools/open_interpreter/config.json | 6 +++- agentstack/_tools/payman/__init__.py | 25 ++++++++++++++++ agentstack/_tools/payman/config.json | 29 ++++++++++++++----- agentstack/_tools/perplexity/__init__.py | 4 +++ agentstack/_tools/perplexity/config.json | 6 +++- agentstack/_tools/stripe/config.json | 26 ++++++++++++----- agentstack/_tools/vision/__init__.py | 21 ++++++++++++++ agentstack/_tools/vision/config.json | 8 ++++- agentstack/_tools/weaviate/__init__.py | 9 ++++++ agentstack/_tools/weaviate/config.json | 14 +++++---- 30 files changed, 321 insertions(+), 54 deletions(-) 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..ccc411d9 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 "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 "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 "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 "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/firecrawl/__init__.py b/agentstack/_tools/firecrawl/__init__.py index 1f912b31..da560838 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 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,4 +45,8 @@ 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) diff --git a/agentstack/_tools/firecrawl/config.json b/agentstack/_tools/firecrawl/config.json index 5dcf2748..2a1640bd 100644 --- a/agentstack/_tools/firecrawl/config.json +++ b/agentstack/_tools/firecrawl/config.json @@ -2,12 +2,22 @@ "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"], - "cta": "Create an API key at https://www.firecrawl.dev/" + "tools": { + "web_scrape": { + "actions": ["read"] + }, + "web_crawl": { + "actions": ["read"] + }, + "retrieve_web_crawl": { + "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/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 e491153a..1c8c0761 100644 --- a/agentstack/_tools/vision/__init__.py +++ b/agentstack/_tools/vision/__init__.py @@ -3,11 +3,18 @@ import base64 from typing import Optional import requests +from fnmatch import fnmatch +from agentstack import tools from openai import OpenAI __all__ = ["analyze_image"] +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 analyze_image(image_path_url: str) -> str: """ Analyze an image using OpenAI's Vision API. @@ -18,13 +25,27 @@ def analyze_image(image_path_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." + client = OpenAI() if not image_path_url: return "Image Path or URL is required." if "http" in image_path_url: + if not permissions.allow_http: + return "User has not granted permission to access the internet." return _analyze_web_image(client, image_path_url) + + if permissions.allowed_dirs: + if not _is_path_allowed(image_path_url, permissions.allowed_dirs): + return ( + f"Error: Access to file {image_path_url} is not allowed. " + f"Allowed directories: {permissions.allowed_dirs}" + ) + return _analyze_local_image(client, image_path_url) diff --git a/agentstack/_tools/vision/config.json b/agentstack/_tools/vision/config.json index 37963f0d..40007e76 100644 --- a/agentstack/_tools/vision/config.json +++ b/agentstack/_tools/vision/config.json @@ -8,5 +8,11 @@ "openai>=1.0.0", "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"] + } + } } From 666a2144305c36e0556f1a02c2c575824747e368 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 7 Feb 2025 16:17:36 -0800 Subject: [PATCH 08/24] Tests pass. --- agentstack/_tools/__init__.py | 13 ++++++------- agentstack/_tools/composio/__init__.py | 8 ++++---- tests/fixtures/tool_config_max.json | 12 ++++++++++-- tests/fixtures/tool_config_min.json | 9 ++++++++- tests/test_frameworks.py | 6 +++--- tests/test_tool_config.py | 6 ++++-- 6 files changed, 35 insertions(+), 19 deletions(-) diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py index 89ce1f6c..b4cb406e 100644 --- a/agentstack/_tools/__init__.py +++ b/agentstack/_tools/__init__.py @@ -185,7 +185,8 @@ def filter_tools(cls, data: dict) -> dict: try: user_config = UserToolConfig(tool_name) except FileNotFoundError: - return data # if the user has no config, allow all tools. + log.debug(f"User has no tools.yaml file; allowing all tools.") + return data log.debug( f"Excluding tools from {tool_name} based on project permissions: " @@ -195,6 +196,7 @@ def filter_tools(cls, data: dict) -> dict: filtered_perms = {} for func_name in user_config.tools: + # TODO what about orphaned tools in the user config base_perms: dict = tool_data.get(func_name, {}) assert base_perms, f"Tool config.json for '{tool_name}' does not include '{func_name}'." @@ -205,12 +207,8 @@ def filter_tools(cls, data: dict) -> dict: user_perms = _user_perms.model_dump() assert user_perms is not None, f"User tool permission got unexpected type {type(_user_perms)}." - filtered_perms[func_name] = ToolPermission( - **{ - **base_perms, - **user_perms, - } - ) + all_perms = {**base_perms, **user_perms} + filtered_perms[func_name] = ToolPermission(**all_perms) data['tools'] = filtered_perms return data @@ -393,6 +391,7 @@ def _initialize_user_tool_config() -> None: all of the tools available to the user. This is used to bring an existing project up to date with a UserToolConfig. """ + # TODO actually use this # TODO there is documentation in the example project file for this, which we # should include in old projects, too. diff --git a/agentstack/_tools/composio/__init__.py b/agentstack/_tools/composio/__init__.py index ccc411d9..f574f489 100644 --- a/agentstack/_tools/composio/__init__.py +++ b/agentstack/_tools/composio/__init__.py @@ -36,7 +36,7 @@ def execute_action( """ permissions = tools.get_permissions(execute_action) if not permissions.EXECUTE: - return "User has not granted execute permission." + return {'error': "User has not granted execute permission."} toolset = ComposioToolSet() action = Action(action_name) @@ -55,7 +55,7 @@ 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 "User has not granted read permission." + return {'error': "User has not granted read permission."} toolset = ComposioToolSet() action = Action(action_name) @@ -70,7 +70,7 @@ def find_actions_by_use_case( """Find actions by use case.""" permissions = tools.get_permissions(find_actions_by_use_case) if not permissions.READ: - return "User has not granted read permission." + return [{'error': "User has not granted read permission."}] toolset = ComposioToolSet() actions = toolset.find_actions_by_use_case(*apps, use_case=use_case) @@ -84,7 +84,7 @@ def find_actions_by_tags( """Find actions by tags.""" permissions = tools.get_permissions(find_actions_by_tags) if not permissions.READ: - return "User has not granted read permission." + return [{'error': "User has not granted read permission."}] toolset = ComposioToolSet() actions = toolset.find_actions_by_tags(*apps, tags=tags) diff --git a/tests/fixtures/tool_config_max.json b/tests/fixtures/tool_config_max.json index 1ec8b0fc..b9ef86d7 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,14 @@ "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"] + } + } } \ 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/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_tool_config.py b/tests/test_tool_config.py index bf187e44..55577c24 100644 --- a/tests/test_tool_config.py +++ b/tests/test_tool_config.py @@ -11,7 +11,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 @@ -22,7 +23,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"] + # TODO test config.tools assert config.url == "https://example.com" assert config.cta == "Click me!" assert config.env == {"ENV_VAR1": "value1", "ENV_VAR2": "value2"} From ef7b87ba6f2efb40b2955a2cd7b5ea079f38ceb2 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 7 Feb 2025 19:06:58 -0800 Subject: [PATCH 09/24] Preserve comments in tools.yaml. Bugfixes. --- agentstack/_tools/__init__.py | 160 ++++++++++-------- agentstack/agents.py | 18 +- agentstack/frameworks/__init__.py | 13 +- .../src/config/tools.yaml | 37 ++++ .../src/config/tools.yaml | 37 ++++ .../src/config/tools.yaml | 37 ++++ .../src/config/tools.yaml | 37 ++++ agentstack/generation/tool_generation.py | 10 +- agentstack/inputs.py | 14 +- agentstack/tasks.py | 18 +- agentstack/yaml.py | 20 +++ tests/test_agents_config.py | 2 +- tests/test_generation_tool.py | 5 +- 13 files changed, 298 insertions(+), 110 deletions(-) create mode 100644 agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/config/tools.yaml create mode 100644 agentstack/frameworks/templates/langgraph/{{cookiecutter.project_metadata.project_slug}}/src/config/tools.yaml create mode 100644 agentstack/frameworks/templates/llamaindex/{{cookiecutter.project_metadata.project_slug}}/src/config/tools.yaml create mode 100644 agentstack/frameworks/templates/openai_swarm/{{cookiecutter.project_metadata.project_slug}}/src/config/tools.yaml create mode 100644 agentstack/yaml.py diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py index b4cb406e..b79a38a9 100644 --- a/agentstack/_tools/__init__.py +++ b/agentstack/_tools/__init__.py @@ -6,24 +6,21 @@ from pathlib import Path import enum import pydantic -from ruamel.yaml import YAML, YAMLError -from ruamel.yaml.scalarstring import ScalarString 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 / 'src/config/tools.yaml' + return conf.PATH / USER_TOOL_CONFIG_FILENAME -yaml = YAML() -yaml.preserve_quotes = True # Preserve quotes in existing data - """ Tool Authors ------------ @@ -175,30 +172,46 @@ class ToolConfig(pydantic.BaseModel): post_install: Optional[str] = None post_remove: Optional[str] = None - @pydantic.model_validator(mode='before') @classmethod - def filter_tools(cls, data: dict) -> dict: - """Include only tool functions that are explicitly allowed by the user.""" - tool_name = data['name'] - tool_data = data['tools'] + def from_tool_name(cls, name: str) -> 'ToolConfig': + """Load a tool's configuration by name.""" + path = TOOLS_DIR / name / TOOLS_CONFIG_FILENAME + if not os.path.exists(path): + raise ValidationError(f'No known agentstack tool: {name}') + return cls.from_json(path) + + @classmethod + def from_json(cls, path: Path) -> 'ToolConfig': + """Load a tool's configuration from a path to a JSON file.""" + data = open_json_file(path) + try: + return cls(**data) + except pydantic.ValidationError as e: + error_str = "Error validating tool config:\n" + for error in e.errors(): + error_str += f"{' '.join([str(loc) for loc in error['loc']])}: {error['msg']}\n" + raise ValidationError(f"Error loading tool from {path}.\n{error_str}") + @property + def allowed_tools(self) -> dict[str, ToolPermission]: + """Get the tools this project has access to.""" try: - user_config = UserToolConfig(tool_name) + user_config = UserToolConfig(self.name) except FileNotFoundError: log.debug(f"User has no tools.yaml file; allowing all tools.") - return data + return self.tools log.debug( - f"Excluding tools from {tool_name} based on project permissions: " - f"{', '.join(tool_data.keys() - user_config.tools.keys()) or 'None'}\n" + 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: dict = tool_data.get(func_name, {}) - assert base_perms, f"Tool config.json for '{tool_name}' does not include '{func_name}'." + 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 @@ -207,37 +220,20 @@ def filter_tools(cls, data: dict) -> dict: 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, **user_perms} + all_perms = {**base_perms.model_dump(), **user_perms} filtered_perms[func_name] = ToolPermission(**all_perms) - - data['tools'] = filtered_perms - return data - - @classmethod - def from_tool_name(cls, name: str) -> 'ToolConfig': - """Load a tool's configuration by name.""" - path = TOOLS_DIR / name / TOOLS_CONFIG_FILENAME - if not os.path.exists(path): - raise ValidationError(f'No known agentstack tool: {name}') - return cls.from_json(path) - - @classmethod - def from_json(cls, path: Path) -> 'ToolConfig': - """Load a tool's configuration from a path to a JSON file.""" - data = open_json_file(path) - try: - return cls(**data) - except pydantic.ValidationError as e: - error_str = "Error validating tool config:\n" - for error in e.errors(): - error_str += f"{' '.join([str(loc) for loc in error['loc']])}: {error['msg']}\n" - raise ValidationError(f"Error loading tool from {path}.\n{error_str}") + return filtered_perms @property def tool_names(self) -> list[str]: - """Get the names of all tools this project has access to.""" + """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: """ @@ -306,7 +302,7 @@ class UserToolConfig(pydantic.BaseModel): Use it as a context manager to make and save edits: ```python with UserToolConfig('tool_name') as config: - # TODO ToolPermission might not be instantiated + # TODO `ToolPermission` might not be instantiated so this is a bad example config.tools['tool_function'].actions = [Actions.READ, Actions.WRITE] ``` @@ -331,15 +327,10 @@ def __init__(self, tool_name: str): filename = _get_user_tool_config_path() try: with open(filename, 'r') as f: - data = yaml.load(f) or {} + data = yaml.parser.load(f) or {} data = data.get(tool_name, {}) or {} - super().__init__( - **{ - 'name': tool_name, - 'tools': data, - } - ) - except YAMLError as e: + 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: @@ -348,17 +339,54 @@ def __init__(self, tool_name: str): 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}") - def add_tool(self, tool_config: ToolConfig) -> None: - """Add stubs for a tool to the user's configuration.""" - self.tools.update({tool_name: None for tool_name in tool_config.tool_names}) + @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 - if not tool_data: # empty configs get marked with `~` - tool_data = ScalarString('~') + # if not tool_data: # empty configs get marked with `~` + # tool_data = ScalarString('~') return {tool_name: tool_data} @@ -368,7 +396,7 @@ def write(self): try: with open(filename, 'r') as f: - data = yaml.load(f) or {} + data = yaml.parser.load(f) or {} except FileNotFoundError: data = {} @@ -376,7 +404,7 @@ def write(self): data.update(self.model_dump()) with open(filename, 'w') as f: - yaml.dump(data, f) + yaml.parser.dump(data, f) def __enter__(self) -> 'UserToolConfig': return self @@ -385,22 +413,6 @@ def __exit__(self, *args): self.write() -def _initialize_user_tool_config() -> 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. - """ - # TODO actually use this - # TODO there is documentation in the example project file for this, which we - # should include in old projects, too. - - for tool_name in conf.get_installed_tools(): - tool_config = get_tool(tool_name) - with UserToolConfig(tool_name) as user_tool_config: - user_tool_config.add_tool(tool_config) - - def get_permissions(func: Callable) -> ToolPermission: """ Get the permissions for use inside of a tool function. 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 5c1229d9..405ea712 100644 --- a/agentstack/frameworks/__init__.py +++ b/agentstack/frameworks/__init__.py @@ -8,7 +8,7 @@ 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 @@ -310,6 +310,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. @@ -413,7 +421,8 @@ def wrapped_method(*args, **kwargs): tool_funcs = [] tool_config = get_tool(tool_name) - for tool_func_name in tool_config.tools: + # `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 b2dcfa44..9361e89a 100644 --- a/agentstack/generation/tool_generation.py +++ b/agentstack/generation/tool_generation.py @@ -6,7 +6,7 @@ from agentstack import frameworks from agentstack import packaging from agentstack.utils import term_color -from agentstack._tools import ToolConfig +from agentstack._tools import ToolConfig, UserToolConfig from agentstack.generation import asttools from agentstack.generation.files import EnvFile @@ -30,6 +30,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) 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/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_generation_tool.py b/tests/test_generation_tool.py index 9b3b9a8c..a25dfd47 100644 --- a/tests/test_generation_tool.py +++ b/tests/test_generation_tool.py @@ -7,7 +7,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, remove_tool @@ -22,6 +22,7 @@ def setUp(self): os.makedirs(self.project_dir) os.makedirs(self.project_dir / 'src') + os.makedirs(self.project_dir / 'src' / 'config') os.makedirs(self.project_dir / 'src' / 'tools') (self.project_dir / 'src' / '__init__.py').touch() @@ -49,6 +50,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') From 4100bedc54c6c6028ab2ce3538b0f77e4ea3d7da Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 7 Feb 2025 22:58:59 -0800 Subject: [PATCH 10/24] 100% coverage for _tools/__init__.py --- agentstack/_tools/__init__.py | 30 +++-- tests/fixtures/__init__.py | 0 tests/fixtures/malformed.yaml | 4 + tests/fixtures/test_tool.py | 5 + tests/fixtures/tool_config_invalid.json | 5 + tests/fixtures/tool_config_max.json | 3 + tests/fixtures/tools.yaml | 4 + tests/fixtures/tools_invalid.yaml | 4 + tests/test_tool_config.py | 154 +++++++++++++++++++++--- 9 files changed, 175 insertions(+), 34 deletions(-) create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/malformed.yaml create mode 100644 tests/fixtures/test_tool.py create mode 100644 tests/fixtures/tool_config_invalid.json create mode 100644 tests/fixtures/tools.yaml create mode 100644 tests/fixtures/tools_invalid.yaml diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py index b79a38a9..121500a8 100644 --- a/agentstack/_tools/__init__.py +++ b/agentstack/_tools/__init__.py @@ -128,10 +128,10 @@ class ToolPermission(pydantic.BaseModel): """ actions: list[Action] - model_config = pydantic.ConfigDict(extra='allow') # allow extra fields + attributes: dict[str, Any] = pydantic.Field(default_factory=dict) def __init__(self, **data): - super().__init__(actions=data.pop('actions'), **data) + super().__init__(actions=data.pop('actions'), attributes=data) @property def READ(self) -> bool: @@ -149,8 +149,13 @@ def EXECUTE(self) -> bool: return Action.EXECUTE in self.actions def __getattr__(self, name: str) -> Any: - """Developer-defined extra fields are accessible as attributes.""" - return getattr(self.__dict__, name, None) + """Get an attribute from the attributes dict.""" + return self.attributes.get(name, None) + + def model_dump(self, *args, **kwargs) -> dict: + """Dump the model as a dict.""" + model_dump = super().model_dump(*args, **kwargs) + return {**model_dump['attributes'], 'actions': model_dump['actions']} class ToolConfig(pydantic.BaseModel): @@ -213,7 +218,7 @@ def allowed_tools(self) -> dict[str, ToolPermission]: 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] + _user_perms: Optional[ToolPermission] = user_config.tools.get(func_name) if _user_perms is None: # `None` if user chooses to inherit all defaults user_perms = {} if isinstance(_user_perms, ToolPermission): @@ -245,7 +250,7 @@ def method_stub(name: str): def not_implemented(*args, **kwargs): # 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( + 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})." ) @@ -334,7 +339,7 @@ def __init__(self, tool_name: str): # TODO format MarkedYAMLError lines/messages raise ValidationError(f"Error parsing tools file: {filename}\n{e}") except pydantic.ValidationError as e: - error_str = "Error validating tool config:\n" + 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}") @@ -382,23 +387,16 @@ def add_stubs(self) -> None: 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 - # if not tool_data: # empty configs get marked with `~` - # tool_data = ScalarString('~') - return {tool_name: tool_data} def write(self): filename = _get_user_tool_config_path() log.debug(f"Writing tool '{self.name}' to {filename}") - try: - with open(filename, 'r') as f: - data = yaml.parser.load(f) or {} - except FileNotFoundError: - data = {} + with open(filename, 'r') as f: + data = yaml.parser.load(f) or {} # update just this tool data.update(self.model_dump()) 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_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 b9ef86d7..dcc3f08f 100644 --- a/tests/fixtures/tool_config_max.json +++ b/tests/fixtures/tool_config_max.json @@ -20,6 +20,9 @@ }, "tool2": { "actions": ["read"] + }, + "tool3": { + "actions": ["write"] } } } \ 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_tool_config.py b/tests/test_tool_config.py index 55577c24..ed6e1a38 100644 --- a/tests/test_tool_config.py +++ b/tests/test_tool_config.py @@ -1,12 +1,42 @@ +import os +import shutil import json -import unittest import re from pathlib import Path -from agentstack._tools import ToolConfig, get_all_tool_paths, get_all_tool_names +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_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.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/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) + def test_minimal_json(self): config = ToolConfig.from_json(BASE_PATH / "fixtures/tool_config_min.json") assert config.name == "tool_name" @@ -23,7 +53,7 @@ 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.tool_names == ["tool1", "tool2"] + assert config.tool_names == ["tool1", "tool2", "tool3"] # TODO test config.tools assert config.url == "https://example.com" assert config.cta == "Click me!" @@ -45,20 +75,108 @@ 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" + ) + + 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 - 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" - ) + @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) - assert config.name == path.stem + 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_rwe(self): + tool_permission = ToolPermission(actions=['read', 'write', 'execute']) + assert tool_permission.READ + assert tool_permission.WRITE + assert tool_permission.EXECUTE + + 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) \ No newline at end of file From 6af2c3094d461dbce44636ef89bc65a99300eebd Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 7 Feb 2025 23:05:03 -0800 Subject: [PATCH 11/24] Cleanup extra file. --- agentstack/_tools/example_user_config.yaml | 43 ---------------------- 1 file changed, 43 deletions(-) delete mode 100644 agentstack/_tools/example_user_config.yaml diff --git a/agentstack/_tools/example_user_config.yaml b/agentstack/_tools/example_user_config.yaml deleted file mode 100644 index 9b68586f..00000000 --- a/agentstack/_tools/example_user_config.yaml +++ /dev/null @@ -1,43 +0,0 @@ -# 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'] -# ``` - -file_read: - read_file: - actions: ['read', 'write'] - allowed_dirs: ['/home/tcdent/*'] - allowed_extensions: ['*.txt', '*.md'] - other_function: ~ # inherit defaults \ No newline at end of file From 51258656813e53573f8a5dcdb12b00356b09ee5c Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Tue, 11 Feb 2025 15:53:29 -0800 Subject: [PATCH 12/24] Apply agentstack tool permissions to stripe tool. --- agentstack/__init__.py | 2 ++ agentstack/_tools/__init__.py | 2 +- agentstack/_tools/stripe/__init__.py | 54 +++++++++++++++++++++------- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/agentstack/__init__.py b/agentstack/__init__.py index 16618b0f..12a6c9fd 100644 --- a/agentstack/__init__.py +++ b/agentstack/__init__.py @@ -13,6 +13,7 @@ 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___ = [ @@ -22,6 +23,7 @@ "tools", "get_tags", "get_framework", + "get_tool", "get_agent", "get_all_agents", "get_all_agent_names", diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py index 121500a8..f3cf42a2 100644 --- a/agentstack/_tools/__init__.py +++ b/agentstack/_tools/__init__.py @@ -218,7 +218,7 @@ def allowed_tools(self) -> dict[str, ToolPermission]: 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.get(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): 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", From 64d5ed11158779ddf5a0edfeb87183c853f46119 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Thu, 13 Feb 2025 16:15:25 -0800 Subject: [PATCH 13/24] Update docs. --- docs/tools/package-structure.mdx | 55 ++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/docs/tools/package-structure.mdx b/docs/tools/package-structure.mdx index 57ceb905..27fa6e41 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 From 56ee072e681dcd51b9061803e069e75e48490a0d Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Thu, 13 Feb 2025 16:17:14 -0800 Subject: [PATCH 14/24] Fix vision tool. --- agentstack/_tools/vision/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/agentstack/_tools/vision/__init__.py b/agentstack/_tools/vision/__init__.py index 1ad0a1e1..023827cb 100644 --- a/agentstack/_tools/vision/__init__.py +++ b/agentstack/_tools/vision/__init__.py @@ -126,9 +126,9 @@ def analyze_image(image_path_or_url: str) -> str: return _analyze_web_image(image_path_or_url, media_type) if permissions.allowed_dirs: - if not _is_path_allowed(image_path_url, permissions.allowed_dirs): + if not _is_path_allowed(image_path_or_url, permissions.allowed_dirs): return ( - f"Error: Access to file {image_path_url} is not allowed. " + f"Error: Access to file {image_path_or_url} is not allowed. " f"Allowed directories: {permissions.allowed_dirs}" ) From 46bb2498533bc4f8a955997d406a2752d20019d5 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Thu, 13 Feb 2025 16:27:49 -0800 Subject: [PATCH 15/24] Better docs. --- docs/tools/package-structure.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tools/package-structure.mdx b/docs/tools/package-structure.mdx index 27fa6e41..370aaacf 100644 --- a/docs/tools/package-structure.mdx +++ b/docs/tools/package-structure.mdx @@ -47,7 +47,7 @@ encouraged that versions are specified, which use the `package>=version` format. List of public functions that are exposed by the tool as keys and permissions as a dictionary. -#### `tools.actions` (list['read', 'write', 'execute']) [required] +#### `tools..actions` (list['read', 'write', 'execute']) [required] At a minimum, each tool must have a single action of `read`, `write`, or `execute`. ```json @@ -58,7 +58,7 @@ At a minimum, each tool must have a single action of `read`, `write`, or `execut } ``` -#### `tools.*` (str) [optional] +#### `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. From 5c36a2dc3b980618add3d3304ba526ad49283942 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 14 Feb 2025 18:30:40 +0000 Subject: [PATCH 16/24] Update llms.txt --- docs/llms.txt | 1129 +++++++++++++++++++++++++------------------------ 1 file changed, 566 insertions(+), 563 deletions(-) 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 + + From 572b752765e8b7b4ee788a3f3e5401fb01311035 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 14 Feb 2025 12:11:05 -0800 Subject: [PATCH 17/24] Add metaclass to `agentstack.tools` public method to allow type aliases. --- agentstack/__init__.py | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/agentstack/__init__.py b/agentstack/__init__.py index 12a6c9fd..23657c9e 100644 --- a/agentstack/__init__.py +++ b/agentstack/__init__.py @@ -5,7 +5,7 @@ end user inside their project. """ -from typing import Callable +from typing import Callable, Type, TypeAlias, ClassVar from pathlib import Path from agentstack import conf from agentstack.utils import get_framework @@ -60,24 +60,37 @@ 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. + + 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) - def get_permissions(self, func: Callable) -> _tools.ToolPermission: + def get_permissions(cls, func: Callable) -> _tools.ToolPermission: """ Get the permissions for a tool function. """ - # aliased here to expose in the public API return _tools.get_permissions(func) -tools = ToolLoader() + +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 + From e6329e3dc1be55810e72478a37cbe14b582ef647 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 14 Feb 2025 12:11:38 -0800 Subject: [PATCH 18/24] Add `DELETE` permission. --- agentstack/_tools/__init__.py | 9 +++++++++ tests/test_tool_config.py | 5 +++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py index 45a171a5..260ea986 100644 --- a/agentstack/_tools/__init__.py +++ b/agentstack/_tools/__init__.py @@ -101,8 +101,12 @@ def get_agent() -> Agent: class Action(enum.Enum): READ = 'read' WRITE = 'write' + DELETE = 'delete' EXECUTE = 'execute' + def __str__(self) -> str: + return self.value + class ToolPermission(pydantic.BaseModel): """ @@ -143,6 +147,11 @@ 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?""" diff --git a/tests/test_tool_config.py b/tests/test_tool_config.py index 3337e3be..e997b860 100644 --- a/tests/test_tool_config.py +++ b/tests/test_tool_config.py @@ -180,11 +180,12 @@ def test_load_malformed_user_config(self, mock_get_user_tool_config_path): with self.assertRaises(ValidationError): UserToolConfig('tool_name') - def test_tool_permission_rwe(self): - tool_permission = ToolPermission(actions=['read', 'write', 'execute']) + 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') From 192331259f64077589ded5c9a34c4ceac4255f82 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 14 Feb 2025 12:12:22 -0800 Subject: [PATCH 19/24] Add permisioons to sql tool. --- agentstack/_tools/sql/__init__.py | 52 ++++++++++++++++++++++++++----- agentstack/_tools/sql/config.json | 12 ++++--- 2 files changed, 53 insertions(+), 11 deletions(-) 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 From d95a1ac4cd254c23e70f5ccf9b43b77691418bcc Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 14 Feb 2025 13:04:54 -0800 Subject: [PATCH 20/24] Fix ToolPermission serialization --- agentstack/_tools/__init__.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py index 260ea986..c89e90de 100644 --- a/agentstack/_tools/__init__.py +++ b/agentstack/_tools/__init__.py @@ -21,6 +21,16 @@ def _get_user_tool_config_path() -> Path: return conf.PATH / USER_TOOL_CONFIG_FILENAME +def _get_custom_tool_path(name: str) -> Path: + """Get the path to a custom tool.""" + return conf.PATH / 'src/tools' / name / TOOLS_CONFIG_FILENAME + + +def _get_builtin_tool_path(name: str) -> Path: + """Get the path to a builtin tool.""" + return TOOLS_DIR / name / TOOLS_CONFIG_FILENAME + + """ Tool Authors ------------ @@ -161,20 +171,10 @@ def __getattr__(self, name: str) -> Any: """Get an attribute from the attributes dict.""" return self.attributes.get(name, None) - def model_dump(self, *args, **kwargs) -> dict: - """Dump the model as a dict.""" - model_dump = super().model_dump(*args, **kwargs) - return {**model_dump['attributes'], 'actions': model_dump['actions']} - - -def _get_custom_tool_path(name: str) -> Path: - """Get the path to a custom tool.""" - return conf.PATH / 'src/tools' / name / TOOLS_CONFIG_FILENAME - - -def _get_builtin_tool_path(name: str) -> Path: - """Get the path to a builtin tool.""" - return TOOLS_DIR / name / TOOLS_CONFIG_FILENAME + @pydantic.model_serializer + def ser_model(self) -> dict: + """Merge attributes into top level""" + return {**self.attributes, 'actions': self.actions} class ToolConfig(pydantic.BaseModel): @@ -227,7 +227,7 @@ def write_to_file(self, filename: Path): 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]: From 0bd8aede722699f413c27c9a3e9ee58a53c2fa53 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 14 Feb 2025 13:33:19 -0800 Subject: [PATCH 21/24] Adapt create_tool to accept permissions. --- agentstack/_tools/__init__.py | 10 +++++++++- agentstack/generation/tool_generation.py | 4 ++-- tests/fixtures/tool_config_custom.json | 9 ++++++++- tests/test_generation_tool.py | 9 ++++----- tests/test_tool_config.py | 2 +- 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py index c89e90de..9629b838 100644 --- a/agentstack/_tools/__init__.py +++ b/agentstack/_tools/__init__.py @@ -188,7 +188,7 @@ class ToolConfig(pydantic.BaseModel): name: str category: str - tools: dict[str, ToolPermission] + tools: dict[str, ToolPermission] = pydantic.Field(default_factory=dict) url: Optional[str] = None cta: Optional[str] = None env: Optional[dict] = None @@ -221,6 +221,14 @@ def from_json(cls, path: Path) -> 'ToolConfig': error_str += f"{' '.join([str(loc) for loc in error['loc']])}: {error['msg']}\n" raise ValidationError(f"Error loading tool from {path}.\n{error_str}") + def 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': diff --git a/agentstack/generation/tool_generation.py b/agentstack/generation/tool_generation.py index 8700d477..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, UserToolConfig +from agentstack._tools import Action, ToolPermission, ToolConfig, UserToolConfig from agentstack.generation import asttools from agentstack.generation.files import EnvFile @@ -96,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/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/test_generation_tool.py b/tests/test_generation_tool.py index 424b7c02..04cce6bf 100644 --- a/tests/test_generation_tool.py +++ b/tests/test_generation_tool.py @@ -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 @@ -100,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""" @@ -109,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 e997b860..e4b9e93f 100644 --- a/tests/test_tool_config.py +++ b/tests/test_tool_config.py @@ -203,7 +203,7 @@ def test_tool_missing(self): 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") From 8a6c464c92ef058b60987d8ecdcca6c2d2ab8398 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 14 Feb 2025 13:34:52 -0800 Subject: [PATCH 22/24] Cleanup imports. --- agentstack/__init__.py | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/agentstack/__init__.py b/agentstack/__init__.py index 23657c9e..3f257228 100644 --- a/agentstack/__init__.py +++ b/agentstack/__init__.py @@ -5,7 +5,7 @@ end user inside their project. """ -from typing import Callable, Type, TypeAlias, ClassVar +from typing import Callable, TypeAlias from pathlib import Path from agentstack import conf from agentstack.utils import get_framework @@ -17,29 +17,32 @@ from agentstack import frameworks ___all___ = [ - "conf", - "agent", - "task", - "tools", - "get_tags", - "get_framework", - "get_tool", - "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 @@ -47,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 @@ -63,10 +68,11 @@ def get_tags() -> list[str]: class ToolsMetaclass(type): """ Metaclass for the public tools interface. - + 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__(cls, tool_name: str) -> list[Callable]: """ Get a tool's callables by name with `agentstack.tools[tool_name]` @@ -84,13 +90,13 @@ def get_permissions(cls, func: Callable) -> _tools.ToolPermission: class tools(metaclass=ToolsMetaclass): """ Provides the public interface for accessing `agentstack._tools` methods and - types that we explicitly expose. - + 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 + Action: TypeAlias = _tools.Action From 87c593120380f2d9bf2a58a09b80886cc1c3c17f Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 14 Feb 2025 13:44:03 -0800 Subject: [PATCH 23/24] Add permission checks to firecrawl. --- agentstack/_tools/firecrawl/__init__.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/agentstack/_tools/firecrawl/__init__.py b/agentstack/_tools/firecrawl/__init__.py index b1824aa5..4f5f6830 100644 --- a/agentstack/_tools/firecrawl/__init__.py +++ b/agentstack/_tools/firecrawl/__init__.py @@ -63,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 @@ -78,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 @@ -92,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) @@ -108,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 "User has not granted read permission." + params: Dict[str, Any] = {} if prompt is not None: @@ -130,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 @@ -146,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 From c41f8728c26b5a8669397e74d7b4c2404b69a0c0 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 14 Feb 2025 13:44:47 -0800 Subject: [PATCH 24/24] Type checking. --- agentstack/_tools/firecrawl/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentstack/_tools/firecrawl/__init__.py b/agentstack/_tools/firecrawl/__init__.py index 4f5f6830..333f57d9 100644 --- a/agentstack/_tools/firecrawl/__init__.py +++ b/agentstack/_tools/firecrawl/__init__.py @@ -122,7 +122,7 @@ def extract_data(urls: List[str], schema: Optional[Dict[str, Any]] = None, promp """ permissions = tools.get_permissions(extract_data) if not permissions.READ: - return "User has not granted read permission." + return {'error': "User has not granted read permission."} params: Dict[str, Any] = {}