Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bb48c91e7 |
@@ -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)
|
||||
|
||||
387
docs/review_packets/hermes-harness-2026-04-21.md
Normal file
387
docs/review_packets/hermes-harness-2026-04-21.md
Normal file
@@ -0,0 +1,387 @@
|
||||
# Morning Review Packet
|
||||
|
||||
Source epic: [EPIC: Morning review packet — Hermes harness features landed 2026-04-21](https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/issues/949)
|
||||
|
||||
## Epic context
|
||||
|
||||
EPIC: Morning review packet — Hermes harness features landed 2026-04-21
|
||||
|
||||
Source: git log on upstream/main since 2026-04-21 00:00 EDT, plus the current local branch `burn/921-poka-yoke-hardcoded-paths` for the branch-only path-guard work.
|
||||
|
||||
Important review note:
|
||||
- Validate upstream-landed features on `upstream/main` or a synced branch.
|
||||
- Validate the path-guard work on `burn/921-poka-yoke-hardcoded-paths`.
|
||||
|
||||
This epic is a morning-review packet: one QA issue per feature cluster, each with concrete acceptance criteria and targeted tests or manual checks.
|
||||
|
||||
## Success criteria
|
||||
- [ ] Every issue has a clear PASS / FAIL outcome.
|
||||
- [ ] Test output or manual evidence is attached to each issue.
|
||||
- [ ] Any drift between upstream/main and forge/main is called out explicitly.
|
||||
|
||||
## Sub-issues
|
||||
### Upstream/main features landed 2026-04-21
|
||||
- [ ] #950 [QA] Verify AI Gateway provider UX + attribution headers
|
||||
- [ ] #951 [QA] Verify transport abstraction + AnthropicTransport wiring
|
||||
- [ ] #952 [QA] Verify CLI voice beep toggle
|
||||
- [ ] #953 [QA] Verify bundled skill scripts run out of the box
|
||||
- [ ] #954 [QA] Verify maps skill guest_house / camp_site / bakery expansion
|
||||
- [ ] #955 [QA] Verify KittenTTS local provider end-to-end
|
||||
- [ ] #956 [QA] Verify numbered keyboard shortcuts for approval + clarify prompts
|
||||
- [ ] #957 [QA] Verify optional adversarial-ux-test skill catalog flow
|
||||
- [ ] #958 [QA] Verify /usage account limits in CLI + gateway
|
||||
- [ ] #959 [QA] Verify OpenCode-Go curated catalog additions
|
||||
- [ ] #960 [QA] Verify patch 'did you mean?' suggestions
|
||||
- [ ] #961 [QA] Verify web dashboard update/restart action buttons
|
||||
|
||||
### Local branch-only work
|
||||
- [ ] #962 [QA] Verify hardcoded-home path guard on burn/921 branch
|
||||
|
||||
## Summary
|
||||
|
||||
| Issue | State | Commits | Tests |
|
||||
| --- | --- | --- | --- |
|
||||
| #950 | open | 5 | 2 |
|
||||
| #951 | open | 2 | 2 |
|
||||
| #952 | open | 1 | 1 |
|
||||
| #953 | open | 1 | 2 |
|
||||
| #954 | open | 1 | 0 |
|
||||
| #955 | open | 2 | 1 |
|
||||
| #956 | open | 1 | 0 |
|
||||
| #957 | open | 1 | 0 |
|
||||
| #958 | open | 2 | 2 |
|
||||
| #959 | open | 1 | 1 |
|
||||
| #960 | open | 2 | 1 |
|
||||
| #961 | closed | 1 | 0 |
|
||||
| #962 | closed | 1 | 1 |
|
||||
|
||||
## #950 — [QA] Verify AI Gateway provider UX + attribution headers
|
||||
|
||||
State: open
|
||||
URL: https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/issues/950
|
||||
|
||||
### Branch / checkout
|
||||
- Validate on `upstream/main` or an equivalent synced checkout.
|
||||
|
||||
### Commits
|
||||
- `b11753879` — attribution default_headers for ai-gateway provider
|
||||
- `700437440` — curated picker with live pricing
|
||||
- `ac26a460f` — promote ai-gateway in provider picker ordering
|
||||
- `5bb2d11b0` — auto-promote free Moonshot models
|
||||
- `29f57ec95` — Vercel deep-link for API key creation
|
||||
|
||||
### Targeted tests
|
||||
- `tests/hermes_cli/test_ai_gateway_models.py`
|
||||
- `tests/run_agent/test_provider_attribution_headers.py`
|
||||
|
||||
### Tasks
|
||||
- [ ] Open `hermes model` and verify `ai-gateway` appears near the top.
|
||||
- [ ] Verify live pricing appears in the picker.
|
||||
- [ ] Verify free Moonshot models are promoted.
|
||||
- [ ] Trigger API-key setup flow and verify the Vercel deep link.
|
||||
- [ ] Send one ai-gateway request and verify attribution headers are attached.
|
||||
|
||||
### Acceptance criteria
|
||||
- [ ] UI ordering and pricing match the landed behavior.
|
||||
- [ ] Attribution headers are present on ai-gateway requests.
|
||||
- [ ] Targeted tests pass.
|
||||
|
||||
## #951 — [QA] Verify transport abstraction + AnthropicTransport wiring
|
||||
|
||||
State: open
|
||||
URL: https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/issues/951
|
||||
|
||||
### Branch / checkout
|
||||
- Validate on `upstream/main` or an equivalent synced checkout.
|
||||
|
||||
### Commits
|
||||
- `7ab5eebd0` — transport types + Anthropic normalize migration
|
||||
- `731f4fbae` — transport ABC + AnthropicTransport wired to all paths
|
||||
|
||||
### Targeted tests
|
||||
- `tests/agent/transports/test_types.py`
|
||||
- `tests/agent/test_anthropic_normalize_v2.py`
|
||||
|
||||
### Tasks
|
||||
- [ ] Verify plain-text Anthropic responses normalize correctly.
|
||||
- [ ] Verify tool-call responses preserve IDs, names, and arguments.
|
||||
- [ ] Verify reasoning/thinking is preserved separately from visible content.
|
||||
- [ ] Verify finish_reason mapping remains correct across paths.
|
||||
|
||||
### Acceptance criteria
|
||||
- [ ] Normalized response shape is stable.
|
||||
- [ ] Tool-call and reasoning payloads survive normalization.
|
||||
- [ ] Targeted tests pass.
|
||||
|
||||
## #952 — [QA] Verify CLI voice beep toggle
|
||||
|
||||
State: open
|
||||
URL: https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/issues/952
|
||||
|
||||
### Branch / checkout
|
||||
- Validate on `upstream/main` or an equivalent synced checkout.
|
||||
|
||||
### Commits
|
||||
- `b48ea41d2` — voice: add CLI beep toggle
|
||||
|
||||
### Targeted tests
|
||||
- `tests/tools/test_voice_cli_integration.py`
|
||||
|
||||
### Tasks
|
||||
- [ ] Enable the beep option in config and confirm voice mode emits the beep.
|
||||
- [ ] Disable the option and confirm the same path is silent.
|
||||
- [ ] Verify voice mode still strips markdown before speech output.
|
||||
- [ ] Verify voice mode does not pollute conversation history with TTS-only text.
|
||||
|
||||
### Acceptance criteria
|
||||
- [ ] Beep behavior is actually toggled by config.
|
||||
- [ ] Existing voice/TTS integration behavior is not regressed.
|
||||
- [ ] Targeted tests pass.
|
||||
|
||||
## #953 — [QA] Verify bundled skill scripts run out of the box
|
||||
|
||||
State: open
|
||||
URL: https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/issues/953
|
||||
|
||||
### Branch / checkout
|
||||
- Validate on `upstream/main` or an equivalent synced checkout.
|
||||
|
||||
### Commits
|
||||
- `328223576` — make bundled skill scripts runnable out of the box
|
||||
|
||||
### Targeted tests
|
||||
- `tests/agent/test_skill_commands.py`
|
||||
- `tests/tools/test_local_shell_init.py`
|
||||
|
||||
### Tasks
|
||||
- [ ] Pick a bundled skill that ships a script and run it without manual chmod/PATH surgery.
|
||||
- [ ] Verify local terminal execution resolves the installed skill script correctly.
|
||||
- [ ] Verify local shell init still behaves correctly.
|
||||
|
||||
### Acceptance criteria
|
||||
- [ ] Bundled skill scripts execute from the installed skill location with no manual prep.
|
||||
- [ ] Local shell init remains healthy.
|
||||
- [ ] Targeted tests pass.
|
||||
|
||||
## #954 — [QA] Verify maps skill guest_house / camp_site / bakery expansion
|
||||
|
||||
State: open
|
||||
URL: https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/issues/954
|
||||
|
||||
### Branch / checkout
|
||||
- Validate on `upstream/main` or an equivalent synced checkout.
|
||||
|
||||
### Commits
|
||||
- `c5a814b23` — maps: add guest_house, camp_site, and dual-key bakery lookup
|
||||
|
||||
### Tasks
|
||||
- [ ] Use the maps skill to search for a guest house in a known populated area.
|
||||
- [ ] Use the maps skill to search for a camp site in a known populated area.
|
||||
- [ ] Use the maps skill to search for a bakery and verify both supported keys resolve correctly.
|
||||
- [ ] Confirm results are sensible and non-empty.
|
||||
|
||||
### Acceptance criteria
|
||||
- [ ] All three place types resolve correctly.
|
||||
- [ ] Bakery lookup works through both supported keys.
|
||||
- [ ] Manual evidence is attached in the issue.
|
||||
|
||||
## #955 — [QA] Verify KittenTTS local provider end-to-end
|
||||
|
||||
State: open
|
||||
URL: https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/issues/955
|
||||
|
||||
### Branch / checkout
|
||||
- Validate on `upstream/main` or an equivalent synced checkout.
|
||||
|
||||
### Commits
|
||||
- `1830ebfc5` — add KittenTTS provider
|
||||
- `2d7ff9c5b` — complete KittenTTS integration across tools/setup/docs/tests
|
||||
|
||||
### Targeted tests
|
||||
- `tests/tools/test_tts_kittentts.py`
|
||||
|
||||
### Tasks
|
||||
- [ ] Configure TTS to use `kittentts`.
|
||||
- [ ] Generate speech to `.wav` and verify playable output.
|
||||
- [ ] Verify voice / speed / cleaned text are passed correctly.
|
||||
- [ ] Generate repeated requests and verify model caching behavior.
|
||||
- [ ] Generate a non-wav output and verify ffmpeg conversion path.
|
||||
- [ ] Verify missing-package behavior returns a helpful error.
|
||||
|
||||
### Acceptance criteria
|
||||
- [ ] KittenTTS works end-to-end when installed.
|
||||
- [ ] Failure mode is operator-friendly when not installed.
|
||||
- [ ] Targeted tests pass.
|
||||
|
||||
## #956 — [QA] Verify numbered keyboard shortcuts for approval + clarify prompts
|
||||
|
||||
State: open
|
||||
URL: https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/issues/956
|
||||
|
||||
### Branch / checkout
|
||||
- Validate on `upstream/main` or an equivalent synced checkout.
|
||||
|
||||
### Commits
|
||||
- `d1ed6f4fb` — CLI: add numbered keyboard shortcuts to approval and clarify prompts
|
||||
|
||||
### Tasks
|
||||
- [ ] Trigger an approval prompt and choose an option with number keys.
|
||||
- [ ] Trigger a clarify prompt and choose an option with number keys.
|
||||
- [ ] Verify the correct option is submitted both times.
|
||||
- [ ] Verify normal keyboard navigation still works.
|
||||
|
||||
### Acceptance criteria
|
||||
- [ ] Number-key selection works for both prompt types.
|
||||
- [ ] Legacy keyboard navigation is not broken.
|
||||
- [ ] Manual evidence is attached in the issue.
|
||||
|
||||
## #957 — [QA] Verify optional adversarial-ux-test skill catalog flow
|
||||
|
||||
State: open
|
||||
URL: https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/issues/957
|
||||
|
||||
### Branch / checkout
|
||||
- Validate on `upstream/main` or an equivalent synced checkout.
|
||||
|
||||
### Commits
|
||||
- `e50e7f11b` — skills: add adversarial-ux-test optional skill
|
||||
|
||||
### Tasks
|
||||
- [ ] Verify the optional skill appears in the optional skill catalog.
|
||||
- [ ] Install or enable the skill.
|
||||
- [ ] Load it successfully through Hermes.
|
||||
- [ ] Disable or remove it and verify catalog state updates cleanly.
|
||||
|
||||
### Acceptance criteria
|
||||
- [ ] Catalog listing is correct.
|
||||
- [ ] Install / load / disable lifecycle works cleanly.
|
||||
- [ ] Manual evidence is attached in the issue.
|
||||
|
||||
## #958 — [QA] Verify /usage account limits in CLI + gateway
|
||||
|
||||
State: open
|
||||
URL: https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/issues/958
|
||||
|
||||
### Branch / checkout
|
||||
- Validate on `upstream/main` or an equivalent synced checkout.
|
||||
|
||||
### Commits
|
||||
- `8a11b0a20` — per-provider account limits module
|
||||
- `bcc5d7b67` — append account limits section in CLI and gateway
|
||||
|
||||
### Targeted tests
|
||||
- `tests/test_account_usage.py`
|
||||
- `tests/gateway/test_usage_command.py`
|
||||
|
||||
### Tasks
|
||||
- [ ] Run `/usage` in CLI for a provider with account limits.
|
||||
- [ ] Verify provider, remaining quota, total limit, and reset window render correctly.
|
||||
- [ ] Run `/usage` through the gateway and verify the same section appears.
|
||||
- [ ] Verify zero-value cache read/write sections stay hidden when appropriate.
|
||||
|
||||
### Acceptance criteria
|
||||
- [ ] CLI and gateway both show the landed account-limits section correctly.
|
||||
- [ ] Targeted tests pass.
|
||||
|
||||
## #959 — [QA] Verify OpenCode-Go curated catalog additions
|
||||
|
||||
State: open
|
||||
URL: https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/issues/959
|
||||
|
||||
### Branch / checkout
|
||||
- Validate on `upstream/main` or an equivalent synced checkout.
|
||||
|
||||
### Commits
|
||||
- `4fea1769d` — opencode-go: add Kimi K2.6 and Qwen3.5/3.6 Plus to curated catalog
|
||||
|
||||
### Targeted tests
|
||||
- `tests/hermes_cli/test_opencode_go_in_model_list.py`
|
||||
|
||||
### Tasks
|
||||
- [ ] With valid OpenCode-Go credentials, open `hermes model`.
|
||||
- [ ] Verify Kimi K2.6 appears.
|
||||
- [ ] Verify Qwen 3.5 Plus and 3.6 Plus appear.
|
||||
- [ ] Unset credentials and verify the provider/catalog hides correctly.
|
||||
|
||||
### Acceptance criteria
|
||||
- [ ] New curated models are present when credentials exist.
|
||||
- [ ] Catalog visibility still respects credential gating.
|
||||
- [ ] Targeted tests pass.
|
||||
|
||||
## #960 — [QA] Verify patch 'did you mean?' suggestions
|
||||
|
||||
State: open
|
||||
URL: https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/issues/960
|
||||
|
||||
### Branch / checkout
|
||||
- Validate on `upstream/main` or an equivalent synced checkout.
|
||||
|
||||
### Commits
|
||||
- `15abf4ed8` — add `did you mean?` feedback when patch fails to match
|
||||
- `5e6427a42` — gate it to true no-match cases and extend to v4a / skill_manage
|
||||
|
||||
### Targeted tests
|
||||
- `tests/tools/test_fuzzy_match.py`
|
||||
|
||||
### Tasks
|
||||
- [ ] Intentionally run a replace/patch with a near-miss `old_string`.
|
||||
- [ ] Verify the tool suggests a useful nearby line/context.
|
||||
- [ ] Verify suggestions only appear on true no-match failures.
|
||||
- [ ] Verify the behavior also works via file tools, v4a patching, and skill_manage.
|
||||
|
||||
### Acceptance criteria
|
||||
- [ ] Suggestion quality is helpful, not noisy.
|
||||
- [ ] Suggestions are correctly gated to no-match cases.
|
||||
- [ ] Targeted tests pass.
|
||||
|
||||
## #961 — [QA] Verify web dashboard update/restart action buttons
|
||||
|
||||
State: closed
|
||||
URL: https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/issues/961
|
||||
|
||||
### Branch / checkout
|
||||
- Validate on `upstream/main` or an equivalent synced checkout.
|
||||
|
||||
### Commits
|
||||
- `fc21c1420` — add buttons to update Hermes and restart gateway
|
||||
|
||||
### Files touched
|
||||
- `web/src/pages/StatusPage.tsx`
|
||||
- `web/src/lib/api.ts`
|
||||
- `web/src/i18n/en.ts`
|
||||
|
||||
### Tasks
|
||||
- [ ] Open the Web UI status page and verify both buttons are present.
|
||||
- [ ] Click Restart Gateway in a safe environment and verify running/output/success-or-failure states render.
|
||||
- [ ] Click Update Hermes and verify the same action lifecycle.
|
||||
- [ ] Verify the page remains responsive while actions are running.
|
||||
|
||||
### Acceptance criteria
|
||||
- [ ] Both action buttons are present and wired.
|
||||
- [ ] Action status polling and result rendering work end-to-end.
|
||||
- [ ] Manual evidence is attached in the issue.
|
||||
|
||||
## #962 — [QA] Verify hardcoded-home path guard on burn/921 branch
|
||||
|
||||
State: closed
|
||||
URL: https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/issues/962
|
||||
|
||||
### Branch / checkout
|
||||
- Validate specifically on `burn/921-poka-yoke-hardcoded-paths` (not upstream/main).
|
||||
|
||||
### Commits
|
||||
- `5dcb90531` — Poka-yoke: prevent hardcoded home-directory paths
|
||||
|
||||
### Targeted tests
|
||||
- `tests/test_path_guard.py`
|
||||
|
||||
### Tasks
|
||||
- [ ] Verify hardcoded `/Users/...` paths are rejected.
|
||||
- [ ] Verify hardcoded `~/.hermes/...` paths are rejected in guarded contexts.
|
||||
- [ ] Verify valid relative paths still pass.
|
||||
- [ ] Verify appropriate absolute paths still pass where intended.
|
||||
- [ ] Verify linting catches violations in non-test files.
|
||||
|
||||
### Acceptance criteria
|
||||
- [ ] Guard blocks the dangerous patterns and preserves allowed ones.
|
||||
- [ ] Targeted tests pass.
|
||||
301
scripts/morning_review_packet.py
Normal file
301
scripts/morning_review_packet.py
Normal file
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a morning review packet from a Gitea epic and its child QA issues.
|
||||
|
||||
This script fetches a parent epic plus its sub-issues, extracts the structured
|
||||
sections from each QA issue body, and renders a single markdown packet suitable
|
||||
for morning review.
|
||||
|
||||
Usage:
|
||||
python scripts/morning_review_packet.py --epic-number 949
|
||||
python scripts/morning_review_packet.py --epic-number 949 --children 950-962
|
||||
python scripts/morning_review_packet.py --epic-number 949 --output docs/review_packets/hermes-harness-2026-04-21.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
DEFAULT_BASE_URL = "https://forge.alexanderwhitestone.com"
|
||||
DEFAULT_OWNER = "Timmy_Foundation"
|
||||
DEFAULT_REPO = "hermes-agent"
|
||||
DEFAULT_TOKEN_PATH = Path.home() / ".config" / "gitea" / "token"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommitEvidence:
|
||||
sha: str
|
||||
summary: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReviewIssue:
|
||||
number: int
|
||||
title: str
|
||||
state: str
|
||||
url: str
|
||||
comments: int = 0
|
||||
parent_issue: int | None = None
|
||||
checkout_notes: list[str] = field(default_factory=list)
|
||||
commits: list[CommitEvidence] = field(default_factory=list)
|
||||
targeted_tests: list[str] = field(default_factory=list)
|
||||
files_touched: list[str] = field(default_factory=list)
|
||||
tasks: list[str] = field(default_factory=list)
|
||||
acceptance_criteria: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_issue_number_spec(spec: str) -> list[int]:
|
||||
"""Parse a comma-separated issue list like ``950-952,955,962``."""
|
||||
numbers: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for chunk in (part.strip() for part in spec.split(",")):
|
||||
if not chunk:
|
||||
continue
|
||||
if "-" in chunk:
|
||||
start_str, end_str = (part.strip() for part in chunk.split("-", 1))
|
||||
start = int(start_str)
|
||||
end = int(end_str)
|
||||
if end < start:
|
||||
raise ValueError(f"Invalid descending issue range: {chunk}")
|
||||
for number in range(start, end + 1):
|
||||
if number not in seen:
|
||||
numbers.append(number)
|
||||
seen.add(number)
|
||||
else:
|
||||
number = int(chunk)
|
||||
if number not in seen:
|
||||
numbers.append(number)
|
||||
seen.add(number)
|
||||
return numbers
|
||||
|
||||
|
||||
def _parse_sections(body: str) -> dict[str, list[str]]:
|
||||
sections: dict[str, list[str]] = {}
|
||||
current: str | None = None
|
||||
for raw_line in body.splitlines():
|
||||
line = raw_line.rstrip()
|
||||
if line.startswith("## "):
|
||||
current = line[3:].strip()
|
||||
sections[current] = []
|
||||
continue
|
||||
if current is not None:
|
||||
sections[current].append(line)
|
||||
return sections
|
||||
|
||||
|
||||
def _clean_bullet(line: str) -> str | None:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
stripped = re.sub(r"^-\s*\[(?: |x|X)\]\s*", "", stripped)
|
||||
stripped = re.sub(r"^-\s*", "", stripped)
|
||||
return stripped.strip() or None
|
||||
|
||||
|
||||
def _extract_bullets(lines: Iterable[str]) -> list[str]:
|
||||
items: list[str] = []
|
||||
for line in lines:
|
||||
cleaned = _clean_bullet(line)
|
||||
if cleaned:
|
||||
items.append(cleaned)
|
||||
return items
|
||||
|
||||
|
||||
def _extract_parent_issue(body: str, sections: dict[str, list[str]]) -> int | None:
|
||||
parent_lines = sections.get("Parent", [])
|
||||
for line in parent_lines:
|
||||
match = re.search(r"#(\d+)", line)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
match = re.search(r"Linked to Epic\s+#(\d+)", body, flags=re.IGNORECASE)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def _extract_commits(lines: Iterable[str]) -> list[CommitEvidence]:
|
||||
commits: list[CommitEvidence] = []
|
||||
for item in _extract_bullets(lines):
|
||||
match = re.match(r"`([^`]+)`\s*(.*)", item)
|
||||
if match:
|
||||
commits.append(CommitEvidence(sha=match.group(1).strip(), summary=match.group(2).strip()))
|
||||
else:
|
||||
commits.append(CommitEvidence(sha="", summary=item))
|
||||
return commits
|
||||
|
||||
|
||||
def _strip_backticks(items: Iterable[str]) -> list[str]:
|
||||
cleaned: list[str] = []
|
||||
for item in items:
|
||||
cleaned.append(item.replace("`", "").strip())
|
||||
return cleaned
|
||||
|
||||
|
||||
def discover_child_issue_numbers(epic_body: str) -> list[int]:
|
||||
"""Discover sub-issue numbers from an epic body."""
|
||||
sections = _parse_sections(epic_body)
|
||||
sub_lines = sections.get("Sub-issues")
|
||||
if not sub_lines:
|
||||
return []
|
||||
numbers: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for line in sub_lines:
|
||||
for match in re.finditer(r"#(\d+)", line):
|
||||
number = int(match.group(1))
|
||||
if number not in seen:
|
||||
numbers.append(number)
|
||||
seen.add(number)
|
||||
return numbers
|
||||
|
||||
|
||||
def parse_child_issue(issue: dict) -> ReviewIssue:
|
||||
body = issue.get("body") or ""
|
||||
sections = _parse_sections(body)
|
||||
commit_lines = sections.get("Commits landed today", []) or sections.get("Commit landed today", [])
|
||||
|
||||
return ReviewIssue(
|
||||
number=int(issue["number"]),
|
||||
title=issue.get("title") or "",
|
||||
state=(issue.get("state") or "unknown").lower(),
|
||||
url=issue.get("html_url") or issue.get("url") or "",
|
||||
comments=int(issue.get("comments") or 0),
|
||||
parent_issue=_extract_parent_issue(body, sections),
|
||||
checkout_notes=_extract_bullets(sections.get("Branch / checkout", [])),
|
||||
commits=_extract_commits(commit_lines),
|
||||
targeted_tests=_strip_backticks(_extract_bullets(sections.get("Targeted tests", []))),
|
||||
files_touched=_strip_backticks(_extract_bullets(sections.get("Files touched", []))),
|
||||
tasks=_extract_bullets(sections.get("Tasks", [])),
|
||||
acceptance_criteria=_extract_bullets(sections.get("Acceptance Criteria", [])),
|
||||
)
|
||||
|
||||
|
||||
def build_packet_markdown(epic_issue: dict, child_issues: list[ReviewIssue]) -> str:
|
||||
title = epic_issue.get("title") or f"Epic #{epic_issue.get('number')}"
|
||||
url = epic_issue.get("html_url") or epic_issue.get("url") or ""
|
||||
body = epic_issue.get("body") or ""
|
||||
children = sorted(child_issues, key=lambda item: item.number)
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("# Morning Review Packet")
|
||||
lines.append("")
|
||||
lines.append(f"Source epic: [{title}]({url})")
|
||||
lines.append("")
|
||||
lines.append("## Epic context")
|
||||
lines.append("")
|
||||
lines.append(title)
|
||||
lines.append("")
|
||||
for line in body.splitlines():
|
||||
if line.strip():
|
||||
lines.append(line)
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append("")
|
||||
lines.append("## Summary")
|
||||
lines.append("")
|
||||
lines.append("| Issue | State | Commits | Tests |")
|
||||
lines.append("| --- | --- | --- | --- |")
|
||||
for child in children:
|
||||
lines.append(
|
||||
f"| #{child.number} | {child.state} | {len(child.commits)} | {len(child.targeted_tests)} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
for child in children:
|
||||
lines.append(f"## #{child.number} — {child.title}")
|
||||
lines.append("")
|
||||
lines.append(f"State: {child.state}")
|
||||
lines.append(f"URL: {child.url}")
|
||||
lines.append("")
|
||||
if child.checkout_notes:
|
||||
lines.append("### Branch / checkout")
|
||||
for note in child.checkout_notes:
|
||||
lines.append(f"- {note}")
|
||||
lines.append("")
|
||||
if child.commits:
|
||||
lines.append("### Commits")
|
||||
for commit in child.commits:
|
||||
if commit.sha:
|
||||
lines.append(f"- `{commit.sha}` — {commit.summary}")
|
||||
else:
|
||||
lines.append(f"- {commit.summary}")
|
||||
lines.append("")
|
||||
if child.targeted_tests:
|
||||
lines.append("### Targeted tests")
|
||||
for test_path in child.targeted_tests:
|
||||
lines.append(f"- `{test_path}`")
|
||||
lines.append("")
|
||||
if child.files_touched:
|
||||
lines.append("### Files touched")
|
||||
for file_path in child.files_touched:
|
||||
lines.append(f"- `{file_path}`")
|
||||
lines.append("")
|
||||
if child.tasks:
|
||||
lines.append("### Tasks")
|
||||
for task in child.tasks:
|
||||
lines.append(f"- [ ] {task}")
|
||||
lines.append("")
|
||||
if child.acceptance_criteria:
|
||||
lines.append("### Acceptance criteria")
|
||||
for item in child.acceptance_criteria:
|
||||
lines.append(f"- [ ] {item}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def _resolve_token(explicit_token: str | None = None) -> str:
|
||||
if explicit_token:
|
||||
return explicit_token.strip()
|
||||
env_token = os.getenv("GITEA_TOKEN")
|
||||
if env_token:
|
||||
return env_token.strip()
|
||||
if DEFAULT_TOKEN_PATH.exists():
|
||||
return DEFAULT_TOKEN_PATH.read_text().strip()
|
||||
raise FileNotFoundError(f"No Gitea token found. Set GITEA_TOKEN or create {DEFAULT_TOKEN_PATH}")
|
||||
|
||||
|
||||
def fetch_issue(base_url: str, owner: str, repo: str, number: int, token: str) -> dict:
|
||||
url = f"{base_url.rstrip('/')}/api/v1/repos/{owner}/{repo}/issues/{number}"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.loads(response.read().decode())
|
||||
|
||||
|
||||
def collect_child_issues(base_url: str, owner: str, repo: str, epic_issue: dict, token: str, children_spec: str | None = None) -> list[dict]:
|
||||
numbers = parse_issue_number_spec(children_spec) if children_spec else discover_child_issue_numbers(epic_issue.get("body") or "")
|
||||
return [fetch_issue(base_url, owner, repo, number, token) for number in numbers]
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Build a markdown morning review packet from a Gitea epic")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--owner", default=DEFAULT_OWNER)
|
||||
parser.add_argument("--repo", default=DEFAULT_REPO)
|
||||
parser.add_argument("--epic-number", type=int, required=True)
|
||||
parser.add_argument("--children", help="Explicit issue list/ranges, e.g. 950-962")
|
||||
parser.add_argument("--token", help="Gitea token (defaults to GITEA_TOKEN or ~/.config/gitea/token)")
|
||||
parser.add_argument("--output", help="Write markdown packet to this path instead of stdout")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
token = _resolve_token(args.token)
|
||||
epic_issue = fetch_issue(args.base_url, args.owner, args.repo, args.epic_number, token)
|
||||
child_issue_dicts = collect_child_issues(args.base_url, args.owner, args.repo, epic_issue, token, args.children)
|
||||
packet = build_packet_markdown(epic_issue, [parse_child_issue(issue) for issue in child_issue_dicts])
|
||||
|
||||
if args.output:
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(packet)
|
||||
else:
|
||||
print(packet, end="")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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 == ""
|
||||
162
tests/test_morning_review_packet.py
Normal file
162
tests/test_morning_review_packet.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
import morning_review_packet as mrp
|
||||
|
||||
|
||||
EPIC_BODY = """Source: git log on upstream/main since 2026-04-21 00:00 EDT.
|
||||
|
||||
## Success criteria
|
||||
- [ ] Every issue has a clear PASS / FAIL outcome.
|
||||
|
||||
## Sub-issues
|
||||
- [ ] #950 [QA] Verify AI Gateway provider UX + attribution headers
|
||||
- [ ] #951 [QA] Verify transport abstraction + AnthropicTransport wiring
|
||||
- [x] #962 [QA] Verify hardcoded-home path guard on burn/921 branch
|
||||
"""
|
||||
|
||||
|
||||
CHILD_BODY_PLURAL = """## Parent
|
||||
#949
|
||||
|
||||
## Branch / checkout
|
||||
- Validate on `upstream/main` or an equivalent synced checkout.
|
||||
|
||||
## Commits landed today
|
||||
- `b11753879` attribution default_headers for ai-gateway provider
|
||||
- `700437440` curated picker with live pricing
|
||||
|
||||
## Targeted tests
|
||||
- `tests/hermes_cli/test_ai_gateway_models.py`
|
||||
- `tests/run_agent/test_provider_attribution_headers.py`
|
||||
|
||||
## Tasks
|
||||
- [ ] Verify the picker ordering.
|
||||
- [ ] Verify attribution headers.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Picker shows AI Gateway prominently.
|
||||
- [ ] Headers appear on OpenRouter calls.
|
||||
"""
|
||||
|
||||
|
||||
CHILD_BODY_SINGULAR = """## Parent
|
||||
#949
|
||||
|
||||
## Branch / checkout
|
||||
- Validate on `upstream/main` or an equivalent synced checkout.
|
||||
|
||||
## Commit landed today
|
||||
- `fc21c1420` add buttons to update Hermes and restart gateway
|
||||
|
||||
## Files touched
|
||||
- `web/src/pages/StatusPage.tsx`
|
||||
- `web/src/lib/api.ts`
|
||||
- `web/src/i18n/en.ts`
|
||||
|
||||
## Tasks
|
||||
- [ ] Open the Web UI status page and verify both buttons are present.
|
||||
- [ ] Click Restart Gateway in a safe environment.
|
||||
"""
|
||||
|
||||
|
||||
def test_discover_child_issue_numbers_from_epic_body():
|
||||
assert mrp.discover_child_issue_numbers(EPIC_BODY) == [950, 951, 962]
|
||||
|
||||
|
||||
def test_parse_issue_number_spec_supports_ranges_and_lists():
|
||||
assert mrp.parse_issue_number_spec("950-952,955,962") == [950, 951, 952, 955, 962]
|
||||
|
||||
|
||||
def test_parse_child_issue_extracts_structured_sections():
|
||||
issue = {
|
||||
"number": 950,
|
||||
"title": "[QA] Verify AI Gateway provider UX + attribution headers",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/950",
|
||||
"comments": 0,
|
||||
"body": CHILD_BODY_PLURAL,
|
||||
}
|
||||
|
||||
parsed = mrp.parse_child_issue(issue)
|
||||
|
||||
assert parsed.number == 950
|
||||
assert parsed.parent_issue == 949
|
||||
assert parsed.checkout_notes == ["Validate on `upstream/main` or an equivalent synced checkout."]
|
||||
assert [c.sha for c in parsed.commits] == ["b11753879", "700437440"]
|
||||
assert parsed.targeted_tests == [
|
||||
"tests/hermes_cli/test_ai_gateway_models.py",
|
||||
"tests/run_agent/test_provider_attribution_headers.py",
|
||||
]
|
||||
assert parsed.tasks == [
|
||||
"Verify the picker ordering.",
|
||||
"Verify attribution headers.",
|
||||
]
|
||||
assert parsed.acceptance_criteria == [
|
||||
"Picker shows AI Gateway prominently.",
|
||||
"Headers appear on OpenRouter calls.",
|
||||
]
|
||||
|
||||
|
||||
def test_parse_child_issue_handles_singular_commit_heading_and_files_touched():
|
||||
issue = {
|
||||
"number": 961,
|
||||
"title": "[QA] Verify web dashboard update/restart action buttons",
|
||||
"state": "closed",
|
||||
"html_url": "https://forge.example/961",
|
||||
"comments": 16,
|
||||
"body": CHILD_BODY_SINGULAR,
|
||||
}
|
||||
|
||||
parsed = mrp.parse_child_issue(issue)
|
||||
|
||||
assert [c.sha for c in parsed.commits] == ["fc21c1420"]
|
||||
assert parsed.files_touched == [
|
||||
"web/src/pages/StatusPage.tsx",
|
||||
"web/src/lib/api.ts",
|
||||
"web/src/i18n/en.ts",
|
||||
]
|
||||
assert parsed.tasks == [
|
||||
"Open the Web UI status page and verify both buttons are present.",
|
||||
"Click Restart Gateway in a safe environment.",
|
||||
]
|
||||
|
||||
|
||||
def test_build_packet_markdown_renders_summary_and_details():
|
||||
epic_issue = {
|
||||
"number": 949,
|
||||
"title": "EPIC: Morning review packet — Hermes harness features landed 2026-04-21",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/949",
|
||||
"body": EPIC_BODY,
|
||||
}
|
||||
child_a = mrp.parse_child_issue({
|
||||
"number": 950,
|
||||
"title": "[QA] Verify AI Gateway provider UX + attribution headers",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/950",
|
||||
"comments": 0,
|
||||
"body": CHILD_BODY_PLURAL,
|
||||
})
|
||||
child_b = mrp.parse_child_issue({
|
||||
"number": 961,
|
||||
"title": "[QA] Verify web dashboard update/restart action buttons",
|
||||
"state": "closed",
|
||||
"html_url": "https://forge.example/961",
|
||||
"comments": 16,
|
||||
"body": CHILD_BODY_SINGULAR,
|
||||
})
|
||||
|
||||
markdown = mrp.build_packet_markdown(epic_issue, [child_a, child_b])
|
||||
|
||||
assert "# Morning Review Packet" in markdown
|
||||
assert "EPIC: Morning review packet — Hermes harness features landed 2026-04-21" in markdown
|
||||
assert "| #950 | open | 2 | 2 |" in markdown
|
||||
assert "| #961 | closed | 1 | 0 |" in markdown
|
||||
assert "## #950 — [QA] Verify AI Gateway provider UX + attribution headers" in markdown
|
||||
assert "## #961 — [QA] Verify web dashboard update/restart action buttons" in markdown
|
||||
assert "`b11753879` — attribution default_headers for ai-gateway provider" in markdown
|
||||
assert "`web/src/pages/StatusPage.tsx`" in markdown
|
||||
Reference in New Issue
Block a user