Compare commits

..

2 Commits

Author SHA1 Message Date
5a24894f78 fix: update hermes_cli/web_server.py for agent card discovery
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Successful in 43s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Nix / nix (ubuntu-latest) (pull_request) Failing after 5s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 38s
Tests / test (pull_request) Failing after 10m58s
Tests / e2e (pull_request) Successful in 1m32s
Nix / nix (macos-latest) (pull_request) Has been cancelled
2026-04-16 03:45:04 +00:00
a474eb8459 fix: add agent/agent_card.py for agent card discovery 2026-04-16 03:45:01 +00:00
3 changed files with 100 additions and 309 deletions

View File

@@ -1,224 +1,135 @@
"""A2A Agent Card — publish capabilities for fleet discovery.
"""
Agent Card — A2A-compliant agent discovery.
Part of #843: fix: implement A2A agent card for fleet discovery (#819)
Each fleet agent publishes an A2A-compliant agent card describing its capabilities.
Standard discovery endpoint: /.well-known/agent-card.json
Issue #819: feat: A2A agent card — publish capabilities for fleet discovery
Provides metadata about the agent's identity, capabilities, and installed skills
for discovery by other agents in the fleet.
"""
import json
import logging
import os
import socket
from dataclasses import dataclass, field, asdict
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
from hermes_cli import __version__
from hermes_cli.config import load_config, get_hermes_home
from agent.skill_utils import (
iter_skill_index_files,
parse_frontmatter,
get_all_skills_dirs,
get_disabled_skill_names,
skill_matches_platform
)
logger = logging.getLogger(__name__)
@dataclass
class AgentSkill:
"""A single skill the agent can perform."""
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=lambda: ["text/plain"])
output_modes: List[str] = field(default_factory=lambda: ["text/plain"])
version: str = "1.0.0"
@dataclass
class AgentCapabilities:
"""What the agent can do."""
streaming: bool = True
push_notifications: bool = False
state_transition_history: bool = True
tools: bool = True
vision: bool = False
reasoning: bool = False
@dataclass
class AgentCard:
"""A2A-compliant agent card."""
name: str
description: str
url: str
version: str = "1.0.0"
version: str = __version__
capabilities: AgentCapabilities = field(default_factory=AgentCapabilities)
skills: List[AgentSkill] = field(default_factory=list)
default_input_modes: List[str] = field(default_factory=lambda: ["text/plain", "application/json"])
default_output_modes: List[str] = field(default_factory=lambda: ["text/plain", "application/json"])
metadata: Dict[str, Any] = field(default_factory=dict)
defaultInputModes: List[str] = field(default_factory=lambda: ["text/plain"])
defaultOutputModes: List[str] = field(default_factory=lambda: ["text/plain"])
def to_dict(self) -> Dict[str, Any]:
"""Convert to JSON-serializable dict."""
d = asdict(self)
# Rename for A2A spec compliance
d["defaultInputModes"] = d.pop("default_input_modes")
d["defaultOutputModes"] = d.pop("default_output_modes")
return d
def to_json(self) -> str:
"""Serialize to JSON string."""
return json.dumps(self.to_dict(), indent=2)
def _load_skills_from_directory(skills_dir: Path) -> List[AgentSkill]:
"""Scan ~/.hermes/skills/ for SKILL.md frontmatter."""
def _load_skills() -> List[AgentSkill]:
"""Scan all enabled skills and return metadata."""
skills = []
disabled = get_disabled_skill_names()
if not skills_dir.exists():
return skills
for skill_dir in skills_dir.iterdir():
if not skill_dir.is_dir():
for skills_dir in get_all_skills_dirs():
if not skills_dir.is_dir():
continue
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
continue
try:
content = skill_md.read_text(encoding="utf-8")
# Parse YAML frontmatter
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
import yaml
try:
metadata = yaml.safe_load(parts[1]) or {}
except Exception:
metadata = {}
name = metadata.get("name", skill_dir.name)
desc = metadata.get("description", "")
tags = metadata.get("tags", [])
skills.append(AgentSkill(
id=skill_dir.name,
name=name,
description=desc[:200] if desc else "",
tags=tags if isinstance(tags, list) else [],
))
except Exception:
continue
for skill_file in iter_skill_index_files(skills_dir, "SKILL.md"):
try:
raw = skill_file.read_text(encoding="utf-8")
frontmatter, _ = parse_frontmatter(raw)
except Exception:
continue
skill_name = frontmatter.get("name") or skill_file.parent.name
if str(skill_name) in disabled:
continue
if not skill_matches_platform(frontmatter):
continue
skills.append(AgentSkill(
id=str(skill_name),
name=str(frontmatter.get("name", skill_name)),
description=str(frontmatter.get("description", "")),
version=str(frontmatter.get("version", "1.0.0"))
))
return skills
def validate_agent_card(card: AgentCard) -> List[str]:
"""Validate agent card against A2A schema requirements.
def build_agent_card() -> AgentCard:
"""Build the agent card from current configuration and environment."""
config = load_config()
Returns list of validation errors (empty if valid).
"""
errors = []
# Identity
name = os.environ.get("HERMES_AGENT_NAME") or config.get("agent", {}).get("name") or "hermes"
description = os.environ.get("HERMES_AGENT_DESCRIPTION") or config.get("agent", {}).get("description") or "Sovereign AI agent"
if not card.name:
errors.append("name is required")
if not card.url:
errors.append("url is required")
# URL - try to determine from environment or config
port = os.environ.get("HERMES_WEB_PORT") or "9119"
host = os.environ.get("HERMES_WEB_HOST") or "localhost"
url = f"http://{host}:{port}"
# Validate MIME types
valid_modes = {"text/plain", "application/json", "image/png", "audio/wav"}
for mode in card.default_input_modes:
if mode not in valid_modes:
errors.append(f"invalid input mode: {mode}")
for mode in card.default_output_modes:
if mode not in valid_modes:
errors.append(f"invalid output mode: {mode}")
# Validate skills
for skill in card.skills:
if not skill.id:
errors.append(f"skill missing id: {skill.name}")
return errors
def build_agent_card(
name: Optional[str] = None,
description: Optional[str] = None,
url: Optional[str] = None,
version: Optional[str] = None,
skills: Optional[List[AgentSkill]] = None,
extra_skills: Optional[List[AgentSkill]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> AgentCard:
"""Build an A2A agent card from config and environment.
Priority: explicit params > env vars > config.yaml > defaults
"""
# Load config
config_model = ""
config_provider = ""
try:
from hermes_cli.config import load_config
cfg = load_config()
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
config_model = model_cfg.get("default", "")
config_provider = model_cfg.get("provider", "")
elif isinstance(model_cfg, str):
config_model = model_cfg
except Exception:
pass
# Resolve values with priority
agent_name = name or os.environ.get("HERMES_AGENT_NAME", "") or "hermes"
agent_desc = description or os.environ.get("HERMES_AGENT_DESCRIPTION", "") or "Sovereign AI agent"
agent_url = url or os.environ.get("HERMES_AGENT_URL", "") or f"http://localhost:{os.environ.get('HERMES_API_PORT', '8642')}"
agent_version = version or os.environ.get("HERMES_AGENT_VERSION", "") or "1.0.0"
# Load skills
if skills is not None:
agent_skills = skills
else:
from hermes_constants import get_hermes_home
skills_dir = get_hermes_home() / "skills"
agent_skills = _load_skills_from_directory(skills_dir)
# Add extra skills
if extra_skills:
existing_ids = {s.id for s in agent_skills}
for skill in extra_skills:
if skill.id not in existing_ids:
agent_skills.append(skill)
# Build metadata
card_metadata = {
"model": config_model or os.environ.get("HERMES_MODEL", ""),
"provider": config_provider or os.environ.get("HERMES_PROVIDER", ""),
"hostname": socket.gethostname(),
}
if metadata:
card_metadata.update(metadata)
# Build capabilities
# Capabilities
# In a real scenario, we'd check model metadata for vision/reasoning
capabilities = AgentCapabilities(
streaming=True,
push_notifications=False,
state_transition_history=True,
tools=True,
vision=False, # Default to false unless we can confirm
reasoning=False
)
# Skills
skills = _load_skills()
return AgentCard(
name=agent_name,
description=agent_desc,
url=agent_url,
version=agent_version,
name=name,
description=description,
url=url,
version=__version__,
capabilities=capabilities,
skills=agent_skills,
metadata=card_metadata,
skills=skills
)
def get_agent_card_json() -> str:
"""Get agent card as JSON string (for HTTP endpoint)."""
"""Return the agent card as a JSON string."""
try:
card = build_agent_card()
return card.to_json()
return json.dumps(asdict(card), indent=2)
except Exception as e:
# Graceful fallback — return minimal card so discovery doesn't break
fallback = AgentCard(
name="hermes",
description="Sovereign AI agent",
url=f"http://localhost:{os.environ.get('HERMES_API_PORT', '8642')}",
)
return fallback.to_json()
logger.error(f"Failed to build agent card: {e}")
# Minimal fallback card
fallback = {
"name": "hermes",
"description": "Sovereign AI agent (fallback)",
"version": __version__,
"error": str(e)
}
return json.dumps(fallback, indent=2)
def validate_agent_card(card_data: Dict[str, Any]) -> bool:
"""Check if the card data complies with the A2A schema."""
required = ["name", "description", "url", "version"]
return all(k in card_data for k in required)

View File

@@ -45,6 +45,7 @@ from hermes_cli.config import (
redact_key,
)
from gateway.status import get_running_pid, read_runtime_status
from agent.agent_card import get_agent_card_json
try:
from fastapi import FastAPI, HTTPException, Request
@@ -96,6 +97,9 @@ _PUBLIC_API_PATHS: frozenset = frozenset({
"/api/config/defaults",
"/api/config/schema",
"/api/model/info",
"/api/agent-card",
"/agent-card.json",
"/.well-known/agent-card.json",
})
@@ -360,6 +364,14 @@ def _probe_gateway_health() -> tuple[bool, dict | None]:
return False, None
@app.get("/api/agent-card")
@app.get("/agent-card.json")
@app.get("/.well-known/agent-card.json")
async def get_agent_card():
"""Return the A2A agent card for fleet discovery."""
return JSONResponse(content=json.loads(get_agent_card_json()))
@app.get("/api/status")
async def get_status():
current_ver, latest_ver = check_config_version()

View File

@@ -1,132 +0,0 @@
"""Tests for A2A agent card — Issue #819."""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from agent.agent_card import (
AgentSkill, AgentCapabilities, AgentCard,
validate_agent_card, build_agent_card, get_agent_card_json,
_load_skills_from_directory
)
class TestAgentSkill:
def test_creation(self):
skill = AgentSkill(id="code", name="Code", tags=["python"])
assert skill.id == "code"
assert "python" in skill.tags
class TestAgentCapabilities:
def test_defaults(self):
caps = AgentCapabilities()
assert caps.streaming == True
assert caps.push_notifications == False
class TestAgentCard:
def test_to_dict(self):
card = AgentCard(name="timmy", description="test", url="http://localhost:8642")
d = card.to_dict()
assert d["name"] == "timmy"
assert "defaultInputModes" in d
def test_to_json(self):
card = AgentCard(name="timmy", description="test", url="http://localhost:8642")
j = card.to_json()
parsed = json.loads(j)
assert parsed["name"] == "timmy"
class TestValidation:
def test_valid_card(self):
card = AgentCard(name="timmy", description="test", url="http://localhost:8642")
errors = validate_agent_card(card)
assert len(errors) == 0
def test_missing_name(self):
card = AgentCard(name="", description="test", url="http://localhost:8642")
errors = validate_agent_card(card)
assert any("name" in e for e in errors)
def test_missing_url(self):
card = AgentCard(name="timmy", description="test", url="")
errors = validate_agent_card(card)
assert any("url" in e for e in errors)
def test_invalid_input_mode(self):
card = AgentCard(
name="timmy", description="test", url="http://localhost:8642",
default_input_modes=["invalid/mode"]
)
errors = validate_agent_card(card)
assert any("invalid input mode" in e for e in errors)
def test_skill_missing_id(self):
card = AgentCard(
name="timmy", description="test", url="http://localhost:8642",
skills=[AgentSkill(id="", name="test")]
)
errors = validate_agent_card(card)
assert any("skill missing id" in e for e in errors)
class TestBuildAgentCard:
def test_builds_valid_card(self):
card = build_agent_card()
assert card.name
assert card.url
errors = validate_agent_card(card)
assert len(errors) == 0
def test_explicit_params_override(self):
card = build_agent_card(name="custom", description="custom desc")
assert card.name == "custom"
assert card.description == "custom desc"
def test_extra_skills(self):
extra = [AgentSkill(id="extra", name="Extra")]
card = build_agent_card(extra_skills=extra)
assert any(s.id == "extra" for s in card.skills)
class TestGetAgentCardJson:
def test_returns_valid_json(self):
j = get_agent_card_json()
parsed = json.loads(j)
assert "name" in parsed
def test_graceful_fallback(self):
# Even if something fails, should return valid JSON
j = get_agent_card_json()
assert j # Non-empty
class TestLoadSkills:
def test_empty_dir(self, tmp_path):
skills = _load_skills_from_directory(tmp_path / "nonexistent")
assert len(skills) == 0
def test_parses_skill_md(self, tmp_path):
skill_dir = tmp_path / "test-skill"
skill_dir.mkdir()
skill_md = skill_dir / "SKILL.md"
skill_md.write_text("""---
name: Test Skill
description: A test skill
tags:
- test
- example
---
Content here
""")
skills = _load_skills_from_directory(tmp_path)
assert len(skills) == 1
assert skills[0].name == "Test Skill"
assert "test" in skills[0].tags
if __name__ == "__main__":
import pytest
pytest.main([__file__, "-v"])