diff --git a/agentstack/__init__.py b/agentstack/__init__.py index e645be5c..e4328361 100644 --- a/agentstack/__init__.py +++ b/agentstack/__init__.py @@ -1,8 +1,14 @@ +""" +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. +""" +from agentstack.exceptions import ValidationError +from agentstack.inputs import get_inputs + +___all___ = [ + "ValidationError", + "get_inputs", +] -class ValidationError(Exception): - """ - Raised when a validation error occurs ie. a file does not meet the required - format or a syntax error is found. - """ - pass \ No newline at end of file diff --git a/agentstack/cli/__init__.py b/agentstack/cli/__init__.py index 47adf18c..94f53ebf 100644 --- a/agentstack/cli/__init__.py +++ b/agentstack/cli/__init__.py @@ -1 +1,2 @@ -from .cli import init_project_builder, list_tools, configure_default_model, run_project, export_template +from .cli import init_project_builder, list_tools, configure_default_model, export_template +from .run import run_project \ No newline at end of file diff --git a/agentstack/cli/agentstack_data.py b/agentstack/cli/agentstack_data.py index c51e3dd7..ec540deb 100644 --- a/agentstack/cli/agentstack_data.py +++ b/agentstack/cli/agentstack_data.py @@ -54,7 +54,7 @@ class ProjectStructure: def __init__(self): self.agents = [] self.tasks = [] - self.inputs = [] + self.inputs = {} def add_agent(self, agent): self.agents.append(agent) diff --git a/agentstack/cli/cli.py b/agentstack/cli/cli.py index 9c5e2c0c..1ee48434 100644 --- a/agentstack/cli/cli.py +++ b/agentstack/cli/cli.py @@ -1,21 +1,16 @@ -import json -import shutil -import sys +from typing import Optional +import os, sys import time from datetime import datetime -from typing import Optional from pathlib import Path -import requests + +import json +import shutil import itertools from art import text2art import inquirer -import os -import importlib.resources from cookiecutter.main import cookiecutter -from dotenv import load_dotenv -import subprocess -from packaging.metadata import Metadata from .agentstack_data import ( FrameworkData, @@ -28,12 +23,11 @@ from agentstack.tools import get_all_tools from agentstack.generation.files import ConfigFile, ProjectFile from agentstack import frameworks -from agentstack import packaging from agentstack import generation +from agentstack import inputs 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 @@ -162,27 +156,6 @@ 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 framework not in frameworks.SUPPORTED_FRAMEWORKS: - print(term_color(f"Framework {framework} is not supported by agentstack.", 'red')) - sys.exit(1) - - _path = Path(path) - - try: - frameworks.validate_project(framework, _path) - except frameworks.ValidationError as e: - print(term_color("Project validation failed:", 'red')) - print(e) - sys.exit(1) - - load_dotenv(Path.home() / '.env') # load the user's .env file - load_dotenv(_path / '.env', override=True) # load the project's .env file - print("Running your agent...") - subprocess.run(['python', 'src/main.py'], env=os.environ) - - def ask_framework() -> str: framework = "CrewAI" # framework = inquirer.list_input( @@ -401,7 +374,7 @@ def insert_template( project_structure = ProjectStructure() project_structure.agents = design["agents"] project_structure.tasks = design["tasks"] - project_structure.set_inputs(design["inputs"]) + project_structure.inputs = design["inputs"] cookiecutter_data = CookiecutterData( project_metadata=project_metadata, @@ -537,13 +510,8 @@ def export_template(output_filename: str, path: str = ''): ) ) - inputs: list[str] = [] - # TODO extract inputs from project - # for input in frameworks.get_input_names(): - # inputs.append(input) - template = TemplateConfig( - template_version=1, + template_version=2, name=metadata.project_name, description=metadata.project_description, framework=framework, @@ -551,7 +519,7 @@ def export_template(output_filename: str, path: str = ''): agents=agents, tasks=tasks, tools=tools, - inputs=inputs, + inputs=inputs.get_inputs(), ) try: diff --git a/agentstack/cli/run.py b/agentstack/cli/run.py new file mode 100644 index 00000000..53686747 --- /dev/null +++ b/agentstack/cli/run.py @@ -0,0 +1,70 @@ +from typing import Optional +import sys +from pathlib import Path +import importlib.util +from dotenv import load_dotenv + +from agentstack import ValidationError +from agentstack import inputs +from agentstack import frameworks +from agentstack.utils import term_color, get_framework + +MAIN_FILENAME: Path = Path("src/main.py") +MAIN_MODULE_NAME = "main" + + +def _import_project_module(path: Path): + """ + Import `main` from the project path. + + We do it this way instead of spawning a subprocess so that we can share + state with the user's project. + """ + spec = importlib.util.spec_from_file_location(MAIN_MODULE_NAME, str(path / MAIN_FILENAME)) + + assert spec is not None # appease type checker + 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)) + spec.loader.exec_module(project_module) + return project_module + + +def run_project(command: str = 'run', path: Optional[str] = None, cli_args: Optional[str] = None): + """Validate that the project is ready to run and then run it.""" + _path = Path(path) if path else Path.cwd() + framework = get_framework(_path) + + if framework not 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 ValidationError as e: + print(term_color(f"Project validation failed:\n{e}", 'red')) + sys.exit(1) + + # Parse extra --input-* arguments for runtime overrides of the project's inputs + if cli_args: + for arg in cli_args: + if not arg.startswith('--input-'): + continue + key, value = arg[len('--input-') :].split('=') + inputs.add_input_for_run(key, value) + + load_dotenv(Path.home() / '.env') # load the user's .env file + load_dotenv(_path / '.env', override=True) # load the project's .env file + + # import src/main.py from the project path + try: + project_main = _import_project_module(_path) + except ImportError as e: + print(term_color(f"Failed to import project. Does '{MAIN_FILENAME}' exist?:\n{e}", 'red')) + sys.exit(1) + + # run `command` from the project's main.py + # TODO try/except this and print detailed information with a --debug flag + print("Running your agent...") + return getattr(project_main, command)() diff --git a/agentstack/exceptions.py b/agentstack/exceptions.py new file mode 100644 index 00000000..c0e95569 --- /dev/null +++ b/agentstack/exceptions.py @@ -0,0 +1,7 @@ +class ValidationError(Exception): + """ + Raised when a validation error occurs ie. a file does not meet the required + format or a syntax error is found. + """ + + pass diff --git a/agentstack/inputs.py b/agentstack/inputs.py new file mode 100644 index 00000000..209d5a52 --- /dev/null +++ b/agentstack/inputs.py @@ -0,0 +1,92 @@ +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 ValidationError + + +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] = {} + + +class InputsConfig: + """ + Interface for interacting with inputs configuration. + + Use it as a context manager to make and save edits: + ```python + with InputsConfig() as inputs: + inputs.topic = "Open Source Aritifical Intelligence" + ``` + """ + + _attributes: dict[str, str] + + def __init__(self, path: Optional[Path] = None): + self.path = path if path else Path() + filename = self.path / INPUTS_FILENAME + + if not os.path.exists(filename): + os.makedirs(filename.parent, exist_ok=True) + filename.touch() + + try: + with open(filename, 'r') as f: + self._attributes = yaml.load(f) or {} + except YAMLError as e: + # TODO format MarkedYAMLError lines/messages + raise ValidationError(f"Error parsing inputs file: {filename}\n{e}") + + def __getitem__(self, key: str) -> str: + return self._attributes[key] + + def __setitem__(self, key: str, value: str): + self._attributes[key] = value + + def __contains__(self, key: str) -> bool: + return key in self._attributes + + def to_dict(self) -> dict[str, str]: + return self._attributes + + def model_dump(self) -> dict: + dump = {} + for key, value in self._attributes.items(): + dump[key] = FoldedScalarString(value) + return dump + + def write(self): + with open(self.path / INPUTS_FILENAME, 'w') as f: + yaml.dump(self.model_dump(), f) + + def __enter__(self) -> 'InputsConfig': + return self + + def __exit__(self, *args): + self.write() + + +def get_inputs(path: Optional[Path] = None) -> dict: + """ + Get the inputs configuration file and override with run_inputs. + """ + path = path if path else Path() + config = InputsConfig(path).to_dict() + # run_inputs override saved inputs + for key, value in run_inputs.items(): + config[key] = value + return config + + +def add_input_for_run(key: str, value: str): + """ + Add an input override for the current run. + This is used by the CLI to allow inputs to be set at runtime. + """ + run_inputs[key] = value diff --git a/agentstack/main.py b/agentstack/main.py index 3b99ad3b..e5e004f8 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -1,5 +1,4 @@ import argparse -import os import sys from agentstack.cli import ( @@ -10,7 +9,7 @@ export_template, ) from agentstack.telemetry import track_cli_command -from agentstack.utils import get_version, get_framework +from agentstack.utils import get_version from agentstack import generation from agentstack.update import check_for_updates @@ -43,7 +42,30 @@ def main(): init_parser.add_argument("--template", "-t", help="Agent template to use") # 'run' command - _ = subparsers.add_parser("run", aliases=["r"], help="Run your agent") + run_parser = subparsers.add_parser( + "run", + aliases=["r"], + help="Run your agent", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' + --input-=VALUE Specify inputs to be passed to the run. + These will override the inputs in the project's inputs.yaml file. + Examples: --input-topic=Sports --input-content-type=News + ''', + ) + run_parser.add_argument( + "--function", + "-f", + help="Function to call in main.py, defaults to 'run'", + default="run", + dest="function", + ) + run_parser.add_argument( + "--path", + "-p", + help="Path to the project directory, defaults to current working directory", + dest="path", + ) # 'generate' command generate_parser = subparsers.add_parser("generate", aliases=["g"], help="Generate agents or tasks") @@ -94,8 +116,8 @@ def main(): update = subparsers.add_parser('update', aliases=['u'], help='Check for updates') - # Parse arguments - args = parser.parse_args() + # Parse known args and store unknown args in extras; some commands use them later on + args, extra_args = parser.parse_known_args() # Handle version if args.version: @@ -115,8 +137,7 @@ def main(): elif args.command in ["init", "i"]: init_project_builder(args.slug_name, args.template, args.wizard) elif args.command in ["run", "r"]: - framework = get_framework() - run_project(framework) + run_project(command=args.function, path=args.path, cli_args=extra_args) elif args.command in ['generate', 'g']: if args.generate_command in ['agent', 'a']: if not args.llm: diff --git a/agentstack/proj_templates.py b/agentstack/proj_templates.py index 3bae6a8c..a3cc1123 100644 --- a/agentstack/proj_templates.py +++ b/agentstack/proj_templates.py @@ -5,7 +5,32 @@ import requests import json from agentstack import ValidationError -from agentstack.utils import get_package_path, open_json_file, term_color +from agentstack.utils import get_package_path + + +class TemplateConfig_v1(pydantic.BaseModel): + name: str + description: str + template_version: Literal[1] + framework: str + method: str + agents: list[dict] + tasks: list[dict] + tools: list[dict] + inputs: list[str] + + def to_v2(self) -> 'TemplateConfig': + return TemplateConfig( + name=self.name, + description=self.description, + template_version=2, + framework=self.framework, + method=self.method, + agents=[TemplateConfig.Agent(**agent) for agent in self.agents], + tasks=[TemplateConfig.Task(**task) for task in self.tasks], + tools=[TemplateConfig.Tool(**tool) for tool in self.tools], + inputs={key: "" for key in self.inputs}, + ) class TemplateConfig(pydantic.BaseModel): @@ -26,11 +51,11 @@ class TemplateConfig(pydantic.BaseModel): The framework the template is for. method: str The method used by the project. ie. "sequential" - agents: list[dict] + agents: list[TemplateConfig.Agent] A list of agents used by the project. - tasks: list[dict] + tasks: list[TemplateConfig.Task] A list of tasks used by the project. - tools: list[dict] + tools: list[TemplateConfig.Tool] A list of tools used by the project. inputs: list[str] A list of inputs used by the project. @@ -55,13 +80,13 @@ class Tool(pydantic.BaseModel): name: str description: str - template_version: Literal[1] + template_version: Literal[2] framework: str method: str agents: list[Agent] tasks: list[Task] tools: list[Tool] - inputs: list[str] + inputs: dict[str, str] def write_to_file(self, filename: Path): if not filename.suffix == '.json': @@ -74,20 +99,16 @@ def write_to_file(self, filename: Path): @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): - raise ValidationError(f"Template {name} not found.") - return cls.from_json(path) + if not name in get_all_template_names(): + raise ValidationError(f"Template {name} not bundled with agentstack.") + return cls.from_file(path) @classmethod - def from_json(cls, path: Path) -> 'TemplateConfig': - data = open_json_file(path) - try: - return cls(**data) - except pydantic.ValidationError as e: - err_msg = "Error validating template config JSON: \n {path}\n\n" - for error in e.errors(): - err_msg += f"{' '.join([str(loc) for loc in error['loc']])}: {error['msg']}\n" - raise ValidationError(err_msg) + def from_file(cls, path: Path) -> 'TemplateConfig': + if not os.path.exists(path): + raise ValidationError(f"Template {path} not found.") + with open(path, 'r') as f: + return cls.from_json(json.load(f)) @classmethod def from_url(cls, url: str) -> 'TemplateConfig': @@ -95,11 +116,26 @@ 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:\n {url}") + raise ValidationError(f"Failed to fetch template from {url}") + return cls.from_json(response.json()) + + @classmethod + def from_json(cls, data: dict) -> 'TemplateConfig': try: - return cls(**response.json()) + match data.get('template_version'): + case 1: + return TemplateConfig_v1(**data).to_v2() + case 2: + return cls(**data) # current version + case _: + raise ValidationError(f"Unsupported template version: {data.get('template_version')}") + except pydantic.ValidationError as e: + err_msg = "Error validating template config JSON:\n" + for error in e.errors(): + err_msg += f"{' '.join([str(loc) for loc in error['loc']])}: {error['msg']}\n" + raise ValidationError(err_msg) except json.JSONDecodeError as e: - raise ValidationError(f"Error decoding template JSON from URL:\n {url}\n\n{e}") + raise ValidationError(f"Error decoding template JSON.\n{e}") def get_all_template_paths() -> list[Path]: @@ -116,4 +152,4 @@ def get_all_template_names() -> list[str]: def get_all_templates() -> list[TemplateConfig]: - return [TemplateConfig.from_json(path) for path in get_all_template_paths()] + return [TemplateConfig.from_file(path) for path in get_all_template_paths()] diff --git a/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/config/inputs.yaml b/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/config/inputs.yaml new file mode 100644 index 00000000..fcac19a9 --- /dev/null +++ b/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/config/inputs.yaml @@ -0,0 +1,4 @@ +{%- for key, value in cookiecutter.structure.inputs.items() %} +{{key}}: > + {{value}} +{%- endfor %} \ No newline at end of file diff --git a/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/main.py b/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/main.py index 291a6177..98f7987e 100644 --- a/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/main.py +++ b/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/main.py @@ -1,35 +1,30 @@ #!/usr/bin/env python import sys from crew import {{cookiecutter.project_metadata.project_name|replace('-', '')|replace('_', '')|capitalize}}Crew +import agentstack import agentops agentops.init(default_tags=['crewai', 'agentstack']) +instance = {{cookiecutter.project_metadata.project_name|replace('-', '')|replace('_', '')|capitalize}}Crew().crew() def run(): """ Run the crew. """ - inputs = { -{%- for input in cookiecutter.structure.inputs %} - "{{input}}": "", -{%- endfor %} - } - {{cookiecutter.project_metadata.project_name|replace('-', '')|replace('_', '')|capitalize}}Crew().crew().kickoff(inputs=inputs) + instance.kickoff(inputs=agentstack.get_inputs()) def train(): """ Train the crew for a given number of iterations. """ - inputs = { -{%- for input in cookiecutter.structure.inputs %} - "{{input}}": "", -{%- endfor %} - } try: - {{cookiecutter.project_metadata.project_name|replace('-', '')|replace('_', '')|capitalize}}Crew().crew().train(n_iterations=int(sys.argv[1]), filename=sys.argv[2], inputs=inputs) - + instance.train( + n_iterations=int(sys.argv[1]), + filename=sys.argv[2], + inputs=agentstack.get_inputs(), + ) except Exception as e: raise Exception(f"An error occurred while training the crew: {e}") @@ -39,8 +34,7 @@ def replay(): Replay the crew execution from a specific task. """ try: - {{cookiecutter.project_metadata.project_name|replace('-', '')|replace('_', '')|capitalize}}Crew().crew().replay(task_id=sys.argv[1]) - + instance.replay(task_id=sys.argv[1]) except Exception as e: raise Exception(f"An error occurred while replaying the crew: {e}") @@ -49,14 +43,12 @@ def test(): """ Test the crew execution and returns the results. """ - inputs = { -{%- for input in cookiecutter.structure.inputs %} - "{{input}}": "", -{%- endfor %} - } try: - {{cookiecutter.project_metadata.project_name|replace('-', '')|replace('_', '')|capitalize}}Crew().crew().test(n_iterations=int(sys.argv[1]), openai_model_name=sys.argv[2], inputs=inputs) - + instance.test( + n_iterations=int(sys.argv[1]), + openai_model_name=sys.argv[2], + inputs=agentstack.get_inputs(), + ) except Exception as e: raise Exception(f"An error occurred while replaying the crew: {e}") diff --git a/agentstack/templates/proj_templates/content_creator.json b/agentstack/templates/proj_templates/content_creator.json index 53247ca9..3e498ced 100644 --- a/agentstack/templates/proj_templates/content_creator.json +++ b/agentstack/templates/proj_templates/content_creator.json @@ -1,7 +1,7 @@ { "name": "content_creator", "description": "Multi-agent system for creating high-quality content", - "template_version": 1, + "template_version": 2, "framework": "crewai", "agents": [{ "name": "researcher", @@ -46,5 +46,9 @@ "agents": ["researcher"] }], "method": "sequential", - "inputs": ["topic", "audience", "content_type"] + "inputs": { + "topic": "", + "audience": "", + "content_type": "" + } } \ No newline at end of file diff --git a/tests/fixtures/inputs_max.yaml b/tests/fixtures/inputs_max.yaml new file mode 100644 index 00000000..4de77bbe --- /dev/null +++ b/tests/fixtures/inputs_max.yaml @@ -0,0 +1,4 @@ +input_name: >- + This in an input +input_name_2: >- + This is another input \ No newline at end of file diff --git a/tests/fixtures/inputs_min.yaml b/tests/fixtures/inputs_min.yaml new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_cli_templates.py b/tests/test_cli_templates.py index 7296256d..285b91c1 100644 --- a/tests/test_cli_templates.py +++ b/tests/test_cli_templates.py @@ -47,6 +47,8 @@ def test_export_template_v1(self): self.assertEqual(result.returncode, 0) result = self._run_cli('export', 'test_template.json') + print(result.stdout) + print(result.stderr) 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() @@ -56,7 +58,7 @@ def test_export_template_v1(self): """{ "name": "test_project", "description": "New agentstack project", - "template_version": 1, + "template_version": 2, "framework": "crewai", "method": "sequential", "agents": [ @@ -84,6 +86,6 @@ def test_export_template_v1(self): ] } ], - "inputs": [] + "inputs": {} }""", ) diff --git a/tests/test_inputs_config.py b/tests/test_inputs_config.py new file mode 100644 index 00000000..1f20ace2 --- /dev/null +++ b/tests/test_inputs_config.py @@ -0,0 +1,29 @@ +import os +import shutil +import unittest +from pathlib import Path +from agentstack.inputs import InputsConfig + +BASE_PATH = Path(__file__).parent + + +class InputsConfigTest(unittest.TestCase): + def setUp(self): + self.project_dir = BASE_PATH / "tmp/inputs_config" + os.makedirs(self.project_dir) + os.makedirs(self.project_dir / "src/config") + + def tearDown(self): + shutil.rmtree(self.project_dir) + + def test_minimal_input_config(self): + shutil.copy(BASE_PATH / "fixtures/inputs_min.yaml", self.project_dir / "src/config/inputs.yaml") + config = InputsConfig(self.project_dir) + assert config.to_dict() == {} + + def test_maximal_input_config(self): + shutil.copy(BASE_PATH / "fixtures/inputs_max.yaml", self.project_dir / "src/config/inputs.yaml") + config = InputsConfig(self.project_dir) + assert config['input_name'] == "This in an input" + assert config['input_name_2'] == "This is another input" + assert config.to_dict() == {'input_name': "This in an input", 'input_name_2': "This is another input"} diff --git a/tests/test_project_run.py b/tests/test_project_run.py index 787589da..0740aad4 100644 --- a/tests/test_project_run.py +++ b/tests/test_project_run.py @@ -20,6 +20,9 @@ def setUp(self): os.makedirs(self.project_dir / 'src') (self.project_dir / 'src' / '__init__.py').touch() + with open(self.project_dir / 'src' / 'main.py', 'w') as f: + f.write('def run(): pass') + # set the framework in agentstack.json shutil.copy(BASE_PATH / 'fixtures' / 'agentstack.json', self.project_dir / 'agentstack.json') with ConfigFile(self.project_dir) as config: @@ -36,11 +39,11 @@ def tearDown(self): shutil.rmtree(self.project_dir) def test_run_project(self): - run_project(self.framework, self.project_dir) + run_project(path=self.project_dir) def test_env_is_set(self): """ After running a project, the environment variables should be set from project_dir/.env. """ - run_project(self.framework, self.project_dir) + run_project(path=self.project_dir) assert os.getenv('ENV_VAR1') == 'value1' diff --git a/tests/test_templates_config.py b/tests/test_templates_config.py index a8a38d64..226e0c25 100644 --- a/tests/test_templates_config.py +++ b/tests/test_templates_config.py @@ -19,7 +19,7 @@ def test_all_configs_from_template_name(self, template_name: str): @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) + config = TemplateConfig.from_file(template_path) assert config.name == template_path.stem # We can assume that pydantic validation caught any other issues