Compare commits

..

2 Commits

Author SHA1 Message Date
Alexander Whitestone
985488bcbe feat: add A2A task delegation over mTLS (#804)
All checks were successful
Lint / lint (pull_request) Successful in 11s
2026-04-22 11:14:26 -04:00
Alexander Whitestone
524868d4f4 test: add red coverage for A2A task delegation (#804) 2026-04-22 11:09:18 -04:00
7 changed files with 684 additions and 512 deletions

View File

@@ -29,6 +29,8 @@ import logging
import os
import ssl
import threading
import time
import uuid
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any, Callable, Dict, Optional
@@ -441,3 +443,244 @@ class A2AMTLSClient:
def post(self, url: str, json: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
data = (__import__("json").dumps(json).encode() if json is not None else None)
return self._request("POST", url, data=data, **kwargs)
# ---------------------------------------------------------------------------
# Structured A2A task delegation over mTLS
# ---------------------------------------------------------------------------
_TERMINAL_TASK_STATES = {"completed", "failed", "canceled", "rejected"}
def _iso_now() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def _task_status(state: str, message: str) -> Dict[str, Any]:
return {
"state": state,
"message": message,
"timestamp": _iso_now(),
}
def _coerce_artifact(result: Any) -> Dict[str, Any]:
if isinstance(result, dict):
if "text" in result:
return result
if "artifact" in result and isinstance(result["artifact"], dict):
return result["artifact"]
return {"text": str(result)}
def _build_task_record(task_id: str, task: str, requester: Optional[str], metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return {
"taskId": task_id,
"task": task,
"requester": requester,
"metadata": metadata or {},
"artifacts": [],
"status": _task_status("submitted", "Task submitted"),
}
def _default_agent_card(host: str, port: int) -> Dict[str, Any]:
base_url = f"https://{host}:{port}"
try:
from agent.agent_card import build_agent_card
from dataclasses import asdict
card = asdict(build_agent_card())
except Exception as exc: # pragma: no cover - fallback only exercised when card build breaks
logger.warning("Falling back to minimal agent card: %s", exc)
card = {
"name": os.environ.get("HERMES_AGENT_NAME", "hermes"),
"description": "Hermes A2A task server",
"version": "unknown",
}
card["url"] = base_url
card["a2aTaskEndpoint"] = f"{base_url}/a2a/rpc"
return card
def _default_local_hermes_executor(task_payload: Dict[str, Any]) -> Dict[str, Any]:
task_text = str(task_payload.get("task", "")).strip()
if not task_text:
return {"text": ""}
from run_agent import AIAgent
agent = AIAgent(quiet_mode=True)
result = agent.chat(task_text)
return {
"text": result,
"metadata": {"executor": "local-hermes"},
}
class A2ATaskServer:
"""JSON-RPC A2A task server running over the routing mTLS server."""
def __init__(
self,
cert: str | Path,
key: str | Path,
ca: str | Path,
host: str = "127.0.0.1",
port: int = 9443,
executor: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
card_factory: Optional[Callable[[], Dict[str, Any]]] = None,
) -> None:
self.host = host
self.port = port
self._server = A2AMTLSServer(cert=cert, key=key, ca=ca, host=host, port=port)
self._executor = executor or _default_local_hermes_executor
self._card_factory = card_factory or (lambda: _default_agent_card(self.host, self.port))
self._tasks: Dict[str, Dict[str, Any]] = {}
self._lock = threading.Lock()
self._server.add_route("/.well-known/agent-card.json", self._handle_agent_card)
self._server.add_route("/agent-card.json", self._handle_agent_card)
self._server.add_route("/a2a/rpc", self._handle_rpc)
def __enter__(self) -> "A2ATaskServer":
self.start()
return self
def __exit__(self, *_: Any) -> None:
self.stop()
def start(self) -> None:
self._server.start()
def stop(self) -> None:
self._server.stop()
def _handle_agent_card(self, payload: Dict[str, Any], *, peer_cn: str | None = None) -> Dict[str, Any]:
return self._card_factory()
def _handle_rpc(self, payload: Dict[str, Any], *, peer_cn: str | None = None) -> Dict[str, Any]:
req_id = payload.get("id")
if payload.get("jsonrpc") != "2.0":
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32600, "message": "invalid jsonrpc version"}}
method = payload.get("method")
params = payload.get("params") or {}
try:
if method == "tasks/send":
result = self._rpc_send_task(params, peer_cn=peer_cn)
elif method == "tasks/get":
result = self._rpc_get_task(params)
else:
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": f"unknown method: {method}"}}
except Exception as exc:
logger.exception("A2A task RPC failed: %s", exc)
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32000, "message": str(exc)}}
return {"jsonrpc": "2.0", "id": req_id, "result": result}
def _rpc_send_task(self, params: Dict[str, Any], *, peer_cn: str | None = None) -> Dict[str, Any]:
task_text = str(params.get("task", "")).strip()
if not task_text:
raise ValueError("task is required")
task_id = params.get("taskId") or uuid.uuid4().hex
requester = params.get("requester") or peer_cn
metadata = dict(params.get("metadata") or {})
if peer_cn:
metadata.setdefault("peer_cn", peer_cn)
record = _build_task_record(task_id, task_text, requester, metadata)
with self._lock:
self._tasks[task_id] = record
worker = threading.Thread(target=self._run_task, args=(task_id,), daemon=True, name=f"a2a-task-{task_id[:8]}")
worker.start()
return self._copy_task(task_id)
def _rpc_get_task(self, params: Dict[str, Any]) -> Dict[str, Any]:
task_id = str(params.get("taskId", "")).strip()
if not task_id:
raise ValueError("taskId is required")
return self._copy_task(task_id)
def _copy_task(self, task_id: str) -> Dict[str, Any]:
with self._lock:
if task_id not in self._tasks:
raise KeyError(f"unknown taskId: {task_id}")
return json.loads(json.dumps(self._tasks[task_id]))
def _run_task(self, task_id: str) -> None:
with self._lock:
task = self._tasks[task_id]
task["status"] = _task_status("working", "Task is running")
task_payload = {
"taskId": task["taskId"],
"task": task["task"],
"requester": task.get("requester"),
"metadata": dict(task.get("metadata") or {}),
}
try:
result = self._executor(task_payload)
artifact = _coerce_artifact(result)
with self._lock:
task = self._tasks[task_id]
task["artifacts"] = [artifact]
task["status"] = _task_status("completed", "Task completed")
except Exception as exc:
with self._lock:
task = self._tasks[task_id]
task["status"] = _task_status("failed", f"Task failed: {exc}")
class A2ATaskClient(A2AMTLSClient):
"""Client helper for A2A JSON-RPC task send/get flows."""
def discover_card(self, base_url: str) -> Dict[str, Any]:
return self.get(f"{base_url.rstrip('/')}/.well-known/agent-card.json")
def _rpc_call(self, base_url: str, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
payload = {
"jsonrpc": "2.0",
"id": uuid.uuid4().hex,
"method": method,
"params": params,
}
response = self.post(f"{base_url.rstrip('/')}/a2a/rpc", json=payload)
if "error" in response:
error = response["error"]
raise RuntimeError(error.get("message") or str(error))
return response.get("result", {})
def send_task(
self,
base_url: str,
*,
task: str,
requester: str | None = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
return self._rpc_call(
base_url,
"tasks/send",
{
"task": task,
"requester": requester,
"metadata": metadata or {},
},
)
def get_task(self, base_url: str, task_id: str) -> Dict[str, Any]:
return self._rpc_call(base_url, "tasks/get", {"taskId": task_id})
def wait_for_task(
self,
base_url: str,
task_id: str,
*,
timeout: float = 30.0,
poll_interval: float = 0.5,
) -> Dict[str, Any]:
deadline = time.monotonic() + timeout
while True:
task = self.get_task(base_url, task_id)
state = str(((task.get("status") or {}).get("state") or "")).lower()
if state in _TERMINAL_TASK_STATES:
return task
if time.monotonic() >= deadline:
raise TimeoutError(f"Timed out waiting for task {task_id}")
time.sleep(poll_interval)

View File

@@ -1,70 +1,43 @@
from __future__ import annotations
"""
A2A agent card generation for fleet discovery.
Agent Card — A2A-compliant agent discovery.
Part of #843: fix: implement A2A agent card for fleet discovery (#819)
Refs #801.
Closes #802.
Provides metadata about the agent's identity, capabilities, and installed skills
for discovery by other agents in the fleet.
"""
import argparse
import json
import logging
import os
import socket
import sys
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, Iterable, List, Mapping, Sequence
from urllib.parse import urlparse, urlunparse
from pathlib import Path
from typing import Any, Dict, List, Optional
from hermes_cli import __version__
from hermes_cli.config import load_config
from hermes_cli.config import load_config, get_hermes_home
from agent.skill_utils import (
get_all_skills_dirs,
get_disabled_skill_names,
iter_skill_index_files,
parse_frontmatter,
skill_matches_platform,
get_all_skills_dirs,
get_disabled_skill_names,
skill_matches_platform
)
logger = logging.getLogger(__name__)
DEFAULT_DESCRIPTION = "Sovereign AI agent — orchestration, code, research"
DEFAULT_INPUT_MODES = ["text/plain", "application/json"]
DEFAULT_OUTPUT_MODES = ["text/plain", "application/json"]
_REQUIRED_CAPABILITY_FLAGS = (
"streaming",
"pushNotifications",
"stateTransitionHistory",
)
@dataclass
class AgentSkill:
id: str
name: str
description: str = ""
tags: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
data: Dict[str, Any] = {"id": self.id, "name": self.name}
if self.description:
data["description"] = self.description
if self.tags:
data["tags"] = self.tags
return data
version: str = "1.0.0"
@dataclass
class AgentCapabilities:
streaming: bool = True
pushNotifications: bool = False
stateTransitionHistory: bool = True
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
tools: bool = True
vision: bool = False
reasoning: bool = False
@dataclass
class AgentCard:
@@ -74,81 +47,14 @@ class AgentCard:
version: str = __version__
capabilities: AgentCapabilities = field(default_factory=AgentCapabilities)
skills: List[AgentSkill] = field(default_factory=list)
defaultInputModes: List[str] = field(default_factory=lambda: list(DEFAULT_INPUT_MODES))
defaultOutputModes: List[str] = field(default_factory=lambda: list(DEFAULT_OUTPUT_MODES))
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
data: Dict[str, Any] = {
"name": self.name,
"description": self.description,
"url": self.url,
"version": self.version,
"capabilities": self.capabilities.to_dict(),
"skills": [skill.to_dict() for skill in self.skills],
"defaultInputModes": list(self.defaultInputModes),
"defaultOutputModes": list(self.defaultOutputModes),
}
if self.metadata:
data["metadata"] = dict(self.metadata)
return data
def to_json(self, indent: int = 2) -> str:
return json.dumps(self.to_dict(), indent=indent)
def _env_or_empty(key: str) -> str:
return os.environ.get(key, "").strip()
def _as_agent_config(config: Mapping[str, Any] | None) -> Dict[str, Any]:
if not isinstance(config, Mapping):
return {}
agent_cfg = config.get("agent")
return dict(agent_cfg) if isinstance(agent_cfg, Mapping) else {}
def _as_a2a_config(config: Mapping[str, Any] | None) -> Dict[str, Any]:
if not isinstance(config, Mapping):
return {}
a2a_cfg = config.get("a2a")
return dict(a2a_cfg) if isinstance(a2a_cfg, Mapping) else {}
def _normalize_string_list(value: Any) -> List[str]:
if value is None:
return []
if isinstance(value, str):
parts = value.split(",")
elif isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, str)):
parts = list(value)
else:
parts = [value]
out: List[str] = []
seen = set()
for item in parts:
text = str(item).strip()
if not text or text in seen:
continue
seen.add(text)
out.append(text)
return out
def _normalize_skill_tags(frontmatter: Mapping[str, Any]) -> List[str]:
tags = _normalize_string_list(frontmatter.get("tags"))
category = str(frontmatter.get("category") or "").strip()
if category and category not in tags:
tags.append(category)
return tags
defaultInputModes: List[str] = field(default_factory=lambda: ["text/plain"])
defaultOutputModes: List[str] = field(default_factory=lambda: ["text/plain"])
def _load_skills() -> List[AgentSkill]:
"""Scan enabled skills and return A2A skill metadata."""
skills: List[AgentSkill] = []
"""Scan all enabled skills and return metadata."""
skills = []
disabled = get_disabled_skill_names()
seen_ids = set()
for skills_dir in get_all_skills_dirs():
if not skills_dir.is_dir():
continue
@@ -159,262 +65,71 @@ def _load_skills() -> List[AgentSkill]:
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
skill_id = str(frontmatter.get("name") or skill_file.parent.name).strip().lower().replace(" ", "-")
if skill_id in disabled or skill_id in seen_ids:
continue
seen_ids.add(skill_id)
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
display_name = str(frontmatter.get("title") or frontmatter.get("name") or skill_file.parent.name).strip()
description = str(frontmatter.get("description") or "").strip()
tags = _normalize_skill_tags(frontmatter)
skills.append(
AgentSkill(
id=skill_id,
name=display_name,
description=description,
tags=tags,
)
)
def build_agent_card() -> AgentCard:
"""Build the agent card from current configuration and environment."""
config = load_config()
# 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"
# 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}"
# Capabilities
# In a real scenario, we'd check model metadata for vision/reasoning
capabilities = AgentCapabilities(
streaming=True,
tools=True,
vision=False, # Default to false unless we can confirm
reasoning=False
)
# Skills
skills = _load_skills()
return AgentCard(
name=name,
description=description,
url=url,
version=__version__,
capabilities=capabilities,
skills=skills
)
return sorted(skills, key=lambda skill: skill.id)
def _get_agent_name(config: Mapping[str, Any] | None, override: str | None = None) -> str:
if override:
return override
env_name = _env_or_empty("HERMES_AGENT_NAME") or _env_or_empty("AGENT_NAME")
if env_name:
return env_name
agent_cfg = _as_agent_config(config)
if agent_cfg.get("name"):
return str(agent_cfg["name"]).strip()
def get_agent_card_json() -> str:
"""Return the agent card as a JSON string."""
try:
hostname = socket.gethostname().split(".", 1)[0].strip()
if hostname:
return hostname
except Exception:
pass
return "hermes"
def _get_description(config: Mapping[str, Any] | None, override: str | None = None) -> str:
if override:
return override
env_description = _env_or_empty("HERMES_AGENT_DESCRIPTION") or _env_or_empty("AGENT_DESCRIPTION")
if env_description:
return env_description
agent_cfg = _as_agent_config(config)
if agent_cfg.get("description"):
return str(agent_cfg["description"]).strip()
return DEFAULT_DESCRIPTION
def _normalize_a2a_url(url: str) -> str:
raw = (url or "").strip()
if not raw:
return ""
parsed = urlparse(raw if "://" in raw else f"https://{raw}")
scheme = parsed.scheme or "https"
netloc = parsed.netloc or parsed.path
path = parsed.path if parsed.netloc else ""
normalized_path = path.rstrip("/") if path not in ("", "/") else ""
if not normalized_path.endswith("/a2a"):
normalized_path = f"{normalized_path}/a2a" if normalized_path else "/a2a"
return urlunparse((scheme, netloc, normalized_path, "", "", ""))
def _get_agent_url(config: Mapping[str, Any] | None, override: str | None = None) -> str:
if override:
return _normalize_a2a_url(override)
agent_cfg = _as_agent_config(config)
a2a_cfg = _as_a2a_config(config)
explicit = (
_env_or_empty("HERMES_A2A_PUBLIC_URL")
or str(a2a_cfg.get("public_url") or "").strip()
or str(agent_cfg.get("a2a_public_url") or "").strip()
)
if explicit:
return _normalize_a2a_url(explicit)
host = (
_env_or_empty("HERMES_A2A_HOST")
or str(a2a_cfg.get("host") or "").strip()
or _env_or_empty("HERMES_WEB_HOST")
or str(agent_cfg.get("host") or "").strip()
or "localhost"
)
port = (
_env_or_empty("HERMES_A2A_PORT")
or str(a2a_cfg.get("port") or "").strip()
or _env_or_empty("HERMES_WEB_PORT")
or str(agent_cfg.get("port") or "").strip()
or "9119"
)
scheme = (
_env_or_empty("HERMES_A2A_SCHEME")
or str(a2a_cfg.get("scheme") or "").strip()
or ("https" if (_env_or_empty("HERMES_MTLS_CERT") or str(port) == "9443") else "http")
)
return _normalize_a2a_url(f"{scheme}://{host}:{port}")
def _merge_skills(base_skills: Iterable[AgentSkill], extra_skills: Iterable[AgentSkill] | None = None) -> List[AgentSkill]:
merged: Dict[str, AgentSkill] = {}
for skill in list(base_skills) + list(extra_skills or []):
if skill.id not in merged:
merged[skill.id] = skill
return [merged[key] for key in sorted(merged)]
def build_agent_card(
*,
name: str | None = None,
description: str | None = None,
url: str | None = None,
extra_skills: Iterable[AgentSkill] | None = None,
metadata: Mapping[str, Any] | None = None,
) -> AgentCard:
"""Build an A2A-compliant agent card from config, env, and installed skills."""
try:
config = load_config()
except Exception as exc:
logger.debug("Falling back to empty config while building agent card: %s", exc)
config = {}
card = AgentCard(
name=_get_agent_name(config, override=name),
description=_get_description(config, override=description),
url=_get_agent_url(config, override=url),
skills=_merge_skills(_load_skills(), extra_skills),
metadata=dict(metadata or {}),
)
return card
def validate_agent_card(card: AgentCard | Dict[str, Any]) -> List[str]:
"""Return a list of schema-validation errors for an agent card."""
data = card.to_dict() if isinstance(card, AgentCard) else dict(card)
errors: List[str] = []
for field_name in ("name", "description", "url", "version"):
value = data.get(field_name)
if not isinstance(value, str) or not value.strip():
errors.append(f"{field_name} must be a non-empty string")
url_value = str(data.get("url") or "")
parsed = urlparse(url_value)
if not parsed.scheme or not parsed.netloc:
errors.append("url must be an absolute http/https URL")
elif parsed.scheme not in {"http", "https"}:
errors.append("url must use http or https")
elif not parsed.path.rstrip("/").endswith("/a2a"):
errors.append("url must point to the /a2a endpoint")
capabilities = data.get("capabilities")
if not isinstance(capabilities, Mapping):
errors.append("capabilities must be an object")
else:
for capability_name in _REQUIRED_CAPABILITY_FLAGS:
if not isinstance(capabilities.get(capability_name), bool):
errors.append(f"capabilities.{capability_name} must be a boolean")
for field_name, required_modes in (
("defaultInputModes", DEFAULT_INPUT_MODES),
("defaultOutputModes", DEFAULT_OUTPUT_MODES),
):
modes = data.get(field_name)
if not isinstance(modes, list) or not modes:
errors.append(f"{field_name} must be a non-empty list of MIME types")
continue
for mode in modes:
if not isinstance(mode, str) or "/" not in mode:
errors.append(f"{field_name} entries must be MIME types")
for required_mode in required_modes:
if required_mode not in modes:
errors.append(f"{field_name} must include {required_mode}")
skills = data.get("skills")
if not isinstance(skills, list):
errors.append("skills must be a list")
else:
for index, skill in enumerate(skills):
if not isinstance(skill, Mapping):
errors.append(f"skills[{index}] must be an object")
continue
if not str(skill.get("id") or "").strip():
errors.append(f"skills[{index}] missing id")
if not str(skill.get("name") or "").strip():
errors.append(f"skills[{index}] missing name")
tags = skill.get("tags", [])
if tags is None:
tags = []
if not isinstance(tags, list):
errors.append(f"skills[{index}].tags must be a list")
else:
for tag in tags:
if not isinstance(tag, str) or not tag.strip():
errors.append(f"skills[{index}].tags entries must be non-empty strings")
metadata = data.get("metadata")
if metadata is not None and not isinstance(metadata, Mapping):
errors.append("metadata must be an object when present")
return errors
def get_agent_card_json(
*,
name: str | None = None,
description: str | None = None,
url: str | None = None,
metadata: Mapping[str, Any] | None = None,
indent: int = 2,
) -> str:
"""Return the local agent card as JSON, falling back to an error card on failure."""
try:
card = build_agent_card(name=name, description=description, url=url, metadata=metadata)
errors = validate_agent_card(card)
if errors:
raise ValueError("; ".join(errors))
return card.to_json(indent=indent)
except Exception as exc:
logger.error("Failed to build agent card: %s", exc)
card = build_agent_card()
return json.dumps(asdict(card), indent=2)
except Exception as e:
logger.error(f"Failed to build agent card: {e}")
# Minimal fallback card
fallback = {
"name": name or _env_or_empty("HERMES_AGENT_NAME") or "hermes",
"description": "Sovereign AI agent (agent card fallback)",
"url": url or "http://localhost:9119/a2a",
"name": "hermes",
"description": "Sovereign AI agent (fallback)",
"version": __version__,
"capabilities": AgentCapabilities().to_dict(),
"skills": [],
"defaultInputModes": list(DEFAULT_INPUT_MODES),
"defaultOutputModes": list(DEFAULT_OUTPUT_MODES),
"error": str(exc),
"error": str(e)
}
return json.dumps(fallback, indent=indent)
return json.dumps(fallback, indent=2)
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Generate an A2A-compliant Hermes agent card")
parser.add_argument("--name", help="Override the agent name")
parser.add_argument("--description", help="Override the agent description")
parser.add_argument("--url", help="Override the public A2A URL")
parser.add_argument("--validate", action="store_true", help="Validate before printing; exit 1 on schema errors")
args = parser.parse_args(list(argv) if argv is not None else None)
card = build_agent_card(name=args.name, description=args.description, url=args.url)
errors = validate_agent_card(card)
if args.validate and errors:
for error in errors:
print(error, file=sys.stderr)
return 1
print(card.to_json(indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
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)

132
hermes_cli/a2a_cmd.py Normal file
View File

@@ -0,0 +1,132 @@
"""CLI helpers for A2A task delegation."""
from __future__ import annotations
import json
import os
import re
import sys
import time
from pathlib import Path
from typing import Any
from agent.a2a_mtls import A2ATaskClient, A2ATaskServer
from hermes_cli.config import get_hermes_home
def _registry_path() -> Path:
return get_hermes_home() / "a2a_agents.json"
def _default_identity_paths() -> tuple[str, str, str]:
hermes_home = get_hermes_home()
agent_name = os.environ.get("HERMES_AGENT_NAME", "hermes").lower()
cert = os.environ.get(
"HERMES_A2A_CERT",
str(hermes_home / "pki" / "agents" / agent_name / f"{agent_name}.crt"),
)
key = os.environ.get(
"HERMES_A2A_KEY",
str(hermes_home / "pki" / "agents" / agent_name / f"{agent_name}.key"),
)
ca = os.environ.get(
"HERMES_A2A_CA",
str(hermes_home / "pki" / "ca" / "fleet-ca.crt"),
)
return cert, key, ca
def load_agent_registry(path: Path | None = None) -> dict[str, Any]:
registry_path = path or _registry_path()
if not registry_path.exists():
return {}
return json.loads(registry_path.read_text(encoding="utf-8"))
def resolve_agent_url(agent: str, *, registry_path: Path | None = None) -> str:
key = re.sub(r"[^A-Za-z0-9]+", "_", agent).upper()
env_value = os.getenv(f"HERMES_A2A_{key}_URL")
if env_value:
return env_value
registry = load_agent_registry(registry_path)
entry = registry.get(agent)
if isinstance(entry, str) and entry:
return entry
if isinstance(entry, dict):
url = entry.get("url") or entry.get("base_url") or entry.get("card_url")
if url:
return str(url)
if agent.startswith("https://") or agent.startswith("http://"):
return agent
raise SystemExit(f"Unknown A2A agent '{agent}'. Set HERMES_A2A_{key}_URL or add it to {_registry_path()}.")
def _print(data: dict[str, Any]) -> None:
print(json.dumps(data, indent=2, ensure_ascii=False))
def cmd_send(args) -> None:
base_url = args.url or resolve_agent_url(args.agent)
cert, key, ca = args.cert, args.key, args.ca
if not (cert and key and ca):
cert, key, ca = _default_identity_paths()
client = A2ATaskClient(cert=cert, key=key, ca=ca)
card = client.discover_card(base_url)
task = client.send_task(
base_url,
task=args.task,
requester=args.requester,
metadata={"agent": args.agent},
)
if args.wait:
task = client.wait_for_task(
base_url,
task["taskId"],
timeout=args.timeout,
poll_interval=args.poll_interval,
)
_print({
"agent": args.agent,
"url": base_url,
"card": card,
"task": task,
})
def cmd_status(args) -> None:
base_url = args.url or resolve_agent_url(args.agent)
cert, key, ca = args.cert, args.key, args.ca
if not (cert and key and ca):
cert, key, ca = _default_identity_paths()
client = A2ATaskClient(cert=cert, key=key, ca=ca)
task = client.get_task(base_url, args.task_id)
_print({"agent": args.agent, "url": base_url, "task": task})
def cmd_serve(args) -> None:
cert, key, ca = args.cert, args.key, args.ca
if not (cert and key and ca):
cert, key, ca = _default_identity_paths()
server = A2ATaskServer(cert=cert, key=key, ca=ca, host=args.host, port=args.port)
server.start()
print(f"A2A task server listening on https://{args.host}:{args.port}")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
server.stop()
def cmd_a2a(args) -> None:
command = getattr(args, "a2a_command", None) or "send"
if command == "send":
cmd_send(args)
return
if command == "status":
cmd_status(args)
return
if command == "serve":
cmd_serve(args)
return
raise SystemExit(f"Unknown a2a command: {command}")

View File

@@ -173,6 +173,13 @@ from hermes_constants import OPENROUTER_BASE_URL
logger = logging.getLogger(__name__)
def cmd_a2a(args):
"""Dispatch A2A CLI subcommands lazily to avoid heavy imports at startup."""
from hermes_cli.a2a_cmd import cmd_a2a as _cmd_a2a
return _cmd_a2a(args)
def _relative_time(ts) -> str:
"""Format a timestamp as relative time (e.g., '2h ago', 'yesterday')."""
if not ts:
@@ -4781,6 +4788,45 @@ For more help on a command:
gateway_parser.set_defaults(func=cmd_gateway)
# =========================================================================
# a2a command
# =========================================================================
a2a_parser = subparsers.add_parser(
"a2a",
help="A2A task delegation over mutual TLS",
description="Send, inspect, and serve structured A2A tasks between Hermes agents",
)
a2a_subparsers = a2a_parser.add_subparsers(dest="a2a_command")
a2a_send = a2a_subparsers.add_parser("send", help="Send an A2A task to another agent")
a2a_send.add_argument("--agent", required=True, help="Agent alias or URL (for example: allegro)")
a2a_send.add_argument("--task", required=True, help="Task text to delegate")
a2a_send.add_argument("--url", help="Explicit base URL for the remote agent")
a2a_send.add_argument("--requester", default=None, help="Requester label included in task metadata")
a2a_send.add_argument("--wait", action="store_true", help="Poll until the task reaches a terminal state")
a2a_send.add_argument("--timeout", type=float, default=30.0, help="Wait timeout in seconds (default: 30)")
a2a_send.add_argument("--poll-interval", type=float, default=0.5, help="Polling interval in seconds while waiting (default: 0.5)")
a2a_send.add_argument("--cert", default=None, help="Client certificate path (defaults from HERMES_A2A_CERT)")
a2a_send.add_argument("--key", default=None, help="Client private key path (defaults from HERMES_A2A_KEY)")
a2a_send.add_argument("--ca", default=None, help="Fleet CA certificate path (defaults from HERMES_A2A_CA)")
a2a_status = a2a_subparsers.add_parser("status", help="Fetch the current status of an A2A task")
a2a_status.add_argument("--agent", required=True, help="Agent alias or URL (for example: allegro)")
a2a_status.add_argument("--task-id", required=True, help="Task identifier returned by a2a send")
a2a_status.add_argument("--url", help="Explicit base URL for the remote agent")
a2a_status.add_argument("--cert", default=None, help="Client certificate path (defaults from HERMES_A2A_CERT)")
a2a_status.add_argument("--key", default=None, help="Client private key path (defaults from HERMES_A2A_KEY)")
a2a_status.add_argument("--ca", default=None, help="Fleet CA certificate path (defaults from HERMES_A2A_CA)")
a2a_serve = a2a_subparsers.add_parser("serve", help="Run the local A2A task server")
a2a_serve.add_argument("--host", default=os.environ.get("HERMES_A2A_HOST", "127.0.0.1"), help="Bind host (default: HERMES_A2A_HOST or 127.0.0.1)")
a2a_serve.add_argument("--port", type=int, default=int(os.environ.get("HERMES_A2A_PORT", "9443")), help="Bind port (default: HERMES_A2A_PORT or 9443)")
a2a_serve.add_argument("--cert", default=None, help="Server certificate path (defaults from HERMES_A2A_CERT)")
a2a_serve.add_argument("--key", default=None, help="Server private key path (defaults from HERMES_A2A_KEY)")
a2a_serve.add_argument("--ca", default=None, help="Fleet CA certificate path (defaults from HERMES_A2A_CA)")
a2a_parser.set_defaults(func=cmd_a2a)
# =========================================================================
# setup command
# =========================================================================

View File

@@ -572,3 +572,94 @@ class TestA2AMTLSServerAndClient:
assert not errors, f"Concurrent connection errors: {errors}"
assert len(results) == 3
@_requires_crypto
class TestA2ATaskServerAndClient:
"""Structured A2A task send/get flow over mTLS."""
@pytest.fixture(autouse=True)
def _pki(self, tmp_path):
ca_dir = tmp_path / "ca"
ca_dir.mkdir()
self.ca_crt, self.ca_key = _make_ca_keypair(ca_dir)
agent_dir = tmp_path / "agents"
agent_dir.mkdir()
self.srv_crt, self.srv_key = _make_agent_keypair(
agent_dir, "timmy", self.ca_crt, self.ca_key
)
self.cli_crt, self.cli_key = _make_agent_keypair(
agent_dir, "allegro", self.ca_crt, self.ca_key
)
@pytest.fixture()
def task_server(self):
from agent.a2a_mtls import A2ATaskServer
gate = threading.Event()
def analyze_executor(task: dict[str, object]) -> dict[str, object]:
gate.wait(timeout=2)
text = str(task.get("task", ""))
return {
"text": f"analysis:{text}",
"metadata": {"tool": "local-hermes-stub"},
}
port = _find_free_port()
server = A2ATaskServer(
cert=self.srv_crt,
key=self.srv_key,
ca=self.ca_crt,
host="127.0.0.1",
port=port,
executor=analyze_executor,
)
with server:
time.sleep(0.1)
yield server, port, gate
def test_task_send_get_and_completion_flow(self, task_server):
from agent.a2a_mtls import A2ATaskClient
server, port, gate = task_server
client = A2ATaskClient(cert=self.cli_crt, key=self.cli_key, ca=self.ca_crt)
base_url = f"https://127.0.0.1:{port}"
card = client.discover_card(base_url)
assert card["name"]
submitted = client.send_task(base_url, task="Analyze README.md", requester="timmy")
assert submitted["status"]["state"] in {"submitted", "working"}
in_flight = client.get_task(base_url, submitted["taskId"])
assert in_flight["status"]["state"] in {"submitted", "working"}
gate.set()
completed = client.wait_for_task(base_url, submitted["taskId"], timeout=5.0, poll_interval=0.05)
assert completed["status"]["state"] == "completed"
assert completed["artifacts"][0]["text"] == "analysis:Analyze README.md"
def test_failed_executor_marks_task_failed(self):
from agent.a2a_mtls import A2ATaskClient, A2ATaskServer
def failing_executor(task: dict[str, object]) -> dict[str, object]:
raise RuntimeError("boom")
port = _find_free_port()
server = A2ATaskServer(
cert=self.srv_crt,
key=self.srv_key,
ca=self.ca_crt,
host="127.0.0.1",
port=port,
executor=failing_executor,
)
with server:
time.sleep(0.1)
client = A2ATaskClient(cert=self.cli_crt, key=self.cli_key, ca=self.ca_crt)
base_url = f"https://127.0.0.1:{port}"
submitted = client.send_task(base_url, task="explode", requester="timmy")
failed = client.wait_for_task(base_url, submitted["taskId"], timeout=5.0, poll_interval=0.05)
assert failed["status"]["state"] == "failed"
assert "boom" in failed["status"]["message"]

View File

@@ -1,150 +0,0 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from agent import agent_card as mod
DEFAULT_DESCRIPTION = "Sovereign AI agent — orchestration, code, research"
def _set_base_context(monkeypatch, *, name: str = "Timmy", description: str = DEFAULT_DESCRIPTION, url: str = "https://timmy.local:9443/a2a", skills=None):
monkeypatch.setattr(mod, "load_config", lambda: {"agent": {"name": name, "description": description}})
monkeypatch.setattr(
mod,
"_load_skills",
lambda: list(
skills
if skills is not None
else [
mod.AgentSkill(
id="code",
name="Code Implementation",
description="Implement and patch code",
tags=["python", "gitea"],
)
]
),
)
monkeypatch.setenv("HERMES_A2A_PUBLIC_URL", url)
monkeypatch.delenv("HERMES_AGENT_NAME", raising=False)
monkeypatch.delenv("AGENT_NAME", raising=False)
monkeypatch.delenv("HERMES_AGENT_DESCRIPTION", raising=False)
monkeypatch.delenv("AGENT_DESCRIPTION", raising=False)
def test_build_agent_card_matches_issue_802_schema(monkeypatch):
_set_base_context(monkeypatch)
card = mod.build_agent_card()
payload = card.to_dict()
assert payload["name"] == "Timmy"
assert payload["description"] == DEFAULT_DESCRIPTION
assert payload["url"] == "https://timmy.local:9443/a2a"
assert payload["capabilities"] == {
"streaming": True,
"pushNotifications": False,
"stateTransitionHistory": True,
}
assert payload["defaultInputModes"] == ["text/plain", "application/json"]
assert payload["defaultOutputModes"] == ["text/plain", "application/json"]
assert payload["skills"][0]["tags"] == ["python", "gitea"]
assert mod.validate_agent_card(payload) == []
@pytest.mark.parametrize(
("name", "url"),
[
("Timmy", "https://timmy.local:9443/a2a"),
("Allegro", "https://allegro.local:9443/a2a"),
("Ezra", "https://ezra.local:9443/a2a"),
],
)
def test_build_agent_card_supports_fleet_members(monkeypatch, name, url):
_set_base_context(monkeypatch, name=name, url=url, skills=[])
payload = mod.build_agent_card().to_dict()
assert payload["name"] == name
assert payload["url"] == url
assert mod.validate_agent_card(payload) == []
def test_load_skills_collects_tags_and_category(monkeypatch, tmp_path):
skill_root = tmp_path / "skills"
skill_dir = skill_root / "code-implementation"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"""---
name: Code Implementation
description: Implement and patch code
tags: [python, gitea]
category: discovery
---
# Code Implementation
""",
encoding="utf-8",
)
monkeypatch.setattr(mod, "get_all_skills_dirs", lambda: [skill_root])
monkeypatch.setattr(mod, "get_disabled_skill_names", lambda: set())
monkeypatch.setattr(mod, "skill_matches_platform", lambda _frontmatter: True)
skills = mod._load_skills()
assert len(skills) == 1
assert skills[0].id == "code-implementation"
assert skills[0].name == "Code Implementation"
assert skills[0].description == "Implement and patch code"
assert skills[0].tags == ["python", "gitea", "discovery"]
def test_validate_agent_card_reports_schema_errors():
errors = mod.validate_agent_card(
{
"name": "",
"description": "",
"url": "timmy.local",
"version": "",
"capabilities": {"streaming": True},
"skills": [{"id": "", "name": "", "tags": "python"}],
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["plain"],
"metadata": [],
}
)
assert any("name must be a non-empty string" in error for error in errors)
assert any("url must be an absolute http/https URL" in error for error in errors)
assert any("capabilities.pushNotifications" in error for error in errors)
assert any("skills[0] missing id" in error for error in errors)
assert any("skills[0].tags must be a list" in error for error in errors)
assert any("defaultInputModes must include application/json" in error for error in errors)
assert any("defaultOutputModes entries must be MIME types" in error for error in errors)
assert any("metadata must be an object" in error for error in errors)
def test_get_agent_card_json_emits_valid_json(monkeypatch):
_set_base_context(monkeypatch)
payload = json.loads(mod.get_agent_card_json())
assert payload["name"] == "Timmy"
assert mod.validate_agent_card(payload) == []
def test_main_validate_prints_card(monkeypatch, capsys):
_set_base_context(monkeypatch)
exit_code = mod.main(["--validate"])
captured = capsys.readouterr()
assert exit_code == 0
payload = json.loads(captured.out)
assert payload["url"] == "https://timmy.local:9443/a2a"
assert captured.err == ""

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
from unittest.mock import patch
import pytest
def test_cmd_send_uses_registry_and_waits_for_terminal_task(tmp_path, monkeypatch, capsys):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "a2a_agents.json").write_text(
json.dumps({"allegro": {"url": "https://127.0.0.1:9443"}}),
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from hermes_cli.a2a_cmd import cmd_a2a
class FakeClient:
def __init__(self, **kwargs):
self.kwargs = kwargs
def discover_card(self, base_url: str):
assert base_url == "https://127.0.0.1:9443"
return {"name": "allegro", "url": base_url}
def send_task(self, base_url: str, *, task: str, requester: str | None = None, metadata=None):
assert task == "analyze README"
return {"taskId": "task-123", "status": {"state": "submitted"}}
def wait_for_task(self, base_url: str, task_id: str, *, timeout: float, poll_interval: float):
assert task_id == "task-123"
return {
"taskId": task_id,
"status": {"state": "completed"},
"artifacts": [{"text": "README looks healthy"}],
}
args = argparse.Namespace(
a2a_command="send",
agent="allegro",
task="analyze README",
url=None,
wait=True,
timeout=5.0,
poll_interval=0.01,
requester="timmy",
cert="cert.pem",
key="key.pem",
ca="ca.pem",
)
with patch("hermes_cli.a2a_cmd.A2ATaskClient", FakeClient):
cmd_a2a(args)
result = json.loads(capsys.readouterr().out)
assert result["agent"] == "allegro"
assert result["card"]["name"] == "allegro"
assert result["task"]["status"]["state"] == "completed"
assert result["task"]["artifacts"][0]["text"] == "README looks healthy"
def test_resolve_agent_url_supports_env_override(monkeypatch):
monkeypatch.setenv("HERMES_A2A_ALLEGRO_URL", "https://fleet-allegro:9443")
from hermes_cli.a2a_cmd import resolve_agent_url
assert resolve_agent_url("allegro") == "https://fleet-allegro:9443"
def test_cmd_send_requires_known_agent(tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from hermes_cli.a2a_cmd import cmd_a2a
args = argparse.Namespace(
a2a_command="send",
agent="unknown",
task="do work",
url=None,
wait=False,
timeout=5.0,
poll_interval=0.05,
requester=None,
cert="cert.pem",
key="key.pem",
ca="ca.pem",
)
with pytest.raises(SystemExit):
cmd_a2a(args)