From b37f6f5f3e8cda5e417923c7a5f02d92084ac3bc Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Thu, 19 Dec 2024 13:28:43 -0800 Subject: [PATCH 01/30] Add extensible log handler, redirect logs to a file in the user's project directory and send to stdout/stderr in cli. Add tests for log module. --- agentstack/cli/agentstack_data.py | 2 +- agentstack/cli/cli.py | 2 +- agentstack/cli/run.py | 6 +- agentstack/conf.py | 12 ++ agentstack/generation/tool_generation.py | 20 +-- agentstack/log.py | 167 +++++++++++++++++++++++ agentstack/logger.py | 30 ---- agentstack/main.py | 11 ++ tests/test_log.py | 163 ++++++++++++++++++++++ 9 files changed, 368 insertions(+), 45 deletions(-) create mode 100644 agentstack/log.py delete mode 100644 agentstack/logger.py create mode 100644 tests/test_log.py diff --git a/agentstack/cli/agentstack_data.py b/agentstack/cli/agentstack_data.py index ec540deb..1db7696b 100644 --- a/agentstack/cli/agentstack_data.py +++ b/agentstack/cli/agentstack_data.py @@ -3,7 +3,7 @@ from typing import Optional from agentstack.utils import clean_input, get_version -from agentstack.logger import log +from agentstack import log class ProjectMetadata: diff --git a/agentstack/cli/cli.py b/agentstack/cli/cli.py index 0c085d5d..9837b89f 100644 --- a/agentstack/cli/cli.py +++ b/agentstack/cli/cli.py @@ -16,7 +16,7 @@ ProjectStructure, CookiecutterData, ) -from agentstack.logger import log +from agentstack import log from agentstack import conf from agentstack.conf import ConfigFile from agentstack.utils import get_package_path diff --git a/agentstack/cli/run.py b/agentstack/cli/run.py index 17c48b49..8e5cff86 100644 --- a/agentstack/cli/run.py +++ b/agentstack/cli/run.py @@ -15,7 +15,7 @@ MAIN_MODULE_NAME = "main" -def _format_friendy_error_message(exception: Exception): +def _format_friendly_error_message(exception: Exception): """ Projects will throw various errors, especially on first runs, so we catch them here and print a more helpful message. @@ -84,7 +84,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 @@ -124,6 +124,6 @@ def run_project(command: str = 'run', debug: bool = False, cli_args: Optional[st if debug: raise exception print(term_color("\nAn error occurred while running your project:\n", 'red')) - print(_format_friendy_error_message(exception)) + print(_format_friendly_error_message(exception)) print(term_color("\nRun `agentstack run --debug` for a full traceback.", 'blue')) sys.exit(1) diff --git a/agentstack/conf.py b/agentstack/conf.py index 2b7810e4..04653272 100644 --- a/agentstack/conf.py +++ b/agentstack/conf.py @@ -9,6 +9,9 @@ DEFAULT_FRAMEWORK = "crewai" CONFIG_FILENAME = "agentstack.json" +DEBUG: bool = False + +# The path to the project directory ie. working directory. PATH: Path = Path() @@ -18,6 +21,15 @@ def set_path(path: Union[str, Path, None]): PATH = Path(path) if path else Path() +def set_debug(debug: bool): + """ + Set the debug flag in the project's configuration for the session; does not + get saved to the project's configuration file. + """ + global DEBUG + DEBUG = debug + + def get_framework() -> Optional[str]: """The framework used in the project. Will be available after PATH has been set and if we are inside a project directory. diff --git a/agentstack/generation/tool_generation.py b/agentstack/generation/tool_generation.py index 314ff481..0db00767 100644 --- a/agentstack/generation/tool_generation.py +++ b/agentstack/generation/tool_generation.py @@ -6,6 +6,7 @@ import ast from agentstack import conf +from agentstack import log from agentstack.conf import ConfigFile from agentstack.exceptions import ValidationError from agentstack import frameworks @@ -83,7 +84,7 @@ def add_tool(tool_name: str, agents: Optional[list[str]] = []): tool = ToolConfig.from_tool_name(tool_name) if tool_name in agentstack_config.tools: - print(term_color(f'Tool {tool_name} is already installed', 'blue')) + log.notify(f'Tool {tool_name} is already installed') else: # handle install tool_file_path = tool.get_impl_file_path(agentstack_config.framework) @@ -97,7 +98,7 @@ def add_tool(tool_name: str, agents: Optional[list[str]] = []): with ToolsInitFile(conf.PATH / TOOLS_INIT_FILENAME) as tools_init: tools_init.add_import_for_tool(tool, agentstack_config.framework) except ValidationError as e: - print(term_color(f"Error adding tool:\n{e}", 'red')) + log.error(f"Error adding tool:\n{e}") if tool.env: # add environment variables which don't exist with EnvFile() as env: @@ -117,20 +118,19 @@ def add_tool(tool_name: str, agents: Optional[list[str]] = []): if not agents: # If no agents are specified, add the tool to all agents agents = frameworks.get_agent_names() for agent_name in agents: - print(f'Adding tool {tool.name} to agent {agent_name}') + log.info(f'Adding tool {tool.name} to agent {agent_name}') frameworks.add_tool(tool, agent_name) - print(term_color(f'๐Ÿ”จ Tool {tool.name} added to agentstack project successfully', 'green')) + log.success(f'๐Ÿ”จ Tool {tool.name} added to agentstack project successfully') if tool.cta: - print(term_color(f'๐Ÿชฉ {tool.cta}', 'blue')) + log.notify(f'๐Ÿชฉ {tool.cta}') def remove_tool(tool_name: str, agents: Optional[list[str]] = []): agentstack_config = ConfigFile() if tool_name not in agentstack_config.tools: - print(term_color(f'Tool {tool_name} is not installed', 'red')) - sys.exit(1) + raise ValidationError(f'Tool {tool_name} is not installed') tool = ToolConfig.from_tool_name(tool_name) if tool.packages: @@ -140,13 +140,13 @@ def remove_tool(tool_name: str, agents: Optional[list[str]] = []): try: os.remove(conf.PATH / f'src/tools/{tool.module_name}.py') except FileNotFoundError: - print(f'"src/tools/{tool.module_name}.py" not found') + log.warning(f'"src/tools/{tool.module_name}.py" not found') try: # Edit the user's project tool init file to exclude the tool with ToolsInitFile(conf.PATH / TOOLS_INIT_FILENAME) as tools_init: tools_init.remove_import_for_tool(tool, agentstack_config.framework) except ValidationError as e: - print(term_color(f"Error removing tool:\n{e}", 'red')) + log.error(f"Error removing tool:\n{e}") # Edit the framework entrypoint file to exclude the tool in the agent definition if not agents: # If no agents are specified, remove the tool from all agents @@ -161,7 +161,7 @@ def remove_tool(tool_name: str, agents: Optional[list[str]] = []): with agentstack_config as config: config.tools.remove(tool.name) - print( + log.success( term_color(f'๐Ÿ”จ Tool {tool_name}', 'green'), term_color('removed', 'red'), term_color('from agentstack project successfully', 'green'), diff --git a/agentstack/log.py b/agentstack/log.py new file mode 100644 index 00000000..971bfd6a --- /dev/null +++ b/agentstack/log.py @@ -0,0 +1,167 @@ +""" +`agentstack.log` + +TODO would be cool to intercept all messages from the framework and redirect +them through this logger. This would allow us to capture all messages and display +them in the console and filter based on priority. + +TODO With agentstack serve, we can direct all messages to the API, too. +""" + +from typing import IO, Optional, Callable +import os, sys +import io +import logging +from agentstack import conf +from agentstack.utils import term_color + +__all__ = [ + 'set_stdout', + 'set_stderr', + 'debug', + 'success', + 'notify', + 'info', + 'warning', + 'error', +] + +LOG_NAME: str = 'agentstack' +LOG_FILENAME: str = 'agentstack.log' + +# define additional log levels to accommodate other messages inside the app +# TODO add agent output definitions for messages from the agent +DEBUG = logging.DEBUG # 10 +SUCCESS = 18 +NOTIFY = 19 +INFO = logging.INFO # 20 +WARNING = logging.WARNING # 30 +ERROR = logging.ERROR # 40 + +logging.addLevelName(NOTIFY, 'NOTIFY') +logging.addLevelName(SUCCESS, 'SUCCESS') + +# `instance` is lazy so we have time to set up handlers +instance: Optional[logging.Logger] = None + +stdout: IO = io.StringIO() +stderr: IO = io.StringIO() + + +def set_stdout(stream: IO): + """ + Redirect standard output messages to the given stream. + In practice, if a shell is available, pass: `sys.stdout`. + But, this can be any stream that implements the `write` method. + """ + global stdout, instance + stdout = stream + instance = None # force re-initialization + + +def set_stderr(stream: IO): + """ + Redirect standard error messages to the given stream. + In practice, if a shell is available, pass: `sys.stderr`. + But, this can be any stream that implements the `write` method. + """ + global stderr, instance + stderr = stream + instance = None # force re-initialization + + +def _create_handler(levelno: int) -> Callable: + """ + Get the logging function for the given log level. + ie. log.debug("message") + """ + + def handler(msg, *args, **kwargs): + global instance + if instance is None: + instance = _build_logger() + return instance.log(levelno, msg, *args, **kwargs) + + return handler + + +debug = _create_handler(DEBUG) +success = _create_handler(SUCCESS) +notify = _create_handler(NOTIFY) +info = _create_handler(INFO) +warning = _create_handler(WARNING) +error = _create_handler(ERROR) + + +class ConsoleFormatter(logging.Formatter): + """Formats log messages for display in the console.""" + + formats = { + DEBUG: logging.Formatter('DEBUG: %(message)s'), + SUCCESS: logging.Formatter(term_color('%(message)s', 'green')), + NOTIFY: logging.Formatter(term_color('%(message)s', 'blue')), + INFO: logging.Formatter('%(message)s'), + WARNING: logging.Formatter(term_color('%(message)s', 'yellow')), + ERROR: logging.Formatter(term_color('%(message)s', 'red')), + } + + def format(self, record: logging.LogRecord) -> str: + return self.formats[record.levelno].format(record) + + +class FileFormatter(logging.Formatter): + """Formats log messages for display in a log file.""" + + formats = { + DEBUG: logging.Formatter('DEBUG (%(asctime)s):\n %(pathname)s:%(lineno)d\n %(message)s'), + SUCCESS: logging.Formatter('%(message)s'), + NOTIFY: logging.Formatter('%(message)s'), + INFO: logging.Formatter('INFO: %(message)s'), + WARNING: logging.Formatter('WARN: %(message)s'), + ERROR: logging.Formatter('ERROR (%(asctime)s):\n %(pathname)s:%(lineno)d\n %(message)s'), + } + + def format(self, record: logging.LogRecord) -> str: + return self.formats[record.levelno].format(record) + + +def _build_logger() -> logging.Logger: + """ + Build the logger with the appropriate handlers. + All log messages are written to the log file. + Errors and above are written to stderr if a stream has been configured. + Warnings and below are written to stdout if a stream has been configured. + """ + # global stdout, stderr + + # `conf.PATH`` can change during startup, so defer building the path + log_filename = conf.PATH / LOG_FILENAME + if not os.path.exists(log_filename): + os.makedirs(log_filename.parent, exist_ok=True) + log_filename.touch() + + log = logging.getLogger(LOG_NAME) + # min log level set here cascades to all handlers + log.setLevel(DEBUG if conf.DEBUG else INFO) + + file_handler = logging.FileHandler(log_filename) + file_handler.setFormatter(FileFormatter()) + file_handler.setLevel(DEBUG) + log.addHandler(file_handler) + + # stdout handler for warnings and below + # `stdout` can change, so defer building the stream until we need it + stdout_handler = logging.StreamHandler(stdout) + stdout_handler.setFormatter(ConsoleFormatter()) + stdout_handler.setLevel(DEBUG) + stdout_handler.addFilter(lambda record: record.levelno < ERROR) + log.addHandler(stdout_handler) + + # stderr handler for errors and above + # `stderr` can change, so defer building the stream until we need it + stderr_handler = logging.StreamHandler(stderr) + stderr_handler.setFormatter(ConsoleFormatter()) + stderr_handler.setLevel(ERROR) + log.addHandler(stderr_handler) + + return log diff --git a/agentstack/logger.py b/agentstack/logger.py deleted file mode 100644 index e41c4887..00000000 --- a/agentstack/logger.py +++ /dev/null @@ -1,30 +0,0 @@ -import sys -import logging - - -def get_logger(name, debug=False): - """ - Configure and get a logger with the given name. - """ - if debug: - log_level = logging.DEBUG - else: - log_level = logging.INFO - - logger = logging.getLogger(name) - logger.setLevel(log_level) - handler = logging.StreamHandler(sys.stdout) - handler.setLevel(log_level) - - formatter = logging.Formatter( - "%(asctime)s - %(process)d - %(threadName)s - %(filename)s:%(lineno)d - %(name)s - %(levelname)s - %(message)s" - ) - handler.setFormatter(formatter) - - if not logger.handlers: - logger.addHandler(handler) - - return logger - - -log = get_logger(__name__) diff --git a/agentstack/main.py b/agentstack/main.py index eac6482a..b08ffea6 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -3,6 +3,7 @@ import webbrowser from agentstack import conf +from agentstack import log from agentstack.cli import ( init_project_builder, add_tool, @@ -145,6 +146,8 @@ def main(): # Set the project path from --path if it is provided in the global_parser conf.set_path(args.project_path) + # Set the debug flag + conf.set_debug(args.debug) # Handle version if args.version: @@ -194,8 +197,16 @@ def main(): if __name__ == "__main__": + # display logging messages in the console + log.set_stdout(sys.stdout) + log.set_stderr(sys.stderr) + try: main() + except Exception as e: + log.error((f"An error occurred: {e}\n" "Run again with --debug for more information.")) + log.debug("Full traceback:", exc_info=e) + sys.exit(1) except KeyboardInterrupt: # Handle Ctrl+C (KeyboardInterrupt) print("\nTerminating AgentStack CLI") diff --git a/tests/test_log.py b/tests/test_log.py new file mode 100644 index 00000000..bdfdaa4d --- /dev/null +++ b/tests/test_log.py @@ -0,0 +1,163 @@ +import unittest +import sys +import io +import logging +import shutil +from pathlib import Path +from agentstack import log, conf +from agentstack.log import SUCCESS, NOTIFY + +BASE_PATH = Path(__file__).parent + + +class TestLog(unittest.TestCase): + def setUp(self): + # Create test directory if it doesn't exist + self.test_dir = BASE_PATH / 'tmp/test_log' + self.test_dir.mkdir(parents=True, exist_ok=True) + + # Set log file to test directory + self.test_log_file = self.test_dir / 'test.log' + log.LOG_FILENAME = self.test_log_file + + # Create string IO objects to capture stdout/stderr + self.stdout = io.StringIO() + self.stderr = io.StringIO() + + # Set up clean logging instance + log.instance = None + log.set_stdout(self.stdout) + log.set_stderr(self.stderr) + + def tearDown(self): + # Clean up test directory + if self.test_dir.exists(): + shutil.rmtree(self.test_dir) + + # Reset log instance and streams + log.instance = None + log.set_stdout(sys.stdout) + log.set_stderr(sys.stderr) + + # Clear string IO buffers + self.stdout.close() + self.stderr.close() + + def test_debug_message(self): + log.debug("Debug message") + self.assertIn("DEBUG: Debug message", self.stdout.getvalue()) + self.assertIn("DEBUG", self.test_log_file.read_text()) + + def test_success_message(self): + log.success("Success message") + self.assertIn("Success message", self.stdout.getvalue()) + self.assertIn("Success message", self.test_log_file.read_text()) + + def test_notify_message(self): + log.notify("Notify message") + self.assertIn("Notify message", self.stdout.getvalue()) + self.assertIn("Notify message", self.test_log_file.read_text()) + + def test_info_message(self): + log.info("Info message") + self.assertIn("Info message", self.stdout.getvalue()) + self.assertIn("INFO: Info message", self.test_log_file.read_text()) + + def test_warning_message(self): + log.warning("Warning message") + self.assertIn("Warning message", self.stdout.getvalue()) + self.assertIn("WARN: Warning message", self.test_log_file.read_text()) + + def test_error_message(self): + log.error("Error message") + self.assertIn("Error message", self.stderr.getvalue()) + self.assertIn("ERROR", self.test_log_file.read_text()) + + def test_multiple_messages(self): + log.info("First message") + log.error("Second message") + log.warning("Third message") + + stdout_content = self.stdout.getvalue() + stderr_content = self.stderr.getvalue() + file_content = self.test_log_file.read_text() + + self.assertIn("First message", stdout_content) + self.assertIn("Third message", stdout_content) + self.assertIn("Second message", stderr_content) + self.assertIn("First message", file_content) + self.assertIn("Second message", file_content) + self.assertIn("Third message", file_content) + + def test_custom_log_levels(self): + self.assertEqual(SUCCESS, 18) + self.assertEqual(NOTIFY, 19) + self.assertEqual(logging.getLevelName(SUCCESS), 'SUCCESS') + self.assertEqual(logging.getLevelName(NOTIFY), 'NOTIFY') + + def test_formatter_console(self): + formatter = log.ConsoleFormatter() + record = logging.LogRecord('test', logging.INFO, 'pathname', 1, 'Test message', (), None) + formatted = formatter.format(record) + self.assertEqual(formatted, 'Test message') + + def test_formatter_file(self): + formatter = log.FileFormatter() + record = logging.LogRecord('test', logging.INFO, 'pathname', 1, 'Test message', (), None) + formatted = formatter.format(record) + self.assertEqual(formatted, 'INFO: Test message') + + def test_stream_redirection(self): + new_stdout = io.StringIO() + new_stderr = io.StringIO() + log.set_stdout(new_stdout) + log.set_stderr(new_stderr) + + log.info("Test stdout") + log.error("Test stderr") + + self.assertIn("Test stdout", new_stdout.getvalue()) + self.assertIn("Test stderr", new_stderr.getvalue()) + + def test_debug_level_config(self): + # Test with debug disabled + conf.DEBUG = False + log.instance = None # Reset logger + log.debug("Hidden debug") + self.assertEqual("", self.stdout.getvalue()) + + # Test with debug enabled + conf.DEBUG = True + log.instance = None # Reset logger + log.debug("Visible debug") + self.assertIn("Visible debug", self.stdout.getvalue()) + + def test_log_file_creation(self): + # Delete log file if exists + if self.test_log_file.exists(): + self.test_log_file.unlink() + + # First log should create file + self.assertFalse(self.test_log_file.exists()) + log.info("Create log file") + self.assertTrue(self.test_log_file.exists()) + self.assertIn("Create log file", self.test_log_file.read_text()) + + def test_handler_levels(self): + # Reset logger to test handler configuration + log.instance = None + logger = log._build_logger() + + # Check handler levels + handlers = logger.handlers + file_handler = next(h for h in handlers if isinstance(h, logging.FileHandler)) + stdout_handler = next( + h for h in handlers if isinstance(h, logging.StreamHandler) and h.stream == self.stdout + ) + stderr_handler = next( + h for h in handlers if isinstance(h, logging.StreamHandler) and h.stream == self.stderr + ) + + self.assertEqual(file_handler.level, log.DEBUG) + self.assertEqual(stdout_handler.level, log.DEBUG) + self.assertEqual(stderr_handler.level, log.ERROR) From 0de9e47457b27549d34af7123810bf3c1e93e3d2 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Thu, 19 Dec 2024 16:35:01 -0800 Subject: [PATCH 02/30] Fix custom log levels, expand & cleanup tests. --- agentstack/generation/tool_generation.py | 7 +- agentstack/log.py | 4 +- tests/test_log.py | 111 +++++++++++++---------- 3 files changed, 67 insertions(+), 55 deletions(-) diff --git a/agentstack/generation/tool_generation.py b/agentstack/generation/tool_generation.py index 0db00767..c319ab3a 100644 --- a/agentstack/generation/tool_generation.py +++ b/agentstack/generation/tool_generation.py @@ -11,7 +11,6 @@ from agentstack.exceptions import ValidationError from agentstack import frameworks from agentstack import packaging -from agentstack.utils import term_color from agentstack.tools import ToolConfig from agentstack.generation import asttools from agentstack.generation.files import EnvFile @@ -161,8 +160,4 @@ def remove_tool(tool_name: str, agents: Optional[list[str]] = []): with agentstack_config as config: config.tools.remove(tool.name) - log.success( - term_color(f'๐Ÿ”จ Tool {tool_name}', 'green'), - term_color('removed', 'red'), - term_color('from agentstack project successfully', 'green'), - ) + log.success(f'๐Ÿ”จ Tool {tool_name} removed from agentstack project successfully') diff --git a/agentstack/log.py b/agentstack/log.py index 971bfd6a..ecd7e299 100644 --- a/agentstack/log.py +++ b/agentstack/log.py @@ -32,8 +32,8 @@ # define additional log levels to accommodate other messages inside the app # TODO add agent output definitions for messages from the agent DEBUG = logging.DEBUG # 10 -SUCCESS = 18 -NOTIFY = 19 +SUCCESS = 21 # Just above INFO +NOTIFY = 22 # Just above INFO INFO = logging.INFO # 20 WARNING = logging.WARNING # 30 ERROR = logging.ERROR # 40 diff --git a/tests/test_log.py b/tests/test_log.py index bdfdaa4d..d058d56d 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -34,19 +34,14 @@ def tearDown(self): if self.test_dir.exists(): shutil.rmtree(self.test_dir) - # Reset log instance and streams - log.instance = None - log.set_stdout(sys.stdout) - log.set_stderr(sys.stderr) - # Clear string IO buffers self.stdout.close() self.stderr.close() def test_debug_message(self): log.debug("Debug message") - self.assertIn("DEBUG: Debug message", self.stdout.getvalue()) - self.assertIn("DEBUG", self.test_log_file.read_text()) + self.assertIn("Debug message", self.stdout.getvalue()) + self.assertIn("Debug message", self.test_log_file.read_text()) def test_success_message(self): log.success("Success message") @@ -61,17 +56,17 @@ def test_notify_message(self): def test_info_message(self): log.info("Info message") self.assertIn("Info message", self.stdout.getvalue()) - self.assertIn("INFO: Info message", self.test_log_file.read_text()) + self.assertIn("Info message", self.test_log_file.read_text()) def test_warning_message(self): log.warning("Warning message") self.assertIn("Warning message", self.stdout.getvalue()) - self.assertIn("WARN: Warning message", self.test_log_file.read_text()) + self.assertIn("Warning message", self.test_log_file.read_text()) def test_error_message(self): log.error("Error message") self.assertIn("Error message", self.stderr.getvalue()) - self.assertIn("ERROR", self.test_log_file.read_text()) + self.assertIn("Error message", self.test_log_file.read_text()) def test_multiple_messages(self): log.info("First message") @@ -89,24 +84,6 @@ def test_multiple_messages(self): self.assertIn("Second message", file_content) self.assertIn("Third message", file_content) - def test_custom_log_levels(self): - self.assertEqual(SUCCESS, 18) - self.assertEqual(NOTIFY, 19) - self.assertEqual(logging.getLevelName(SUCCESS), 'SUCCESS') - self.assertEqual(logging.getLevelName(NOTIFY), 'NOTIFY') - - def test_formatter_console(self): - formatter = log.ConsoleFormatter() - record = logging.LogRecord('test', logging.INFO, 'pathname', 1, 'Test message', (), None) - formatted = formatter.format(record) - self.assertEqual(formatted, 'Test message') - - def test_formatter_file(self): - formatter = log.FileFormatter() - record = logging.LogRecord('test', logging.INFO, 'pathname', 1, 'Test message', (), None) - formatted = formatter.format(record) - self.assertEqual(formatted, 'INFO: Test message') - def test_stream_redirection(self): new_stdout = io.StringIO() new_stderr = io.StringIO() @@ -121,13 +98,13 @@ def test_stream_redirection(self): def test_debug_level_config(self): # Test with debug disabled - conf.DEBUG = False + conf.set_debug(False) log.instance = None # Reset logger log.debug("Hidden debug") self.assertEqual("", self.stdout.getvalue()) # Test with debug enabled - conf.DEBUG = True + conf.set_debug(True) log.instance = None # Reset logger log.debug("Visible debug") self.assertIn("Visible debug", self.stdout.getvalue()) @@ -143,21 +120,61 @@ def test_log_file_creation(self): self.assertTrue(self.test_log_file.exists()) self.assertIn("Create log file", self.test_log_file.read_text()) - def test_handler_levels(self): - # Reset logger to test handler configuration + def test_debug_mode_filtering(self): + # Test with debug mode off + conf.set_debug(False) + log.instance = None # Reset logger to apply new debug setting + + log.debug("Debug message when off") + log.info("Info message when off") + + stdout_off = self.stdout.getvalue() + self.assertNotIn("Debug message when off", stdout_off) + self.assertIn("Info message when off", stdout_off) + + # Clear buffers + self.stdout.truncate(0) + self.stdout.seek(0) + + # Test with debug mode on + conf.set_debug(True) + log.instance = None # Reset logger to apply new debug setting + + log.debug("Debug message when on") + log.info("Info message when on") + + stdout_on = self.stdout.getvalue() + self.assertIn("Debug message when on", stdout_on) + self.assertIn("Info message when on", stdout_on) + + def test_custom_levels_visibility(self): + """Custom levels should print below DEBUG level""" + # Test with debug mode off + conf.set_debug(False) log.instance = None - logger = log._build_logger() - - # Check handler levels - handlers = logger.handlers - file_handler = next(h for h in handlers if isinstance(h, logging.FileHandler)) - stdout_handler = next( - h for h in handlers if isinstance(h, logging.StreamHandler) and h.stream == self.stdout - ) - stderr_handler = next( - h for h in handlers if isinstance(h, logging.StreamHandler) and h.stream == self.stderr - ) - - self.assertEqual(file_handler.level, log.DEBUG) - self.assertEqual(stdout_handler.level, log.DEBUG) - self.assertEqual(stderr_handler.level, log.ERROR) + + log.debug("Debug message when debug off") + log.success("Success message when debug off") + log.notify("Notify message when debug off") + + stdout_off = self.stdout.getvalue() + self.assertNotIn("Debug message when debug off", stdout_off) + self.assertIn("Success message when debug off", stdout_off) + self.assertIn("Notify message when debug off", stdout_off) + + # Clear buffers + self.stdout.truncate(0) + self.stdout.seek(0) + + # Test with debug mode on + conf.set_debug(True) + log.instance = None + + log.debug("Debug message when debug on") + log.success("Success message when debug on") + log.notify("Notify message when debug on") + + stdout_on = self.stdout.getvalue() + self.assertIn("Debug message when debug on", stdout_on) + self.assertIn("Success message when debug on", stdout_on) + self.assertIn("Notify message when debug on", stdout_on) From 8b712cb5a310d3a74d99227d285a61ac43d824fd Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Thu, 19 Dec 2024 22:26:26 -0800 Subject: [PATCH 03/30] Thoughts on additional log levels. --- agentstack/log.py | 64 ++++++++++++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/agentstack/log.py b/agentstack/log.py index ecd7e299..70d99124 100644 --- a/agentstack/log.py +++ b/agentstack/log.py @@ -1,6 +1,18 @@ """ `agentstack.log` +DEBUG: Detailed technical information, typically of interest when diagnosing problems. +TOOL_USE: A message to indicate the use of a tool. +THINKING: Information about an internal monologue or reasoning. +INFO: Useful information about the state of the application. +NOTIFY: A notification or update. +SUCCESS: An indication of a successful operation. +RESPONSE: A response to a request. +WARNING: An indication that something unexpected happened, but not severe. +ERROR: An indication that something went wrong, and the application may not be able to continue. + +TODO TOOL_USE and THINKING are below INFO; this is intentional for now. + TODO would be cool to intercept all messages from the framework and redirect them through this logger. This would allow us to capture all messages and display them in the console and filter based on priority. @@ -19,9 +31,12 @@ 'set_stdout', 'set_stderr', 'debug', - 'success', - 'notify', + 'tool_use', + 'thinking', 'info', + 'notify', + 'success', + 'response', 'warning', 'error', ] @@ -30,16 +45,21 @@ LOG_FILENAME: str = 'agentstack.log' # define additional log levels to accommodate other messages inside the app -# TODO add agent output definitions for messages from the agent DEBUG = logging.DEBUG # 10 -SUCCESS = 21 # Just above INFO -NOTIFY = 22 # Just above INFO +TOOL_USE = 16 +THINKING = 18 INFO = logging.INFO # 20 +NOTIFY = 22 +SUCCESS = 24 +RESPONSE = 26 WARNING = logging.WARNING # 30 ERROR = logging.ERROR # 40 +logging.addLevelName(THINKING, 'THINKING') +logging.addLevelName(TOOL_USE, 'TOOL_USE') logging.addLevelName(NOTIFY, 'NOTIFY') logging.addLevelName(SUCCESS, 'SUCCESS') +logging.addLevelName(RESPONSE, 'RESPONSE') # `instance` is lazy so we have time to set up handlers instance: Optional[logging.Logger] = None @@ -71,10 +91,7 @@ def set_stderr(stream: IO): def _create_handler(levelno: int) -> Callable: - """ - Get the logging function for the given log level. - ie. log.debug("message") - """ + """Get the logging handler for the given log level.""" def handler(msg, *args, **kwargs): global instance @@ -86,9 +103,12 @@ def handler(msg, *args, **kwargs): debug = _create_handler(DEBUG) -success = _create_handler(SUCCESS) -notify = _create_handler(NOTIFY) +tool_use = _create_handler(TOOL_USE) +thinking = _create_handler(THINKING) info = _create_handler(INFO) +notify = _create_handler(NOTIFY) +success = _create_handler(SUCCESS) +response = _create_handler(RESPONSE) warning = _create_handler(WARNING) error = _create_handler(ERROR) @@ -96,33 +116,31 @@ def handler(msg, *args, **kwargs): class ConsoleFormatter(logging.Formatter): """Formats log messages for display in the console.""" + default_format = logging.Formatter('%(message)s') formats = { DEBUG: logging.Formatter('DEBUG: %(message)s'), SUCCESS: logging.Formatter(term_color('%(message)s', 'green')), NOTIFY: logging.Formatter(term_color('%(message)s', 'blue')), - INFO: logging.Formatter('%(message)s'), WARNING: logging.Formatter(term_color('%(message)s', 'yellow')), ERROR: logging.Formatter(term_color('%(message)s', 'red')), } def format(self, record: logging.LogRecord) -> str: - return self.formats[record.levelno].format(record) + template = self.formats.get(record.levelno, self.default_format) + return template.format(record) class FileFormatter(logging.Formatter): """Formats log messages for display in a log file.""" + default_format = logging.Formatter('%(levelname)s: %(message)s') formats = { DEBUG: logging.Formatter('DEBUG (%(asctime)s):\n %(pathname)s:%(lineno)d\n %(message)s'), - SUCCESS: logging.Formatter('%(message)s'), - NOTIFY: logging.Formatter('%(message)s'), - INFO: logging.Formatter('INFO: %(message)s'), - WARNING: logging.Formatter('WARN: %(message)s'), - ERROR: logging.Formatter('ERROR (%(asctime)s):\n %(pathname)s:%(lineno)d\n %(message)s'), } def format(self, record: logging.LogRecord) -> str: - return self.formats[record.levelno].format(record) + template = self.formats.get(record.levelno, self.default_format) + return template.format(record) def _build_logger() -> logging.Logger: @@ -134,16 +152,16 @@ def _build_logger() -> logging.Logger: """ # global stdout, stderr + log = logging.getLogger(LOG_NAME) + # min log level set here cascades to all handlers + log.setLevel(DEBUG if conf.DEBUG else INFO) + # `conf.PATH`` can change during startup, so defer building the path log_filename = conf.PATH / LOG_FILENAME if not os.path.exists(log_filename): os.makedirs(log_filename.parent, exist_ok=True) log_filename.touch() - log = logging.getLogger(LOG_NAME) - # min log level set here cascades to all handlers - log.setLevel(DEBUG if conf.DEBUG else INFO) - file_handler = logging.FileHandler(log_filename) file_handler.setFormatter(FileFormatter()) file_handler.setLevel(DEBUG) From fe9172d44c37de131ba13ccc367acdefc3395d1c Mon Sep 17 00:00:00 2001 From: Braelyn Boynton Date: Sat, 21 Dec 2024 23:55:06 -0500 Subject: [PATCH 04/30] merge telemetry and logging --- agentstack/main.py | 107 +++++++++++++++++++++++---------------------- 1 file changed, 55 insertions(+), 52 deletions(-) diff --git a/agentstack/main.py b/agentstack/main.py index 7511fd92..429d7fca 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -1,6 +1,7 @@ import sys import argparse import webbrowser +from typing import Optional from agentstack import conf from agentstack import log @@ -154,54 +155,65 @@ def main(): print(f"AgentStack CLI version: {get_version()}") sys.exit(0) - telemetry_id = track_cli_command(args.command, " ".join(sys.argv[1:])) check_for_updates(update_requested=args.command in ('update', 'u')) - # Handle commands + telemetry_id = None try: - if args.command in ["docs"]: - webbrowser.open("https://docs.agentstack.sh/") - elif args.command in ["quickstart"]: - webbrowser.open("https://docs.agentstack.sh/quickstart") - elif args.command in ["templates"]: - webbrowser.open("https://docs.agentstack.sh/quickstart") - elif args.command in ["init", "i"]: - init_project_builder(args.slug_name, args.template, args.wizard) - elif args.command in ["run", "r"]: - run_project(command=args.function, debug=args.debug, cli_args=extra_args) - elif args.command in ['generate', 'g']: - if args.generate_command in ['agent', 'a']: - if not args.llm: - configure_default_model() - generation.add_agent(args.name, args.role, args.goal, args.backstory, args.llm) - elif args.generate_command in ['task', 't']: - generation.add_task(args.name, args.description, args.expected_output, args.agent) - else: - generate_parser.print_help() - elif args.command in ["tools", "t"]: - if args.tools_command in ["list", "l"]: - list_tools() - elif args.tools_command in ["add", "a"]: - agents = [args.agent] if args.agent else None - agents = args.agents.split(",") if args.agents else agents - add_tool(args.name, agents) - elif args.tools_command in ["remove", "r"]: - 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: - parser.print_help() + telemetry_id = track_cli_command(args.command, " ".join(sys.argv[1:])) + handle_commands(args, extra_args, parser, tools_parser, generate_parser) + update_telemetry(telemetry_id, result=0) + + except KeyboardInterrupt: + # Handle Ctrl+C (KeyboardInterrupt) + print("\nTerminating AgentStack CLI") + sys.exit(1) except Exception as e: - update_telemetry(telemetry_id, result=1, message=str(e)) - print(term_color("An error occurred while running your AgentStack command:", "red")) - print(e) + # Update telemetry with failure + if telemetry_id is not None: + update_telemetry(telemetry_id, result=1, message=str(e)) + log.error(f"An error occurred: {e}\nRun again with --debug for more information.") + log.debug("Full traceback:", exc_info=e) sys.exit(1) - update_telemetry(telemetry_id, result=0) + +def handle_commands(args, extra_args, parser, tools_parser, generate_parser): + # Handle commands + if args.command in ["docs"]: + webbrowser.open("https://docs.agentstack.sh/") + elif args.command in ["quickstart"]: + webbrowser.open("https://docs.agentstack.sh/quickstart") + elif args.command in ["templates"]: + webbrowser.open("https://docs.agentstack.sh/quickstart") + elif args.command in ["init", "i"]: + init_project_builder(args.slug_name, args.template, args.wizard) + elif args.command in ["run", "r"]: + run_project(command=args.function, debug=args.debug, cli_args=extra_args) + elif args.command in ['generate', 'g']: + if args.generate_command in ['agent', 'a']: + if not args.llm: + configure_default_model() + generation.add_agent(args.name, args.role, args.goal, args.backstory, args.llm) + elif args.generate_command in ['task', 't']: + generation.add_task(args.name, args.description, args.expected_output, args.agent) + else: + generate_parser.print_help() + elif args.command in ["tools", "t"]: + if args.tools_command in ["list", "l"]: + list_tools() + elif args.tools_command in ["add", "a"]: + agents = [args.agent] if args.agent else None + agents = args.agents.split(",") if args.agents else agents + add_tool(args.name, agents) + elif args.tools_command in ["remove", "r"]: + 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: + parser.print_help() if __name__ == "__main__": @@ -209,13 +221,4 @@ def main(): log.set_stdout(sys.stdout) log.set_stderr(sys.stderr) - try: - main() - except Exception as e: - log.error((f"An error occurred: {e}\n" "Run again with --debug for more information.")) - log.debug("Full traceback:", exc_info=e) - sys.exit(1) - except KeyboardInterrupt: - # Handle Ctrl+C (KeyboardInterrupt) - print("\nTerminating AgentStack CLI") - sys.exit(1) + main() From d0d81778f1cb326c195335b5d5fa73e80a78199d Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Mon, 30 Dec 2024 15:47:12 -0800 Subject: [PATCH 05/30] No more sys.exit outside of main.py. Raise exceptions on internal errors. Utilize logging levels for messaging. --- agentstack/agents.py | 4 +- agentstack/cli/cli.py | 60 +++++++++-------------- agentstack/cli/run.py | 31 ++++++------ agentstack/generation/agent_generation.py | 9 ++-- agentstack/generation/task_generation.py | 10 ++-- agentstack/generation/tool_generation.py | 4 +- agentstack/inputs.py | 3 +- agentstack/main.py | 2 +- agentstack/tasks.py | 4 +- agentstack/tools.py | 12 ++--- agentstack/update.py | 20 +++----- agentstack/utils.py | 10 ++-- tests/test_cli_loads.py | 7 +-- tests/test_generation_agent.py | 2 +- tests/test_generation_files.py | 4 +- tests/test_generation_tasks.py | 2 +- 16 files changed, 79 insertions(+), 105 deletions(-) diff --git a/agentstack/agents.py b/agentstack/agents.py index 1c5ab290..ed3be6fe 100644 --- a/agentstack/agents.py +++ b/agentstack/agents.py @@ -4,7 +4,7 @@ import pydantic from ruamel.yaml import YAML, YAMLError from ruamel.yaml.scalarstring import FoldedScalarString -from agentstack import conf +from agentstack import conf, log from agentstack.exceptions import ValidationError @@ -76,6 +76,7 @@ def model_dump(self, *args, **kwargs) -> dict: return {self.name: dump} def write(self): + log.debug(f"Writing agent {self.name} to {AGENTS_FILENAME}") filename = conf.PATH / AGENTS_FILENAME with open(filename, 'r') as f: @@ -96,6 +97,7 @@ def __exit__(self, *args): def get_all_agent_names() -> list[str]: filename = conf.PATH / AGENTS_FILENAME if not os.path.exists(filename): + log.debug(f"Project does not have an {AGENTS_FILENAME} file.") return [] with open(filename, 'r') as f: data = yaml.load(f) or {} diff --git a/agentstack/cli/cli.py b/agentstack/cli/cli.py index 9837b89f..5af63c6a 100644 --- a/agentstack/cli/cli.py +++ b/agentstack/cli/cli.py @@ -16,8 +16,7 @@ ProjectStructure, CookiecutterData, ) -from agentstack import log -from agentstack import conf +from agentstack import conf, log from agentstack.conf import ConfigFile from agentstack.utils import get_package_path from agentstack.generation.files import ProjectFile @@ -45,16 +44,13 @@ def init_project_builder( use_wizard: bool = False, ): if not slug_name and not use_wizard: - print(term_color("Project name is required. Use `agentstack init `", 'red')) - return + raise Exception("Project name is required. Use `agentstack init `") if slug_name and not is_snake_case(slug_name): - print(term_color("Project name must be snake case", 'red')) - return + raise Exception("Project name must be snake case") if template is not None and use_wizard: - print(term_color("Template and wizard flags cannot be used together", 'red')) - return + raise Exception("Template and wizard flags cannot be used together") template_data = None if template is not None: @@ -62,14 +58,12 @@ 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}.\n{e}", 'red')) - sys.exit(1) + raise Exception(f"Failed to fetch template data from {template}.\n{e}") else: try: template_data = TemplateConfig.from_template_name(template) except Exception as e: - print(term_color(f"Failed to load template {template}.\n{e}", 'red')) - sys.exit(1) + raise Exception(f"Failed to load template {template}.\n{e}") if template_data: project_details = { @@ -131,19 +125,20 @@ def welcome_message(): border = "-" * len(tagline) # Print the welcome message with ASCII art - print(title) - print(border) - print(tagline) - print(border) + log.info(title) + log.info(border) + log.info(tagline) + log.info(border) def configure_default_model(): """Set the default model""" agentstack_config = ConfigFile() if agentstack_config.default_model: + log.debug("Using default model from project config.") return # Default model already set - print("Project does not have a default model configured.") + log.info("Project does not have a default model configured.") other_msg = "Other (enter a model name)" model = inquirer.list_input( message="Which model would you like to use?", @@ -151,9 +146,10 @@ def configure_default_model(): ) if model == other_msg: # If the user selects "Other", prompt for a model name - print('A list of available models is available at: "https://docs.litellm.ai/docs/providers"') + log.info('A list of available models is available at: "https://docs.litellm.ai/docs/providers"') model = inquirer.text(message="Enter the model name") + log.debug("Writing default model to project config.") with ConfigFile() as agentstack_config: agentstack_config.default_model = model @@ -179,7 +175,7 @@ def ask_framework() -> str: # choices=["CrewAI", "Autogen", "LiteLLM"], # ) - print("Congrats! Your project is ready to go! Quickly add features now or skip to do it later.\n\n") + log.success("Congrats! Your project is ready to go! Quickly add features now or skip to do it later.\n\n") return framework @@ -324,10 +320,10 @@ def ask_tools() -> list: tools_to_add.append(tool_selection.split(' - ')[0]) - print("Adding tools:") + log.info("Adding tools:") for t in tools_to_add: - print(f' - {t}') - print('') + log.info(f' - {t}') + log.info('') adding_tools = inquirer.confirm("Add another tool?") return tools_to_add @@ -337,7 +333,7 @@ def ask_project_details(slug_name: Optional[str] = None) -> dict: name = inquirer.text(message="What's the name of your project (snake_case)", default=slug_name or '') if not is_snake_case(name): - print(term_color("Project name must be snake case", 'red')) + log.error("Project name must be snake case") return ask_project_details(slug_name) questions = inquirer.prompt( @@ -396,13 +392,7 @@ def insert_template( ) if os.path.isdir(project_details['name']): - print( - term_color( - f"Directory {template_path} already exists. Please check this and try again", - "red", - ) - ) - sys.exit(1) + raise Exception(f"Directory {template_path} already exists. Project directory must not exist.") cookiecutter(str(template_path), no_input=True, extra_context=None) @@ -420,7 +410,7 @@ def insert_template( # os.system("poetry install") # os.system("cls" if os.name == "nt" else "clear") # TODO: add `agentstack docs` command - print( + log.info( "\n" "๐Ÿš€ \033[92mAgentStack project generated successfully!\033[0m\n\n" " Next, run:\n" @@ -444,8 +434,7 @@ def export_template(output_filename: str): try: metadata = ProjectFile() except Exception as e: - print(term_color(f"Failed to load project metadata: {e}", 'red')) - sys.exit(1) + raise Exception(f"Failed to load project metadata: {e}") # Read all the agents from the project's agents.yaml file agents: list[TemplateConfig.Agent] = [] @@ -505,7 +494,6 @@ def export_template(output_filename: str): try: template.write_to_file(conf.PATH / output_filename) - print(term_color(f"Template saved to: {conf.PATH / output_filename}", 'green')) + log.success(f"Template saved to: {conf.PATH / output_filename}") except Exception as e: - print(term_color(f"Failed to write template to file: {e}", 'red')) - sys.exit(1) + raise Exception(f"Failed to write template to file: {e}") diff --git a/agentstack/cli/run.py b/agentstack/cli/run.py index 8e5cff86..707c6ecf 100644 --- a/agentstack/cli/run.py +++ b/agentstack/cli/run.py @@ -5,7 +5,7 @@ import importlib.util from dotenv import load_dotenv -from agentstack import conf +from agentstack import conf, log from agentstack.exceptions import ValidationError from agentstack import inputs from agentstack import frameworks @@ -68,7 +68,11 @@ def _format_friendly_error_message(exception: Exception): "Ensure all tasks referenced in your code are defined in the tasks.yaml file." ) case (_, _, _): - return f"{name}: {message}, {tracebacks[-1]}" + log.debug( + f"Unhandled exception; if this is a common error, consider adding it to " + f"`cli.run._format_friendly_error_message`. Exception: {exception}" + ) + raise exception # re-raise the original exception so we preserve context def _import_project_module(path: Path): @@ -89,17 +93,15 @@ def _import_project_module(path: Path): return project_module -def run_project(command: str = 'run', debug: bool = False, cli_args: Optional[str] = None): +def run_project(command: str = 'run', cli_args: Optional[str] = None): """Validate that the project is ready to run and then run it.""" if conf.get_framework() not in frameworks.SUPPORTED_FRAMEWORKS: - print(term_color(f"Framework {conf.get_framework()} is not supported by agentstack.", 'red')) - sys.exit(1) + raise ValidationError(f"Framework {conf.get_framework()} is not supported by agentstack.") try: frameworks.validate_project() except ValidationError as e: - print(term_color(f"Project validation failed:\n{e}", 'red')) - sys.exit(1) + raise e # Parse extra --input-* arguments for runtime overrides of the project's inputs if cli_args: @@ -107,6 +109,7 @@ def run_project(command: str = 'run', debug: bool = False, cli_args: Optional[st if not arg.startswith('--input-'): continue key, value = arg[len('--input-') :].split('=') + log.debug(f"Using CLI input override: {key}={value}") inputs.add_input_for_run(key, value) load_dotenv(Path.home() / '.env') # load the user's .env file @@ -114,16 +117,10 @@ def run_project(command: str = 'run', debug: bool = False, cli_args: Optional[st # import src/main.py from the project path and run `command` from the project's main.py try: - print("Running your agent...") + log.notify("Running your agent...") project_main = _import_project_module(conf.PATH) getattr(project_main, command)() except ImportError as e: - print(term_color(f"Failed to import project. Does '{MAIN_FILENAME}' exist?:\n{e}", 'red')) - sys.exit(1) - except Exception as exception: - if debug: - raise exception - print(term_color("\nAn error occurred while running your project:\n", 'red')) - print(_format_friendly_error_message(exception)) - print(term_color("\nRun `agentstack run --debug` for a full traceback.", 'blue')) - sys.exit(1) + raise ValidationError(f"Failed to import project. Does '{MAIN_FILENAME}' exist?:\n{e}") + except Exception as e: + raise Exception(_format_friendly_error_message(e)) diff --git a/agentstack/generation/agent_generation.py b/agentstack/generation/agent_generation.py index 31bbd63c..6099fa4e 100644 --- a/agentstack/generation/agent_generation.py +++ b/agentstack/generation/agent_generation.py @@ -1,6 +1,6 @@ import sys from typing import Optional -from pathlib import Path +from agentstack import log from agentstack.exceptions import ValidationError from agentstack.conf import ConfigFile from agentstack import frameworks @@ -27,9 +27,8 @@ def add_agent( try: frameworks.add_agent(agent) - print(f" > Added to {AGENTS_FILENAME}") + log.info(f"Added agent \"{agent_name}\" to project.") except ValidationError as e: - print(f"Error adding agent to project:\n{e}") - sys.exit(1) + raise ValidationError(f"Error adding agent to project:\n{e}") - print(f"Added agent \"{agent_name}\" to your AgentStack project successfully!") + log.success(f"Added agent \"{agent_name}\" to your AgentStack project successfully!") diff --git a/agentstack/generation/task_generation.py b/agentstack/generation/task_generation.py index 91bee560..05548427 100644 --- a/agentstack/generation/task_generation.py +++ b/agentstack/generation/task_generation.py @@ -1,6 +1,6 @@ -import sys from typing import Optional from pathlib import Path +from agentstack import log from agentstack.exceptions import ValidationError from agentstack import frameworks from agentstack.utils import verify_agentstack_project @@ -28,8 +28,8 @@ def add_task( try: frameworks.add_task(task) - print(f" > Added to {TASKS_FILENAME}") + log.info(f"Added task \"{task_name}\" to project.") except ValidationError as e: - print(f"Error adding task to project:\n{e}") - sys.exit(1) - print(f"Added task \"{task_name}\" to your AgentStack project successfully!") + raise ValidationError(f"Error adding task to project:\n{e}") + + log.success(f"Added task \"{task_name}\" to your AgentStack project successfully!") diff --git a/agentstack/generation/tool_generation.py b/agentstack/generation/tool_generation.py index c319ab3a..11eac3fe 100644 --- a/agentstack/generation/tool_generation.py +++ b/agentstack/generation/tool_generation.py @@ -144,8 +144,8 @@ def remove_tool(tool_name: str, agents: Optional[list[str]] = []): try: # Edit the user's project tool init file to exclude the tool with ToolsInitFile(conf.PATH / TOOLS_INIT_FILENAME) as tools_init: tools_init.remove_import_for_tool(tool, agentstack_config.framework) - except ValidationError as e: - log.error(f"Error removing tool:\n{e}") + except ValidationError as e: # continue with removal + log.error(f"Error removing tool {tool_name} from `tools/__init__.py`:\n{e}") # Edit the framework entrypoint file to exclude the tool in the agent definition if not agents: # If no agents are specified, remove the tool from all agents diff --git a/agentstack/inputs.py b/agentstack/inputs.py index 248e0d79..bc3b51b6 100644 --- a/agentstack/inputs.py +++ b/agentstack/inputs.py @@ -3,7 +3,7 @@ from pathlib import Path from ruamel.yaml import YAML, YAMLError from ruamel.yaml.scalarstring import FoldedScalarString -from agentstack import conf +from agentstack import conf, log from agentstack.exceptions import ValidationError @@ -62,6 +62,7 @@ def model_dump(self) -> dict: return dump def write(self): + log.debug(f"Writing inputs to {INPUTS_FILENAME}") with open(conf.PATH / INPUTS_FILENAME, 'w') as f: yaml.dump(self.model_dump(), f) diff --git a/agentstack/main.py b/agentstack/main.py index 429d7fca..a1077d63 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -187,7 +187,7 @@ def handle_commands(args, extra_args, parser, tools_parser, generate_parser): elif args.command in ["init", "i"]: init_project_builder(args.slug_name, args.template, args.wizard) elif args.command in ["run", "r"]: - run_project(command=args.function, debug=args.debug, cli_args=extra_args) + run_project(command=args.function, 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/tasks.py b/agentstack/tasks.py index f5e79846..b0bca0a5 100644 --- a/agentstack/tasks.py +++ b/agentstack/tasks.py @@ -4,7 +4,7 @@ import pydantic from ruamel.yaml import YAML, YAMLError from ruamel.yaml.scalarstring import FoldedScalarString -from agentstack import conf +from agentstack import conf, log from agentstack.exceptions import ValidationError @@ -72,6 +72,7 @@ def model_dump(self, *args, **kwargs) -> dict: return {self.name: dump} def write(self): + log.debug(f"Writing task {self.name} to {TASKS_FILENAME}") filename = conf.PATH / TASKS_FILENAME with open(filename, 'r') as f: @@ -92,6 +93,7 @@ def __exit__(self, *args): def get_all_task_names() -> list[str]: filename = conf.PATH / TASKS_FILENAME if not os.path.exists(filename): + log.debug(f"Project does not have an {TASKS_FILENAME} file.") return [] with open(filename, 'r') as f: data = yaml.load(f) or {} diff --git a/agentstack/tools.py b/agentstack/tools.py index 1acb8d97..3794f0bc 100644 --- a/agentstack/tools.py +++ b/agentstack/tools.py @@ -26,9 +26,8 @@ class ToolConfig(pydantic.BaseModel): @classmethod def from_tool_name(cls, name: str) -> 'ToolConfig': path = get_package_path() / f'tools/{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'No known agentstack tool: {name}') return cls.from_json(path) @classmethod @@ -37,11 +36,10 @@ def from_json(cls, path: Path) -> 'ToolConfig': try: return cls(**data) except pydantic.ValidationError as e: - # TODO raise exceptions and handle message/exit in cli - print(term_color(f"Error validating tool config JSON: \n{path}", 'red')) + error_str = "Error validating tool config:\n" for error in e.errors(): - print(f"{' '.join([str(loc) for loc in error['loc']])}: {error['msg']}") - sys.exit(1) + error_str += f"{' '.join([str(loc) for loc in error['loc']])}: {error['msg']}\n" + raise ValidationError(f"Error loading tool from {path}.\n{error_str}") @property def module_name(self) -> str: diff --git a/agentstack/update.py b/agentstack/update.py index 8a01792f..dd67c422 100644 --- a/agentstack/update.py +++ b/agentstack/update.py @@ -112,32 +112,24 @@ def check_for_updates(update_requested: bool = False): if not update_requested and not should_update(): return - print("Checking for updates...\n") + log.info("Checking for updates...\n") try: latest_version: Version = get_latest_version(AGENTSTACK_PACKAGE) except Exception as e: - print(term_color("Failed to retrieve package index.", 'red')) - return + raise Exception(f"Failed to retrieve package index: {e}") installed_version: Version = parse_version(get_version(AGENTSTACK_PACKAGE)) if latest_version > installed_version: - print('') # newline + log.info('') # newline if inquirer.confirm( f"New version of {AGENTSTACK_PACKAGE} available: {latest_version}! Do you want to install?" ): packaging.upgrade(f'{AGENTSTACK_PACKAGE}[{get_framework()}]') - print( - term_color( - f"{AGENTSTACK_PACKAGE} updated. Re-run your command to use the latest version.", 'green' - ) - ) - sys.exit(0) + log.success(f"{AGENTSTACK_PACKAGE} updated. Re-run your command to use the latest version.") else: - print( - term_color("Skipping update. Run `agentstack update` to install the latest version.", 'blue') - ) + log.info("Skipping update. Run `agentstack update` to install the latest version.") else: - print(f"{AGENTSTACK_PACKAGE} is up to date ({installed_version})") + log.info(f"{AGENTSTACK_PACKAGE} is up to date ({installed_version})") record_update_check() diff --git a/agentstack/utils.py b/agentstack/utils.py index e1564dc2..074b7a84 100644 --- a/agentstack/utils.py +++ b/agentstack/utils.py @@ -13,7 +13,6 @@ def get_version(package: str = 'agentstack'): try: return version(package) except (KeyError, FileNotFoundError) as e: - print(e) return "Unknown version" @@ -21,12 +20,11 @@ def verify_agentstack_project(): try: agentstack_config = conf.ConfigFile() except FileNotFoundError: - print( - "\033[31mAgentStack Error: This does not appear to be an AgentStack project." - "\nPlease ensure you're at the root directory of your project and a file named agentstack.json exists. " - "If you're starting a new project, run `agentstack init`\033[0m" + raise Exception( + "Error: This does not appear to be an AgentStack project.\n" + "Please ensure you're at the root directory of your project and a file named agentstack.json exists. " + "If you're starting a new project, run `agentstack init`." ) - sys.exit(1) def get_package_path() -> Path: diff --git a/tests/test_cli_loads.py b/tests/test_cli_loads.py index 6ac8fcad..6b0e7a93 100644 --- a/tests/test_cli_loads.py +++ b/tests/test_cli_loads.py @@ -22,9 +22,6 @@ def run_cli(self, *args): def test_version(self): """Test the --version command.""" result = self.run_cli("--version") - print(result.stdout) - print(result.stderr) - print(result.returncode) self.assertEqual(result.returncode, 0) self.assertIn("AgentStack CLI version:", result.stdout) @@ -47,8 +44,8 @@ def test_run_command_invalid_project(self): os.chdir(test_dir) result = self.run_cli('run') - self.assertNotEqual(result.returncode, 0) - self.assertIn("Project validation failed", result.stdout) + self.assertEqual(result.returncode, 1) + self.assertIn("No such file or directory: 'src/crew.py'", result.stderr) shutil.rmtree(test_dir) diff --git a/tests/test_generation_agent.py b/tests/test_generation_agent.py index f2b39f5f..3e3e90cf 100644 --- a/tests/test_generation_agent.py +++ b/tests/test_generation_agent.py @@ -54,7 +54,7 @@ def test_add_agent(self): ast.parse(entrypoint_src) def test_add_agent_exists(self): - with self.assertRaises(SystemExit) as context: + with self.assertRaises(Exception) as context: add_agent( 'test_agent', role='role', diff --git a/tests/test_generation_files.py b/tests/test_generation_files.py index 900efdfd..83bb941d 100644 --- a/tests/test_generation_files.py +++ b/tests/test_generation_files.py @@ -74,7 +74,7 @@ def test_verify_agentstack_project_valid(self): def test_verify_agentstack_project_invalid(self): conf.set_path(BASE_PATH / "missing") - with self.assertRaises(SystemExit) as _: + with self.assertRaises(Exception) as _: verify_agentstack_project() def test_get_framework(self): @@ -82,7 +82,7 @@ def test_get_framework(self): def test_get_framework_missing(self): conf.set_path(BASE_PATH / "missing") - with self.assertRaises(SystemExit) as _: + with self.assertRaises(Exception) as _: get_framework() def test_read_env(self): diff --git a/tests/test_generation_tasks.py b/tests/test_generation_tasks.py index 7c871cd2..2f05ebfc 100644 --- a/tests/test_generation_tasks.py +++ b/tests/test_generation_tasks.py @@ -55,7 +55,7 @@ def test_add_task(self): ast.parse(entrypoint_src) def test_add_agent_exists(self): - with self.assertRaises(SystemExit) as context: + with self.assertRaises(Exception) as context: add_task( 'test_task', description='description', From e69384a35047a9f0a9fca879392785f1ae393e9c Mon Sep 17 00:00:00 2001 From: Braelyn Boynton Date: Sun, 22 Dec 2024 13:29:45 -0500 Subject: [PATCH 06/30] repo org update --- CONTRIBUTING.md | 33 ++++++++++++++++++++++++++++++--- foo.yaml | 0 pyproject.toml | 5 +++-- 3 files changed, 33 insertions(+), 5 deletions(-) delete mode 100644 foo.yaml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 946cbf33..1f09f3ee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,8 +18,6 @@ The best place to engage in conversation about your contribution is in the Issue `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 ## Adding Tools If you're reading this section, you probably have a product that AI agents can use as a tool. We're glad you're here! @@ -61,4 +59,33 @@ pre-commit install ``` ## Tests -HAHAHAHAHAHAHA good one +CLI tests are a bit hacky, so we are not tracking coverage. +That said, _some_ testing is required for any new functionality added by a PR. + +Tests MUST pass to have your PR merged. We _will not_ allow main to be in a failing state, so if your tests are failing, this is your problem to fix. + +### Run tests locally +Install the testing requirements +```bash +pip install 'agentstack[test]' +``` + +Then run tests in all supported python versions with +```bash +tox +``` + +## Need Help? +If you're reading this, we're very thankful you wanted to contribute! I understand it can be a little overwhelming to +get up to speed on a project like this and we are here to help! + +### Open a draft PR +While we can't promise to write code for you, if you're stuck or need advice/help, open a draft PR and explain what you were trying to build and where you're stuck! Chances are, one of us have the context needed to help you get unstuck :) + +### Chat on our Discord +We have an active [Discord server](https://discord.gg/JdWkh9tgTQ) with contributors and AgentStack users! There is a channel just for contributors on there. Feel free to drop a message explaining what you're trying to build and why you're stuck. Someone from our team should reply soon! + +# Thank You! +The team behind AgentStack believe that the barrier to entry for building agents is far too high right now! We believe that this technology can be streamlined and made more accessible. If you're here, you likely feel the same! Any contribution is appreciated. + +If you're looking for work, we are _always_ open to hiring passionate engineers of all skill levels! While closing issues cannot guarantee an offer, we've found that engineers who contribute to our open source repo are some of the best we could ever hope to find via recruiters! Be active in the community and let us know you're interested in joining the team! \ No newline at end of file diff --git a/foo.yaml b/foo.yaml deleted file mode 100644 index e69de29b..00000000 diff --git a/pyproject.toml b/pyproject.toml index 69f3c301..915af8f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "agentstack" -version = "0.2.2.2" +version = "0.2.3" description = "The fastest way to build robust AI agents" authors = [ - { name="Braelyn Boynton", email="bboynton97@gmail.com" } + { name="Braelyn Boynton", email="bboynton97@gmail.com" }, + { name="Travis Dent", email=" root@a10k.co" } ] license = { text = "MIT" } readme = "README.md" From 773e01732a312fcffe0a41081ae5ee1cd957677e Mon Sep 17 00:00:00 2001 From: Braelyn Boynton Date: Sun, 22 Dec 2024 14:10:05 -0500 Subject: [PATCH 07/30] sticker pack --- CONTRIBUTING.md | 5 +++++ .../{{cookiecutter.project_metadata.project_slug}}/README.md | 5 ++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f09f3ee..f5cbd7ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,6 +3,11 @@ First of all, __thank you__ for your interest in contributing to AgentStack! Eve Our vision is to build the defacto CLI for quickly spinning up an AI Agent project. We want to be the [create-react-app](https://create-react-app.dev/) of agents. Our inspiration also includes the oh-so-convenient [Angular CLI](https://v17.angular.io/cli). +### Exclusive Contributor Sticker +AgentStack contributors all receive a free sticker pack including an exclusive holographic sticker only available to contributors to the project :) + +Once your PR is merge, fill out [this form](https://docs.google.com/forms/d/e/1FAIpQLSfvBEnsT8nsQleonJHoWQtHuhbsgUJ0a9IjOqeZbMGkga2NtA/viewform?usp=sf_link) and I'll send your sticker pack out ASAP! <3 + ## How to Help Grab an issue from the [issues tab](https://github.com/AgentOps-AI/AgentStack/issues)! Plenty are labelled "Good First Issue". Fork the repo and create a PR when ready! diff --git a/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/README.md b/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/README.md index 889ea879..177242e5 100644 --- a/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/README.md +++ b/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/README.md @@ -1,8 +1,6 @@ # {{ cookiecutter.project_metadata.project_name }} {{ cookiecutter.project_metadata.description }} -~~ Built with AgentStack ~~ - ## How to build your Crew ### With the CLI Add an agent using AgentStack with the CLI: @@ -21,7 +19,7 @@ Add tools with `agentstack tools add` and view tools available with `agentstack In this directory, run `poetry install` To run your project, use the following command: -`crewai run` or `python src/main.py` +`agentstack run` This will initialize your crew of AI agents and begin task execution as defined in your configuration in the main.py file. @@ -36,3 +34,4 @@ If you need to reset the memory of your crew before running it again, you can do `crewai reset-memory` This will clear the crew's memory, allowing for a fresh start. +> ๐Ÿชฉ Project built with [AgentStack](https://github.com/AgentOps-AI/AgentStack) \ No newline at end of file From 38223e220c4c976f4a1bbd374c1e640a591785d6 Mon Sep 17 00:00:00 2001 From: Braelyn Boynton Date: Sun, 22 Dec 2024 15:48:37 -0500 Subject: [PATCH 08/30] authenticate CLI with agentstack account --- agentstack/auth.py | 142 +++++++++++++++++++++++++++++++++++++++++++++ agentstack/main.py | 116 ++++++++++++++++++------------------ 2 files changed, 200 insertions(+), 58 deletions(-) create mode 100644 agentstack/auth.py diff --git a/agentstack/auth.py b/agentstack/auth.py new file mode 100644 index 00000000..7d9d0363 --- /dev/null +++ b/agentstack/auth.py @@ -0,0 +1,142 @@ +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import parse_qs, urlparse +import webbrowser +import json +import os +import threading +import socket +from pathlib import Path + +import inquirer +from appdirs import user_data_dir +from agentstack import log + + +try: + base_dir = Path(user_data_dir("agentstack", "agency")) + # Test if we can write to directory + test_file = base_dir / '.test_write_permission' + test_file.touch() + test_file.unlink() +except (RuntimeError, OSError, PermissionError): + # In CI or when directory is not writable, use temp directory + base_dir = Path(os.getenv('TEMP', '/tmp')) + + +class AuthCallbackHandler(BaseHTTPRequestHandler): + def do_GET(self): + """Handle the OAuth callback from the browser""" + try: + # Parse the query parameters + query_components = parse_qs(urlparse(self.path).query) + + # Extract the token from query parameters + token = query_components.get('token', [''])[0] + + if token: + # Store the token + base_dir.mkdir(exist_ok=True, parents=True) + + with open(base_dir / 'auth.json', 'w') as f: + json.dump({'bearer_token': token}, f) + + # Send success response + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + + success_html = """ + + + +

