diff --git a/MANIFEST.in b/MANIFEST.in index bf2bea26..94b39eaa 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,5 @@ recursive-include agentstack/templates * recursive-include agentstack/frameworks/templates * recursive-include agentstack/_tools * +recursive-include agentstack/serve/serve.py include agentstack.json .env .env.example \ No newline at end of file diff --git a/agentstack/cli/cli.py b/agentstack/cli/cli.py index a40e83ff..84ed91e6 100644 --- a/agentstack/cli/cli.py +++ b/agentstack/cli/cli.py @@ -1,12 +1,28 @@ from typing import Optional import os, sys +import importlib +from typing import Optional +import os +import time +from datetime import datetime + +import json +import shutil from art import text2art import inquirer from agentstack import conf, log +from agentstack.cli.agentstack_data import CookiecutterData, ProjectStructure, ProjectMetadata, FrameworkData from agentstack.conf import ConfigFile +from agentstack.generation import InsertionPoint, ProjectFile +from agentstack import frameworks +from agentstack import inputs +from agentstack.agents import get_all_agents +from agentstack.tasks import get_all_tasks +from agentstack.utils import get_package_path, open_json_file, term_color, is_snake_case, get_framework, \ + validator_not_empty, verify_agentstack_project +from agentstack.templates import TemplateConfig from agentstack.exceptions import ValidationError from agentstack.utils import validator_not_empty, is_snake_case -from agentstack.generation import InsertionPoint PREFERRED_MODELS = [ @@ -81,6 +97,305 @@ def get_validated_input( return value +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?", + ) + + if not use_wizard: + return {'agents': [], 'tasks': []} + + os.system("cls" if os.name == "nt" else "clear") + + title = text2art("AgentWizard", font="shimrod") + + print(title) + + print(""" +šŖ welcome to the agent builder wizard!! šŖ + +First we need to create the agents that will work together to accomplish tasks: + """) + make_agent = True + agents = [] + while make_agent: + print('---') + print(f"Agent #{len(agents)+1}") + agent = None + agent = ask_agent_details() + agents.append(agent) + make_agent = inquirer.confirm(message="Create another agent?") + + print('') + for x in range(3): + time.sleep(0.3) + print('.') + print('Boom! We made some agents (ļ¾>Ļ<)ļ¾ :td:*:dļ¾āā ,td:*:dļ¾āā') + time.sleep(0.5) + print('') + print('Now lets make some tasks for the agents to accomplish!') + print('') + + make_task = True + tasks = [] + while make_task: + print('---') + print(f"Task #{len(tasks) + 1}") + task = ask_task_details(agents) + tasks.append(task) + make_task = inquirer.confirm(message="Create another task?") + + print('') + for x in range(3): + time.sleep(0.3) + print('.') + print('Let there be tasks (ć Ė_Ė)ććζ|||ζćζ|||ζćζ|||ζ') + + return {'tasks': tasks, 'agents': agents} + + +def ask_tools() -> list: + use_tools = inquirer.confirm( + message="Do you want to add agent tools now? (you can do this later with `agentstack tools add <tool_name>`)", + ) + + if not use_tools: + return [] + + tools_to_add = [] + + adding_tools = True + script_dir = os.path.dirname(os.path.abspath(__file__)) + tools_json_path = os.path.join(script_dir, '..', 'tools', 'tools.json') + + # Load the JSON data + tools_data = open_json_file(tools_json_path) + + while adding_tools: + tool_type = inquirer.list_input( + message="What category tool do you want to add?", + choices=list(tools_data.keys()) + ["~~ Stop adding tools ~~"], + ) + + tools_in_cat = [f"{t['name']} - {t['url']}" for t in tools_data[tool_type] if t not in tools_to_add] + tool_selection = inquirer.list_input(message="Select your tool", choices=tools_in_cat) + + tools_to_add.append(tool_selection.split(' - ')[0]) + + log.info("Adding tools:") + for t in tools_to_add: + log.info(f' - {t}') + log.info('') + adding_tools = inquirer.confirm("Add another tool?") + + return tools_to_add + + +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): + log.error("Project name must be snake case") + return ask_project_details(slug_name) + + questions = inquirer.prompt( + [ + inquirer.Text("version", message="What's the initial version", default="0.1.0"), + inquirer.Text("description", message="Enter a description for your project"), + inquirer.Text("author", message="Who's the author (your name)?"), + ] + ) + + questions['name'] = name + + return questions + + +def insert_template( + project_details: dict, + framework_name: str, + design: dict, + template_data: Optional[TemplateConfig] = None, +): + framework = FrameworkData( + name=framework_name.lower(), + ) + project_metadata = ProjectMetadata( + project_name=project_details["name"], + description=project_details["description"], + author_name=project_details["author"], + version="0.0.1", + license="MIT", + year=datetime.now().year, + template=template_data.name if template_data else 'none', + template_version=template_data.template_version if template_data else 0, + ) + + project_structure = ProjectStructure( + method=template_data.method if template_data else "sequential", + manager_agent=template_data.manager_agent if template_data else None, + ) + project_structure.agents = design["agents"] + project_structure.tasks = design["tasks"] + project_structure.inputs = design["inputs"] + + cookiecutter_data = CookiecutterData( + project_metadata=project_metadata, + structure=project_structure, + framework=framework_name.lower(), + ) + + template_path = get_package_path() / f'templates/{framework.name}' + with open(f"{template_path}/cookiecutter.json", "w") as json_file: + json.dump(cookiecutter_data.to_dict(), json_file) + # TODO this should not be written to the package directory + + # copy .env.example to .env + shutil.copy( + f'{template_path}/{"{{cookiecutter.project_metadata.project_slug}}"}/.env.example', + f'{template_path}/{"{{cookiecutter.project_metadata.project_slug}}"}/.env', + ) + + cookiecutter(str(template_path), no_input=True, extra_context=None) + + # TODO: inits a git repo in the directory the command was run in + # TODO: not where the project is generated. Fix this + # TODO: also check if git is installed or if there are any git repos above the current dir + try: + pass + # subprocess.check_output(["git", "init"]) + # subprocess.check_output(["git", "add", "."]) + except: + print("Failed to initialize git repository. Maybe you're already in one? Do this with: git init") + + +def export_template(output_filename: str): + """ + Export the current project as a template. + """ + try: + metadata = ProjectFile() + except Exception as e: + raise Exception(f"Failed to load project metadata: {e}") + + # Read all the agents from the project's agents.yaml file + agents: list[TemplateConfig.Agent] = [] + for agent in get_all_agents(): + agents.append( + TemplateConfig.Agent( + name=agent.name, + role=agent.role, + goal=agent.goal, + backstory=agent.backstory, + allow_delegation=False, # TODO + model=agent.llm, # TODO consistent naming (llm -> model) + ) + ) + + # Read all the tasks from the project's tasks.yaml file + tasks: list[TemplateConfig.Task] = [] + for task in get_all_tasks(): + tasks.append( + TemplateConfig.Task( + name=task.name, + description=task.description, + expected_output=task.expected_output, + agent=task.agent, + ) + ) + + # Export all of the configured tools from the project + tools_agents: dict[str, list[str]] = {} + for agent_name in frameworks.get_agent_names(): + for tool_name in frameworks.get_agent_tool_names(agent_name): + if not tool_name: + continue + if tool_name not in tools_agents: + tools_agents[tool_name] = [] + tools_agents[tool_name].append(agent_name) + + tools: list[TemplateConfig.Tool] = [] + for tool_name, agent_names in tools_agents.items(): + tools.append( + TemplateConfig.Tool( + name=tool_name, + agents=agent_names, + ) + ) + + template = TemplateConfig( + template_version=3, + name=metadata.project_name, + description=metadata.project_description, + framework=get_framework(), + method="sequential", # TODO this needs to be stored in the project somewhere + manager_agent=None, # TODO + agents=agents, + tasks=tasks, + tools=tools, + inputs=inputs.get_inputs(), + ) + + try: + template.write_to_file(conf.PATH / output_filename) + log.success(f"Template saved to: {conf.PATH / output_filename}") + except Exception as e: + raise Exception(f"Failed to write template to file: {e}") + + +def serve_project(): + verify_agentstack_project() + + # TODO: only silence output conditionally - maybe a debug or verbose option + os.system("docker stop agentstack-local > /dev/null 2>&1") + os.system("docker rm agentstack-local > /dev/null 2>&1") + with importlib.resources.path('agentstack.serve', 'Dockerfile') as path: + os.system(f"docker build -t agent-service -f {path} . --progress=plain") + os.system("docker run --name agentstack-local -p 6969:6969 agent-service") + + def parse_insertion_point(position: Optional[str] = None) -> Optional[InsertionPoint]: """ Parse an insertion point CLI argument into an InsertionPoint enum. diff --git a/agentstack/cli/run.py b/agentstack/cli/run.py index 3e142b19..a33b2f19 100644 --- a/agentstack/cli/run.py +++ b/agentstack/cli/run.py @@ -1,4 +1,4 @@ -from typing import Optional, List +from typing import Optional, List, Dict import sys import asyncio import traceback @@ -16,7 +16,7 @@ MAIN_MODULE_NAME = "main" -def _format_friendly_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. @@ -133,4 +133,4 @@ def run_project(command: str = 'run', cli_args: Optional[List[str]] = None): 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)) + raise Exception(format_friendly_error_message(e)) diff --git a/agentstack/cli/spinner.py b/agentstack/cli/spinner.py new file mode 100644 index 00000000..dc70ba58 --- /dev/null +++ b/agentstack/cli/spinner.py @@ -0,0 +1,95 @@ +import itertools +import shutil +import sys +import threading +import time +from typing import Optional, Literal + +from agentstack import log + + +class Spinner: + def __init__(self, message="Working", delay=0.1): + self.spinner = itertools.cycle(['ā ', 'ā ', 'ā ¹', 'ā ø', 'ā ¼', 'ā “', 'ā ¦', 'ā §', 'ā ', 'ā ']) + self.delay = delay + self.message = message + self.running = False + self.spinner_thread = None + self.start_time = None + self._lock = threading.Lock() + self._last_printed_len = 0 + self._last_message = "" + + def _clear_line(self): + """Clear the current line in terminal.""" + sys.stdout.write('\r' + ' ' * self._last_printed_len + '\r') + sys.stdout.flush() + + def spin(self): + while self.running: + with self._lock: + elapsed = time.time() - self.start_time + terminal_width = shutil.get_terminal_size().columns + spinner_char = next(self.spinner) + time_str = f"{elapsed:.1f}s" + + # Format: [spinner] Message... [time] + message = f"\r{spinner_char} {self.message}... [{time_str}]" + + # Ensure we don't exceed terminal width + if len(message) > terminal_width: + message = message[:terminal_width - 3] + "..." + + # Clear previous line and print new one + self._clear_line() + sys.stdout.write(message) + sys.stdout.flush() + self._last_printed_len = len(message) + + time.sleep(self.delay) + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop() + + def start(self): + if not self.running: + self.running = True + self.start_time = time.time() + self.spinner_thread = threading.Thread(target=self.spin) + self.spinner_thread.start() + + def stop(self): + if self.running: + self.running = False + if self.spinner_thread: + self.spinner_thread.join() + with self._lock: + self._clear_line() + + def update_message(self, message): + """Update spinner message and ensure clean line.""" + with self._lock: + self._clear_line() + self.message = message + + def clear_and_log(self, message, color: Literal['success', 'info'] = 'success'): + """Temporarily clear spinner, print message, and resume spinner. + Skips printing if message is the same as the last message printed.""" + with self._lock: + # Skip if message is same as last one + if hasattr(self, '_last_message') and self._last_message == message: + return + + self._clear_line() + if color == 'success': + log.success(message) + else: + log.info(message) + sys.stdout.flush() + + # Store current message + self._last_message = message \ No newline at end of file diff --git a/agentstack/conf.py b/agentstack/conf.py index 64319f4b..77ba038b 100644 --- a/agentstack/conf.py +++ b/agentstack/conf.py @@ -71,6 +71,8 @@ class ConfigFile(BaseModel): Config Schema ------------- + project_name: str + The name of the project. framework: str The framework used in the project. Defaults to 'crewai'. tools: list[str] @@ -87,8 +89,11 @@ class ConfigFile(BaseModel): The version of the template system used to generate the project. use_git: Optional[bool] Whether to use git for automatic commits of you project. + hosted_project_id: Optional[str] + The ID of the deployed project on https://AgentStack.sh """ + project_name: str framework: str = DEFAULT_FRAMEWORK # TODO this should probably default to None tools: list[str] = [] telemetry_opt_out: Optional[bool] = None @@ -96,6 +101,7 @@ class ConfigFile(BaseModel): agentstack_version: Optional[str] = get_version() template: Optional[str] = None template_version: Optional[str] = None + hosted_project_id: Optional[int] = None use_git: Optional[bool] = True def __init__(self): diff --git a/agentstack/deploy.py b/agentstack/deploy.py new file mode 100644 index 00000000..030af0dc --- /dev/null +++ b/agentstack/deploy.py @@ -0,0 +1,217 @@ +import asyncio +import json +import os +import tempfile +import time +import tomllib +import webbrowser +import zipfile +from pathlib import Path + +from agentstack.auth import get_stored_token, login +from agentstack.cli.spinner import Spinner +from agentstack.conf import ConfigFile +from agentstack.utils import term_color +from agentstack import log +import requests +import websockets + + +# ORIGIN = "localhost:3000" +ORIGIN = "build.agentstack.sh" +# PROTOCOL = "http" +PROTOCOL = "https" # "http" + +async def connect_websocket(project_id, spinner): + uri = f"ws://{ORIGIN}/ws/build/{project_id}" + async with websockets.connect(uri) as websocket: + try: + while True: + message = await websocket.recv() + data = json.loads(message) + if data['type'] == 'build': + spinner.clear_and_log(f"šļø {data.get('data','')}", 'info') + elif data['type'] == 'push': + spinner.clear_and_log(f"š¤ {data.get('data','')}", 'info') + elif data['type'] == 'connected': + spinner.clear_and_log(f"\n\n~~ Build stream connected! ~~") + elif data['type'] == 'deploy': + spinner.clear_and_log(f"š {data.get('data','')}", 'info') + elif data['type'] == 'error': + raise Exception(f"Failed to deploy: {data.get('data')}") + elif data['type'] == 'complete': + return + except websockets.ConnectionClosed: + raise Exception("Websocket connection closed unexpectedly") + + +async def deploy(): + log.info("Deploying your agentstack agent!") + bearer_token = get_stored_token() + if not bearer_token: + success = login() + if success: + bearer_token = get_stored_token() + else: + log.error(term_color("Failed to authenticate with AgentStack.sh", "red")) + return + + project_id = get_project_id() + + with Spinner() as spinner: + websocket_task = asyncio.create_task(connect_websocket(project_id, spinner)) + time.sleep(0.1) + try: + spinner.update_message("Collecting files") + spinner.clear_and_log(" šļø Files collected") + files = collect_files(str(Path('.')), ('.py', '.toml', '.yaml', '.json')) + if not files: + raise Exception("No files found to deploy") + + spinner.update_message("Creating zip file") + zip_file = create_zip_in_memory(files, spinner) + spinner.clear_and_log(" šļø Created zip file") + + spinner.update_message("Uploading to server") + + response = requests.post( + f'{PROTOCOL}://{ORIGIN}/deploy/build', + files={'code': ('code.zip', zip_file)}, + params={'projectId': project_id}, + headers={'Authorization': f'Bearer {bearer_token}'} + ) + + spinner.clear_and_log(" š” Uploaded to server") + + if response.status_code != 200: + raise Exception(response.text) + + spinner.update_message("Building and deploying your agent") + + # Wait for build completion + await websocket_task + + log.success("\nš Successfully deployed with AgentStack.sh! Opening in browser...") + # webbrowser.open(f"http://localhost:5173/project/{project_id}") # TODO: agentops platform url + + except Exception as e: + spinner.stop() + log.error(f"\nš Failed to deploy with AgentStack.sh: {e}") + return + +def load_pyproject(): + if os.path.exists("pyproject.toml"): + with open("pyproject.toml", "rb") as f: + return tomllib.load(f) + return None + +def get_project_id(): + project_config = ConfigFile() + project_id = project_config.hosted_project_id + + if project_id: + return project_id + + bearer_token = get_stored_token() + + # if not in config, create project and store it + log.info("š§ Creating AgentStack.sh Project") + headers = { + 'Authorization': f'Bearer {bearer_token}', + 'Content-Type': 'application/json' + } + + payload = { + 'name': project_config.project_name + } + + try: + response = requests.post( + url=f"{PROTOCOL}://{ORIGIN}/projects", + # url="https://api.agentstack.sh/projects", + headers=headers, + json=payload + ) + + response.raise_for_status() + res_data = response.json() + project_id = res_data['id'] + project_config.hosted_project_id = project_id + project_config.write() + return project_id + + except requests.exceptions.RequestException as e: + log.error(f"Error making request: {e}") + return None + + +def collect_files(root_path='.', file_types=('.py', '.toml', '.yaml', '.json')): + """Collect files of specified types from directory tree.""" + files = set() # Using set for faster lookups and unique entries + root = Path(root_path) + + def should_process_dir(path): + """Check if directory should be processed.""" + skip_dirs = {'.git', '.venv', 'venv', '__pycache__', 'node_modules', '.pytest_cache'} + return path.name not in skip_dirs + + def process_directory(path): + """Process a directory and collect matching files.""" + if not should_process_dir(path): + return set() + + matching_files = set() + try: + for file_path in path.iterdir(): + if file_path.is_file() and file_path.suffix in file_types: + matching_files.add(file_path) + elif file_path.is_dir(): + matching_files.update(process_directory(file_path)) + except PermissionError: + log.error(f"Permission denied accessing {path}") + except Exception as e: + log.error(f"Error processing {path}: {e}") + + return matching_files + + # Start with files in root directory + files.update(f for f in root.iterdir() if f.is_file() and f.suffix in file_types) + + # Process subdirectories + for path in root.iterdir(): + if path.is_dir(): + files.update(process_directory(path)) + + return sorted(files) # Convert back to sorted list for consistent ordering + + +def create_zip_in_memory(files, spinner): + """Create a ZIP file in memory with progress updates.""" + tmp = tempfile.SpooledTemporaryFile(max_size=10 * 1024 * 1024) + total_files = len(files) + + with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zf: + for i, file in enumerate(files, 1): + try: + spinner.update_message(f"Adding files to zip ({i}/{total_files})") + zf.write(file) + except Exception as e: + log.error(f"Error adding {file} to zip: {e}") + + tmp.seek(0) + + # Get final zip size + current_pos = tmp.tell() + tmp.seek(0, 2) # Seek to end + zip_size = tmp.tell() + tmp.seek(current_pos) # Restore position + + def format_size(size_bytes): + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024 or unit == 'GB': + return f"{size_bytes:.1f}{unit}" + size_bytes /= 1024 + + # log.info(f" > Zip created: {format_size(zip_size)}") + + return tmp \ No newline at end of file diff --git a/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/agentstack.json b/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/agentstack.json index 5511a17a..3243f1a6 100644 --- a/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/agentstack.json +++ b/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/agentstack.json @@ -1,4 +1,5 @@ { + "project_name": "{{ cookiecutter.project_metadata.project_name }}", "framework": "{{ cookiecutter.framework }}", "agentstack_version": "{{ cookiecutter.project_metadata.agentstack_version }}", "template": "{{ cookiecutter.project_metadata.template }}", diff --git a/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/main.py b/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/main.py index c8c347d4..6f6d3dfc 100644 --- a/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/main.py +++ b/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/main.py @@ -4,15 +4,23 @@ import agentstack import agentops -agentops.init(default_tags=agentstack.get_tags()) +agentops.init(default_tags=agentstack.get_tags(), skip_auto_end_session=True, auto_start_session=False) instance = {{cookiecutter.project_metadata.project_name|replace('-', '')|replace('_', '')|capitalize}}Crew().crew() -def run(): +def run() -> [str, str]: """ Run the agent. + Returns: + A Tuple: (The output of running the agent, agentops session_id) """ - instance.kickoff(inputs=agentstack.get_inputs()) + session = agentops.start_session() + try: + result = instance.kickoff(inputs=agentstack.get_inputs()) + session.end_session(end_state="Success") + return result.raw, str(session.session_id) + except: + session.end_session(end_state="Fail") def train(): diff --git a/agentstack/log.py b/agentstack/log.py index 9ee0a3d6..1c7df437 100644 --- a/agentstack/log.py +++ b/agentstack/log.py @@ -24,7 +24,6 @@ """ from typing import IO, Optional, Callable -import os, sys import io import logging from agentstack import conf diff --git a/agentstack/main.py b/agentstack/main.py index 3139a9a5..094f113e 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -1,3 +1,4 @@ +import asyncio import sys import argparse import webbrowser @@ -8,17 +9,19 @@ init_project, list_tools, add_tool, - remove_tool, add_agent, add_task, run_project, export_template, + # serve_project ) +from agentstack.cli.cli import serve_project from agentstack.telemetry import track_cli_command, update_telemetry from agentstack.utils import get_version, term_color from agentstack import generation from agentstack import repo from agentstack.update import check_for_updates +from agentstack.deploy import deploy def _main(): @@ -36,7 +39,7 @@ def _main(): action="store_true", ) global_parser.add_argument( - "--no-git", + "--no-git", help="Disable automatic git commits of changes to your project.", dest="no_git", action="store_true", @@ -149,13 +152,21 @@ def _main(): ) tools_remove_parser.add_argument("name", help="Name of the tool to remove") + # 'export' export_parser = subparsers.add_parser( 'export', aliases=['e'], help='Export your agent as a template', parents=[global_parser] ) export_parser.add_argument('filename', help='The name of the file to export to') + # 'update' update = subparsers.add_parser('update', aliases=['u'], help='Check for updates', parents=[global_parser]) + # 'deploy' + deploy_ = subparsers.add_parser('deploy', aliases=['d'], help='Deploy your agent to AgentStack.sh', parents=[global_parser]) + + # 'serve' command + serve_parser = subparsers.add_parser('serve', aliases=['s'], help='Serve your agent') + # Parse known args and store unknown args in extras; some commands use them later on args, extra_args = parser.parse_known_args() @@ -206,6 +217,12 @@ def _main(): # inside project dir commands only elif args.command in ["run", "r"]: run_project(command=args.function, cli_args=extra_args) + elif args.command in ['deploy', 'd']: + conf.assert_project() + asyncio.run(deploy()) + elif args.command in ['serve', 's']: + conf.assert_project() + serve_project() elif args.command in ['generate', 'g']: if args.generate_command in ['agent', 'a']: add_agent( diff --git a/agentstack/serve/Dockerfile b/agentstack/serve/Dockerfile new file mode 100644 index 00000000..3bcf8028 --- /dev/null +++ b/agentstack/serve/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-slim-bookworm +RUN rm /bin/sh && ln -s /bin/bash /bin/sh + +WORKDIR /app + +RUN apt update && apt install -y git gcc build-essential tree +RUN apt clean && rm -rf /var/lib/apt/lists/* + +# Install uv +RUN pip install --no-cache-dir uv + +# Copy everything since we're installing local package 3 +COPY . . + +RUN uv pip install --system psutil +RUN uv pip install --system flask +RUN uv pip install --system gunicorn +RUN uv pip install --system git+https://github.com/AgentOps-AI/AgentStack.git@deploy-command +RUN mkdir -p src +RUN cp /usr/local/lib/python3.12/site-packages/agentstack/serve/serve.py ./src +RUN cp /usr/local/lib/python3.12/site-packages/agentstack/serve/gunicorn.config.py ./src +RUN uv pip install --system . +RUN rm -rf /root/.cache/uv/* /root/.cache/pip/* /tmp/* + +# Expose the port +EXPOSE 6969 + +# Use Gunicorn with config file +CMD ["gunicorn", "--config", "src/gunicorn.config.py", "src.serve:app"] \ No newline at end of file diff --git a/agentstack/serve/gunicorn.config.py b/agentstack/serve/gunicorn.config.py new file mode 100644 index 00000000..a6c32322 --- /dev/null +++ b/agentstack/serve/gunicorn.config.py @@ -0,0 +1,18 @@ +import multiprocessing +import os + +bind = f"0.0.0.0:{os.getenv('PORT') or '6969'}" +workers = 1 +threads = 1 +worker_class = "sync" +max_requests = 1 +max_requests_jitter = 0 +timeout = 300 +keepalive = 2 +worker_connections = 1 +errorlog = "-" +accesslog = "-" +capture_output = True + +def post_worker_init(worker): + worker.nr = 1 \ No newline at end of file diff --git a/agentstack/serve/serve.py b/agentstack/serve/serve.py new file mode 100644 index 00000000..aac6bcc5 --- /dev/null +++ b/agentstack/serve/serve.py @@ -0,0 +1,178 @@ +import importlib +import sys +from pathlib import Path +from urllib.parse import urlparse + +from dotenv import load_dotenv +from agentstack import conf, frameworks, inputs, 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 + +MAIN_FILENAME: Path = Path("src/main.py") +MAIN_MODULE_NAME = "main" + +load_dotenv(dotenv_path="/app/.env") +app = Flask(__name__) + +current_webhook_url = 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)}") + raise + +@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.""" + 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 + + 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)) + + assert spec is not None + assert spec.loader is not None + + 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]: + """Validates a URL and returns a tuple of (is_valid, error_message).""" + if not url: + return False, "URL cannot be empty" + + try: + result = urlparse(url) + + if not result.scheme: + return False, "Missing protocol (e.g., http:// or https://)" + + if not result.netloc: + return False, "Missing domain name" + + if result.scheme not in ['http', 'https']: + return False, f"Invalid protocol: {result.scheme}" + + if '.' not in result.netloc: + return False, "Invalid domain format" + + return True, "" + + except Exception as e: + return False, f"Invalid URL format: {str(e)}" + + +def get_waitress_config(): + return { + 'host': '0.0.0.0', + 'port': int(os.getenv('PORT') or '6969'), + 'threads': 1, # Similar to Gunicorn threads + 'connection_limit': 1, # Similar to worker_connections + 'channel_timeout': 300, # Similar to timeout + 'cleanup_interval': 2, # Similar to keepalive + 'log_socket_errors': True, + 'max_request_body_size': 1073741824, # 1GB + 'clear_untrusted_proxy_headers': True + } + +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 + + 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/pyproject.toml b/pyproject.toml index 5cbc9bc8..7ed57b34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agentstack" -version = "0.3.3" +version = "0.3.5" description = "The fastest way to build robust AI agents" authors = [ { name="Braelyn Boynton", email="bboynton97@gmail.com" }, @@ -33,6 +33,7 @@ dependencies = [ "uv>=0.5.6", "tomli>=2.2.1", "gitpython>=3.1.44", + "websockets>=14.2" ] [project.optional-dependencies] diff --git a/tests/test_cli_spinner.py b/tests/test_cli_spinner.py new file mode 100644 index 00000000..b0bad8a4 --- /dev/null +++ b/tests/test_cli_spinner.py @@ -0,0 +1,116 @@ +import unittest +from unittest.mock import patch, MagicMock, call +import time +import threading +from io import StringIO +from agentstack.cli.spinner import Spinner + + +class TestSpinner(unittest.TestCase): + def setUp(self): + """Set up test cases.""" + self.mock_stdout_patcher = patch('sys.stdout', new_callable=StringIO) + self.mock_stdout = self.mock_stdout_patcher.start() + + self.mock_terminal_patcher = patch('shutil.get_terminal_size') + self.mock_terminal = self.mock_terminal_patcher.start() + self.mock_terminal.return_value = MagicMock(columns=80) + + # Patch the log module where Spinner is importing it + self.mock_log_patcher = patch('agentstack.cli.spinner.log') + self.mock_log = self.mock_log_patcher.start() + + def tearDown(self): + """Clean up after tests.""" + self.mock_stdout_patcher.stop() + self.mock_terminal_patcher.stop() + self.mock_log_patcher.stop() + + def test_spinner_initialization(self): + """Test spinner initialization.""" + spinner = Spinner(message="Test") + self.assertEqual(spinner.message, "Test") + self.assertEqual(spinner.delay, 0.1) + self.assertFalse(spinner.running) + self.assertIsNone(spinner.spinner_thread) + self.assertIsNone(spinner.start_time) + + def test_context_manager(self): + """Test spinner works as context manager.""" + with Spinner("Test") as spinner: + self.assertTrue(spinner.running) + self.assertTrue(spinner.spinner_thread.is_alive()) + time.sleep(0.2) + + self.assertFalse(spinner.running) + self.assertFalse(spinner.spinner_thread.is_alive()) + + def test_clear_and_log(self): + """Test clear_and_log functionality.""" + test_message = "Test log message" + with Spinner("Test") as spinner: + spinner.clear_and_log(test_message) + time.sleep(0.2) + + # Verify log.success was called with the message + self.mock_log.success.assert_called_once_with(test_message) + + def test_concurrent_logging(self): + """Test thread safety of logging while spinner is running.""" + messages = ["Message 0", "Message 1", "Message 2"] + + def log_messages(spinner): + for msg in messages: + spinner.clear_and_log(msg) + time.sleep(0.1) + + with Spinner("Test") as spinner: + thread = threading.Thread(target=log_messages, args=(spinner,)) + thread.start() + thread.join() + + # Verify all messages were logged + self.assertEqual(self.mock_log.success.call_count, len(messages)) + self.mock_log.success.assert_has_calls([call(msg) for msg in messages]) + + def test_thread_cleanup(self): + """Test proper thread cleanup after stopping.""" + spinner = Spinner("Test") + spinner.start() + time.sleep(0.2) + spinner.clear_and_log("Test message") + spinner.stop() + + # Give thread time to clean up + time.sleep(0.1) + self.assertFalse(spinner.running) + self.assertFalse(spinner.spinner_thread.is_alive()) + self.mock_log.success.assert_called_once_with("Test message") + + def test_rapid_message_updates(self): + """Test spinner handles rapid message updates and logging.""" + messages = [f"Message {i}" for i in range(5)] + with Spinner("Initial") as spinner: + for msg in messages: + spinner.update_message(msg) + spinner.clear_and_log(f"Logged: {msg}") + time.sleep(0.05) + + # Verify all messages were logged + self.assertEqual(self.mock_log.success.call_count, len(messages)) + self.mock_log.success.assert_has_calls([ + call(f"Logged: {msg}") for msg in messages + ]) + + @patch('time.time') + def test_elapsed_time_display(self, mock_time): + """Test elapsed time is displayed correctly.""" + mock_time.side_effect = [1000, 1001, 1002] # Mock timestamps + + with Spinner("Test") as spinner: + spinner.clear_and_log("Time check") + time.sleep(0.2) + output = self.mock_stdout.getvalue() + self.assertIn("[1.0s]", output) + self.mock_log.success.assert_called_once_with("Time check") + diff --git a/tests/test_deploy.py b/tests/test_deploy.py new file mode 100644 index 00000000..57e74603 --- /dev/null +++ b/tests/test_deploy.py @@ -0,0 +1,149 @@ +import os +import unittest +from unittest.mock import patch, Mock, mock_open +from pathlib import Path +import tempfile +import zipfile + +from agentstack.deploy import deploy, collect_files, create_zip_in_memory, get_project_id, load_pyproject + +class TestDeployFunctions(unittest.TestCase): + def setUp(self): + # Common setup for tests + self.bearer_token = "test_token" + self.project_id = "test_project_123" + + @patch('agentstack.deploy.get_stored_token') + @patch('agentstack.deploy.login') + @patch('agentstack.deploy.get_project_id') + @patch('agentstack.deploy.requests.post') + @patch('agentstack.deploy.webbrowser.open') + def test_deploy_success(self, mock_browser, mock_post, mock_get_project, mock_login, mock_token): + # Setup mocks + mock_token.return_value = self.bearer_token + mock_get_project.return_value = self.project_id + mock_post.return_value.status_code = 200 + + # Call deploy function + deploy() + + # Verify API call + mock_post.assert_called_once() + self.assertIn('/deploy/build', mock_post.call_args[0][0]) + self.assertIn('Bearer test_token', mock_post.call_args[1]['headers']['Authorization']) + + # Verify browser opened + mock_browser.assert_called_once_with(f"http://localhost:5173/project/{self.project_id}") + + @patch('agentstack.deploy.get_stored_token') + @patch('agentstack.deploy.login') + def test_deploy_no_auth(self, mock_login, mock_token): + # Setup mocks for failed authentication + mock_token.return_value = None + mock_login.return_value = False + + # Call deploy function + deploy() + + # Verify login was attempted + mock_login.assert_called_once() + mock_token.assert_called_once() + + def test_collect_files(self): + # Create temporary directory structure + with tempfile.TemporaryDirectory() as tmpdir: + # Create test files + Path(tmpdir, 'test.py').touch() + Path(tmpdir, 'test.toml').touch() + Path(tmpdir, 'ignore.txt').touch() + + # Create subdirectory with files + subdir = Path(tmpdir, 'subdir') + subdir.mkdir() + Path(subdir, 'sub.py').touch() + + # Create excluded directory + venv = Path(tmpdir, '.venv') + venv.mkdir() + Path(venv, 'venv.py').touch() + + # Collect files + files = collect_files(tmpdir, ('.py', '.toml')) + + # Verify results + file_names = {f.name for f in files} + self.assertIn('test.py', file_names) + self.assertIn('test.toml', file_names) + self.assertIn('sub.py', file_names) + self.assertNotIn('ignore.txt', file_names) + self.assertNotIn('venv.py', file_names) + + @patch('agentstack.deploy.requests.post') + def test_get_project_id_create_new(self, mock_post): + # Mock successful project creation + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = {'id': 'new_project_123'} + + # Mock ConfigFile + with patch('agentstack.deploy.ConfigFile') as mock_config: + mock_config.return_value.hosted_project_id = None + mock_config.return_value.project_name = 'test_project' + + project_id = get_project_id() + + self.assertEqual(project_id, 'new_project_123') + mock_post.assert_called_once() + + def test_load_pyproject(self): + # Test with valid pyproject.toml + mock_toml_content = b''' + [project] + name = "test-project" + version = "1.0.0" + ''' + + mock_file = mock_open(read_data=mock_toml_content) + with patch('builtins.open', mock_file): + with patch('os.path.exists') as mock_exists: + mock_exists.return_value = True + result = load_pyproject() + + # Verify file was opened in binary mode + mock_file.assert_called_once_with("pyproject.toml", "rb") + + self.assertIsNotNone(result) + self.assertIn('project', result) + self.assertEqual(result['project']['name'], 'test-project') + + @patch('agentstack.deploy.log.error') + def test_create_zip_in_memory(self, mock_log): + # Create temporary test files + with tempfile.TemporaryDirectory() as tmpdir: + # Create a test directory structure + test_dir = Path(tmpdir) + test_file = test_dir / 'test.py' + test_file.write_text('print("test")') + + # Create mock spinner + mock_spinner = Mock() + + # Test zip creation + # We'll need to change to the temp directory to maintain correct relative paths + original_dir = Path.cwd() + try: + os.chdir(tmpdir) + # Now use relative path for the file + files = [Path('test.py')] + zip_file = create_zip_in_memory(files, mock_spinner) + + # Verify zip contents + with zipfile.ZipFile(zip_file, 'r') as zf: + self.assertIn('test.py', zf.namelist()) + # Additional verification of zip contents + self.assertEqual(len(zf.namelist()), 1) + with zf.open('test.py') as f: + content = f.read().decode('utf-8') + self.assertEqual(content, 'print("test")') + finally: + # Make sure we always return to the original directory + os.chdir(original_dir)