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/auth.py b/agentstack/auth.py index a4fc46c0..1daa980b 100644 --- a/agentstack/auth.py +++ b/agentstack/auth.py @@ -9,7 +9,7 @@ import inquirer from appdirs import user_data_dir -from agentstack.logger import log +from agentstack import log try: @@ -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/agentstack_data.py b/agentstack/cli/agentstack_data.py index 0b7305a4..1520cb61 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 41770572..7b470bf1 100644 --- a/agentstack/cli/cli.py +++ b/agentstack/cli/cli.py @@ -16,8 +16,7 @@ ProjectStructure, CookiecutterData, ) -from agentstack.logger 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 @@ -60,14 +59,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 = { @@ -118,25 +115,26 @@ 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) # 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?", @@ -144,9 +142,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 @@ -172,7 +171,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 @@ -192,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(): @@ -331,10 +327,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 @@ -344,7 +340,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( @@ -404,16 +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.isdir(project_details['name']): - # print( - # term_color( - # f"Directory {template_path} already exists. Please check this and try again", - # "red", - # ) - # ) - # sys.exit(1) - + cookiecutter(str(template_path), no_input=True, extra_context=None) # TODO: inits a git repo in the directory the command was run in @@ -434,8 +421,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] = [] @@ -497,7 +483,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/init.py b/agentstack/cli/init.py index 6fd546de..1e002fc0 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.\n" + message += "Full installation instructions at: https://docs.astral.sh/uv/getting-started/installation\n" match sys.platform: case 'linux' | 'darwin': - print("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 - 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/cli/run.py b/agentstack/cli/run.py index bed89db6..70d4bc48 100644 --- a/agentstack/cli/run.py +++ b/agentstack/cli/run.py @@ -5,17 +5,17 @@ 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 -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" -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. @@ -68,7 +68,11 @@ def _format_friendy_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,17 @@ 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.""" + verify_agentstack_project() + 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 +111,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 +119,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_friendy_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/conf.py b/agentstack/conf.py index c92e49d9..4411aab4 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() def assert_project() -> None: @@ -24,6 +27,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/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 314ff481..11eac3fe 100644 --- a/agentstack/generation/tool_generation.py +++ b/agentstack/generation/tool_generation.py @@ -6,11 +6,11 @@ import ast from agentstack import conf +from agentstack import log from agentstack.conf import ConfigFile 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 @@ -83,7 +83,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 +97,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 +117,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 +139,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')) + 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 @@ -161,8 +160,4 @@ def remove_tool(tool_name: str, agents: Optional[list[str]] = []): with agentstack_config as config: config.tools.remove(tool.name) - print( - 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/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/log.py b/agentstack/log.py new file mode 100644 index 00000000..af3ca697 --- /dev/null +++ b/agentstack/log.py @@ -0,0 +1,189 @@ +""" +`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 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 +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', + 'tool_use', + 'thinking', + 'info', + 'notify', + 'success', + 'response', + 'warning', + 'error', +] + +LOG_NAME: str = 'agentstack' +LOG_FILENAME: str = 'agentstack.log' + +# define additional log levels to accommodate other messages inside the app +DEBUG = logging.DEBUG # 10 +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 + +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 handler for the given log level.""" + + 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) +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) + + +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')), + WARNING: logging.Formatter(term_color('%(message)s', 'yellow')), + ERROR: logging.Formatter(term_color('%(message)s', 'red')), + } + + def format(self, record: logging.LogRecord) -> str: + 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'), + } + + def format(self, record: logging.LogRecord) -> str: + template = self.formats.get(record.levelno, self.default_format) + return template.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 + + 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) + + 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 + 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 + 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 b725e079..6f86cd21 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -2,7 +2,8 @@ import argparse import webbrowser -from agentstack import conf, auth +from agentstack import conf, log +from agentstack import auth from agentstack.cli import ( init_project, add_tool, @@ -17,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", @@ -148,11 +149,13 @@ 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: - print(f"AgentStack CLI version: {get_version()}") - sys.exit(0) + log.info(f"AgentStack CLI version: {get_version()}") + return telemetry_id = track_cli_command(args.command, " ".join(sys.argv[1:])) check_for_updates(update_requested=args.command in ('update', 'u')) @@ -189,7 +192,7 @@ def main(): # inside project dir commands only elif args.command in ["run", "r"]: conf.assert_project() - 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']: conf.assert_project() if args.generate_command in ['agent', 'a']: @@ -208,16 +211,37 @@ def main(): 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")) raise e update_telemetry(telemetry_id, result=0) -if __name__ == "__main__": +def main() -> int: + """ + Main entry point for the AgentStack CLI. + """ + # 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: \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: # Handle Ctrl+C (KeyboardInterrupt) print("\nTerminating AgentStack CLI") - sys.exit(1) + 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. + # Module syntax is typically only used by tests. + # see `project.scripts.agentstack` in pyproject.toml for the bin config. + sys.exit(main()) 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], 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/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 diff --git a/agentstack/tools.py b/agentstack/tools.py index 1acb8d97..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 @@ -26,9 +27,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 +37,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 d26de18d..52d7aa3d 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 @@ -115,32 +116,24 @@ def check_for_updates(update_requested: bool = False): if not update_requested and not should_update(): return - print("Checking for updates...") + 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 a3c57988..d68df3be 100644 --- a/agentstack/utils.py +++ b/agentstack/utils.py @@ -14,7 +14,6 @@ def get_version(package: str = 'agentstack'): try: return version(package) except (KeyError, FileNotFoundError) as e: - print(e) return "Unknown version" @@ -22,12 +21,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( + "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`." ) - sys.exit(1) def get_package_path() -> Path: diff --git a/tests/test_cli_loads.py b/tests/test_cli_loads.py index 074e6caf..09593db0 100644 --- a/tests/test_cli_loads.py +++ b/tests/test_cli_loads.py @@ -13,9 +13,6 @@ class TestAgentStackCLI(unittest.TestCase): def test_version(self): """Test the --version command.""" result = run_cli("--version") - print(result.stdout) - print(result.stderr) - print(result.returncode) self.assertEqual(result.returncode, 0) self.assertIn("AgentStack CLI version:", result.stdout) @@ -38,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("Project validation failed", result.stdout) + self.assertEqual(result.returncode, 1) + self.assertIn("An error occurred", result.stderr) shutil.rmtree(test_dir, ignore_errors=True) 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 c650f0ac..92f1aa09 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', diff --git a/tests/test_log.py b/tests/test_log.py new file mode 100644 index 00000000..d058d56d --- /dev/null +++ b/tests/test_log.py @@ -0,0 +1,180 @@ +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) + + # Clear string IO buffers + self.stdout.close() + self.stderr.close() + + def test_debug_message(self): + log.debug("Debug message") + 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") + 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 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("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 message", 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_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.set_debug(False) + log.instance = None # Reset logger + log.debug("Hidden debug") + self.assertEqual("", self.stdout.getvalue()) + + # Test with debug enabled + conf.set_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_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 + + 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)