From eea1412d1a48a61bfbd4f2a67db575d672907c2a Mon Sep 17 00:00:00 2001 From: BarnacleBoy Date: Mon, 11 May 2026 15:44:10 +0000 Subject: [PATCH] feat(a2a): add Agent Card generation for A2A protocol discovery Implements Phase 1 of A2A (Agent-to-Agent) protocol support: - Add agent/a2a/ module with Agent Card generation - Generate A2A-compliant Agent Card from Hermes tools/config - Add GET /.well-known/agent.json endpoint to API server - Map Hermes tools to A2A skills with proper metadata - Include CORS headers and cache control for discovery - Add a2a-sdk>=1.0.0 as optional dependency A2A is the Linux Foundation standard for inter-agent communication. This enables other A2A-compliant agents to discover Hermes. Refs: https://github.com/NousResearch/hermes-agent/issues/514 --- A2A_IMPLEMENTATION.md | 96 +++++ agent/a2a/__init__.py | 25 ++ agent/a2a/agent_card.py | 607 +++++++++++++++++++++++++++++++ gateway/platforms/api_server.py | 41 +++ pyproject.toml | 2 + tests/gateway/test_api_server.py | 70 ++++ 6 files changed, 841 insertions(+) create mode 100644 A2A_IMPLEMENTATION.md create mode 100644 agent/a2a/__init__.py create mode 100644 agent/a2a/agent_card.py diff --git a/A2A_IMPLEMENTATION.md b/A2A_IMPLEMENTATION.md new file mode 100644 index 000000000000..42ebbbee21a1 --- /dev/null +++ b/A2A_IMPLEMENTATION.md @@ -0,0 +1,96 @@ +# A2A Protocol Support - Implementation Summary + +## Overview + +This PR implements Agent Card generation for the A2A (Agent-to-Agent) protocol, enabling Hermes Agent to be discovered by other A2A-compliant agents. + +**Related Issue:** https://github.com/NousResearch/hermes-agent/issues/514 + +## What was implemented + +### 1. Agent Card Generation Module (`agent/a2a/`) + +- `agent/a2a/__init__.py` - Package entry point +- `agent/a2a/agent_card.py` - Complete Agent Card generation with: + - A2A-compliant data classes (AgentCard, AgentSkill, AgentCapabilities, AgentInterface) + - Mapping of Hermes tools to A2A skills + - Configurable base URL and capabilities + - JSON serialization support + +### 2. API Server Endpoint (`gateway/platforms/api_server.py`) + +- Added `GET /.well-known/agent.json` endpoint +- Returns A2A-compliant Agent Card JSON +- Includes CORS headers for cross-origin access +- Cache-Control headers (1 hour) to reduce server load + +### 3. Tests (`tests/gateway/test_api_server.py`) + +- `TestAgentCard` test class with 4 tests: + - Agent Card endpoint returns valid JSON + - CORS headers for cross-origin access + - Cache-Control headers present + - Skills list includes core Hermes capabilities + +### 4. Dependencies (`pyproject.toml`) + +- Added `[a2a]` optional dependency: `a2a-sdk>=1.0.0,<2` +- Added `a2a` to `[all]` extra + +## Agent Card Output + +```json +{ + "name": "Hermes Agent", + "description": "Self-improving AI agent with memory, skills, and tool ecosystem...", + "version": "1.0.0", + "url": "http://localhost:8642/.well-known/agent.json", + "capabilities": { + "streaming": true, + "push_notifications": false + }, + "supported_interfaces": [{ + "url": "http://localhost:8642", + "protocol_binding": "JSONRPC", + "protocol_version": "1.0" + }], + "skills": [ + {"id": "hermes_memory", "name": "Persistent Memory", ...}, + {"id": "delegate_task", "name": "Task Delegation", ...}, + ... + ], + "provider": { + "name": "Nous Research", + "url": "https://hermes-agent.nousresearch.com" + } +} +``` + +## Testing + +```bash +# Run agent card tests +pytest tests/gateway/test_api_server.py::TestAgentCard -v + +# Generate agent card directly +python -c "from agent.a2a import generate_agent_card; print(generate_agent_card().to_json())" +``` + +## Next Steps (Future PRs) + +### Phase 2: A2A Client +- Implement `tools/a2a_tool.py` for discovering and calling remote A2A agents +- Agent Card resolution from remote URLs +- Task management (send message, get task, list tasks) + +### Phase 3: A2A Server (Full) +- Implement JSON-RPC endpoints for A2A protocol operations +- Task lifecycle management (create, update, complete, cancel) +- Streaming support via Server-Sent Events +- Push notification support + +## References + +- A2A Protocol Specification: https://a2a-protocol.org/latest/specification/ +- A2A Python SDK: https://github.com/a2aproject/a2a-python +- Hermes Issue #514: https://github.com/NousResearch/hermes-agent/issues/514 \ No newline at end of file diff --git a/agent/a2a/__init__.py b/agent/a2a/__init__.py new file mode 100644 index 000000000000..bde36c48d10a --- /dev/null +++ b/agent/a2a/__init__.py @@ -0,0 +1,25 @@ +""" +A2A (Agent-to-Agent) Protocol Support for Hermes Agent. + +This module provides A2A protocol capabilities: +- Agent Card generation (/.well-known/agent.json) +- A2A client functionality (future) +- A2A server functionality (future) + +A2A is an open standard (Apache 2.0, Linux Foundation) for inter-agent communication. +It complements MCP: MCP connects agents to tools, A2A connects agents to agents. + +Related: https://github.com/NousResearch/hermes-agent/issues/514 +""" + +from agent.a2a.agent_card import ( + generate_agent_card, + get_agent_card_json, + get_well_known_agent_card_endpoint, +) + +__all__ = [ + "generate_agent_card", + "get_agent_card_json", + "get_well_known_agent_card_endpoint", +] \ No newline at end of file diff --git a/agent/a2a/agent_card.py b/agent/a2a/agent_card.py new file mode 100644 index 000000000000..654d4756ce7a --- /dev/null +++ b/agent/a2a/agent_card.py @@ -0,0 +1,607 @@ +""" +Agent Card Generation for A2A Protocol. + +Generates A2A Agent Cards (/.well-known/agent.json) that describe Hermes Agent's +capabilities, skills, and endpoints for inter-agent discovery. + +A2A Protocol Reference: https://a2a-protocol.org/latest/specification/ +Hermes Issue: https://github.com/NousResearch/hermes-agent/issues/514 +""" + +import json +import logging +from typing import Any, Dict, List, Optional +from dataclasses import dataclass, field, asdict + +logger = logging.getLogger(__name__) + +# A2A Protocol version we support +A2A_PROTOCOL_VERSION = "1.0" + +# Default transport binding +DEFAULT_TRANSPORT = "JSONRPC" # JSON-RPC 2.0 over HTTP + +# Default input/output modes +DEFAULT_INPUT_MODES = ["text/plain"] +DEFAULT_OUTPUT_MODES = ["text/plain"] + + +@dataclass +class AgentSkill: + """ + Describes a specific capability or function the agent can perform. + + Maps to A2A AgentSkill protobuf message. + """ + id: str + name: str + description: str + tags: List[str] = field(default_factory=list) + examples: List[str] = field(default_factory=list) + input_modes: List[str] = field(default_factory=list) + output_modes: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + result = { + "id": self.id, + "name": self.name, + "description": self.description, + } + if self.tags: + result["tags"] = self.tags + if self.examples: + result["examples"] = self.examples + if self.input_modes: + result["input_modes"] = self.input_modes + if self.output_modes: + result["output_modes"] = self.output_modes + return result + + +@dataclass +class AgentCapabilities: + """ + Describes the capabilities supported by the agent. + + Maps to A2A AgentCapabilities protobuf message. + """ + streaming: bool = True + push_notifications: bool = False + extensions: List[Dict[str, Any]] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + result = { + "streaming": self.streaming, + "push_notifications": self.push_notifications, + } + if self.extensions: + result["extensions"] = self.extensions + return result + + +@dataclass +class AgentInterface: + """ + Describes a transport interface for the agent. + + Maps to A2A AgentInterface protobuf message. + """ + url: str + protocol_binding: str = "JSONRPC" # JSONRPC, GRPC, or HTTP_JSON + protocol_version: str = A2A_PROTOCOL_VERSION + tenant: str = "" # Optional tenant for multi-tenant + + def to_dict(self) -> Dict[str, Any]: + result = { + "url": self.url, + "protocol_binding": self.protocol_binding, + "protocol_version": self.protocol_version, + } + if self.tenant: + result["tenant"] = self.tenant + return result + + +@dataclass +class AgentProvider: + """ + Information about the agent's provider/organization. + + Maps to A2A AgentProvider protobuf message. + """ + name: str + url: str = "" + version: str = "" + + def to_dict(self) -> Dict[str, Any]: + result = {"name": self.name} + if self.url: + result["url"] = self.url + if self.version: + result["version"] = self.version + return result + + +@dataclass +class SecurityScheme: + """ + Describes a security scheme for authentication. + + A2A uses standard OpenAPI-style security schemes. + """ + id: str + type: str # "apiKey", "http", "oauth2", "openIdConnect" + description: str = "" + name: str = "" # For apiKey type + in_: str = "" # "header", "query", "cookie" for apiKey + scheme: str = "" # For http type (e.g., "bearer") + flows: Dict[str, Any] = field(default_factory=dict) # For oauth2 + + def to_dict(self) -> Dict[str, Any]: + result = {"id": self.id, "type": self.type} + if self.description: + result["description"] = self.description + if self.type == "apiKey": + if self.name: + result["name"] = self.name + if self.in_: + result["in"] = self.in_ + elif self.type == "http": + if self.scheme: + result["scheme"] = self.scheme + elif self.type == "oauth2": + if self.flows: + result["flows"] = self.flows + return result + + +@dataclass +class AgentCard: + """ + A2A Agent Card - the "business card" for agent discovery. + + This is the complete AgentCard definition that gets served at + /.well-known/agent.json for A2A protocol compliance. + + Reference: https://a2a-protocol.org/latest/specification/#411-agentcard + """ + name: str + description: str + version: str + url: str + capabilities: AgentCapabilities + supported_interfaces: List[AgentInterface] + default_input_modes: List[str] = field(default_factory=lambda: DEFAULT_INPUT_MODES) + default_output_modes: List[str] = field(default_factory=lambda: DEFAULT_OUTPUT_MODES) + skills: List[AgentSkill] = field(default_factory=list) + provider: Optional[AgentProvider] = None + documentation_url: str = "" + icon_url: str = "" + security_schemes: Dict[str, SecurityScheme] = field(default_factory=dict) + security_requirements: List[Dict[str, List[str]]] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Convert to A2A-compatible dictionary.""" + result = { + "name": self.name, + "description": self.description, + "version": self.version, + "url": self.url, + "capabilities": self.capabilities.to_dict(), + "supported_interfaces": [i.to_dict() for i in self.supported_interfaces], + "default_input_modes": self.default_input_modes, + "default_output_modes": self.default_output_modes, + "skills": [s.to_dict() for s in self.skills], + } + if self.provider: + result["provider"] = self.provider.to_dict() + if self.documentation_url: + result["documentation_url"] = self.documentation_url + if self.icon_url: + result["icon_url"] = self.icon_url + if self.security_schemes: + result["security_schemes"] = { + k: v.to_dict() for k, v in self.security_schemes.items() + } + if self.security_requirements: + result["security_requirements"] = self.security_requirements + return result + + def to_json(self, indent: int = 2) -> str: + """Convert to JSON string.""" + return json.dumps(self.to_dict(), indent=indent) + + +# --------------------------------------------------------------------------- +# Hermes-specific Agent Card generation +# --------------------------------------------------------------------------- + +# Map Hermes tool names to human-readable A2A Skills +_HERMES_TOOL_TO_SKILL: Dict[str, AgentSkill] = { + # Core tools + "web_search": AgentSkill( + id="web_search", + name="Web Search", + description="Search the web for information using Brave Search API", + tags=["research", "web", "search"], + ), + "web_extract": AgentSkill( + id="web_extract", + name="Web Content Extraction", + description="Extract and parse content from web pages", + tags=["research", "web", "extraction"], + ), + "terminal": AgentSkill( + id="terminal", + name="Terminal Execution", + description="Execute shell commands in a Linux environment", + tags=["execution", "shell", "commands"], + ), + "process": AgentSkill( + id="process", + name="Process Management", + description="Manage background processes started with terminal", + tags=["execution", "process", "background"], + ), + "read_file": AgentSkill( + id="read_file", + name="File Reading", + description="Read text files with line numbers and pagination", + tags=["file", "read", "content"], + ), + "write_file": AgentSkill( + id="write_file", + name="File Writing", + description="Write content to files, creating parent directories automatically", + tags=["file", "write", "create"], + ), + "patch": AgentSkill( + id="patch", + name="File Patching", + description="Apply targeted find-and-replace edits to files", + tags=["file", "edit", "patch"], + ), + "search_files": AgentSkill( + id="search_files", + name="File Search", + description="Search file contents or find files by name using ripgrep", + tags=["file", "search", "ripgrep"], + ), + "vision_analyze": AgentSkill( + id="vision_analyze", + name="Image Analysis", + description="Analyze images from URLs or file paths using vision models", + tags=["vision", "image", "analysis"], + ), + "image_generate": AgentSkill( + id="image_generate", + name="Image Generation", + description="Generate images from text prompts using configured providers", + tags=["image", "generation", "creative"], + ), + "skills_list": AgentSkill( + id="skills_list", + name="List Skills", + description="List available skills (name + description)", + tags=["skills", "discovery"], + ), + "skill_view": AgentSkill( + id="skill_view", + name="View Skill", + description="Load a skill's full content including references and templates", + tags=["skills", "knowledge"], + ), + "skill_manage": AgentSkill( + id="skill_manage", + name="Manage Skills", + description="Create, update, or delete skills", + tags=["skills", "management"], + ), + "todo": AgentSkill( + id="todo", + name="Task Management", + description="Manage a task list for the current session", + tags=["planning", "tasks", "todo"], + ), + "memory": AgentSkill( + id="memory", + name="Persistent Memory", + description="Save durable information to persistent memory that survives across sessions", + tags=["memory", "persistence", "context"], + ), + "session_search": AgentSkill( + id="session_search", + name="Session History Search", + description="Search long-term memory of past conversations", + tags=["memory", "search", "history"], + ), + "clarify": AgentSkill( + id="clarify", + name="Clarification", + description="Ask the user a question when clarification is needed", + tags=["interaction", "questions"], + ), + "execute_code": AgentSkill( + id="execute_code", + name="Code Execution", + description="Run a Python script that can call Hermes tools programmatically", + tags=["code", "python", "execution"], + ), + "delegate_task": AgentSkill( + id="delegate_task", + name="Task Delegation", + description="Spawn one or more subagents to work on tasks in isolated contexts", + tags=["delegation", "multi-agent", "parallel"], + ), + "cronjob": AgentSkill( + id="cronjob", + name="Cronjob Management", + description="Manage scheduled cron jobs with scheduling, running, and monitoring", + tags=["scheduling", "cron", "automation"], + ), + "send_message": AgentSkill( + id="send_message", + name="Cross-Platform Messaging", + description="Send a message to a connected messaging platform (Discord, Telegram, etc.)", + tags=["messaging", "communication"], + ), + "computer_use": AgentSkill( + id="computer_use", + name="Computer Use", + description="Background desktop control via cua-driver (screenshots, mouse, keyboard)", + tags=["desktop", "automation", "control"], + ), +} + +# Core Hermes skills that define the agent's identity +_HERMES_CORE_SKILLS = [ + AgentSkill( + id="hermes_memory", + name="Persistent Memory", + description="Store and recall user facts, preferences, and context across sessions", + tags=["memory", "persistence", "personalization"], + ), + AgentSkill( + id="hermes_skills", + name="Dynamic Skills", + description="Create, load, and improve reusable skill documents for specialized tasks", + tags=["skills", "learning", "automation"], + ), + AgentSkill( + id="hermes_delegation", + name="Task Delegation", + description="Spawn sub-agents for parallel work with isolated contexts", + tags=["delegation", "multi-agent", "orchestration"], + ), +] + + +def _get_hermes_skills(enabled_toolsets: Optional[List[str]] = None) -> List[AgentSkill]: + """ + Get list of A2A Skills representing Hermes Agent capabilities. + + Args: + enabled_toolsets: Optional list of enabled toolset names to filter skills. + If None, returns all available skills. + + Returns: + List of AgentSkill objects representing Hermes capabilities. + """ + from toolsets import resolve_toolset, get_all_toolsets + + skills = list(_HERMES_CORE_SKILLS) + + if enabled_toolsets is None: + # If no toolsets specified, resolve all to get all tools + try: + all_toolsets = get_all_toolsets() + all_tools = set() + for ts_name in all_toolsets: + try: + tools = resolve_toolset(ts_name) + all_tools.update(tools) + except Exception: + pass + tool_names = list(all_tools) + except Exception: + # Fallback to core tools if toolsets module fails + tool_names = [] + else: + # Resolve specific toolsets + tool_names = [] + try: + for ts_name in enabled_toolsets: + tools = resolve_toolset(ts_name) + tool_names.extend(tools) + tool_names = list(set(tool_names)) # Dedupe + except Exception: + pass + + # Map tool names to skills + for tool_name in tool_names: + if tool_name in _HERMES_TOOL_TO_SKILL: + skills.append(_HERMES_TOOL_TO_SKILL[tool_name]) + + return skills + + +def _get_base_url(config: Optional[Any] = None) -> str: + """ + Determine the base URL for the A2A endpoint. + + Priority: + 1. Configured a2a.url in config.yaml + 2. API server host:port from config + 3. Default localhost:8642 + + Args: + config: Optional Hermes config object + + Returns: + Base URL string for the A2A endpoint. + """ + # Default + base_url = "http://localhost:8642" + + if config is None: + return base_url + + # Try config values + try: + # Check for explicit A2A config + if hasattr(config, "a2a") and config.a2a: + if hasattr(config.a2a, "url") and config.a2a.url: + return config.a2a.url + except Exception: + pass + + try: + # Check for API server config + if hasattr(config, "api_server") and config.api_server: + host = getattr(config.api_server, "host", "127.0.0.1") + port = getattr(config.api_server, "port", 8642) + # Use localhost for 0.0.0.0 binding + if host in ("0.0.0.0", "::", ""): + host = "localhost" + return f"http://{host}:{port}" + except Exception: + pass + + return base_url + + +def generate_agent_card( + config: Optional[Any] = None, + enabled_toolsets: Optional[List[str]] = None, + name: Optional[str] = None, + description: Optional[str] = None, + version: Optional[str] = None, + streaming: bool = True, + push_notifications: bool = False, +) -> AgentCard: + """ + Generate an A2A Agent Card for Hermes Agent. + + This function creates a standards-compliant Agent Card that describes + Hermes Agent's capabilities for discovery by other A2A-compliant agents. + + Args: + config: Optional Hermes config object for extracting URL and settings + enabled_toolsets: Optional list of enabled toolset names + name: Override agent name (default: "Hermes Agent") + description: Override agent description + version: Override agent version + streaming: Whether streaming is supported (default: True) + push_notifications: Whether push notifications are supported (default: False) + + Returns: + AgentCard object ready for serialization to JSON. + + Example: + >>> card = generate_agent_card() + >>> json_string = card.to_json() + >>> print(json_string) + """ + # Get version from hermes if possible + try: + from hermes_constants import HERMES_VERSION + hermes_version = HERMES_VERSION + except ImportError: + hermes_version = "1.0.0" + + base_url = _get_base_url(config) + a2a_url = f"{base_url.rstrip('/')}/.well-known/agent.json" + + # Build capabilities + capabilities = AgentCapabilities( + streaming=streaming, + push_notifications=push_notifications, + ) + + # Build interface + interface = AgentInterface( + url=base_url, + protocol_binding=DEFAULT_TRANSPORT, + protocol_version=A2A_PROTOCOL_VERSION, + ) + + # Get skills + skills = _get_hermes_skills(enabled_toolsets) + + # Build provider info + provider = AgentProvider( + name="Nous Research", + url="https://hermes-agent.nousresearch.com", + version=hermes_version, + ) + + # Build the card + card = AgentCard( + name=name or "Hermes Agent", + description=description or ( + "Self-improving AI agent with memory, skills, and tool ecosystem. " + "Supports persistent memory across sessions, dynamic skill creation, " + "cross-platform messaging, and task delegation to sub-agents." + ), + version=version or hermes_version, + url=a2a_url, + capabilities=capabilities, + supported_interfaces=[interface], + default_input_modes=DEFAULT_INPUT_MODES, + default_output_modes=DEFAULT_OUTPUT_MODES, + skills=skills, + provider=provider, + documentation_url="https://hermes-agent.nousresearch.com/docs", + ) + + return card + + +def get_agent_card_json( + config: Optional[Any] = None, + enabled_toolsets: Optional[List[str]] = None, + **kwargs, +) -> str: + """ + Get the Agent Card as a JSON string. + + Convenience wrapper around generate_agent_card() that returns JSON. + + Args: + config: Optional Hermes config object + enabled_toolsets: Optional list of enabled toolset names + **kwargs: Additional arguments passed to generate_agent_card() + + Returns: + JSON string of the Agent Card document. + """ + card = generate_agent_card( + config=config, + enabled_toolsets=enabled_toolsets, + **kwargs, + ) + return card.to_json() + + +def get_well_known_agent_card_endpoint() -> str: + """ + Get the well-known URI path for the Agent Card. + + Returns: + The path component: /.well-known/agent.json + """ + return "/.well-known/agent.json" + + +# --------------------------------------------------------------------------- +# Module-level test +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + # Generate and print a sample Agent Card + card = generate_agent_card() + print("Hermes Agent A2A Agent Card:") + print("=" * 60) + print(card.to_json(indent=2)) + print("=" * 60) + print(f"\nWell-known endpoint: {get_well_known_agent_card_endpoint()}") \ No newline at end of file diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 357ecbd47851..516b9048840d 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -866,6 +866,46 @@ async def _handle_health(self, request: "web.Request") -> "web.Response": """GET /health — simple health check.""" return web.json_response({"status": "ok", "platform": "hermes-agent"}) + async def _handle_agent_card(self, request: "web.Request") -> "web.Response": + """GET /.well-known/agent.json — A2A Agent Card for inter-agent discovery. + + Returns the A2A (Agent-to-Agent) protocol Agent Card that describes + this Hermes Agent's capabilities, skills, and endpoints. This enables + other A2A-compliant agents to discover and communicate with Hermes. + + Reference: https://a2a-protocol.org/latest/specification/ + Issue: https://github.com/NousResearch/hermes-agent/issues/514 + """ + try: + from agent.a2a.agent_card import ( + generate_agent_card, + get_agent_card_json, + ) + # Generate the card with current configuration + # The config is accessed via self._config if available + config = getattr(self, "_config", None) + card_json = get_agent_card_json(config=config) + return web.Response( + text=card_json, + content_type="application/json", + headers={ + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=3600", # Cache for 1 hour + }, + ) + except ImportError as e: + logger.error("A2A agent_card module not available: %s", e) + return web.json_response( + {"error": "A2A support not available", "code": "not_implemented"}, + status=501, + ) + except Exception as e: + logger.exception("Error generating Agent Card: %s", e) + return web.json_response( + {"error": "Internal server error", "code": "internal_error"}, + status=500, + ) + async def _handle_health_detailed(self, request: "web.Request") -> "web.Response": """GET /health/detailed — rich status for cross-container dashboard probing. @@ -3336,6 +3376,7 @@ async def connect(self) -> bool: self._app = web.Application(middlewares=mws, client_max_size=MAX_REQUEST_BYTES) self._app["api_server_adapter"] = self self._app.router.add_get("/health", self._handle_health) + self._app.router.add_get("/.well-known/agent.json", self._handle_agent_card) self._app.router.add_get("/health/detailed", self._handle_health_detailed) self._app.router.add_get("/v1/health", self._handle_health) self._app.router.add_get("/v1/models", self._handle_models) diff --git a/pyproject.toml b/pyproject.toml index 1eba1aa16577..82dff95406fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,7 @@ pty = [ ] honcho = ["honcho-ai>=2.0.1,<3"] mcp = ["mcp>=1.2.0,<2"] +a2a = ["a2a-sdk>=1.0.0,<2"] homeassistant = ["aiohttp>=3.9.0,<4"] sms = ["aiohttp>=3.9.0,<4"] # Computer use — macOS background desktop control via cua-driver (MCP stdio). @@ -162,6 +163,7 @@ all = [ "hermes-agent[pty]", "hermes-agent[honcho]", "hermes-agent[mcp]", + "hermes-agent[a2a]", "hermes-agent[homeassistant]", "hermes-agent[sms]", "hermes-agent[acp]", diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 9e00a3758712..375c25440066 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -353,6 +353,7 @@ def _create_app(adapter: APIServerAdapter) -> web.Application: app = web.Application(middlewares=mws) app["api_server_adapter"] = adapter app.router.add_get("/health", adapter._handle_health) + app.router.add_get("/.well-known/agent.json", adapter._handle_agent_card) app.router.add_get("/health/detailed", adapter._handle_health_detailed) app.router.add_get("/v1/health", adapter._handle_health) app.router.add_get("/v1/models", adapter._handle_models) @@ -3062,3 +3063,72 @@ async def test_capabilities_advertises_session_key_header(self, adapter): data = await resp.json() assert data["features"]["session_key_header"] == "X-Hermes-Session-Key" + +# --------------------------------------------------------------------------- +# A2A Agent Card Endpoint +# --------------------------------------------------------------------------- + + +class TestAgentCard: + """Tests for the A2A Agent Card endpoint (/.well-known/agent.json).""" + + @pytest.mark.asyncio + async def test_agent_card_endpoint_exists(self, adapter): + """GET /.well-known/agent.json returns a valid Agent Card.""" + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/.well-known/agent.json") + assert resp.status == 200 + # Check content type + assert "application/json" in resp.headers.get("Content-Type", "") + # Parse JSON + data = await resp.json() + # Verify required A2A fields + assert "name" in data + assert "description" in data + assert "version" in data + assert "url" in data + assert "capabilities" in data + assert "supported_interfaces" in data + assert "skills" in data + # Verify Hermes-specific content + assert data["name"] == "Hermes Agent" + assert "memory" in data["description"].lower() or "persistent" in data["description"].lower() + assert data["capabilities"]["streaming"] is True + + @pytest.mark.asyncio + async def test_agent_card_cors_headers(self, adapter): + """Agent Card endpoint has CORS headers for cross-origin access.""" + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/.well-known/agent.json") + assert resp.status == 200 + # CORS should allow all origins for agent discovery + assert resp.headers.get("Access-Control-Allow-Origin") == "*" + + @pytest.mark.asyncio + async def test_agent_card_cache_control(self, adapter): + """Agent Card has cache headers to reduce server load.""" + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/.well-known/agent.json") + assert resp.status == 200 + cache_control = resp.headers.get("Cache-Control", "") + assert "public" in cache_control + assert "max-age" in cache_control + + @pytest.mark.asyncio + async def test_agent_card_skills_list(self, adapter): + """Agent Card includes Hermes tools as A2A skills.""" + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/.well-known/agent.json") + assert resp.status == 200 + data = await resp.json() + skills = data.get("skills", []) + skill_ids = [s["id"] for s in skills] + # Core Hermes skills should be present + assert "hermes_memory" in skill_ids or any("memory" in s["id"] for s in skills) + # Should have multiple skills + assert len(skills) > 5, f"Expected more than 5 skills, got {len(skills)}" +