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