diff --git a/.gitignore b/.gitignore index b0736799..2edc46f7 100644 --- a/.gitignore +++ b/.gitignore @@ -162,7 +162,14 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ +# aider +.aider* + +# VSCode +.vscode + # AgentStack +.agentstack* example_project/ ex/ **/ex/ @@ -171,5 +178,6 @@ cookiecutter.json examples/tests/ examples/tests/**/* +uv.lock .DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6375ae0..946cbf33 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,9 +12,11 @@ The best place to engage in conversation about your contribution is in the Issue ## Setup 1. Clone the repo -2. `poetry install` -3. `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 + `git clone https://github.com/AgentOps-AI/AgentStack.git` + `cd AgentStack` +2. Install agentstack as an edtiable project and set it up for development and testing + `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 ## Project Structure TODO @@ -59,4 +61,4 @@ pre-commit install ``` ## Tests -HAHAHAHAHAHAHA good one \ No newline at end of file +HAHAHAHAHAHAHA good one diff --git a/agentstack/__init__.py b/agentstack/__init__.py index ca4e0a68..4dac67dc 100644 --- a/agentstack/__init__.py +++ b/agentstack/__init__.py @@ -1,10 +1,12 @@ """ -This it the beginning of the agentstack public API. +This it the beginning of the agentstack public API. Methods that have been imported into this file are expected to be used by the -end user inside of their project. +end user inside of their project. """ -from typing import Callable + +from typing import Optional, Callable, Any +from functools import wraps from pathlib import Path from agentstack import conf from agentstack.utils import get_framework @@ -12,11 +14,12 @@ from agentstack import frameworks ___all___ = [ - "conf", - "tools", - "get_tags", - "get_framework", - "get_inputs", + "conf", + "extra", + "tools", + "get_tags", + "get_framework", + "get_inputs", ] @@ -27,16 +30,83 @@ def get_tags() -> list[str]: return ['agentstack', get_framework(), *conf.get_installed_tools()] -class ToolLoader: +def agent(agent_name: Optional[str] = None): + """ + The `agent` decorator. + This will pass kwargs needed when instantiating an agent in your framework. + """ + + def decorator(func): + nonlocal agent_name + if agent_name is None: + agent_name = func.__name__ + + @wraps(func) + def wrapper(**kwargs): + return func(**frameworks.get_agent_decorator_kwargs(agent_name)) + + return wrapper + + if callable(agent_name): + # This handles the case when the decorator is used without parentheses + f = agent_name + agent_name = None + return decorator(f) + + return decorator + + +def task(task_name: Optional[str] = None): + """ + The `task` decorator. + This will pass kwargs needed when instantiating a task in your framework. """ - Provides the public interface for accessing tools, wrapped in the - framework-specific callable format. + + def decorator(func): + nonlocal task_name + if task_name is None: + task_name = func.__name__ + + @wraps(func) + def wrapper(**kwargs): + return func(**frameworks.get_task_decorator_kwargs(task_name)) + + return wrapper + + if callable(task_name): + # This handles the case when the decorator is used without parentheses + f = task_name + task_name = None + return decorator(f) + return decorator + + +class ConfExtraLoader: + """ + Provides the public interface for accessing the `extra` vars in the project's + agentstack.json file. + """ + + def __getitem__(self, name: str) -> Any: + conf_file = conf.ConfigFile() + return conf_file.extra.get(name) if conf_file.extra else None + + +extra = ConfExtraLoader() + + +class ToolLoader: + """ + Provides the public interface for accessing tools, wrapped in the + framework-specific callable format. + Get a tool's callables by name with `agentstack.tools[tool_name]` Include them in your agent's tool list with `tools = [*agentstack.tools[tool_name], ]` """ + def __getitem__(self, tool_name: str) -> list[Callable]: return frameworks.get_tool_callables(tool_name) -tools = ToolLoader() +tools = ToolLoader() diff --git a/agentstack/agents.py b/agentstack/agents.py index 1c5ab290..dff045f8 100644 --- a/agentstack/agents.py +++ b/agentstack/agents.py @@ -38,7 +38,10 @@ class AgentConfig(pydantic.BaseModel): The backstory of the agent. llm: str The model this agent should use. - Adheres to the format set by the framework. + Always follows the format `org/repo`, regardless of the framework. + # TODO we need to reformat this on-read depending on the framework + retries: int + The number of times the agent should retry a task. """ name: str @@ -46,6 +49,7 @@ class AgentConfig(pydantic.BaseModel): goal: str = "" backstory: str = "" llm: str = "" + retries: int = 0 def __init__(self, name: str): filename = conf.PATH / AGENTS_FILENAME diff --git a/agentstack/cli/__init__.py b/agentstack/cli/__init__.py index 32c08ec3..49a5975f 100644 --- a/agentstack/cli/__init__.py +++ b/agentstack/cli/__init__.py @@ -1,3 +1,3 @@ from .cli import init_project_builder, configure_default_model, export_template from .tools import list_tools, add_tool -from .run import run_project \ No newline at end of file +from .run import run_project diff --git a/agentstack/cli/run.py b/agentstack/cli/run.py index 17c48b49..5aaccb97 100644 --- a/agentstack/cli/run.py +++ b/agentstack/cli/run.py @@ -1,5 +1,7 @@ from typing import Optional import sys +import asyncio +import inspect import traceback from pathlib import Path import importlib.util @@ -84,7 +86,7 @@ def _import_project_module(path: Path): assert spec.loader is not None # appease type checker project_module = importlib.util.module_from_spec(spec) - sys.path.append(str((path / MAIN_FILENAME).parent)) + sys.path.insert(0, str((path / MAIN_FILENAME).parent)) spec.loader.exec_module(project_module) return project_module @@ -116,7 +118,12 @@ def run_project(command: str = 'run', debug: bool = False, cli_args: Optional[st try: print("Running your agent...") project_main = _import_project_module(conf.PATH) - getattr(project_main, command)() + callback = getattr(project_main, command) + + if inspect.iscoroutinefunction(callback): + asyncio.run(callback()) + else: + callback() except ImportError as e: print(term_color(f"Failed to import project. Does '{MAIN_FILENAME}' exist?:\n{e}", 'red')) sys.exit(1) diff --git a/agentstack/conf.py b/agentstack/conf.py index 2b7810e4..00181dc7 100644 --- a/agentstack/conf.py +++ b/agentstack/conf.py @@ -65,6 +65,8 @@ class ConfigFile(BaseModel): The template used to generate the project. template_version: Optional[str] The version of the template system used to generate the project. + extra: Optional[dict] + Variables that will be available inside the project with `agentstack.conf[]`. """ framework: str = DEFAULT_FRAMEWORK # TODO this should probably default to None @@ -74,6 +76,7 @@ class ConfigFile(BaseModel): agentstack_version: Optional[str] = get_version() template: Optional[str] = None template_version: Optional[str] = None + extra: Optional[dict] = None def __init__(self): if os.path.exists(PATH / CONFIG_FILENAME): diff --git a/agentstack/frameworks/__init__.py b/agentstack/frameworks/__init__.py index 3659b621..03758760 100644 --- a/agentstack/frameworks/__init__.py +++ b/agentstack/frameworks/__init__.py @@ -11,12 +11,18 @@ CREWAI = 'crewai' -SUPPORTED_FRAMEWORKS = [CREWAI, ] +PYDANTIC_AI = 'pydantic_ai' +SUPPORTED_FRAMEWORKS = [ + CREWAI, + PYDANTIC_AI, +] + class FrameworkModule(Protocol): """ Protocol spec for a framework implementation module. """ + ENTRYPOINT: Path """ Relative path to the entrypoint file for the framework in the user's project. @@ -60,6 +66,12 @@ def get_agent_names(self) -> list[str]: """ ... + def get_agent_decorator_kwargs(self, agent_name: str) -> dict: + """ + Get the kwargs needed to instantiate an agent in the user's project. + """ + ... + def get_agent_tool_names(self, agent_name: str) -> list[str]: """ Get a list of tool names in an agent in the user's project. @@ -83,6 +95,12 @@ def get_task_names(self) -> list[str]: Get a list of task names in the user's project. """ ... + + def get_task_decorator_kwargs(task_name: str) -> dict: + """ + Get the keyword arguments for the function affected by the task decorator. + """ + ... def get_framework_module(framework: str) -> FrameworkModule: @@ -94,65 +112,89 @@ def get_framework_module(framework: str) -> FrameworkModule: except ImportError: raise Exception(f"Framework {framework} could not be imported.") + def get_entrypoint_path(framework: str) -> Path: """ Get the path to the entrypoint file for a framework. """ return conf.PATH / get_framework_module(framework).ENTRYPOINT + def validate_project(): """ Validate that the user's project is ready to run. """ return get_framework_module(get_framework()).validate_project() + def add_tool(tool: ToolConfig, agent_name: str): """ - Add a tool to the user's project. + Add a tool to the user's project. The tool will have aready been installed in the user's application and have all dependencies installed. We're just handling code generation here. """ return get_framework_module(get_framework()).add_tool(tool, agent_name) + def remove_tool(tool: ToolConfig, agent_name: str): """ Remove a tool from the user's project. """ return get_framework_module(get_framework()).remove_tool(tool, agent_name) + def get_tool_callables(tool_name: str) -> list[Callable]: """ Get a tool by name and return it as a list of framework-native callables. """ return get_framework_module(get_framework()).get_tool_callables(tool_name) + def get_agent_names() -> list[str]: """ Get a list of agent names in the user's project. """ return get_framework_module(get_framework()).get_agent_names() + +def get_agent_decorator_kwargs(agent_name: str) -> dict: + """ + Get the kwargs needed to instantiate an agent in the user's project. + """ + return get_framework_module(get_framework()).get_agent_decorator_kwargs(agent_name) + + def get_agent_tool_names(agent_name: str) -> list[str]: """ Get a list of tool names in the user's project. """ return get_framework_module(get_framework()).get_agent_tool_names(agent_name) + def add_agent(agent: AgentConfig): """ Add an agent to the user's project. """ return get_framework_module(get_framework()).add_agent(agent) + def add_task(task: TaskConfig): """ Add a task to the user's project. """ return get_framework_module(get_framework()).add_task(task) + def get_task_names() -> list[str]: """ Get a list of task names in the user's project. """ return get_framework_module(get_framework()).get_task_names() + +def get_task_decorator_kwargs(task_name: str) -> dict: + """ + Get the keyword arguments for the function affected by the task decorator. + """ + return get_framework_module(get_framework()).get_task_decorator_kwargs(task_name) + diff --git a/agentstack/frameworks/crewai.py b/agentstack/frameworks/crewai.py index 8ace0123..f7761b1b 100644 --- a/agentstack/frameworks/crewai.py +++ b/agentstack/frameworks/crewai.py @@ -1,4 +1,4 @@ -from typing import Optional, Any, Callable +from typing import Optional, Callable from pathlib import Path import ast from agentstack import conf @@ -11,7 +11,11 @@ try: from crewai.tools import tool as _crewai_tool_decorator except ImportError: - raise ValidationError("Could not import `crewai`. Is this an AgentStack CrewAI project?") + raise ValidationError( + "Could not import `crewai`. " + "Ensure you have installed the CrewAI version of AgentStack with: " + "`uv pip install agentstack[crewai]`" + ) ENTRYPOINT: Path = Path('src/crew.py') @@ -117,7 +121,7 @@ def get_agent_tools(self, agent_name: str) -> ast.List: """ Get the tools used by an agent as AST nodes. - Tool definitons are inside of the methods marked with an `@agent` decorator. + Tool definitions are inside of the methods marked with an `@agent` decorator. The method returns a new class instance with the tools as a list of callables under the kwarg `tools`. """ @@ -186,7 +190,7 @@ def add_agent_tools(self, agent_name: str, tool: ToolConfig): """ Add new tools to be used by an agent. - Tool definitons are inside of the methods marked with an `@agent` decorator. + Tool definitions are inside of the methods marked with an `@agent` decorator. The method returns a new class instance with the tools as a list of callables under the kwarg `tools`. """ @@ -235,7 +239,7 @@ def remove_agent_tools(self, agent_name: str, tool: ToolConfig): def validate_project() -> None: """ Validate that a CrewAI project is ready to run. - Raises an `agentstack.VaidationError` if the project is not valid. + Raises an `agentstack.ValidationError` if the project is not valid. """ try: crew_file = CrewFile(conf.PATH / ENTRYPOINT) @@ -293,7 +297,7 @@ def get_agent_names() -> list[str]: return [method.name for method in crew_file.get_agent_methods()] -def get_agent_tool_names(agent_name: str) -> list[Any]: +def get_agent_tool_names(agent_name: str) -> list[str]: """ Get a list of tools used by an agent. """ @@ -301,6 +305,13 @@ def get_agent_tool_names(agent_name: str) -> list[Any]: return crew_file.get_agent_tool_names(agent_name) +def get_agent_decorator_kwargs(agent_name: str) -> dict: + """ + Get the kwargs needed to instantiate an agent in a CrewAI framework. + """ + return {} # TODO we're not using this decorator in crew projects (yet) + + def add_agent(agent: AgentConfig) -> None: """ Add an agent method to the CrewAI entrypoint. @@ -332,12 +343,7 @@ def get_tool_callables(tool_name: str) -> list[Callable]: """ tool_funcs = [] tool_config = ToolConfig.from_tool_name(tool_name) - for tool_func_name in tool_config.tools: - tool_func = getattr(tool_config.module, tool_func_name) - - assert callable(tool_func), f"Tool function {tool_func_name} is not callable." - assert tool_func.__doc__, f"Tool function {tool_func_name} is missing a docstring." - + for tool_func in tool_config.get_all_callables(): # apply the CrewAI tool decorator to the tool function tool_funcs.append(_crewai_tool_decorator(tool_func)) return tool_funcs diff --git a/agentstack/frameworks/pydantic_ai.py b/agentstack/frameworks/pydantic_ai.py new file mode 100644 index 00000000..45b93664 --- /dev/null +++ b/agentstack/frameworks/pydantic_ai.py @@ -0,0 +1,155 @@ +from typing import Optional, Callable +from pathlib import Path +from agentstack import conf +from agentstack.exceptions import ValidationError +from agentstack.tools import ToolConfig +from agentstack.tasks import TaskConfig +from agentstack.agents import AgentConfig +from agentstack.inputs import get_inputs +from agentstack.generation import asttools + +try: + import pydantic_ai # unused, but maybe it's nice to validate now? +except ImportError as e: + raise ValidationError( + "Could not import `pydantic_ai`. " + "Ensure you have installed the Pydantic AI version of AgentStack with: " + "`uv pip install agentstack[pydantic_ai]`" + ) from e + +ENTRYPOINT: Path = Path("src/app.py") + +PROMPT_AGENT: str = "You are {role}. {backstory}\nYour personal goal is: {goal}" +PROMPT_TASK: str = ( + "\nThis is the expect criteria for your final answer: {expected_output}\n " + "you MUST return the actual complete content as the final answer, not a summary. " + "\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:" +) + + +class PydanticAIFile(asttools.File): + pass + + +def _format_llm(llm: str) -> str: + """ + Format the language model for Pydantic AI from the AgentStack format. + "provider/model" -> "provider:model" + """ + # TODO verify this is true with multiple slashes in the model name, too + return llm.replace('/', ':') + + +def _format_system_prompt_for_agent(agent_config: AgentConfig) -> str: + """ + Format the system prompt for an agent. + """ + inputs = get_inputs() + return PROMPT_AGENT.format( + role=agent_config.role.format(**inputs), + goal=agent_config.goal.format(**inputs), + backstory=agent_config.backstory.format(**inputs), + ) + + +def _format_user_prompt_for_task(task_config: TaskConfig) -> str: + """ + Format the user prompt for a task. + """ + inputs = get_inputs() + return PROMPT_TASK.format( + description=task_config.description.format(**inputs), + expected_output=task_config.expected_output.format(**inputs), + ) + + +def validate_project() -> None: + """ + Validate that a user's project is ready to run. + Raises a `ValidationError` if the project is not valid. + """ + pass + + +def get_task_names() -> list[str]: + """ + Get a list of task names in the user's project. + """ + pass + + +def get_task_decorator_kwargs(task_name: str) -> dict: + """ + Get the keyword arguments for the function affected by the agent decorator. + """ + task_config = TaskConfig(task_name) + inputs = get_inputs() + return { + 'user_prompt': _format_user_prompt_for_task(task_config), + } + + +def add_task(task: TaskConfig) -> None: + """ + Add a task to the user's project. + """ + pass + + +def get_agent_names() -> list[str]: + """ + Get a list of agent names in the user's project. + """ + pass + + +def get_agent_tool_names(agent_name: str) -> list[str]: + """ + Get a list of tool names for an agent in the user's project. + """ + pass + + +def get_agent_decorator_kwargs(agent_name: str) -> dict: + """ + Get the keyword arguments for the function affected by the agent decorator. + """ + agent_config = AgentConfig(agent_name) + return { + 'name': agent_name, + 'model': _format_llm(agent_config.llm), + 'system_prompt': _format_system_prompt_for_agent(agent_config), + 'retries': agent_config.retries, + } + + +def add_agent(agent: AgentConfig) -> None: + """ + Add an agent to the user's project. + """ + pass + + +def add_tool(tool: ToolConfig, agent_name: str) -> None: + """ + Add a tool to an agent in the user's project. + """ + pass + + +def remove_tool(tool: ToolConfig, agent_name: str) -> None: + """ + Remove a tool from an agent in the user's project. + """ + pass + + +def get_tool_callables(tool_name: str) -> list[Callable]: + """ + Get a tool's implementations for use by a Pydantic AI agent. + """ + # Pydantic AI wraps functional tools passed to the Agent `tools` attribute + # automatically, so we don't need to do anything to prepare the tool for use. + tool_config = ToolConfig.from_tool_name(tool_name) + return tool_config.get_all_callables() diff --git a/agentstack/generation/__init__.py b/agentstack/generation/__init__.py index 477b8999..ad0b3c90 100644 --- a/agentstack/generation/__init__.py +++ b/agentstack/generation/__init__.py @@ -1,4 +1,4 @@ from .agent_generation import add_agent from .task_generation import add_task from .tool_generation import add_tool, remove_tool -from .files import EnvFile, ProjectFile \ No newline at end of file +from .files import EnvFile, ProjectFile diff --git a/agentstack/generation/files.py b/agentstack/generation/files.py index fa218570..317925b4 100644 --- a/agentstack/generation/files.py +++ b/agentstack/generation/files.py @@ -21,10 +21,10 @@ class EnvFile: and instead just append new lines to the end of the file. This preseres comments and other formatting that the user may have added and prevents opportunities for data loss. - + If the value of a variable is None, it will be commented out when it is written to the file. This gives the user a suggestion, but doesn't override values that - may have been set by the user via other means. + may have been set by the user via other means. `path` is the directory where the .env file is located. Defaults to the current working directory. diff --git a/agentstack/tools/__init__.py b/agentstack/tools/__init__.py index 4783fdb4..299fa90d 100644 --- a/agentstack/tools/__init__.py +++ b/agentstack/tools/__init__.py @@ -1,4 +1,4 @@ -from typing import Optional, Protocol, runtime_checkable +from typing import Optional, Callable, Protocol, runtime_checkable from types import ModuleType import os import sys @@ -12,7 +12,7 @@ class ToolConfig(pydantic.BaseModel): """ This represents the configuration data for a tool. - It parses and validates the `config.json` file and provides a dynamic + It parses and validates the `config.json` file and provides a dynamic interface for interacting with the tool implementation. """ @@ -52,17 +52,21 @@ def type(self) -> type: Dynamically generate a type for the tool module. ie. indicate what methods it's importable module should have. """ + def method_stub(name: str): def not_implemented(*args, **kwargs): - raise NotImplementedError(( + raise NotImplementedError( f"Method '{name}' is configured in config.json for tool '{self.name}'" f"but has not been implemented in the tool module ({self.module_name})." - )) + ) + return not_implemented + # fmt: off type_ = type(f'{snake_to_camel(self.name)}Module', (Protocol,), { # type: ignore[arg-type] method_name: method_stub(method_name) for method_name in self.tools - }) + },) + # fmt: on return runtime_checkable(type_) @property @@ -81,24 +85,35 @@ def module(self) -> ModuleType: assert isinstance(_module, self.type) return _module except AssertionError as e: - raise ValidationError(( + raise ValidationError( f"Tool module `{self.module_name}` does not match the expected implementation. \n" f"The tool's config.json file lists the following public methods: `{'`, `'.join(self.tools)}` " f"but only implements: '{'`, `'.join([m for m in dir(_module) if not m.startswith('_')])}`" - )) + ) except ModuleNotFoundError as e: - raise ValidationError(( + raise ValidationError( f"Could not import tool module: {self.module_name}\n" f"Are you sure you have installed the tool? (agentstack tools add {self.name})\n" f"ModuleNotFoundError: {e}" - )) + ) + + def get_callable(self, func_name: str) -> Callable: + """Get a tool function as a callable by function name.""" + tool_func = getattr(self.module, func_name) + assert callable(tool_func), f"Tool function {func_name} is not callable." + assert tool_func.__doc__, f"Tool function {func_name} is missing a docstring." + return tool_func + + def get_all_callables(self) -> list[Callable]: + """Get all the tool functions as callables.""" + return [self.get_callable(func_name) for func_name in self.tools] def get_all_tool_paths() -> list[Path]: """ Get all the paths to the tool configuration files. ie. agentstack/tools// - Tools are identified by having a `config.json` file instide the tools/ directory. + Tools are identified by having a `config.json` file inside the tools/ directory. """ paths = [] tools_dir = get_package_path() / 'tools' diff --git a/agentstack/tools/agent_connect/__init__.py b/agentstack/tools/agent_connect/__init__.py index 44528a39..31f0caa1 100644 --- a/agentstack/tools/agent_connect/__init__.py +++ b/agentstack/tools/agent_connect/__init__.py @@ -17,16 +17,17 @@ ssl_key_path = os.getenv("AGENT_CONNECT_SSL_KEY_PATH") if not host_domain: - raise Exception(( + raise Exception( "Host domain has not been provided.\n" "Did you set the AGENT_CONNECT_HOST_DOMAIN in you project's .env file?" - )) + ) if not did_document_path: - raise Exception(( + raise Exception( "DID document path has not been provided.\n" "Did you set the AGENT_CONNECT_DID_DOCUMENT_PATH in you project's .env file?" - )) + ) + def generate_did_info(node: SimpleNode, did_document_path: str) -> None: """ diff --git a/agentstack/tools/browserbase/__init__.py b/agentstack/tools/browserbase/__init__.py index 6bd3425e..7edac526 100644 --- a/agentstack/tools/browserbase/__init__.py +++ b/agentstack/tools/browserbase/__init__.py @@ -18,7 +18,7 @@ def load_url( ) -> Any: """ Load a URL in a headless browser and return the page content. - + Args: url: URL to load text_content: Return text content if True, otherwise return raw content diff --git a/agentstack/tools/code_interpreter/__init__.py b/agentstack/tools/code_interpreter/__init__.py index be82f1f7..5b0c9958 100644 --- a/agentstack/tools/code_interpreter/__init__.py +++ b/agentstack/tools/code_interpreter/__init__.py @@ -14,10 +14,12 @@ def _verify_docker_image() -> None: client.images.get(DEFAULT_IMAGE_TAG) except docker.errors.ImageNotFound: if not os.path.exists(DOCKERFILE_PATH): - raise Exception(( - "Dockerfile path has not been provided.\n" - "Did you set the DOCKERFILE_PATH in you project's .env file?" - )) + raise Exception( + ( + "Dockerfile path has not been provided.\n" + "Did you set the DOCKERFILE_PATH in you project's .env file?" + ) + ) client.images.build( path=DOCKERFILE_PATH, @@ -51,16 +53,16 @@ def _init_docker_container() -> docker.models.containers.Container: def run_code(code: str, libraries_used: list[str]) -> str: """ Run the code in a Docker container using Python 3. - + The container will be built and started, the code will be executed, and the container will be stopped. - + Args: code: The code to be executed. ALWAYS PRINT the final result and the output of the code. libraries_used: A list of libraries to be installed in the container before running the code. """ _verify_docker_image() container = _init_docker_container() - + for library in libraries_used: container.exec_run(f"pip install {library}") @@ -68,8 +70,4 @@ def run_code(code: str, libraries_used: list[str]) -> str: container.stop() container.remove() - return ( - f"exit code: {result.exit_code}\n" - f"{result.output.decode('utf-8')}" - ) - + return f"exit code: {result.exit_code}\n" f"{result.output.decode('utf-8')}" diff --git a/agentstack/tools/directory_search/__init__.py b/agentstack/tools/directory_search/__init__.py index 04a53ab6..cf199910 100644 --- a/agentstack/tools/directory_search/__init__.py +++ b/agentstack/tools/directory_search/__init__.py @@ -1,4 +1,5 @@ """Framework-agnostic directory search implementation using embedchain.""" + from typing import Optional from pathlib import Path from embedchain.loaders.directory_loader import DirectoryLoader diff --git a/agentstack/tools/directory_search/pyproject.toml b/agentstack/tools/directory_search/pyproject.toml deleted file mode 100644 index b575386f..00000000 --- a/agentstack/tools/directory_search/pyproject.toml +++ /dev/null @@ -1,8 +0,0 @@ -[project] -name = "agentstack.directory_search" -version = "0.1" - -dependencies = [] - -[project.optional-dependencies] -crewai = [] \ No newline at end of file diff --git a/agentstack/tools/file_read/__init__.py b/agentstack/tools/file_read/__init__.py index 1ab49990..3fca8dcf 100644 --- a/agentstack/tools/file_read/__init__.py +++ b/agentstack/tools/file_read/__init__.py @@ -1,6 +1,7 @@ """ Framework-agnostic implementation of file reading functionality. """ + from typing import Optional from pathlib import Path diff --git a/agentstack/tools/file_read/pyproject.toml b/agentstack/tools/file_read/pyproject.toml deleted file mode 100644 index a4f20180..00000000 --- a/agentstack/tools/file_read/pyproject.toml +++ /dev/null @@ -1,8 +0,0 @@ -[project] -name = "agentstack.file_read" -version = "0.1" - -dependencies = [] - -[project.optional-dependencies] -crewai = [] diff --git a/agentstack/tools/ftp/__init__.py b/agentstack/tools/ftp/__init__.py index 889a6592..3248f551 100644 --- a/agentstack/tools/ftp/__init__.py +++ b/agentstack/tools/ftp/__init__.py @@ -8,34 +8,29 @@ if not HOST: - raise Exception(( - "Host domain has not been provided.\n" - "Did you set the FTP_HOST in you project's .env file?" - )) + raise Exception( + "Host domain has not been provided.\n Did you set the FTP_HOST in you project's .env file?" + ) if not USER: - raise Exception(( - "User has not been provided.\n" - "Did you set the FTP_USER in you project's .env file?" - )) + raise Exception("User has not been provided.\n Did you set the FTP_USER in you project's .env file?") if not PASSWORD: - raise Exception(( - "Password has not been provided.\n" - "Did you set the FTP_PASSWORD in you project's .env file?" - )) + raise Exception( + "Password has not been provided.\n Did you set the FTP_PASSWORD in you project's .env file?" + ) def upload_files(file_paths: list[str]): """ Upload a list of files to the FTP server. - + Args: file_paths: A list of file paths to upload to the FTP server. Returns: bool: True if all files were uploaded successfully, False otherwise. """ - + assert HOST and USER and PASSWORD # appease type checker result = True diff --git a/agentstack/tools/stripe/__init__.py b/agentstack/tools/stripe/__init__.py index ee4bd14d..9c428f83 100644 --- a/agentstack/tools/stripe/__init__.py +++ b/agentstack/tools/stripe/__init__.py @@ -1,116 +1,79 @@ -"""Stripe tool for AgentStack. - -This module provides framework-agnostic functions for interacting with the Stripe API, -supporting payment links, products, and prices operations. -""" - -import json -import os -from typing import Dict, Optional - -import stripe -from stripe_agent_toolkit.functions import ( - create_payment_link as toolkit_create_payment_link, - create_product as toolkit_create_product, - create_price as toolkit_create_price, +from typing import Callable, Optional +import os, sys +from stripe_agent_toolkit.configuration import Configuration, is_tool_allowed +from stripe_agent_toolkit.api import StripeAPI +from stripe_agent_toolkit.tools import tools + +__all__ = [ + "create_payment_link", + "create_product", + "list_products", + "create_price", + "list_prices", +] + +STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY") + +if not STRIPE_SECRET_KEY: + raise Exception( + "Stripe Secret Key not found. Did you set the STRIPE_SECRET_KEY in you project's .env file?" + ) + +_configuration = Configuration( + { + "actions": { + "payment_links": { + "create": True, + }, + "products": { + "create": True, + "read": True, + }, + "prices": { + "create": True, + "read": True, + }, + } + } +) +client = StripeAPI( + secret_key=STRIPE_SECRET_KEY, + context=_configuration.get('context') or None, ) -from stripe_agent_toolkit.configuration import Context -from stripe_agent_toolkit.schema import CreatePaymentLink, CreateProduct, CreatePrice - - -def setup_stripe(secret_key: str) -> None: - """Initialize Stripe with the provided secret key.""" - stripe.api_key = secret_key - stripe.set_app_info("agentstack-stripe", version="0.1.0", url="https://github.com/AgentOps-AI/AgentStack") - - -def create_payment_link(price: str, quantity: int) -> Dict: - """Create a payment link for a specific price and quantity. - - Args: - price: The ID of the price to create a payment link for - quantity: The quantity of items to include in the payment link - - Returns: - Dict containing the payment link ID and URL - """ - # Validate input using toolkit's schema - params = CreatePaymentLink(price=price, quantity=quantity) - return toolkit_create_payment_link(Context(), params.price, params.quantity) - - -def get_payment_link(payment_link_id: str) -> Dict: - """Retrieve a payment link by ID. - - Args: - payment_link_id: The ID of the payment link to retrieve - - Returns: - Dict containing the payment link ID and URL - """ - payment_link = stripe.PaymentLink.retrieve(payment_link_id) - return {"id": payment_link.id, "url": payment_link.url} - - -def create_product(name: str, description: Optional[str] = None) -> Dict: - """Create a new product. - - Args: - name: The name of the product - description: Optional description of the product - - Returns: - Dict containing the product details - """ - # Validate input using toolkit's schema - params = CreateProduct(name=name, description=description) - return toolkit_create_product(Context(), params.name, params.description) - - -def update_product(product_id: str, **kwargs) -> Dict: - """Update an existing product. - - Args: - product_id: The ID of the product to update - **kwargs: Additional fields to update (e.g., name, description) - - Returns: - Dict containing the updated product details - """ - product = stripe.Product.modify(product_id, **kwargs) - return json.loads(str(product)) - - -def create_price(product: str, currency: str, unit_amount: int) -> Dict: - """Create a new price for a product. - - Args: - product: The ID of the product to create a price for - currency: Three-letter ISO currency code - unit_amount: The amount in cents to charge - - Returns: - Dict containing the price details - """ - # Validate input using toolkit's schema - params = CreatePrice(product=product, currency=currency, unit_amount=unit_amount) - return toolkit_create_price(Context(), params.product, params.currency, params.unit_amount) - - -def update_price(price_id: str, **kwargs) -> Dict: - """Update an existing price. - - Args: - price_id: The ID of the price to update - **kwargs: Additional fields to update (e.g., nickname, active) - - Returns: - Dict containing the updated price details - """ - price = stripe.Price.modify(price_id, **kwargs) - return json.loads(str(price)) -# Initialize Stripe when the module is imported if the environment variable is set -if "STRIPE_SECRET_KEY" in os.environ: - setup_stripe(os.environ["STRIPE_SECRET_KEY"]) +def _create_tool_function(tool: dict) -> Callable: + """Dynamically create a tool function based on the tool schema.""" + # `tool` is not typed, but follows this schema: + # { + # "method": "create_customer", + # "name": "Create Customer", + # "description": CREATE_CUSTOMER_PROMPT, + # "args_schema": CreateCustomer, + # "actions": { + # "customers": { + # "create": True, + # } + # }, + # } + schema = tool['args_schema'] + + def func(**kwargs) -> str: + validated_data = schema(**kwargs) + return client.run(tool['method'], **validated_data.dict(exclude_unset=True)) + + func.__name__ = tool['method'] + func.__doc__ = f"{tool['name']}: \n{tool['description']}" + func.__annotations__ = { + 'return': str, + **{name: field.annotation for name, field in schema.model_fields.items()}, + } + return func + + +# Dynamically create tool functions based on the configuration and add them to the module. +for tool in tools: + if not is_tool_allowed(tool, _configuration): + continue + + setattr(sys.modules[__name__], tool['method'], _create_tool_function(tool)) diff --git a/agentstack/tools/stripe/config.json b/agentstack/tools/stripe/config.json index c4d3dbd1..89b18366 100644 --- a/agentstack/tools/stripe/config.json +++ b/agentstack/tools/stripe/config.json @@ -1,16 +1,20 @@ { - "name": "stripe", - "description": "Interact with Stripe API for payment processing", - "version": "0.1.0", - "dependencies": [ - "stripe>=11.0.0", - "stripe-agent-toolkit>=0.2.0" - ], - "cta": "Visit https://stripe.com/docs/api for more information", - "environment_variables": { - "STRIPE_SECRET_KEY": { - "description": "Your Stripe API secret key", - "required": true - } - } -} + "name": "stripe", + "url": "https://github.com/stripe/agent-toolkit", + "category": "application-specific", + "env": { + "STRIPE_SECRET_KEY": null + }, + "dependencies": [ + "stripe-agent-toolkit==0.2.0", + "stripe>=11.0.0" + ], + "tools": [ + "create_payment_link", + "create_product", + "list_products", + "create_price", + "list_prices" + ], + "cta": "🔑 Create your Stripe API key here: https://dashboard.stripe.com/account/apikeys" +} \ No newline at end of file diff --git a/agentstack/tools/vision/__init__.py b/agentstack/tools/vision/__init__.py index 55869473..d6485598 100644 --- a/agentstack/tools/vision/__init__.py +++ b/agentstack/tools/vision/__init__.py @@ -1,4 +1,70 @@ """Vision tool for analyzing images using OpenAI's Vision API.""" -from .vision import analyze_image + +import base64 +from typing import Optional +import requests +from openai import OpenAI __all__ = ["analyze_image"] + + +def analyze_image(image_path_url: str) -> str: + """ + Analyze an image using OpenAI's Vision API. + + Args: + image_path_url: Local path or URL to the image + + Returns: + str: Description of the image contents + """ + client = OpenAI() + + if not image_path_url: + return "Image Path or URL is required." + + if "http" in image_path_url: + return _analyze_web_image(client, image_path_url) + return _analyze_local_image(client, image_path_url) + + +def _analyze_web_image(client: OpenAI, image_path_url: str) -> str: + response = client.chat.completions.create( + model="gpt-4-vision-preview", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + {"type": "image_url", "image_url": {"url": image_path_url}}, + ], + } + ], + max_tokens=300, + ) + return response.choices[0].message.content + + +def _analyze_local_image(client: OpenAI, image_path: str) -> str: + base64_image = _encode_image(image_path) + headers = {"Content-Type": "application/json", "Authorization": f"Bearer {client.api_key}"} + payload = { + "model": "gpt-4-vision-preview", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}, + ], + } + ], + "max_tokens": 300, + } + response = requests.post("https://api.openai.com/v1/chat/completions", headers=headers, json=payload) + return response.json()["choices"][0]["message"]["content"] + + +def _encode_image(image_path: str) -> str: + with open(image_path, "rb") as image_file: + return base64.b64encode(image_file.read()).decode("utf-8") diff --git a/agentstack/tools/vision/vision.py b/agentstack/tools/vision/vision.py deleted file mode 100644 index 8d02c958..00000000 --- a/agentstack/tools/vision/vision.py +++ /dev/null @@ -1,74 +0,0 @@ -import base64 -from typing import Optional -import requests -from openai import OpenAI - - -def analyze_image(image_path_url: str) -> str: - """ - Analyze an image using OpenAI's Vision API. - - Args: - image_path_url: Local path or URL to the image - - Returns: - str: Description of the image contents - """ - client = OpenAI() - - if not image_path_url: - return "Image Path or URL is required." - - if "http" in image_path_url: - return _analyze_web_image(client, image_path_url) - return _analyze_local_image(client, image_path_url) - - -def _analyze_web_image(client: OpenAI, image_path_url: str) -> str: - response = client.chat.completions.create( - model="gpt-4-vision-preview", - messages=[{ - "role": "user", - "content": [ - {"type": "text", "text": "What's in this image?"}, - {"type": "image_url", "image_url": {"url": image_path_url}} - ] - }], - max_tokens=300 - ) - return response.choices[0].message.content - - -def _analyze_local_image(client: OpenAI, image_path: str) -> str: - base64_image = _encode_image(image_path) - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {client.api_key}" - } - payload = { - "model": "gpt-4-vision-preview", - "messages": [{ - "role": "user", - "content": [ - {"type": "text", "text": "What's in this image?"}, - { - "type": "image_url", - "image_url": { - "url": f"data:image/jpeg;base64,{base64_image}" - } - } - ] - }], - "max_tokens": 300 - } - response = requests.post( - "https://api.openai.com/v1/chat/completions", - headers=headers, - json=payload - ) - return response.json()["choices"][0]["message"]["content"] - - -def _encode_image(image_path: str) -> str: - with open(image_path, "rb") as image_file: - return base64.b64encode(image_file.read()).decode("utf-8") diff --git a/pyproject.toml b/pyproject.toml index 343a13b0..c5d0ac7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,8 +44,11 @@ crewai = [ "crewai==0.83.0", "crewai-tools==0.14.0", ] +pydantic_ai = [ + "pydantic-ai>=0.0.14", +] all = [ - "agentstack[dev,test,crewai]", + "agentstack[dev,test,crewai,pydantic_ai]", ] [tool.setuptools.package-data] @@ -67,8 +70,7 @@ exclude = [ "dist", "*.egg-info", "agentstack/templates/", - "examples", - "__init__.py" + "examples" ] line-length = 110