diff --git a/agentstack/cli/__init__.py b/agentstack/cli/__init__.py index afd42af5..1a35e913 100644 --- a/agentstack/cli/__init__.py +++ b/agentstack/cli/__init__.py @@ -1 +1 @@ -from .cli import init_project_builder, list_tools, configure_default_model +from .cli import init_project_builder, list_tools, configure_default_model, run_project diff --git a/agentstack/cli/cli.py b/agentstack/cli/cli.py index 16f3684c..f227fb83 100644 --- a/agentstack/cli/cli.py +++ b/agentstack/cli/cli.py @@ -4,6 +4,7 @@ import time from datetime import datetime from typing import Optional +from pathlib import Path import requests import itertools @@ -18,7 +19,9 @@ from agentstack.utils import get_package_path from agentstack.generation.files import ConfigFile from agentstack.generation.tool_generation import get_all_tools -from agentstack import packaging, generation +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.update import AGENTSTACK_PACKAGE @@ -151,6 +154,24 @@ def configure_default_model(path: Optional[str] = None): agentstack_config.default_model = model +def run_project(framework: str, path: str = ''): + """Validate that the project is ready to run and then run it.""" + if not framework in frameworks.SUPPORTED_FRAMEWORKS: + print(term_color(f"Framework {framework} is not supported by agentstack.", 'red')) + sys.exit(1) + + try: + frameworks.validate_project(framework, path) + except frameworks.ValidationError as e: + print(term_color("Project validation failed:", 'red')) + print(e) + sys.exit(1) + + path = Path(path) + entrypoint = path/frameworks.get_entrypoint_path(framework) + os.system(f'python {entrypoint}') + + def ask_framework() -> str: framework = "CrewAI" # framework = inquirer.list_input( diff --git a/agentstack/frameworks/__init__.py b/agentstack/frameworks/__init__.py new file mode 100644 index 00000000..8c4779b4 --- /dev/null +++ b/agentstack/frameworks/__init__.py @@ -0,0 +1,41 @@ +""" +Methods for interacting with framework-specific features. + +Each framework should have a module in the `frameworks` package which defines the following methods: + +- `ENTRYPOINT`: Path: Relative path to the entrypoint file for the framework +- `validate_project(path: Optional[Path] = None) -> None`: Validate that a project is ready to run. + Raises a `ValidationError` if the project is not valid. +""" +from typing import Optional +from importlib import import_module +from pathlib import Path + + +CREWAI = 'crewai' +SUPPORTED_FRAMEWORKS = [CREWAI, ] + +def get_framework_module(framework: str) -> import_module: + """ + Get the module for a framework. + """ + if framework == CREWAI: + from . import crewai + return crewai + else: + raise ValueError(f"Framework {framework} not supported") + +def get_entrypoint_path(framework: str) -> Path: + """ + Get the path to the entrypoint file for a framework. + """ + return get_framework_module(framework).ENTRYPOINT + +class ValidationError(Exception): pass + +def validate_project(framework: str, path: Optional[Path] = None) -> None: + """ + Run the framework specific project validation. + """ + return get_framework_module(framework).validate_project(path) + diff --git a/agentstack/frameworks/crewai.py b/agentstack/frameworks/crewai.py new file mode 100644 index 00000000..249caca2 --- /dev/null +++ b/agentstack/frameworks/crewai.py @@ -0,0 +1,63 @@ +from typing import Optional +from pathlib import Path +import ast +from . import SUPPORTED_FRAMEWORKS, ValidationError + + +ENTRYPOINT: Path = Path('src/crew.py') + +def validate_project(path: Optional[Path] = None) -> None: + """ + Validate that a CrewAI project is ready to run. + Raises a frameworks.VaidationError if the project is not valid. + """ + try: + if path is None: path = Path() + with open(path/ENTRYPOINT, 'r') as f: + tree = ast.parse(f.read()) + except (FileNotFoundError, SyntaxError) as e: + raise ValidationError(f"Failed to parse {ENTRYPOINT}\n {e}") + + # A valid project must have a class in the crew.py file decorated with `@CrewBase` + try: + class_node = _find_class_with_decorator(tree, 'CrewBase')[0] + except IndexError: + raise ValidationError(f"`@CrewBase` decorated class not found in {ENTRYPOINT}") + + # The Crew class must have one or more methods decorated with `@agent` + if len(_find_decorated_method_in_class(class_node, 'task')) < 1: + raise ValidationError( + f"`@task` decorated method not found in `{class_node.name}` class in {ENTRYPOINT}.\n" + "Create a new task using `agentstack generate task `.") + + # The Crew class must have one or more methods decorated with `@agent` + if len(_find_decorated_method_in_class(class_node, 'agent')) < 1: + raise ValidationError( + f"`@agent` decorated method not found in `{class_node.name}` class in {ENTRYPOINT}.\n" + "Create a new agent using `agentstack generate agent `.") + + # The Crew class must have one method decorated with `@crew` + if len(_find_decorated_method_in_class(class_node, 'crew')) < 1: + raise ValidationError(f"`@crew` decorated method not found in `{class_node.name}` class in {ENTRYPOINT}") + +# TODO move these to a shared AST utility module +def _find_class_with_decorator(tree: ast.AST, decorator_name: str) -> list[ast.ClassDef]: + """Find a class definition that is marked by a decorator in an AST.""" + nodes = [] + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.ClassDef): + for decorator in node.decorator_list: + if isinstance(decorator, ast.Name) and decorator.id == decorator_name: + nodes.append(node) + return nodes + +def _find_decorated_method_in_class(classdef: ast.ClassDef, decorator_name: str) -> list[ast.FunctionDef]: + """Find all method definitions in a class definition which are decorated with a specific decorator.""" + nodes = [] + for node in ast.iter_child_nodes(classdef): + if isinstance(node, ast.FunctionDef): + for decorator in node.decorator_list: + if isinstance(decorator, ast.Name) and decorator.id == decorator_name: + nodes.append(node) + return nodes + diff --git a/agentstack/generation/gen_utils.py b/agentstack/generation/gen_utils.py index f9ac0e5f..dc9ac38c 100644 --- a/agentstack/generation/gen_utils.py +++ b/agentstack/generation/gen_utils.py @@ -2,8 +2,10 @@ import sys from enum import Enum from typing import Optional, Union, List +from pathlib import Path from agentstack.utils import term_color +from agentstack import frameworks def insert_code_after_tag(file_path, tag, code_to_insert, next_line=False): @@ -72,14 +74,6 @@ def string_in_file(file_path: str, str_to_match: str) -> bool: return str_to_match in file_content -def _framework_filename(framework: str, path: str = ''): - if framework == 'crewai': - return f'{path}src/crew.py' - - print(term_color(f'Unknown framework: {framework}', 'red')) - sys.exit(1) - - class CrewComponent(str, Enum): AGENT = "agent" TASK = "task" @@ -103,7 +97,8 @@ def get_crew_components( Returns: Dictionary with 'agents' and 'tasks' keys containing lists of names """ - filename = _framework_filename(framework, path) + path = Path(path) + filename = path/frameworks.get_entrypoint_path(framework) # Convert single component type to list for consistent handling if isinstance(component_type, CrewComponent): diff --git a/agentstack/generation/tool_generation.py b/agentstack/generation/tool_generation.py index ba3d30aa..a241ba6f 100644 --- a/agentstack/generation/tool_generation.py +++ b/agentstack/generation/tool_generation.py @@ -7,7 +7,7 @@ from typing import Optional, List, Dict, Union from . import get_agent_names -from .gen_utils import insert_code_after_tag, string_in_file, _framework_filename +from .gen_utils import insert_code_after_tag, string_in_file from ..utils import open_json_file, get_framework, term_color import os import shutil @@ -19,25 +19,12 @@ from agentstack import packaging from agentstack.utils import get_package_path from agentstack.generation.files import ConfigFile, EnvFile +from agentstack import frameworks from .gen_utils import insert_code_after_tag, string_in_file from ..utils import open_json_file, get_framework, term_color TOOL_INIT_FILENAME = "src/tools/__init__.py" -FRAMEWORK_FILENAMES: dict[str, str] = { - 'crewai': 'src/crew.py', -} - -def get_framework_filename(framework: str, path: str = ''): - if path: - path = path.endswith('/') and path or path + '/' - else: - path = './' - try: - return f"{path}{FRAMEWORK_FILENAMES[framework]}" - except KeyError: - print(term_color(f'Unknown framework: {framework}', 'red')) - sys.exit(1) class ToolConfig(BaseModel): name: str @@ -375,7 +362,8 @@ def modify_agent_tools( print(term_color(f"Agent '{agent}' not found in the project.", 'red')) sys.exit(1) - filename = _framework_filename(framework, path) + path = Path(path) + filename = path/frameworks.get_entrypoint_path(framework) with open(filename, 'r', encoding='utf-8') as f: source_lines = f.readlines() diff --git a/agentstack/main.py b/agentstack/main.py index 45bad63a..5471f33b 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -2,7 +2,7 @@ import os import sys -from agentstack.cli import init_project_builder, list_tools, configure_default_model +from agentstack.cli import init_project_builder, list_tools, configure_default_model, run_project from agentstack.telemetry import track_cli_command from agentstack.utils import get_version, get_framework import agentstack.generation as generation @@ -102,8 +102,7 @@ def main(): init_project_builder(args.slug_name, args.template, args.wizard) elif args.command in ['run', 'r']: framework = get_framework() - if framework == "crewai": - os.system('python src/main.py') + run_project(framework) elif args.command in ['generate', 'g']: if args.generate_command in ['agent', 'a']: if not args.llm: diff --git a/tests/fixtures/agentstack.json b/tests/fixtures/agentstack.json index 4ca18a10..f39237b1 100644 --- a/tests/fixtures/agentstack.json +++ b/tests/fixtures/agentstack.json @@ -1,4 +1,4 @@ { "framework": "crewai", - "tools": ["tool1", "tool2"] + "tools": [] } \ No newline at end of file diff --git a/tests/test_cli_loads.py b/tests/test_cli_loads.py index 49bb15cd..13597c62 100644 --- a/tests/test_cli_loads.py +++ b/tests/test_cli_loads.py @@ -1,9 +1,10 @@ import subprocess -import sys +import os, sys import unittest from pathlib import Path import shutil +BASE_PATH = Path(__file__).parent class TestAgentStackCLI(unittest.TestCase): CLI_ENTRY = [sys.executable, "-m", "agentstack.main"] # Replace with your actual CLI entry point if different @@ -31,12 +32,14 @@ def test_invalid_command(self): def test_init_command(self): """Test the 'init' command to create a project directory.""" - test_dir = Path("test_project") + test_dir = Path(BASE_PATH/'tmp/test_project') # Ensure the directory doesn't exist from previous runs if test_dir.exists(): shutil.rmtree(test_dir) - + os.makedirs(test_dir) + + os.chdir(test_dir) result = self.run_cli("init", str(test_dir)) self.assertEqual(result.returncode, 0) self.assertTrue(test_dir.exists()) @@ -44,6 +47,23 @@ def test_init_command(self): # Clean up shutil.rmtree(test_dir) + def test_run_command_invalid_project(self): + """Test the 'run' command on an invalid project.""" + test_dir = Path(BASE_PATH/'tmp/test_project') + if test_dir.exists(): + shutil.rmtree(test_dir) + os.makedirs(test_dir) + + # Write a basic agentstack.json file + with (test_dir/'agentstack.json').open('w') as f: + f.write(open(BASE_PATH/'fixtures/agentstack.json', 'r').read()) + + os.chdir(test_dir) + result = self.run_cli('run') + self.assertNotEqual(result.returncode, 0) + self.assertIn("Project validation failed", result.stdout) + + shutil.rmtree(test_dir) if __name__ == "__main__": unittest.main() diff --git a/tests/test_generation_files.py b/tests/test_generation_files.py index e2d80d7e..1b0ec56d 100644 --- a/tests/test_generation_files.py +++ b/tests/test_generation_files.py @@ -12,7 +12,7 @@ class GenerationFilesTest(unittest.TestCase): def test_read_config(self): config = ConfigFile(BASE_PATH / "fixtures") # + agentstack.json assert config.framework == "crewai" - assert config.tools == ["tool1", "tool2"] + assert config.tools == [] assert config.telemetry_opt_out is None assert config.default_model is None