diff --git a/MANIFEST.in b/MANIFEST.in index 44aa8e5a..30c4bfb9 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,7 @@ +# Include all template files with explicit patterns recursive-include agentstack/templates * +recursive-include agentstack/templates/agent_protocol *.* +recursive-include agentstack/templates/agent_protocol/{{cookiecutter.project_metadata.project_slug}} *.* +recursive-include agentstack/templates/agent_protocol/{{cookiecutter.project_metadata.project_slug}}/src *.* recursive-include agentstack/tools * -include agentstack.json .env .env.example \ No newline at end of file +include agentstack.json .env .env.example diff --git a/agentstack/cli/cli.py b/agentstack/cli/cli.py index 0c085d5d..2b79371f 100644 --- a/agentstack/cli/cli.py +++ b/agentstack/cli/cli.py @@ -3,6 +3,7 @@ import time from datetime import datetime from pathlib import Path +import webbrowser import json import shutil @@ -159,29 +160,21 @@ def configure_default_model(): def ask_framework() -> str: - framework = "CrewAI" - # framework = inquirer.list_input( - # message="What agent framework do you want to use?", - # choices=["CrewAI", "Autogen", "LiteLLM", "Learn what these are (link)"], - # ) - # - # if framework == "Learn what these are (link)": - # webbrowser.open("https://youtu.be/xvFZjo5PgG0") - # framework = inquirer.list_input( - # message="What agent framework do you want to use?", - # choices=["CrewAI", "Autogen", "LiteLLM"], - # ) - # - # while framework in ['Autogen', 'LiteLLM']: - # print(f"{framework} support coming soon!!") - # framework = inquirer.list_input( - # message="What agent framework do you want to use?", - # choices=["CrewAI", "Autogen", "LiteLLM"], - # ) + framework = inquirer.list_input( + message="What agent framework do you want to use?", + choices=["CrewAI", "Agent Protocol", "Learn what these are (link)"], + ) + + if framework == "Learn what these are (link)": + webbrowser.open("https://youtu.be/xvFZjo5PgG0") + framework = inquirer.list_input( + message="What agent framework do you want to use?", + choices=["CrewAI", "Agent Protocol"], + ) print("Congrats! Your project is ready to go! Quickly add features now or skip to do it later.\n\n") - return framework + return framework.lower() def ask_design() -> dict: diff --git a/agentstack/exceptions.py b/agentstack/exceptions.py index c0e95569..4c452470 100644 --- a/agentstack/exceptions.py +++ b/agentstack/exceptions.py @@ -1,3 +1,8 @@ +""" +AgentStack exception classes. +""" + + class ValidationError(Exception): """ Raised when a validation error occurs ie. a file does not meet the required @@ -5,3 +10,27 @@ class ValidationError(Exception): """ pass + + +class AgentProtocolError(ValidationError): + """Base exception for agent-protocol errors.""" + + pass + + +class ThreadError(AgentProtocolError): + """Errors related to thread operations in agent-protocol.""" + + pass + + +class RunError(AgentProtocolError): + """Errors related to run operations in agent-protocol.""" + + pass + + +class StoreError(AgentProtocolError): + """Errors related to store operations in agent-protocol.""" + + pass diff --git a/agentstack/frameworks/__init__.py b/agentstack/frameworks/__init__.py index 0da933ab..9aa461da 100644 --- a/agentstack/frameworks/__init__.py +++ b/agentstack/frameworks/__init__.py @@ -11,7 +11,8 @@ CREWAI = 'crewai' -SUPPORTED_FRAMEWORKS = [CREWAI, ] +AGENT_PROTOCOL = 'agent_protocol' +SUPPORTED_FRAMEWORKS = [CREWAI, AGENT_PROTOCOL] class FrameworkModule(Protocol): """ diff --git a/agentstack/frameworks/agent_protocol.py b/agentstack/frameworks/agent_protocol.py new file mode 100644 index 00000000..8e589c4f --- /dev/null +++ b/agentstack/frameworks/agent_protocol.py @@ -0,0 +1,231 @@ +""" +Agent Protocol framework implementation for AgentStack. + +This module implements the agent-protocol specification (https://github.com/langchain-ai/agent-protocol) +as a framework within AgentStack. +""" + +from typing import Optional, Any, List +from pathlib import Path +import ast +import re + +from agentstack.exceptions import ValidationError, ThreadError, RunError, StoreError +from agentstack.tools import ToolConfig +from agentstack.tasks import TaskConfig +from agentstack.agents import AgentConfig +from agentstack import conf, frameworks + + +ENTRYPOINT: Path = Path('src/agent.py') + + +class AgentProtocolFile: + """ + Handles agent-protocol specific file operations. + + This class manages the interaction with agent-protocol files, including + validation, reading, and writing operations. + """ + + def __init__(self, path: Path): + self.path = path + if not self.path.exists(): + raise ValidationError(f"Agent protocol file not found at {self.path}") + + self._content = self.path.read_text() + + def validate(self) -> None: + """Validate the agent protocol file structure.""" + try: + tree = ast.parse(self._content) + except SyntaxError as e: + raise ValidationError(f"Invalid Python syntax in {self.path}: {e}") + + # Check for FastAPI app and required components + has_app = False + has_agent_protocol = False + has_tools = False + has_task_handler = False + has_step_handler = False + + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name): + if target.id == 'app': + has_app = True + elif target.id == 'agent_protocol': + has_agent_protocol = True + elif target.id == 'tools': + has_tools = True + elif isinstance(node, ast.FunctionDef): + for decorator in node.decorator_list: + # Handle all possible decorator patterns + decorator_str = '' + if isinstance(decorator, ast.Name): + decorator_str = decorator.id + elif isinstance(decorator, ast.Attribute): + # Build full decorator path (e.g., agent_protocol.on_task) + parts = [] + current = decorator + while isinstance(current, ast.Attribute): + parts.append(current.attr) + current = current.value + if isinstance(current, ast.Name): + parts.append(current.id) + decorator_str = '.'.join(reversed(parts)) + elif isinstance(decorator, ast.Call): + # Handle decorator calls (e.g., @decorator()) + if isinstance(decorator.func, ast.Attribute): + parts = [] + current = decorator.func + while isinstance(current, ast.Attribute): + parts.append(current.attr) + current = current.value + if isinstance(current, ast.Name): + parts.append(current.id) + decorator_str = '.'.join(reversed(parts)) + elif isinstance(decorator.func, ast.Name): + decorator_str = decorator.func.id + + # Check for task and step handlers + if 'on_task' in decorator_str: + has_task_handler = True + elif 'on_step' in decorator_str: + has_step_handler = True + + if not has_app: + raise ValidationError(f"FastAPI app not found in {self.path}") + if not has_agent_protocol: + raise ValidationError(f"AgentProtocol instance not found in {self.path}") + if not has_tools: + raise ValidationError(f"Tools list not found in {self.path}") + if not has_task_handler: + raise ValidationError(f"@agent_protocol.on_task handler not found in {self.path}") + if not has_step_handler: + raise ValidationError(f"@agent_protocol.on_step handler not found in {self.path}") + + def _update_tools_list(self, tool: ToolConfig, add: bool = True) -> None: + """Update the tools list in the file.""" + if add: + if tool.tools_bundled: + tool_str = f"*tools.{tool.name}" + else: + tool_str = f"tools.{tool.name}" + + # If tools list exists, modify it + if re.search(r"tools\s*=\s*\[", self._content): + if tool_str not in self._content: + # Replace existing tools list with updated one + self._content = re.sub( + r"tools\s*=\s*\[(.*?)\]", + lambda m: f"tools=[{tool_str}]" + if not m.group(1).strip() + else f"tools=[{', '.join(t.strip() for t in m.group(1).split(',') if t.strip())}, {tool_str}]", + self._content, + flags=re.DOTALL, + ) + else: + # Create new tools list + self._content = re.sub( + r"(import tools\n+)", f"\\1tools=[{tool_str}] # Initial tool setup\n\n", self._content + ) + else: + # Remove tool from list + if tool.tools_bundled: + tool_str = f"*tools.{tool.name}" + else: + tool_str = f"tools.{tool.name}" + + # First, get the current tools list content + match = re.search(r"tools\s*=\s*\[(.*?)\]", self._content, re.DOTALL) + if match: + tools = [t.strip() for t in match.group(1).split(',') if t.strip()] + # Remove the tool if it exists + tools = [t for t in tools if t != tool_str] + # Update the tools list with proper format + self._content = re.sub( + r"tools\s*=\s*\[.*?\]", + f"tools=[{', '.join(tools)}]" if tools else "tools=[]", + self._content, + flags=re.DOTALL, + ) + + # Write updated content back to file + self.path.write_text(self._content) + + +def validate_project() -> None: + """Validate the agent protocol project structure.""" + entrypoint_path = frameworks.get_entrypoint_path('agent_protocol') + if not entrypoint_path.exists(): + raise ValidationError("Agent Protocol entrypoint file not found") + + file = AgentProtocolFile(entrypoint_path) + file.validate() + + # Additional project-level validation can be added here + + +def get_task_names() -> List[str]: + """ + Get a list of task names from the agent protocol implementation. + Currently returns an empty list as tasks are created dynamically. + """ + return [] + + +def add_task(task: TaskConfig) -> None: + """ + Add a task to the agent protocol implementation. + Tasks in agent-protocol are created dynamically through the API. + """ + pass + + +def get_agent_names() -> List[str]: + """ + Get a list of available agent names. + Currently returns a single agent as agent-protocol typically uses one agent. + """ + return ["agent_protocol_agent"] + + +def add_agent(agent: AgentConfig) -> None: + """ + Register a new agent in the agent protocol implementation. + Updates the agent configuration in the agent.py file. + """ + pass + + +def add_tool(tool: ToolConfig, agent_name: str) -> None: + """ + Add a tool to the specified agent in the agent protocol implementation. + + Args: + tool: Tool configuration to add + agent_name: Name of the agent to add the tool to + """ + entrypoint_path = frameworks.get_entrypoint_path('agent_protocol') + if not entrypoint_path.exists(): + raise ValidationError("Agent Protocol entrypoint file not found") + + # Validate project before adding tool + validate_project() + + agent_file = AgentProtocolFile(entrypoint_path) + agent_file._update_tools_list(tool, add=True) + + +def remove_tool(tool: ToolConfig, agent_name: str) -> None: + """ + Remove a tool from the specified agent in the agent protocol implementation. + + Args: + tool: Tool configuration to remove + agent_name: Name of the agent to remove the tool from + """ + agent_file = AgentProtocolFile(conf.PATH / ENTRYPOINT) + agent_file._update_tools_list(tool, add=False) diff --git a/agentstack/generation/__init__.py b/agentstack/generation/__init__.py index 477b8999..ca09bc61 100644 --- a/agentstack/generation/__init__.py +++ b/agentstack/generation/__init__.py @@ -1,4 +1,5 @@ from .agent_generation import add_agent from .task_generation import add_task from .tool_generation import add_tool, remove_tool -from .files import EnvFile, ProjectFile \ No newline at end of file +from .files import EnvFile, ProjectFile +from .project_generation import generate_project diff --git a/agentstack/generation/project_generation.py b/agentstack/generation/project_generation.py new file mode 100644 index 00000000..05d9eebf --- /dev/null +++ b/agentstack/generation/project_generation.py @@ -0,0 +1,71 @@ +""" +Project generation utilities for AgentStack. + +This module handles the generation of new projects using cookiecutter templates +and framework-specific configurations. +""" + +import os +from pathlib import Path +import shutil +from cookiecutter.main import cookiecutter +from agentstack.utils import get_package_path +from agentstack.exceptions import ValidationError + + +def generate_project(project_dir: Path, framework: str, project_name: str, project_slug: str) -> None: + """ + Generate a new project using the appropriate template for the specified framework. + + Args: + project_dir: Directory where the project should be created + framework: Name of the framework to use (e.g., 'crewai', 'agent_protocol') + project_name: Human-readable name of the project + project_slug: URL-friendly slug for the project + """ + # Get absolute path to template directory using package path + package_path = get_package_path() + template_dir = package_path / 'templates' / framework + + # Validate template directory and cookiecutter.json existence + if not template_dir.exists(): + raise ValidationError( + f"Template directory for framework '{framework}' not found at {template_dir}. " + f"Package path: {package_path}" + ) + + cookiecutter_json = template_dir / 'cookiecutter.json' + if not cookiecutter_json.exists(): + raise ValidationError( + f"cookiecutter.json not found in template directory at {cookiecutter_json}. " + f"Directory contents: {list(template_dir.iterdir())}" + ) + + # Create project directory if it doesn't exist + os.makedirs(project_dir, exist_ok=True) + + # Generate project using cookiecutter with absolute path + cookiecutter( + str(template_dir.absolute()), + output_dir=str(project_dir.parent.absolute()), + no_input=True, + extra_context={ + 'project_metadata': { + 'project_name': project_name, + 'project_slug': project_slug, + } + }, + ) + + # Move contents from nested directory to project_dir + nested_dir = project_dir.parent / project_slug + if nested_dir.exists() and nested_dir != project_dir: + for item in nested_dir.iterdir(): + target_path = project_dir / item.name + if target_path.exists(): + if target_path.is_dir(): + shutil.rmtree(target_path) + else: + target_path.unlink() + shutil.move(str(item), str(target_path)) + nested_dir.rmdir() diff --git a/agentstack/templates/agent_protocol/{{cookiecutter.project_metadata.project_slug}}/.env.example b/agentstack/templates/agent_protocol/{{cookiecutter.project_metadata.project_slug}}/.env.example new file mode 100644 index 00000000..d591dd59 --- /dev/null +++ b/agentstack/templates/agent_protocol/{{cookiecutter.project_metadata.project_slug}}/.env.example @@ -0,0 +1,5 @@ +# Agent Protocol Configuration +AGENT_PROTOCOL_PORT=8000 + +# LLM Configuration +OPENAI_API_KEY=your-openai-api-key-here diff --git a/agentstack/templates/agent_protocol/{{cookiecutter.project_metadata.project_slug}}/src/agent.py b/agentstack/templates/agent_protocol/{{cookiecutter.project_metadata.project_slug}}/src/agent.py new file mode 100644 index 00000000..d8c5c464 --- /dev/null +++ b/agentstack/templates/agent_protocol/{{cookiecutter.project_metadata.project_slug}}/src/agent.py @@ -0,0 +1,47 @@ +""" +Agent Protocol implementation for AgentStack. + +This module implements a FastAPI server that follows the agent-protocol specification +for serving LLM agents in production. +""" +from typing import Optional, Dict, Any +from fastapi import FastAPI +from agent_protocol import AgentProtocol, Step, Task + +app = FastAPI() +agent_protocol = AgentProtocol(app) + + +@agent_protocol.on_task +async def handle_task(task: Task) -> None: + """ + Handle incoming tasks from the agent protocol. + + Args: + task: The task to be processed + """ + # Initialize task processing + await task.step.create_step( + "Initializing task processing", + is_last=False + ) + + +@agent_protocol.on_step +async def handle_step(step: Step) -> None: + """ + Handle individual steps within a task. + + Args: + step: The step to be processed + """ + # Process step and return result + await step.create_step( + "Processing step", + is_last=True + ) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/pyproject.toml b/pyproject.toml index 69f3c301..a9d4d689 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools", "wheel"] +requires = ["setuptools>=61.0.0", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -47,8 +47,13 @@ crewai = [ [tool.setuptools.package-data] -agentstack = ["templates/**/*"] - +agentstack = [ + "templates/**/*", + "templates/agent_protocol/**/*", + "templates/agent_protocol/{{cookiecutter.project_metadata.project_slug}}/**/*", + "templates/agent_protocol/{{cookiecutter.project_metadata.project_slug}}/src/**/*", + "tools/**/*" +] [project.scripts] agentstack = "agentstack.main:main" diff --git a/tests/fixtures/frameworks/agent_protocol/entrypoint_max.py b/tests/fixtures/frameworks/agent_protocol/entrypoint_max.py new file mode 100644 index 00000000..f943bb95 --- /dev/null +++ b/tests/fixtures/frameworks/agent_protocol/entrypoint_max.py @@ -0,0 +1,47 @@ +""" +Maximum implementation of agent-protocol for testing. +""" + +from typing import Optional, Dict, Any, List +from fastapi import FastAPI +from agent_protocol import AgentProtocol, Step, Task + +import tools + +app = FastAPI() +agent_protocol = AgentProtocol(app) + + +def test_agent() -> None: + pass + + +def test_agent_two() -> None: + pass + + +def task_test() -> None: + pass + + +def task_test_two() -> None: + pass + + +tools = [] + + +@agent_protocol.on_task +async def handle_task(task: Task) -> None: + await task.step.create_step("Processing task", is_last=False) + + +@agent_protocol.on_step +async def handle_step(step: Step) -> None: + await step.create_step("Processing step", is_last=True) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/tests/fixtures/frameworks/agent_protocol/entrypoint_min.py b/tests/fixtures/frameworks/agent_protocol/entrypoint_min.py new file mode 100644 index 00000000..7d6a943d --- /dev/null +++ b/tests/fixtures/frameworks/agent_protocol/entrypoint_min.py @@ -0,0 +1,29 @@ +""" +Minimal implementation of agent-protocol for testing. +""" + +from fastapi import FastAPI +from agent_protocol import AgentProtocol, Step, Task + +import tools + +app = FastAPI() +agent_protocol = AgentProtocol(app) + +tools = [] # Initial tool setup + + +@agent_protocol.on_task +async def handle_task(task: Task) -> None: + pass + + +@agent_protocol.on_step +async def handle_step(step: Step) -> None: + pass + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/tests/fixtures/frameworks/agent_protocol/tools.py b/tests/fixtures/frameworks/agent_protocol/tools.py new file mode 100644 index 00000000..cca03f65 --- /dev/null +++ b/tests/fixtures/frameworks/agent_protocol/tools.py @@ -0,0 +1,13 @@ +""" +Test tools for agent-protocol framework. +""" + +from typing import List + + +def test_tool(): + """Test tool implementation.""" + pass + + +test_tool_star: List = [test_tool] diff --git a/tests/test_project_run.py b/tests/test_project_run.py index cb30f133..5706a24c 100644 --- a/tests/test_project_run.py +++ b/tests/test_project_run.py @@ -8,6 +8,7 @@ from agentstack.conf import ConfigFile from agentstack import frameworks from agentstack.cli import run_project +from agentstack.generation import project_generation BASE_PATH = Path(__file__).parent @@ -17,12 +18,13 @@ class ProjectRunTest(unittest.TestCase): def setUp(self): self.project_dir = BASE_PATH / 'tmp/project_run' / self.framework - os.makedirs(self.project_dir) - os.makedirs(self.project_dir / 'src') - (self.project_dir / 'src' / '__init__.py').touch() - - with open(self.project_dir / 'src' / 'main.py', 'w') as f: - f.write('def run(): pass') + # Generate project using the proper template + project_generation.generate_project( + project_dir=self.project_dir, + framework=self.framework, + project_name="Test Project", + project_slug="test-project" + ) # set the framework in agentstack.json shutil.copy(BASE_PATH / 'fixtures' / 'agentstack.json', self.project_dir / 'agentstack.json') @@ -30,10 +32,6 @@ def setUp(self): with ConfigFile() as config: config.framework = self.framework - # populate the entrypoint - entrypoint_path = frameworks.get_entrypoint_path(self.framework) - shutil.copy(BASE_PATH / f"fixtures/frameworks/{self.framework}/entrypoint_max.py", entrypoint_path) - # write a basic .env file shutil.copy(BASE_PATH / 'fixtures' / '.env', self.project_dir / '.env')