From 70ad2c3e4faf793dc6a8f8af3d995c4d81accd91 Mon Sep 17 00:00:00 2001 From: Charlie-Zhou Date: Fri, 25 Jul 2025 15:51:01 +0800 Subject: [PATCH] Added Proxy Registration System and Related Proxy Implementations - Introduced a proxy registration system that supports dynamic loading and management of proxies via configuration files. - Implemented multiple proxies, including a GitHub proxy and a standard proxy, supporting basic features and tool listings. - Added test scripts to verify the fundamental functionality of the proxy registration system. - Provided example configuration files (in both YAML and JSON formats) to help users customize proxy settings. - Updated the proxy interface to ensure all proxy implementations adhere to a unified interface specification. Fixes #55 --- config/agents.json | 68 +++++ config/agents.yaml | 70 +++++ examples/agent_registry_demo.py | 225 ++++++++++++++++ examples/mcp/enhanced_tavily_agent.py | 345 ++++++++++++++++++++++++ spoon_ai/agents/__init__.py | 25 +- spoon_ai/agents/enhanced_base.py | 211 +++++++++++++++ spoon_ai/agents/github_agent.py | 196 ++++++++++++++ spoon_ai/agents/registry.py | 368 ++++++++++++++++++++++++++ test_agent_registry.py | 184 +++++++++++++ 9 files changed, 1691 insertions(+), 1 deletion(-) create mode 100644 config/agents.json create mode 100644 config/agents.yaml create mode 100644 examples/agent_registry_demo.py create mode 100644 examples/mcp/enhanced_tavily_agent.py create mode 100644 spoon_ai/agents/enhanced_base.py create mode 100644 spoon_ai/agents/github_agent.py create mode 100644 spoon_ai/agents/registry.py create mode 100644 test_agent_registry.py diff --git a/config/agents.json b/config/agents.json new file mode 100644 index 0000000..daa848f --- /dev/null +++ b/config/agents.json @@ -0,0 +1,68 @@ +{ + "plugin_directories": [ + "./plugins", + "./custom_agents" + ], + "agents": [ + { + "name": "tavily", + "class": "examples.mcp.tavily_search_agent.TavilySearchAgent", + "mcp_transport": "SSETransport", + "endpoint": "http://localhost:8765/sse", + "api_key": "TAVILY_API_KEY", + "description": "Intelligent web search agent based on Tavily API", + "max_steps": 5 + }, + { + "name": "github", + "class": "spoon_ai.agents.github_agent.GitHubAgent", + "mcp_transport": "SSETransport", + "endpoint": "http://localhost:8123/sse", + "description": "GitHub integration agent supporting repository management and issue tracking", + "max_steps": 10 + }, + { + "name": "chat", + "class": "spoon_ai.agents.enhanced_base.StandardAgent", + "description": "Standard conversation agent providing basic conversation functionality", + "system_prompt": "You are a helpful AI assistant that can answer various questions and provide assistance.", + "max_steps": 3 + }, + { + "name": "thirdweb", + "class": "examples.mcp.SpoonThirdWebagent.SpoonThirdWebAgent", + "transport_config": { + "type": "StdioTransport", + "command": "npx", + "args": ["-y", "thirdweb-mcp"], + "env": { + "THIRDWEB_SECRET_KEY": "your-thirdweb-secret-key" + } + }, + "description": "ThirdWeb blockchain integration agent", + "max_steps": 8 + }, + { + "name": "react", + "class": "spoon_ai.agents.spoon_react.SpoonReactAI", + "description": "ReAct framework agent supporting reasoning and action loops", + "system_prompt": "You are a ReAct agent capable of reasoning and taking actions to solve problems.", + "next_step_prompt": "Based on the current situation, consider what action to take next.", + "max_steps": 10 + } + ], + "global_config": { + "default_llm": { + "llm_provider": "openai", + "model_name": "anthropic/claude-sonnet-4", + "base_url": "https://openrouter.ai/api/v1" + }, + "default_agent_settings": { + "max_steps": 5 + }, + "logging": { + "level": "INFO", + "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + } + } +} \ No newline at end of file diff --git a/config/agents.yaml b/config/agents.yaml new file mode 100644 index 0000000..2eaa2cf --- /dev/null +++ b/config/agents.yaml @@ -0,0 +1,70 @@ +# Agent configuration file +# Supports dynamic loading and management of agents through configuration files + +# Plugin directory configuration (optional) +plugin_directories: + - "./plugins" + - "./custom_agents" + +# Agent configuration list +agents: + # Tavily search agent - using SSE transport + - name: "tavily" + class: "examples.mcp.tavily_search_agent.TavilySearchAgent" + mcp_transport: "SSETransport" + endpoint: "http://localhost:8765/sse" + api_key: "TAVILY_API_KEY" + description: "Intelligent web search agent based on Tavily API" + max_steps: 5 + + # GitHub agent - using SSE transport + - name: "github" + class: "spoon_ai.agents.github_agent.GitHubAgent" + mcp_transport: "SSETransport" + endpoint: "http://localhost:8123/sse" + description: "GitHub integration agent supporting repository management and issue tracking" + max_steps: 10 + + # Standard conversation agent - no MCP transport + - name: "chat" + class: "spoon_ai.agents.enhanced_base.StandardAgent" + description: "Standard conversation agent providing basic conversation functionality" + system_prompt: "You are a helpful AI assistant that can answer various questions and provide assistance." + max_steps: 3 + + # ThirdWeb integration agent - using stdio transport + - name: "thirdweb" + class: "examples.mcp.SpoonThirdWebagent.SpoonThirdWebAgent" + transport_config: + type: "StdioTransport" + command: "npx" + args: ["-y", "thirdweb-mcp"] + env: + THIRDWEB_SECRET_KEY: "your-thirdweb-secret-key" + description: "ThirdWeb blockchain integration agent" + max_steps: 8 + + # Custom ReAct agent + - name: "react" + class: "spoon_ai.agents.spoon_react.SpoonReactAI" + description: "ReAct framework agent supporting reasoning and action loops" + system_prompt: "You are a ReAct agent capable of reasoning and taking actions to solve problems." + next_step_prompt: "Based on the current situation, consider what action to take next." + max_steps: 10 + +# Global configuration (optional) +global_config: + # Default LLM configuration + default_llm: + llm_provider: "openai" + model_name: "anthropic/claude-sonnet-4" + base_url: "https://openrouter.ai/api/v1" + + # Default agent settings + default_agent_settings: + max_steps: 5 + + # Logging configuration + logging: + level: "INFO" + format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" \ No newline at end of file diff --git a/examples/agent_registry_demo.py b/examples/agent_registry_demo.py new file mode 100644 index 0000000..b32a458 --- /dev/null +++ b/examples/agent_registry_demo.py @@ -0,0 +1,225 @@ +""" +Agent Registry System Usage Example +Demonstrates how to use the new agent registry mechanism to dynamically load and manage agents +""" + +import asyncio +import logging +from pathlib import Path +from spoon_ai.agents.registry import AgentRegistry +from spoon_ai.chat import ChatBot + +# Setup logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def demo_agent_registry(): + """ + Demonstrate basic functionality of the agent registry system + """ + print("๐Ÿค– Agent Registry System Demo") + print("=" * 50) + + # 1. Create agent registry and load configuration + print("\n๐Ÿ“‹ Step 1: Load agent configuration") + config_path = Path("config/agents.yaml") + + try: + registry = AgentRegistry(config_path) + print(f"โœ… Successfully loaded config file: {config_path}") + except FileNotFoundError: + print(f"โš ๏ธ Config file not found: {config_path}") + print("Creating registry with default configuration...") + registry = AgentRegistry() + + # Manually add a simple agent configuration + simple_config = { + 'agents': [ + { + 'name': 'chat', + 'class': 'spoon_ai.agents.enhanced_base.StandardAgent', + 'description': 'Standard chat agent', + 'system_prompt': 'You are a helpful AI assistant.', + 'max_steps': 3 + } + ] + } + registry.config = simple_config + + # 2. Load agents + print("\n๐Ÿ”ง Step 2: Load agents") + try: + await registry.load_agents() + print("โœ… Agents loaded successfully") + except Exception as e: + print(f"โŒ Agent loading failed: {e}") + return + + # 3. List loaded agents + print("\n๐Ÿ“‹ Step 3: List loaded agents") + agent_names = registry.list_agents() + print(f"Number of loaded agents: {len(agent_names)}") + for name in agent_names: + agent = registry.get_agent(name) + print(f" - {name}: {agent.description if hasattr(agent, 'description') else 'No description'}") + + # 4. Test agent functionality + print("\n๐Ÿงช Step 4: Test agent functionality") + + # Get first agent for testing + if agent_names: + test_agent_name = agent_names[0] + test_agent = registry.get_agent(test_agent_name) + + print(f"Testing agent: {test_agent_name}") + + # Check if agent is initialized + if hasattr(test_agent, 'is_initialized') and test_agent.is_initialized(): + print("โœ… Agent is initialized") + else: + print("โš ๏ธ Agent is not initialized") + + # Get agent capabilities + if hasattr(test_agent, 'get_capabilities'): + capabilities = await test_agent.get_capabilities() + print(f"Agent capabilities: {capabilities}") + + # Health check + if hasattr(test_agent, 'health_check'): + health = await test_agent.health_check() + print(f"Health status: {health}") + + # List available tools + if hasattr(test_agent, 'list_tools'): + tools = await test_agent.list_tools() + print(f"Number of available tools: {len(tools)}") + for tool in tools[:3]: # Only show first 3 tools + print(f" - {tool.get('name', 'Unknown')}: {tool.get('description', 'No description')}") + + # Test chat functionality + print(f"\n๐Ÿ’ฌ Testing conversation with {test_agent_name}:") + try: + response = await test_agent.run("Hello, please introduce yourself.") + print(f"Agent response: {response}") + except Exception as e: + print(f"Conversation test failed: {e}") + + # 5. Dynamically add agent + print("\nโž• Step 5: Dynamically add agent") + + new_agent_config = { + 'name': 'dynamic_agent', + 'class': 'spoon_ai.agents.enhanced_base.StandardAgent', + 'description': 'Dynamically added agent', + 'system_prompt': 'You are a dynamically created AI assistant, specifically designed to demonstrate dynamic agent functionality.', + 'max_steps': 2 + } + + try: + new_agent = await registry.load_agent(new_agent_config) + print(f"โœ… Successfully added dynamic agent: {new_agent.name}") + + # Test new agent + response = await new_agent.run("How were you created?") + print(f"New agent response: {response}") + + except Exception as e: + print(f"โŒ Dynamic agent addition failed: {e}") + + # 6. Agent management + print("\n๐Ÿ—‚๏ธ Step 6: Agent management") + + # List agents again + updated_agent_names = registry.list_agents() + print(f"Current number of agents: {len(updated_agent_names)}") + + # Remove an agent + if 'dynamic_agent' in updated_agent_names: + success = await registry.remove_agent('dynamic_agent') + if success: + print("โœ… Successfully removed dynamic agent") + else: + print("โŒ Failed to remove dynamic agent") + + # 7. Cleanup + print("\n๐Ÿงน Step 7: Cleanup resources") + await registry.cleanup() + print("โœ… Cleanup completed") + + print("\n๐ŸŽ‰ Agent registry system demo completed!") + + +async def demo_plugin_system(): + """ + Demonstrate plugin system functionality (requires plugin directory) + """ + print("\n๐Ÿ”Œ Plugin System Demo") + print("=" * 30) + + # Create plugin directory example + plugin_dir = Path("plugins") + if not plugin_dir.exists(): + plugin_dir.mkdir() + print(f"Created plugin directory: {plugin_dir}") + + # Create example plugin + plugin_file = plugin_dir / "example_plugin.py" + plugin_content = ''' +""" +Example Plugin Agent +""" +from spoon_ai.agents.enhanced_base import EnhancedBaseAgent + +class ExamplePluginAgent(EnhancedBaseAgent): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.name = "PluginAgent" + self.description = "Example agent from plugin" + + async def step(self) -> str: + return "This is a response from the plugin agent!" +''' + + plugin_file.write_text(plugin_content, encoding='utf-8') + print(f"Created example plugin: {plugin_file}") + + # Configure registry with plugins + config_with_plugins = { + 'plugin_directories': ['./plugins'], + 'agents': [ + { + 'name': 'plugin_agent', + 'class': 'example_plugin.ExamplePluginAgent', + 'description': 'Agent loaded from plugin' + } + ] + } + + registry = AgentRegistry() + registry.config = config_with_plugins + registry.load_plugin_directories(['./plugins']) + + try: + await registry.load_agents() + plugin_agent = registry.get_agent('plugin_agent') + if plugin_agent: + print("โœ… Successfully loaded plugin agent") + response = await plugin_agent.run("Test plugin agent") + print(f"Plugin agent response: {response}") + else: + print("โŒ Plugin agent loading failed") + except Exception as e: + print(f"Plugin demo error: {e}") + + +async def main(): + """ + Main function, run all demos + """ + await demo_agent_registry() + await demo_plugin_system() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/mcp/enhanced_tavily_agent.py b/examples/mcp/enhanced_tavily_agent.py new file mode 100644 index 0000000..7c21c59 --- /dev/null +++ b/examples/mcp/enhanced_tavily_agent.py @@ -0,0 +1,345 @@ +""" +Enhanced Tavily Search Agent based on the new registration system +Demonstrates how to use EnhancedBaseAgent to create feature-rich agents +""" + +import os +import logging +from typing import Dict, Any, Optional +from fastmcp.client.transports import StdioTransport + +from spoon_ai.agents.enhanced_base import EnhancedBaseAgent +from spoon_ai.chat import ChatBot + +logger = logging.getLogger(__name__) + + +class EnhancedTavilyAgent(EnhancedBaseAgent): + """ + Enhanced Tavily Search Agent based on the new agent registration system + Provides intelligent web search functionality with support for multiple search types and result processing + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # Set basic agent information + self.name = "EnhancedTavilyAgent" + self.description = ( + "Enhanced Tavily search agent providing intelligent web search, news search, " + "research information collection and other functions with context-aware result processing" + ) + + # Tavily-specific configuration + self.api_key = kwargs.get('api_key', os.getenv('TAVILY_API_KEY')) + self.max_results = kwargs.get('max_results', 5) + self.search_depth = kwargs.get('search_depth', 'basic') + + # Set system prompt + self.system_prompt = """ + You are the Enhanced TavilySearchAgent, a professional web search AI assistant. + + Your core capabilities: + 1. Intelligent Web Search - Use Tavily API for high-quality web searches + 2. News and Current Events Search - Get the latest news and current information + 3. Research Information Collection - Deep collection of research materials on specific topics + 4. Context-Aware Processing - Optimize search strategies based on conversation context + 5. Intelligent Result Organization - Analyze and summarize search results + + Search Strategies: + - For general queries, use basic search mode + - For news queries, prioritize searching for latest information + - For research queries, perform deep searches and provide multi-perspective information + - For technical queries, seek authoritative sources and best practices + + Always provide accurate, timely, relevant information and cite information sources. + If search results are not ideal, will try to optimize search keywords and search again. + """ + + self.next_step_prompt = """ + Based on the user's query and existing search results, decide the next action: + 1. If more information is needed, optimize search keywords and search again + 2. If results are sufficient, organize and summarize information + 3. If the query is unclear, request user clarification + 4. If specific types of search are needed (such as news, academic), adjust search strategy + """ + + async def _initialize_agent(self) -> None: + """ + Initialize Tavily agent-specific logic + """ + # Validate API key + if not self.api_key or self.api_key == "your-api-key-here": + logger.warning( + "Tavily API key not set or using default value. " + "Please set environment variable TAVILY_API_KEY or specify api_key in configuration." + ) + else: + logger.info("Tavily API key configured") + + # If MCP transport configuration exists, verify connection + if hasattr(self, 'list_mcp_tools'): + try: + tools = await self.list_mcp_tools() + tavily_tools = [tool for tool in tools if 'tavily' in tool.name.lower()] + logger.info(f"Tavily agent initialization complete, available tools: {len(tavily_tools)}") + + if not tavily_tools: + logger.warning("No Tavily-related MCP tools found") + except Exception as e: + logger.warning(f"Error initializing Tavily MCP tools: {e}") + + # Initialize search history + self.search_history = [] + self.context_keywords = set() + + async def step(self) -> str: + """ + Execute one step of the Tavily agent + """ + messages = self.memory.get_messages() + if not messages: + return "No messages to process" + + last_message = messages[-1] + if last_message.role.value != "user": + return "Waiting for user input" + + user_query = last_message.content + + try: + # Analyze query type + query_type = self._analyze_query_type(user_query) + logger.info(f"Query type: {query_type}") + + # Extract keywords and update context + keywords = self._extract_keywords(user_query) + self.context_keywords.update(keywords) + + # Execute corresponding search strategy based on query type + if query_type == "news": + result = await self._search_news(user_query) + elif query_type == "research": + result = await self._research_search(user_query) + elif query_type == "technical": + result = await self._technical_search(user_query) + else: + result = await self._general_search(user_query) + + # Process and format results + formatted_response = await self._format_search_response(result, user_query, query_type) + + # Add to memory + self.add_message("assistant", formatted_response) + + # Update search history + self.search_history.append({ + "query": user_query, + "type": query_type, + "keywords": keywords, + "result_summary": result[:200] if isinstance(result, str) else "Search completed" + }) + + return formatted_response + + except Exception as e: + error_msg = f"Error during search process: {str(e)}" + logger.error(error_msg) + self.add_message("assistant", error_msg) + return error_msg + + def _analyze_query_type(self, query: str) -> str: + """ + Analyze query type to determine search strategy + """ + query_lower = query.lower() + + # News-related keywords + news_keywords = ['news', 'latest', 'recent', 'today', 'yesterday', 'this week', 'breaking', 'current'] + if any(keyword in query_lower for keyword in news_keywords): + return "news" + + # Research-related keywords + research_keywords = ['research', 'study', 'analysis', 'report', 'paper', 'survey', 'statistics', 'academic'] + if any(keyword in query_lower for keyword in research_keywords): + return "research" + + # Technical-related keywords + tech_keywords = ['tutorial', 'how to', 'configure', 'install', 'code', 'programming', 'development', 'api', 'documentation'] + if any(keyword in query_lower for keyword in tech_keywords): + return "technical" + + return "general" + + def _extract_keywords(self, query: str) -> set: + """ + Extract keywords from query + """ + # Simple keyword extraction (more complex NLP methods can be used in practice) + import re + words = re.findall(r'\b\w+\b', query.lower()) + # Filter common stop words + stop_words = {'the', 'is', 'in', 'and', 'or', 'but', 'if', 'because', 'a', 'an', 'to', 'for', 'of', 'with', 'by'} + keywords = {word for word in words if len(word) > 2 and word not in stop_words} + return keywords + + async def _search_news(self, query: str) -> str: + """ + Execute news search + """ + if hasattr(self, 'call_mcp_tool'): + try: + # Use news-specific search parameters + result = await self.call_mcp_tool( + 'tavily_search', + query=query, + search_depth='basic', + include_domains=['news.google.com', 'reuters.com', 'bbc.com', 'cnn.com'], + max_results=self.max_results + ) + return result + except Exception as e: + logger.error(f"News search failed: {e}") + return f"News search failed: {str(e)}" + else: + return "MCP tools not available, cannot execute news search" + + async def _research_search(self, query: str) -> str: + """ + Execute research deep search + """ + if hasattr(self, 'call_mcp_tool'): + try: + # Use deep search parameters + result = await self.call_mcp_tool( + 'tavily_search', + query=query, + search_depth='advanced', + include_domains=['scholar.google.com', 'arxiv.org', 'researchgate.net'], + max_results=self.max_results * 2 # Research search needs more results + ) + return result + except Exception as e: + logger.error(f"Research search failed: {e}") + return f"Research search failed: {str(e)}" + else: + return "MCP tools not available, cannot execute research search" + + async def _technical_search(self, query: str) -> str: + """ + Execute technical search + """ + if hasattr(self, 'call_mcp_tool'): + try: + # Use technical-specific search parameters + result = await self.call_mcp_tool( + 'tavily_search', + query=query, + search_depth='basic', + include_domains=['stackoverflow.com', 'github.com', 'docs.python.org', 'developer.mozilla.org'], + max_results=self.max_results + ) + return result + except Exception as e: + logger.error(f"Technical search failed: {e}") + return f"Technical search failed: {str(e)}" + else: + return "MCP tools not available, cannot execute technical search" + + async def _general_search(self, query: str) -> str: + """ + Execute general search + """ + if hasattr(self, 'call_mcp_tool'): + try: + result = await self.call_mcp_tool( + 'tavily_search', + query=query, + search_depth=self.search_depth, + max_results=self.max_results + ) + return result + except Exception as e: + logger.error(f"General search failed: {e}") + return f"Search failed: {str(e)}" + else: + return "MCP tools not available, cannot execute search" + + async def _format_search_response(self, search_result: str, original_query: str, query_type: str) -> str: + """ + Format search response using LLM for intelligent organization + """ + try: + formatting_prompt = f""" + Please provide a structured answer for the user query based on the following search results. + + User Query: {original_query} + Query Type: {query_type} + Search Results: {search_result} + + Please organize the answer in the following format: + 1. Brief Summary (2-3 sentences) + 2. Detailed Information (list main content in bullet points) + 3. Information Sources (if available) + 4. Related Suggestions or Follow-up Actions (if applicable) + + Keep the answer accurate, useful and easy to understand. + """ + + formatted_response = await self.llm.achat( + messages=[{"role": "user", "content": formatting_prompt}], + system_prompt="You are an information organization expert, skilled at converting search results into clear, useful answers." + ) + + return formatted_response + + except Exception as e: + logger.warning(f"Failed to format answer, returning raw results: {e}") + return f"Search Results:\n{search_result}" + + async def get_search_history(self) -> list: + """ + Get search history + """ + return self.search_history + + async def get_context_keywords(self) -> set: + """ + Get context keywords + """ + return self.context_keywords + + async def clear_context(self) -> None: + """ + Clear search context + """ + self.search_history.clear() + self.context_keywords.clear() + logger.info("Search context cleared") + + +# Simplified configuration function for registration system +def create_enhanced_tavily_agent(**kwargs) -> EnhancedTavilyAgent: + """ + Factory function to create Enhanced Tavily Agent + """ + # Auto-configure MCP transport + if 'mcp_transport' not in kwargs: + stdio_transport = StdioTransport( + command="npx", + args=["-y", "tavily-mcp"], + env={ + "TAVILY_API_KEY": kwargs.get('api_key', os.getenv("TAVILY_API_KEY", "your-api-key-here")) + } + ) + kwargs['mcp_transport'] = stdio_transport + + # Set default LLM (if not provided) + if 'llm' not in kwargs: + kwargs['llm'] = ChatBot( + llm_provider="openai", + model_name="anthropic/claude-sonnet-4", + base_url="https://openrouter.ai/api/v1" + ) + + return EnhancedTavilyAgent(**kwargs) \ No newline at end of file diff --git a/spoon_ai/agents/__init__.py b/spoon_ai/agents/__init__.py index e458837..3547092 100644 --- a/spoon_ai/agents/__init__.py +++ b/spoon_ai/agents/__init__.py @@ -1,3 +1,26 @@ +# Existing agent imports from .spoon_react import SpoonReactAI from .toolcall import ToolCallAgent -from .spoon_react_mcp import SpoonReactMCP \ No newline at end of file +from .spoon_react_mcp import SpoonReactMCP + +# New agent registry system imports +from .registry import AgentRegistry, AgentInterface +from .enhanced_base import EnhancedBaseAgent, StandardAgent +from .github_agent import GitHubAgent + +# Export all available agents and registry-related classes +__all__ = [ + # Original agents + 'SpoonReactAI', + 'ToolCallAgent', + 'SpoonReactMCP', + + # Agent registry system + 'AgentRegistry', + 'AgentInterface', + 'EnhancedBaseAgent', + 'StandardAgent', + + # Specific agent implementations + 'GitHubAgent', +] \ No newline at end of file diff --git a/spoon_ai/agents/enhanced_base.py b/spoon_ai/agents/enhanced_base.py new file mode 100644 index 0000000..9e2a679 --- /dev/null +++ b/spoon_ai/agents/enhanced_base.py @@ -0,0 +1,211 @@ +import logging +from typing import List, Dict, Any, Optional +from abc import abstractmethod + +from .base import BaseAgent +from .registry import AgentInterface + +logger = logging.getLogger(__name__) + + +class EnhancedBaseAgent(BaseAgent, AgentInterface): + """ + Enhanced base agent class that inherits from BaseAgent and implements AgentInterface + Provides standardized agent interface implementation + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._initialized = False + self._tools = [] + + async def initialize(self) -> bool: + """ + Initialize the agent + + Returns: + bool: Whether initialization was successful + """ + try: + # Subclasses can override this method to execute specific initialization logic + await self._initialize_agent() + self._initialized = True + logger.info(f"Agent {self.name} initialized successfully") + return True + except Exception as e: + logger.error(f"Agent {self.name} initialization failed: {e}") + return False + + async def _initialize_agent(self) -> None: + """ + Subclasses can override this method to implement specific initialization logic + """ + pass + + async def list_tools(self) -> List[Dict[str, Any]]: + """ + List available tools for the agent + + Returns: + List[Dict[str, Any]]: List of tools + """ + tools = [] + + # If has MCP client, get MCP tools + if hasattr(self, 'list_mcp_tools'): + try: + mcp_tools = await self.list_mcp_tools() + for tool in mcp_tools: + tools.append({ + 'name': tool.name, + 'description': tool.description, + 'source': 'mcp', + 'schema': tool.inputSchema if hasattr(tool, 'inputSchema') else None + }) + except Exception as e: + logger.warning(f"Failed to get MCP tools: {e}") + + # If has local tool manager, get local tools + if hasattr(self, 'avaliable_tools') and self.avaliable_tools: + try: + local_tools = self.avaliable_tools.get_tools() + for tool in local_tools: + tools.append({ + 'name': tool.name, + 'description': tool.description, + 'source': 'local', + 'schema': tool.args_schema if hasattr(tool, 'args_schema') else None + }) + except Exception as e: + logger.warning(f"Failed to get local tools: {e}") + + self._tools = tools + return tools + + def is_initialized(self) -> bool: + """ + Check if agent is initialized + + Returns: + bool: Whether agent is initialized + """ + return self._initialized + + async def get_capabilities(self) -> Dict[str, Any]: + """ + Get agent capability description + + Returns: + Dict[str, Any]: Capability description + """ + capabilities = { + 'name': self.name, + 'description': self.description, + 'max_steps': self.max_steps, + 'has_mcp': hasattr(self, 'list_mcp_tools'), + 'has_local_tools': hasattr(self, 'avaliable_tools'), + 'tools_count': len(await self.list_tools()), + 'initialized': self.is_initialized() + } + + return capabilities + + async def health_check(self) -> Dict[str, Any]: + """ + Health check + + Returns: + Dict[str, Any]: Health status + """ + health = { + 'status': 'healthy', + 'initialized': self.is_initialized(), + 'agent_name': self.name, + 'state': self.state.value if hasattr(self.state, 'value') else str(self.state), + 'current_step': self.current_step, + 'max_steps': self.max_steps + } + + # Check MCP connection + if hasattr(self, 'list_mcp_tools'): + try: + await self.list_mcp_tools() + health['mcp_status'] = 'connected' + except Exception as e: + health['mcp_status'] = 'disconnected' + health['mcp_error'] = str(e) + health['status'] = 'degraded' + + return health + + def reset_state(self) -> None: + """ + Reset agent state + """ + from spoon_ai.schema import AgentState + self.state = AgentState.IDLE + self.current_step = 0 + logger.info(f"Agent {self.name} state reset") + + async def cleanup(self) -> None: + """ + Clean up agent resources + """ + try: + # If has MCP client, execute cleanup + if hasattr(self, 'cleanup') and callable(getattr(self, 'cleanup', None)): + await super().cleanup() + + # Reset state + self.reset_state() + self._initialized = False + + logger.info(f"Agent {self.name} cleanup completed") + except Exception as e: + logger.error(f"Error cleaning up agent {self.name}: {e}") + + +class StandardAgent(EnhancedBaseAgent): + """ + Standard agent implementation providing basic agent functionality + Can be used as a template for creating new agents + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + if not self.name: + self.name = "StandardAgent" + if not self.description: + self.description = "Standard agent implementation providing basic conversation and tool calling functionality" + + async def step(self) -> str: + """ + Execute one step + + Returns: + str: Step result + """ + # Get the last user message + messages = self.memory.get_messages() + if not messages: + return "No messages to process" + + last_message = messages[-1] + if last_message.role.value != "user": + return "Waiting for user input" + + # Use LLM to generate response + try: + response = await self.llm.achat( + messages=[{"role": "user", "content": last_message.content}], + system_prompt=self.system_prompt or "You are a helpful AI assistant." + ) + + # Add assistant response to memory + self.add_message("assistant", response) + + return response + except Exception as e: + error_msg = f"Error generating response: {str(e)}" + logger.error(error_msg) + return error_msg \ No newline at end of file diff --git a/spoon_ai/agents/github_agent.py b/spoon_ai/agents/github_agent.py new file mode 100644 index 0000000..187d4c8 --- /dev/null +++ b/spoon_ai/agents/github_agent.py @@ -0,0 +1,196 @@ +""" +GitHub agent implementation example +Demonstrates how to create specific agents based on the new agent registration system +""" + +import logging +from typing import List, Dict, Any, Optional +from .enhanced_base import EnhancedBaseAgent + +logger = logging.getLogger(__name__) + + +class GitHubAgent(EnhancedBaseAgent): + """ + GitHub integration agent supporting repository management, issue tracking, and other functions + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.name = "GitHubAgent" + self.description = "GitHub integration agent supporting repository management, issue tracking, Pull Request management, and other functions" + + # GitHub-specific configuration + self.github_token = kwargs.get('github_token') + self.default_repo = kwargs.get('default_repo') + + # Set system prompt + self.system_prompt = """ + You are a GitHub agent specialized in handling GitHub-related operations. + + You can perform the following operations: + - Get repository information + - Manage issues (create, view, update) + - Manage Pull Requests + - View commit history + - Manage branches + + Always provide accurate and useful GitHub-related information, and use available tools when needed. + """ + + self.next_step_prompt = """ + Based on the user's request, decide what GitHub operation to perform next. + If you need more information, ask the user. + """ + + async def _initialize_agent(self) -> None: + """ + Initialize GitHub agent specific logic + """ + if hasattr(self, 'list_mcp_tools'): + try: + # Verify MCP connection and get available tools + tools = await self.list_mcp_tools() + github_tools = [tool for tool in tools if 'github' in tool.name.lower()] + logger.info(f"GitHub agent initialization complete, available GitHub tools: {len(github_tools)}") + except Exception as e: + logger.warning(f"Failed to initialize GitHub MCP tools: {e}") + + # Verify GitHub configuration + if self.github_token: + logger.info("GitHub token configured") + else: + logger.warning("GitHub token not configured, some features may be limited") + + async def step(self) -> str: + """ + Execute one step of the GitHub agent + """ + messages = self.memory.get_messages() + if not messages: + return "No messages to process" + + last_message = messages[-1] + if last_message.role.value != "user": + return "Waiting for user input" + + user_content = last_message.content + + # Analyze user request to determine GitHub operations to execute + try: + # Check if it contains GitHub-related keywords + github_keywords = ['repo', 'repository', 'issue', 'pull request', 'pr', 'commit', 'branch'] + is_github_request = any(keyword in user_content.lower() for keyword in github_keywords) + + if is_github_request: + # If has MCP tools, try to use GitHub-related tools + if hasattr(self, 'list_mcp_tools'): + return await self._handle_github_request(user_content) + else: + return await self._handle_github_request_without_mcp(user_content) + else: + # Normal conversation handling + response = await self.llm.achat( + messages=[{"role": "user", "content": user_content}], + system_prompt=self.system_prompt + ) + + self.add_message("assistant", response) + return response + + except Exception as e: + error_msg = f"Error processing GitHub request: {str(e)}" + logger.error(error_msg) + self.add_message("assistant", error_msg) + return error_msg + + async def _handle_github_request(self, user_content: str) -> str: + """ + Handle GitHub requests using MCP tools + """ + try: + # Here you can call corresponding GitHub MCP tools based on user requests + # Example: Get repository information + if 'repo info' in user_content.lower() or 'repository info' in user_content.lower(): + if self.default_repo: + result = await self.call_mcp_tool('get_repository', repo=self.default_repo) + response = f"Repository information:\n{result}" + else: + response = "Please specify the repository name to query" + + # Example: List issues + elif 'list issues' in user_content.lower() or 'show issues' in user_content.lower(): + if self.default_repo: + result = await self.call_mcp_tool('list_issues', repo=self.default_repo) + response = f"Issue list:\n{result}" + else: + response = "Please specify the repository name to query issues for" + + else: + # Use LLM to handle other requests + response = await self.llm.achat( + messages=[{"role": "user", "content": user_content}], + system_prompt=self.system_prompt + "\n\nNote: You can use GitHub MCP tools to handle related requests." + ) + + self.add_message("assistant", response) + return response + + except Exception as e: + error_msg = f"Failed to handle request using GitHub MCP tools: {str(e)}" + logger.error(error_msg) + return error_msg + + async def _handle_github_request_without_mcp(self, user_content: str) -> str: + """ + Handle GitHub requests without MCP tools + """ + response = await self.llm.achat( + messages=[{"role": "user", "content": user_content}], + system_prompt=self.system_prompt + "\n\nNote: Currently not connected to GitHub MCP service, can only provide general GitHub-related advice." + ) + + self.add_message("assistant", response) + return response + + async def get_repository_info(self, repo_name: str) -> Dict[str, Any]: + """ + Get repository information + """ + if hasattr(self, 'call_mcp_tool'): + try: + result = await self.call_mcp_tool('get_repository', repo=repo_name) + return {"success": True, "data": result} + except Exception as e: + return {"success": False, "error": str(e)} + else: + return {"success": False, "error": "MCP tools not available"} + + async def list_issues(self, repo_name: str, state: str = "open") -> Dict[str, Any]: + """ + List repository issues + """ + if hasattr(self, 'call_mcp_tool'): + try: + result = await self.call_mcp_tool('list_issues', repo=repo_name, state=state) + return {"success": True, "data": result} + except Exception as e: + return {"success": False, "error": str(e)} + else: + return {"success": False, "error": "MCP tools not available"} + + async def create_issue(self, repo_name: str, title: str, body: str = "") -> Dict[str, Any]: + """ + Create new issue + """ + if hasattr(self, 'call_mcp_tool'): + try: + result = await self.call_mcp_tool('create_issue', + repo=repo_name, + title=title, + body=body) + return {"success": True, "data": result} + except Exception as e: + return {"success": False, "error": str(e)} + else: + return {"success": False, "error": "MCP tools not available"} \ No newline at end of file diff --git a/spoon_ai/agents/registry.py b/spoon_ai/agents/registry.py new file mode 100644 index 0000000..74d496b --- /dev/null +++ b/spoon_ai/agents/registry.py @@ -0,0 +1,368 @@ +import importlib +import yaml +import json +import logging +from pathlib import Path +from typing import Dict, List, Any, Optional, Union, Type +from abc import ABC, abstractmethod + +from .base import BaseAgent +from .mcp_client_mixin import MCPClientMixin +from fastmcp.client.transports import ( + FastMCPTransport, + PythonStdioTransport, + SSETransport, + WSTransport, + StdioTransport +) + +logger = logging.getLogger(__name__) + + +class AgentInterface(ABC): + """ + Abstract agent interface that defines standard methods all agents must implement + """ + + @abstractmethod + async def initialize(self) -> bool: + """ + Initialize the agent + + Returns: + bool: Whether initialization was successful + """ + pass + + @abstractmethod + async def run(self, request: Optional[str] = None) -> str: + """ + Run the agent to process a request + + Args: + request: User request + + Returns: + str: Processing result + """ + pass + + @abstractmethod + async def list_tools(self) -> List[Dict[str, Any]]: + """ + List available tools for the agent + + Returns: + List[Dict[str, Any]]: List of tools + """ + pass + + +class AgentRegistry: + """ + Agent registry that supports dynamic loading and management of agents through configuration files + """ + + def __init__(self, config_path: Optional[Union[str, Path]] = None): + """ + Initialize the agent registry + + Args: + config_path: Configuration file path, supports YAML and JSON formats + """ + self.config = {} + self.agents: Dict[str, BaseAgent] = {} + self.agent_configs: Dict[str, Dict[str, Any]] = {} + self.plugin_directories: List[Path] = [] + + if config_path: + self.load_config(config_path) + + def load_config(self, config_path: Union[str, Path]) -> None: + """ + Load configuration file + + Args: + config_path: Configuration file path + """ + config_path = Path(config_path) + + if not config_path.exists(): + raise FileNotFoundError(f"Configuration file does not exist: {config_path}") + + try: + with open(config_path, 'r', encoding='utf-8') as f: + if config_path.suffix.lower() in ['.yaml', '.yml']: + self.config = yaml.safe_load(f) + elif config_path.suffix.lower() == '.json': + self.config = json.load(f) + else: + raise ValueError(f"Unsupported configuration file format: {config_path.suffix}") + + logger.info(f"Successfully loaded configuration file: {config_path}") + + # Load plugin directories + if 'plugin_directories' in self.config: + self.load_plugin_directories(self.config['plugin_directories']) + + except Exception as e: + logger.error(f"Failed to load configuration file: {e}") + raise + + def load_plugin_directories(self, plugin_dirs: List[str]) -> None: + """ + Load plugin directories + + Args: + plugin_dirs: List of plugin directories + """ + for plugin_dir in plugin_dirs: + plugin_path = Path(plugin_dir) + if plugin_path.exists() and plugin_path.is_dir(): + self.plugin_directories.append(plugin_path) + logger.info(f"Added plugin directory: {plugin_path}") + else: + logger.warning(f"Plugin directory does not exist: {plugin_path}") + + def _create_mcp_transport(self, transport_config: Dict[str, Any]) -> Union[FastMCPTransport, None]: + """ + Create MCP transport object based on configuration + + Args: + transport_config: Transport configuration + + Returns: + MCP transport object + """ + transport_type = transport_config.get('type') + + if transport_type == 'SSETransport': + return SSETransport(transport_config.get('endpoint')) + elif transport_type == 'WSTransport': + return WSTransport(transport_config.get('endpoint')) + elif transport_type == 'StdioTransport': + return StdioTransport( + command=transport_config.get('command'), + args=transport_config.get('args', []), + env=transport_config.get('env', {}) + ) + elif transport_type == 'PythonStdioTransport': + return PythonStdioTransport( + command=transport_config.get('command'), + args=transport_config.get('args', []), + env=transport_config.get('env', {}) + ) + else: + logger.warning(f"Unknown transport type: {transport_type}") + return None + + def _import_agent_class(self, class_path: str) -> Type[BaseAgent]: + """ + Dynamically import agent class + + Args: + class_path: Class path in format "module.path.ClassName" + + Returns: + Agent class + """ + try: + # First try to import from standard path + module_name, class_name = class_path.rsplit(".", 1) + module = importlib.import_module(module_name) + agent_class = getattr(module, class_name) + + return agent_class + except (ImportError, AttributeError) as e: + # If standard import fails, try importing from plugin directories + logger.warning(f"Standard import failed: {e}, trying plugin directories") + + for plugin_dir in self.plugin_directories: + try: + # Add plugin directory to system path + import sys + if str(plugin_dir) not in sys.path: + sys.path.insert(0, str(plugin_dir)) + + module = importlib.import_module(module_name) + agent_class = getattr(module, class_name) + logger.info(f"Successfully imported from plugin directory: {class_path}") + return agent_class + except (ImportError, AttributeError): + continue + + raise ImportError(f"Unable to import agent class: {class_path}") + + def _create_mcp_agent_class(self, base_class: Type[BaseAgent]) -> Type[BaseAgent]: + """ + Dynamically create agent class with MCP functionality + + Args: + base_class: Base agent class + + Returns: + Enhanced agent class + """ + # If already an MCP agent, return directly + if issubclass(base_class, MCPClientMixin): + return base_class + + # Dynamically create multiple inheritance class + class MCPEnabledAgent(base_class, MCPClientMixin): + def __init__(self, mcp_transport=None, **kwargs): + # Initialize base agent first + base_class.__init__(self, **kwargs) + + # If MCP transport is provided, initialize MCP client + if mcp_transport: + MCPClientMixin.__init__(self, mcp_transport=mcp_transport) + + return MCPEnabledAgent + + async def load_agents(self) -> None: + """ + Load all agents according to configuration + """ + if 'agents' not in self.config: + logger.warning("No agent configuration found in config file") + return + + for agent_config in self.config['agents']: + await self.load_agent(agent_config) + + async def load_agent(self, agent_config: Dict[str, Any]) -> BaseAgent: + """ + Load a single agent + + Args: + agent_config: Agent configuration + + Returns: + Created agent instance + """ + name = agent_config.get('name') + if not name: + raise ValueError("Agent configuration missing name") + + class_path = agent_config.get('class') + if not class_path: + raise ValueError(f"Agent {name} configuration missing class path") + + try: + # Dynamically import agent class + base_agent_class = self._import_agent_class(class_path) + + # Prepare initialization parameters + init_kwargs = {} + + # Add basic configuration + for key, value in agent_config.items(): + if key not in ['name', 'class', 'mcp_transport', 'endpoint']: + init_kwargs[key] = value + + # Handle MCP transport configuration + mcp_transport = None + if 'mcp_transport' in agent_config or 'endpoint' in agent_config: + # Simplified configuration: directly specify transport type and endpoint + if 'mcp_transport' in agent_config: + transport_type = agent_config['mcp_transport'] + endpoint = agent_config.get('endpoint') + + if transport_type == 'SSETransport' and endpoint: + from fastmcp.client.transports import SSETransport + mcp_transport = SSETransport(endpoint) + elif transport_type == 'WSTransport' and endpoint: + from fastmcp.client.transports import WSTransport + mcp_transport = WSTransport(endpoint) + + # Or complete transport configuration + elif 'transport_config' in agent_config: + mcp_transport = self._create_mcp_transport(agent_config['transport_config']) + + # Create enhanced agent class + agent_class = self._create_mcp_agent_class(base_agent_class) + + # Create agent instance + if mcp_transport: + agent = agent_class(mcp_transport=mcp_transport, **init_kwargs) + else: + agent = agent_class(**init_kwargs) + + # Set agent name + if hasattr(agent, 'name'): + agent.name = name + + # Save agent and configuration + self.agents[name] = agent + self.agent_configs[name] = agent_config + + logger.info(f"Successfully loaded agent: {name} ({class_path})") + + # If agent implements initialize method, call it + if hasattr(agent, 'initialize'): + await agent.initialize() + + return agent + + except Exception as e: + logger.error(f"Failed to load agent {name}: {e}") + raise + + def get_agent(self, name: str) -> Optional[BaseAgent]: + """ + Get agent by name + + Args: + name: Agent name + + Returns: + Agent instance, or None if not found + """ + return self.agents.get(name) + + def list_agents(self) -> List[str]: + """ + List all loaded agent names + + Returns: + List of agent names + """ + return list(self.agents.keys()) + + async def remove_agent(self, name: str) -> bool: + """ + Remove an agent + + Args: + name: Agent name + + Returns: + Whether removal was successful + """ + if name in self.agents: + agent = self.agents[name] + + # If agent has cleanup method, call it + if hasattr(agent, 'cleanup'): + try: + await agent.cleanup() + except Exception as e: + logger.warning(f"Error cleaning up agent {name}: {e}") + + del self.agents[name] + if name in self.agent_configs: + del self.agent_configs[name] + + logger.info(f"Removed agent: {name}") + return True + + return False + + async def cleanup(self) -> None: + """ + Clean up all agents + """ + for name in list(self.agents.keys()): + await self.remove_agent(name) + + logger.info("All agents cleaned up") \ No newline at end of file diff --git a/test_agent_registry.py b/test_agent_registry.py new file mode 100644 index 0000000..c803422 --- /dev/null +++ b/test_agent_registry.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +Simple agent registry system test script +Verifies that basic functionality is working correctly +""" + +import asyncio +import sys +import logging +from pathlib import Path + +# Set up basic logging +logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') +logger = logging.getLogger(__name__) + +async def test_agent_registry(): + """ + Test basic functionality of the agent registry system + """ + print("๐Ÿงช Agent Registry System Basic Functionality Test") + print("=" * 40) + + try: + # Import necessary modules + from spoon_ai.agents import ( + AgentRegistry, + AgentInterface, + EnhancedBaseAgent, + StandardAgent + ) + print("โœ… Module import successful") + + # Test 1: Create empty registry + print("\n๐Ÿ“‹ Test 1: Create Registry") + registry = AgentRegistry() + print("โœ… Registry creation successful") + + # Test 2: Manually add standard agent + print("\n๐Ÿ“‹ Test 2: Add Standard Agent") + simple_config = { + 'name': 'test_agent', + 'class': 'spoon_ai.agents.enhanced_base.StandardAgent', + 'description': 'Test agent', + 'system_prompt': 'You are a test AI assistant.', + 'max_steps': 2 + } + + # Simulate configuration + registry.config = {'agents': [simple_config]} + + # Load agents + await registry.load_agents() + print("โœ… Agent loading successful") + + # Test 3: Get agent + print("\n๐Ÿ“‹ Test 3: Get Agent") + agent = registry.get_agent('test_agent') + if agent: + print(f"โœ… Agent retrieval successful: {agent.name}") + + # Test if agent implements interface + if isinstance(agent, AgentInterface): + print("โœ… Agent implements AgentInterface") + else: + print("โŒ Agent does not implement AgentInterface") + + # Test agent methods + if hasattr(agent, 'initialize') and hasattr(agent, 'list_tools'): + print("โœ… Agent has required methods") + else: + print("โŒ Agent missing required methods") + else: + print("โŒ Agent retrieval failed") + return False + + # Test 4: Agent functionality + print("\n๐Ÿ“‹ Test 4: Agent Functionality Test") + + # Check initialization status + if hasattr(agent, 'is_initialized'): + initialized = agent.is_initialized() + print(f"Agent initialization status: {initialized}") + + # Get agent capabilities + if hasattr(agent, 'get_capabilities'): + capabilities = await agent.get_capabilities() + print(f"Agent capabilities: {capabilities}") + + # Health check + if hasattr(agent, 'health_check'): + health = await agent.health_check() + print(f"Health status: {health['status']}") + + # List tools + if hasattr(agent, 'list_tools'): + tools = await agent.list_tools() + print(f"Available tools count: {len(tools)}") + + print("โœ… Agent functionality test completed") + + # Test 5: Cleanup + print("\n๐Ÿ“‹ Test 5: Resource Cleanup") + await registry.cleanup() + print("โœ… Cleanup completed") + + print("\n๐ŸŽ‰ All tests passed!") + return True + + except ImportError as e: + print(f"โŒ Import error: {e}") + print("Please ensure you run this script from the project root directory") + return False + except Exception as e: + print(f"โŒ Test failed: {e}") + import traceback + traceback.print_exc() + return False + +async def test_config_loading(): + """ + Test configuration file loading functionality + """ + print("\n๐Ÿ”ง Configuration File Loading Test") + print("-" * 30) + + try: + from spoon_ai.agents import AgentRegistry + + # Check if configuration files exist + config_files = [ + Path("config/agents.yaml"), + Path("config/agents.json") + ] + + for config_file in config_files: + if config_file.exists(): + print(f"โœ… Configuration file exists: {config_file}") + + try: + registry = AgentRegistry(config_file) + print(f"โœ… Configuration file loaded successfully: {config_file}") + + # Check configuration content + if 'agents' in registry.config: + agent_count = len(registry.config['agents']) + print(f" - Configured {agent_count} agents") + else: + print(" - No agent definitions in configuration") + + except Exception as e: + print(f"โŒ Configuration file loading failed {config_file}: {e}") + else: + print(f"โš ๏ธ Configuration file does not exist: {config_file}") + + except Exception as e: + print(f"โŒ Configuration loading test failed: {e}") + +def main(): + """ + Main function + """ + print("๐Ÿค– Spoon-AI Agent Registry System Test") + print("=" * 50) + + try: + # Run async tests + success = asyncio.run(test_agent_registry()) + asyncio.run(test_config_loading()) + + if success: + print("\n๐ŸŽฏ Test Result: Success") + print("Agent registry system basic functionality is working properly!") + else: + print("\nโŒ Test Result: Failed") + sys.exit(1) + + except KeyboardInterrupt: + print("\nโš ๏ธ Test interrupted by user") + except Exception as e: + print(f"\nโŒ Unexpected error during testing: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file