Implements Google Agent2Agent Protocol v1.0 with full fleet integration: ## Phase 1 - Agent Card & Discovery - Agent Card types with JSON serialization (camelCase, Part discrimination by key) - Card generation from YAML config (~/.hermes/agent_card.yaml) - Fleet registry with LocalFileRegistry + GiteaRegistry backends - Discovery by skill ID or tag ## Phase 2 - Task Delegation - Async A2A client with JSON-RPC SendMessage/GetTask/ListTasks/CancelTask - FastAPI server with pluggable task handlers (skill-routed) - CLI tool (bin/a2a_delegate.py) for fleet delegation - Broadcast to multiple agents in parallel ## Phase 3 - Security & Reliability - Bearer token + API key auth (configurable per agent) - Retry logic (max 3 retries, 30s timeout) - Audit logging for all inter-agent requests - Error handling per A2A spec (-32001 to -32009 codes) ## Test Coverage - 37 tests covering types, card building, registry, server integration - Auth (required + success), handler routing, error handling Files: - nexus/a2a/ (types.py, card.py, client.py, server.py, registry.py) - bin/a2a_delegate.py (CLI) - config/ (agent_card.example.yaml, fleet_agents.json) - docs/A2A_PROTOCOL.md - tests/test_a2a.py (37 tests, all passing)
168 lines
5.0 KiB
Python
168 lines
5.0 KiB
Python
"""
|
|
A2A Agent Card — generation, loading, and serving.
|
|
|
|
Reads from ~/.hermes/agent_card.yaml (or a passed path) and produces
|
|
a valid A2A AgentCard that can be served at /.well-known/agent-card.json.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import yaml
|
|
|
|
from nexus.a2a.types import (
|
|
AgentCard,
|
|
AgentCapabilities,
|
|
AgentInterface,
|
|
AgentSkill,
|
|
)
|
|
|
|
logger = logging.getLogger("nexus.a2a.card")
|
|
|
|
DEFAULT_CARD_PATH = Path.home() / ".hermes" / "agent_card.yaml"
|
|
|
|
|
|
def load_card_config(path: Path = DEFAULT_CARD_PATH) -> dict:
|
|
"""Load raw YAML config for agent card."""
|
|
if not path.exists():
|
|
raise FileNotFoundError(
|
|
f"Agent card config not found at {path}. "
|
|
f"Copy config/agent_card.example.yaml to {path} and customize it."
|
|
)
|
|
with open(path) as f:
|
|
return yaml.safe_load(f)
|
|
|
|
|
|
def build_card(config: dict) -> AgentCard:
|
|
"""
|
|
Build an AgentCard from a config dict.
|
|
|
|
Expected YAML structure (see config/agent_card.example.yaml):
|
|
|
|
name: "Bezalel"
|
|
description: "CI/CD and deployment specialist"
|
|
version: "1.0.0"
|
|
url: "https://bezalel.example.com"
|
|
protocol_binding: "HTTP+JSON"
|
|
skills:
|
|
- id: "ci-health"
|
|
name: "CI Health Check"
|
|
description: "Run CI pipeline health checks"
|
|
tags: ["ci", "devops"]
|
|
- id: "deploy"
|
|
name: "Deploy Service"
|
|
description: "Deploy a service to production"
|
|
tags: ["deploy", "ops"]
|
|
default_input_modes: ["text/plain"]
|
|
default_output_modes: ["text/plain"]
|
|
streaming: false
|
|
push_notifications: false
|
|
auth:
|
|
scheme: "bearer"
|
|
token_env: "A2A_AUTH_TOKEN"
|
|
"""
|
|
name = config["name"]
|
|
description = config["description"]
|
|
version = config.get("version", "1.0.0")
|
|
url = config.get("url", "http://localhost:8080")
|
|
binding = config.get("protocol_binding", "HTTP+JSON")
|
|
|
|
# Build skills
|
|
skills = []
|
|
for s in config.get("skills", []):
|
|
skills.append(
|
|
AgentSkill(
|
|
id=s["id"],
|
|
name=s.get("name", s["id"]),
|
|
description=s.get("description", ""),
|
|
tags=s.get("tags", []),
|
|
examples=s.get("examples", []),
|
|
input_modes=s.get("inputModes", config.get("default_input_modes", ["text/plain"])),
|
|
output_modes=s.get("outputModes", config.get("default_output_modes", ["text/plain"])),
|
|
)
|
|
)
|
|
|
|
# Build security schemes from auth config
|
|
auth = config.get("auth", {})
|
|
security_schemes = {}
|
|
security_requirements = []
|
|
|
|
if auth.get("scheme") == "bearer":
|
|
security_schemes["bearerAuth"] = {
|
|
"httpAuthSecurityScheme": {
|
|
"scheme": "Bearer",
|
|
"bearerFormat": auth.get("bearer_format", "token"),
|
|
}
|
|
}
|
|
security_requirements = [
|
|
{"schemes": {"bearerAuth": {"list": []}}}
|
|
]
|
|
elif auth.get("scheme") == "api_key":
|
|
key_name = auth.get("key_name", "X-API-Key")
|
|
security_schemes["apiKeyAuth"] = {
|
|
"apiKeySecurityScheme": {
|
|
"location": "header",
|
|
"name": key_name,
|
|
}
|
|
}
|
|
security_requirements = [
|
|
{"schemes": {"apiKeyAuth": {"list": []}}}
|
|
]
|
|
|
|
return AgentCard(
|
|
name=name,
|
|
description=description,
|
|
version=version,
|
|
supported_interfaces=[
|
|
AgentInterface(
|
|
url=url,
|
|
protocol_binding=binding,
|
|
protocol_version="1.0",
|
|
)
|
|
],
|
|
capabilities=AgentCapabilities(
|
|
streaming=config.get("streaming", False),
|
|
push_notifications=config.get("push_notifications", False),
|
|
),
|
|
default_input_modes=config.get("default_input_modes", ["text/plain"]),
|
|
default_output_modes=config.get("default_output_modes", ["text/plain"]),
|
|
skills=skills,
|
|
security_schemes=security_schemes,
|
|
security_requirements=security_requirements,
|
|
)
|
|
|
|
|
|
def load_agent_card(path: Path = DEFAULT_CARD_PATH) -> AgentCard:
|
|
"""Full pipeline: load YAML → build AgentCard."""
|
|
config = load_card_config(path)
|
|
return build_card(config)
|
|
|
|
|
|
def get_auth_headers(config: dict) -> dict:
|
|
"""
|
|
Build auth headers from the agent card config for outbound requests.
|
|
|
|
Returns dict of HTTP headers to include.
|
|
"""
|
|
auth = config.get("auth", {})
|
|
headers = {"A2A-Version": "1.0"}
|
|
|
|
scheme = auth.get("scheme")
|
|
if scheme == "bearer":
|
|
token_env = auth.get("token_env", "A2A_AUTH_TOKEN")
|
|
token = os.environ.get(token_env, "")
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
elif scheme == "api_key":
|
|
key_env = auth.get("key_env", "A2A_API_KEY")
|
|
key_name = auth.get("key_name", "X-API-Key")
|
|
key = os.environ.get(key_env, "")
|
|
if key:
|
|
headers[key_name] = key
|
|
|
|
return headers
|