diff --git a/agentstack/cli/run.py b/agentstack/cli/run.py index a33b2f19..5a131e83 100644 --- a/agentstack/cli/run.py +++ b/agentstack/cli/run.py @@ -1,111 +1,12 @@ -from typing import Optional, List, Dict -import sys -import asyncio -import traceback -from pathlib import Path -import importlib.util -from dotenv import load_dotenv - +from typing import Optional from agentstack import conf, log -from agentstack.exceptions import ValidationError from agentstack import inputs -from agentstack import frameworks -from agentstack.utils import get_framework, verify_agentstack_project - -MAIN_FILENAME: Path = Path("src/main.py") -MAIN_MODULE_NAME = "main" - - -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. - - In order to prevent us from having to import all possible backend exceptions - we do string matching on the exception type and traceback contents. - """ - # TODO These end up being pretty framework-specific; consider individual implementations. - COMMON_LLM_ENV_VARS = ( - 'OPENAI_API_KEY', - 'ANTHROPIC_API_KEY', - ) - - name = exception.__class__.__name__ - message = str(exception) - tracebacks = traceback.format_exception(type(exception), exception, exception.__traceback__) - - match (name, message, tracebacks): - # The user doesn't have an environment variable set for the LLM provider. - case ('AuthenticationError', m, t) if 'litellm.AuthenticationError' in t[-1]: - variable_name = [k for k in COMMON_LLM_ENV_VARS if k in message] or ["correct"] - return ( - "We were unable to connect to the LLM provider. " - f"Ensure your .env file has the {variable_name[0]} variable set." - ) - # This happens when the LLM configured for an agent is invalid. - case ('BadRequestError', m, t) if 'LLM Provider NOT provided' in t[-1]: - return ( - "An invalid LLM was configured for an agent. " - "Ensure the 'llm' attribute of the agent in the agents.yaml file is in the format /." - ) - # The user has not configured the correct agent name in the tasks.yaml file. - case ('KeyError', m, t) if 'self.tasks_config[task_name]["agent"]' in t[-2]: - return ( - f"The agent {message} is not defined in your agents file. " - "Ensure the 'agent' fields in your tasks.yaml correspond to an entry in the agents.yaml file." - ) - # The user does not have an agent defined in agents.yaml file, but it does - # exist in the entrypoint code. - case ('KeyError', m, t) if 'config=self.agents_config[' in t[-2]: - return ( - f"The agent {message} is not defined in your agents file. " - "Ensure all agents referenced in your code are defined in the agents.yaml file." - ) - # The user does not have a task defined in tasks.yaml file, but it does - # exist in the entrypoint code. - case ('KeyError', m, t) if 'config=self.tasks_config[' in t[-2]: - return ( - f"The task {message} is not defined in your tasks. " - "Ensure all tasks referenced in your code are defined in the tasks.yaml file." - ) - case (_, _, _): - 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 +from agentstack import run -def _import_project_module(path: Path): - """ - Import `main` from the project path. - - We do it this way instead of spawning a subprocess so that we can share - state with the user's project. - """ - spec = importlib.util.spec_from_file_location(MAIN_MODULE_NAME, str(path / MAIN_FILENAME)) - - assert spec is not None # appease type checker - assert spec.loader is not None # appease type checker - - project_module = importlib.util.module_from_spec(spec) - sys.path.insert(0, str((path / MAIN_FILENAME).parent)) - spec.loader.exec_module(project_module) - return project_module - - -def run_project(command: str = 'run', cli_args: Optional[List[str]] = None): +def run_project(command: str = 'run', cli_args: Optional[list[str]] = None): """Validate that the project is ready to run and then run it.""" - conf.assert_project() - verify_agentstack_project() - - if conf.get_framework() not in frameworks.SUPPORTED_FRAMEWORKS: - raise ValidationError(f"Framework {conf.get_framework()} is not supported by agentstack.") - - try: - frameworks.validate_project() - except ValidationError as e: - raise e + run.preflight() # Parse extra --input-* arguments for runtime overrides of the project's inputs if cli_args: @@ -116,21 +17,5 @@ def run_project(command: str = 'run', cli_args: Optional[List[str]] = None): 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 - load_dotenv(conf.PATH / '.env', override=True) # load the project's .env file + run.run_project(command=command) - # import src/main.py from the project path and run `command` from the project's main.py - try: - log.notify("Running your agent...") - project_main = _import_project_module(conf.PATH) - main = getattr(project_main, command) - - # handle both async and sync entrypoints - if asyncio.iscoroutinefunction(main): - asyncio.run(main()) - else: - main() - except ImportError as e: - raise ValidationError(f"Failed to import AgentStack project at: {conf.PATH.absolute()}\n{e}") - except Exception as e: - raise Exception(format_friendly_error_message(e)) diff --git a/agentstack/frameworks/__init__.py b/agentstack/frameworks/__init__.py index 8c45e41d..73a712c8 100644 --- a/agentstack/frameworks/__init__.py +++ b/agentstack/frameworks/__init__.py @@ -1,11 +1,12 @@ -from typing import Optional, Union, Protocol, Callable +from typing import overload, runtime_checkable +from typing import Optional, Union, Protocol, Callable, Generator from types import ModuleType from abc import ABCMeta, abstractmethod from importlib import import_module from dataclasses import dataclass from pathlib import Path import ast -from agentstack import conf +from agentstack import conf, log from agentstack.exceptions import ValidationError from agentstack.generation import InsertionPoint from agentstack.utils import get_framework @@ -21,6 +22,7 @@ LANGGRAPH = 'langgraph' OPENAI_SWARM = 'openai_swarm' LLAMAINDEX = 'llamaindex' +CUSTOM = 'custom' SUPPORTED_FRAMEWORKS = [ CREWAI, LANGGRAPH, @@ -110,6 +112,22 @@ def get_graph(self) -> list[graph.Edge]: ... +@runtime_checkable +class EntrypointProtocol(Protocol): + """ + Protocol defining the interface for a framework's entrypoint file. + """ + @overload + def run(self, inputs: dict[str, str]) -> None: + """Run the entrypoint.""" + ... + + @overload + def run(self, inputs: dict[str, str]) -> Generator[tuple[str, str], None, None]: + """Run the entrypoint.""" + ... + + class BaseEntrypointFile(asttools.File, metaclass=ABCMeta): """ This handles interactions with a Framework's entrypoint file that are common @@ -169,7 +187,7 @@ def add_import(self, module_name: str, attributes: str): def get_base_class(self) -> ast.ClassDef: """ A base class is the first class inside of the file that follows the - naming convention: `Graph` + naming convention defined by `base_class_pattern`. """ pattern = self.base_class_pattern try: @@ -296,6 +314,9 @@ def get_framework_module(framework: str) -> FrameworkModule: """ Get the module for a framework. """ + if framework == CUSTOM: + raise Exception("Custom frameworks do not support modification.") + try: return import_module(f".{framework}", package=__package__) except ImportError: @@ -315,6 +336,11 @@ def validate_project(): Validate that the user's project is ready to run. """ framework = get_framework() + + if framework == CUSTOM: + log.debug("Skipping validation for custom framework.") + return + entrypoint_path = get_entrypoint_path(framework) module = get_framework_module(framework) entrypoint = module.get_entrypoint() @@ -359,6 +385,15 @@ def validate_project(): for task_name in get_all_task_names(): if task_name not in task_method_names: raise ValidationError(f"Task `{task_name}` defined in tasks.yaml but not in {entrypoint_path}") + + # Verify that the entrypoint class follows the EntrypointProtocol definition + # TODO we need to actually import the user's code to reference the entrypoint class + # EntrypointClass = + # if not isinstance(EntrypointClass, EntrypointProtocol): + # raise ValidationError( + # f"Entrypoint class `{EntrypointClass.__name__}` does not follow the " + # "EntrypointProtocol definition." + # ) def add_tool(tool: ToolConfig, agent_name: str): diff --git a/agentstack/inputs.py b/agentstack/inputs.py index bc3b51b6..605967eb 100644 --- a/agentstack/inputs.py +++ b/agentstack/inputs.py @@ -77,6 +77,8 @@ def get_inputs() -> dict: """ Get the inputs configuration file and override with run_inputs. """ + global run_inputs + config = InputsConfig().to_dict() # run_inputs override saved inputs for key, value in run_inputs.items(): @@ -89,4 +91,6 @@ def add_input_for_run(key: str, value: str): Add an input override for the current run. This is used by the CLI to allow inputs to be set at runtime. """ + global run_inputs + run_inputs[key] = value diff --git a/agentstack/log.py b/agentstack/log.py index 1c7df437..66022360 100644 --- a/agentstack/log.py +++ b/agentstack/log.py @@ -51,6 +51,7 @@ TOOL_USE = 16 THINKING = 18 INFO = logging.INFO # 20 +STREAM = 21 NOTIFY = 22 SUCCESS = 24 RESPONSE = 26 @@ -59,6 +60,7 @@ logging.addLevelName(THINKING, 'THINKING') logging.addLevelName(TOOL_USE, 'TOOL_USE') +logging.addLevelName(STREAM, 'STREAM') logging.addLevelName(NOTIFY, 'NOTIFY') logging.addLevelName(SUCCESS, 'SUCCESS') logging.addLevelName(RESPONSE, 'RESPONSE') @@ -68,6 +70,7 @@ stdout: IO = io.StringIO() stderr: IO = io.StringIO() +_stream: IO = io.StringIO() def set_stdout(stream: IO): @@ -92,6 +95,16 @@ def set_stderr(stream: IO): instance = None # force re-initialization +def set_stream(stream: IO): + """ + Redirect standard output and error messages to the given stream. + This is useful for getting a stream of log data to other interfaces. + """ + global _stream, instance + _stream = stream + instance = None # force re-initialization + + def _create_handler(levelno: int) -> Callable: """Get the logging handler for the given log level.""" @@ -107,6 +120,7 @@ def handler(msg, *args, **kwargs): debug = _create_handler(DEBUG) tool_use = _create_handler(TOOL_USE) thinking = _create_handler(THINKING) +stream = _create_handler(STREAM) info = _create_handler(INFO) notify = _create_handler(NOTIFY) success = _create_handler(SUCCESS) @@ -115,34 +129,49 @@ def handler(msg, *args, **kwargs): error = _create_handler(ERROR) -class ConsoleFormatter(logging.Formatter): +class BaseFormatter(logging.Formatter): + default_format = logging.Formatter('%(message)s\n') + formats: dict[int, logging.Formatter] + + def format(self, record: logging.LogRecord) -> str: + template = self.formats.get(record.levelno, self.default_format) + return template.format(record) + + +class ConsoleFormatter(BaseFormatter): """Formats log messages for display in the console.""" - default_format = logging.Formatter('%(message)s') + default_format = logging.Formatter('%(message)s\n') 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')), + DEBUG: logging.Formatter('DEBUG: %(message)s\n'), + STREAM: logging.Formatter('%(message)s'), + SUCCESS: logging.Formatter(term_color('%(message)s\n', 'green')), + NOTIFY: logging.Formatter(term_color('%(message)s\n', 'blue')), + WARNING: logging.Formatter(term_color('%(message)s\n', 'yellow')), + ERROR: logging.Formatter(term_color('%(message)s\n', '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): +class FileFormatter(BaseFormatter): """Formats log messages for display in a log file.""" - default_format = logging.Formatter('%(levelname)s: %(message)s') + default_format = logging.Formatter('%(levelname)s: %(message)s\n') formats = { - DEBUG: logging.Formatter('DEBUG (%(asctime)s):\n %(pathname)s:%(lineno)d\n %(message)s'), + DEBUG: logging.Formatter('DEBUG (%(asctime)s):\n %(pathname)s:%(lineno)d\n %(message)s\n'), + STREAM: logging.Formatter('%(message)s\n'), } - def format(self, record: logging.LogRecord) -> str: - template = self.formats.get(record.levelno, self.default_format) - return template.format(record) + +class StreamFormatter(BaseFormatter): + """ + Formats log messages for display in a stream. + * Only prints `log.stream` messages. + """ + + default_format = logging.Formatter('') # don't print + formats = { + STREAM: logging.Formatter('%(message)s'), + } def _build_logger() -> logging.Logger: @@ -152,7 +181,7 @@ def _build_logger() -> logging.Logger: 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 + # global stdout, stderr, _stream log = logging.getLogger(LOG_NAME) log.handlers.clear() # remove any existing handlers @@ -172,6 +201,7 @@ def _build_logger() -> logging.Logger: file_handler = logging.FileHandler(log_filename) file_handler.setFormatter(FileFormatter()) file_handler.setLevel(DEBUG) + file_handler.terminator = '' log.addHandler(file_handler) except FileNotFoundError: pass # we are not in a writeable directory @@ -182,6 +212,7 @@ def _build_logger() -> logging.Logger: stdout_handler.setFormatter(ConsoleFormatter()) stdout_handler.setLevel(DEBUG) stdout_handler.addFilter(lambda record: record.levelno < ERROR) + stdout_handler.terminator = '' log.addHandler(stdout_handler) # stderr handler for errors and above @@ -189,6 +220,15 @@ def _build_logger() -> logging.Logger: stderr_handler = logging.StreamHandler(stderr) stderr_handler.setFormatter(ConsoleFormatter()) stderr_handler.setLevel(ERROR) + stderr_handler.terminator = '' log.addHandler(stderr_handler) + + # stream handler for all messages + # `stream` can change, so defer building the stream until we need it + stream_handler = logging.StreamHandler(_stream) + stream_handler.setFormatter(StreamFormatter()) + stream_handler.setLevel(DEBUG) + stream_handler.terminator = '' + log.addHandler(stream_handler) return log diff --git a/agentstack/run.py b/agentstack/run.py new file mode 100644 index 00000000..a7b9c773 --- /dev/null +++ b/agentstack/run.py @@ -0,0 +1,132 @@ +from typing import Optional, List, Dict +import sys +import asyncio +import traceback +from pathlib import Path +import importlib.util +from dotenv import load_dotenv + +from agentstack import conf, log +from agentstack.exceptions import ValidationError +from agentstack import inputs +from agentstack import frameworks +from agentstack.utils import get_framework, verify_agentstack_project + +MAIN_FILENAME: Path = Path("src/main.py") +MAIN_MODULE_NAME = "main" + + +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. + + In order to prevent us from having to import all possible backend exceptions + we do string matching on the exception type and traceback contents. + """ + # TODO These end up being pretty framework-specific; consider individual implementations. + COMMON_LLM_ENV_VARS = ( + 'OPENAI_API_KEY', + 'ANTHROPIC_API_KEY', + ) + + name = exception.__class__.__name__ + message = str(exception) + tracebacks = traceback.format_exception(type(exception), exception, exception.__traceback__) + + match (name, message, tracebacks): + # The user doesn't have an environment variable set for the LLM provider. + case ('AuthenticationError', m, t) if 'litellm.AuthenticationError' in t[-1]: + variable_name = [k for k in COMMON_LLM_ENV_VARS if k in message] or ["correct"] + return ( + "We were unable to connect to the LLM provider. " + f"Ensure your .env file has the {variable_name[0]} variable set." + ) + # This happens when the LLM configured for an agent is invalid. + case ('BadRequestError', m, t) if 'LLM Provider NOT provided' in t[-1]: + return ( + "An invalid LLM was configured for an agent. " + "Ensure the 'llm' attribute of the agent in the agents.yaml file is in the format /." + ) + # The user has not configured the correct agent name in the tasks.yaml file. + case ('KeyError', m, t) if 'self.tasks_config[task_name]["agent"]' in t[-2]: + return ( + f"The agent {message} is not defined in your agents file. " + "Ensure the 'agent' fields in your tasks.yaml correspond to an entry in the agents.yaml file." + ) + # The user does not have an agent defined in agents.yaml file, but it does + # exist in the entrypoint code. + case ('KeyError', m, t) if 'config=self.agents_config[' in t[-2]: + return ( + f"The agent {message} is not defined in your agents file. " + "Ensure all agents referenced in your code are defined in the agents.yaml file." + ) + # The user does not have a task defined in tasks.yaml file, but it does + # exist in the entrypoint code. + case ('KeyError', m, t) if 'config=self.tasks_config[' in t[-2]: + return ( + f"The task {message} is not defined in your tasks. " + "Ensure all tasks referenced in your code are defined in the tasks.yaml file." + ) + case (_, _, _): + 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): + """ + Import `main` from the project path. + + We do it this way instead of spawning a subprocess so that we can share + state with the user's project. + """ + spec = importlib.util.spec_from_file_location(MAIN_MODULE_NAME, str(path / MAIN_FILENAME)) + + assert spec is not None # appease type checker + assert spec.loader is not None # appease type checker + + project_module = importlib.util.module_from_spec(spec) + sys.path.insert(0, str((path / MAIN_FILENAME).parent)) + spec.loader.exec_module(project_module) + return project_module + + +def preflight(): + """Validate that the project is ready to run.""" + conf.assert_project() + verify_agentstack_project() + + # TODO ensure this is actually redundant, but it feels that way + # if conf.get_framework() not in frameworks.SUPPORTED_FRAMEWORKS: + # raise ValidationError(f"Framework {conf.get_framework()} is not supported by agentstack.") + + try: + frameworks.validate_project() + except ValidationError as e: + raise e + + +def run_project(command: str = 'run', cli_args: Optional[List[str]] = None): + """Run a user project.""" + load_dotenv(Path.home() / '.env') # load the user's .env file + load_dotenv(conf.PATH / '.env', override=True) # load the project's .env file + + # import src/main.py from the project path and run `command` from the project's main.py + try: + log.notify("Running your agent...") + project_main = _import_project_module(conf.PATH) + main = getattr(project_main, command) + + # handle both async and sync entrypoints + if asyncio.iscoroutinefunction(main): + asyncio.run(main()) + else: + main() + except ImportError as e: + raise ValidationError(f"Failed to import AgentStack project at: {conf.PATH.absolute()}\n{e}") + except Exception as e: + raise Exception(format_friendly_error_message(e)) + diff --git a/agentstack/serve/serve.py b/agentstack/serve/serve.py index c7248f34..7dc4ead6 100644 --- a/agentstack/serve/serve.py +++ b/agentstack/serve/serve.py @@ -1,134 +1,174 @@ +from typing import Optional, Any, Callable, Generator +import os, sys +import io +from threading import Thread +import time import importlib -import sys +from enum import Enum from pathlib import Path +import pydantic from urllib.parse import urlparse - +import json +import requests from dotenv import load_dotenv -from agentstack import conf, frameworks, inputs, log + +from flask import Flask, send_file, request, jsonify +from flask import Response as BaseResponse +from flask_sock import Sock +from flask_cors import CORS + +from agentstack import conf, log from agentstack.exceptions import ValidationError -from agentstack.utils import verify_agentstack_project -# TODO: move this to not cli, but cant be utils due to circular import -from agentstack.cli.run import format_friendly_error_message -from flask import Flask, request, jsonify -import requests -from typing import Dict, Any, Optional, Tuple -import os +from agentstack import inputs +from agentstack import run -MAIN_FILENAME: Path = Path("src/main.py") -MAIN_MODULE_NAME = "main" load_dotenv(dotenv_path="/app/.env") -app = Flask(__name__) +#app = Flask(__name__) + +#current_webhook_url = None + +ALLOWED_ORIGINS = ['*'] -current_webhook_url = None +class Message(pydantic.BaseModel): + class Type(str, Enum): + CHAT = "chat" + + type: Type + data: dict[str, Any] -def call_webhook(webhook_url: str, data: Dict[str, Any]) -> None: + +# TODO subclass flask.Response for response types +class Response(pydantic.BaseModel): + class Type(str, Enum): + DATA = "data" + SUCCESS = "success" + ERROR = "error" + + type: Type + data: Optional[dict[str, Any]] = None + + +def call_webhook(webhook_url: str, data: dict[str, Any]) -> None: """Send results to the specified webhook URL.""" try: response = requests.post(webhook_url, json=data) response.raise_for_status() except requests.exceptions.RequestException as e: - app.logger.error(f"Webhook call failed: {str(e)}") + log.error(f"Webhook call failed: {str(e)}") raise -@app.route("/health", methods=["GET"]) -def health(): - return "Agent Server Up" -@app.route('/process', methods=['POST']) -def process_agent(): - global current_webhook_url +# @app.route("/health", methods=["GET"]) +# def health(): +# return "Agent Server Up" + +# @app.route('/process', methods=['POST']) +# def process_agent(): +# global current_webhook_url + +# request_data = None +# try: +# request_data = request.get_json() + +# if not request_data or 'webhook_url' not in request_data: +# result, message = validate_url(request_data.get("webhook_url")) +# if not result: +# return jsonify({'error': f'Invalid webhook_url in request: {message}'}), 400 + +# if not request_data or 'inputs' not in request_data: +# return jsonify({'error': 'Missing input data in request'}), 400 + +# current_webhook_url = request_data.pop('webhook_url') + +# return jsonify({ +# 'status': 'accepted', +# 'message': 'Agent process started' +# }), 202 + +# except Exception as e: +# error_message = str(e) +# app.logger.error(f"Error processing request: {error_message}") +# return jsonify({ +# 'status': 'error', +# 'error': error_message +# }), 500 + +# finally: +# if current_webhook_url: +# try: +# result, session_id = run_project(api_inputs=request_data.get('inputs')) +# call_webhook(current_webhook_url, { +# 'status': 'success', +# 'result': result, +# 'session_id': session_id +# }) +# except Exception as e: +# error_message = str(e) +# app.logger.error(f"Error in process: {error_message}") +# try: +# call_webhook(current_webhook_url, { +# 'status': 'error', +# 'error': error_message +# }) +# except: +# app.logger.error("Failed to send error to webhook") +# finally: +# current_webhook_url = None + + +def run_project(command: str = 'run', api_args: Optional[dict[str, str]] = None, + api_inputs: Optional[dict[str, str]] = None): + """Validate that the project is ready to run and then run it.""" + # TODO `api_args` is unused + run.preflight() - request_data = None - try: - request_data = request.get_json() + if api_inputs: + for key, value in api_inputs.items(): + inputs.add_input_for_run(key, value) - if not request_data or 'webhook_url' not in request_data: - result, message = validate_url(request_data.get("webhook_url")) - if not result: - return jsonify({'error': f'Invalid webhook_url in request: {message}'}), 400 + run.run_project(command=command) - if not request_data or 'inputs' not in request_data: - return jsonify({'error': 'Missing input data in request'}), 400 - current_webhook_url = request_data.pop('webhook_url') +class TailIO(io.StringIO): + """Tail-able StringIO for streaming updated log output.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._pos = 0 - return jsonify({ - 'status': 'accepted', - 'message': 'Agent process started' - }), 202 + def follow_tail(self) -> Generator[str, None, None]: + """ + Continuously yield new lines from the StringIO buffer that have been + written since the last read. + """ + while True: + self.seek(self._pos) + if data := self.read(): + self._pos = self.tell() + yield data + else: + time.sleep(0.1) - except Exception as e: - error_message = str(e) - app.logger.error(f"Error processing request: {error_message}") - return jsonify({ - 'status': 'error', - 'error': error_message - }), 500 - - finally: - if current_webhook_url: - try: - result, session_id = run_project(api_inputs=request_data.get('inputs')) - call_webhook(current_webhook_url, { - 'status': 'success', - 'result': result, - 'session_id': session_id - }) - except Exception as e: - error_message = str(e) - app.logger.error(f"Error in process: {error_message}") - try: - call_webhook(current_webhook_url, { - 'status': 'error', - 'error': error_message - }) - except: - app.logger.error("Failed to send error to webhook") - finally: - current_webhook_url = None -def run_project(command: str = 'run', api_args: Optional[Dict[str, str]] = None, - api_inputs: Optional[Dict[str, str]] = None): +def run_project_stream(inputs: dict[str, str], command: str = 'run') -> Generator[str, None, None]: """Validate that the project is ready to run and then run it.""" - verify_agentstack_project() + log_output = TailIO() + log.set_stream(log_output) - if conf.get_framework() not in frameworks.SUPPORTED_FRAMEWORKS: - raise ValidationError(f"Framework {conf.get_framework()} is not supported by agentstack.") + thread = Thread(target=lambda: run_project(command=command, api_inputs=inputs)) + thread.start() - try: - frameworks.validate_project() - except ValidationError as e: - raise e - - for key, value in api_inputs.items(): - inputs.add_input_for_run(key, value) - - load_dotenv(Path.home() / '.env') # load the user's .env file - load_dotenv(conf.PATH / '.env', override=True) # load the project's .env file - - try: - log.notify("Running your agent...") - project_main = _import_project_module(conf.PATH) - return getattr(project_main, command)() - except ImportError as e: - raise ValidationError(f"Failed to import AgentStack project at: {conf.PATH.absolute()}\n{e}") - except Exception as e: - raise Exception(format_friendly_error_message(e)) - -def _import_project_module(path: Path): - """Import `main` from the project path.""" - spec = importlib.util.spec_from_file_location(MAIN_MODULE_NAME, str(path / MAIN_FILENAME)) + while thread.is_alive(): + for line in log_output.follow_tail(): + yield line + else: + for line in log_output.follow_tail(): + yield line # stragglers post-thread - assert spec is not None - assert spec.loader is not None + thread.join() - project_module = importlib.util.module_from_spec(spec) - sys.path.insert(0, str((path / MAIN_FILENAME).parent)) - spec.loader.exec_module(project_module) - return project_module -def validate_url(url: str) -> Tuple[bool, str]: +def validate_url(url: str) -> tuple[bool, str]: """Validates a URL and returns a tuple of (is_valid, error_message).""" if not url: return False, "URL cannot be empty" @@ -153,12 +193,181 @@ def validate_url(url: str) -> Tuple[bool, str]: except Exception as e: return False, f"Invalid URL format: {str(e)}" + +class ProjectServer: + app: Flask + sock: Sock + webhook_url: Optional[str] = None + + def __init__(self): + self.app = Flask(__name__) + self.sock = Sock(self.app) + + CORS(self.app, + origins=ALLOWED_ORIGINS, + methods=["*"]) + self.register_routes() + + def register_routes(self): + """Register all routes for the application""" + routes = self.get_routes() + for route, handler, method in routes: + if method in ('GET', 'POST', 'PUT', 'DELETE'): + # Regular HTTP routes + self.app.route(route, methods=[method])(handler) + else: + # WebSocket routes + self.sock.route(route)(handler) + + def get_routes(self) -> list[tuple[str, Callable, Optional[str]]]: + return [ + ('/', self.index, 'GET'), + ('/ws', self.websocket_handler, None), + ('/health', self.health, 'GET'), + ('/process', self.process, 'POST'), + ] + + def format_response(self, response: Response) -> BaseResponse: + """Dump a response object to JSON""" + return jsonify(response.model_dump()) + + def index(self) -> tuple[BaseResponse, int]: + """Serve a user interface""" + # TODO delegate this to the user project. + return send_file(conf.PATH / 'src/index.html'), 200 + + def health(self) -> tuple[BaseResponse, int]: + """Health check endpoint""" + response = Response( + type=Response.Type.DATA, + data={'status': 'ok'}, + ) + return self.format_response(response), 200 + + def process(self) -> tuple[BaseResponse, int]: + request_data = None + try: + request_data = request.get_json() + + if not request_data or 'webhook_url' not in request_data: + result, message = validate_url(request_data.get("webhook_url")) + if not result: + raise ValueError(f'Invalid webhook_url in request: {message}') + + if not request_data or 'inputs' not in request_data: + raise ValueError('Missing input data in request') + + self.webhook_url = request_data.pop('webhook_url') + return self.format_response(Response( + type=Response.Type.SUCCESS, + data={'message': 'Agent process started'} + )), 202 + + except Exception as e: + error_message = str(e) + log.error(f"Error processing request: {error_message}") + return self.format_response(Response( + type=Response.Type.ERROR, + data={'message': error_message} + )), 500 + + finally: + if not self.webhook_url: + # project will not run if we don't have a webhook url + log.error("No webhook URL provided") + return self.format_response(Response( + type=Response.Type.ERROR, + data={'message': 'No webhook URL provided'} + )), 500 + + try: + assert request_data, "request_data is None" + # TODO `command` + result, session_id = run_project(api_inputs=request_data.get('inputs')) + call_webhook(self.webhook_url, { + 'status': 'success', + 'result': result, + 'session_id': session_id + }) + except Exception as e: + error_message = str(e) + log.error(f"Error in process: {error_message}") + try: + call_webhook(self.webhook_url, { + 'status': 'error', + 'error': error_message + }) + except: + log.error("Failed to send error to webhook") + finally: + self.webhook_url = None + + def handle_message(self, message: Message) -> Generator[Response, None, None]: + """Handle incoming messages""" + if message.type == Message.Type.CHAT: + assert 'role' in message.data + assert 'content' in message.data + + inputs = { + 'prompt': message.data['content'], + } + + # TODO assistant is hardcoded + for content in run_project_stream(inputs): + yield Response( + type=Response.Type.DATA, + data={'role': 'assistant', 'content': content}, + ) + time.sleep(0.01) # small delay to prevent overwhelming the socket + + def get_response(self, message: dict[str, Any]) -> Generator[Response, None, None]: + """Process incoming message and generate responses""" + try: + _message = Message.model_validate(message) + for response in self.handle_message(_message): + yield response + except ValueError as e: + yield Response( + type=Response.Type.ERROR, + data={'error': f"Invalid message format: {str(e)}"}, + ) + except Exception: + yield Response( + type=Response.Type.ERROR, + data={'error': "Unknown message type"}, + ) + + def websocket_handler(self, ws) -> None: + """Handle WebSocket connections""" + while True: + try: + raw_message = json.loads(ws.receive()) + for response in self.get_response(raw_message): + ws.send(json.dumps(response.model_dump())) + except Exception as e: + response = Response( + type=Response.Type.ERROR, + data={'error': str(e)} + ) + ws.send(json.dumps(response.model_dump())) + break + + def run(self, host='0.0.0.0', port=6969, **kwargs) -> None: + """Run the Flask application""" + self.app.run(host=host, port=port, **kwargs) + + if __name__ == '__main__': port = int(os.environ.get('PORT', 6969)) print("🚧 Running your agent on a development server") print(f"Send agent requests to http://localhost:{port}") print("Learn more about agent requests at https://docs.agentstack.sh/") # TODO: add docs for this + log.set_stderr(sys.stderr) + log.set_stdout(sys.stdout) + conf.set_path(Path.cwd()) + + app = ProjectServer() app.run(host='0.0.0.0', port=port) else: print("Starting production server with Gunicorn") \ No newline at end of file diff --git a/agentstack/templates/__init__.py b/agentstack/templates/__init__.py index 1602fc56..100c7e8a 100644 --- a/agentstack/templates/__init__.py +++ b/agentstack/templates/__init__.py @@ -232,7 +232,7 @@ def from_user_input(cls, identifier: str): Load a template from a user-provided identifier. Three cases will be tried: A URL, a file path, or a template name. """ - if identifier.startswith('https://'): + if identifier.startswith('https://') or identifier.startswith('http://'): return cls.from_url(identifier) if identifier.endswith('.json'): @@ -263,8 +263,6 @@ def from_file(cls, path: Path) -> 'TemplateConfig': @classmethod def from_url(cls, url: str) -> 'TemplateConfig': - if not url.startswith("https://"): - raise ValidationError(f"Invalid URL: {url}") response = requests.get(url) if response.status_code != 200: raise ValidationError(f"Failed to fetch template from {url}") diff --git a/agentstack/update.py b/agentstack/update.py index 2787e3d4..1b028022 100644 --- a/agentstack/update.py +++ b/agentstack/update.py @@ -5,7 +5,7 @@ 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, get_base_dir +from agentstack.utils import get_version, get_framework, get_base_dir, is_interactive_shell from agentstack import packaging @@ -109,6 +109,11 @@ def check_for_updates(update_requested: bool = False): installed_version: Version = parse_version(get_version(AGENTSTACK_PACKAGE)) if latest_version > installed_version: + if not is_interactive_shell(): + log.info(f"New version of {AGENTSTACK_PACKAGE} available: {latest_version}") + log.info(f"Environment is non-interactive so skipping install.") + return + log.info('') # newline if inquirer.confirm( f"New version of {AGENTSTACK_PACKAGE} available: {latest_version}! Do you want to install?" diff --git a/agentstack/utils.py b/agentstack/utils.py index cc2569ed..828b3299 100644 --- a/agentstack/utils.py +++ b/agentstack/utils.py @@ -1,5 +1,4 @@ -import os -import sys +import os, sys import json from ruamel.yaml import YAML import re @@ -44,6 +43,21 @@ def get_framework() -> str: return framework +def is_interactive_shell(): + """Return True if the current environment is interactive, False otherwise.""" + if os.getenv('NONINTERACTIVE') == '1': + return False + + if not sys.stdin.isatty(): + return False + + try: + os.get_terminal_size() + return True + except OSError: + return False + + def get_telemetry_opt_out() -> bool: """ Gets the telemetry opt out setting. diff --git a/docs/llms.txt b/docs/llms.txt index 2e0af5b7..fa23ea5c 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -514,10 +514,6 @@ 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_user_input(identifier: str)` `` Returns a `TemplateConfig` object for either a URL, file path, or builtin template name. @@ -716,7 +712,7 @@ title: 'System Analyzer' description: 'Inspect a project directory and improve it' --- -[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/proj_templates/system_analyzer.json) +[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/system_analyzer.json) ```bash agentstack init --template=system_analyzer @@ -737,7 +733,7 @@ title: 'Researcher' description: 'Research and report result from a query' --- -[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/proj_templates/research.json) +[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/research.json) ```bash agentstack init --template=research @@ -828,7 +824,54 @@ title: 'Content Creator' description: 'Research a topic and create content on it' --- -[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/proj_templates/content_creator.json) +[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/content_creator.json) + +## frameworks/list.mdx + +--- +title: Frameworks +description: 'Supported frameworks in AgentStack' +icon: 'ship' +--- + +These are documentation links to the frameworks supported directly by AgentStack. + +To start a project with one of these frameworks, use +```bash +agentstack init --framework +``` + +## Framework Docs + + + An intuitive agentic framework (recommended) + + + A complex but capable framework with a _steep_ learning curve + + + A simple framework with a cult following + + + An expansive framework with many ancillary features + + ## tools/package-structure.mdx @@ -1043,7 +1086,7 @@ You can pass the `--wizard` flag to `agentstack init` to use an interactive proj You can also pass a `--template=` argument to `agentstack init` which will pre-populate your project with functionality from a built-in template, or one found on the internet. A `template_name` can be one of three identifiers: -- A built-in AgentStack template (see the `templates/proj_templates` directory in the AgentStack repo for bundled templates). +- A built-in AgentStack template (see the `templates` directory in the AgentStack repo for bundled templates). - A template file from the internet; pass the full https URL of the template. - A local template file; pass an absolute or relative path. diff --git a/pyproject.toml b/pyproject.toml index 697d2c29..24a29e44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ - "agentops>=0.3.18", + "agentops==0.3.18", "typer>=0.12.5", "inquirer>=3.4.0", "art>=6.3", @@ -33,7 +33,10 @@ dependencies = [ "uv>=0.5.6", "tomli>=2.2.1", "gitpython>=3.1.44", - "websockets>=14.2" + "websockets>=14.2", + "flask>=3.1.0", + "flask-sock>=0.7.0", + "flask-cors>=5.0.0", ] [project.optional-dependencies]