Authentication successful! You can close this window.

+ + + """ + self.wfile.write(success_html.encode()) + + # Signal the main thread that we're done + self.server.authentication_successful = True + else: + self.send_response(400) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(b'Authentication failed: No token received') + + except Exception as e: + self.send_response(500) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(f'Error: {str(e)}'.encode()) + +def find_free_port(): + """Find a free port on localhost""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('', 0)) + s.listen(1) + port = s.getsockname()[1] + return port + +def start_auth_server(): + """Start the local authentication server""" + port = find_free_port() + server = HTTPServer(('localhost', port), AuthCallbackHandler) + server.authentication_successful = False + return server, port + + +def login(): + """Log in to AgentStack""" + try: + # check if already logged in + token = get_stored_token() + if token: + print("You are already authenticated!") + if not inquirer.confirm('Would you like to log in with a different account?'): + return + + # Start the local server + server, port = start_auth_server() + + # Create server thread + server_thread = threading.Thread(target=server.serve_forever) + server_thread.daemon = True + server_thread.start() + + # Open the browser to the login page + auth_url_base = os.getenv('AGENTSTACK_AUTHORIZATION_BASE_URL', 'https://agentstack.sh') + auth_url = f"{auth_url_base}/login?callback_port={port}" + webbrowser.open(auth_url) + + # Wait for authentication to complete + while not server.authentication_successful: + pass + + # Cleanup + server.shutdown() + server_thread.join() + + print("๐Ÿ” Authentication successful! Token has been stored.") + return True + + except Exception as e: + log.warn(f"Authentication failed: {str(e)}", err=True) + return False + + +def get_stored_token(): + """Retrieve the stored bearer token""" + try: + auth_path = base_dir / 'auth.json' + if not auth_path.exists(): + return None + + with open(auth_path) as f: + config = json.load(f) + return config.get('bearer_token') + except Exception: + return None \ No newline at end of file diff --git a/agentstack/main.py b/agentstack/main.py index a1077d63..13c263d1 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -1,10 +1,9 @@ import sys import argparse import webbrowser -from typing import Optional -from agentstack import conf -from agentstack import log +from agentstack import conf, log +from agentstack import auth from agentstack.cli import ( init_project_builder, add_tool, @@ -52,6 +51,9 @@ def main(): # 'templates' command subparsers.add_parser("templates", help="View Agentstack templates") + # 'login' command + subparsers.add_parser("login", help="Authenticate with Agentstack.sh") + # 'init' command init_parser = subparsers.add_parser( "init", aliases=["i"], help="Initialize a directory for the project", parents=[global_parser] @@ -155,65 +157,54 @@ def main(): print(f"AgentStack CLI version: {get_version()}") sys.exit(0) + telemetry_id = track_cli_command(args.command, " ".join(sys.argv[1:])) check_for_updates(update_requested=args.command in ('update', 'u')) - telemetry_id = None + # Handle commands try: - telemetry_id = track_cli_command(args.command, " ".join(sys.argv[1:])) - handle_commands(args, extra_args, parser, tools_parser, generate_parser) - update_telemetry(telemetry_id, result=0) - - except KeyboardInterrupt: - # Handle Ctrl+C (KeyboardInterrupt) - print("\nTerminating AgentStack CLI") - sys.exit(1) + if args.command in ["docs"]: + webbrowser.open("https://docs.agentstack.sh/") + elif args.command in ["quickstart"]: + webbrowser.open("https://docs.agentstack.sh/quickstart") + elif args.command in ["templates"]: + webbrowser.open("https://docs.agentstack.sh/quickstart") + elif args.command in ["init", "i"]: + init_project_builder(args.slug_name, args.template, args.wizard) + elif args.command in ["run", "r"]: + run_project(command=args.function, cli_args=extra_args) + elif args.command in ['generate', 'g']: + if args.generate_command in ['agent', 'a']: + if not args.llm: + configure_default_model() + generation.add_agent(args.name, args.role, args.goal, args.backstory, args.llm) + elif args.generate_command in ['task', 't']: + generation.add_task(args.name, args.description, args.expected_output, args.agent) + else: + generate_parser.print_help() + elif args.command in ["tools", "t"]: + if args.tools_command in ["list", "l"]: + list_tools() + elif args.tools_command in ["add", "a"]: + agents = [args.agent] if args.agent else None + agents = args.agents.split(",") if args.agents else agents + add_tool(args.name, agents) + elif args.tools_command in ["remove", "r"]: + generation.remove_tool(args.name) + else: + tools_parser.print_help() + elif args.command in ['export', 'e']: + export_template(args.filename) + elif args.command in ['login']: + auth.login() + elif args.command in ['update', 'u']: + pass # Update check already done + else: + parser.print_help() except Exception as e: - # Update telemetry with failure - if telemetry_id is not None: - update_telemetry(telemetry_id, result=1, message=str(e)) - log.error(f"An error occurred: {e}\nRun again with --debug for more information.") - log.debug("Full traceback:", exc_info=e) - sys.exit(1) - + update_telemetry(telemetry_id, result=1, message=str(e)) + raise e -def handle_commands(args, extra_args, parser, tools_parser, generate_parser): - # Handle commands - if args.command in ["docs"]: - webbrowser.open("https://docs.agentstack.sh/") - elif args.command in ["quickstart"]: - webbrowser.open("https://docs.agentstack.sh/quickstart") - elif args.command in ["templates"]: - webbrowser.open("https://docs.agentstack.sh/quickstart") - elif args.command in ["init", "i"]: - init_project_builder(args.slug_name, args.template, args.wizard) - elif args.command in ["run", "r"]: - run_project(command=args.function, cli_args=extra_args) - elif args.command in ['generate', 'g']: - if args.generate_command in ['agent', 'a']: - if not args.llm: - configure_default_model() - generation.add_agent(args.name, args.role, args.goal, args.backstory, args.llm) - elif args.generate_command in ['task', 't']: - generation.add_task(args.name, args.description, args.expected_output, args.agent) - else: - generate_parser.print_help() - elif args.command in ["tools", "t"]: - if args.tools_command in ["list", "l"]: - list_tools() - elif args.tools_command in ["add", "a"]: - agents = [args.agent] if args.agent else None - agents = args.agents.split(",") if args.agents else agents - add_tool(args.name, agents) - elif args.tools_command in ["remove", "r"]: - 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: - parser.print_help() + update_telemetry(telemetry_id, result=0) if __name__ == "__main__": @@ -221,4 +212,13 @@ def handle_commands(args, extra_args, parser, tools_parser, generate_parser): log.set_stdout(sys.stdout) log.set_stderr(sys.stderr) - main() + try: + main() + except Exception as e: + log.error((f"An error occurred: {e}\n" "Run again with --debug for more information.")) + log.debug("Full traceback:", exc_info=e) + sys.exit(1) + except KeyboardInterrupt: + # Handle Ctrl+C (KeyboardInterrupt) + print("\nTerminating AgentStack CLI") + sys.exit(1) From 3e8e4a10df86b86b9fe453447d2f36f945da6a67 Mon Sep 17 00:00:00 2001 From: Tadej Krevh Date: Tue, 17 Dec 2024 01:59:27 +0100 Subject: [PATCH 09/30] Fixed a bug, if entered agent name was empty. Improved general text inputs with validations. Simplified code. --- agentstack/cli/cli.py | 139 +++++++++++++++++++++++------------------- agentstack/utils.py | 16 ++++- 2 files changed, 91 insertions(+), 64 deletions(-) diff --git a/agentstack/cli/cli.py b/agentstack/cli/cli.py index 5af63c6a..f7d9991c 100644 --- a/agentstack/cli/cli.py +++ b/agentstack/cli/cli.py @@ -1,8 +1,8 @@ from typing import Optional -import os, sys +import os +import sys import time from datetime import datetime -from pathlib import Path import json import shutil @@ -25,8 +25,9 @@ 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.utils import open_json_file, term_color, is_snake_case, get_framework, validator_not_empty from agentstack.proj_templates import TemplateConfig +from agentstack.exceptions import ValidationError PREFERRED_MODELS = [ @@ -180,6 +181,75 @@ def ask_framework() -> str: return framework +def get_validated_input( + message: str, + validate_func=None, + min_length: int = 0, + snake_case: bool = False, +) -> str: + """Helper function to get validated input from user. + + Args: + message: The prompt message to display + validate_func: Optional custom validation function + min_length: Minimum length requirement (0 for no requirement) + snake_case: Whether to enforce snake_case naming + """ + while True: + try: + value = inquirer.text( + message=message, + validate=validate_func or validator_not_empty(min_length) if min_length else None, + ) + if snake_case and not is_snake_case(value): + raise ValidationError("Input must be in snake_case") + return value + except ValidationError as e: + print(term_color(f"Error: {str(e)}", 'red')) + + +def ask_agent_details(): + agent = {} + + agent['name'] = get_validated_input( + "What's the name of this agent? (snake_case)", min_length=3, snake_case=True + ) + + agent['role'] = get_validated_input("What role does this agent have?", min_length=3) + + agent['goal'] = get_validated_input("What is the goal of the agent?", min_length=10) + + agent['backstory'] = get_validated_input("Give your agent a backstory", min_length=10) + + agent['model'] = inquirer.list_input( + message="What LLM should this agent use?", choices=PREFERRED_MODELS, default=PREFERRED_MODELS[0] + ) + + return agent + + +def ask_task_details(agents: list[dict]) -> dict: + task = {} + + task['name'] = get_validated_input( + "What's the name of this task? (snake_case)", min_length=3, snake_case=True + ) + + task['description'] = get_validated_input("Describe the task in more detail", min_length=10) + + task['expected_output'] = get_validated_input( + "What do you expect the result to look like? (ex: A 5 bullet point summary of the email)", + min_length=10, + ) + + task['agent'] = inquirer.list_input( + message="Which agent should be assigned this task?", + choices=[a['name'] for a in agents], + ) + + return task + + def ask_design() -> dict: use_wizard = inquirer.confirm( message="Would you like to use the CLI wizard to set up agents and tasks?", @@ -204,39 +274,10 @@ def ask_design() -> dict: while make_agent: print('---') print(f"Agent #{len(agents)+1}") - - agent_incomplete = True agent = None - while agent_incomplete: - agent = inquirer.prompt( - [ - inquirer.Text("name", message="What's the name of this agent? (snake_case)"), - inquirer.Text("role", message="What role does this agent have?"), - inquirer.Text("goal", message="What is the goal of the agent?"), - inquirer.Text("backstory", message="Give your agent a backstory"), - # TODO: make a list - #2 - inquirer.Text( - 'model', - message="What LLM should this agent use? (any LiteLLM provider)", - default="openai/gpt-4", - ), - # inquirer.List("model", message="What LLM should this agent use? (any LiteLLM provider)", choices=[ - # 'mixtral_llm', - # 'mixtral_llm', - # ]), - ] - ) - - if not agent['name'] or agent['name'] == '': - print(term_color("Error: Agent name is required - Try again", 'red')) - agent_incomplete = True - elif not is_snake_case(agent['name']): - print(term_color("Error: Agent name must be snake case - Try again", 'red')) - else: - agent_incomplete = False - - make_agent = inquirer.confirm(message="Create another agent?") + agent = ask_agent_details() agents.append(agent) + make_agent = inquirer.confirm(message="Create another agent?") print('') for x in range(3): @@ -253,35 +294,9 @@ def ask_design() -> dict: while make_task: print('---') print(f"Task #{len(tasks) + 1}") - - task_incomplete = True - task = None - while task_incomplete: - task = inquirer.prompt( - [ - inquirer.Text("name", message="What's the name of this task? (snake_case)"), - inquirer.Text("description", message="Describe the task in more detail"), - inquirer.Text( - "expected_output", - message="What do you expect the result to look like? (ex: A 5 bullet point summary of the email)", - ), - inquirer.List( - "agent", - message="Which agent should be assigned this task?", - choices=[a['name'] for a in agents], # type: ignore - ), - ] - ) - - if not task['name'] or task['name'] == '': - print(term_color("Error: Task name is required - Try again", 'red')) - elif not is_snake_case(task['name']): - print(term_color("Error: Task name must be snake case - Try again", 'red')) - else: - task_incomplete = False - - make_task = inquirer.confirm(message="Create another task?") + task = ask_task_details(agents) tasks.append(task) + make_task = inquirer.confirm(message="Create another task?") print('') for x in range(3): diff --git a/agentstack/utils.py b/agentstack/utils.py index 074b7a84..a6310579 100644 --- a/agentstack/utils.py +++ b/agentstack/utils.py @@ -1,5 +1,5 @@ -from typing import Optional, Union -import os, sys +import os +import sys import json from ruamel.yaml import YAML import re @@ -7,6 +7,7 @@ from pathlib import Path import importlib.resources from agentstack import conf +from inquirer import errors as inquirer_errors def get_version(package: str = 'agentstack'): @@ -106,3 +107,14 @@ def term_color(text: str, color: str) -> str: def is_snake_case(string: str): return bool(re.match('^[a-z0-9_]+$', string)) + + +def validator_not_empty(min_length=1): + def validator(_, answer): + if len(answer) < min_length: + raise inquirer_errors.ValidationError( + '', reason=f"This field must be at least {min_length} characters long." + ) + return True + + return validator From c677b33f6a4f21b6eac3dbf44493222cf392f955 Mon Sep 17 00:00:00 2001 From: Tadej Krevh Date: Tue, 17 Dec 2024 21:35:28 +0100 Subject: [PATCH 10/30] Added tests for new functions. Refactored subprocess.run to be cross-platform compatible. Fixed failing tests on win32. --- tests/cli_test_utils.py | 36 ++++++++++++++++++++++++++++ tests/test_cli_init.py | 15 ++++++------ tests/test_cli_loads.py | 20 +++++++--------- tests/test_cli_templates.py | 15 +++++------- tests/test_cli_tools.py | 47 +++++++++++++++++++++++++++++-------- tests/test_utils.py | 21 ++++++++++++++++- 6 files changed, 114 insertions(+), 40 deletions(-) create mode 100644 tests/cli_test_utils.py diff --git a/tests/cli_test_utils.py b/tests/cli_test_utils.py new file mode 100644 index 00000000..fea013cc --- /dev/null +++ b/tests/cli_test_utils.py @@ -0,0 +1,36 @@ +import os, sys +import subprocess + +def run_cli(cli_entry, *args): + """Helper method to run the CLI with arguments. Cross-platform.""" + try: + # Use shell=True on Windows to handle path issues + if sys.platform == 'win32': + # Add PYTHONIOENCODING to the environment + env = os.environ.copy() + env['PYTHONIOENCODING'] = 'utf-8' + result = subprocess.run( + " ".join(str(arg) for arg in cli_entry + list(args)), + capture_output=True, + text=True, + shell=True, + env=env, + encoding='utf-8' + ) + else: + result = subprocess.run( + [*cli_entry, *args], + capture_output=True, + text=True, + encoding='utf-8' + ) + + if result.returncode != 0: + print(f"Command failed with code {result.returncode}") + print(f"STDOUT: {result.stdout}") + print(f"STDERR: {result.stderr}") + + return result + except Exception as e: + print(f"Exception running command: {e}") + raise \ No newline at end of file diff --git a/tests/test_cli_init.py b/tests/test_cli_init.py index b07d7a3f..218f7821 100644 --- a/tests/test_cli_init.py +++ b/tests/test_cli_init.py @@ -1,9 +1,9 @@ -import subprocess import os, sys import unittest from parameterized import parameterized from pathlib import Path import shutil +from cli_test_utils import run_cli BASE_PATH = Path(__file__).parent CLI_ENTRY = [ @@ -16,18 +16,17 @@ class CLIInitTest(unittest.TestCase): def setUp(self): self.project_dir = Path(BASE_PATH / 'tmp/cli_init') - os.makedirs(self.project_dir) + os.chdir(BASE_PATH) # Change to parent directory first + os.makedirs(self.project_dir, exist_ok=True) os.chdir(self.project_dir) + # Force UTF-8 encoding for the test environment + os.environ['PYTHONIOENCODING'] = 'utf-8' 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) + shutil.rmtree(self.project_dir, ignore_errors=True) def test_init_command(self): """Test the 'init' command to create a project directory.""" - result = self._run_cli('init', 'test_project') + result = run_cli(CLI_ENTRY, 'init', 'test_project') self.assertEqual(result.returncode, 0) self.assertTrue((self.project_dir / 'test_project').exists()) diff --git a/tests/test_cli_loads.py b/tests/test_cli_loads.py index 6b0e7a93..383e0947 100644 --- a/tests/test_cli_loads.py +++ b/tests/test_cli_loads.py @@ -3,6 +3,7 @@ import unittest from pathlib import Path import shutil +from cli_test_utils import run_cli BASE_PATH = Path(__file__).parent @@ -14,20 +15,15 @@ class TestAgentStackCLI(unittest.TestCase): "agentstack.main", ] - def run_cli(self, *args): - """Helper method to run the CLI with arguments.""" - result = subprocess.run([*self.CLI_ENTRY, *args], capture_output=True, text=True) - return result - def test_version(self): """Test the --version command.""" - result = self.run_cli("--version") + result = run_cli(self.CLI_ENTRY, "--version") self.assertEqual(result.returncode, 0) self.assertIn("AgentStack CLI version:", result.stdout) def test_invalid_command(self): """Test an invalid command gracefully exits.""" - result = self.run_cli("invalid_command") + result = run_cli(self.CLI_ENTRY, "invalid_command") self.assertNotEqual(result.returncode, 0) self.assertIn("usage:", result.stderr) @@ -35,7 +31,7 @@ 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) + shutil.rmtree(test_dir, ignore_errors=True) os.makedirs(test_dir) # Write a basic agentstack.json file @@ -43,11 +39,11 @@ def test_run_command_invalid_project(self): f.write(open(BASE_PATH / 'fixtures/agentstack.json', 'r').read()) os.chdir(test_dir) - result = self.run_cli('run') - self.assertEqual(result.returncode, 1) - self.assertIn("No such file or directory: 'src/crew.py'", result.stderr) + result = run_cli(self.CLI_ENTRY, 'run') + self.assertNotEqual(result.returncode, 0) + self.assertIn("An error occurred", result.stdout) - shutil.rmtree(test_dir) + shutil.rmtree(test_dir, ignore_errors=True) if __name__ == "__main__": diff --git a/tests/test_cli_templates.py b/tests/test_cli_templates.py index 285b91c1..a791bb9b 100644 --- a/tests/test_cli_templates.py +++ b/tests/test_cli_templates.py @@ -5,6 +5,7 @@ from pathlib import Path import shutil from agentstack.proj_templates import get_all_template_names +from cli_test_utils import run_cli BASE_PATH = Path(__file__).parent CLI_ENTRY = [ @@ -17,20 +18,16 @@ class CLITemplatesTest(unittest.TestCase): def setUp(self): self.project_dir = Path(BASE_PATH / 'tmp/cli_templates') - os.makedirs(self.project_dir) + os.makedirs(self.project_dir, exist_ok=True) 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) + shutil.rmtree(self.project_dir, ignore_errors=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) + result = run_cli(CLI_ENTRY, 'init', 'test_project', '--template', template_name) self.assertEqual(result.returncode, 0) self.assertTrue((self.project_dir / 'test_project').exists()) @@ -39,7 +36,7 @@ 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') + result = self._run_cli('generate', 'agent', 'test_agent', '--llm', 'openai/gpt-4o') self.assertEqual(result.returncode, 0) result = self._run_cli('generate', 'task', 'test_task', '--agent', 'test_agent') self.assertEqual(result.returncode, 0) @@ -67,7 +64,7 @@ def test_export_template_v1(self): "role": "Add your role here", "goal": "Add your goal here", "backstory": "Add your backstory here", - "model": "opeenai/gpt-4o" + "model": "openai/gpt-4o" } ], "tasks": [ diff --git a/tests/test_cli_tools.py b/tests/test_cli_tools.py index f5048b72..92ab425f 100644 --- a/tests/test_cli_tools.py +++ b/tests/test_cli_tools.py @@ -5,6 +5,10 @@ from pathlib import Path import shutil from agentstack.tools import get_all_tool_names +from cli_test_utils import run_cli +from agentstack.utils import validator_not_empty +from agentstack.cli.cli import get_validated_input + BASE_PATH = Path(__file__).parent CLI_ENTRY = [ @@ -18,29 +22,52 @@ class CLIToolsTest(unittest.TestCase): def setUp(self): self.project_dir = Path(BASE_PATH / 'tmp/cli_tools') - os.makedirs(self.project_dir) + os.makedirs(self.project_dir, exist_ok=True) 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) + shutil.rmtree(self.project_dir, ignore_errors=True) @parameterized.expand([(x,) for x in get_all_tool_names()]) @unittest.skip("Dependency resolution issue") def test_add_tool(self, tool_name): """Test the adding every tool to a project.""" - result = self._run_cli('init', f"{tool_name}_project") + result = run_cli(CLI_ENTRY, 'init', f"{tool_name}_project") self.assertEqual(result.returncode, 0) os.chdir(self.project_dir / f"{tool_name}_project") - result = self._run_cli('generate', 'agent', 'test_agent', '--llm', 'opeenai/gpt-4o') + result = run_cli(CLI_ENTRY, 'generate', 'agent', 'test_agent', '--llm', 'opeenai/gpt-4o') self.assertEqual(result.returncode, 0) - result = self._run_cli('generate', 'task', 'test_task') + result = run_cli(CLI_ENTRY, 'generate', 'task', 'test_task') self.assertEqual(result.returncode, 0) - result = self._run_cli('tools', 'add', tool_name) + result = run_cli(CLI_ENTRY, 'tools', 'add', tool_name) print(result.stdout) self.assertEqual(result.returncode, 0) self.assertTrue(self.project_dir.exists()) + + def test_get_validated_input(self): + """Test the get_validated_input function with various validation scenarios""" + from agentstack.cli.cli import get_validated_input + from unittest.mock import patch + from inquirer.errors import ValidationError + from agentstack.utils import validator_not_empty + + # Test basic input + with patch('inquirer.text', return_value='test_input'): + result = get_validated_input("Test message") + self.assertEqual(result, 'test_input') + + # Test min length validation - valid input + with patch('inquirer.text', return_value='abc'): + result = get_validated_input("Test message", min_length=3) + self.assertEqual(result, 'abc') + + # Test min length validation - invalid input should raise ValidationError + validator = validator_not_empty(3) + with self.assertRaises(ValidationError): + validator(None, 'ab') + + # Test snake_case validation + with patch('inquirer.text', return_value='test_case'): + result = get_validated_input("Test message", snake_case=True) + self.assertEqual(result, 'test_case') \ No newline at end of file diff --git a/tests/test_utils.py b/tests/test_utils.py index 720965d2..6b210ab7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,7 @@ import unittest -from agentstack.utils import clean_input, is_snake_case +from agentstack.utils import clean_input, is_snake_case, validator_not_empty +from inquirer import errors as inquirer_errors class TestUtils(unittest.TestCase): @@ -18,3 +19,21 @@ def test_is_snake_case(self): assert not is_snake_case("Hello-World") assert not is_snake_case("hello-world") assert not is_snake_case("hello world") + + def test_validator_not_empty(self): + validator = validator_not_empty(min_length=1) + + # Valid input should return True + self.assertTrue(validator(None, "test")) + self.assertTrue(validator(None, "a")) + + # Empty input should raise ValidationError + with self.assertRaises(inquirer_errors.ValidationError): + validator(None, "") + + # Test with larger min_length + validator = validator_not_empty(min_length=3) + self.assertTrue(validator(None, "test")) + with self.assertRaises(inquirer_errors.ValidationError): + validator(None, "ab") + From dcd9814dcdaf82cc0d13ec0f5e23e71f68495951 Mon Sep 17 00:00:00 2001 From: Tadej Krevh Date: Thu, 19 Dec 2024 10:09:03 +0100 Subject: [PATCH 11/30] Moved CLI_ENTRY out of individual test files to cli_test_utils as it was the same across all tests --- tests/cli_test_utils.py | 12 +++++++++--- tests/test_cli_init.py | 8 +------- tests/test_cli_loads.py | 11 +++-------- tests/test_cli_templates.py | 8 +------- tests/test_cli_tools.py | 21 +++++++-------------- 5 files changed, 21 insertions(+), 39 deletions(-) diff --git a/tests/cli_test_utils.py b/tests/cli_test_utils.py index fea013cc..43277d4c 100644 --- a/tests/cli_test_utils.py +++ b/tests/cli_test_utils.py @@ -1,7 +1,13 @@ import os, sys import subprocess -def run_cli(cli_entry, *args): +CLI_ENTRY = [ + sys.executable, + "-m", + "agentstack.main", +] + +def run_cli(*args): """Helper method to run the CLI with arguments. Cross-platform.""" try: # Use shell=True on Windows to handle path issues @@ -10,7 +16,7 @@ def run_cli(cli_entry, *args): env = os.environ.copy() env['PYTHONIOENCODING'] = 'utf-8' result = subprocess.run( - " ".join(str(arg) for arg in cli_entry + list(args)), + " ".join(str(arg) for arg in CLI_ENTRY + list(args)), capture_output=True, text=True, shell=True, @@ -19,7 +25,7 @@ def run_cli(cli_entry, *args): ) else: result = subprocess.run( - [*cli_entry, *args], + [*CLI_ENTRY, *args], capture_output=True, text=True, encoding='utf-8' diff --git a/tests/test_cli_init.py b/tests/test_cli_init.py index 218f7821..02267d35 100644 --- a/tests/test_cli_init.py +++ b/tests/test_cli_init.py @@ -6,12 +6,6 @@ from cli_test_utils import run_cli BASE_PATH = Path(__file__).parent -CLI_ENTRY = [ - sys.executable, - "-m", - "agentstack.main", -] - class CLIInitTest(unittest.TestCase): def setUp(self): @@ -27,6 +21,6 @@ def tearDown(self): def test_init_command(self): """Test the 'init' command to create a project directory.""" - result = run_cli(CLI_ENTRY, 'init', 'test_project') + result = run_cli('init', 'test_project') self.assertEqual(result.returncode, 0) self.assertTrue((self.project_dir / 'test_project').exists()) diff --git a/tests/test_cli_loads.py b/tests/test_cli_loads.py index 383e0947..84b22f93 100644 --- a/tests/test_cli_loads.py +++ b/tests/test_cli_loads.py @@ -9,21 +9,16 @@ class TestAgentStackCLI(unittest.TestCase): - CLI_ENTRY = [ - sys.executable, - "-m", - "agentstack.main", - ] def test_version(self): """Test the --version command.""" - result = run_cli(self.CLI_ENTRY, "--version") + result = run_cli("--version") self.assertEqual(result.returncode, 0) self.assertIn("AgentStack CLI version:", result.stdout) def test_invalid_command(self): """Test an invalid command gracefully exits.""" - result = run_cli(self.CLI_ENTRY, "invalid_command") + result = run_cli("invalid_command") self.assertNotEqual(result.returncode, 0) self.assertIn("usage:", result.stderr) @@ -39,7 +34,7 @@ def test_run_command_invalid_project(self): f.write(open(BASE_PATH / 'fixtures/agentstack.json', 'r').read()) os.chdir(test_dir) - result = run_cli(self.CLI_ENTRY, 'run') + result = run_cli('run') self.assertNotEqual(result.returncode, 0) self.assertIn("An error occurred", result.stdout) diff --git a/tests/test_cli_templates.py b/tests/test_cli_templates.py index a791bb9b..f9bba704 100644 --- a/tests/test_cli_templates.py +++ b/tests/test_cli_templates.py @@ -8,12 +8,6 @@ from cli_test_utils import run_cli BASE_PATH = Path(__file__).parent -CLI_ENTRY = [ - sys.executable, - "-m", - "agentstack.main", -] - class CLITemplatesTest(unittest.TestCase): def setUp(self): @@ -27,7 +21,7 @@ def tearDown(self): @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 = run_cli(CLI_ENTRY, 'init', 'test_project', '--template', template_name) + result = 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_tools.py b/tests/test_cli_tools.py index 92ab425f..9443639c 100644 --- a/tests/test_cli_tools.py +++ b/tests/test_cli_tools.py @@ -8,15 +8,12 @@ from cli_test_utils import run_cli from agentstack.utils import validator_not_empty from agentstack.cli.cli import get_validated_input +from unittest.mock import patch +from inquirer.errors import ValidationError +from agentstack.utils import validator_not_empty BASE_PATH = Path(__file__).parent -CLI_ENTRY = [ - sys.executable, - "-m", - "agentstack.main", -] - # TODO parameterized framework class CLIToolsTest(unittest.TestCase): @@ -32,25 +29,21 @@ def tearDown(self): @unittest.skip("Dependency resolution issue") def test_add_tool(self, tool_name): """Test the adding every tool to a project.""" - result = run_cli(CLI_ENTRY, 'init', f"{tool_name}_project") + result = run_cli('init', f"{tool_name}_project") self.assertEqual(result.returncode, 0) os.chdir(self.project_dir / f"{tool_name}_project") - result = run_cli(CLI_ENTRY, 'generate', 'agent', 'test_agent', '--llm', 'opeenai/gpt-4o') + result = run_cli('generate', 'agent', 'test_agent', '--llm', 'opeenai/gpt-4o') self.assertEqual(result.returncode, 0) - result = run_cli(CLI_ENTRY, 'generate', 'task', 'test_task') + result = run_cli('generate', 'task', 'test_task') self.assertEqual(result.returncode, 0) - result = run_cli(CLI_ENTRY, 'tools', 'add', tool_name) + result = run_cli('tools', 'add', tool_name) print(result.stdout) self.assertEqual(result.returncode, 0) self.assertTrue(self.project_dir.exists()) def test_get_validated_input(self): """Test the get_validated_input function with various validation scenarios""" - from agentstack.cli.cli import get_validated_input - from unittest.mock import patch - from inquirer.errors import ValidationError - from agentstack.utils import validator_not_empty # Test basic input with patch('inquirer.text', return_value='test_input'): From 3ecedb7e05733a3faeac2f7d31f181b95b237b14 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Mon, 23 Dec 2024 11:43:38 -0800 Subject: [PATCH 12/30] Environment variables are written commented-out if no value is set. This prevents us from overriding existing values in the user's env with placeholder values. --- agentstack/generation/files.py | 47 ++++++++++++++++++++++++---------- tests/fixtures/.env | 4 ++- tests/test_generation_files.py | 23 +++++++++++++++-- 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/agentstack/generation/files.py b/agentstack/generation/files.py index f2ad90a0..f6fa12c6 100644 --- a/agentstack/generation/files.py +++ b/agentstack/generation/files.py @@ -1,5 +1,6 @@ from typing import Optional, Union import os, sys +import string from pathlib import Path if sys.version_info >= (3, 11): @@ -9,7 +10,7 @@ from agentstack import conf -ENV_FILEMANE = ".env" +ENV_FILENAME = ".env" PYPROJECT_FILENAME = "pyproject.toml" @@ -17,10 +18,15 @@ class EnvFile: """ Interface for interacting with the .env file inside a project directory. Unlike the ConfigFile, we do not re-write the entire file on every change, - and instead just append new lines to the end of the file. This preseres + and instead just append new lines to the end of the file. This preserves 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 (for example, but the user's shell). + Commented variable are not re-parsed when the file is read. + `path` is the directory where the .env file is located. Defaults to the current working directory. `filename` is the name of the .env file, defaults to '.env'. @@ -34,14 +40,14 @@ class EnvFile: variables: dict[str, str] - def __init__(self, filename: str = ENV_FILEMANE): + def __init__(self, filename: str = ENV_FILENAME): self._filename = filename self.read() - def __getitem__(self, key): + def __getitem__(self, key) -> str: return self.variables[key] - def __setitem__(self, key, value): + def __setitem__(self, key, value) -> None: if key in self.variables: raise ValueError("EnvFile does not allow overwriting values.") self.append_if_new(key, value) @@ -49,32 +55,47 @@ def __setitem__(self, key, value): def __contains__(self, key) -> bool: return key in self.variables - def append_if_new(self, key, value): + def append_if_new(self, key, value) -> None: + """Setting a non-existent key will append it to the end of the file.""" if key not in self.variables: self.variables[key] = value self._new_variables[key] = value - def read(self): - def parse_line(line): + def read(self) -> None: + def parse_line(line) -> tuple[str, Union[str, None]]: + """ + Parse a line from the .env file. + Pairs are split on the first '=' character, and stripped of whitespace & quotes. + If the value is empty, it is returned as None. + Only the last occurrence of a variable is stored. + """ key, value = line.split('=') - return key.strip(), value.strip() + key = key.strip() + value = value.strip(string.whitespace + '"') + return key, value if value else None if os.path.exists(conf.PATH / self._filename): with open(conf.PATH / self._filename, 'r') as f: - self.variables = dict([parse_line(line) for line in f.readlines() if '=' in line]) + self.variables = dict( + [parse_line(line) for line in f.readlines() if '=' in line and not line.startswith('#')] + ) else: self.variables = {} self._new_variables = {} - def write(self): + def write(self) -> None: + """Append new variables to the end of the file.""" with open(conf.PATH / self._filename, 'a') as f: for key, value in self._new_variables.items(): - f.write(f"\n{key}={value}") + if value is None: + f.write(f'\n#{key}=""') # comment-out empty values + else: + f.write(f'\n{key}={value}') def __enter__(self) -> 'EnvFile': return self - def __exit__(self, *args): + def __exit__(self, *args) -> None: self.write() diff --git a/tests/fixtures/.env b/tests/fixtures/.env index 3f1c8b1d..9197de0d 100644 --- a/tests/fixtures/.env +++ b/tests/fixtures/.env @@ -1,3 +1,5 @@ ENV_VAR1=value1 -ENV_VAR2=value2 \ No newline at end of file +ENV_VAR2=value_ignored +ENV_VAR2=value2 +#ENV_VAR3="" \ No newline at end of file diff --git a/tests/test_generation_files.py b/tests/test_generation_files.py index 83bb941d..869ca8e8 100644 --- a/tests/test_generation_files.py +++ b/tests/test_generation_files.py @@ -93,7 +93,7 @@ def test_read_env(self): assert env["ENV_VAR1"] == "value1" assert env["ENV_VAR2"] == "value2" with self.assertRaises(KeyError) as _: - env["ENV_VAR3"] + env["ENV_VAR100"] def test_write_env(self): shutil.copy(BASE_PATH / "fixtures/.env", self.project_dir / ".env") @@ -103,4 +103,23 @@ def test_write_env(self): env.append_if_new("ENV_VAR100", "value2") # Should be added tmp_data = open(self.project_dir / ".env").read() - assert tmp_data == """\nENV_VAR1=value1\nENV_VAR2=value2\nENV_VAR100=value2""" + assert ( + tmp_data + == """\nENV_VAR1=value1\nENV_VAR2=value_ignored\nENV_VAR2=value2\n#ENV_VAR3=""\nENV_VAR100=value2""" + ) + + def test_write_env_commented(self): + """We should be able to write a commented-out value.""" + shutil.copy(BASE_PATH / "fixtures/.env", self.project_dir / ".env") + + with EnvFile() as env: + env.append_if_new("ENV_VAR3", "value3") + + env = EnvFile() # re-read the file + assert env.variables == {"ENV_VAR1": "value1", "ENV_VAR2": "value2", "ENV_VAR3": "value3"} + + tmp_file = open(self.project_dir / ".env").read() + assert ( + tmp_file + == """\nENV_VAR1=value1\nENV_VAR2=value_ignored\nENV_VAR2=value2\n#ENV_VAR3=""\nENV_VAR3=value3""" + ) From e1d2733c824c1613a6a812109e6ba40fe4ce12b7 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Mon, 23 Dec 2024 11:46:57 -0800 Subject: [PATCH 13/30] Update tool configs to use null values for placeholder environment variables. --- agentstack/tools/agent_connect.json | 8 ++++---- agentstack/tools/browserbase.json | 4 ++-- agentstack/tools/composio.json | 2 +- agentstack/tools/exa.json | 2 +- agentstack/tools/firecrawl.json | 2 +- agentstack/tools/ftp.json | 6 +++--- agentstack/tools/mem0.json | 2 +- agentstack/tools/neon.json | 2 +- agentstack/tools/perplexity.json | 2 +- agentstack/tools/stripe.json | 2 +- 10 files changed, 16 insertions(+), 16 deletions(-) diff --git a/agentstack/tools/agent_connect.json b/agentstack/tools/agent_connect.json index 3dd6a034..e0f41702 100644 --- a/agentstack/tools/agent_connect.json +++ b/agentstack/tools/agent_connect.json @@ -4,12 +4,12 @@ "category": "network-protocols", "packages": ["agent-connect"], "env": { - "HOST_DOMAIN": "...", + "HOST_DOMAIN": null, "HOST_PORT": 80, "HOST_WS_PATH": "/ws", - "DID_DOCUMENT_PATH": "...", - "SSL_CERT_PATH": "...", - "SSL_KEY_PATH": "..." + "DID_DOCUMENT_PATH": null, + "SSL_CERT_PATH": null, + "SSL_KEY_PATH": null }, "tools": ["send_message", "receive_message"] } diff --git a/agentstack/tools/browserbase.json b/agentstack/tools/browserbase.json index d005c278..6e442262 100644 --- a/agentstack/tools/browserbase.json +++ b/agentstack/tools/browserbase.json @@ -4,8 +4,8 @@ "category": "browsing", "packages": ["browserbase", "playwright"], "env": { - "BROWSERBASE_API_KEY": "...", - "BROWSERBASE_PROJECT_ID": "..." + "BROWSERBASE_API_KEY": null, + "BROWSERBASE_PROJECT_ID": null }, "tools": ["browserbase"], "cta": "Create an API key at https://www.browserbase.com/" diff --git a/agentstack/tools/composio.json b/agentstack/tools/composio.json index c2b20d0d..8af447ee 100644 --- a/agentstack/tools/composio.json +++ b/agentstack/tools/composio.json @@ -4,7 +4,7 @@ "category": "unified-apis", "packages": ["composio-crewai"], "env": { - "COMPOSIO_API_KEY": "..." + "COMPOSIO_API_KEY": null }, "tools": ["composio_tools"], "tools_bundled": true, diff --git a/agentstack/tools/exa.json b/agentstack/tools/exa.json index 2dada5e3..d8bc4679 100644 --- a/agentstack/tools/exa.json +++ b/agentstack/tools/exa.json @@ -4,7 +4,7 @@ "category": "web-retrieval", "packages": ["exa_py"], "env": { - "EXA_API_KEY": "..." + "EXA_API_KEY": null }, "tools": ["search_and_contents"], "cta": "Get your Exa API key at https://dashboard.exa.ai/api-keys" diff --git a/agentstack/tools/firecrawl.json b/agentstack/tools/firecrawl.json index 7937fde4..e85bca63 100644 --- a/agentstack/tools/firecrawl.json +++ b/agentstack/tools/firecrawl.json @@ -4,7 +4,7 @@ "category": "browsing", "packages": ["firecrawl-py"], "env": { - "FIRECRAWL_API_KEY": "..." + "FIRECRAWL_API_KEY": null }, "tools": ["web_scrape", "web_crawl", "retrieve_web_crawl"], "cta": "Create an API key at https://www.firecrawl.dev/" diff --git a/agentstack/tools/ftp.json b/agentstack/tools/ftp.json index ca11fcc8..81a41fe8 100644 --- a/agentstack/tools/ftp.json +++ b/agentstack/tools/ftp.json @@ -3,9 +3,9 @@ "category": "computer-control", "packages": [], "env": { - "FTP_HOST": "...", - "FTP_USER": "...", - "FTP_PASSWORD": "..." + "FTP_HOST": null, + "FTP_USER": null, + "FTP_PASSWORD": null }, "tools": ["upload_files"], "cta": "Be sure to add your FTP credentials to .env" diff --git a/agentstack/tools/mem0.json b/agentstack/tools/mem0.json index dfd224ac..919121b5 100644 --- a/agentstack/tools/mem0.json +++ b/agentstack/tools/mem0.json @@ -4,7 +4,7 @@ "category": "storage", "packages": ["mem0ai"], "env": { - "MEM0_API_KEY": "..." + "MEM0_API_KEY": null }, "tools": ["write_to_memory", "read_from_memory"], "cta": "Create your mem0 API key at https://mem0.ai/" diff --git a/agentstack/tools/neon.json b/agentstack/tools/neon.json index 8fd13f6d..aa3ebc91 100644 --- a/agentstack/tools/neon.json +++ b/agentstack/tools/neon.json @@ -4,7 +4,7 @@ "url": "https://github.com/neondatabase/neon", "packages": ["neon-api", "psycopg2-binary"], "env": { - "NEON_API_KEY": "..." + "NEON_API_KEY": null }, "tools": ["create_database", "execute_sql_ddl", "run_sql_query"], "cta": "Create an API key at https://www.neon.tech" diff --git a/agentstack/tools/perplexity.json b/agentstack/tools/perplexity.json index ba6fe696..90630723 100644 --- a/agentstack/tools/perplexity.json +++ b/agentstack/tools/perplexity.json @@ -3,7 +3,7 @@ "url": "https://perplexity.ai", "category": "search", "env": { - "PERPLEXITY_API_KEY": "..." + "PERPLEXITY_API_KEY": null }, "tools": ["query_perplexity"] } \ No newline at end of file diff --git a/agentstack/tools/stripe.json b/agentstack/tools/stripe.json index 212c6b23..91a73aae 100644 --- a/agentstack/tools/stripe.json +++ b/agentstack/tools/stripe.json @@ -4,7 +4,7 @@ "category": "application-specific", "packages": ["stripe-agent-toolkit", "stripe"], "env": { - "STRIPE_SECRET_KEY": "sk-..." + "STRIPE_SECRET_KEY": null }, "tools_bundled": true, "tools": ["stripe_tools"], From 25aaa3d7ec7532ffc683daea6c90b7aa61eb4ec4 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Mon, 23 Dec 2024 11:52:50 -0800 Subject: [PATCH 14/30] Don't override `false`` values as None when re-parsing env vars --- agentstack/generation/files.py | 7 ++----- tests/test_generation_files.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/agentstack/generation/files.py b/agentstack/generation/files.py index f6fa12c6..678a16f5 100644 --- a/agentstack/generation/files.py +++ b/agentstack/generation/files.py @@ -62,17 +62,14 @@ def append_if_new(self, key, value) -> None: self._new_variables[key] = value def read(self) -> None: - def parse_line(line) -> tuple[str, Union[str, None]]: + def parse_line(line) -> tuple[str, str]: """ Parse a line from the .env file. Pairs are split on the first '=' character, and stripped of whitespace & quotes. - If the value is empty, it is returned as None. Only the last occurrence of a variable is stored. """ key, value = line.split('=') - key = key.strip() - value = value.strip(string.whitespace + '"') - return key, value if value else None + return key.strip(), value.strip(string.whitespace + '"') if os.path.exists(conf.PATH / self._filename): with open(conf.PATH / self._filename, 'r') as f: diff --git a/tests/test_generation_files.py b/tests/test_generation_files.py index 869ca8e8..92f1aa09 100644 --- a/tests/test_generation_files.py +++ b/tests/test_generation_files.py @@ -107,6 +107,16 @@ def test_write_env(self): tmp_data == """\nENV_VAR1=value1\nENV_VAR2=value_ignored\nENV_VAR2=value2\n#ENV_VAR3=""\nENV_VAR100=value2""" ) + + def test_write_env_numeric_that_can_be_boolean(self): + shutil.copy(BASE_PATH / "fixtures/.env", self.project_dir / ".env") + + with EnvFile() as env: + env.append_if_new("ENV_VAR100", 0) + env.append_if_new("ENV_VAR101", 1) + + env = EnvFile() # re-read the file + assert env.variables == {"ENV_VAR1": "value1", "ENV_VAR2": "value2", "ENV_VAR100": "0", "ENV_VAR101": "1"} def test_write_env_commented(self): """We should be able to write a commented-out value.""" From a88dba94ff56e3322fa6dde09fc81c809a46e658 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Wed, 11 Dec 2024 11:30:16 -0800 Subject: [PATCH 15/30] Document for project structure and tasks leading to 0.3 release --- docs/contributing/project-structure.mdx | 205 ++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 docs/contributing/project-structure.mdx diff --git a/docs/contributing/project-structure.mdx b/docs/contributing/project-structure.mdx new file mode 100644 index 00000000..4e201123 --- /dev/null +++ b/docs/contributing/project-structure.mdx @@ -0,0 +1,205 @@ +--- +title: 'Project Structure' +description: 'Concepts and Structure of AgentStack' +--- + +> This document is a work-in-progress as we build to version 0.3 and helps +define the structure of the project that we are aiming to create. + +AgentStack is a framework-agnostic toolkit for bootstrapping and managing +AI agents. Out of the box it has support for a number of tools and generates +code to get your project off the ground and deployed to a production environment. +It also aims to provide robust tooling for running and managing agents including +logging, debugging, deployment, and observability via [AgentOps](https://www.agentops.ai/). + +Developers with limited agent experience should be able to get an agentic +workflow up and running in a matter of minutes. Developers with more experience +should be able to leverage the tools provided by AgentStack to create more +complex workflows and deploy them to production with ease. + +# Concepts + +## Projects +A project is a user's implementation of AgentStack that is used to implement +and agentic workflow. This is a directory the `agentstack` shell command is +executed from. + +## Frameworks +Frameworks are the target platforms that `agentstack` can generate code for. +We don't implement all of the functionality provided by a framework, but instead +leverage them to create agentic workflows and provide tooling to aid in their +creation and operation. [Documented in Frameworks](#frameworks-1) + + +# Public API +The public API is available inside of a project after declaring `import agentstack` + +## `agentstack.PATH` +`` This is the path to the current project directory. + +> TODO: The current project `Path` can be accessed throughout the application +with `agentstack.PATH`. + +## `agentstack.FRAMEWORK` +`` This is the name of the current framework. If the application +is not being run from a project directory, this will be `None`. + +> TODO: The current framework name can be accessed throughout the application, +if it is being executed within a project directory, with `agentstack.FRAMEWORK`. +If not inside a project directory, the framework will be `None`; this can be used +to determine if the application has a project available. + +## `agentstack.get_inputs()` +`` This function returns the inputs for a project. These are the +variables that can be used to configure tasks in the project and are stored in the +`inputs.yaml` file inside the project directory. + + +# Core +These namespaces occupy the root of `agentstack` and are shared across all +project & frameworks. Methods from these products are generally candidates for +availability in the public API for use within a project. + +## `agents` +Agents are the actual personalities that accomplish work. We provide tools for +interacting with the `agents.yaml` configuration file in this package. + +## `tasks` +Tasks are the individual units of work that an Agent can perform. `agents` will +use the `tools` they have available to accomplish `tasks`. We provide tools for +interacting with the `tasks.yaml` configuration file in this package. + +## `inputs` +Inputs are variable data that can be used to configure `tasks`. Behind the scenes +`inputs` are interpolated into `task` prompts to determine their specialization. +We provide tools for interacting with the `inputs.yaml` configuration file in this +package. + +> TODO: Command line argument parsing to select which `inputs` to use or provide overrides. +> TODO: Iterable inputs that can be used to generate `tasks` for multiple sequential runs. + +## `tools` +Tools are implementations from useful third party libraries that are provided +to Agents in the user's project. Configuration, dependency management, and wrapper +implementations are provided by AgentStack. Tools implemented at this level are +framework-agnostic and expose useful implementations as `callable`s for agents to use. + +Details of tool implementations are documented in the [Tools](#tools-1) section. + +> TODO: Some frameworks use decorators to prepare tools for use by agents. Determine +what that actually does and if we can do it in an agnostic way that avoids the +need for decorators. Fundamentally a tool is just a `callable` to so we should +be able to keep this simple. + +## `templates` +Templates are configuration data stored in a JSON file that can be used to +generate an entire project. This is useful for bootstrapping a new project +which adheres to a common pattern or exporting your project to share. + +> TODO: Templates are currently identified as `proj_templates` since they conflict +with the templates used by `generation`. Move existing templates to be part of +the generation package. + +## `log` +AgentStack provides a robust logging interface for tracking and debugging +agentic workflows. Runs are separated into separate named files for easy tracking +and have standard conventions for outputs from different parts of the system +for parsing. + +> TODO: Logging is not yet this robust. +> TODO: Rename `logging` to `log` for brevity. + +## `serve` +Completed agents can be deployed to the AgentStack cloud service with a single +command. This provides a fast, secure, and publicly available interface for your +agentic workflows. + +> TODO: This is under development. + +## `cli` +The command line interface for `agentstack` is provided in this package. Outside +of `main.py` all logic relating to the command line interface resides here. + +> TODO: Code from other parts of the application should always throw exceptions +and leave the CLI to handle error messaging and control flow. + +## `packaging` +We manage dependencies for tools that are added to the project, in addition +to keeping AgentStack up-to-date. + +> TODO: Migrate packaging to use `uv` exclusively. + +## `update` +Auto-updates for AgentStack. + + +# Tools +> TODO: Tools should be documented here, or in sub-pages of documentation for +an overview of their usage. + +# Generation +AgentStack generates code for a number of frameworks. The generated code is +a starting point for a user's project and is meant to be modified and extended +to suit the user's needs. + +## `generation.agents` +This is code that creates and modifies the `agents` in a user's project. Agents +include code that is part of a framework-specific entrypoint file. + +> TODO: Rename `generation.agent_generation` to `generation.agents`. + +## `generation.tasks` +This is code that creates and modifies the `tasks` in a user's project. Tasks +include code that is part of a framework-specific entrypoint file. + +> TODO: Rename `generation.task_generation` to `generation.tasks`. + +## `generation.tools` +This is code that creates and modifies the `tools` in a user's project. Tools +are imported into the project and available for use by `agents`. + +> TODO: Rename `generation.tool_generation` to `generation.tools`. + +## `generation.files` +This is code that creates and modifies the `files` in a user's project. + +### `agentstack.json` +This is the configuration file for a user's project. It contains the project's +configuration and metadata. + +> TODO This could be moved from `generation.files` to `agentstack.config` since +it's technically a global concept. + +### `.env` +This is the environment file for a user's project. It contains the project's +environment variables. We dynamically modify this file to include relevant +variables to support `tools` that are used in the project. + +## `generation.asttools` +Since we're interacting with generated code, we provide a shared toolkit for +common AST operations. + + +# Frameworks +AgentStack generates code for a number of frameworks. The generated code is +a starting point for a user's project and is meant to be modified and extended +to suit the user's needs. The `frameworks` package contains code that adapts +general interactions with a framework into a specific implementation. + +## `frameworks.FrameworkModule` +This is the base protocol for all framework implementationsโ€“ all implementations +must implement this protocol. + +## `frameworks.crewai` +This is the implementation for the CrewAI framework. CrewAI is a framework for +creating and managing AI agents. All code related specifically to CrewAI is +contained in this package. + +## `frameworks.openai_swarms` +> TODO: Add OpenAI Swarms as a framework. + +## `frameworks.agency_swarm` +> TODO: Add [VRSEN Agency Swarm](https://github.com/VRSEN/agency-swarm?tab=readme-ov-file) as a framework. + +## `frameworks.langgraph` +> TODO Add [LangGraph](https://langchain-ai.github.io/langgraph/) as a framework. \ No newline at end of file From 2e27aa032d80401197cc6cfd295a8a0bb990e29c Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 13 Dec 2024 14:42:04 -0800 Subject: [PATCH 16/30] Update project structure docs with progress made and future plans --- docs/contributing/project-structure.mdx | 134 ++++++++++++++++++++---- 1 file changed, 115 insertions(+), 19 deletions(-) diff --git a/docs/contributing/project-structure.mdx b/docs/contributing/project-structure.mdx index 4e201123..df5db5ac 100644 --- a/docs/contributing/project-structure.mdx +++ b/docs/contributing/project-structure.mdx @@ -30,54 +30,113 @@ We don't implement all of the functionality provided by a framework, but instead leverage them to create agentic workflows and provide tooling to aid in their creation and operation. [Documented in Frameworks](#frameworks-1) +## Runtime +When a user initiates `agentstack run` the runtime is the environment that is +created to execute the tasks in the project. This includes the environment +variables, the tools that are available, and the agents that are available to +perform work. The [Public API](#public-api) is available to the user's project +at runtime. + +### Environment +The environment is the set of variables that are available to the project. The +user's `~/.env` file is loaded first, and then the project's `.env` file is loaded +to override any variables specific to the project. + # Public API -The public API is available inside of a project after declaring `import agentstack` +The public API is available inside of a project after declaring `import agentstack`. +We intentionally keep the exports sparse to maintain a usable module tree inside +the user's project, while only ever importing the single keyword. -## `agentstack.PATH` +## `agentstack.conf.PATH` `` This is the path to the current project directory. -> TODO: The current project `Path` can be accessed throughout the application -with `agentstack.PATH`. +## `agentstack.tools[]` +`` This is a tool that is available to agents in the project. Tools +are implementations from useful third party libraries that are provided to Agents +in the user's project. Configuration, dependency management, and wrapper +implementations are provided by AgentStack. Tools implemented at this level are +framework-agnostic and expose useful implementations as `callable`s for agents to +including docstrings and type hints. -## `agentstack.FRAMEWORK` -`` This is the name of the current framework. If the application -is not being run from a project directory, this will be `None`. +> TODO: The public tools API is not yet implemented. -> TODO: The current framework name can be accessed throughout the application, -if it is being executed within a project directory, with `agentstack.FRAMEWORK`. -If not inside a project directory, the framework will be `None`; this can be used -to determine if the application has a project available. +## `agentstack.get_framework()` +`` This is the name of the current framework ie. `"crewai"`. ## `agentstack.get_inputs()` `` This function returns the inputs for a project. These are the variables that can be used to configure tasks in the project and are stored in the `inputs.yaml` file inside the project directory. +## `agentstack.get_tags()` +`` This function returns the tags for a project. These are strings +that help identify the workflow in an `AgentOps` observability context. # Core These namespaces occupy the root of `agentstack` and are shared across all project & frameworks. Methods from these products are generally candidates for availability in the public API for use within a project. + ## `agents` Agents are the actual personalities that accomplish work. We provide tools for interacting with the `agents.yaml` configuration file in this package. +### `AgentConfig.__init__(name: str)` +`` Initialize an `AgentConfig` to read and modify `agents.yaml` in +the current project. + +### `agents.get_all_agent_names()` +`` This function returns a list of all the agent names in the project. + +### `agents.get_all_agents()` +`` This function returns a list of all the agents in the project. + + ## `tasks` Tasks are the individual units of work that an Agent can perform. `agents` will use the `tools` they have available to accomplish `tasks`. We provide tools for interacting with the `tasks.yaml` configuration file in this package. +### `TaskConfig.__init__(name: str)` +`` Initialize a `TaskConfig` to read and modify `tasks.yaml` in the +current project. + +### `tasks.get_all_task_names()` +`` This function returns a list of all the task names in the project. + +### `tasks.get_all_tasks()` +`` This function returns a list of all the tasks in the project. + + ## `inputs` Inputs are variable data that can be used to configure `tasks`. Behind the scenes `inputs` are interpolated into `task` prompts to determine their specialization. We provide tools for interacting with the `inputs.yaml` configuration file in this package. -> TODO: Command line argument parsing to select which `inputs` to use or provide overrides. > TODO: Iterable inputs that can be used to generate `tasks` for multiple sequential runs. +### `InputsConfig.__init__(name: str)` +`` Initialize an `InputsConfig` to read and modify `inputs.yaml` in +the current project. + +#### `InputsConfig.__getitem__(key: str)` +`` Instance method to get the value of an input from the `inputs.yaml` file. + +#### `InputsConfig.__setitem__(key: str, value: str)` +`` Instance method to set the value of an input in the `inputs.yaml` file. + +### `inputs.get_inputs()` +`` This function returns the inputs for a project. + +### `inputs.add_input_for_run(key: str, value: str)` +`` This function adds an input for a run to the `inputs.yaml` file. A run +is the current execution of the `agentstack` command (ie. `agentstack run --inputs-foo=bar`) +and inputs set here will not be saved to the project state. + + ## `tools` Tools are implementations from useful third party libraries that are provided to Agents in the user's project. Configuration, dependency management, and wrapper @@ -90,16 +149,60 @@ Details of tool implementations are documented in the [Tools](#tools-1) section. what that actually does and if we can do it in an agnostic way that avoids the need for decorators. Fundamentally a tool is just a `callable` to so we should be able to keep this simple. +> TODO: Expose tools to the user's project through `agentstack.tools['tool_name']` +which dynamically generates the wrapped tool based on the active framework. ## `templates` Templates are configuration data stored in a JSON file that can be used to generate an entire project. This is useful for bootstrapping a new project which adheres to a common pattern or exporting your project to share. +Templates are versioned, and each previous version provides a method to convert +it's content to the current version. + > TODO: Templates are currently identified as `proj_templates` since they conflict with the templates used by `generation`. Move existing templates to be part of the generation package. +### `TemplateConfig.from_template_name(name: str)` +`` This function returns a `TemplateConfig` object for a given +template name. + +### `TemplateConfig.from_file(path: Path)` +`` This function returns a `TemplateConfig` object for a given +template file path. + +### `TemplateConfig.from_url(url: str)` +`` This function returns a `TemplateConfig` object after loading +data from a URL. + +### `TemplateConfig.from_json(data: dict)` +`` This function returns a `TemplateConfig` object from a parsed +JSON object. + +### `TemplateConfig.write_to_file(filename: Path)` +`` Instance method to serialize and write the `TemplateConfig` data to a file. + +### `templates.get_all_template_paths()` +`` This function returns a list of all the template paths in the project. + +### `templates.get_all_template_names()` +`` This function returns a list of all the template names in the project. + +### `templates.get_all_templates()` +`` This function returns a list of all the templates in the +project as `TemplateConfig` objects. + + +## `conf` +Configuration data for the AgentStack application. This includes the path to the +current project directory and the name of the current framework. + +### `agentstack.json` +This is the configuration file for a user's project. It contains the project's +configuration and metadata. It can be read and modified directly by accessing +`conf.ConfigFile`. + ## `log` AgentStack provides a robust logging interface for tracking and debugging agentic workflows. Runs are separated into separate named files for easy tracking @@ -163,13 +266,6 @@ are imported into the project and available for use by `agents`. ## `generation.files` This is code that creates and modifies the `files` in a user's project. -### `agentstack.json` -This is the configuration file for a user's project. It contains the project's -configuration and metadata. - -> TODO This could be moved from `generation.files` to `agentstack.config` since -it's technically a global concept. - ### `.env` This is the environment file for a user's project. It contains the project's environment variables. We dynamically modify this file to include relevant From b716ec8707af17aa779881246820ed620b733b49 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Thu, 19 Dec 2024 22:33:53 -0800 Subject: [PATCH 17/30] Update v0.3 roadmap. --- docs/contributing/project-structure.mdx | 39 +++++++------------------ 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/docs/contributing/project-structure.mdx b/docs/contributing/project-structure.mdx index df5db5ac..12ea17e3 100644 --- a/docs/contributing/project-structure.mdx +++ b/docs/contributing/project-structure.mdx @@ -30,6 +30,11 @@ We don't implement all of the functionality provided by a framework, but instead leverage them to create agentic workflows and provide tooling to aid in their creation and operation. [Documented in Frameworks](#frameworks-1) +## Tools +Tools are implementations from useful third party libraries that are provided +to Agents in the user's project. AgentStack handles implementation details and +dependency management for these tools. [Documented in Tools](#tools-1) + ## Runtime When a user initiates `agentstack run` the runtime is the environment that is created to execute the tasks in the project. This includes the environment @@ -57,9 +62,7 @@ are implementations from useful third party libraries that are provided to Agent in the user's project. Configuration, dependency management, and wrapper implementations are provided by AgentStack. Tools implemented at this level are framework-agnostic and expose useful implementations as `callable`s for agents to -including docstrings and type hints. - -> TODO: The public tools API is not yet implemented. +use including docstrings and type hints for argument and return types. ## `agentstack.get_framework()` `` This is the name of the current framework ie. `"crewai"`. @@ -137,21 +140,6 @@ is the current execution of the `agentstack` command (ie. `agentstack run --inpu and inputs set here will not be saved to the project state. -## `tools` -Tools are implementations from useful third party libraries that are provided -to Agents in the user's project. Configuration, dependency management, and wrapper -implementations are provided by AgentStack. Tools implemented at this level are -framework-agnostic and expose useful implementations as `callable`s for agents to use. - -Details of tool implementations are documented in the [Tools](#tools-1) section. - -> TODO: Some frameworks use decorators to prepare tools for use by agents. Determine -what that actually does and if we can do it in an agnostic way that avoids the -need for decorators. Fundamentally a tool is just a `callable` to so we should -be able to keep this simple. -> TODO: Expose tools to the user's project through `agentstack.tools['tool_name']` -which dynamically generates the wrapped tool based on the active framework. - ## `templates` Templates are configuration data stored in a JSON file that can be used to generate an entire project. This is useful for bootstrapping a new project @@ -209,9 +197,6 @@ agentic workflows. Runs are separated into separate named files for easy trackin and have standard conventions for outputs from different parts of the system for parsing. -> TODO: Logging is not yet this robust. -> TODO: Rename `logging` to `log` for brevity. - ## `serve` Completed agents can be deployed to the AgentStack cloud service with a single command. This provides a fast, secure, and publicly available interface for your @@ -227,10 +212,8 @@ of `main.py` all logic relating to the command line interface resides here. and leave the CLI to handle error messaging and control flow. ## `packaging` -We manage dependencies for tools that are added to the project, in addition -to keeping AgentStack up-to-date. - -> TODO: Migrate packaging to use `uv` exclusively. +We manage the virtual environment and dependencies for tools that are added to +the project, in addition to keeping AgentStack up-to-date. ## `update` Auto-updates for AgentStack. @@ -291,11 +274,11 @@ This is the implementation for the CrewAI framework. CrewAI is a framework for creating and managing AI agents. All code related specifically to CrewAI is contained in this package. +## `frameworks.langgraph` +> TODO Add [LangGraph](https://langchain-ai.github.io/langgraph/) as a framework. + ## `frameworks.openai_swarms` > TODO: Add OpenAI Swarms as a framework. ## `frameworks.agency_swarm` > TODO: Add [VRSEN Agency Swarm](https://github.com/VRSEN/agency-swarm?tab=readme-ov-file) as a framework. - -## `frameworks.langgraph` -> TODO Add [LangGraph](https://langchain-ai.github.io/langgraph/) as a framework. \ No newline at end of file From a0983d360fd85a9c3cad1fcb5ae6f6799a2b80dd Mon Sep 17 00:00:00 2001 From: Braelyn Boynton Date: Wed, 25 Dec 2024 19:52:53 -0500 Subject: [PATCH 18/30] telem with user token --- agentstack/telemetry.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/agentstack/telemetry.py b/agentstack/telemetry.py index 3659e4b6..92cc43ae 100644 --- a/agentstack/telemetry.py +++ b/agentstack/telemetry.py @@ -28,6 +28,7 @@ import psutil import requests from agentstack import conf +from agentstack.auth import get_stored_token from agentstack.utils import get_telemetry_opt_out, get_framework, get_version TELEMETRY_URL = 'https://api.agentstack.sh/telemetry' @@ -77,7 +78,12 @@ def collect_machine_telemetry(command: str): def track_cli_command(command: str, args: Optional[str] = None): try: data = collect_machine_telemetry(command) - return requests.post(TELEMETRY_URL, json={"command": command, "args":args, **data}).json().get('id') + headers = {} + token = get_stored_token() + if token: + headers['Authorization'] = f'Bearer {token}' + + return requests.post(TELEMETRY_URL, json={"command": command, "args":args, **data}, headers=headers).json().get('id') except Exception: pass From eef36ad93b10f4f0325f4ded2efc7ee7cf27d0f9 Mon Sep 17 00:00:00 2001 From: Tommy Bui Nguyen Date: Sun, 29 Dec 2024 10:39:20 -0500 Subject: [PATCH 19/30] update footer social links to point to agentstack socials --- docs/mint.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/mint.json b/docs/mint.json index 0b0e3fd4..d6263701 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -78,7 +78,7 @@ } ], "footerSocials": { - "x": "https://x.com/braelyn_ai", - "github": "https://github.com/bboynton97" + "x": "https://x.com/agentopsai", + "github": "https://github.com/AgentOps-AI" } } From eae05ba5b648f4326408b4770bf8f606b49c53be Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Mon, 30 Dec 2024 16:09:17 -0800 Subject: [PATCH 20/30] Merge regression --- tests/test_cli_loads.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_cli_loads.py b/tests/test_cli_loads.py index 84b22f93..09593db0 100644 --- a/tests/test_cli_loads.py +++ b/tests/test_cli_loads.py @@ -35,8 +35,8 @@ def test_run_command_invalid_project(self): os.chdir(test_dir) result = run_cli('run') - self.assertNotEqual(result.returncode, 0) - self.assertIn("An error occurred", result.stdout) + self.assertEqual(result.returncode, 1) + self.assertIn("An error occurred", result.stderr) shutil.rmtree(test_dir, ignore_errors=True) From 39e75baeb4a58994bc10023bd0777412034cd341 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Mon, 30 Dec 2024 18:23:19 -0800 Subject: [PATCH 21/30] Missing imports --- agentstack/tools.py | 1 + agentstack/update.py | 1 + 2 files changed, 2 insertions(+) diff --git a/agentstack/tools.py b/agentstack/tools.py index 3794f0bc..1feec0af 100644 --- a/agentstack/tools.py +++ b/agentstack/tools.py @@ -3,6 +3,7 @@ import sys from pathlib import Path import pydantic +from agentstack.exceptions import ValidationError from agentstack.utils import get_package_path, open_json_file, term_color diff --git a/agentstack/update.py b/agentstack/update.py index dd67c422..f08859ae 100644 --- a/agentstack/update.py +++ b/agentstack/update.py @@ -4,6 +4,7 @@ from pathlib import Path from packaging.version import parse as parse_version, Version import inquirer +from agentstack import log from agentstack.utils import term_color, get_version, get_framework from agentstack import packaging from appdirs import user_data_dir From bfe6b0f1253763bd05bbcc72f83771753d0a6afc Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Mon, 30 Dec 2024 18:36:07 -0800 Subject: [PATCH 22/30] Correct main interaction to support installed binary (`main.main` gets imported and called instead of running main as a module) --- agentstack/main.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/agentstack/main.py b/agentstack/main.py index 13c263d1..c48b7013 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -18,7 +18,7 @@ from agentstack.update import check_for_updates -def main(): +def _main(): global_parser = argparse.ArgumentParser(add_help=False) global_parser.add_argument( "--path", @@ -155,7 +155,7 @@ def main(): # Handle version if args.version: print(f"AgentStack CLI version: {get_version()}") - sys.exit(0) + return telemetry_id = track_cli_command(args.command, " ".join(sys.argv[1:])) check_for_updates(update_requested=args.command in ('update', 'u')) @@ -207,18 +207,28 @@ def main(): update_telemetry(telemetry_id, result=0) -if __name__ == "__main__": +def main() -> int: + """ + Main entry point for the AgentStack CLI. + + This function is called when the `agentstack` command is run from the terminal. + ``` + from agentstack.main import main + sys.exit(main()) + ``` + """ # display logging messages in the console log.set_stdout(sys.stdout) log.set_stderr(sys.stderr) try: - main() + _main() + return 0 except Exception as e: log.error((f"An error occurred: {e}\n" "Run again with --debug for more information.")) log.debug("Full traceback:", exc_info=e) - sys.exit(1) + return 1 except KeyboardInterrupt: # Handle Ctrl+C (KeyboardInterrupt) print("\nTerminating AgentStack CLI") - sys.exit(1) + return 1 From 295b573a27a141e642f6c9ba198e164c01d43e3b Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Tue, 31 Dec 2024 10:25:18 -0800 Subject: [PATCH 23/30] Prevent loggers from other modules from affecting the agenrstack logger. Improve cleanliness of messaging. --- agentstack/log.py | 1 + agentstack/main.py | 6 ++++-- agentstack/utils.py | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/agentstack/log.py b/agentstack/log.py index 70d99124..9e515018 100644 --- a/agentstack/log.py +++ b/agentstack/log.py @@ -153,6 +153,7 @@ def _build_logger() -> logging.Logger: # global stdout, stderr log = logging.getLogger(LOG_NAME) + log.propagate = False # prevent inheritance from the root logger # min log level set here cascades to all handlers log.setLevel(DEBUG if conf.DEBUG else INFO) diff --git a/agentstack/main.py b/agentstack/main.py index c48b7013..11681625 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -154,7 +154,7 @@ def _main(): # Handle version if args.version: - print(f"AgentStack CLI version: {get_version()}") + log.info(f"AgentStack CLI version: {get_version()}") return telemetry_id = track_cli_command(args.command, " ".join(sys.argv[1:])) @@ -225,7 +225,9 @@ def main() -> int: _main() return 0 except Exception as e: - log.error((f"An error occurred: {e}\n" "Run again with --debug for more information.")) + log.error(f"An error occurred: \n{e}") + if not conf.DEBUG: + log.info("Run again with --debug for more information.") log.debug("Full traceback:", exc_info=e) return 1 except KeyboardInterrupt: diff --git a/agentstack/utils.py b/agentstack/utils.py index a6310579..d68df3be 100644 --- a/agentstack/utils.py +++ b/agentstack/utils.py @@ -22,8 +22,8 @@ def verify_agentstack_project(): agentstack_config = conf.ConfigFile() except FileNotFoundError: raise Exception( - "Error: This does not appear to be an AgentStack project.\n" - "Please ensure you're at the root directory of your project and a file named agentstack.json exists. " + "This does not appear to be an AgentStack project.\n" + "Please ensure you're at the root directory of your project and a file named agentstack.json exists.\n" "If you're starting a new project, run `agentstack init`." ) From 4e80428defdc71ff9cbded0e6b8a1b95ba499d0d Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Tue, 31 Dec 2024 10:25:50 -0800 Subject: [PATCH 24/30] Resolve #175 --- agentstack/cli/run.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/agentstack/cli/run.py b/agentstack/cli/run.py index 707c6ecf..70d4bc48 100644 --- a/agentstack/cli/run.py +++ b/agentstack/cli/run.py @@ -9,7 +9,7 @@ from agentstack.exceptions import ValidationError from agentstack import inputs from agentstack import frameworks -from agentstack.utils import term_color, get_framework +from agentstack.utils import term_color, get_framework, verify_agentstack_project MAIN_FILENAME: Path = Path("src/main.py") MAIN_MODULE_NAME = "main" @@ -95,6 +95,8 @@ def _import_project_module(path: Path): def run_project(command: str = 'run', cli_args: Optional[str] = None): """Validate that the project is ready to run and then run it.""" + verify_agentstack_project() + if conf.get_framework() not in frameworks.SUPPORTED_FRAMEWORKS: raise ValidationError(f"Framework {conf.get_framework()} is not supported by agentstack.") From 29a562d95318c1cd043561c3646a2d2be82e2cfa Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Tue, 31 Dec 2024 10:44:25 -0800 Subject: [PATCH 25/30] Fix main entrypoint to have congruency between module usage and bin script usage --- agentstack/main.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/agentstack/main.py b/agentstack/main.py index 11681625..eaffac0e 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -210,12 +210,6 @@ def _main(): def main() -> int: """ Main entry point for the AgentStack CLI. - - This function is called when the `agentstack` command is run from the terminal. - ``` - from agentstack.main import main - sys.exit(main()) - ``` """ # display logging messages in the console log.set_stdout(sys.stdout) @@ -234,3 +228,11 @@ def main() -> int: # Handle Ctrl+C (KeyboardInterrupt) print("\nTerminating AgentStack CLI") return 1 + + +if __name__ == "__main__": + # Note that since we primarily interact with the CLI through a bin, all logic + # needs to reside within the main() function. + # This module syntax is typically only used by tests. + # see `project.scripts.agentstack` in pyproject.toml for the bin config. + sys.exit(main()) From 6081e041217c93b6843a073dbbbac870a6488d11 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Tue, 31 Dec 2024 13:24:27 -0800 Subject: [PATCH 26/30] Comments cleanup --- agentstack/log.py | 28 +++++++++++++++++----------- agentstack/main.py | 4 ++-- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/agentstack/log.py b/agentstack/log.py index 9e515018..a2ebd742 100644 --- a/agentstack/log.py +++ b/agentstack/log.py @@ -11,7 +11,10 @@ WARNING: An indication that something unexpected happened, but not severe. ERROR: An indication that something went wrong, and the application may not be able to continue. -TODO TOOL_USE and THINKING are below INFO; this is intentional for now. +TODO when running commands outside of a project directory, the log file +is created in the current working directory. +State changes, like going from a pre-initialized project to a valid project, +should trigger a re-initialization of the logger. TODO would be cool to intercept all messages from the framework and redirect them through this logger. This would allow us to capture all messages and display @@ -157,16 +160,19 @@ def _build_logger() -> logging.Logger: # min log level set here cascades to all handlers log.setLevel(DEBUG if conf.DEBUG else INFO) - # `conf.PATH`` can change during startup, so defer building the path - log_filename = conf.PATH / LOG_FILENAME - if not os.path.exists(log_filename): - os.makedirs(log_filename.parent, exist_ok=True) - log_filename.touch() - - file_handler = logging.FileHandler(log_filename) - file_handler.setFormatter(FileFormatter()) - file_handler.setLevel(DEBUG) - log.addHandler(file_handler) + try: + # `conf.PATH` can change during startup, so defer building the path + log_filename = conf.PATH / LOG_FILENAME + if not os.path.exists(log_filename): + os.makedirs(log_filename.parent, exist_ok=True) + log_filename.touch() + + file_handler = logging.FileHandler(log_filename) + file_handler.setFormatter(FileFormatter()) + file_handler.setLevel(DEBUG) + log.addHandler(file_handler) + except FileNotFoundError: + pass # we are not in a writeable directory # stdout handler for warnings and below # `stdout` can change, so defer building the stream until we need it diff --git a/agentstack/main.py b/agentstack/main.py index eaffac0e..27ee9a71 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -232,7 +232,7 @@ def main() -> int: if __name__ == "__main__": # Note that since we primarily interact with the CLI through a bin, all logic - # needs to reside within the main() function. - # This module syntax is typically only used by tests. + # needs to reside within the main() function. + # Module syntax is typically only used by tests. # see `project.scripts.agentstack` in pyproject.toml for the bin config. sys.exit(main()) From 7b4bc548fabbe59ac4c753039e84e6383474ebbe Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 10 Jan 2025 10:08:34 -0800 Subject: [PATCH 27/30] Only write to log files that already exist. This prevents us from pre-initializing a project directory befor einit has run, and also saves us from littering agentstack.log files outside of project directories. --- agentstack/log.py | 5 +---- .../agentstack.log | 0 2 files changed, 1 insertion(+), 4 deletions(-) create mode 100644 agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/agentstack.log diff --git a/agentstack/log.py b/agentstack/log.py index a2ebd742..af3ca697 100644 --- a/agentstack/log.py +++ b/agentstack/log.py @@ -162,11 +162,8 @@ def _build_logger() -> logging.Logger: try: # `conf.PATH` can change during startup, so defer building the path + # log file only gets written to if it exists, which happens on project init log_filename = conf.PATH / LOG_FILENAME - if not os.path.exists(log_filename): - os.makedirs(log_filename.parent, exist_ok=True) - log_filename.touch() - file_handler = logging.FileHandler(log_filename) file_handler.setFormatter(FileFormatter()) file_handler.setLevel(DEBUG) diff --git a/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/agentstack.log b/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/agentstack.log new file mode 100644 index 00000000..e69de29b From b6790fdbeb93468b84ec914f886c074f0205f21f Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 10 Jan 2025 10:15:36 -0800 Subject: [PATCH 28/30] Migrate print statements to use agentstack.log --- agentstack/auth.py | 4 ++-- agentstack/cli/cli.py | 24 +++++++++--------------- agentstack/cli/init.py | 27 ++++++++++++--------------- agentstack/packaging.py | 22 +++++++++++----------- 4 files changed, 34 insertions(+), 43 deletions(-) diff --git a/agentstack/auth.py b/agentstack/auth.py index 7d9d0363..1daa980b 100644 --- a/agentstack/auth.py +++ b/agentstack/auth.py @@ -95,7 +95,7 @@ def login(): # check if already logged in token = get_stored_token() if token: - print("You are already authenticated!") + log.success("You are already authenticated!") if not inquirer.confirm('Would you like to log in with a different account?'): return @@ -120,7 +120,7 @@ def login(): server.shutdown() server_thread.join() - print("๐Ÿ” Authentication successful! Token has been stored.") + log.success("๐Ÿ” Authentication successful! Token has been stored.") return True except Exception as e: diff --git a/agentstack/cli/cli.py b/agentstack/cli/cli.py index c6865319..7b470bf1 100644 --- a/agentstack/cli/cli.py +++ b/agentstack/cli/cli.py @@ -115,7 +115,7 @@ def init_project_builder( def welcome_message(): - os.system("cls" if os.name == "nt" else "clear") + #os.system("cls" if os.name == "nt" else "clear") title = text2art("AgentStack", font="smisome1") tagline = "The easiest way to build a robust agent application!" border = "-" * len(tagline) @@ -191,16 +191,13 @@ def get_validated_input( snake_case: Whether to enforce snake_case naming """ while True: - try: - value = inquirer.text( - message=message, - validate=validate_func or validator_not_empty(min_length) if min_length else None, - ) - if snake_case and not is_snake_case(value): - raise ValidationError("Input must be in snake_case") - return value - except ValidationError as e: - print(term_color(f"Error: {str(e)}", 'red')) + value = inquirer.text( + message=message, + validate=validate_func or validator_not_empty(min_length) if min_length else None, + ) + if snake_case and not is_snake_case(value): + raise ValidationError("Input must be in snake_case") + return value def ask_agent_details(): @@ -403,10 +400,7 @@ def insert_template( f'{template_path}/{"{{cookiecutter.project_metadata.project_slug}}"}/.env.example', f'{template_path}/{"{{cookiecutter.project_metadata.project_slug}}"}/.env', ) - - if os.path.exists(project_details['name']): - raise Exception(f"Directory {project_details['name']} already exists. Project directory must not exist.") - + cookiecutter(str(template_path), no_input=True, extra_context=None) # TODO: inits a git repo in the directory the command was run in diff --git a/agentstack/cli/init.py b/agentstack/cli/init.py index 6fd546de..a9d150a8 100644 --- a/agentstack/cli/init.py +++ b/agentstack/cli/init.py @@ -1,7 +1,8 @@ import os, sys from typing import Optional from pathlib import Path -from agentstack import conf +from agentstack import conf, log +from agentstack.exceptions import EnvironmentError from agentstack import packaging from agentstack.cli import welcome_message, init_project_builder from agentstack.utils import term_color @@ -15,14 +16,14 @@ def require_uv(): uv_bin = packaging.get_uv_bin() assert os.path.exists(uv_bin) except (AssertionError, ImportError): - print(term_color("Error: uv is not installed.", 'red')) - print("Full installation instructions at: https://docs.astral.sh/uv/getting-started/installation") + message = "Error: uv is not installed." + message += "Full installation instructions at: https://docs.astral.sh/uv/getting-started/installation" match sys.platform: case 'linux' | 'darwin': - print("Hint: run `curl -LsSf https://astral.sh/uv/install.sh | sh`") + messsage += "Hint: run `curl -LsSf https://astral.sh/uv/install.sh | sh`" case _: pass - sys.exit(1) + raise EnvironmentError(message) def init_project( @@ -43,26 +44,22 @@ def init_project( if slug_name: conf.set_path(conf.PATH / slug_name) else: - print("Error: No project directory specified.") - print("Run `agentstack init `") - sys.exit(1) + raise Exception("Error: No project directory specified.\n Run `agentstack init `") if os.path.exists(conf.PATH): # cookiecutter requires the directory to not exist - print(f"Error: Directory already exists: {conf.PATH}") - sys.exit(1) + raise Exception(f"Error: Directory already exists: {conf.PATH}") welcome_message() - print(term_color("๐Ÿฆพ Creating a new AgentStack project...", 'blue')) - print(f"Using project directory: {conf.PATH.absolute()}") + log.notify("๐Ÿฆพ Creating a new AgentStack project...") + log.info(f"Using project directory: {conf.PATH.absolute()}") # copy the project skeleton, create a virtual environment, and install dependencies init_project_builder(slug_name, template, use_wizard) packaging.create_venv() packaging.install_project() - print( - "\n" - "๐Ÿš€ \033[92mAgentStack project generated successfully!\033[0m\n\n" + log.success("๐Ÿš€ AgentStack project generated successfully!\n") + log.info( " To get started, activate the virtual environment with:\n" f" cd {conf.PATH}\n" " source .venv/bin/activate\n\n" diff --git a/agentstack/packaging.py b/agentstack/packaging.py index b472a51e..2b1104bf 100644 --- a/agentstack/packaging.py +++ b/agentstack/packaging.py @@ -4,7 +4,7 @@ import re import subprocess import select -from agentstack import conf +from agentstack import conf, log DEFAULT_PYTHON_VERSION = "3.12" @@ -25,10 +25,10 @@ def install(package: str): def on_progress(line: str): if RE_UV_PROGRESS.match(line): - print(line.strip()) + log.info(line.strip()) def on_error(line: str): - print(f"uv: [error]\n {line.strip()}") + log.error(f"uv: [error]\n {line.strip()}") _wrap_command_with_callbacks( [get_uv_bin(), 'add', '--python', '.venv/bin/python', package], @@ -42,10 +42,10 @@ def install_project(): def on_progress(line: str): if RE_UV_PROGRESS.match(line): - print(line.strip()) + log.info(line.strip()) def on_error(line: str): - print(f"uv: [error]\n {line.strip()}") + log.error(f"uv: [error]\n {line.strip()}") _wrap_command_with_callbacks( [get_uv_bin(), 'pip', 'install', '--python', '.venv/bin/python', '.'], @@ -60,10 +60,10 @@ def remove(package: str): # TODO it may be worth considering removing unused sub-dependencies as well def on_progress(line: str): if RE_UV_PROGRESS.match(line): - print(line.strip()) + log.info(line.strip()) def on_error(line: str): - print(f"uv: [error]\n {line.strip()}") + log.error(f"uv: [error]\n {line.strip()}") _wrap_command_with_callbacks( [get_uv_bin(), 'remove', '--python', '.venv/bin/python', package], @@ -78,10 +78,10 @@ def upgrade(package: str): # TODO should we try to update the project's pyproject.toml as well? def on_progress(line: str): if RE_UV_PROGRESS.match(line): - print(line.strip()) + log.info(line.strip()) def on_error(line: str): - print(f"uv: [error]\n {line.strip()}") + log.error(f"uv: [error]\n {line.strip()}") _wrap_command_with_callbacks( [get_uv_bin(), 'pip', 'install', '-U', '--python', '.venv/bin/python', package], @@ -99,10 +99,10 @@ def create_venv(python_version: str = DEFAULT_PYTHON_VERSION): def on_progress(line: str): if RE_VENV_PROGRESS.match(line): - print(line.strip()) + log.info(line.strip()) def on_error(line: str): - print(f"uv: [error]\n {line.strip()}") + log.error(f"uv: [error]\n {line.strip()}") _wrap_command_with_callbacks( [get_uv_bin(), 'venv', '--python', python_version], From 75b94114271b00e9338220284aa2032fdfed1932 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 10 Jan 2025 10:16:22 -0800 Subject: [PATCH 29/30] Typo. --- agentstack/cli/init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentstack/cli/init.py b/agentstack/cli/init.py index a9d150a8..14e1ad58 100644 --- a/agentstack/cli/init.py +++ b/agentstack/cli/init.py @@ -20,7 +20,7 @@ def require_uv(): message += "Full installation instructions at: https://docs.astral.sh/uv/getting-started/installation" match sys.platform: case 'linux' | 'darwin': - messsage += "Hint: run `curl -LsSf https://astral.sh/uv/install.sh | sh`" + message += "Hint: run `curl -LsSf https://astral.sh/uv/install.sh | sh`" case _: pass raise EnvironmentError(message) From 718cd28a7eed0812785422b92c9e95e311ea5f95 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 10 Jan 2025 10:18:10 -0800 Subject: [PATCH 30/30] Error message newlines. --- agentstack/cli/init.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/agentstack/cli/init.py b/agentstack/cli/init.py index 14e1ad58..1e002fc0 100644 --- a/agentstack/cli/init.py +++ b/agentstack/cli/init.py @@ -16,11 +16,11 @@ def require_uv(): uv_bin = packaging.get_uv_bin() assert os.path.exists(uv_bin) except (AssertionError, ImportError): - message = "Error: uv is not installed." - message += "Full installation instructions at: https://docs.astral.sh/uv/getting-started/installation" + message = "Error: uv is not installed.\n" + message += "Full installation instructions at: https://docs.astral.sh/uv/getting-started/installation\n" match sys.platform: case 'linux' | 'darwin': - message += "Hint: run `curl -LsSf https://astral.sh/uv/install.sh | sh`" + message += "Hint: run `curl -LsSf https://astral.sh/uv/install.sh | sh`\n" case _: pass raise EnvironmentError(message)