diff --git a/agentstack/agents.py b/agentstack/agents.py index 8040f26f..a83c91ab 100644 --- a/agentstack/agents.py +++ b/agentstack/agents.py @@ -29,22 +29,22 @@ class AgentConfig(pydantic.BaseModel): ------------- name: str The name of the agent; used for lookup. - role: Optional[str] + role: str The role of the agent. - goal: Optional[str] + goal: str The goal of the agent. - backstory: Optional[str] + backstory: str The backstory of the agent. - llm: Optional[str] + llm: str The model this agent should use. Adheres to the format set by the framework. """ name: str - role: Optional[str] = "" - goal: Optional[str] = "" - backstory: Optional[str] = "" - llm: Optional[str] = "" + role: str = "" + goal: str = "" + backstory: str = "" + llm: str = "" def __init__(self, name: str, path: Optional[Path] = None): if not path: @@ -96,3 +96,18 @@ def __enter__(self) -> 'AgentConfig': def __exit__(self, *args): self.write() + + +def get_all_agent_names(path: Optional[Path] = None) -> list[str]: + if not path: + path = Path() + filename = path / AGENTS_FILENAME + if not os.path.exists(filename): + return [] + with open(filename, 'r') as f: + data = yaml.load(f) or {} + return list(data.keys()) + + +def get_all_agents(path: Optional[Path] = None) -> list[AgentConfig]: + return [AgentConfig(name, path) for name in get_all_agent_names(path)] diff --git a/agentstack/cli/__init__.py b/agentstack/cli/__init__.py index 1a35e913..47adf18c 100644 --- a/agentstack/cli/__init__.py +++ b/agentstack/cli/__init__.py @@ -1 +1 @@ -from .cli import init_project_builder, list_tools, configure_default_model, run_project +from .cli import init_project_builder, list_tools, configure_default_model, run_project, export_template diff --git a/agentstack/cli/cli.py b/agentstack/cli/cli.py index 3d426746..7a097321 100644 --- a/agentstack/cli/cli.py +++ b/agentstack/cli/cli.py @@ -15,6 +15,7 @@ from cookiecutter.main import cookiecutter from dotenv import load_dotenv import subprocess +from packaging.metadata import Metadata from .agentstack_data import ( FrameworkData, @@ -25,11 +26,13 @@ from agentstack.logger import log from agentstack.utils import get_package_path from agentstack.tools import get_all_tools -from agentstack.generation.files import ConfigFile +from agentstack.generation.files import ConfigFile, ProjectFile from agentstack import frameworks from agentstack import packaging from agentstack import generation -from agentstack.utils import open_json_file, term_color, is_snake_case +from agentstack.agents import get_all_agents +from agentstack.tasks import get_all_tasks +from agentstack.utils import open_json_file, term_color, is_snake_case, get_framework from agentstack.update import AGENTSTACK_PACKAGE from agentstack.proj_templates import TemplateConfig @@ -62,13 +65,13 @@ def init_project_builder( try: template_data = TemplateConfig.from_url(template) except Exception as e: - print(term_color(f"Failed to fetch template data from {template}", 'red')) + print(term_color(f"Failed to fetch template data from {template}.\n{e}", 'red')) sys.exit(1) else: try: template_data = TemplateConfig.from_template_name(template) except Exception as e: - print(term_color(f"Failed to load template {template}", 'red')) + print(term_color(f"Failed to load template {template}.\n{e}", 'red')) sys.exit(1) if template_data: @@ -81,11 +84,11 @@ def init_project_builder( } framework = template_data.framework design = { - 'agents': template_data.agents, - 'tasks': template_data.tasks, + 'agents': [agent.model_dump() for agent in template_data.agents], + 'tasks': [task.model_dump() for task in template_data.tasks], 'inputs': template_data.inputs, } - tools = template_data.tools + tools = [tools.model_dump() for tools in template_data.tools] elif use_wizard: welcome_message() @@ -465,3 +468,85 @@ def list_tools(): print("\n\n✨ Add a tool with: agentstack tools add ") print(" https://docs.agentstack.sh/tools/core") + + +def export_template(output_filename: str, path: str = ''): + """ + Export the current project as a template. + """ + _path = Path(path) + framework = get_framework(_path) + + try: + metadata = ProjectFile(_path) + except Exception as e: + print(term_color(f"Failed to load project metadata: {e}", 'red')) + sys.exit(1) + + # Read all the agents from the project's agents.yaml file + agents: list[TemplateConfig.Agent] = [] + for agent in get_all_agents(_path): + agents.append( + TemplateConfig.Agent( + name=agent.name, + role=agent.role, + goal=agent.goal, + backstory=agent.backstory, + model=agent.llm, # TODO consistent naming (llm -> model) + ) + ) + + # Read all the tasks from the project's tasks.yaml file + tasks: list[TemplateConfig.Task] = [] + for task in get_all_tasks(_path): + tasks.append( + TemplateConfig.Task( + name=task.name, + description=task.description, + expected_output=task.expected_output, + agent=task.agent, + ) + ) + + # Export all of the configured tools from the project + tools_agents: dict[str, list[str]] = {} + for agent_name in frameworks.get_agent_names(framework, _path): + for tool_name in frameworks.get_agent_tool_names(framework, agent_name, _path): + if not tool_name: + continue + if tool_name not in tools_agents: + tools_agents[tool_name] = [] + tools_agents[tool_name].append(agent_name) + + tools: list[TemplateConfig.Tool] = [] + for tool_name, agent_names in tools_agents.items(): + tools.append( + TemplateConfig.Tool( + name=tool_name, + agents=agent_names, + ) + ) + + inputs: list[str] = [] + # TODO extract inputs from project + # for input in frameworks.get_input_names(): + # inputs.append(input) + + template = TemplateConfig( + template_version=1, + name=metadata.project_name, + description=metadata.project_description, + framework=framework, + method="sequential", # TODO this needs to be stored in the project somewhere + agents=agents, + tasks=tasks, + tools=tools, + inputs=inputs, + ) + + try: + template.write_to_file(_path / output_filename) + print(term_color(f"Template saved to: {_path / output_filename}", 'green')) + except Exception as e: + print(term_color(f"Failed to write template to file: {e}", 'red')) + sys.exit(1) diff --git a/agentstack/frameworks/__init__.py b/agentstack/frameworks/__init__.py index fef72bc6..9c828d9d 100644 --- a/agentstack/frameworks/__init__.py +++ b/agentstack/frameworks/__init__.py @@ -28,6 +28,12 @@ def validate_project(self, path: Optional[Path] = None) -> None: """ ... + def get_tool_names(self, path: Optional[Path] = None) -> list[str]: + """ + Get a list of tool names in the user's project. + """ + ... + def add_tool(self, tool: ToolConfig, agent_name: str, path: Optional[Path] = None) -> None: """ Add a tool to an agent in the user's project. @@ -46,6 +52,12 @@ def get_agent_names(self, path: Optional[Path] = None) -> list[str]: """ ... + def get_agent_tool_names(self, agent_name: str, path: Optional[Path] = None) -> list[str]: + """ + Get a list of tool names in an agent in the user's project. + """ + ... + def add_agent(self, agent: AgentConfig, path: Optional[Path] = None) -> None: """ Add an agent to the user's project. @@ -102,6 +114,12 @@ def get_agent_names(framework: str, path: Optional[Path] = None) -> list[str]: """ return get_framework_module(framework).get_agent_names(path) +def get_agent_tool_names(framework: str, agent_name: str, path: Optional[Path] = None) -> list[str]: + """ + Get a list of tool names in the user's project. + """ + return get_framework_module(framework).get_agent_tool_names(agent_name, path) + def add_agent(framework: str, agent: AgentConfig, path: Optional[Path] = None): """ Add an agent to the user's project. diff --git a/agentstack/frameworks/crewai.py b/agentstack/frameworks/crewai.py index 3fcebef9..2a3e0c3d 100644 --- a/agentstack/frameworks/crewai.py +++ b/agentstack/frameworks/crewai.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, Any from pathlib import Path import ast from agentstack import ValidationError @@ -259,6 +259,18 @@ def get_agent_names(path: Optional[Path] = None) -> list[str]: return [method.name for method in crew_file.get_agent_methods()] +def get_agent_tool_names(agent_name: str, path: Optional[Path] = None) -> list[Any]: + """ + Get a list of tools used by an agent. + """ + if path is None: + path = Path() + with CrewFile(path / ENTRYPOINT) as crew_file: + tools = crew_file.get_agent_tools(agent_name) + print([node for node in tools.elts]) + return [asttools.get_node_value(node) for node in tools.elts] + + def add_agent(agent: AgentConfig, path: Optional[Path] = None) -> None: """ Add an agent method to the CrewAI entrypoint. diff --git a/agentstack/generation/agent_generation.py b/agentstack/generation/agent_generation.py index 2bc03157..502b7d88 100644 --- a/agentstack/generation/agent_generation.py +++ b/agentstack/generation/agent_generation.py @@ -27,7 +27,7 @@ def add_agent( config.role = role or "Add your role here" config.goal = goal or "Add your goal here" config.backstory = backstory or "Add your backstory here" - config.llm = llm or agentstack_config.default_model + config.llm = llm or agentstack_config.default_model or "" try: frameworks.add_agent(framework, agent, path) diff --git a/agentstack/generation/asttools.py b/agentstack/generation/asttools.py index 575e0403..9ab2f04b 100644 --- a/agentstack/generation/asttools.py +++ b/agentstack/generation/asttools.py @@ -9,7 +9,7 @@ functions that are useful for the specific tasks we need to accomplish. """ -from typing import TypeVar, Optional, Union, Iterable +from typing import TypeVar, Optional, Union, Iterable, Any from pathlib import Path import ast import astor @@ -175,3 +175,16 @@ def find_decorated_method_in_class(classdef: ast.ClassDef, decorator_name: str) def create_attribute(base_name: str, attr_name: str) -> ast.Attribute: """Create an AST node for an attribute""" return ast.Attribute(value=ast.Name(id=base_name, ctx=ast.Load()), attr=attr_name, ctx=ast.Load()) + + +def get_node_value(node: Union[ast.expr, ast.Attribute, ast.Constant, ast.Str, ast.Num]) -> Any: + if isinstance(node, ast.Constant): + return node.value + elif isinstance(node, ast.Attribute): + prefix = get_node_value(node.value) + if prefix: + return '.'.join([prefix, node.attr]) + else: + return node.attr + else: + return None diff --git a/agentstack/generation/files.py b/agentstack/generation/files.py index cbd38204..38f1ca80 100644 --- a/agentstack/generation/files.py +++ b/agentstack/generation/files.py @@ -1,14 +1,20 @@ from typing import Optional, Union -import os +import os, sys import json from pathlib import Path from pydantic import BaseModel + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib from agentstack.utils import get_version DEFAULT_FRAMEWORK = "crewai" CONFIG_FILENAME = "agentstack.json" ENV_FILEMANE = ".env" +PYPROJECT_FILENAME = "pyproject.toml" class ConfigFile(BaseModel): @@ -140,3 +146,45 @@ def __enter__(self) -> 'EnvFile': def __exit__(self, *args): self.write() + + +class ProjectFile: + """ + Interface for interacting with pyproject.toml files inside of a project directory. + This class is read-only and does not support writing changes back to the file. + We expose project metadata as properties to support migration to other formats + in the future. + """ + + _data: dict + + def __init__(self, path: Union[str, Path, None] = None, filename: str = PYPROJECT_FILENAME): + self._path = Path(path) if path else Path.cwd() + self._filename = filename + self.read() + + @property + def project_metadata(self) -> dict: + try: + return self._data['tool']['poetry'] + except KeyError: + raise KeyError("No poetry metadata found in pyproject.toml.") + + @property + def project_name(self) -> str: + return self.project_metadata.get('name', '') + + @property + def project_version(self) -> str: + return self.project_metadata.get('version', '') + + @property + def project_description(self) -> str: + return self.project_metadata.get('description', '') + + def read(self): + if os.path.exists(self._path / self._filename): + with open(self._path / self._filename, 'rb') as f: + self._data = tomllib.load(f) + else: + raise FileNotFoundError(f"File {self._path / self._filename} does not exist.") diff --git a/agentstack/main.py b/agentstack/main.py index f117c1d1..3b99ad3b 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -2,7 +2,13 @@ import os import sys -from agentstack.cli import init_project_builder, list_tools, configure_default_model, run_project +from agentstack.cli import ( + init_project_builder, + list_tools, + configure_default_model, + run_project, + export_template, +) from agentstack.telemetry import track_cli_command from agentstack.utils import get_version, get_framework from agentstack import generation @@ -83,6 +89,9 @@ def main(): tools_remove_parser = tools_subparsers.add_parser("remove", aliases=["r"], help="Remove a tool") tools_remove_parser.add_argument("name", help="Name of the tool to remove") + export_parser = subparsers.add_parser('export', aliases=['e'], help='Export your agent as a template') + export_parser.add_argument('filename', help='The name of the file to export to') + update = subparsers.add_parser('update', aliases=['u'], help='Check for updates') # Parse arguments @@ -128,6 +137,8 @@ def main(): generation.remove_tool(args.name) else: tools_parser.print_help() + elif args.command in ['export', 'e']: + export_template(args.filename) elif args.command in ['update', 'u']: pass # Update check already done else: diff --git a/agentstack/proj_templates.py b/agentstack/proj_templates.py index c96000c5..3bae6a8c 100644 --- a/agentstack/proj_templates.py +++ b/agentstack/proj_templates.py @@ -1,8 +1,9 @@ -from typing import Optional +from typing import Optional, Literal import os, sys from pathlib import Path import pydantic import requests +import json from agentstack import ValidationError from agentstack.utils import get_package_path, open_json_file, term_color @@ -19,38 +20,62 @@ class TemplateConfig(pydantic.BaseModel): The name of the project. description: str A description of the template. - template_version: str + template_version: int The version of the template. framework: str The framework the template is for. method: str The method used by the project. ie. "sequential" agents: list[dict] - A list of agents used by the project. TODO vaidate this against an agent schema + A list of agents used by the project. tasks: list[dict] - A list of tasks used by the project. TODO validate this against a task schema + A list of tasks used by the project. tools: list[dict] - A list of tools used by the project. TODO validate this against a tool schema + A list of tools used by the project. inputs: list[str] A list of inputs used by the project. """ + class Agent(pydantic.BaseModel): + name: str + role: str + goal: str + backstory: str + model: str + + class Task(pydantic.BaseModel): + name: str + description: str + expected_output: str + agent: str + + class Tool(pydantic.BaseModel): + name: str + agents: list[str] + name: str description: str - template_version: int + template_version: Literal[1] framework: str method: str - agents: list[dict] - tasks: list[dict] - tools: list[dict] + agents: list[Agent] + tasks: list[Task] + tools: list[Tool] inputs: list[str] + def write_to_file(self, filename: Path): + if not filename.suffix == '.json': + filename = filename.with_suffix('.json') + + with open(filename, 'w') as f: + model_dump = self.model_dump() + f.write(json.dumps(model_dump, indent=4)) + @classmethod def from_template_name(cls, name: str) -> 'TemplateConfig': path = get_package_path() / f'templates/proj_templates/{name}.json' - if not os.path.exists(path): # TODO raise exceptions and handle message/exit in cli - print(term_color(f'No known agentstack tool: {name}', 'red')) - sys.exit(1) + if not os.path.exists(path): + raise ValidationError(f"Template {name} not found.") return cls.from_json(path) @classmethod @@ -59,11 +84,10 @@ def from_json(cls, path: Path) -> 'TemplateConfig': try: return cls(**data) except pydantic.ValidationError as e: - # TODO raise exceptions and handle message/exit in cli - print(term_color(f"Error validating template config JSON: \n{path}", 'red')) + err_msg = "Error validating template config JSON: \n {path}\n\n" for error in e.errors(): - print(f"{' '.join([str(loc) for loc in error['loc']])}: {error['msg']}") - sys.exit(1) + err_msg += f"{' '.join([str(loc) for loc in error['loc']])}: {error['msg']}\n" + raise ValidationError(err_msg) @classmethod def from_url(cls, url: str) -> 'TemplateConfig': @@ -71,8 +95,11 @@ def from_url(cls, url: str) -> 'TemplateConfig': raise ValidationError(f"Invalid URL: {url}") response = requests.get(url) if response.status_code != 200: - raise ValidationError(f"Failed to fetch template from {url}") - return cls(**response.json()) + raise ValidationError(f"Failed to fetch template from URL:\n {url}") + try: + return cls(**response.json()) + except json.JSONDecodeError as e: + raise ValidationError(f"Error decoding template JSON from URL:\n {url}\n\n{e}") def get_all_template_paths() -> list[Path]: diff --git a/agentstack/tasks.py b/agentstack/tasks.py index bad1d52e..4600fd1c 100644 --- a/agentstack/tasks.py +++ b/agentstack/tasks.py @@ -29,18 +29,18 @@ class TaskConfig(pydantic.BaseModel): ------------- name: str The name of the agent; used for lookup. - description: Optional[str] + description: str The description of the task. - expected_output: Optional[str] + expected_output: str The expected output of the task. - agent: Optional[str] + agent: str The agent to use for the task. """ name: str - description: Optional[str] = "" - expected_output: Optional[str] = "" - agent: Optional[str] = "" + description: str = "" + expected_output: str = "" + agent: str = "" def __init__(self, name: str, path: Optional[Path] = None): if not path: @@ -92,3 +92,18 @@ def __enter__(self) -> 'TaskConfig': def __exit__(self, *args): self.write() + + +def get_all_task_names(path: Optional[Path] = None) -> list[str]: + if not path: + path = Path() + filename = path / TASKS_FILENAME + if not os.path.exists(filename): + return [] + with open(filename, 'r') as f: + data = yaml.load(f) or {} + return list(data.keys()) + + +def get_all_tasks(path: Optional[Path] = None) -> list[TaskConfig]: + return [TaskConfig(name, path) for name in get_all_task_names(path)] diff --git a/agentstack/utils.py b/agentstack/utils.py index c7c1ab61..647e4ee5 100644 --- a/agentstack/utils.py +++ b/agentstack/utils.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, Union import os, sys import json from ruamel.yaml import YAML @@ -37,7 +37,7 @@ def get_package_path() -> Path: return importlib.resources.files('agentstack') # type: ignore[return-value] -def get_framework(path: Optional[str] = None) -> str: +def get_framework(path: Union[str, Path, None] = None) -> str: from agentstack.generation import ConfigFile try: diff --git a/tests/test_cli_init.py b/tests/test_cli_init.py index 7d894125..b07d7a3f 100644 --- a/tests/test_cli_init.py +++ b/tests/test_cli_init.py @@ -4,7 +4,6 @@ from parameterized import parameterized from pathlib import Path import shutil -from agentstack.proj_templates import get_all_template_names BASE_PATH = Path(__file__).parent CLI_ENTRY = [ @@ -32,10 +31,3 @@ def test_init_command(self): result = self._run_cli('init', 'test_project') self.assertEqual(result.returncode, 0) self.assertTrue((self.project_dir / 'test_project').exists()) - - @parameterized.expand([(x,) for x in get_all_template_names()]) - def test_init_command_for_template(self, template_name): - """Test the 'init' command to create a project directory with a template.""" - result = self._run_cli('init', 'test_project', '--template', template_name) - self.assertEqual(result.returncode, 0) - self.assertTrue((self.project_dir / 'test_project').exists()) diff --git a/tests/test_cli_templates.py b/tests/test_cli_templates.py new file mode 100644 index 00000000..e98edf8f --- /dev/null +++ b/tests/test_cli_templates.py @@ -0,0 +1,88 @@ +import subprocess +import os, sys +import unittest +from parameterized import parameterized +from pathlib import Path +import shutil +from agentstack.proj_templates import get_all_template_names + +BASE_PATH = Path(__file__).parent +CLI_ENTRY = [ + sys.executable, + "-m", + "agentstack.main", +] + + +class CLITemplatesTest(unittest.TestCase): + def setUp(self): + self.project_dir = Path(BASE_PATH / 'tmp/cli_templates') + os.makedirs(self.project_dir) + os.chdir(self.project_dir) + + def tearDown(self): + shutil.rmtree(self.project_dir) + + def _run_cli(self, *args): + """Helper method to run the CLI with arguments.""" + return subprocess.run([*CLI_ENTRY, *args], capture_output=True, text=True) + + @parameterized.expand([(x,) for x in get_all_template_names()]) + def test_init_command_for_template(self, template_name): + """Test the 'init' command to create a project directory with a template.""" + result = self._run_cli('init', 'test_project', '--template', template_name) + self.assertEqual(result.returncode, 0) + self.assertTrue((self.project_dir / 'test_project').exists()) + + def test_export_template_v1(self): + result = self._run_cli('init', f"test_project") + self.assertEqual(result.returncode, 0) + os.chdir(self.project_dir / f"test_project") + result = self._run_cli('generate', 'agent', 'test_agent', '--llm', 'opeenai/gpt-4o') + self.assertEqual(result.returncode, 0) + result = self._run_cli('generate', 'task', 'test_task', '--agent', 'test_agent') + self.assertEqual(result.returncode, 0) + result = self._run_cli('tools', 'add', 'ftp', '--agents', 'test_agent') + self.assertEqual(result.returncode, 0) + + result = self._run_cli('export', 'test_template.json') + self.assertEqual(result.returncode, 0) + self.assertTrue((self.project_dir / 'test_project/test_template.json').exists()) + template_str = (self.project_dir / 'test_project/test_template.json').read_text() + self.maxDiff = None + self.assertEqual( + template_str, + """{ + "name": "test_project", + "description": "New agentstack project", + "template_version": 1, + "framework": "crewai", + "method": "sequential", + "agents": [ + { + "name": "test_agent", + "role": "Add your role here", + "goal": "Add your goal here", + "backstory": "Add your backstory here", + "model": "opeenai/gpt-4o" + } + ], + "tasks": [ + { + "name": "test_task", + "description": "Add your description here", + "expected_output": "Add your expected_output here", + "agent": "test_agent" + } + ], + "tools": [ + { + "name": "upload_files", + "agents": [ + "test_agent" + ] + } + ], + "inputs": [] +}""", + ) diff --git a/tests/test_templates_config.py b/tests/test_templates_config.py index 10077c80..a8a38d64 100644 --- a/tests/test_templates_config.py +++ b/tests/test_templates_config.py @@ -1,20 +1,36 @@ +from pathlib import Path import json import unittest -from pathlib import Path +from parameterized import parameterized +from agentstack import ValidationError from agentstack.proj_templates import TemplateConfig, get_all_template_names, get_all_template_paths BASE_PATH = Path(__file__).parent +VALID_TEMPLATE_URL = "https://raw.githubusercontent.com/AgentOps-AI/AgentStack/13a6e335fb163b932ed037562fcedbc269f0d5a5/agentstack/templates/proj_templates/content_creator.json" +INVALID_TEMPLATE_URL = "https://raw.githubusercontent.com/AgentOps-AI/AgentStack/13a6e335fb163b932ed037562fcedbc269f0d5a5/tests/fixtures/tool_config_min.json" class TemplateConfigTest(unittest.TestCase): - def test_all_configs_from_template_name(self): - for template_name in get_all_template_names(): - config = TemplateConfig.from_template_name(template_name) - assert config.name == template_name - # We can assume that pydantic validation caught any other issues + @parameterized.expand([(x,) for x in get_all_template_names()]) + def test_all_configs_from_template_name(self, template_name: str): + config = TemplateConfig.from_template_name(template_name) + assert config.name == template_name + # We can assume that pydantic validation caught any other issues + + @parameterized.expand([(x,) for x in get_all_template_paths()]) + def test_all_configs_from_template_path(self, template_path: Path): + config = TemplateConfig.from_json(template_path) + assert config.name == template_path.stem + # We can assume that pydantic validation caught any other issues + + def test_invalid_template_name(self): + with self.assertRaises(ValidationError): + TemplateConfig.from_template_name("invalid") + + def test_load_template_from_valid_url(self): + config = TemplateConfig.from_url(VALID_TEMPLATE_URL) + assert config.name == "content_creator" - def test_all_configs_from_template_path(self): - for path in get_all_template_paths(): - config = TemplateConfig.from_json(path) - assert config.name == path.stem - # We can assume that pydantic validation caught any other issues + def load_template_from_invalid_url(self): + with self.assertRaises(ValidationError): + TemplateConfig.from_url(INVALID_TEMPLATE_URL)