From 938fc57366f8776374c959a30031d4239aaef00f Mon Sep 17 00:00:00 2001 From: destroyersrt Date: Mon, 13 Oct 2025 20:12:42 +0530 Subject: [PATCH 1/4] cleanup --- __init__.py | 9 +- examples/nanda_agent.py | 4 +- nanda_core/__init__.py | 3 +- nanda_core/core/__init__.py | 3 +- nanda_core/core/adapter.py | 79 +---- nanda_core/core/agent_bridge.py | 8 +- nanda_core/core/agent_facts.py | 366 ------------------------ nanda_core/core/custom_agent_handler.py | 165 ----------- nanda_core/core/nanda_compat.py | 243 ---------------- nanda_core/discovery/__init__.py | 15 - nanda_core/discovery/agent_discovery.py | 256 ----------------- nanda_core/discovery/agent_ranker.py | 307 -------------------- nanda_core/discovery/task_analyzer.py | 289 ------------------- scripts/deploy-agent.sh | 200 ------------- 14 files changed, 13 insertions(+), 1934 deletions(-) delete mode 100644 nanda_core/core/agent_facts.py delete mode 100644 nanda_core/core/custom_agent_handler.py delete mode 100644 nanda_core/core/nanda_compat.py delete mode 100644 nanda_core/discovery/__init__.py delete mode 100644 nanda_core/discovery/agent_discovery.py delete mode 100644 nanda_core/discovery/agent_ranker.py delete mode 100644 nanda_core/discovery/task_analyzer.py delete mode 100644 scripts/deploy-agent.sh diff --git a/__init__.py b/__init__.py index 4ff9925..679308c 100644 --- a/__init__.py +++ b/__init__.py @@ -7,17 +7,12 @@ and comprehensive monitoring capabilities. """ -from .nanda_core.core.adapter import NANDA, StreamlinedAdapter -from .nanda_core.core.adapter import echo_agent, pirate_agent, helpful_agent +from .nanda_core.core.adapter import NANDA __version__ = "2.0.0" __author__ = "NANDA Team" __email__ = "support@nanda.ai" __all__ = [ - "NANDA", # Main class - "StreamlinedAdapter", # Alias - "echo_agent", # Example agents - "pirate_agent", - "helpful_agent" + "NANDA" ] \ No newline at end of file diff --git a/examples/nanda_agent.py b/examples/nanda_agent.py index 3dadfa4..847799f 100644 --- a/examples/nanda_agent.py +++ b/examples/nanda_agent.py @@ -204,7 +204,7 @@ def main(): port=PORT, registry_url=AGENT_CONFIG["registry_url"], public_url=AGENT_CONFIG["public_url"], - enable_telemetry=False + enable_telemetry=True ) print(f"๐Ÿš€ Agent URL: http://localhost:{PORT}/a2a") @@ -262,7 +262,7 @@ def create_custom_agent(agent_name, specialization, domain, expertise_list, port agent_logic=agent_logic, port=port, registry_url=custom_config["registry_url"], - enable_telemetry=False + enable_telemetry=True ) print(f"๐Ÿค– Starting custom LLM agent: {agent_name}") diff --git a/nanda_core/__init__.py b/nanda_core/__init__.py index 89a23f1..b6561ca 100644 --- a/nanda_core/__init__.py +++ b/nanda_core/__init__.py @@ -3,11 +3,10 @@ Core components for the Streamlined NANDA Adapter """ -from .core.adapter import NANDA, StreamlinedAdapter +from .core.adapter import NANDA from .core.agent_bridge import SimpleAgentBridge __all__ = [ "NANDA", - "StreamlinedAdapter", "SimpleAgentBridge" ] \ No newline at end of file diff --git a/nanda_core/core/__init__.py b/nanda_core/core/__init__.py index dfb91d8..f0d8570 100644 --- a/nanda_core/core/__init__.py +++ b/nanda_core/core/__init__.py @@ -3,11 +3,10 @@ Core components for the Streamlined NANDA Adapter """ -from .adapter import NANDA, StreamlinedAdapter +from .adapter import NANDA from .agent_bridge import SimpleAgentBridge __all__ = [ "NANDA", - "StreamlinedAdapter", "SimpleAgentBridge" ] \ No newline at end of file diff --git a/nanda_core/core/adapter.py b/nanda_core/core/adapter.py index ac526b1..b98892c 100644 --- a/nanda_core/core/adapter.py +++ b/nanda_core/core/adapter.py @@ -23,7 +23,7 @@ def __init__(self, registry_url: Optional[str] = None, public_url: Optional[str] = None, host: str = "0.0.0.0", - enable_telemetry: bool = False): + enable_telemetry: bool = True): """ Create a simple NANDA agent @@ -93,76 +93,9 @@ def _register(self): print(f"โš ๏ธ Failed to register agent: HTTP {response.status_code}") except Exception as e: print(f"โš ๏ธ Registration error: {e}") - - def stop(self): - """Stop the agent (placeholder for cleanup)""" - print(f"๐Ÿ›‘ Stopping agent '{self.agent_id}'") - - -# Keep the StreamlinedAdapter class name for compatibility but simplified -class StreamlinedAdapter(NANDA): - """Alias for NANDA class for compatibility""" - pass - - -# Example agent logic functions -def echo_agent(message: str, conversation_id: str) -> str: - """Simple echo agent""" - return f"Echo: {message}" - - -def pirate_agent(message: str, conversation_id: str) -> str: - """Pirate-style agent""" - return f"Arrr! {message}, matey!" - - -def helpful_agent(message: str, conversation_id: str) -> str: - """Helpful agent""" - if "time" in message.lower(): - from datetime import datetime - return f"Current time: {datetime.now().strftime('%H:%M:%S')}" - elif "help" in message.lower(): - return "I can help with time, calculations, and general questions!" - elif any(op in message for op in ['+', '-', '*', '/']): - try: - result = eval(message.replace('x', '*').replace('X', '*')) - return f"Result: {result}" - except: - return "Invalid calculation" - else: - return f"I can help with: {message}" - - -def main(): - """Main CLI entry point""" - import argparse - - parser = argparse.ArgumentParser(description="Simple NANDA Agent") - parser.add_argument("--agent-id", required=True, help="Agent ID") - parser.add_argument("--port", type=int, default=6000, help="Server port") - parser.add_argument("--host", default="0.0.0.0", help="Server host") - parser.add_argument("--registry", help="Registry URL") - parser.add_argument("--public-url", help="Public URL for registration") - parser.add_argument("--no-register", action="store_true", help="Don't register with registry") - - args = parser.parse_args() - # Use helpful agent as default - nanda = NANDA( - agent_id=args.agent_id, - agent_logic=helpful_agent, - port=args.port, - registry_url=args.registry, - public_url=args.public_url, - host=args.host - ) - - try: - nanda.start(register=not args.no_register) - except KeyboardInterrupt: - print("\n๐Ÿ›‘ Server stopped") - nanda.stop() - - -if __name__ == "__main__": - main() \ No newline at end of file + def stop(self): + """Stop the agent and cleanup telemetry""" + if self.telemetry: + self.telemetry.stop() + print(f"๐Ÿ›‘ Stopping agent '{self.agent_id}'") \ No newline at end of file diff --git a/nanda_core/core/agent_bridge.py b/nanda_core/core/agent_bridge.py index bf022a3..fc1a668 100644 --- a/nanda_core/core/agent_bridge.py +++ b/nanda_core/core/agent_bridge.py @@ -197,7 +197,7 @@ def _send_to_agent(self, target_agent_id: str, message_text: str, conversation_i ) if self.telemetry: - self.telemetry.log_agent_message_sent(self.agent_id, target_agent_id, conversation_id) + self.telemetry.log_message_sent(target_agent_id, conversation_id) # Extract the actual response content from the target agent logger.info(f"๐Ÿ” [{self.agent_id}] Response type: {type(response)}, has parts: {hasattr(response, 'parts') if response else 'None'}") @@ -234,12 +234,6 @@ def _lookup_agent(self, agent_id: str) -> Optional[str]: # Fallback to local discovery (for testing) local_agents = { "test_agent": "http://localhost:6000", - "pirate_agent": "http://localhost:6001", - "helpful_agent": "http://localhost:6002", - "echo_agent": "http://localhost:6003", - "simple_test_agent": "http://localhost:6005", - "agent_alpha": "http://localhost:6010", - "agent_beta": "http://localhost:6011" } if agent_id in local_agents: diff --git a/nanda_core/core/agent_facts.py b/nanda_core/core/agent_facts.py deleted file mode 100644 index d8dd0c3..0000000 --- a/nanda_core/core/agent_facts.py +++ /dev/null @@ -1,366 +0,0 @@ -#!/usr/bin/env python3 -""" -AgentFacts Generator and Server for NANDA Project -Implements the AgentFacts specification for agent capability description -""" - -import json -import os -from typing import Dict, List, Any, Optional -from datetime import datetime, timedelta -from dataclasses import dataclass, asdict -import threading - -try: - from flask import Flask, jsonify, send_from_directory - FLASK_AVAILABLE = True -except ImportError: - print("โš ๏ธ Flask not available - AgentFacts server will be disabled") - FLASK_AVAILABLE = False - Flask = None - - -@dataclass -class AgentCapabilities: - """Agent capabilities structure""" - modalities: List[str] # ["text", "image", "audio"] - skills: List[str] # ["financial_analysis", "data_visualization"] - domains: List[str] # ["finance", "healthcare", "marketing"] - languages: List[str] # ["english", "spanish"] - streaming: bool = False - batch: bool = True - reasoning: bool = True - memory: bool = False - - -@dataclass -class AgentEndpoints: - """Agent endpoints structure""" - static: str # Primary A2A endpoint - api: Optional[str] = None # REST API endpoint - websocket: Optional[str] = None # WebSocket endpoint - - -@dataclass -class AgentCertification: - """Agent certification information""" - level: str = "verified" # verified, beta, experimental - issued_by: str = "NANDA" - issued_date: str = None - expires_date: str = None - - def __post_init__(self): - if not self.issued_date: - self.issued_date = datetime.now().isoformat() - if not self.expires_date: - # Default 30-day expiration - expires = datetime.now() + timedelta(days=30) - self.expires_date = expires.isoformat() - - -@dataclass -class AgentFacts: - """Complete AgentFacts specification""" - context: str = "https://projectnanda.org/agentfacts/v1" - id: str = None - handle: str = None - provider: str = "streamlined_adapter" - jurisdiction: str = "USA" - version: str = "1.0" - certification: AgentCertification = None - capabilities: AgentCapabilities = None - endpoints: AgentEndpoints = None - description: str = "" - tags: List[str] = None - - def __post_init__(self): - if not self.certification: - self.certification = AgentCertification() - if not self.tags: - self.tags = [] - - -class AgentFactsGenerator: - """Generate AgentFacts JSON for agents""" - - def __init__(self, base_url: str = None): - self.base_url = base_url or "http://localhost" - - def create_agent_facts(self, - agent_id: str, - port: int, - capabilities: AgentCapabilities, - description: str = "", - tags: List[str] = None) -> AgentFacts: - """Create AgentFacts for an agent""" - - endpoints = AgentEndpoints( - static=f"{self.base_url}:{port}", - api=f"{self.base_url}:{port + 100}" # API port offset - ) - - agent_facts = AgentFacts( - id=f"did:nanda:agent:{agent_id}", - handle=f"@{agent_id}", - capabilities=capabilities, - endpoints=endpoints, - description=description, - tags=tags or [] - ) - - return agent_facts - - def to_json(self, agent_facts: AgentFacts) -> Dict[str, Any]: - """Convert AgentFacts to JSON-serializable dict""" - result = { - "@context": agent_facts.context, - "id": agent_facts.id, - "handle": agent_facts.handle, - "provider": agent_facts.provider, - "jurisdiction": agent_facts.jurisdiction, - "version": agent_facts.version, - "certification": asdict(agent_facts.certification), - "capabilities": asdict(agent_facts.capabilities), - "endpoints": asdict(agent_facts.endpoints) - } - - if agent_facts.description: - result["description"] = agent_facts.description - - if agent_facts.tags: - result["tags"] = agent_facts.tags - - return result - - -class AgentFactsServer: - """HTTP server for serving AgentFacts JSON files""" - - def __init__(self, port: int = 8080): - self.port = port - self.agent_facts = {} # agent_id -> AgentFacts - self.server_thread = None - - if not FLASK_AVAILABLE: - print(f"โš ๏ธ Flask not available - AgentFacts server on port {port} disabled") - self.app = None - return - - self.app = Flask(__name__) - self.setup_routes() - - def setup_routes(self): - """Setup Flask routes for AgentFacts""" - - @self.app.route('/@.json') - def get_agent_facts(agent_id): - """Serve AgentFacts JSON for specific agent""" - if agent_id in self.agent_facts: - generator = AgentFactsGenerator() - facts_json = generator.to_json(self.agent_facts[agent_id]) - return jsonify(facts_json) - else: - return {"error": f"Agent {agent_id} not found"}, 404 - - @self.app.route('/agents') - def list_agents(): - """List all available agents""" - return jsonify({ - "agents": list(self.agent_facts.keys()), - "count": len(self.agent_facts) - }) - - @self.app.route('/health') - def health_check(): - """Health check endpoint""" - return {"status": "healthy", "agents": len(self.agent_facts)} - - def register_agent_facts(self, agent_id: str, agent_facts: AgentFacts): - """Register AgentFacts for an agent""" - self.agent_facts[agent_id] = agent_facts - print(f"๐Ÿ“‹ Registered AgentFacts for {agent_id}") - - def get_agent_facts_url(self, agent_id: str) -> str: - """Get the AgentFacts URL for an agent""" - return f"http://localhost:{self.port}/@{agent_id}.json" - - def start_server(self): - """Start the AgentFacts server in background""" - if not FLASK_AVAILABLE or not self.app: - print(f"โš ๏ธ Cannot start AgentFacts server - Flask not available") - return - - def run_server(): - self.app.run(host='0.0.0.0', port=self.port, debug=False) - - self.server_thread = threading.Thread(target=run_server, daemon=True) - self.server_thread.start() - print(f"๐Ÿ“ก AgentFacts server started on port {self.port}") - - def stop_server(self): - """Stop the AgentFacts server""" - # Flask doesn't have a built-in stop method, so we use daemon threads - print(f"๐Ÿ›‘ AgentFacts server stopping...") - - -# Predefined capability templates for common agent types -class CapabilityTemplates: - """Common capability templates for different agent types""" - - @staticmethod - def data_scientist(level: str = "senior") -> AgentCapabilities: - """Data scientist capabilities""" - skills = ["data_analysis", "statistical_modeling", "data_visualization"] - if level == "senior": - skills.extend(["machine_learning", "deep_learning", "feature_engineering"]) - elif level == "ml_specialist": - skills.extend(["machine_learning", "deep_learning", "neural_networks", "model_optimization"]) - - return AgentCapabilities( - modalities=["text"], - skills=skills, - domains=["data_science", "analytics"], - languages=["english"], - batch=True, - reasoning=True - ) - - @staticmethod - def financial_analyst(specialty: str = "general") -> AgentCapabilities: - """Financial analyst capabilities""" - skills = ["financial_analysis", "market_research", "financial_modeling"] - domains = ["finance", "economics"] - - if specialty == "risk": - skills.extend(["risk_assessment", "portfolio_analysis", "stress_testing"]) - domains.append("risk_management") - elif specialty == "investment": - skills.extend(["investment_analysis", "valuation", "portfolio_optimization"]) - domains.append("investments") - - return AgentCapabilities( - modalities=["text"], - skills=skills, - domains=domains, - languages=["english"], - batch=True, - reasoning=True - ) - - @staticmethod - def healthcare_expert(specialty: str = "general") -> AgentCapabilities: - """Healthcare expert capabilities""" - skills = ["medical_knowledge", "symptom_analysis", "treatment_planning"] - domains = ["healthcare", "medicine"] - - if specialty == "diagnosis": - skills.extend(["diagnostic_reasoning", "differential_diagnosis", "clinical_assessment"]) - domains.append("diagnostics") - elif specialty == "treatment": - skills.extend(["treatment_protocols", "medication_management", "care_planning"]) - domains.append("therapeutics") - - return AgentCapabilities( - modalities=["text"], - skills=skills, - domains=domains, - languages=["english"], - batch=True, - reasoning=True, - memory=True # Medical agents often need patient history - ) - - @staticmethod - def marketing_specialist(focus: str = "strategy") -> AgentCapabilities: - """Marketing specialist capabilities""" - skills = ["market_analysis", "customer_segmentation", "campaign_planning"] - domains = ["marketing", "business"] - - if focus == "content": - skills.extend(["content_creation", "copywriting", "brand_messaging"]) - domains.append("content_marketing") - elif focus == "digital": - skills.extend(["digital_marketing", "social_media", "seo_optimization"]) - domains.append("digital_marketing") - - return AgentCapabilities( - modalities=["text"], - skills=skills, - domains=domains, - languages=["english"], - batch=True, - reasoning=True - ) - - @staticmethod - def general_assistant() -> AgentCapabilities: - """General assistant capabilities""" - return AgentCapabilities( - modalities=["text"], - skills=["general_assistance", "task_coordination", "information_retrieval"], - domains=["general", "productivity"], - languages=["english"], - batch=True, - reasoning=True, - memory=True - ) - - -# Example usage -def create_sample_agent_facts(): - """Create sample AgentFacts for testing""" - generator = AgentFactsGenerator("http://10.189.72.201") - - # Senior Data Scientist - senior_ds_facts = generator.create_agent_facts( - agent_id="senior_data_scientist", - port=7001, - capabilities=CapabilityTemplates.data_scientist("senior"), - description="Senior data scientist with 10+ years experience in machine learning and statistical analysis", - tags=["expert", "senior", "python", "sql", "machine_learning"] - ) - - # Financial Risk Analyst - risk_analyst_facts = generator.create_agent_facts( - agent_id="risk_analyst", - port=7005, - capabilities=CapabilityTemplates.financial_analyst("risk"), - description="Financial risk analyst specializing in portfolio risk assessment and stress testing", - tags=["finance", "risk", "portfolio", "quantitative"] - ) - - return { - "senior_data_scientist": senior_ds_facts, - "risk_analyst": risk_analyst_facts - } - - -if __name__ == "__main__": - # Test the AgentFacts system - facts_server = AgentFactsServer(8080) - - # Create sample facts - sample_facts = create_sample_agent_facts() - - # Register with server - for agent_id, facts in sample_facts.items(): - facts_server.register_agent_facts(agent_id, facts) - - # Start server - facts_server.start_server() - - print("๐Ÿงช Testing AgentFacts URLs:") - for agent_id in sample_facts.keys(): - url = facts_server.get_agent_facts_url(agent_id) - print(f" {agent_id}: {url}") - - print("\n๐Ÿ“ก AgentFacts server running. Test with:") - print(" curl http://localhost:8080/@senior_data_scientist.json") - print(" curl http://localhost:8080/agents") - - try: - import time - while True: - time.sleep(1) - except KeyboardInterrupt: - print("\n๐Ÿ›‘ Stopping AgentFacts server") \ No newline at end of file diff --git a/nanda_core/core/custom_agent_handler.py b/nanda_core/core/custom_agent_handler.py deleted file mode 100644 index 906fb31..0000000 --- a/nanda_core/core/custom_agent_handler.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -""" -Custom Agent Handler for attaching user-defined agent logic -""" - -from typing import Callable, Optional, Dict, Any -from python_a2a import Message - - -class CustomAgentHandler: - """Handles custom agent logic without message improvement""" - - def __init__(self): - self.message_handler: Optional[Callable[[str, str], str]] = None - self.query_handler: Optional[Callable[[str, str], str]] = None - self.command_handlers: Dict[str, Callable[[str, str], str]] = {} - - # Conversation control - self.conversation_counts: Dict[str, int] = {} - self.max_exchanges_per_conversation: Optional[int] = None - self.stop_keywords: list = [] - self.enable_stop_control: bool = False - - def set_message_handler(self, handler: Callable[[str, str], str]): - """ - Set custom message handler for regular messages - - Args: - handler: Function that takes (message_text, conversation_id) -> response_text - Note: This is NOT for improving messages, but for custom agent responses - """ - self.message_handler = handler - - def set_query_handler(self, handler: Callable[[str, str], str]): - """ - Set custom handler for /query commands - - Args: - handler: Function that takes (query_text, conversation_id) -> response_text - """ - self.query_handler = handler - - def add_command_handler(self, command: str, handler: Callable[[str, str], str]): - """ - Add custom handler for specific commands - - Args: - command: Command name (without /) - handler: Function that takes (command_args, conversation_id) -> response_text - """ - self.command_handlers[command] = handler - - def enable_conversation_control(self, max_exchanges: int = None, stop_keywords: list = None): - """ - Enable conversation control mechanisms - - Args: - max_exchanges: Maximum number of exchanges per conversation (None = unlimited) - stop_keywords: List of keywords that end conversations (e.g., ['bye', 'stop']) - """ - self.enable_stop_control = True - self.max_exchanges_per_conversation = max_exchanges - self.stop_keywords = stop_keywords or [] - print(f"๐Ÿ›‘ Conversation control enabled: max_exchanges={max_exchanges}, stop_keywords={stop_keywords}") - - def should_respond_to_conversation(self, message_text: str, conversation_id: str) -> bool: - """ - Check if agent should respond based on conversation control rules - - Returns: - True if agent should respond, False if conversation should stop - """ - if not self.enable_stop_control: - return True # No control enabled, always respond - - # Track conversation count - if conversation_id not in self.conversation_counts: - self.conversation_counts[conversation_id] = 0 - self.conversation_counts[conversation_id] += 1 - - current_count = self.conversation_counts[conversation_id] - - # Check exchange limit - if self.max_exchanges_per_conversation and current_count > self.max_exchanges_per_conversation: - print(f"๐Ÿ›‘ Conversation {conversation_id} stopped: exceeded max exchanges ({self.max_exchanges_per_conversation})") - return False - - # Check stop keywords - message_lower = message_text.lower() - for keyword in self.stop_keywords: - if keyword.lower() in message_lower: - print(f"๐Ÿ›‘ Conversation {conversation_id} stopped: stop keyword '{keyword}' detected") - return False - - return True - - def handle_message(self, message_text: str, conversation_id: str, message_type: str = "regular") -> Optional[str]: - """ - Handle message using appropriate custom handler - - Args: - message_text: The message content (NOT modified/improved) - conversation_id: Conversation identifier - message_type: Type of message (regular, query, command) - - Returns: - Custom response or None if no handler available - """ - if message_type == "regular" and self.message_handler: - return self.message_handler(message_text, conversation_id) - elif message_type == "query" and self.query_handler: - return self.query_handler(message_text, conversation_id) - elif message_type == "command": - # Extract command from message_text - parts = message_text.split(" ", 1) - command = parts[0][1:] if parts[0].startswith("/") else parts[0] - args = parts[1] if len(parts) > 1 else "" - - if command in self.command_handlers: - return self.command_handlers[command](args, conversation_id) - - return None - - def has_handlers(self) -> bool: - """Check if any custom handlers are configured""" - return (self.message_handler is not None or - self.query_handler is not None or - len(self.command_handlers) > 0) - - -# Example usage patterns for documentation -class AgentExamples: - """Example agent implementations""" - - @staticmethod - def simple_echo_agent(message_text: str, conversation_id: str) -> str: - """Simple echo agent that repeats messages""" - return f"Echo: {message_text}" - - @staticmethod - def math_agent(message_text: str, conversation_id: str) -> str: - """Simple math agent for calculations""" - try: - # Simple math evaluation (in production, use safer parsing) - if any(op in message_text for op in ['+', '-', '*', '/', '(', ')']): - # In real implementation, use ast.literal_eval or similar for safety - result = eval(message_text.replace('x', '*')) - return f"Result: {result}" - else: - return "Please provide a math expression" - except: - return "Invalid math expression" - - @staticmethod - def file_agent(query_text: str, conversation_id: str) -> str: - """Agent that handles file-related queries""" - if "list files" in query_text.lower(): - import os - files = os.listdir(".")[:10] # Limit to 10 files - return f"Files: {', '.join(files)}" - elif "current directory" in query_text.lower(): - import os - return f"Current directory: {os.getcwd()}" - else: - return "Available commands: 'list files', 'current directory'" \ No newline at end of file diff --git a/nanda_core/core/nanda_compat.py b/nanda_core/core/nanda_compat.py deleted file mode 100644 index 598ab10..0000000 --- a/nanda_core/core/nanda_compat.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env python3 -""" -NANDA Compatibility Layer for Streamlined Adapter - -Provides backwards compatibility with the original NANDA interface while using -the streamlined adapter architecture underneath. -""" - -import os -import sys -import threading -from typing import Callable, Optional, Dict, Any -from .adapter import StreamlinedAdapter - - -class NANDA: - """ - Backwards compatibility class that mimics the original NANDA interface - but uses the streamlined adapter underneath without message improvement. - """ - - def __init__(self, improvement_logic: Callable[[str], str]): - """ - Initialize NANDA with custom improvement logic - - Note: In the streamlined version, this becomes a custom response handler - instead of a message modifier. - - Args: - improvement_logic: Function that takes (message_text: str) -> str - This will be converted to a response handler, not message modifier - """ - self.improvement_logic = improvement_logic - self.adapter = None - - print(f"๐Ÿค– NANDA (Streamlined) initialized with handler logic: {improvement_logic.__name__}") - print("โš ๏ธ Note: This logic will be used for agent responses, NOT message modification") - - # Create the streamlined adapter - self._create_adapter() - - # Set up custom handlers - self._setup_handlers() - - def _create_adapter(self): - """Create the underlying streamlined adapter""" - # Get agent configuration from environment - agent_id = os.getenv("AGENT_ID", "nanda_agent") - self.adapter = StreamlinedAdapter(agent_id) - print(f"โœ… Streamlined adapter created for agent: {agent_id}") - - def _setup_handlers(self): - """Set up custom handlers using the improvement logic""" - - def response_handler(message_text: str, conversation_id: str) -> str: - """Convert improvement logic to response handler""" - try: - # Use the improvement logic as a response generator - response = self.improvement_logic(message_text) - return f"Agent Response: {response}" - except Exception as e: - print(f"Error in custom handler: {e}") - return f"Received: {message_text}" - - # Set the handler - self.adapter.set_message_handler(response_handler) - print(f"๐Ÿ”ง Custom response handler '{self.improvement_logic.__name__}' configured") - - def start_server(self, host: str = "0.0.0.0"): - """ - Start the agent bridge server - - Args: - host: Host to bind to (default: "0.0.0.0") - """ - print("๐Ÿš€ NANDA (Streamlined) starting server...") - - # Read configuration from environment - public_url = os.getenv("PUBLIC_URL") - api_url = os.getenv("API_URL") - - # Register with registry if PUBLIC_URL is set - register_with_registry = bool(public_url) - - if public_url: - print(f"๐Ÿ“ Will register agent with registry using URL: {public_url}") - else: - print("โš ๏ธ PUBLIC_URL not set - agent will not be registered with registry") - - # Start the streamlined adapter server - self.adapter.start_server(host=host, register_with_registry=register_with_registry) - - def start_server_api(self, anthropic_key: str, domain: str, agent_id: Optional[str] = None, - port: int = 6000, api_port: int = 6001, **kwargs): - """ - Start NANDA with API server support (backwards compatibility) - - Args: - anthropic_key: Anthropic API key - domain: Domain name for the server - agent_id: Agent ID (optional) - port: Agent bridge port - api_port: API server port - **kwargs: Additional arguments (for compatibility) - """ - print("๐Ÿš€ NANDA (Streamlined) starting with API server support...") - - # Set environment variables for compatibility - os.environ["ANTHROPIC_API_KEY"] = anthropic_key - if agent_id: - os.environ["AGENT_ID"] = agent_id - os.environ["PORT"] = str(port) - - # Generate URLs based on domain - public_url = f"https://{domain}:{port}" - api_url = f"https://{domain}:{api_port}" - - os.environ["PUBLIC_URL"] = public_url - os.environ["API_URL"] = api_url - - print(f"๐ŸŒ Domain: {domain}") - print(f"๐Ÿ”— Public URL: {public_url}") - print(f"๐Ÿ”— API URL: {api_url}") - - # Start the Flask API server in a separate thread - self._start_api_server(api_port) - - # Start the main agent server - self.start_server() - - def _start_api_server(self, api_port: int): - """Start a simple Flask API server for compatibility""" - try: - from flask import Flask, request, jsonify - from flask_cors import CORS - - app = Flask(__name__) - CORS(app) - - @app.route('/api/health', methods=['GET']) - def health_check(): - return jsonify({"status": "healthy", "agent_id": self.adapter.agent_id}) - - @app.route('/api/send', methods=['POST']) - def send_message(): - data = request.get_json() - message = data.get('message', '') - # This would typically send to the agent - return jsonify({"status": "message received", "message": message}) - - @app.route('/api/agents/list', methods=['GET']) - def list_agents(): - agents = self.adapter.list_available_agents() - return jsonify(agents) - - @app.route('/api/receive_message', methods=['POST']) - def receive_message(): - data = request.get_json() - # Handle incoming messages from UI clients - return jsonify({"status": "ok"}) - - @app.route('/api/render', methods=['GET']) - def render(): - # Return latest message (for compatibility) - return jsonify({"message": "No recent messages"}) - - def run_flask(): - print(f"๐Ÿ“ก Starting API server on port {api_port}") - app.run(host='0.0.0.0', port=api_port, ssl_context='adhoc', debug=False) - - # Start Flask in background thread - api_thread = threading.Thread(target=run_flask, daemon=True) - api_thread.start() - - except ImportError: - print("โš ๏ธ Flask not available - API server will not start") - print(" Install with: pip install flask flask-cors pyopenssl") - - @property - def bridge(self): - """Access to the underlying bridge (for compatibility)""" - return self.adapter.bridge - - def stop(self): - """Stop the NANDA server""" - if self.adapter: - self.adapter.stop() - - -# Utility functions for compatibility -def create_nanda_adapter(improvement_logic: Callable[[str], str]) -> NANDA: - """Create a NANDA adapter with custom improvement logic""" - return NANDA(improvement_logic) - - -def start_nanda_server(improvement_logic: Callable[[str], str], **kwargs): - """Quick start function for NANDA server""" - nanda = NANDA(improvement_logic) - - if 'anthropic_key' in kwargs and 'domain' in kwargs: - nanda.start_server_api(**kwargs) - else: - nanda.start_server() - - return nanda - - -# Example improvement functions for testing -def example_pirate_improver(message: str) -> str: - """Example pirate-style message improver""" - return f"Arrr! {message}, matey!" - - -def example_professional_improver(message: str) -> str: - """Example professional message improver""" - return f"I would like to formally communicate: {message}" - - -def example_echo_improver(message: str) -> str: - """Example echo improver""" - return f"Echo: {message}" - - -if __name__ == "__main__": - # Test the NANDA compatibility layer - print("๐Ÿงช Testing NANDA Compatibility Layer") - - # Test with a simple improvement function - def test_improver(message: str) -> str: - return f"Improved: {message}" - - # Create NANDA instance - nanda = NANDA(test_improver) - - print(f"โœ… NANDA instance created with agent ID: {nanda.adapter.agent_id}") - print("๐Ÿš€ Starting server...") - - try: - nanda.start_server() - except KeyboardInterrupt: - print("\n๐Ÿ›‘ Server stopped") - nanda.stop() - diff --git a/nanda_core/discovery/__init__.py b/nanda_core/discovery/__init__.py deleted file mode 100644 index 292759c..0000000 --- a/nanda_core/discovery/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 -""" -Agent Discovery and Ranking System -Intelligent search and recommendation for the agent ecosystem -""" - -from .agent_discovery import AgentDiscovery -from .agent_ranker import AgentRanker -from .task_analyzer import TaskAnalyzer - -__all__ = [ - "AgentDiscovery", - "AgentRanker", - "TaskAnalyzer" -] \ No newline at end of file diff --git a/nanda_core/discovery/agent_discovery.py b/nanda_core/discovery/agent_discovery.py deleted file mode 100644 index 7b194c6..0000000 --- a/nanda_core/discovery/agent_discovery.py +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env python3 -""" -Agent Discovery System - Intelligent search and recommendation for the agent ecosystem -""" - -from typing import Dict, List, Any, Optional -from dataclasses import dataclass -from .task_analyzer import TaskAnalyzer, TaskAnalysis -from .agent_ranker import AgentRanker, AgentScore -from ..core.registry_client import RegistryClient - - -@dataclass -class DiscoveryResult: - """Result of agent discovery process""" - task_analysis: TaskAnalysis - recommended_agents: List[AgentScore] - total_agents_evaluated: int - search_time_seconds: float - suggestions: List[str] - - -class AgentDiscovery: - """Main discovery system that coordinates task analysis and agent ranking""" - - def __init__(self, registry_client: Optional[RegistryClient] = None): - self.registry_client = registry_client or RegistryClient() - self.task_analyzer = TaskAnalyzer() - self.agent_ranker = AgentRanker() - self.performance_cache = {} - - def discover_agents(self, task_description: str, limit: int = 5, - min_score: float = 0.3, filters: Dict[str, Any] = None) -> DiscoveryResult: - """Main entry point for agent discovery""" - import time - start_time = time.time() - - # Analyze the task - task_analysis = self.task_analyzer.analyze_task(task_description) - - # Get available agents - agents = self._get_relevant_agents(task_analysis, filters) - - # Get performance data - performance_data = self._get_performance_data() - - # Rank agents - agent_scores = self.agent_ranker.rank_agents(agents, task_analysis, performance_data) - - # Get top recommendations - recommendations = self.agent_ranker.get_top_recommendations( - agent_scores, limit, min_score - ) - - # Generate suggestions - suggestions = self._generate_suggestions(task_analysis, recommendations) - - search_time = time.time() - start_time - - return DiscoveryResult( - task_analysis=task_analysis, - recommended_agents=recommendations, - total_agents_evaluated=len(agents), - search_time_seconds=search_time, - suggestions=suggestions - ) - - def search_agents_by_capabilities(self, capabilities: List[str], - domain: str = None) -> List[Dict[str, Any]]: - """Search agents by specific capabilities""" - filters = {"capabilities": capabilities} - if domain: - filters["domain"] = domain - - return self.registry_client.search_agents(capabilities=capabilities) - - def search_agents_by_domain(self, domain: str) -> List[Dict[str, Any]]: - """Search agents by domain expertise""" - return self.registry_client.search_agents(query=domain) - - def get_agent_details(self, agent_id: str) -> Optional[Dict[str, Any]]: - """Get detailed information about a specific agent""" - return self.registry_client.get_agent_metadata(agent_id) - - def _get_relevant_agents(self, task_analysis: TaskAnalysis, - filters: Dict[str, Any] = None) -> List[Dict[str, Any]]: - """Get agents relevant to the task""" - - # Start with capability-based search - agents = set() - - # Search by required capabilities - if task_analysis.required_capabilities: - cap_agents = self.registry_client.search_agents( - capabilities=task_analysis.required_capabilities - ) - agents.update(tuple(sorted(agent.items())) for agent in cap_agents) - - # Search by domain - if task_analysis.domain and task_analysis.domain != "general": - domain_agents = self.registry_client.search_agents( - query=task_analysis.domain - ) - agents.update(tuple(sorted(agent.items())) for agent in domain_agents) - - # Search by keywords - if task_analysis.keywords: - keyword_query = " ".join(task_analysis.keywords[:3]) # Top 3 keywords - keyword_agents = self.registry_client.search_agents(query=keyword_query) - agents.update(tuple(sorted(agent.items())) for agent in keyword_agents) - - # Convert back to list of dictionaries - agent_list = [dict(agent) for agent in agents] - - # Apply additional filters - if filters: - agent_list = self._apply_filters(agent_list, filters) - - # If no specific matches, get general agents - if not agent_list: - agent_list = self.registry_client.list_agents() - - return agent_list - - def _apply_filters(self, agents: List[Dict[str, Any]], - filters: Dict[str, Any]) -> List[Dict[str, Any]]: - """Apply additional filters to agent list""" - filtered = agents - - if "status" in filters: - filtered = [a for a in filtered if a.get("status") == filters["status"]] - - if "min_score" in filters: - # This would require pre-scoring, so skip for now - pass - - if "exclude_agents" in filters: - exclude_set = set(filters["exclude_agents"]) - filtered = [a for a in filtered if a.get("agent_id") not in exclude_set] - - if "domain" in filters: - domain_filter = filters["domain"].lower() - filtered = [a for a in filtered - if a.get("domain", "").lower() == domain_filter] - - return filtered - - def _get_performance_data(self) -> Dict[str, Any]: - """Get cached performance data for agents""" - # This would typically come from a telemetry system - # For now, return mock data or cached data - return self.performance_cache - - def update_performance_data(self, agent_id: str, performance_metrics: Dict[str, Any]): - """Update performance data for an agent""" - self.performance_cache[agent_id] = performance_metrics - - def _generate_suggestions(self, task_analysis: TaskAnalysis, - recommendations: List[AgentScore]) -> List[str]: - """Generate helpful suggestions based on discovery results""" - suggestions = [] - - if not recommendations: - suggestions.extend([ - "No agents found matching your requirements", - f"Try searching for agents with '{task_analysis.domain}' domain expertise", - "Consider breaking down your task into smaller components", - "Check if your required capabilities are too specific" - ]) - elif len(recommendations) == 1: - suggestions.append("Only one agent found - consider broadening your search criteria") - elif task_analysis.complexity == "complex": - suggestions.extend([ - "This appears to be a complex task", - "Consider using multiple agents for different components", - "Review the top agents' capabilities to ensure full coverage" - ]) - - # Add suggestions based on task type - if task_analysis.task_type == "data_analysis": - suggestions.append("For data analysis tasks, ensure agents have visualization capabilities") - elif task_analysis.task_type == "automation": - suggestions.append("For automation, look for agents with workflow management features") - - # Add performance-based suggestions - if recommendations: - top_score = recommendations[0].score - if top_score < 0.7: - suggestions.append("Match confidence is moderate - review agent details carefully") - - return suggestions - - def explain_recommendations(self, discovery_result: DiscoveryResult) -> str: - """Generate detailed explanation of the discovery process and results""" - lines = [] - - # Task analysis summary - lines.append("=== Task Analysis ===") - lines.append(f"Task Type: {discovery_result.task_analysis.task_type}") - lines.append(f"Domain: {discovery_result.task_analysis.domain}") - lines.append(f"Complexity: {discovery_result.task_analysis.complexity}") - lines.append(f"Required Capabilities: {', '.join(discovery_result.task_analysis.required_capabilities)}") - lines.append(f"Key Keywords: {', '.join(discovery_result.task_analysis.keywords[:5])}") - lines.append(f"Analysis Confidence: {discovery_result.task_analysis.confidence:.2f}") - lines.append("") - - # Search results summary - lines.append("=== Search Results ===") - lines.append(f"Total Agents Evaluated: {discovery_result.total_agents_evaluated}") - lines.append(f"Agents Recommended: {len(discovery_result.recommended_agents)}") - lines.append(f"Search Time: {discovery_result.search_time_seconds:.2f} seconds") - lines.append("") - - # Detailed agent recommendations - if discovery_result.recommended_agents: - lines.append("=== Recommended Agents ===") - for i, agent_score in enumerate(discovery_result.recommended_agents, 1): - lines.append(f"\n{i}. Agent: {agent_score.agent_id}") - lines.append(f" Score: {agent_score.score:.2f}") - lines.append(f" Confidence: {agent_score.confidence:.2f}") - if agent_score.match_reasons: - lines.append(" Match Reasons:") - for reason in agent_score.match_reasons: - lines.append(f" - {reason}") - else: - lines.append("=== No Agents Found ===") - - # Suggestions - if discovery_result.suggestions: - lines.append("\n=== Suggestions ===") - for suggestion in discovery_result.suggestions: - lines.append(f"- {suggestion}") - - return "\n".join(lines) - - def get_similar_agents(self, agent_id: str, limit: int = 3) -> List[Dict[str, Any]]: - """Find agents similar to the given agent""" - target_agent = self.registry_client.get_agent_metadata(agent_id) - if not target_agent: - return [] - - # Create a pseudo-task based on the agent's characteristics - task_desc = f"Task requiring {target_agent.get('domain', 'general')} domain expertise" - if target_agent.get('capabilities'): - task_desc += f" with capabilities: {', '.join(target_agent['capabilities'])}" - - # Discover similar agents - result = self.discover_agents(task_desc, limit=limit + 1) # +1 to exclude self - - # Filter out the original agent - similar = [ - agent for agent in result.recommended_agents - if agent.agent_id != agent_id - ] - - return similar[:limit] \ No newline at end of file diff --git a/nanda_core/discovery/agent_ranker.py b/nanda_core/discovery/agent_ranker.py deleted file mode 100644 index 1fb56f5..0000000 --- a/nanda_core/discovery/agent_ranker.py +++ /dev/null @@ -1,307 +0,0 @@ -#!/usr/bin/env python3 -""" -Agent Ranking System for scoring and recommending agents based on task fit -""" - -from typing import Dict, List, Any, Tuple -from dataclasses import dataclass -from datetime import datetime, timedelta -import math - - -@dataclass -class AgentScore: - """Score result for an agent""" - agent_id: str - score: float - confidence: float - match_reasons: List[str] - metadata: Dict[str, Any] - - -class AgentRanker: - """Ranks agents based on their suitability for specific tasks""" - - def __init__(self): - # Scoring weights for different factors - self.weights = { - "capability_match": 0.35, - "domain_match": 0.25, - "keyword_match": 0.20, - "performance": 0.10, - "availability": 0.05, - "load": 0.05 - } - - def rank_agents(self, agents: List[Dict[str, Any]], task_analysis: Any, - performance_data: Dict[str, Any] = None) -> List[AgentScore]: - """Rank agents based on task requirements""" - - agent_scores = [] - - for agent in agents: - score_result = self._score_agent(agent, task_analysis, performance_data) - agent_scores.append(score_result) - - # Sort by score (descending) - agent_scores.sort(key=lambda x: x.score, reverse=True) - - return agent_scores - - def _score_agent(self, agent: Dict[str, Any], task_analysis: Any, - performance_data: Dict[str, Any] = None) -> AgentScore: - """Calculate comprehensive score for a single agent""" - - agent_id = agent.get("agent_id", "unknown") - match_reasons = [] - - # Calculate individual scores - capability_score = self._score_capabilities(agent, task_analysis, match_reasons) - domain_score = self._score_domain(agent, task_analysis, match_reasons) - keyword_score = self._score_keywords(agent, task_analysis, match_reasons) - performance_score = self._score_performance(agent, performance_data) - availability_score = self._score_availability(agent) - load_score = self._score_load(agent) - - # Calculate weighted total score - total_score = ( - capability_score * self.weights["capability_match"] + - domain_score * self.weights["domain_match"] + - keyword_score * self.weights["keyword_match"] + - performance_score * self.weights["performance"] + - availability_score * self.weights["availability"] + - load_score * self.weights["load"] - ) - - # Calculate confidence based on available data quality - confidence = self._calculate_confidence(agent, task_analysis) - - return AgentScore( - agent_id=agent_id, - score=total_score, - confidence=confidence, - match_reasons=match_reasons, - metadata={ - "capability_score": capability_score, - "domain_score": domain_score, - "keyword_score": keyword_score, - "performance_score": performance_score, - "availability_score": availability_score, - "load_score": load_score - } - ) - - def _score_capabilities(self, agent: Dict[str, Any], task_analysis: Any, - match_reasons: List[str]) -> float: - """Score based on capability matching""" - agent_capabilities = set(agent.get("capabilities", [])) - required_capabilities = set(task_analysis.required_capabilities) - - if not required_capabilities: - return 0.7 # Neutral score when no specific requirements - - if not agent_capabilities: - return 0.3 # Low score for agents with no declared capabilities - - # Calculate overlap - matching_caps = agent_capabilities.intersection(required_capabilities) - match_ratio = len(matching_caps) / len(required_capabilities) - - if matching_caps: - match_reasons.append(f"Matching capabilities: {', '.join(matching_caps)}") - - # Bonus for having more relevant capabilities than required - if len(matching_caps) == len(required_capabilities): - extra_relevant = len(agent_capabilities) - len(required_capabilities) - bonus = min(0.2, extra_relevant * 0.05) - match_ratio += bonus - - return min(1.0, match_ratio) - - def _score_domain(self, agent: Dict[str, Any], task_analysis: Any, - match_reasons: List[str]) -> float: - """Score based on domain expertise""" - agent_domain = agent.get("domain", "").lower() - task_domain = task_analysis.domain.lower() - - if task_domain == "general": - return 0.7 # Neutral score for general tasks - - if not agent_domain or agent_domain == "general": - return 0.5 # Moderate score for general agents - - if agent_domain == task_domain: - match_reasons.append(f"Domain expertise: {task_domain}") - return 1.0 - - # Check for related domains - domain_similarity = self._calculate_domain_similarity(agent_domain, task_domain) - if domain_similarity > 0.5: - match_reasons.append(f"Related domain: {agent_domain}") - - return domain_similarity - - def _score_keywords(self, agent: Dict[str, Any], task_analysis: Any, - match_reasons: List[str]) -> float: - """Score based on keyword matching""" - agent_keywords = set(word.lower() for word in agent.get("keywords", [])) - agent_description = agent.get("description", "").lower() - task_keywords = set(word.lower() for word in task_analysis.keywords) - - if not task_keywords: - return 0.7 # Neutral score when no keywords - - # Direct keyword matches - direct_matches = agent_keywords.intersection(task_keywords) - - # Keywords found in description - description_matches = set() - for keyword in task_keywords: - if keyword in agent_description: - description_matches.add(keyword) - - all_matches = direct_matches.union(description_matches) - - if all_matches: - match_reasons.append(f"Keyword matches: {', '.join(all_matches)}") - - match_ratio = len(all_matches) / len(task_keywords) if task_keywords else 0 - return min(1.0, match_ratio) - - def _score_performance(self, agent: Dict[str, Any], - performance_data: Dict[str, Any] = None) -> float: - """Score based on historical performance""" - if not performance_data: - return 0.7 # Neutral score without performance data - - agent_id = agent.get("agent_id") - if agent_id not in performance_data: - return 0.7 - - perf = performance_data[agent_id] - - # Consider multiple performance metrics - success_rate = perf.get("success_rate", 0.7) - avg_response_time = perf.get("avg_response_time", 5.0) # seconds - reliability = perf.get("reliability", 0.7) - - # Normalize response time (lower is better) - time_score = max(0.0, 1.0 - (avg_response_time / 30.0)) # 30s max - - # Combine metrics - performance_score = (success_rate * 0.5 + time_score * 0.3 + reliability * 0.2) - - return min(1.0, performance_score) - - def _score_availability(self, agent: Dict[str, Any]) -> float: - """Score based on agent availability""" - status = agent.get("status", "unknown").lower() - last_seen_str = agent.get("last_seen") - - if status == "offline": - return 0.0 - elif status == "busy": - return 0.3 - elif status == "available" or status == "online": - return 1.0 - - # If no explicit status, check last seen - if last_seen_str: - try: - last_seen = datetime.fromisoformat(last_seen_str.replace('Z', '+00:00')) - time_diff = datetime.now() - last_seen.replace(tzinfo=None) - - if time_diff < timedelta(minutes=5): - return 1.0 - elif time_diff < timedelta(hours=1): - return 0.8 - elif time_diff < timedelta(days=1): - return 0.5 - else: - return 0.2 - except: - pass - - return 0.5 # Default for unknown availability - - def _score_load(self, agent: Dict[str, Any]) -> float: - """Score based on current agent load""" - current_load = agent.get("current_load", 0.5) # 0.0 to 1.0 - - # Lower load is better - return 1.0 - current_load - - def _calculate_domain_similarity(self, domain1: str, domain2: str) -> float: - """Calculate similarity between domains""" - related_domains = { - "technology": ["software", "it", "programming", "tech"], - "finance": ["banking", "trading", "accounting", "fintech"], - "healthcare": ["medical", "clinical", "pharmaceutical"], - "marketing": ["advertising", "sales", "promotion"], - "education": ["learning", "training", "academic"] - } - - for main_domain, related in related_domains.items(): - if domain1 in related and domain2 in related: - return 0.8 - elif (domain1 == main_domain and domain2 in related) or \ - (domain2 == main_domain and domain1 in related): - return 0.9 - - return 0.2 # Low similarity for unrelated domains - - def _calculate_confidence(self, agent: Dict[str, Any], task_analysis: Any) -> float: - """Calculate confidence in the scoring""" - confidence = 0.5 # Base confidence - - # Increase confidence based on available agent metadata - if agent.get("capabilities"): - confidence += 0.2 - if agent.get("description"): - confidence += 0.1 - if agent.get("domain"): - confidence += 0.1 - if agent.get("last_seen"): - confidence += 0.05 - if agent.get("status"): - confidence += 0.05 - - # Factor in task analysis confidence - confidence *= task_analysis.confidence - - return min(1.0, confidence) - - def get_top_recommendations(self, agent_scores: List[AgentScore], - limit: int = 5, min_score: float = 0.3) -> List[AgentScore]: - """Get top agent recommendations with filtering""" - - # Filter by minimum score and confidence - filtered = [ - score for score in agent_scores - if score.score >= min_score and score.confidence >= 0.4 - ] - - return filtered[:limit] - - def explain_ranking(self, agent_score: AgentScore) -> str: - """Generate human-readable explanation for agent ranking""" - explanations = [] - - explanations.append(f"Overall score: {agent_score.score:.2f} (confidence: {agent_score.confidence:.2f})") - - if agent_score.match_reasons: - explanations.append("Match reasons:") - for reason in agent_score.match_reasons: - explanations.append(f" - {reason}") - - # Add detailed score breakdown - metadata = agent_score.metadata - explanations.append("Score breakdown:") - explanations.append(f" - Capability match: {metadata.get('capability_score', 0):.2f}") - explanations.append(f" - Domain expertise: {metadata.get('domain_score', 0):.2f}") - explanations.append(f" - Keyword relevance: {metadata.get('keyword_score', 0):.2f}") - explanations.append(f" - Performance: {metadata.get('performance_score', 0):.2f}") - explanations.append(f" - Availability: {metadata.get('availability_score', 0):.2f}") - explanations.append(f" - Load: {metadata.get('load_score', 0):.2f}") - - return "\n".join(explanations) \ No newline at end of file diff --git a/nanda_core/discovery/task_analyzer.py b/nanda_core/discovery/task_analyzer.py deleted file mode 100644 index eb78098..0000000 --- a/nanda_core/discovery/task_analyzer.py +++ /dev/null @@ -1,289 +0,0 @@ -#!/usr/bin/env python3 -""" -Task Analyzer for understanding user intent and task requirements -""" - -import re -import os -from typing import Dict, List, Any, Optional -from dataclasses import dataclass -from anthropic import Anthropic - - -@dataclass -class TaskAnalysis: - """Result of task analysis""" - task_type: str - complexity: str # 'simple', 'medium', 'complex' - domain: str - keywords: List[str] - required_capabilities: List[str] - confidence: float - description: str - - -class TaskAnalyzer: - """Analyzes tasks to understand requirements and extract relevant features""" - - def __init__(self): - self.anthropic = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY", "")) - - # Predefined task patterns - self.task_patterns = { - "data_analysis": [ - r"analyz(e|ing|sis)", r"data", r"statistics", r"chart", r"graph", - r"report", r"dashboard", r"metrics", r"kpi" - ], - "web_scraping": [ - r"scrap(e|ing)", r"extract", r"crawl", r"fetch", r"website", - r"html", r"parse", r"web" - ], - "file_management": [ - r"file", r"folder", r"directory", r"organize", r"manage", - r"upload", r"download", r"storage" - ], - "communication": [ - r"email", r"message", r"send", r"notify", r"alert", - r"slack", r"discord", r"teams" - ], - "code_generation": [ - r"code", r"program", r"script", r"function", r"api", - r"development", r"software", r"programming" - ], - "research": [ - r"research", r"search", r"find", r"lookup", r"investigate", - r"study", r"explore", r"discover" - ], - "automation": [ - r"automat(e|ion)", r"workflow", r"process", r"schedule", - r"trigger", r"batch", r"recurring" - ] - } - - self.complexity_indicators = { - "simple": [ - r"simple", r"basic", r"quick", r"easy", r"straightforward" - ], - "complex": [ - r"complex", r"advanced", r"sophisticated", r"comprehensive", - r"detailed", r"multi-step", r"enterprise" - ] - } - - def analyze_task(self, task_description: str) -> TaskAnalysis: - """Analyze a task description and extract requirements""" - - # Clean and prepare text - text = task_description.lower().strip() - - # Extract task type - task_type = self._identify_task_type(text) - - # Determine complexity - complexity = self._assess_complexity(text) - - # Extract domain - domain = self._extract_domain(text) - - # Extract keywords - keywords = self._extract_keywords(text) - - # Identify required capabilities - capabilities = self._identify_capabilities(text, task_type) - - # Use Claude for enhanced analysis if available - enhanced_analysis = self._enhance_with_claude(task_description) - - if enhanced_analysis: - capabilities.extend(enhanced_analysis.get("capabilities", [])) - keywords.extend(enhanced_analysis.get("keywords", [])) - if enhanced_analysis.get("domain"): - domain = enhanced_analysis["domain"] - - # Remove duplicates - capabilities = list(set(capabilities)) - keywords = list(set(keywords)) - - # Calculate confidence score - confidence = self._calculate_confidence(text, task_type, complexity) - - return TaskAnalysis( - task_type=task_type, - complexity=complexity, - domain=domain, - keywords=keywords, - required_capabilities=capabilities, - confidence=confidence, - description=task_description - ) - - def _identify_task_type(self, text: str) -> str: - """Identify the primary task type""" - scores = {} - - for task_type, patterns in self.task_patterns.items(): - score = 0 - for pattern in patterns: - matches = len(re.findall(pattern, text)) - score += matches - scores[task_type] = score - - if not scores or max(scores.values()) == 0: - return "general" - - return max(scores, key=scores.get) - - def _assess_complexity(self, text: str) -> str: - """Assess task complexity based on indicators""" - simple_score = 0 - complex_score = 0 - - for pattern in self.complexity_indicators["simple"]: - simple_score += len(re.findall(pattern, text)) - - for pattern in self.complexity_indicators["complex"]: - complex_score += len(re.findall(pattern, text)) - - # Default to medium if no clear indicators - if simple_score > complex_score: - return "simple" - elif complex_score > simple_score: - return "complex" - else: - # Analyze length and structure as fallback - word_count = len(text.split()) - if word_count < 10: - return "simple" - elif word_count > 50: - return "complex" - else: - return "medium" - - def _extract_domain(self, text: str) -> str: - """Extract the domain/industry context""" - domain_keywords = { - "finance": [r"financial", r"banking", r"investment", r"trading", r"accounting"], - "healthcare": [r"medical", r"health", r"patient", r"hospital", r"clinical"], - "technology": [r"software", r"tech", r"programming", r"development", r"it"], - "marketing": [r"marketing", r"advertising", r"campaign", r"promotion", r"brand"], - "education": [r"education", r"learning", r"teaching", r"student", r"course"], - "ecommerce": [r"shop", r"store", r"product", r"order", r"payment", r"cart"], - "logistics": [r"shipping", r"delivery", r"transport", r"warehouse", r"supply"] - } - - for domain, patterns in domain_keywords.items(): - for pattern in patterns: - if re.search(pattern, text): - return domain - - return "general" - - def _extract_keywords(self, text: str) -> List[str]: - """Extract important keywords from the text""" - # Remove stop words and extract meaningful terms - stop_words = { - "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", - "of", "with", "by", "from", "up", "about", "into", "through", "during", - "before", "after", "above", "below", "between", "among", "under", "over", - "is", "are", "was", "were", "be", "been", "being", "have", "has", "had", - "do", "does", "did", "will", "would", "could", "should", "may", "might", - "can", "must", "shall", "i", "you", "he", "she", "it", "we", "they", - "me", "him", "her", "us", "them", "my", "your", "his", "its", "our", "their" - } - - words = re.findall(r'\b\w+\b', text) - keywords = [word for word in words if word not in stop_words and len(word) > 2] - - # Return top keywords by frequency - from collections import Counter - word_counts = Counter(keywords) - return [word for word, count in word_counts.most_common(10)] - - def _identify_capabilities(self, text: str, task_type: str) -> List[str]: - """Identify required agent capabilities""" - capabilities = [] - - # Base capabilities by task type - task_capabilities = { - "data_analysis": ["analytics", "visualization", "statistics", "reporting"], - "web_scraping": ["web_access", "html_parsing", "data_extraction"], - "file_management": ["file_operations", "storage_access", "organization"], - "communication": ["messaging", "notifications", "email"], - "code_generation": ["programming", "code_review", "debugging"], - "research": ["search", "information_gathering", "synthesis"], - "automation": ["workflow_management", "scheduling", "integration"] - } - - capabilities.extend(task_capabilities.get(task_type, [])) - - # Additional capability detection - capability_patterns = { - "api_integration": [r"api", r"integration", r"connect", r"webhook"], - "database": [r"database", r"sql", r"query", r"store", r"retrieve"], - "machine_learning": [r"ml", r"machine learning", r"ai", r"model", r"predict"], - "image_processing": [r"image", r"photo", r"picture", r"visual", r"ocr"], - "document_processing": [r"document", r"pdf", r"word", r"text", r"parse"], - "real_time": [r"real.?time", r"live", r"streaming", r"instant"], - "security": [r"secure", r"encrypt", r"auth", r"permission", r"access"] - } - - for capability, patterns in capability_patterns.items(): - for pattern in patterns: - if re.search(pattern, text): - capabilities.append(capability) - break - - return capabilities - - def _enhance_with_claude(self, task_description: str) -> Optional[Dict[str, Any]]: - """Use Claude to enhance task analysis""" - try: - if not self.anthropic.api_key: - return None - - prompt = f"""Analyze the following task description and extract: -1. Primary domain/industry -2. Required capabilities (technical skills, integrations, etc.) -3. Important keywords -4. Task complexity (simple/medium/complex) - -Task: {task_description} - -Respond with JSON format: -{{ - "domain": "domain_name", - "capabilities": ["capability1", "capability2"], - "keywords": ["keyword1", "keyword2"], - "complexity": "simple|medium|complex" -}}""" - - response = self.anthropic.messages.create( - model="claude-3-5-sonnet-20241022", - max_tokens=500, - messages=[{"role": "user", "content": prompt}] - ) - - import json - return json.loads(response.content[0].text) - - except Exception as e: - print(f"Error in Claude enhancement: {e}") - return None - - def _calculate_confidence(self, text: str, task_type: str, complexity: str) -> float: - """Calculate confidence score for the analysis""" - score = 0.5 # Base confidence - - # Increase confidence based on text length and clarity - word_count = len(text.split()) - if word_count >= 5: - score += 0.2 - if word_count >= 15: - score += 0.1 - - # Increase confidence if task type was clearly identified - if task_type != "general": - score += 0.2 - - # Normalize to 0-1 range - return min(1.0, score) \ No newline at end of file diff --git a/scripts/deploy-agent.sh b/scripts/deploy-agent.sh deleted file mode 100644 index 610fb4f..0000000 --- a/scripts/deploy-agent.sh +++ /dev/null @@ -1,200 +0,0 @@ -#!/bin/bash - -# Simple NANDA Agent Deployment Script -# Usage: bash deploy-agent.sh [PORT] [REGISTRY_URL] - -set -e - -AGENT_TYPE=$1 -AGENT_ID=$2 -ANTHROPIC_API_KEY=$3 -PORT=${4:-6000} -REGISTRY_URL=${5:-""} - -if [ -z "$AGENT_TYPE" ] || [ -z "$AGENT_ID" ] || [ -z "$ANTHROPIC_API_KEY" ]; then - echo "๐Ÿค– Simple NANDA Agent Deployment" - echo "=================================" - echo "" - echo "Usage: bash deploy-agent.sh [PORT] [REGISTRY_URL]" - echo "" - echo "Agent Types:" - echo " โ€ข helpful - General helpful agent" - echo " โ€ข pirate - Pirate personality agent" - echo " โ€ข echo - Simple echo agent" - echo " โ€ข analyst - LangChain document analyst (requires LangChain)" - echo "" - echo "Examples:" - echo " bash deploy-agent.sh helpful my_agent sk-ant-xxxxx" - echo " bash deploy-agent.sh analyst doc_analyzer sk-ant-xxxxx 6020" - echo " bash deploy-agent.sh pirate captain_jack sk-ant-xxxxx 6000 https://registry.example.com" - echo "" - exit 1 -fi - -echo "๐Ÿš€ Deploying NANDA Agent" -echo "========================" -echo "Agent Type: $AGENT_TYPE" -echo "Agent ID: $AGENT_ID" -echo "Port: $PORT" -echo "Registry: ${REGISTRY_URL:-"None (local only)"}" -echo "" - -echo "[1/6] Updating system and installing Python..." -# Works on both Ubuntu and Amazon Linux -if command -v apt &> /dev/null; then - # Ubuntu/Debian - sudo apt update -y - sudo apt install -y python3 python3-pip python3-venv git curl -else - # Amazon Linux/CentOS/RHEL - sudo dnf update -y - sudo dnf install -y python3.11 python3.11-pip git curl - # Create python3 symlink if needed - if ! command -v python3 &> /dev/null; then - sudo ln -s /usr/bin/python3.11 /usr/bin/python3 - fi -fi - -echo "[2/6] Setting up project directory..." -cd "$HOME" -PROJECT_DIR="nanda-agent-$AGENT_ID" - -# Remove existing directory if it exists -if [ -d "$PROJECT_DIR" ]; then - echo "Removing existing directory..." - rm -rf "$PROJECT_DIR" -fi - -# Clone streamlined adapter -echo "Cloning streamlined adapter..." -git clone https://github.com/projnanda/NEST.git "$PROJECT_DIR" -cd "$PROJECT_DIR" - -echo "[3/6] Creating Python virtual environment..." -python3 -m venv env -source env/bin/activate - -echo "[4/6] Installing Python dependencies..." -pip install --upgrade pip - -# Install core dependencies -pip install anthropic python-a2a requests - -# Install agent-specific dependencies -case $AGENT_TYPE in - "analyst") - echo "Installing LangChain dependencies for analyst agent..." - pip install langchain-core langchain-anthropic - ;; - "helpful"|"pirate"|"echo") - echo "Using built-in agent type (no extra dependencies)" - ;; - *) - echo "โš ๏ธ Unknown agent type: $AGENT_TYPE. Proceeding with basic installation..." - ;; -esac - -echo "[5/6] Creating agent startup script..." -cat > run_agent.py << EOF -#!/usr/bin/env python3 -""" -Auto-generated NANDA Agent -Agent Type: $AGENT_TYPE -Agent ID: $AGENT_ID -Port: $PORT -""" - -import os -import sys - -# Set API key -os.environ["ANTHROPIC_API_KEY"] = "$ANTHROPIC_API_KEY" - -# Add project to path -sys.path.append(os.path.dirname(__file__)) - -from nanda_core.core.adapter import NANDA, helpful_agent, pirate_agent, echo_agent - -def main(): - print("๐Ÿค– Starting NANDA Agent: $AGENT_ID") - print("Agent Type: $AGENT_TYPE") - print("Port: $PORT") - print("") - - # Select agent logic based on type - agent_logic = helpful_agent # default - - if "$AGENT_TYPE" == "pirate": - agent_logic = pirate_agent - elif "$AGENT_TYPE" == "echo": - agent_logic = echo_agent - elif "$AGENT_TYPE" == "analyst": - try: - from examples.langchain_analyst_agent import DocumentAnalyst, create_analyst_agent_logic - analyst = DocumentAnalyst() - agent_logic = create_analyst_agent_logic(analyst) - print("๐Ÿ“Š LangChain Document Analyst loaded") - except ImportError: - print("โš ๏ธ LangChain dependencies not available, using helpful agent") - agent_logic = helpful_agent - - # Create NANDA agent - nanda = NANDA( - agent_id="$AGENT_ID", - agent_logic=agent_logic, - port=$PORT, - registry_url="${REGISTRY_URL}" if "${REGISTRY_URL}" else None, - public_url="http://\$(curl -s ifconfig.me):$PORT" if "${REGISTRY_URL}" else None - ) - - print("๐Ÿš€ Agent ready! Send messages to http://localhost:$PORT/a2a") - if "${REGISTRY_URL}": - print("๐ŸŒ Will attempt to register with registry: ${REGISTRY_URL}") - - try: - nanda.start(register=bool("${REGISTRY_URL}")) - except KeyboardInterrupt: - print("\\n๐Ÿ›‘ Agent stopped") - -if __name__ == "__main__": - main() -EOF - -chmod +x run_agent.py - -echo "[6/6] Starting agent..." -echo "" -echo "๐ŸŽ‰ NANDA Agent Deployment Complete!" -echo "====================================" -echo "Agent ID: $AGENT_ID" -echo "Type: $AGENT_TYPE" -echo "Port: $PORT" -echo "Directory: $HOME/$PROJECT_DIR" -echo "" -echo "๐Ÿš€ Starting agent in background..." - -# Start agent in background -nohup python3 run_agent.py > agent.log 2>&1 & -AGENT_PID=$! - -sleep 3 - -# Check if agent is running -if ps -p $AGENT_PID > /dev/null; then - echo "โœ… Agent started successfully (PID: $AGENT_PID)" - echo "" - echo "๐Ÿ“‹ Useful commands:" - echo " โ€ข View logs: tail -f $HOME/$PROJECT_DIR/agent.log" - echo " โ€ข Stop agent: kill $AGENT_PID" - echo " โ€ข Test agent: curl -X POST http://localhost:$PORT/a2a -H 'Content-Type: application/json' -d '{\"content\":{\"text\":\"hello\"}}'" - echo "" - echo "๐Ÿ”— Agent URL: http://\$(curl -s ifconfig.me):$PORT/a2a" - echo "" - echo "๐Ÿ“„ View logs:" - tail -10 agent.log -else - echo "โŒ Agent failed to start. Check logs:" - cat agent.log - exit 1 -fi - From 21829a533ab0c8b87766eba74e39e942d501e287 Mon Sep 17 00:00:00 2001 From: destroyersrt Date: Fri, 17 Oct 2025 12:58:41 +0530 Subject: [PATCH 2/4] Official A2A Support --- examples/nanda_agent.py | 275 ++++++------------- nanda_core/__init__.py | 4 +- nanda_core/core/__init__.py | 4 +- nanda_core/core/adapter.py | 123 +++++++-- nanda_core/core/adapter_copy.py | 101 +++++++ nanda_core/core/agent_bridge.py | 366 +++++++++++++------------ nanda_core/core/agent_bridge_copy.py | 252 +++++++++++++++++ nanda_core/core/registry_client.py | 223 +++++++++++---- nanda_core/protocols/AgentExecutor.py | 73 +++++ nanda_core/protocols/a2a/__init__.py | 3 + nanda_core/protocols/a2a/protocol.py | 234 ++++++++++++++++ nanda_core/protocols/base.py | 52 ++++ nanda_core/protocols/router.py | 99 +++++++ nanda_core/telemetry/health_monitor.py | 2 +- setup.py | 15 +- 15 files changed, 1359 insertions(+), 467 deletions(-) create mode 100644 nanda_core/core/adapter_copy.py create mode 100644 nanda_core/core/agent_bridge_copy.py create mode 100644 nanda_core/protocols/AgentExecutor.py create mode 100644 nanda_core/protocols/a2a/__init__.py create mode 100644 nanda_core/protocols/a2a/protocol.py create mode 100644 nanda_core/protocols/base.py create mode 100644 nanda_core/protocols/router.py diff --git a/examples/nanda_agent.py b/examples/nanda_agent.py index 847799f..e9cd833 100644 --- a/examples/nanda_agent.py +++ b/examples/nanda_agent.py @@ -1,72 +1,57 @@ +# examples/nanda_agent.py #!/usr/bin/env python3 """ -LLM-Powered Modular NANDA Agent +LLM-Powered NANDA Agent -This agent uses Anthropic Claude for intelligent responses based on configurable personality and expertise. -Simply update the AGENT_CONFIG section to create different agent personalities. +Configurable agent using Anthropic Claude for intelligent responses. +Customize via environment variables or the config dict. """ import os import sys -import time +import asyncio import uuid from datetime import datetime -from typing import Dict, List, Any -# Add the parent directory to the path to allow importing streamlined_adapter sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) from nanda_core.core.adapter import NANDA -# Try to import Anthropic - will fail gracefully if not available +from dotenv import load_dotenv +load_dotenv() + try: from anthropic import Anthropic ANTHROPIC_AVAILABLE = True except ImportError: ANTHROPIC_AVAILABLE = False - print("โš ๏ธ Warning: anthropic library not available. Install with: pip install anthropic") + print("โš ๏ธ anthropic library not available. Install with: pip install anthropic") # ============================================================================= -# AGENT CONFIGURATION - Customize this section for different agents +# CONFIGURATION # ============================================================================= -# Get configuration from environment variables or use defaults -def get_agent_config(): - """Load agent configuration from environment variables or use defaults""" +def get_config(): + """Load configuration from environment variables""" + base_id = os.getenv("AGENT_ID", "helpful-agent") + agent_id = f"{base_id}-{uuid.uuid4().hex[:6]}" if '-' not in base_id else base_id - # Generate agent_id with hex suffix for uniqueness - base_agent_id = os.getenv("AGENT_ID", "helpful-ubuntu-agent") - if not base_agent_id.endswith('-') and '-' not in base_agent_id.split('-')[-1]: - # Add 6-character hex suffix if not already present - hex_suffix = uuid.uuid4().hex[:6] - agent_id = f"{base_agent_id}-{hex_suffix}" - else: - agent_id = base_agent_id + capabilities = os.getenv("AGENT_CAPABILITIES", "general assistance,conversation") + capabilities_list = [cap.strip() for cap in capabilities.split(",")] - print(f"Generated agent_id: {agent_id}") - agent_name = os.getenv("AGENT_NAME", "Ubuntu Helper") + agent_name = os.getenv("AGENT_NAME", "Helper Agent") domain = os.getenv("AGENT_DOMAIN", "general assistance") - specialization = os.getenv("AGENT_SPECIALIZATION", "helpful and friendly AI assistant") - description = os.getenv("AGENT_DESCRIPTION", "I am a helpful AI assistant specializing in general tasks and Ubuntu system administration.") - capabilities = os.getenv("AGENT_CAPABILITIES", "general assistance,Ubuntu system administration,Python development,cloud deployment,agent-to-agent communication") - registry_url = os.getenv("REGISTRY_URL", None) - public_url = os.getenv("PUBLIC_URL", None) - - # Parse capabilities into a list - expertise_list = [cap.strip() for cap in capabilities.split(",")] + specialization = os.getenv("AGENT_SPECIALIZATION", "helpful AI assistant") + description = os.getenv("AGENT_DESCRIPTION", "I'm a helpful AI assistant.") - # Create dynamic system prompt based on configuration - system_prompt = f"""You are {agent_name}, a {specialization} working in the domain of {domain}. + system_prompt = f"""You are {agent_name}, a {specialization} in {domain}. {description} -You are part of the NANDA (Network of Autonomous Distributed Agents) system. You can communicate with other agents and help users with various tasks. +You are part of the NANDA agent network and can communicate with other agents using @agent-id syntax. -Your capabilities include: -{chr(10).join([f"- {cap}" for cap in expertise_list])} +Your capabilities: {', '.join(capabilities_list)} -Always be helpful, accurate, and concise in your responses. If you're unsure about something, say so honestly. You can also help with basic calculations, provide time information, and engage in casual conversation. - -When someone asks about yourself, mention that you're part of the NANDA agent network and can communicate with other agents using the @agent_name syntax.""" +Be helpful, accurate, and concise.""" return { "agent_id": agent_id, @@ -74,200 +59,98 @@ def get_agent_config(): "domain": domain, "specialization": specialization, "description": description, - "expertise": expertise_list, - "registry_url": registry_url, - "public_url": public_url, - "system_prompt": system_prompt, + "capabilities": capabilities_list, + "port": int(os.getenv("PORT", "6000")), + "registry_url": os.getenv("REGISTRY_URL"), + "public_url": os.getenv("PUBLIC_URL") or f"http://localhost:{os.getenv('PORT', '6000')}", "anthropic_api_key": os.getenv("ANTHROPIC_API_KEY"), - "model": "claude-3-haiku-20240307" # Fast and cost-effective model + "model": os.getenv("ANTHROPIC_MODEL", "claude-3-haiku-20240307"), + "system_prompt": system_prompt } -# Load configuration -AGENT_CONFIG = get_agent_config() - -# Port configuration - use environment variable or default to 6000 -PORT = int(os.getenv("PORT", "6000")) - # ============================================================================= -# LLM-POWERED AGENT LOGIC - Uses Anthropic Claude for intelligent responses +# AGENT LOGIC # ============================================================================= -def create_llm_agent_logic(config: Dict[str, Any]): - """ - Creates an LLM-powered agent logic function based on the provided configuration. - Uses Anthropic Claude for intelligent, context-aware responses. - """ +def create_agent_logic(config): + """Create agent logic function with LLM or fallback""" # Initialize Anthropic client - anthropic_client = None + client = None if ANTHROPIC_AVAILABLE and config.get("anthropic_api_key"): try: - anthropic_client = Anthropic(api_key=config["anthropic_api_key"]) - print(f"โœ… Anthropic Claude initialized for {config['agent_name']}") + client = Anthropic(api_key=config["anthropic_api_key"]) + print(f"โœ… Claude initialized ({config['model']})") except Exception as e: - print(f"โŒ Failed to initialize Anthropic: {e}") - anthropic_client = None - - # Prepare system prompt (already formatted in get_agent_config) - system_prompt = config["system_prompt"] + print(f"โŒ Claude initialization failed: {e}") - def llm_agent_logic(message: str, conversation_id: str) -> str: - """LLM-powered agent logic with fallback to basic responses""" + def agent_logic(message: str, conversation_id: str) -> str: + """Process message with Claude or fallback""" - # If LLM is available, use it for intelligent responses - if anthropic_client: + if client: try: - # Add current time context if time-related query - context_info = "" - if any(time_word in message.lower() for time_word in ['time', 'date', 'when']): - context_info = f"\n\nCurrent time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + # Add time context if relevant + context = "" + if any(word in message.lower() for word in ['time', 'date', 'when']): + context = f"\n\nCurrent time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" - response = anthropic_client.messages.create( + response = client.messages.create( model=config["model"], max_tokens=500, - system=system_prompt + context_info, - messages=[ - { - "role": "user", - "content": message - } - ] + system=config["system_prompt"] + context, + messages=[{"role": "user", "content": message}] ) return response.content[0].text.strip() except Exception as e: - print(f"โŒ LLM Error: {e}") - # Fall back to basic response - return f"Sorry, I'm having trouble processing that right now. Error: {str(e)}" + return f"Error: {str(e)}" - # Fallback to basic responses if LLM not available + # Fallback responses + msg = message.lower().strip() + if any(greeting in msg for greeting in ['hello', 'hi', 'hey']): + return f"Hello! I'm {config['agent_name']}. Set ANTHROPIC_API_KEY for full capabilities." + elif 'time' in msg: + return f"Current time: {datetime.now().strftime('%H:%M:%S')}" else: - return _basic_fallback_response(message, config) - - return llm_agent_logic - -def _basic_fallback_response(message: str, config: Dict[str, Any]) -> str: - """Basic fallback responses when LLM is not available""" - msg = message.lower().strip() - - # Handle greetings - if any(greeting in msg for greeting in ['hello', 'hi', 'hey']): - return f"Hello! I'm {config['agent_name']}, but I need an Anthropic API key to provide intelligent responses. Please set ANTHROPIC_API_KEY environment variable." + return f"I'm {config['agent_name']}. Set ANTHROPIC_API_KEY to enable LLM responses." - # Handle time requests - elif 'time' in msg: - current_time = datetime.now().strftime("%H:%M:%S") - return f"The current time is {current_time}." - - # Handle basic calculations - elif any(op in message for op in ['+', '-', '*', '/', '=']): - try: - calculation = message.replace('x', '*').replace('X', '*').replace('=', '').strip() - result = eval(calculation) - return f"Calculation result: {calculation} = {result}" - except: - return "Sorry, I couldn't calculate that. Please check your expression." - - # Default fallback - else: - return f"I'm {config['agent_name']}, but I need an Anthropic API key to provide intelligent responses. Please set ANTHROPIC_API_KEY environment variable and restart me." + return agent_logic # ============================================================================= -# MAIN EXECUTION +# MAIN # ============================================================================= -def main(): - """Main function to start the LLM-powered modular agent""" - print(f"๐Ÿค– Starting {AGENT_CONFIG['agent_name']}") - print(f"๐Ÿ“ Specialization: {AGENT_CONFIG['specialization']}") - print(f"๐ŸŽฏ Domain: {AGENT_CONFIG['domain']}") - print(f"๐Ÿ› ๏ธ Capabilities: {', '.join(AGENT_CONFIG['expertise'])}") - if AGENT_CONFIG['registry_url']: - print(f"๐ŸŒ Registry: {AGENT_CONFIG['registry_url']}") +async def main(): + config = get_config() - # Check for Anthropic API key - if not AGENT_CONFIG.get("anthropic_api_key"): - print("โš ๏ธ Warning: ANTHROPIC_API_KEY not found in environment variables") - print(" The agent will use basic fallback responses only") - print(" Set ANTHROPIC_API_KEY to enable LLM capabilities") - else: - print(f"๐Ÿง  LLM Model: {AGENT_CONFIG['model']}") + print(f"๐Ÿค– {config['agent_name']} ({config['agent_id']})") + print(f"๐ŸŽฏ {config['domain']} - {config['specialization']}") + print(f"๐Ÿ”— {config['public_url']}/a2a") + if config['registry_url']: + print(f"๐ŸŒ Registry: {config['registry_url']}") - # Create the LLM-powered agent logic based on configuration - agent_logic = create_llm_agent_logic(AGENT_CONFIG) + agent_logic = create_agent_logic(config) - # Create and start the NANDA agent nanda = NANDA( - agent_id=AGENT_CONFIG["agent_id"], + agent_id=config["agent_id"], agent_logic=agent_logic, - port=PORT, - registry_url=AGENT_CONFIG["registry_url"], - public_url=AGENT_CONFIG["public_url"], - enable_telemetry=True + agent_name=config["agent_name"], + domain=config["domain"], + specialization=config["specialization"], + description=config["description"], + capabilities=config["capabilities"], + port=config["port"], + registry_url=config["registry_url"], + public_url=config["public_url"], + enable_telemetry=True, + protocols={"a2a": {"enabled": True}} ) - print(f"๐Ÿš€ Agent URL: http://localhost:{PORT}/a2a") - print("๐Ÿ’ก Try these messages:") - print(" - 'Hello there'") - print(" - 'Tell me about yourself'") - print(" - 'What time is it?'") - print(" - 'How can you help with Ubuntu?'") - print(" - 'Explain Python virtual environments'") - print(" - '5 + 3'") - print("\n๐Ÿ›‘ Press Ctrl+C to stop") - - # Start the agent - nanda.start() - -def create_custom_agent(agent_name, specialization, domain, expertise_list, port=6000, anthropic_api_key=None, registry_url=None): - """ - Helper function to quickly create a custom LLM-powered agent with different config - - Example usage: - create_custom_agent( - agent_name="Data Scientist", - specialization="analytical and precise AI assistant", - domain="data science", - expertise_list=["data analysis", "statistics", "machine learning", "Python"], - port=6001, - anthropic_api_key="sk-ant-xxxxx" - ) - """ - custom_config = AGENT_CONFIG.copy() - custom_config.update({ - "agent_id": agent_name.lower().replace(" ", "-"), - "agent_name": agent_name, - "specialization": specialization, - "domain": domain, - "expertise": expertise_list, - "registry_url": registry_url, - "anthropic_api_key": anthropic_api_key or os.getenv("ANTHROPIC_API_KEY"), - "system_prompt": f"""You are {agent_name}, a {specialization} working in the domain of {domain}. - -You are part of the NANDA (Network of Autonomous Distributed Agents) system. You can communicate with other agents and help users with various tasks. - -Your capabilities include: -{chr(10).join([f"- {expertise}" for expertise in expertise_list])} - -Always be helpful, accurate, and concise in your responses. If you're unsure about something, say so honestly. - -When someone asks about yourself, mention that you're part of the NANDA agent network and can communicate with other agents using the @agent_name syntax.""" - }) - - agent_logic = create_llm_agent_logic(custom_config) - - nanda = NANDA( - agent_id=custom_config["agent_id"], - agent_logic=agent_logic, - port=port, - registry_url=custom_config["registry_url"], - enable_telemetry=True - ) + print("\n๐Ÿ’ฌ Try: 'Hello', 'What time is it?', '@other-agent Hello!'") + print("๐Ÿ›‘ Press Ctrl+C to stop\n") - print(f"๐Ÿค– Starting custom LLM agent: {agent_name}") - print(f"๐Ÿš€ Agent URL: http://localhost:{port}/a2a") - nanda.start() + await nanda.start() if __name__ == "__main__": - main() + asyncio.run(main()) \ No newline at end of file diff --git a/nanda_core/__init__.py b/nanda_core/__init__.py index b6561ca..b287102 100644 --- a/nanda_core/__init__.py +++ b/nanda_core/__init__.py @@ -4,9 +4,9 @@ """ from .core.adapter import NANDA -from .core.agent_bridge import SimpleAgentBridge +from .core.agent_bridge import AgentBridge __all__ = [ "NANDA", - "SimpleAgentBridge" + "AgentBridge" ] \ No newline at end of file diff --git a/nanda_core/core/__init__.py b/nanda_core/core/__init__.py index f0d8570..cf59933 100644 --- a/nanda_core/core/__init__.py +++ b/nanda_core/core/__init__.py @@ -4,9 +4,9 @@ """ from .adapter import NANDA -from .agent_bridge import SimpleAgentBridge +from .agent_bridge import AgentBridge __all__ = [ "NANDA", - "SimpleAgentBridge" + "AgentBridge" ] \ No newline at end of file diff --git a/nanda_core/core/adapter.py b/nanda_core/core/adapter.py index b98892c..5b7089c 100644 --- a/nanda_core/core/adapter.py +++ b/nanda_core/core/adapter.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """ Simple NANDA Adapter - Clean Agent-to-Agent Communication @@ -7,10 +6,13 @@ """ import os +import asyncio import requests from typing import Optional, Callable -from python_a2a import run_server -from .agent_bridge import SimpleAgentBridge +from .agent_bridge import AgentBridge +from .registry_client import RegistryClient +from ..protocols.router import ProtocolRouter +from ..protocols.a2a.protocol import A2AProtocol class NANDA: @@ -19,30 +21,48 @@ class NANDA: def __init__(self, agent_id: str, agent_logic: Callable[[str, str], str], + agent_name: Optional[str] = None, + domain: Optional[str] = None, + specialization: Optional[str] = None, + description: Optional[str] = None, + capabilities: Optional[list] = None, port: int = 6000, registry_url: Optional[str] = None, public_url: Optional[str] = None, host: str = "0.0.0.0", - enable_telemetry: bool = True): + enable_telemetry: bool = True, + protocols: Optional[dict] = None): """ Create a simple NANDA agent Args: agent_id: Unique agent identifier agent_logic: Function that takes (message: str, conversation_id: str) -> response: str + agent_name: Display name (defaults to agent_id) + domain: Agent's domain of expertise + specialization: Agent's specialization + description: Agent description + capabilities: List of capabilities (default: ["text"]) port: Port to run on registry_url: Optional registry URL for agent discovery public_url: Public URL for agent registration (e.g., https://yourdomain.com:6000) host: Host to bind to - enable_telemetry: Enable telemetry logging (optional) + enable_telemetry: Enable telemetry logging + protocols: Dict of protocol configs (e.g., {"a2a": {"enabled": True}}) """ self.agent_id = agent_id self.agent_logic = agent_logic + self.agent_name = agent_name or agent_id + self.domain = domain + self.specialization = specialization + self.description = description or f"AI agent {agent_id}" + self.capabilities = capabilities or ["text"] self.port = port self.registry_url = registry_url - self.public_url = public_url + self.public_url = public_url or f"http://localhost:{port}" self.host = host self.enable_telemetry = enable_telemetry + self.protocols_config = protocols or {"a2a": {"enabled": True}} # Initialize telemetry if enabled self.telemetry = None @@ -54,46 +74,99 @@ def __init__(self, except ImportError: print(f"โš ๏ธ Telemetry requested but module not available") - # Create the bridge with optional features - self.bridge = SimpleAgentBridge( + # Initialize protocol router + self.router = ProtocolRouter() + + # Initialize and register protocols + self._initialize_protocols() + + # Initialize registry client + self.registry = RegistryClient(registry_url) if registry_url else RegistryClient(None) + + # Create the bridge + self.bridge = AgentBridge( + protocol_router=self.router, + registry_client=self.registry, agent_id=agent_id, agent_logic=agent_logic, - registry_url=registry_url, telemetry=self.telemetry ) print(f"๐Ÿค– NANDA Agent '{agent_id}' created") if registry_url: print(f"๐ŸŒ Registry: {registry_url}") - if public_url: - print(f"๐Ÿ”— Public URL: {public_url}") + print(f"๐Ÿ”— Public URL: {self.public_url}") + print(f"๐Ÿ”Œ Protocols: {self.router.get_all_protocols()}") + + def _initialize_protocols(self): + """Initialize and register protocol adapters based on config""" + + # Initialize A2A protocol if enabled + if self.protocols_config.get("a2a", {}).get("enabled", True): + a2a_protocol = A2AProtocol( + agent_id=self.agent_id, + agent_name=self.agent_name, + public_url=self.public_url, + domain=self.domain, + specialization=self.specialization, + description=self.description, + capabilities=self.capabilities + ) + self.router.register(a2a_protocol) + + # TODO: Add SLIM protocol when implemented + # if self.protocols_config.get("slim", {}).get("enabled"): + # slim_protocol = SLIMProtocol(...) + # self.router.register(slim_protocol) - def start(self, register: bool = True): + async def start(self, register: bool = True): """Start the agent server""" # Register with registry if provided - if register and self.registry_url and self.public_url: - self._register() + if register and self.registry_url: + await self._register() print(f"๐Ÿš€ Starting agent '{self.agent_id}' on {self.host}:{self.port}") - # Start the A2A server - run_server(self.bridge, host=self.host, port=self.port) + # Start the bridge (which starts all protocol servers) + await self.bridge.run_server(self.host, self.port) - def _register(self): - """Register agent with registry""" + async def _register(self): + """Register agent with NANDA Index""" try: - data = { + agent_facts = { "agent_id": self.agent_id, - "agent_url": self.public_url + "name": self.agent_name, + "domain": self.domain, + "specialization": self.specialization, + "description": self.description, + "capabilities": self.capabilities, + "url": self.public_url, + "agent_url": self.public_url, # For backward compatibility + "supported_protocols": self.router.get_all_protocols(), + "endpoints": self._get_endpoints() } - response = requests.post(f"{self.registry_url}/register", json=data, timeout=10) - if response.status_code == 200: - print(f"โœ… Agent '{self.agent_id}' registered successfully") - else: - print(f"โš ๏ธ Failed to register agent: HTTP {response.status_code}") + + await self.registry.register(agent_facts) + print(f"โœ… Agent '{self.agent_id}' registered successfully") + except Exception as e: print(f"โš ๏ธ Registration error: {e}") + def _get_endpoints(self) -> dict: + """Get endpoints for all registered protocols + + Returns: + Dict mapping protocol names to endpoint URLs + """ + endpoints = {} + for protocol_name in self.router.get_all_protocols(): + if protocol_name == "a2a": + endpoints["a2a"] = f"{self.public_url}/a2a" + elif protocol_name == "slim": + endpoints["slim"] = f"grpc://{self.public_url}:50051" + + return endpoints + def stop(self): """Stop the agent and cleanup telemetry""" if self.telemetry: diff --git a/nanda_core/core/adapter_copy.py b/nanda_core/core/adapter_copy.py new file mode 100644 index 0000000..b98892c --- /dev/null +++ b/nanda_core/core/adapter_copy.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +Simple NANDA Adapter - Clean Agent-to-Agent Communication + +Simple, clean adapter focused on A2A communication without complexity. +8-10 lines to deploy an agent. +""" + +import os +import requests +from typing import Optional, Callable +from python_a2a import run_server +from .agent_bridge import SimpleAgentBridge + + +class NANDA: + """Simple NANDA class for clean agent deployment""" + + def __init__(self, + agent_id: str, + agent_logic: Callable[[str, str], str], + port: int = 6000, + registry_url: Optional[str] = None, + public_url: Optional[str] = None, + host: str = "0.0.0.0", + enable_telemetry: bool = True): + """ + Create a simple NANDA agent + + Args: + agent_id: Unique agent identifier + agent_logic: Function that takes (message: str, conversation_id: str) -> response: str + port: Port to run on + registry_url: Optional registry URL for agent discovery + public_url: Public URL for agent registration (e.g., https://yourdomain.com:6000) + host: Host to bind to + enable_telemetry: Enable telemetry logging (optional) + """ + self.agent_id = agent_id + self.agent_logic = agent_logic + self.port = port + self.registry_url = registry_url + self.public_url = public_url + self.host = host + self.enable_telemetry = enable_telemetry + + # Initialize telemetry if enabled + self.telemetry = None + if enable_telemetry: + try: + from ..telemetry.telemetry_system import TelemetrySystem + self.telemetry = TelemetrySystem(agent_id) + print(f"๐Ÿ“Š Telemetry enabled for {agent_id}") + except ImportError: + print(f"โš ๏ธ Telemetry requested but module not available") + + # Create the bridge with optional features + self.bridge = SimpleAgentBridge( + agent_id=agent_id, + agent_logic=agent_logic, + registry_url=registry_url, + telemetry=self.telemetry + ) + + print(f"๐Ÿค– NANDA Agent '{agent_id}' created") + if registry_url: + print(f"๐ŸŒ Registry: {registry_url}") + if public_url: + print(f"๐Ÿ”— Public URL: {public_url}") + + def start(self, register: bool = True): + """Start the agent server""" + # Register with registry if provided + if register and self.registry_url and self.public_url: + self._register() + + print(f"๐Ÿš€ Starting agent '{self.agent_id}' on {self.host}:{self.port}") + + # Start the A2A server + run_server(self.bridge, host=self.host, port=self.port) + + def _register(self): + """Register agent with registry""" + try: + data = { + "agent_id": self.agent_id, + "agent_url": self.public_url + } + response = requests.post(f"{self.registry_url}/register", json=data, timeout=10) + if response.status_code == 200: + print(f"โœ… Agent '{self.agent_id}' registered successfully") + else: + print(f"โš ๏ธ Failed to register agent: HTTP {response.status_code}") + except Exception as e: + print(f"โš ๏ธ Registration error: {e}") + + def stop(self): + """Stop the agent and cleanup telemetry""" + if self.telemetry: + self.telemetry.stop() + print(f"๐Ÿ›‘ Stopping agent '{self.agent_id}'") \ No newline at end of file diff --git a/nanda_core/core/agent_bridge.py b/nanda_core/core/agent_bridge.py index fc1a668..028d9fa 100644 --- a/nanda_core/core/agent_bridge.py +++ b/nanda_core/core/agent_bridge.py @@ -1,145 +1,154 @@ -#!/usr/bin/env python3 """ -Simple Agent Bridge for A2A Communication +Agent Bridge for Protocol-Agnostic Communication -Clean, simple bridge focused on agent-to-agent communication. +Handles message routing between agents using any registered protocol. """ -import os +import re import uuid import logging -import requests from typing import Callable, Optional, Dict, Any -from python_a2a import A2AServer, A2AClient, Message, TextContent, MessageRole, Metadata +from ..protocols.router import ProtocolRouter +from .registry_client import RegistryClient -# Configure logger to capture conversation logs logger = logging.getLogger(__name__) -class SimpleAgentBridge(A2AServer): - """Simple Agent Bridge for A2A communication only""" +class AgentBridge: + """Protocol-agnostic agent message router and coordinator""" def __init__(self, - agent_id: str, + protocol_router: ProtocolRouter, + registry_client: RegistryClient, + agent_id: str, agent_logic: Callable[[str, str], str], - registry_url: Optional[str] = None, - telemetry = None): - super().__init__() + telemetry=None): + """Initialize agent bridge + + Args: + protocol_router: Router managing protocol adapters + registry_client: Client for NANDA Index + agent_id: This agent's unique identifier + agent_logic: Agent's business logic function(message: str, conversation_id: str) -> str + telemetry: Optional telemetry system + """ + self.router = protocol_router + self.registry = registry_client self.agent_id = agent_id self.agent_logic = agent_logic - self.registry_url = registry_url self.telemetry = telemetry - def handle_message(self, msg: Message) -> Message: - """Handle incoming messages""" - conversation_id = msg.conversation_id or str(uuid.uuid4()) + # Register this bridge as incoming handler for all protocols + for protocol_name in self.router.get_all_protocols(): + protocol = self.router.get_protocol(protocol_name) + protocol.set_incoming_handler(self.handle_message) - # Only handle text content - if not isinstance(msg.content, TextContent): - return self._create_response( - msg, conversation_id, - "Only text messages supported" - ) + logger.info(f"๐ŸŒ‰ Bridge initialized for {agent_id} with protocols: {self.router.get_all_protocols()}") + + def extract_agent_id(self, content: str) -> Optional[str]: + """Extract @agent-id from message content""" + match = re.search(r'@([\w-]+)', content) + return match.group(1) if match else None + + def parse_incoming_agent_message(self, text: str) -> Optional[Dict[str, str]]: + """Parse incoming agent-to-agent message in format: + FROM: sender\nTO: receiver\nMESSAGE: content + """ + if not (text.startswith("FROM:") and "TO:" in text and "MESSAGE:" in text): + return None + + try: + lines = text.strip().split('\n') + result = {} + + for line in lines: + if line.startswith("FROM:"): + result['from_agent'] = line[5:].strip() + elif line.startswith("TO:"): + result['to_agent'] = line[3:].strip() + elif line.startswith("MESSAGE:"): + result['message'] = line[8:].strip() + + return result if all(k in result for k in ['from_agent', 'to_agent', 'message']) else None + except Exception as e: + logger.error(f"Error parsing agent message: {e}") + return None + + async def handle_message(self, message: Dict[str, Any]) -> Dict[str, Any]: + """Main message handling entry point - user_text = msg.content.text + Called by protocol adapters when messages arrive. + """ + content = message.get("content", {}).get("text", "") + conversation_id = message.get("conversation_id", "") or str(uuid.uuid4()) - # Check if this is an agent-to-agent message in our simple format - if user_text.startswith("FROM:") and "TO:" in user_text and "MESSAGE:" in user_text: - return self._handle_incoming_agent_message(user_text, msg, conversation_id) + # Check if this is an incoming agent-to-agent message + parsed = self.parse_incoming_agent_message(content) + if parsed: + return await self._handle_incoming_agent_message(parsed, conversation_id) - logger.info(f"๐Ÿ“จ [{self.agent_id}] Received: {user_text}") + logger.info(f"๐Ÿ“จ [{self.agent_id}] Received: {content}") - # Handle different message types try: - if user_text.startswith("@"): - # Agent-to-agent message (outgoing) - return self._handle_agent_message(user_text, msg, conversation_id) - elif user_text.startswith("/"): + # Check message type + if content.startswith("@"): + # Outgoing agent-to-agent message + return await self._handle_outgoing_agent_message(content, conversation_id) + elif content.startswith("/"): # System command - return self._handle_command(user_text, msg, conversation_id) + return await self._handle_command(content, conversation_id) else: # Regular message - use agent logic if self.telemetry: self.telemetry.log_message_received(self.agent_id, conversation_id) - response = self.agent_logic(user_text, conversation_id) - return self._create_response(msg, conversation_id, response) + response = self.agent_logic(content, conversation_id) + return self._create_response(response) except Exception as e: - return self._create_response( - msg, conversation_id, - f"Error: {str(e)}" - ) + logger.error(f"โŒ [{self.agent_id}] Error handling message: {e}") + return self._create_response(f"Error: {str(e)}") - def _handle_incoming_agent_message(self, user_text: str, msg: Message, conversation_id: str) -> Message: + async def _handle_incoming_agent_message(self, parsed: Dict[str, str], + conversation_id: str) -> Dict[str, Any]: """Handle incoming messages from other agents""" - try: - lines = user_text.strip().split('\n') - from_agent = "" - to_agent = "" - message_content = "" - - for line in lines: - if line.startswith("FROM:"): - from_agent = line[5:].strip() - elif line.startswith("TO:"): - to_agent = line[3:].strip() - elif line.startswith("MESSAGE:"): - message_content = line[8:].strip() - - logger.info(f"๐Ÿ“จ [{self.agent_id}] โ† [{from_agent}]: {message_content}") - - # Check if this is a reply (don't respond to replies to avoid infinite loops) - if message_content.startswith("Response to "): - logger.info(f"๐Ÿ”„ [{self.agent_id}] Received reply from {from_agent}, displaying to user") - # Display the reply to user but don't respond back to avoid loops - return self._create_response( - msg, conversation_id, - f"[{from_agent}] {message_content[len('Response to ' + self.agent_id + ': '):]}" - ) - - # Process the message through our agent logic - if self.telemetry: - self.telemetry.log_message_received(self.agent_id, conversation_id) - - response = self.agent_logic(message_content, conversation_id) - - # Send response back - return self._create_response( - msg, conversation_id, - f"Response to {from_agent}: {response}" - ) - - except Exception as e: - logger.error(f"โŒ [{self.agent_id}] Error processing incoming agent message: {e}") - return self._create_response( - msg, conversation_id, - f"Error processing message from agent: {str(e)}" - ) - - def _handle_agent_message(self, user_text: str, msg: Message, conversation_id: str) -> Message: + from_agent = parsed['from_agent'] + message_content = parsed['message'] + + logger.info(f"๐Ÿ“จ [{self.agent_id}] โ† [{from_agent}]: {message_content}") + + # Check if this is a reply (avoid infinite loops) + if message_content.startswith("Response to "): + logger.info(f"๐Ÿ”„ [{self.agent_id}] Received reply from {from_agent}") + return self._create_response(f"[{from_agent}] {message_content[len(f'Response to {self.agent_id}: '):]}") + + # Process through agent logic + if self.telemetry: + self.telemetry.log_message_received(self.agent_id, conversation_id) + + response = self.agent_logic(message_content, conversation_id) + return self._create_response(f"Response to {from_agent}: {response}") + + async def _handle_outgoing_agent_message(self, content: str, + conversation_id: str) -> Dict[str, Any]: """Handle messages to other agents (@agent_id message)""" - parts = user_text.split(" ", 1) + parts = content.split(" ", 1) if len(parts) <= 1: - return self._create_response( - msg, conversation_id, - "Invalid format. Use '@agent_id message'" - ) + return self._create_response("Invalid format. Use '@agent_id message'") - target_agent = parts[0][1:] # Remove @ + target_agent_id = parts[0][1:] # Remove @ message_text = parts[1] - logger.info(f"๐Ÿ”„ [{self.agent_id}] Sending to {target_agent}: {message_text}") + logger.info(f"๐Ÿ”„ [{self.agent_id}] Sending to {target_agent_id}: {message_text}") - # Look up target agent and send message - result = self._send_to_agent(target_agent, message_text, conversation_id) - return self._create_response(msg, conversation_id, result) + # Route to target agent + result = await self.route_to_agent(target_agent_id, message_text, conversation_id) + return result - def _handle_command(self, user_text: str, msg: Message, conversation_id: str) -> Message: + async def _handle_command(self, content: str, conversation_id: str) -> Dict[str, Any]: """Handle system commands""" - parts = user_text.split(" ", 1) + parts = content.split(" ", 1) command = parts[0][1:] if len(parts) > 0 else "" - args = parts[1] if len(parts) > 1 else "" if command == "help": help_text = """Available commands: @@ -147,106 +156,109 @@ def _handle_command(self, user_text: str, msg: Message, conversation_id: str) -> /ping - Test agent responsiveness /status - Show agent status @agent_id message - Send message to another agent""" - return self._create_response(msg, conversation_id, help_text) + return self._create_response(help_text) elif command == "ping": - return self._create_response(msg, conversation_id, "Pong!") + return self._create_response("Pong!") elif command == "status": - status = f"Agent: {self.agent_id}, Status: Running" - if self.registry_url: - status += f", Registry: {self.registry_url}" - return self._create_response(msg, conversation_id, status) + protocols = self.router.get_all_protocols() + status = f"Agent: {self.agent_id}, Status: Running, Protocols: {', '.join(protocols)}" + if hasattr(self.registry, 'registry_url') and self.registry.registry_url: + status += f", Registry: {self.registry.registry_url}" + return self._create_response(status) else: return self._create_response( - msg, conversation_id, f"Unknown command: {command}. Use /help for available commands" ) - def _send_to_agent(self, target_agent_id: str, message_text: str, conversation_id: str) -> str: - """Send message to another agent""" + async def route_to_agent(self, target_agent_id: str, message_text: str, + conversation_id: str) -> Dict[str, Any]: + """Route message to target agent via appropriate protocol""" try: - # Look up agent URL - agent_url = self._lookup_agent(target_agent_id) - if not agent_url: - return f"Agent {target_agent_id} not found" + # Resolve agent via NANDA Index + agent_info = await self.registry.resolve(target_agent_id) - # Ensure URL has /a2a endpoint - if not agent_url.endswith('/a2a'): - agent_url = f"{agent_url}/a2a" + if not agent_info: + logger.warning(f"๐Ÿ” Agent {target_agent_id} not found in registry") + return self._create_response(f"Agent {target_agent_id} not found") - logger.info(f"๐Ÿ“ค [{self.agent_id}] โ†’ [{target_agent_id}]: {message_text}") + # Debug: print what we got from registry + logger.info(f"๐Ÿ“‹ Agent info from registry: {agent_info}") - # Create simple message with metadata - simple_message = f"FROM: {self.agent_id}\nTO: {target_agent_id}\nMESSAGE: {message_text}" + # Select protocol + supported_protocols = agent_info.get("supported_protocols", ["a2a"]) + protocol_name = self.router.select_protocol(supported_protocols) + + # Get target URL - try multiple fields + endpoints = agent_info.get("endpoints", {}) + target_url = endpoints.get(protocol_name) - # Send message using A2A client - client = A2AClient(agent_url, timeout=30) - response = client.send_message( - Message( - role=MessageRole.USER, - content=TextContent(text=simple_message), - conversation_id=conversation_id, - metadata=Metadata(custom_fields={ - 'from_agent_id': self.agent_id, - 'to_agent_id': target_agent_id, - 'message_type': 'agent_to_agent' - }) + if not target_url: + # Try different URL fields + target_url = ( + agent_info.get("url") or + agent_info.get("agent_url") or + agent_info.get("public_url") ) - ) + + # Ensure target_url is valid + if not target_url: + logger.error(f"โŒ No URL found for {target_agent_id}. Agent info: {agent_info}") + return self._create_response(f"No endpoint found for {target_agent_id}") + + # Add /a2a suffix if needed and not already present + if protocol_name == "a2a" and not target_url.endswith('/a2a'): + target_url = f"{target_url}/a2a" + + logger.info(f"๐Ÿ“ค [{self.agent_id}] โ†’ [{target_agent_id}] via {protocol_name}: {message_text}") + logger.info(f"๐Ÿ”— Target URL: {target_url}") + + # Create message in simple format + simple_message = f"FROM: {self.agent_id}\nTO: {target_agent_id}\nMESSAGE: {message_text}" + + # Send via protocol + message = { + "content": { + "text": simple_message, + "type": "text" + }, + "conversation_id": conversation_id, + "metadata": { + "from_agent_id": self.agent_id, + "to_agent_id": target_agent_id, + "message_type": "agent_to_agent" + } + } + + response = await self.router.send(protocol_name, target_url, message) if self.telemetry: self.telemetry.log_message_sent(target_agent_id, conversation_id) - # Extract the actual response content from the target agent - logger.info(f"๐Ÿ” [{self.agent_id}] Response type: {type(response)}, has parts: {hasattr(response, 'parts') if response else 'None'}") - if response: - if hasattr(response, 'parts') and response.parts: - response_text = response.parts[0].text - logger.info(f"โœ… [{self.agent_id}] Received response from {target_agent_id}: {response_text[:100]}...") - return f"[{target_agent_id}] {response_text}" - else: - logger.info(f"โœ… [{self.agent_id}] Response has no parts, full response: {str(response)[:200]}...") - return f"[{target_agent_id}] {str(response)}" - else: - logger.info(f"โœ… [{self.agent_id}] Message delivered to {target_agent_id}, no response") - return f"Message sent to {target_agent_id}: {message_text}" + # Extract response + response_text = response.get("content", {}).get("text", str(response)) + logger.info(f"โœ… [{self.agent_id}] Response from {target_agent_id}: {response_text[:100]}...") + + return self._create_response(f"[{target_agent_id}] {response_text}") except Exception as e: - return f"โŒ Error sending to {target_agent_id}: {str(e)}" + logger.error(f"โŒ Error routing to {target_agent_id}: {e}") + import traceback + traceback.print_exc() + return self._create_response(f"โŒ Error sending to {target_agent_id}: {str(e)}") - def _lookup_agent(self, agent_id: str) -> Optional[str]: - """Look up agent URL in registry or use local discovery""" - - # Try registry lookup if available - if self.registry_url: - try: - response = requests.get(f"{self.registry_url}/lookup/{agent_id}", timeout=10) - if response.status_code == 200: - data = response.json() - agent_url = data.get("agent_url") - logger.info(f"๐ŸŒ Found {agent_id} in registry: {agent_url}") - return agent_url - except Exception as e: - logger.warning(f"๐ŸŒ Registry lookup failed: {e}") - - # Fallback to local discovery (for testing) - local_agents = { - "test_agent": "http://localhost:6000", + def _create_response(self, text: str) -> Dict[str, Any]: + """Create a standardized response dict""" + return { + "content": { + "text": f"[{self.agent_id}] {text}", + "type": "text" + } } - - if agent_id in local_agents: - logger.info(f"๐Ÿ  Found {agent_id} locally: {local_agents[agent_id]}") - return local_agents[agent_id] - - return None - def _create_response(self, original_msg: Message, conversation_id: str, text: str) -> Message: - """Create a response message""" - return Message( - role=MessageRole.AGENT, - content=TextContent(text=f"[{self.agent_id}] {text}"), - parent_message_id=original_msg.message_id, - conversation_id=conversation_id - ) \ No newline at end of file + async def run_server(self, host: str = "0.0.0.0", port: int = 8000): + """Start all protocol servers""" + logger.info(f"๐Ÿš€ Starting agent bridge for {self.agent_id} on {host}:{port}") + await self.router.start_all_servers(host, port) \ No newline at end of file diff --git a/nanda_core/core/agent_bridge_copy.py b/nanda_core/core/agent_bridge_copy.py new file mode 100644 index 0000000..fc1a668 --- /dev/null +++ b/nanda_core/core/agent_bridge_copy.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +""" +Simple Agent Bridge for A2A Communication + +Clean, simple bridge focused on agent-to-agent communication. +""" + +import os +import uuid +import logging +import requests +from typing import Callable, Optional, Dict, Any +from python_a2a import A2AServer, A2AClient, Message, TextContent, MessageRole, Metadata + +# Configure logger to capture conversation logs +logger = logging.getLogger(__name__) + + +class SimpleAgentBridge(A2AServer): + """Simple Agent Bridge for A2A communication only""" + + def __init__(self, + agent_id: str, + agent_logic: Callable[[str, str], str], + registry_url: Optional[str] = None, + telemetry = None): + super().__init__() + self.agent_id = agent_id + self.agent_logic = agent_logic + self.registry_url = registry_url + self.telemetry = telemetry + + def handle_message(self, msg: Message) -> Message: + """Handle incoming messages""" + conversation_id = msg.conversation_id or str(uuid.uuid4()) + + # Only handle text content + if not isinstance(msg.content, TextContent): + return self._create_response( + msg, conversation_id, + "Only text messages supported" + ) + + user_text = msg.content.text + + # Check if this is an agent-to-agent message in our simple format + if user_text.startswith("FROM:") and "TO:" in user_text and "MESSAGE:" in user_text: + return self._handle_incoming_agent_message(user_text, msg, conversation_id) + + logger.info(f"๐Ÿ“จ [{self.agent_id}] Received: {user_text}") + + # Handle different message types + try: + if user_text.startswith("@"): + # Agent-to-agent message (outgoing) + return self._handle_agent_message(user_text, msg, conversation_id) + elif user_text.startswith("/"): + # System command + return self._handle_command(user_text, msg, conversation_id) + else: + # Regular message - use agent logic + if self.telemetry: + self.telemetry.log_message_received(self.agent_id, conversation_id) + + response = self.agent_logic(user_text, conversation_id) + return self._create_response(msg, conversation_id, response) + + except Exception as e: + return self._create_response( + msg, conversation_id, + f"Error: {str(e)}" + ) + + def _handle_incoming_agent_message(self, user_text: str, msg: Message, conversation_id: str) -> Message: + """Handle incoming messages from other agents""" + try: + lines = user_text.strip().split('\n') + from_agent = "" + to_agent = "" + message_content = "" + + for line in lines: + if line.startswith("FROM:"): + from_agent = line[5:].strip() + elif line.startswith("TO:"): + to_agent = line[3:].strip() + elif line.startswith("MESSAGE:"): + message_content = line[8:].strip() + + logger.info(f"๐Ÿ“จ [{self.agent_id}] โ† [{from_agent}]: {message_content}") + + # Check if this is a reply (don't respond to replies to avoid infinite loops) + if message_content.startswith("Response to "): + logger.info(f"๐Ÿ”„ [{self.agent_id}] Received reply from {from_agent}, displaying to user") + # Display the reply to user but don't respond back to avoid loops + return self._create_response( + msg, conversation_id, + f"[{from_agent}] {message_content[len('Response to ' + self.agent_id + ': '):]}" + ) + + # Process the message through our agent logic + if self.telemetry: + self.telemetry.log_message_received(self.agent_id, conversation_id) + + response = self.agent_logic(message_content, conversation_id) + + # Send response back + return self._create_response( + msg, conversation_id, + f"Response to {from_agent}: {response}" + ) + + except Exception as e: + logger.error(f"โŒ [{self.agent_id}] Error processing incoming agent message: {e}") + return self._create_response( + msg, conversation_id, + f"Error processing message from agent: {str(e)}" + ) + + def _handle_agent_message(self, user_text: str, msg: Message, conversation_id: str) -> Message: + """Handle messages to other agents (@agent_id message)""" + parts = user_text.split(" ", 1) + if len(parts) <= 1: + return self._create_response( + msg, conversation_id, + "Invalid format. Use '@agent_id message'" + ) + + target_agent = parts[0][1:] # Remove @ + message_text = parts[1] + + logger.info(f"๐Ÿ”„ [{self.agent_id}] Sending to {target_agent}: {message_text}") + + # Look up target agent and send message + result = self._send_to_agent(target_agent, message_text, conversation_id) + return self._create_response(msg, conversation_id, result) + + def _handle_command(self, user_text: str, msg: Message, conversation_id: str) -> Message: + """Handle system commands""" + parts = user_text.split(" ", 1) + command = parts[0][1:] if len(parts) > 0 else "" + args = parts[1] if len(parts) > 1 else "" + + if command == "help": + help_text = """Available commands: +/help - Show this help +/ping - Test agent responsiveness +/status - Show agent status +@agent_id message - Send message to another agent""" + return self._create_response(msg, conversation_id, help_text) + + elif command == "ping": + return self._create_response(msg, conversation_id, "Pong!") + + elif command == "status": + status = f"Agent: {self.agent_id}, Status: Running" + if self.registry_url: + status += f", Registry: {self.registry_url}" + return self._create_response(msg, conversation_id, status) + + else: + return self._create_response( + msg, conversation_id, + f"Unknown command: {command}. Use /help for available commands" + ) + + def _send_to_agent(self, target_agent_id: str, message_text: str, conversation_id: str) -> str: + """Send message to another agent""" + try: + # Look up agent URL + agent_url = self._lookup_agent(target_agent_id) + if not agent_url: + return f"Agent {target_agent_id} not found" + + # Ensure URL has /a2a endpoint + if not agent_url.endswith('/a2a'): + agent_url = f"{agent_url}/a2a" + + logger.info(f"๐Ÿ“ค [{self.agent_id}] โ†’ [{target_agent_id}]: {message_text}") + + # Create simple message with metadata + simple_message = f"FROM: {self.agent_id}\nTO: {target_agent_id}\nMESSAGE: {message_text}" + + # Send message using A2A client + client = A2AClient(agent_url, timeout=30) + response = client.send_message( + Message( + role=MessageRole.USER, + content=TextContent(text=simple_message), + conversation_id=conversation_id, + metadata=Metadata(custom_fields={ + 'from_agent_id': self.agent_id, + 'to_agent_id': target_agent_id, + 'message_type': 'agent_to_agent' + }) + ) + ) + + if self.telemetry: + self.telemetry.log_message_sent(target_agent_id, conversation_id) + + # Extract the actual response content from the target agent + logger.info(f"๐Ÿ” [{self.agent_id}] Response type: {type(response)}, has parts: {hasattr(response, 'parts') if response else 'None'}") + if response: + if hasattr(response, 'parts') and response.parts: + response_text = response.parts[0].text + logger.info(f"โœ… [{self.agent_id}] Received response from {target_agent_id}: {response_text[:100]}...") + return f"[{target_agent_id}] {response_text}" + else: + logger.info(f"โœ… [{self.agent_id}] Response has no parts, full response: {str(response)[:200]}...") + return f"[{target_agent_id}] {str(response)}" + else: + logger.info(f"โœ… [{self.agent_id}] Message delivered to {target_agent_id}, no response") + return f"Message sent to {target_agent_id}: {message_text}" + + except Exception as e: + return f"โŒ Error sending to {target_agent_id}: {str(e)}" + + def _lookup_agent(self, agent_id: str) -> Optional[str]: + """Look up agent URL in registry or use local discovery""" + + # Try registry lookup if available + if self.registry_url: + try: + response = requests.get(f"{self.registry_url}/lookup/{agent_id}", timeout=10) + if response.status_code == 200: + data = response.json() + agent_url = data.get("agent_url") + logger.info(f"๐ŸŒ Found {agent_id} in registry: {agent_url}") + return agent_url + except Exception as e: + logger.warning(f"๐ŸŒ Registry lookup failed: {e}") + + # Fallback to local discovery (for testing) + local_agents = { + "test_agent": "http://localhost:6000", + } + + if agent_id in local_agents: + logger.info(f"๐Ÿ  Found {agent_id} locally: {local_agents[agent_id]}") + return local_agents[agent_id] + + return None + + def _create_response(self, original_msg: Message, conversation_id: str, text: str) -> Message: + """Create a response message""" + return Message( + role=MessageRole.AGENT, + content=TextContent(text=f"[{self.agent_id}] {text}"), + parent_message_id=original_msg.message_id, + conversation_id=conversation_id + ) \ No newline at end of file diff --git a/nanda_core/core/registry_client.py b/nanda_core/core/registry_client.py index 84f83c4..ac12c79 100644 --- a/nanda_core/core/registry_client.py +++ b/nanda_core/core/registry_client.py @@ -1,23 +1,31 @@ -#!/usr/bin/env python3 """ -Registry Client for Nanda Index Registry Integration +Registry Client for NANDA Index Registry Integration Handles agent registration, discovery, and management """ -import requests +import httpx import json import os from typing import Optional, Dict, List, Any from datetime import datetime +import logging + +logger = logging.getLogger(__name__) class RegistryClient: - """Client for interacting with the Nanda index registry""" + """Client for interacting with the NANDA Index registry""" def __init__(self, registry_url: Optional[str] = None): + """Initialize registry client + + Args: + registry_url: URL of NANDA Index (e.g., http://registry.chat39.com:6900) + """ self.registry_url = registry_url or self._get_default_registry_url() - self.session = requests.Session() - self.session.verify = False # For development with self-signed certs + # Use async httpx client instead of requests + self.client = httpx.AsyncClient(timeout=30.0, verify=False) # verify=False for dev with self-signed certs + logger.info(f"Registry client initialized with URL: {self.registry_url}") def _get_default_registry_url(self) -> str: """Get default registry URL from configuration""" @@ -29,60 +37,126 @@ def _get_default_registry_url(self) -> str: pass return "https://registry.chat39.com" - def register_agent(self, agent_id: str, agent_url: str, api_url: Optional[str] = None, agent_facts_url: Optional[str] = None) -> bool: - """Register an agent with the registry""" + async def register(self, agent_facts: Dict[str, Any]) -> bool: + """Register an agent with the registry using AgentFacts + + Args: + agent_facts: Agent metadata dict with fields: + - agent_id: str + - name: str + - domain: str (optional) + - specialization: str (optional) + - description: str (optional) + - capabilities: list + - url: str + - agent_url: str (backward compatibility) + - supported_protocols: list + - endpoints: dict + + Returns: + True if registration successful, False otherwise + """ + if not self.registry_url: + logger.warning("No registry URL configured, skipping registration") + return False + try: - data = { - "agent_id": agent_id, - "agent_url": agent_url - } - if api_url: - data["api_url"] = api_url - if agent_facts_url: - data["agent_facts_url"] = agent_facts_url + # Support both new (register) and old (register_agent) endpoints + response = await self.client.post( + f"{self.registry_url}/register", + json=agent_facts + ) + + if response.status_code == 200: + logger.info(f"โœ… Agent {agent_facts.get('agent_id')} registered successfully") + return True + else: + logger.error(f"โŒ Registration failed: HTTP {response.status_code} - {response.text}") + return False + + except Exception as e: + logger.error(f"โŒ Error registering agent: {e}") + return False - response = self.session.post(f"{self.registry_url}/register", json=data) + async def register_agent(self, agent_id: str, agent_url: str, + api_url: Optional[str] = None, + agent_facts_url: Optional[str] = None) -> bool: + """Legacy registration method for backward compatibility""" + data = { + "agent_id": agent_id, + "agent_url": agent_url + } + if api_url: + data["api_url"] = api_url + if agent_facts_url: + data["agent_facts_url"] = agent_facts_url + + try: + response = await self.client.post(f"{self.registry_url}/register", json=data) return response.status_code == 200 except Exception as e: - print(f"Error registering agent: {e}") + logger.error(f"Error registering agent: {e}") return False - def lookup_agent(self, agent_id: str) -> Optional[Dict[str, Any]]: + async def resolve(self, agent_id: str) -> Optional[Dict[str, Any]]: + """Resolve/lookup an agent in the registry + + Args: + agent_id: Agent identifier to look up + + Returns: + Agent info dict or None if not found + """ + return await self.lookup_agent(agent_id) + + async def lookup_agent(self, agent_id: str) -> Optional[Dict[str, Any]]: """Look up an agent in the registry""" + if not self.registry_url: + logger.warning("No registry URL configured") + return None + try: - response = self.session.get(f"{self.registry_url}/lookup/{agent_id}") + response = await self.client.get(f"{self.registry_url}/lookup/{agent_id}") if response.status_code == 200: return response.json() + else: + logger.warning(f"Agent {agent_id} not found: HTTP {response.status_code}") return None except Exception as e: - print(f"Error looking up agent {agent_id}: {e}") + logger.error(f"Error looking up agent {agent_id}: {e}") return None - def list_agents(self) -> List[Dict[str, Any]]: + async def list_agents(self) -> List[Dict[str, Any]]: """List all registered agents""" + if not self.registry_url: + return [] + try: - response = self.session.get(f"{self.registry_url}/list") + response = await self.client.get(f"{self.registry_url}/list") if response.status_code == 200: return response.json() return [] except Exception as e: - print(f"Error listing agents: {e}") + logger.error(f"Error listing agents: {e}") return [] - def list_clients(self) -> List[Dict[str, Any]]: + async def list_clients(self) -> List[Dict[str, Any]]: """List all registered clients""" + if not self.registry_url: + return [] + try: - response = self.session.get(f"{self.registry_url}/clients") + response = await self.client.get(f"{self.registry_url}/clients") if response.status_code == 200: return response.json() - return self.list_agents() # Fallback to list endpoint + return await self.list_agents() # Fallback to list endpoint except Exception as e: - print(f"Error listing clients: {e}") + logger.error(f"Error listing clients: {e}") return [] - def get_agent_metadata(self, agent_id: str) -> Optional[Dict[str, Any]]: + async def get_agent_metadata(self, agent_id: str) -> Optional[Dict[str, Any]]: """Get detailed metadata for an agent""" - agent_info = self.lookup_agent(agent_id) + agent_info = await self.lookup_agent(agent_id) if not agent_info: return None @@ -90,16 +164,25 @@ def get_agent_metadata(self, agent_id: str) -> Optional[Dict[str, Any]]: metadata = { "agent_id": agent_id, "agent_url": agent_info.get("agent_url"), + "url": agent_info.get("url"), # New field "api_url": agent_info.get("api_url"), + "endpoints": agent_info.get("endpoints", {}), # New field + "supported_protocols": agent_info.get("supported_protocols", ["a2a"]), # New field "last_seen": agent_info.get("last_seen"), "capabilities": agent_info.get("capabilities", []), "description": agent_info.get("description", ""), - "tags": agent_info.get("tags", []) + "tags": agent_info.get("tags", []), + "domain": agent_info.get("domain"), # New field + "specialization": agent_info.get("specialization") # New field } return metadata - def search_agents(self, query: str = "", capabilities: List[str] = None, tags: List[str] = None) -> List[Dict[str, Any]]: + async def search_agents(self, query: str = "", capabilities: List[str] = None, + tags: List[str] = None) -> List[Dict[str, Any]]: """Search for agents based on criteria""" + if not self.registry_url: + return [] + try: params = {} if query: @@ -109,19 +192,20 @@ def search_agents(self, query: str = "", capabilities: List[str] = None, tags: L if tags: params["tags"] = ",".join(tags) - response = self.session.get(f"{self.registry_url}/search", params=params) + response = await self.client.get(f"{self.registry_url}/search", params=params) if response.status_code == 200: return response.json() # Fallback to client-side filtering - return self._filter_agents_locally(query, capabilities, tags) + return await self._filter_agents_locally(query, capabilities, tags) except Exception as e: - print(f"Error searching agents: {e}") - return self._filter_agents_locally(query, capabilities, tags) + logger.error(f"Error searching agents: {e}") + return await self._filter_agents_locally(query, capabilities, tags) - def _filter_agents_locally(self, query: str = "", capabilities: List[str] = None, tags: List[str] = None) -> List[Dict[str, Any]]: + async def _filter_agents_locally(self, query: str = "", capabilities: List[str] = None, + tags: List[str] = None) -> List[Dict[str, Any]]: """Fallback local filtering when server search is not available""" - all_agents = self.list_agents() + all_agents = await self.list_agents() filtered = [] for agent in all_agents: @@ -147,25 +231,32 @@ def _filter_agents_locally(self, query: str = "", capabilities: List[str] = None return filtered - def get_mcp_servers(self, registry_provider: Optional[str] = None) -> List[Dict[str, Any]]: + async def get_mcp_servers(self, registry_provider: Optional[str] = None) -> List[Dict[str, Any]]: """Get list of available MCP servers""" + if not self.registry_url: + return [] + try: params = {} if registry_provider: params["registry_provider"] = registry_provider - response = self.session.get(f"{self.registry_url}/mcp_servers", params=params) + response = await self.client.get(f"{self.registry_url}/mcp_servers", params=params) if response.status_code == 200: return response.json() return [] except Exception as e: - print(f"Error getting MCP servers: {e}") + logger.error(f"Error getting MCP servers: {e}") return [] - def get_mcp_server_config(self, registry_provider: str, qualified_name: str) -> Optional[Dict[str, Any]]: + async def get_mcp_server_config(self, registry_provider: str, + qualified_name: str) -> Optional[Dict[str, Any]]: """Get configuration for a specific MCP server""" + if not self.registry_url: + return None + try: - response = self.session.get(f"{self.registry_url}/get_mcp_registry", params={ + response = await self.client.get(f"{self.registry_url}/get_mcp_registry", params={ 'registry_provider': registry_provider, 'qualified_name': qualified_name }) @@ -182,11 +273,15 @@ def get_mcp_server_config(self, registry_provider: str, qualified_name: str) -> } return None except Exception as e: - print(f"Error getting MCP server config: {e}") + logger.error(f"Error getting MCP server config: {e}") return None - def update_agent_status(self, agent_id: str, status: str, metadata: Optional[Dict[str, Any]] = None) -> bool: + async def update_agent_status(self, agent_id: str, status: str, + metadata: Optional[Dict[str, Any]] = None) -> bool: """Update agent status and metadata""" + if not self.registry_url: + return False + try: data = { "agent_id": agent_id, @@ -196,36 +291,52 @@ def update_agent_status(self, agent_id: str, status: str, metadata: Optional[Dic if metadata: data.update(metadata) - response = self.session.put(f"{self.registry_url}/agents/{agent_id}/status", json=data) + response = await self.client.put( + f"{self.registry_url}/agents/{agent_id}/status", + json=data + ) return response.status_code == 200 except Exception as e: - print(f"Error updating agent status: {e}") + logger.error(f"Error updating agent status: {e}") return False - def unregister_agent(self, agent_id: str) -> bool: + async def unregister_agent(self, agent_id: str) -> bool: """Unregister an agent from the registry""" + if not self.registry_url: + return False + try: - response = self.session.delete(f"{self.registry_url}/agents/{agent_id}") + response = await self.client.delete(f"{self.registry_url}/agents/{agent_id}") return response.status_code == 200 except Exception as e: - print(f"Error unregistering agent: {e}") + logger.error(f"Error unregistering agent: {e}") return False - def health_check(self) -> bool: + async def health_check(self) -> bool: """Check if the registry is healthy""" + if not self.registry_url: + return False + try: - response = self.session.get(f"{self.registry_url}/health", timeout=5) + response = await self.client.get(f"{self.registry_url}/health", timeout=5) return response.status_code == 200 except Exception: return False - def get_registry_stats(self) -> Optional[Dict[str, Any]]: + async def get_registry_stats(self) -> Optional[Dict[str, Any]]: """Get registry statistics""" + if not self.registry_url: + return None + try: - response = self.session.get(f"{self.registry_url}/stats") + response = await self.client.get(f"{self.registry_url}/stats") if response.status_code == 200: return response.json() return None except Exception as e: - print(f"Error getting registry stats: {e}") - return None \ No newline at end of file + logger.error(f"Error getting registry stats: {e}") + return None + + async def close(self): + """Close the HTTP client connection""" + await self.client.aclose() \ No newline at end of file diff --git a/nanda_core/protocols/AgentExecutor.py b/nanda_core/protocols/AgentExecutor.py new file mode 100644 index 0000000..b5d5a53 --- /dev/null +++ b/nanda_core/protocols/AgentExecutor.py @@ -0,0 +1,73 @@ +from typing import Callable +from a2a.types import TextPart +from a2a.utils import new_agent_text_message +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue + +class NANDAAgentExecutor(AgentExecutor): + """Agent executor that delegates to NANDA bridge handler""" + + def __init__(self, message_handler: Callable): + self.message_handler = message_handler + + async def execute(self, context: RequestContext, event_queue: EventQueue): + """Execute agent logic via bridge handler""" + try: + # Extract message text from context + message_text = "" + if context.message and context.message.parts: + for part in context.message.parts: + # Handle different part types + if hasattr(part, 'root'): + part_obj = part.root + else: + part_obj = part + + # Extract text based on part type + if hasattr(part_obj, 'text'): + message_text = part_obj.text + elif hasattr(part_obj, 'kind') and part_obj.kind == 'text': + message_text = part_obj.text if hasattr(part_obj, 'text') else str(part_obj) + else: + message_text = str(part_obj) + + if message_text: + break + + if not message_text: + message_text = "" + + print(f"๐Ÿ“จ Received message: {message_text[:100]}") + + # Convert to bridge format + bridge_message = { + "content": { + "text": message_text, + "type": "text" + }, + "conversation_id": context.task_id or context.context_id or "" + } + + # Call bridge handler + response = await self.message_handler(bridge_message) + + # Extract response text + response_text = response.get("content", {}).get("text", "") + + print(f"โœ… Sending response: {response_text}") + + # Send response via event queue + response_message = new_agent_text_message(response_text) + await event_queue.enqueue_event(response_message) + + except Exception as e: + print(f"โŒ Error in execute: {e}") + import traceback + traceback.print_exc() + # Send error message + error_message = new_agent_text_message(f"Error: {str(e)}") + event_queue.enqueue_event(error_message) + + async def cancel(self, context: RequestContext, event_queue: EventQueue): + """Handle task cancellation""" + event_queue.enqueue_event(new_agent_text_message("Task cancelled")) diff --git a/nanda_core/protocols/a2a/__init__.py b/nanda_core/protocols/a2a/__init__.py new file mode 100644 index 0000000..3f289c1 --- /dev/null +++ b/nanda_core/protocols/a2a/__init__.py @@ -0,0 +1,3 @@ +from .protocol import A2AProtocol + +__all__ = ['A2AProtocol'] \ No newline at end of file diff --git a/nanda_core/protocols/a2a/protocol.py b/nanda_core/protocols/a2a/protocol.py new file mode 100644 index 0000000..ec7aea9 --- /dev/null +++ b/nanda_core/protocols/a2a/protocol.py @@ -0,0 +1,234 @@ +""" +Official A2A SDK Protocol Adapter +""" + +from typing import Callable, Dict, Any +from a2a.client import A2AClient, ClientConfig, ClientFactory +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore +from a2a.server.apps import A2AStarletteApplication +from a2a.types import ( + AgentCard, AgentSkill, AgentCapabilities, AgentInterface, + Message, TextPart, Part, Role, + SendMessageRequest, MessageSendParams, + TransportProtocol +) +import uuid +import httpx +import uvicorn +from ..base import AgentProtocol +from ..AgentExecutor import NANDAAgentExecutor + +class A2AProtocol(AgentProtocol): + """A2A SDK protocol adapter""" + + def __init__(self, agent_id: str, agent_name: str, public_url: str, + domain: str = None, specialization: str = None, + description: str = "", capabilities: list = None): + """Initialize A2A protocol adapter + + Args: + agent_id: Unique agent identifier + agent_name: Display name + public_url: Public URL where agent is accessible + domain: Agent's domain of expertise + specialization: Agent's specialization + description: Agent description + capabilities: List of capabilities (default: ["text"]) + """ + self.agent_id = agent_id + self.agent_name = agent_name + self.public_url = public_url + self.description = description + self.capabilities_list = capabilities or ["text"] + + # Create AgentCard + self.agent_card = AgentCard( + name=agent_name, + description=description, + version="1.0.0", + url=f"{public_url}/", + protocol_version="0.2.5", + skills=[ + AgentSkill( + id=agent_id, + name=agent_name, + description=description, + tags=[domain] if domain else [], + examples=[] + ) + ], + capabilities=AgentCapabilities( + streaming=True, + push_notifications=False + ), + defaultInputModes=["text"], + defaultOutputModes=["text"] + ) + + # Incoming message handler (set by bridge) + self.incoming_handler: Callable = None + + # Server components (initialized when handler is set) + self.agent_executor = None + self.request_handler = None + self.server_app = None + + # HTTP client for A2A client + self.httpx_client = httpx.AsyncClient() + + def set_incoming_handler(self, handler: Callable): + """Set callback for incoming messages + + Args: + handler: Async function(message: dict) -> dict + """ + self.incoming_handler = handler + + # Initialize server components + self.agent_executor = NANDAAgentExecutor(handler) + self.request_handler = DefaultRequestHandler( + agent_executor=self.agent_executor, + task_store=InMemoryTaskStore() + ) + self.server_app = A2AStarletteApplication( + agent_card=self.agent_card, + http_handler=self.request_handler + ) + + async def send_message(self, target_url: str, message: Dict[str, Any]) -> Dict[str, Any]: + """Send message using A2A client""" + try: + # Validate target_url + if not target_url: + raise ValueError("target_url is None or empty") + + # Extract message content + content_text = message.get("content", {}).get("text", "") + conversation_id = message.get("conversation_id", "") + metadata = message.get("metadata", {}) + + print(f"๐Ÿ”„ Sending to {target_url}: {content_text[:50]}...") + + # Create A2A message with proper structure + from a2a.types import TextPart, Part, Message, Role + + text_part = TextPart( + kind='text', + text=content_text + ) + part = Part(root=text_part) + + a2a_message = Message( + message_id=f"msg-{conversation_id}-{uuid.uuid4().hex[:8]}", + role=Role.user, + parts=[part], + metadata=metadata if metadata else None + ) + + # Get client - strip /a2a suffix if present + base_url = target_url.rstrip('/a2a') if target_url else target_url + + client = A2AClient( + httpx_client=self.httpx_client, + url=base_url # Just pass the base URL + ) + + # Create request + from a2a.types import SendMessageRequest, MessageSendParams + request = SendMessageRequest( + id=f"req-{uuid.uuid4().hex[:8]}", + params=MessageSendParams(message=a2a_message) + ) + + # Send message + response = await client.send_message(request) + print(f"๐Ÿ” Response attributes: {dir(response)}") + print(f"๐Ÿ” Response dict: {response.model_dump() if hasattr(response, 'model_dump') else response}") + + if hasattr(response, 'root'): + result = response.root + + # Check if it's an error response + if hasattr(result, 'error') and result.error: + error_msg = result.error.message if hasattr(result.error, 'message') else str(result.error) + return { + "content": { + "text": f"Error: {error_msg}", + "type": "text" + } + } + + # Check if it's a success response with result + if hasattr(result, 'result'): + msg = result.result + if hasattr(msg, 'parts') and msg.parts: + response_text = "" + for part in msg.parts: + part_obj = part.root if hasattr(part, 'root') else part + if hasattr(part_obj, 'text'): + response_text = part_obj.text + break + + return { + "content": { + "text": response_text, + "type": "text" + } + } + + return { + "content": { + "text": "No response received", + "type": "text" + } + } + + except Exception as e: + print(f"โŒ Error sending message: {e}") + import traceback + traceback.print_exc() + return { + "content": { + "text": f"Error: {str(e)}", + "type": "text" + } + } + + def get_metadata(self) -> Dict[str, Any]: + """Get AgentCard metadata + + Returns: + AgentCard as dict + """ + return { + "agent_id": self.agent_id, + "name": self.agent_name, + "url": self.agent_card.url, + "description": self.description, + "capabilities": self.capabilities_list + } + + async def start_server(self, host: str, port: int): + """Start A2A server + + Args: + host: Host to bind to + port: Port to listen on + """ + if not self.server_app: + raise RuntimeError("Server not initialized. Call set_incoming_handler first.") + + print(f"Starting A2A server on {host}:{port}") + + # Build Starlette app + app = self.server_app.build() + + # Run with uvicorn + config = uvicorn.Config(app, host=host, port=port, log_level="info") + server = uvicorn.Server(config) + await server.serve() + + def get_protocol_name(self) -> str: + """Return protocol identifier""" + return "a2a" \ No newline at end of file diff --git a/nanda_core/protocols/base.py b/nanda_core/protocols/base.py new file mode 100644 index 0000000..07ae232 --- /dev/null +++ b/nanda_core/protocols/base.py @@ -0,0 +1,52 @@ +from abc import ABC, abstractmethod +from typing import Callable, Dict, Any + +class AgentProtocol(ABC): + """Base protocol interface for agent communication""" + + @abstractmethod + async def send_message(self, target_url: str, message: Dict[str, Any]) -> Dict[str, Any]: + """Send message to target agent + + Args: + target_url: Target agent's endpoint URL + message: Message dict with 'content', 'conversation_id', etc. + + Returns: + Response dict from target agent + """ + pass + + @abstractmethod + def set_incoming_handler(self, handler: Callable): + """Set callback for incoming messages + + Args: + handler: Async function that processes incoming messages + Should accept message dict and return response dict + """ + pass + + @abstractmethod + def get_metadata(self) -> Dict[str, Any]: + """Get protocol-specific agent metadata (AgentCard, etc.) + + Returns: + Metadata dict for agent discovery + """ + pass + + @abstractmethod + async def start_server(self, host: str, port: int): + """Start protocol server + + Args: + host: Host to bind to + port: Port to listen on + """ + pass + + @abstractmethod + def get_protocol_name(self) -> str: + """Return protocol identifier (a2a, slim, etc.)""" + pass \ No newline at end of file diff --git a/nanda_core/protocols/router.py b/nanda_core/protocols/router.py new file mode 100644 index 0000000..24e16a7 --- /dev/null +++ b/nanda_core/protocols/router.py @@ -0,0 +1,99 @@ +from typing import Dict, List, Optional +from .base import AgentProtocol + +class ProtocolRouter: + """Manages multiple protocol adapters and routes messages""" + + def __init__(self): + self.protocols: Dict[str, AgentProtocol] = {} + self.default_protocol: Optional[str] = None + + def register(self, protocol: AgentProtocol): + """Register a protocol adapter + + Args: + protocol: Protocol adapter instance + """ + name = protocol.get_protocol_name() + self.protocols[name] = protocol + + # First registered protocol becomes default + if self.default_protocol is None: + self.default_protocol = name + + print(f"Registered protocol: {name}") + + def get_protocol(self, name: str) -> Optional[AgentProtocol]: + """Get protocol by name + + Args: + name: Protocol name (a2a, slim, etc.) + + Returns: + Protocol adapter or None + """ + return self.protocols.get(name) + + def get_all_protocols(self) -> List[str]: + """Get list of all registered protocol names""" + return list(self.protocols.keys()) + + async def send(self, protocol_name: str, target_url: str, message: dict) -> dict: + """Send message using specified protocol + + Args: + protocol_name: Protocol to use (a2a, slim) + target_url: Target agent endpoint + message: Message to send + + Returns: + Response from target agent + + Raises: + ValueError: If protocol not registered + """ + protocol = self.protocols.get(protocol_name) + if not protocol: + raise ValueError(f"Protocol '{protocol_name}' not registered. " + f"Available: {list(self.protocols.keys())}") + + return await protocol.send_message(target_url, message) + + def select_protocol(self, supported_protocols: List[str]) -> str: + """Select best available protocol from supported list + + Args: + supported_protocols: List of protocols target agent supports + + Returns: + Selected protocol name + """ + # Try to find first match from supported list + for proto in supported_protocols: + if proto in self.protocols: + return proto + + # Fallback to default if no match + if self.default_protocol: + return self.default_protocol + + # Last resort: use first registered protocol + if self.protocols: + return list(self.protocols.keys())[0] + + raise ValueError("No protocols registered") + + async def start_all_servers(self, host: str, port: int): + """Start all registered protocol servers + + Note: For protocols that need different ports, override in protocol adapter + + Args: + host: Host to bind to + port: Base port (protocols may use port+offset) + """ + for name, protocol in self.protocols.items(): + print(f"Starting {name} protocol server...") + # Each protocol handles its own server startup + # They can use different ports internally + await protocol.start_server(host, port) \ No newline at end of file diff --git a/nanda_core/telemetry/health_monitor.py b/nanda_core/telemetry/health_monitor.py index 47d561c..eac8630 100644 --- a/nanda_core/telemetry/health_monitor.py +++ b/nanda_core/telemetry/health_monitor.py @@ -345,7 +345,7 @@ def _get_registry_url(self) -> str: return f.read().strip() except: pass - return "https://chat.nanda-registry.com:6900" + return "https://registry.chat39.com:6900" def get_health_history(self, check_name: str, hours: int = 24) -> List[HealthCheck]: """Get health check history""" diff --git a/setup.py b/setup.py index c2afdd5..5f07af9 100644 --- a/setup.py +++ b/setup.py @@ -10,14 +10,13 @@ def read_requirements(): """Read requirements from file""" requirements = [ - "flask", - "anthropic", - "requests", - "python-a2a==0.5.6", - "mcp", - "python-dotenv", - "flask-cors", - "psutil" # For system monitoring + "anthropic>=0.18.0", + "a2a-sdk>=0.2.0", # Official A2A SDK (replaced python-a2a) + "httpx>=0.27.0", # For async HTTP (replaces requests) + "uvicorn>=0.30.0", # ASGI server for A2A + "starlette>=0.37.0", # For A2A server (included with a2a-sdk) + "python-dotenv>=1.0.0", + "psutil>=5.9.0", # For system monitoring ] return requirements From 02eac88da0340350e5f267335cf2cf783a9af4c8 Mon Sep 17 00:00:00 2001 From: Jin Gao Date: Thu, 4 Dec 2025 11:39:19 -0500 Subject: [PATCH 3/4] Fix resource cleanup: Close HTTP clients and protocol resources Addresses review feedback from @ryanRfox about missing resource cleanup. Changes: - Added abstract cleanup() method to AgentProtocol base class - Implemented cleanup() in A2AProtocol to properly close httpx_client - Added cleanup_all() method to ProtocolRouter to cleanup all protocols - Updated NANDA.stop() to call protocol cleanup before stopping This ensures all HTTP clients and protocol resources are properly released when agents are stopped, preventing resource leaks. Fixes: PR #12 review feedback Reviewed-by: Jin Gao --- nanda_core/core/adapter.py | 16 +++++++++++++++- nanda_core/protocols/a2a/protocol.py | 8 +++++++- nanda_core/protocols/base.py | 8 ++++++++ nanda_core/protocols/router.py | 18 +++++++++++++++--- 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/nanda_core/core/adapter.py b/nanda_core/core/adapter.py index 5b7089c..6aadcc1 100644 --- a/nanda_core/core/adapter.py +++ b/nanda_core/core/adapter.py @@ -168,7 +168,21 @@ def _get_endpoints(self) -> dict: return endpoints def stop(self): - """Stop the agent and cleanup telemetry""" + """Stop the agent and cleanup resources""" + # Cleanup protocol resources + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + # If we're already in an event loop, schedule the cleanup + asyncio.create_task(self.router.cleanup_all()) + else: + # Otherwise, run it synchronously + asyncio.run(self.router.cleanup_all()) + except Exception as e: + print(f"โš ๏ธ Error during protocol cleanup: {e}") + + # Cleanup telemetry if self.telemetry: self.telemetry.stop() + print(f"๐Ÿ›‘ Stopping agent '{self.agent_id}'") \ No newline at end of file diff --git a/nanda_core/protocols/a2a/protocol.py b/nanda_core/protocols/a2a/protocol.py index ec7aea9..10374c8 100644 --- a/nanda_core/protocols/a2a/protocol.py +++ b/nanda_core/protocols/a2a/protocol.py @@ -231,4 +231,10 @@ async def start_server(self, host: str, port: int): def get_protocol_name(self) -> str: """Return protocol identifier""" - return "a2a" \ No newline at end of file + return "a2a" + + async def cleanup(self): + """Clean up A2A protocol resources""" + if self.httpx_client: + await self.httpx_client.aclose() + print(f"๐Ÿงน Closed HTTP client for A2A protocol") \ No newline at end of file diff --git a/nanda_core/protocols/base.py b/nanda_core/protocols/base.py index 07ae232..f4bc133 100644 --- a/nanda_core/protocols/base.py +++ b/nanda_core/protocols/base.py @@ -49,4 +49,12 @@ async def start_server(self, host: str, port: int): @abstractmethod def get_protocol_name(self) -> str: """Return protocol identifier (a2a, slim, etc.)""" + pass + + @abstractmethod + async def cleanup(self): + """Clean up protocol resources (HTTP clients, connections, etc.) + + Called when agent is stopping to ensure proper resource cleanup. + """ pass \ No newline at end of file diff --git a/nanda_core/protocols/router.py b/nanda_core/protocols/router.py index 24e16a7..c31fba9 100644 --- a/nanda_core/protocols/router.py +++ b/nanda_core/protocols/router.py @@ -85,9 +85,9 @@ def select_protocol(self, supported_protocols: List[str]) -> str: async def start_all_servers(self, host: str, port: int): """Start all registered protocol servers - + Note: For protocols that need different ports, override in protocol adapter - + Args: host: Host to bind to port: Base port (protocols may use port+offset) @@ -96,4 +96,16 @@ async def start_all_servers(self, host: str, port: int): print(f"Starting {name} protocol server...") # Each protocol handles its own server startup # They can use different ports internally - await protocol.start_server(host, port) \ No newline at end of file + await protocol.start_server(host, port) + + async def cleanup_all(self): + """Clean up all registered protocol resources + + Called when agent is stopping to ensure all protocols + properly release their resources (HTTP clients, connections, etc.) + """ + for name, protocol in self.protocols.items(): + try: + await protocol.cleanup() + except Exception as e: + print(f"โš ๏ธ Error cleaning up {name} protocol: {e}") \ No newline at end of file From b2655f568e70d00824985b2a23285344fd75fffd Mon Sep 17 00:00:00 2001 From: Jin Gao Date: Thu, 4 Dec 2025 16:22:11 -0500 Subject: [PATCH 4/4] Fix event loop handling and add comprehensive test suite Fix: - Replace deprecated asyncio.get_event_loop() with get_running_loop() - Properly handle both async and sync contexts in NANDA.stop() - Prevents "no current event loop" errors in Python 3.10+ Test: - Add 15 comprehensive tests covering backward compatibility, A2A protocol, resource cleanup, and integration scenarios - All tests passing (15/15) --- nanda_core/core/adapter.py | 9 +- tests/README.md | 98 ++++ tests/__init__.py | 0 tests/test_protocols/__init__.py | 0 tests/test_protocols/test_a2a_protocol.py | 565 ++++++++++++++++++++++ 5 files changed, 668 insertions(+), 4 deletions(-) create mode 100644 tests/README.md create mode 100644 tests/__init__.py create mode 100644 tests/test_protocols/__init__.py create mode 100644 tests/test_protocols/test_a2a_protocol.py diff --git a/nanda_core/core/adapter.py b/nanda_core/core/adapter.py index 6aadcc1..77a453a 100644 --- a/nanda_core/core/adapter.py +++ b/nanda_core/core/adapter.py @@ -171,12 +171,13 @@ def stop(self): """Stop the agent and cleanup resources""" # Cleanup protocol resources try: - loop = asyncio.get_event_loop() - if loop.is_running(): + try: + # Try to get the currently running event loop + loop = asyncio.get_running_loop() # If we're already in an event loop, schedule the cleanup asyncio.create_task(self.router.cleanup_all()) - else: - # Otherwise, run it synchronously + except RuntimeError: + # No event loop running, create one and run cleanup asyncio.run(self.router.cleanup_all()) except Exception as e: print(f"โš ๏ธ Error during protocol cleanup: {e}") diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..b6d518d --- /dev/null +++ b/tests/README.md @@ -0,0 +1,98 @@ +# NEST Tests + +Comprehensive test suite for NEST framework. + +## Structure + +``` +tests/ +โ”œโ”€โ”€ README.md # This file +โ”œโ”€โ”€ __init__.py # Package marker +โ””โ”€โ”€ test_protocols/ # Protocol tests + โ”œโ”€โ”€ __init__.py + โ””โ”€โ”€ test_a2a_protocol.py # A2A protocol tests (PR #12) +``` + +## Running Tests + +### Install pytest + +```bash +pip install pytest pytest-asyncio +``` + +### Run all tests + +```bash +pytest tests/ -v +``` + +### Run specific test file + +```bash +pytest tests/test_protocols/test_a2a_protocol.py -v +``` + +### Run specific test class + +```bash +pytest tests/test_protocols/test_a2a_protocol.py::TestResourceCleanup -v +``` + +### Run with coverage + +```bash +pip install pytest-cov +pytest tests/ --cov=nanda_core --cov-report=html +``` + +## Test Categories + +### 1. Backward Compatibility Tests (`TestA2AProtocolBackwardCompatibility`) + +Tests that ensure the new implementation doesn't break existing functionality: +- NANDA initialization with default A2A protocol +- Protocol router integration +- Agent bridge integration + +### 2. Official A2A Protocol Tests (`TestOfficialA2AProtocol`) + +Tests for the new official A2A protocol implementation: +- Protocol initialization +- HTTP client creation +- AgentCard generation +- Metadata formatting +- Message handler setup + +### 3. Resource Cleanup Tests (`TestResourceCleanup`) + +Tests for PR #12 resource cleanup fixes: +- A2A protocol cleanup closes HTTP client +- ProtocolRouter cleanup_all() works correctly +- NANDA.stop() triggers cleanup properly +- Error handling during cleanup + +### 4. Integration Tests (`TestIntegration`) + +End-to-end tests for complete workflows: +- Full agent lifecycle (create -> use -> stop) + +## CI/CD Integration + +These tests are designed to run in CI/CD pipelines: + +```yaml +# Example GitHub Actions +- name: Run tests + run: | + pip install pytest pytest-asyncio + pytest tests/ -v +``` + +## Adding New Tests + +1. Create test file in appropriate subdirectory +2. Follow naming convention: `test_*.py` +3. Use descriptive test names: `test_feature_does_something` +4. Add docstrings explaining what's being tested +5. Update this README if adding new test categories diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_protocols/__init__.py b/tests/test_protocols/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_protocols/test_a2a_protocol.py b/tests/test_protocols/test_a2a_protocol.py new file mode 100644 index 0000000..30b611d --- /dev/null +++ b/tests/test_protocols/test_a2a_protocol.py @@ -0,0 +1,565 @@ +#!/usr/bin/env python3 +""" +Comprehensive tests for A2A Protocol Implementation (PR #12) + +Tests three critical areas: +1. Backward compatibility with previous A2A implementation +2. Official A2A protocol support (new implementation) +3. Resource cleanup functionality (PR #12 fix) + +Run with: pytest tests/test_protocols/test_a2a_protocol.py -v + +Error Scenarios Tested: +- Event loop handling in sync/async contexts +- HTTP client lifecycle management +- Protocol router integration +- Resource cleanup on errors +""" + +import pytest +import asyncio +import sys +from nanda_core.core.adapter import NANDA +from nanda_core.protocols.a2a.protocol import A2AProtocol +from nanda_core.protocols.router import ProtocolRouter + + +# Test agent logic +def simple_agent_logic(message: str, conversation_id: str) -> str: + """Simple echo agent for testing""" + return f"Echo: {message}" + + +class TestA2AProtocolBackwardCompatibility: + """ + Test backward compatibility with previous A2A implementation + + Purpose: Ensure existing code doesn't break with the new protocol architecture + + Potential Issues: + - Missing protocol registration + - Router not initialized + - Bridge not connected to router + """ + + def test_nanda_initialization_default(self): + """ + Test that NANDA initializes with A2A protocol by default + + Expected: A2A protocol should be auto-registered + + Failure Reasons: + - Protocol not registered โ†’ Check ProtocolRouter.register() + - Wrong protocol name โ†’ Check A2AProtocol.get_protocol_name() + """ + try: + agent = NANDA( + agent_id="test-backward-compat", + agent_logic=simple_agent_logic, + port=6000, + host="127.0.0.1", + public_url="http://127.0.0.1:6000", + enable_telemetry=False + ) + + # Should have A2A protocol registered + assert "a2a" in agent.router.get_all_protocols(), \ + "A2A protocol not registered. Check NANDA.__init__() protocol setup" + + protocol = agent.router.get_protocol("a2a") + assert protocol is not None, \ + "A2A protocol registered but not retrievable. Check ProtocolRouter.get_protocol()" + + except Exception as e: + pytest.fail(f"NANDA initialization failed: {e}\n" + f"Possible cause: Missing dependencies or protocol registration error") + + def test_agent_has_router(self): + """ + Test that agent has protocol router + + Expected: Agent should have router attribute of type ProtocolRouter + + Failure Reasons: + - Router not initialized โ†’ Check NANDA.__init__() + - Wrong router type โ†’ Check import statements + """ + agent = NANDA( + agent_id="test-router", + agent_logic=simple_agent_logic, + port=6000, + host="127.0.0.1", + public_url="http://127.0.0.1:6000", + enable_telemetry=False + ) + + assert hasattr(agent, 'router'), \ + "Agent missing 'router' attribute. Check NANDA.__init__()" + + assert isinstance(agent.router, ProtocolRouter), \ + f"Router is {type(agent.router)}, expected ProtocolRouter" + + def test_agent_bridge_integration(self): + """ + Test that agent bridge integrates with protocol router + + Expected: Bridge should have router reference + + Failure Reasons: + - Bridge not initialized โ†’ Check NANDA.__init__() + - Router not passed to bridge โ†’ Check AgentBridge.__init__() + """ + agent = NANDA( + agent_id="test-bridge", + agent_logic=simple_agent_logic, + port=6000, + host="127.0.0.1", + public_url="http://127.0.0.1:6000", + enable_telemetry=False + ) + + assert hasattr(agent, 'bridge'), \ + "Agent missing 'bridge' attribute" + + assert hasattr(agent.bridge, 'router'), \ + "Bridge missing 'router' attribute. Check AgentBridge integration" + + +class TestOfficialA2AProtocol: + """ + Test official A2A protocol implementation + + Purpose: Verify the new A2A SDK integration works correctly + + Dependencies: + - a2a-sdk >= 0.2.0 + - httpx >= 0.27.0 + """ + + def test_a2a_protocol_initialization(self): + """ + Test A2A protocol initializes correctly + + Expected: Protocol should store agent metadata + + Failure Reasons: + - Missing required parameters โ†’ Check A2AProtocol.__init__() signature + - AgentCard creation fails โ†’ Check a2a-sdk installation + """ + try: + protocol = A2AProtocol( + agent_id="test-agent", + agent_name="Test Agent", + public_url="http://localhost:6000", + domain="testing", + specialization="test agent", + description="Test description", + capabilities=["text"] + ) + + assert protocol.agent_id == "test-agent", "Agent ID mismatch" + assert protocol.agent_name == "Test Agent", "Agent name mismatch" + assert protocol.get_protocol_name() == "a2a", "Protocol name should be 'a2a'" + + except ImportError as e: + pytest.fail(f"A2A SDK import failed: {e}\n" + f"Solution: pip install a2a-sdk>=0.2.0") + + def test_a2a_protocol_has_httpx_client(self): + """ + Test that A2A protocol creates HTTP client + + Expected: httpx.AsyncClient should be initialized + + Failure Reasons: + - httpx not installed โ†’ pip install httpx>=0.27.0 + - Client not initialized โ†’ Check A2AProtocol.__init__() + """ + protocol = A2AProtocol( + agent_id="test-agent", + agent_name="Test Agent", + public_url="http://localhost:6000" + ) + + assert hasattr(protocol, 'httpx_client'), \ + "Protocol missing 'httpx_client' attribute" + + assert protocol.httpx_client is not None, \ + "HTTP client is None. Check A2AProtocol.__init__()" + + assert not protocol.httpx_client.is_closed, \ + "HTTP client should not be closed after initialization" + + def test_a2a_protocol_agent_card(self): + """ + Test that A2A protocol creates AgentCard + + Expected: AgentCard with correct metadata + + Failure Reasons: + - AgentCard not created โ†’ Check a2a.types import + - Metadata mismatch โ†’ Check AgentCard construction + """ + protocol = A2AProtocol( + agent_id="test-agent", + agent_name="Test Agent", + public_url="http://localhost:6000", + domain="testing", + description="Test description" + ) + + assert hasattr(protocol, 'agent_card'), \ + "Protocol missing 'agent_card' attribute" + + assert protocol.agent_card.name == "Test Agent", \ + f"AgentCard name mismatch: {protocol.agent_card.name}" + + assert protocol.agent_card.description == "Test description", \ + f"AgentCard description mismatch: {protocol.agent_card.description}" + + def test_a2a_protocol_metadata(self): + """ + Test A2A protocol metadata generation + + Expected: Metadata dict with agent info and capabilities + + Failure Reasons: + - get_metadata() returns wrong format + - Missing required fields + """ + protocol = A2AProtocol( + agent_id="test-agent", + agent_name="Test Agent", + public_url="http://localhost:6000", + capabilities=["text", "image"] + ) + + metadata = protocol.get_metadata() + + assert "agent_id" in metadata, "Metadata missing 'agent_id'" + assert metadata["agent_id"] == "test-agent" + + assert "name" in metadata, "Metadata missing 'name'" + assert metadata["name"] == "Test Agent" + + assert "capabilities" in metadata, "Metadata missing 'capabilities'" + assert "text" in metadata["capabilities"], "'text' not in capabilities" + assert "image" in metadata["capabilities"], "'image' not in capabilities" + + @pytest.mark.asyncio + async def test_a2a_protocol_incoming_handler(self): + """ + Test setting incoming message handler + + Expected: Server components should be initialized after setting handler + + Failure Reasons: + - Handler not set โ†’ Check set_incoming_handler() + - Server components not initialized โ†’ Check A2AStarletteApplication creation + """ + protocol = A2AProtocol( + agent_id="test-agent", + agent_name="Test Agent", + public_url="http://localhost:6000" + ) + + # Set handler + async def test_handler(message: dict) -> dict: + return {"response": "test"} + + protocol.set_incoming_handler(test_handler) + + # Should initialize server components + assert protocol.incoming_handler is not None, \ + "Handler not set after set_incoming_handler()" + + assert protocol.agent_executor is not None, \ + "AgentExecutor not initialized. Check NANDAAgentExecutor creation" + + assert protocol.request_handler is not None, \ + "RequestHandler not initialized. Check DefaultRequestHandler creation" + + assert protocol.server_app is not None, \ + "Server app not initialized. Check A2AStarletteApplication creation" + + +class TestResourceCleanup: + """ + Test resource cleanup functionality (PR #12 fix) + + Purpose: Verify HTTP clients and other resources are properly released + + Critical: Resource leaks can cause: + - Socket exhaustion + - Memory leaks + - Connection pool issues + """ + + @pytest.mark.asyncio + async def test_a2a_protocol_cleanup(self): + """ + Test that A2A protocol cleanup closes HTTP client + + Expected: HTTP client should be closed after cleanup() + + Failure Reasons: + - cleanup() not implemented โ†’ Check A2AProtocol.cleanup() + - aclose() not called โ†’ Check httpx_client.aclose() call + """ + protocol = A2AProtocol( + agent_id="test-cleanup", + agent_name="Test Cleanup", + public_url="http://localhost:6000" + ) + + # Client should be open + assert protocol.httpx_client is not None, "HTTP client is None" + assert not protocol.httpx_client.is_closed, \ + "HTTP client should not be closed before cleanup" + + # Cleanup + await protocol.cleanup() + + # Client should be closed + assert protocol.httpx_client.is_closed, \ + "HTTP client not closed after cleanup(). Check A2AProtocol.cleanup() implementation" + + @pytest.mark.asyncio + async def test_protocol_router_cleanup_all(self): + """ + Test that ProtocolRouter cleans up all protocols + + Expected: All registered protocols should be cleaned up + + Failure Reasons: + - cleanup_all() not implemented โ†’ Check ProtocolRouter.cleanup_all() + - Not iterating all protocols โ†’ Check iteration logic + """ + router = ProtocolRouter() + + # Register A2A protocol + protocol = A2AProtocol( + agent_id="test-router-cleanup", + agent_name="Test Router Cleanup", + public_url="http://localhost:6000" + ) + router.register(protocol) + + # Client should be open + assert not protocol.httpx_client.is_closed, \ + "HTTP client should not be closed before router cleanup" + + # Cleanup all + await router.cleanup_all() + + # Client should be closed + assert protocol.httpx_client.is_closed, \ + "HTTP client not closed after router.cleanup_all(). " \ + "Check ProtocolRouter.cleanup_all() implementation" + + def test_nanda_stop_calls_cleanup(self): + """ + Test that NANDA.stop() triggers protocol cleanup + + Expected: stop() should cleanup all protocol resources + + Failure Reasons: + - stop() doesn't call cleanup โ†’ Check NANDA.stop() + - Event loop handling issue โ†’ Check asyncio.get_running_loop() logic + - asyncio.run() not called in sync context โ†’ Check RuntimeError handling + + Context: This test runs in pytest (sync context), so stop() should + create a temporary event loop to run cleanup. + """ + agent = NANDA( + agent_id="test-stop-cleanup", + agent_logic=simple_agent_logic, + port=6000, + host="127.0.0.1", + public_url="http://127.0.0.1:6000", + enable_telemetry=False + ) + + # Get protocol + protocol = agent.router.get_protocol("a2a") + assert protocol is not None, "A2A protocol not found" + assert not protocol.httpx_client.is_closed, \ + "HTTP client should not be closed before stop()" + + # Stop agent (should trigger cleanup) + try: + agent.stop() + except Exception as e: + pytest.fail(f"agent.stop() raised exception: {e}\n" + f"Check NANDA.stop() error handling") + + # HTTP client should be closed + assert protocol.httpx_client.is_closed, \ + "HTTP client not closed after stop(). " \ + "Possible causes:\n" \ + "1. cleanup() not called โ†’ Check NANDA.stop() implementation\n" \ + "2. Event loop issue โ†’ Check asyncio.get_running_loop() / asyncio.run() logic\n" \ + "3. RuntimeError not caught โ†’ Check try-except around get_running_loop()" + + @pytest.mark.asyncio + async def test_cleanup_handles_errors_gracefully(self): + """ + Test that cleanup handles errors without crashing + + Expected: cleanup_all() should continue even if one protocol fails + + Failure Reasons: + - Exception not caught โ†’ Check try-except in cleanup_all() + - Error propagates โ†’ Should print warning, not raise + """ + router = ProtocolRouter() + + # Create a mock protocol that raises error on cleanup + class MockProtocol: + def get_protocol_name(self): + return "mock" + + async def cleanup(self): + raise Exception("Test cleanup error") + + router.protocols["mock"] = MockProtocol() + + # Cleanup should not raise exception + try: + await router.cleanup_all() + success = True + except Exception as e: + success = False + pytest.fail(f"cleanup_all() should handle errors gracefully, but raised: {e}\n" + f"Check ProtocolRouter.cleanup_all() error handling") + + assert success, "cleanup_all() should not raise exceptions" + + @pytest.mark.asyncio + async def test_cleanup_in_async_context(self): + """ + Test cleanup when called from async context + + Expected: Should use create_task() instead of asyncio.run() + + Note: This test verifies the async context path in NANDA.stop() + """ + agent = NANDA( + agent_id="test-async-cleanup", + agent_logic=simple_agent_logic, + port=6000, + host="127.0.0.1", + public_url="http://127.0.0.1:6000", + enable_telemetry=False + ) + + protocol = agent.router.get_protocol("a2a") + + # In async context, stop() should work without errors + agent.stop() + + # Wait a bit for the task to complete + await asyncio.sleep(0.1) + + # Note: In async context, create_task() is used, so cleanup happens asynchronously + # We can't guarantee client is closed immediately + + +class TestIntegration: + """ + Integration tests for complete agent lifecycle + + Purpose: Test real-world usage scenarios end-to-end + """ + + def test_full_agent_lifecycle(self): + """ + Test complete agent lifecycle: create โ†’ use โ†’ stop + + Expected: Agent should initialize, work, and cleanup properly + + This is the most important integration test. If this fails, check: + 1. Agent initialization + 2. Protocol registration + 3. Resource cleanup + """ + # Create + try: + agent = NANDA( + agent_id="test-lifecycle", + agent_logic=simple_agent_logic, + agent_name="Lifecycle Test", + domain="testing", + port=6000, + host="127.0.0.1", + public_url="http://127.0.0.1:6000", + enable_telemetry=False + ) + except Exception as e: + pytest.fail(f"Agent creation failed: {e}") + + # Verify initialization + assert agent.agent_id == "test-lifecycle" + assert "a2a" in agent.router.get_all_protocols(), \ + "A2A protocol not registered during initialization" + + # Get protocol + protocol = agent.router.get_protocol("a2a") + assert protocol is not None, "A2A protocol not retrievable" + assert not protocol.httpx_client.is_closed, \ + "HTTP client closed prematurely" + + # Stop (cleanup) + try: + agent.stop() + except Exception as e: + pytest.fail(f"Agent stop failed: {e}\n" + f"Check NANDA.stop() implementation and error handling") + + # Verify cleanup + assert protocol.httpx_client.is_closed, \ + "HTTP client not closed after stop().\n" \ + f"Test environment: Python {sys.version}\n" \ + f"This indicates the resource cleanup fix (PR #12) may not be working.\n" \ + f"Check NANDA.stop() event loop handling." + + def test_multiple_agents_cleanup(self): + """ + Test cleanup with multiple agents + + Expected: Each agent should cleanup its own resources + """ + agents = [] + + # Create multiple agents + for i in range(3): + agent = NANDA( + agent_id=f"test-agent-{i}", + agent_logic=simple_agent_logic, + port=6000 + i, + host="127.0.0.1", + public_url=f"http://127.0.0.1:{6000 + i}", + enable_telemetry=False + ) + agents.append(agent) + + # Get all protocols + protocols = [agent.router.get_protocol("a2a") for agent in agents] + + # All clients should be open + for protocol in protocols: + assert not protocol.httpx_client.is_closed + + # Stop all agents + for agent in agents: + agent.stop() + + # All clients should be closed + for i, protocol in enumerate(protocols): + assert protocol.httpx_client.is_closed, \ + f"Agent {i} HTTP client not closed" + + +if __name__ == "__main__": + # Run with pytest + pytest.main([__file__, "-v", "--tb=short", "-s"])