Compare commits
3 Commits
fix/format
...
feat/802-a
| Author | SHA1 | Date | |
|---|---|---|---|
| dddbbc9705 | |||
| 5ad181e071 | |||
| 995c9ac8f2 |
358
agent/agent_card.py
Normal file
358
agent/agent_card.py
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
"""
|
||||||
|
A2A Agent Card — publish capabilities for fleet discovery.
|
||||||
|
|
||||||
|
Generates an A2A-compliant agent card from hermes config + installed skills.
|
||||||
|
Serves at GET /.well-known/agent-card.json via the API server adapter.
|
||||||
|
|
||||||
|
Spec: https://github.com/google/A2A (Agent2Agent Protocol v1.0)
|
||||||
|
Issue: #802
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
d = {"id": self.id, "name": self.name}
|
||||||
|
if self.description:
|
||||||
|
d["description"] = self.description
|
||||||
|
if self.tags:
|
||||||
|
d["tags"] = self.tags
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentCapabilities:
|
||||||
|
"""What the agent supports."""
|
||||||
|
streaming: bool = True
|
||||||
|
pushNotifications: bool = False
|
||||||
|
stateTransitionHistory: bool = True
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentCard:
|
||||||
|
"""
|
||||||
|
A2A-compliant agent card.
|
||||||
|
|
||||||
|
Published at /.well-known/agent-card.json for fleet discovery.
|
||||||
|
"""
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
url: str = ""
|
||||||
|
version: str = "1.0.0"
|
||||||
|
capabilities: AgentCapabilities = field(default_factory=AgentCapabilities)
|
||||||
|
skills: List[AgentSkill] = field(default_factory=list)
|
||||||
|
defaultInputModes: List[str] = field(default_factory=lambda: ["text/plain", "application/json"])
|
||||||
|
defaultOutputModes: List[str] = field(default_factory=lambda: ["text/plain", "application/json"])
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
d = {
|
||||||
|
"name": self.name,
|
||||||
|
"url": self.url,
|
||||||
|
"version": self.version,
|
||||||
|
"capabilities": self.capabilities.to_dict(),
|
||||||
|
"defaultInputModes": self.defaultInputModes,
|
||||||
|
"defaultOutputModes": self.defaultOutputModes,
|
||||||
|
}
|
||||||
|
if self.description:
|
||||||
|
d["description"] = self.description
|
||||||
|
if self.skills:
|
||||||
|
d["skills"] = [s.to_dict() for s in self.skills]
|
||||||
|
if self.metadata:
|
||||||
|
d["metadata"] = self.metadata
|
||||||
|
return d
|
||||||
|
|
||||||
|
def to_json(self, indent: int = 2) -> str:
|
||||||
|
return json.dumps(self.to_dict(), indent=indent)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "AgentCard":
|
||||||
|
caps = data.get("capabilities", {})
|
||||||
|
capabilities = AgentCapabilities(
|
||||||
|
streaming=caps.get("streaming", True),
|
||||||
|
pushNotifications=caps.get("pushNotifications", False),
|
||||||
|
stateTransitionHistory=caps.get("stateTransitionHistory", True),
|
||||||
|
)
|
||||||
|
skills = [
|
||||||
|
AgentSkill(
|
||||||
|
id=s.get("id", ""),
|
||||||
|
name=s.get("name", ""),
|
||||||
|
description=s.get("description", ""),
|
||||||
|
tags=s.get("tags", []),
|
||||||
|
)
|
||||||
|
for s in data.get("skills", [])
|
||||||
|
]
|
||||||
|
return cls(
|
||||||
|
name=data.get("name", "hermes-agent"),
|
||||||
|
description=data.get("description", ""),
|
||||||
|
url=data.get("url", ""),
|
||||||
|
version=data.get("version", "1.0.0"),
|
||||||
|
capabilities=capabilities,
|
||||||
|
skills=skills,
|
||||||
|
defaultInputModes=data.get("defaultInputModes", ["text/plain", "application/json"]),
|
||||||
|
defaultOutputModes=data.get("defaultOutputModes", ["text/plain", "application/json"]),
|
||||||
|
metadata=data.get("metadata", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, text: str) -> "AgentCard":
|
||||||
|
return cls.from_dict(json.loads(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_skills_from_directory(skills_dir: Optional[str] = None) -> List[AgentSkill]:
|
||||||
|
"""
|
||||||
|
Scan ~/.hermes/skills/ for installed skills and extract metadata.
|
||||||
|
|
||||||
|
Each skill is a directory with a SKILL.md that may have YAML frontmatter:
|
||||||
|
---
|
||||||
|
name: my-skill
|
||||||
|
description: Does X
|
||||||
|
tags: [foo, bar]
|
||||||
|
---
|
||||||
|
"""
|
||||||
|
if skills_dir is None:
|
||||||
|
skills_dir = os.path.join(
|
||||||
|
os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")),
|
||||||
|
"skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
skills_path = Path(skills_dir)
|
||||||
|
if not skills_path.is_dir():
|
||||||
|
return []
|
||||||
|
|
||||||
|
skills = []
|
||||||
|
seen_ids = set()
|
||||||
|
|
||||||
|
for skill_dir in sorted(skills_path.iterdir()):
|
||||||
|
if not skill_dir.is_dir():
|
||||||
|
continue
|
||||||
|
skill_md = skill_dir / "SKILL.md"
|
||||||
|
if not skill_md.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = skill_md.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse YAML frontmatter
|
||||||
|
name = skill_dir.name
|
||||||
|
description = ""
|
||||||
|
tags: List[str] = []
|
||||||
|
|
||||||
|
if text.startswith("---"):
|
||||||
|
parts = text.split("---", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
fm_text = parts[1]
|
||||||
|
# Simple key: value parsing (avoids yaml dependency)
|
||||||
|
for line in fm_text.strip().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("name:"):
|
||||||
|
name = line.split(":", 1)[1].strip().strip(""'")
|
||||||
|
elif line.startswith("description:"):
|
||||||
|
description = line.split(":", 1)[1].strip().strip(""'")
|
||||||
|
elif line.startswith("tags:"):
|
||||||
|
tag_str = line.split(":", 1)[1].strip()
|
||||||
|
# Parse [a, b, c] or "a, b, c"
|
||||||
|
if tag_str.startswith("["):
|
||||||
|
tags = [t.strip().strip(""'") for t in tag_str[1:-1].split(",") if t.strip()]
|
||||||
|
else:
|
||||||
|
tags = [t.strip() for t in tag_str.split(",") if t.strip()]
|
||||||
|
|
||||||
|
skill_id = skill_dir.name.lower().replace(" ", "-")
|
||||||
|
if skill_id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(skill_id)
|
||||||
|
|
||||||
|
skills.append(AgentSkill(
|
||||||
|
id=skill_id,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
tags=tags,
|
||||||
|
))
|
||||||
|
|
||||||
|
return skills
|
||||||
|
|
||||||
|
|
||||||
|
def _get_agent_name() -> str:
|
||||||
|
"""Derive agent name from config or hostname."""
|
||||||
|
# Check environment
|
||||||
|
name = os.environ.get("AGENT_NAME", "")
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
|
||||||
|
# Try config.yaml
|
||||||
|
hermes_home = os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))
|
||||||
|
config_path = Path(hermes_home) / "config.yaml"
|
||||||
|
if config_path.exists():
|
||||||
|
try:
|
||||||
|
text = config_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("agent_name:") or line.startswith("name:"):
|
||||||
|
val = line.split(":", 1)[1].strip().strip(""'")
|
||||||
|
if val:
|
||||||
|
return val
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback to hostname
|
||||||
|
try:
|
||||||
|
return socket.gethostname().split(".")[0]
|
||||||
|
except Exception:
|
||||||
|
return "hermes-agent"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_agent_url(host: str = "", port: int = 0) -> str:
|
||||||
|
"""Build the agent URL for the card."""
|
||||||
|
if not host:
|
||||||
|
host = os.environ.get("API_SERVER_HOST", os.environ.get("AGENT_HOST", "127.0.0.1"))
|
||||||
|
if not port:
|
||||||
|
port = int(os.environ.get("API_SERVER_PORT", os.environ.get("AGENT_PORT", "8642")))
|
||||||
|
scheme = "https" if os.environ.get("AGENT_TLS", "").lower() in ("1", "true") else "http"
|
||||||
|
return f"{scheme}://{host}:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_description() -> str:
|
||||||
|
"""Get agent description from config or default."""
|
||||||
|
return os.environ.get(
|
||||||
|
"AGENT_DESCRIPTION",
|
||||||
|
"Sovereign AI agent — local-first, chain-anchored"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_model_info() -> dict:
|
||||||
|
"""Get current model info for metadata."""
|
||||||
|
hermes_home = os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))
|
||||||
|
config_path = Path(hermes_home) / "config.yaml"
|
||||||
|
model = ""
|
||||||
|
provider = ""
|
||||||
|
|
||||||
|
if config_path.exists():
|
||||||
|
try:
|
||||||
|
text = config_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("model:"):
|
||||||
|
model = line.split(":", 1)[1].strip().strip(""'")
|
||||||
|
elif line.startswith("provider:"):
|
||||||
|
provider = line.split(":", 1)[1].strip().strip(""'")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"model": os.environ.get("HERMES_MODEL", model),
|
||||||
|
"provider": os.environ.get("HERMES_PROVIDER", provider),
|
||||||
|
"hostname": platform.node(),
|
||||||
|
"python": platform.python_version(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_agent_card(
|
||||||
|
name: str = "",
|
||||||
|
description: str = "",
|
||||||
|
url: str = "",
|
||||||
|
skills_dir: str = "",
|
||||||
|
host: str = "",
|
||||||
|
port: int = 0,
|
||||||
|
extra_skills: Optional[List[AgentSkill]] = None,
|
||||||
|
) -> AgentCard:
|
||||||
|
"""
|
||||||
|
Build an A2A agent card from local config + installed skills.
|
||||||
|
|
||||||
|
All parameters are optional — sensible defaults are derived from
|
||||||
|
environment variables, config.yaml, and the filesystem.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
card = build_agent_card()
|
||||||
|
print(card.to_json())
|
||||||
|
|
||||||
|
# Or from a config dict:
|
||||||
|
card = AgentCard.from_dict({"name": "timmy", ...})
|
||||||
|
"""
|
||||||
|
agent_name = name or _get_agent_name()
|
||||||
|
agent_desc = description or _get_description()
|
||||||
|
agent_url = url or _get_agent_url(host, port)
|
||||||
|
|
||||||
|
# Load skills
|
||||||
|
skills = _load_skills_from_directory(skills_dir or None)
|
||||||
|
if extra_skills:
|
||||||
|
skills.extend(extra_skills)
|
||||||
|
|
||||||
|
# Deduplicate by id
|
||||||
|
seen = set()
|
||||||
|
unique_skills = []
|
||||||
|
for s in skills:
|
||||||
|
if s.id not in seen:
|
||||||
|
seen.add(s.id)
|
||||||
|
unique_skills.append(s)
|
||||||
|
|
||||||
|
# Capabilities
|
||||||
|
streaming = os.environ.get("AGENT_STREAMING", "true").lower() in ("1", "true")
|
||||||
|
push = os.environ.get("AGENT_PUSH_NOTIFICATIONS", "false").lower() in ("1", "true")
|
||||||
|
history = os.environ.get("AGENT_STATE_HISTORY", "true").lower() in ("1", "true")
|
||||||
|
|
||||||
|
card = AgentCard(
|
||||||
|
name=agent_name,
|
||||||
|
description=agent_desc,
|
||||||
|
url=agent_url,
|
||||||
|
version=os.environ.get("AGENT_CARD_VERSION", "1.0.0"),
|
||||||
|
capabilities=AgentCapabilities(
|
||||||
|
streaming=streaming,
|
||||||
|
pushNotifications=push,
|
||||||
|
stateTransitionHistory=history,
|
||||||
|
),
|
||||||
|
skills=unique_skills,
|
||||||
|
metadata=_get_model_info(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
def validate_agent_card(card: AgentCard) -> List[str]:
|
||||||
|
"""
|
||||||
|
Validate an agent card against A2A schema requirements.
|
||||||
|
|
||||||
|
Returns a list of validation errors (empty = valid).
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if not card.name:
|
||||||
|
errors.append("name is required")
|
||||||
|
if not card.url:
|
||||||
|
errors.append("url is required")
|
||||||
|
|
||||||
|
for s in card.skills:
|
||||||
|
if not s.id:
|
||||||
|
errors.append(f"skill missing id: {s.name}")
|
||||||
|
if not s.name:
|
||||||
|
errors.append(f"skill missing name: {s.id}")
|
||||||
|
|
||||||
|
for mode in card.defaultInputModes + card.defaultOutputModes:
|
||||||
|
if "/" not in mode:
|
||||||
|
errors.append(f"invalid MIME type: {mode}")
|
||||||
|
|
||||||
|
return errors
|
||||||
@@ -570,6 +570,33 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||||||
"""GET /health — simple health check."""
|
"""GET /health — simple health check."""
|
||||||
return web.json_response({"status": "ok", "platform": "hermes-agent"})
|
return web.json_response({"status": "ok", "platform": "hermes-agent"})
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_agent_card(self, request: "web.Request") -> "web.Response":
|
||||||
|
"""GET /.well-known/agent-card.json — A2A agent card for fleet discovery."""
|
||||||
|
try:
|
||||||
|
from agent.agent_card import build_agent_card, validate_agent_card
|
||||||
|
card = build_agent_card(host=request.host.split(":")[0] if request.host else "")
|
||||||
|
errors = validate_agent_card(card)
|
||||||
|
if errors:
|
||||||
|
logger.warning("[agent-card] Validation errors: %s", errors)
|
||||||
|
return web.json_response(
|
||||||
|
card.to_dict(),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[agent-card] Failed to build card: %s", e)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"name": os.environ.get("AGENT_NAME", "hermes-agent"),
|
||||||
|
"url": "",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"capabilities": {"streaming": True, "pushNotifications": False, "stateTransitionHistory": True},
|
||||||
|
"defaultInputModes": ["text/plain"],
|
||||||
|
"defaultOutputModes": ["text/plain"],
|
||||||
|
},
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_health_detailed(self, request: "web.Request") -> "web.Response":
|
async def _handle_health_detailed(self, request: "web.Request") -> "web.Response":
|
||||||
"""GET /health/detailed — rich status for cross-container dashboard probing.
|
"""GET /health/detailed — rich status for cross-container dashboard probing.
|
||||||
|
|
||||||
@@ -2287,6 +2314,9 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||||||
async def _sweep_orphaned_runs(self) -> None:
|
async def _sweep_orphaned_runs(self) -> None:
|
||||||
"""Periodically clean up run streams that were never consumed."""
|
"""Periodically clean up run streams that were never consumed."""
|
||||||
while True:
|
while True:
|
||||||
|
# A2A agent card — publish capabilities for fleet discovery (#802)
|
||||||
|
self._app.router.add_get("/.well-known/agent-card.json", self._handle_agent_card)
|
||||||
|
self._app.router.add_get("/agent-card.json", self._handle_agent_card)
|
||||||
await asyncio.sleep(60)
|
await asyncio.sleep(60)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
stale = [
|
stale = [
|
||||||
|
|||||||
209
tests/test_agent_card.py
Normal file
209
tests/test_agent_card.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""Tests for agent/agent_card.py — A2A agent card generator (#802)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent.agent_card import (
|
||||||
|
AgentCard,
|
||||||
|
AgentCapabilities,
|
||||||
|
AgentSkill,
|
||||||
|
build_agent_card,
|
||||||
|
validate_agent_card,
|
||||||
|
_load_skills_from_directory,
|
||||||
|
_get_agent_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentSkill:
|
||||||
|
def test_to_dict(self):
|
||||||
|
s = AgentSkill(id="code", name="Code", description="Write code", tags=["python"])
|
||||||
|
d = s.to_dict()
|
||||||
|
assert d["id"] == "code"
|
||||||
|
assert d["name"] == "Code"
|
||||||
|
assert d["description"] == "Write code"
|
||||||
|
assert d["tags"] == ["python"]
|
||||||
|
|
||||||
|
def test_to_dict_minimal(self):
|
||||||
|
s = AgentSkill(id="x", name="X")
|
||||||
|
d = s.to_dict()
|
||||||
|
assert "description" not in d
|
||||||
|
assert "tags" not in d
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentCapabilities:
|
||||||
|
def test_defaults(self):
|
||||||
|
caps = AgentCapabilities()
|
||||||
|
assert caps.streaming is True
|
||||||
|
assert caps.pushNotifications is False
|
||||||
|
assert caps.stateTransitionHistory is True
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
d = AgentCapabilities().to_dict()
|
||||||
|
assert d == {"streaming": True, "pushNotifications": False, "stateTransitionHistory": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentCard:
|
||||||
|
def test_to_dict(self):
|
||||||
|
card = AgentCard(
|
||||||
|
name="timmy",
|
||||||
|
description="Orchestrator",
|
||||||
|
url="http://localhost:8642",
|
||||||
|
skills=[AgentSkill(id="code", name="Code")],
|
||||||
|
)
|
||||||
|
d = card.to_dict()
|
||||||
|
assert d["name"] == "timmy"
|
||||||
|
assert d["description"] == "Orchestrator"
|
||||||
|
assert len(d["skills"]) == 1
|
||||||
|
assert d["defaultInputModes"] == ["text/plain", "application/json"]
|
||||||
|
|
||||||
|
def test_roundtrip(self):
|
||||||
|
card = AgentCard(
|
||||||
|
name="test-agent",
|
||||||
|
url="http://localhost:9999",
|
||||||
|
skills=[
|
||||||
|
AgentSkill(id="s1", name="Skill 1", tags=["a"]),
|
||||||
|
AgentSkill(id="s2", name="Skill 2"),
|
||||||
|
],
|
||||||
|
metadata={"model": "llama-3"},
|
||||||
|
)
|
||||||
|
json_str = card.to_json()
|
||||||
|
restored = AgentCard.from_json(json_str)
|
||||||
|
assert restored.name == "test-agent"
|
||||||
|
assert len(restored.skills) == 2
|
||||||
|
assert restored.skills[0].id == "s1"
|
||||||
|
assert restored.metadata["model"] == "llama-3"
|
||||||
|
|
||||||
|
def test_from_dict_defaults(self):
|
||||||
|
card = AgentCard.from_dict({})
|
||||||
|
assert card.name == "hermes-agent"
|
||||||
|
assert card.version == "1.0.0"
|
||||||
|
assert card.capabilities.streaming is True
|
||||||
|
|
||||||
|
def test_to_json_is_valid(self):
|
||||||
|
card = AgentCard(name="x", url="http://x")
|
||||||
|
parsed = json.loads(card.to_json())
|
||||||
|
assert parsed["name"] == "x"
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateAgentCard:
|
||||||
|
def test_valid_card(self):
|
||||||
|
card = AgentCard(name="agent", url="http://localhost:8080")
|
||||||
|
assert validate_agent_card(card) == []
|
||||||
|
|
||||||
|
def test_missing_name(self):
|
||||||
|
card = AgentCard(name="", url="http://x")
|
||||||
|
errors = validate_agent_card(card)
|
||||||
|
assert any("name" in e for e in errors)
|
||||||
|
|
||||||
|
def test_missing_url(self):
|
||||||
|
card = AgentCard(name="x", url="")
|
||||||
|
errors = validate_agent_card(card)
|
||||||
|
assert any("url" in e for e in errors)
|
||||||
|
|
||||||
|
def test_invalid_mime(self):
|
||||||
|
card = AgentCard(name="x", url="http://x", defaultInputModes=["plaintext"])
|
||||||
|
errors = validate_agent_card(card)
|
||||||
|
assert any("MIME" in e for e in errors)
|
||||||
|
|
||||||
|
def test_skill_missing_id(self):
|
||||||
|
card = AgentCard(
|
||||||
|
name="x",
|
||||||
|
url="http://x",
|
||||||
|
skills=[AgentSkill(id="", name="Bad Skill")],
|
||||||
|
)
|
||||||
|
errors = validate_agent_card(card)
|
||||||
|
assert any("skill missing id" in e for e in errors)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadSkillsFromDirectory:
|
||||||
|
def test_empty_dir(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
skills = _load_skills_from_directory(d)
|
||||||
|
assert skills == []
|
||||||
|
|
||||||
|
def test_nonexistent_dir(self):
|
||||||
|
skills = _load_skills_from_directory("/nonexistent/path")
|
||||||
|
assert skills == []
|
||||||
|
|
||||||
|
def test_loads_skills(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
skill_dir = Path(d) / "my-skill"
|
||||||
|
skill_dir.mkdir()
|
||||||
|
(skill_dir / "SKILL.md").write_text(textwrap.dedent(""" ---
|
||||||
|
name: My Skill
|
||||||
|
description: Does amazing things
|
||||||
|
tags: [python, awesome]
|
||||||
|
---
|
||||||
|
|
||||||
|
# My Skill
|
||||||
|
Instructions here.
|
||||||
|
"""))
|
||||||
|
skills = _load_skills_from_directory(d)
|
||||||
|
assert len(skills) == 1
|
||||||
|
assert skills[0].id == "my-skill"
|
||||||
|
assert skills[0].name == "My Skill"
|
||||||
|
assert skills[0].description == "Does amazing things"
|
||||||
|
assert skills[0].tags == ["python", "awesome"]
|
||||||
|
|
||||||
|
def test_skips_non_skill_dirs(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
(Path(d) / "not-a-skill").mkdir()
|
||||||
|
skills = _load_skills_from_directory(d)
|
||||||
|
assert skills == []
|
||||||
|
|
||||||
|
def test_skips_files(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
(Path(d) / "random-file.txt").write_text("hello")
|
||||||
|
skills = _load_skills_from_directory(d)
|
||||||
|
assert skills == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildAgentCard:
|
||||||
|
def test_with_env_vars(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("AGENT_NAME", "test-bot")
|
||||||
|
monkeypatch.setenv("AGENT_DESCRIPTION", "A test agent")
|
||||||
|
monkeypatch.setenv("AGENT_HOST", "10.0.0.1")
|
||||||
|
monkeypatch.setenv("AGENT_PORT", "9090")
|
||||||
|
|
||||||
|
card = build_agent_card()
|
||||||
|
assert card.name == "test-bot"
|
||||||
|
assert card.description == "A test agent"
|
||||||
|
assert "10.0.0.1" in card.url
|
||||||
|
assert "9090" in card.url
|
||||||
|
|
||||||
|
def test_with_explicit_params(self):
|
||||||
|
card = build_agent_card(
|
||||||
|
name="explicit-agent",
|
||||||
|
description="Custom desc",
|
||||||
|
url="http://custom:1234",
|
||||||
|
)
|
||||||
|
assert card.name == "explicit-agent"
|
||||||
|
assert card.url == "http://custom:1234"
|
||||||
|
|
||||||
|
def test_extra_skills(self):
|
||||||
|
card = build_agent_card(
|
||||||
|
name="agent",
|
||||||
|
extra_skills=[AgentSkill(id="custom", name="Custom Skill")],
|
||||||
|
)
|
||||||
|
assert any(s.id == "custom" for s in card.skills)
|
||||||
|
|
||||||
|
def test_card_is_valid(self):
|
||||||
|
card = build_agent_card(name="valid-agent")
|
||||||
|
errors = validate_agent_card(card)
|
||||||
|
assert not errors, f"Card validation failed: {errors}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetAgentName:
|
||||||
|
def test_from_env(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("AGENT_NAME", "from-env")
|
||||||
|
assert _get_agent_name() == "from-env"
|
||||||
|
|
||||||
|
def test_fallback_to_hostname(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("AGENT_NAME", raising=False)
|
||||||
|
name = _get_agent_name()
|
||||||
|
assert len(name) > 0
|
||||||
Reference in New Issue
Block a user