build-hermes-plugin.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. #!/usr/bin/env python3
  2. """Build the Hermes lazy-router plugin for The Agency agents.
  3. The generated plugin exposes a small fixed tool surface to Hermes and keeps the
  4. large agent roster in an on-disk JSON data file. That avoids using
  5. skills.external_dirs, which advertises every Agency agent in Hermes' initial
  6. skill catalog.
  7. """
  8. from __future__ import annotations
  9. import argparse
  10. import json
  11. import re
  12. import shutil
  13. import textwrap
  14. from pathlib import Path
  15. PLUGIN_NAME = "agency-agents-router"
  16. def division_dirs(repo_root: Path) -> list[str]:
  17. # divisions.json (repo root) is the single source of truth for the division
  18. # set. Read it rather than hardcoding a copy here: a hardcoded list silently
  19. # drops new divisions from the Hermes roster (e.g. healthcare) the moment the
  20. # catalog grows. check-divisions.sh guards divisions.json against the tracked
  21. # dirs, so deriving from it keeps this plugin in sync by construction.
  22. data = json.loads((repo_root / "divisions.json").read_text(encoding="utf-8"))
  23. return sorted(data["divisions"].keys())
  24. def slugify(value: str) -> str:
  25. value = value.lower()
  26. value = re.sub(r"[^a-z0-9]+", "-", value)
  27. return value.strip("-")
  28. def parse_agent(path: Path, repo_root: Path) -> dict[str, str] | None:
  29. text = path.read_text(encoding="utf-8")
  30. if not text.startswith("---\n"):
  31. return None
  32. parts = text.split("---\n", 2)
  33. if len(parts) < 3:
  34. return None
  35. frontmatter = parts[1]
  36. body = parts[2].lstrip("\n")
  37. fields: dict[str, str] = {}
  38. for line in frontmatter.splitlines():
  39. if ":" not in line or line.startswith((" ", "\t")):
  40. continue
  41. key, value = line.split(":", 1)
  42. fields[key.strip()] = value.strip().strip('"').strip("'")
  43. name = fields.get("name", "").strip()
  44. if not name:
  45. return None
  46. rel = path.relative_to(repo_root)
  47. division = rel.parts[0]
  48. return {
  49. "slug": slugify(name),
  50. "name": name,
  51. "description": fields.get("description", "").strip(),
  52. "division": division,
  53. "color": fields.get("color", "").strip(),
  54. "emoji": fields.get("emoji", "").strip(),
  55. "vibe": fields.get("vibe", "").strip(),
  56. "source_path": str(rel),
  57. "body": body,
  58. }
  59. def collect_agents(repo_root: Path) -> list[dict[str, str]]:
  60. agents: list[dict[str, str]] = []
  61. for dirname in division_dirs(repo_root):
  62. base = repo_root / dirname
  63. if not base.is_dir():
  64. continue
  65. for path in sorted(base.rglob("*.md")):
  66. parsed = parse_agent(path, repo_root)
  67. if parsed:
  68. agents.append(parsed)
  69. agents.sort(key=lambda item: (item["division"], item["slug"]))
  70. seen: set[str] = set()
  71. duplicates: set[str] = set()
  72. for agent in agents:
  73. slug = agent["slug"]
  74. if slug in seen:
  75. duplicates.add(slug)
  76. seen.add(slug)
  77. if duplicates:
  78. dupes = ", ".join(sorted(duplicates))
  79. raise SystemExit(f"duplicate Hermes agent slugs: {dupes}")
  80. return agents
  81. def plugin_yaml() -> str:
  82. return textwrap.dedent(
  83. f"""
  84. name: {PLUGIN_NAME}
  85. version: 1.0.0
  86. description: Lazy search/load/delegate router for The Agency agent roster.
  87. provides_tools:
  88. - agency_agents_search
  89. - agency_agents_inspect
  90. - agency_agents_load
  91. - agency_agents_delegate
  92. """
  93. ).lstrip()
  94. def init_py() -> str:
  95. return r'''"""Hermes plugin: lazy router for The Agency agents."""
  96. from __future__ import annotations
  97. import json
  98. import math
  99. import re
  100. from pathlib import Path
  101. from typing import Any
  102. _DATA_PATH = Path(__file__).parent / "data" / "agents.json"
  103. _AGENTS: list[dict[str, Any]] | None = None
  104. _WORD_RE = re.compile(r"[a-z0-9][a-z0-9+.#_-]*", re.I)
  105. def _load_agents() -> list[dict[str, Any]]:
  106. global _AGENTS
  107. if _AGENTS is None:
  108. _AGENTS = json.loads(_DATA_PATH.read_text(encoding="utf-8"))
  109. return _AGENTS
  110. def _tokens(text: str) -> set[str]:
  111. return {token.lower() for token in _WORD_RE.findall(text or "")}
  112. def _agent_lookup(identifier: str) -> dict[str, Any] | None:
  113. needle = (identifier or "").strip().lower()
  114. if not needle:
  115. return None
  116. slug = re.sub(r"[^a-z0-9]+", "-", needle).strip("-")
  117. for agent in _load_agents():
  118. if agent["slug"] == slug or agent["name"].lower() == needle:
  119. return agent
  120. return None
  121. def _identifier(args: dict[str, Any]) -> str:
  122. # Accept either "agent" or "slug": agency_agents_search returns results keyed
  123. # by "slug", so callers naturally chain search -> load/inspect/delegate with
  124. # slug=. Both name the same thing (a slug or exact display name).
  125. return str(args.get("agent") or args.get("slug") or "").strip()
  126. def _not_found(identifier: str) -> dict[str, Any]:
  127. return {
  128. "success": False,
  129. "error": "agent not found" if identifier else "agent or slug is required",
  130. "agent": identifier or None,
  131. }
  132. def _score(agent: dict[str, Any], query_tokens: set[str], query_text: str) -> float:
  133. haystack_fields = [
  134. agent.get("name", ""),
  135. agent.get("description", ""),
  136. agent.get("division", ""),
  137. agent.get("vibe", ""),
  138. agent.get("body", "")[:8000],
  139. ]
  140. haystack_text = "\n".join(haystack_fields).lower()
  141. haystack_tokens = _tokens(haystack_text)
  142. overlap = query_tokens & haystack_tokens
  143. score = float(len(overlap))
  144. if query_text and query_text in haystack_text:
  145. score += 5.0
  146. name = agent.get("name", "").lower()
  147. description = agent.get("description", "").lower()
  148. for token in query_tokens:
  149. if token in name:
  150. score += 3.0
  151. if token in description:
  152. score += 1.5
  153. if score == 0.0:
  154. return 0.0
  155. # Slightly prefer focused descriptions over huge bodies when scores tie.
  156. return score + (1.0 / math.sqrt(max(len(haystack_tokens), 1)))
  157. def _summary(agent: dict[str, Any], score: float | None = None) -> dict[str, Any]:
  158. item = {
  159. "slug": agent["slug"],
  160. "name": agent["name"],
  161. "division": agent["division"],
  162. "description": agent.get("description", ""),
  163. "vibe": agent.get("vibe", ""),
  164. "source_path": agent.get("source_path", ""),
  165. }
  166. if score is not None:
  167. item["score"] = round(score, 3)
  168. return item
  169. def _specialist_prompt(agent: dict[str, Any], task: str = "") -> str:
  170. task_block = f"\n\n## User task\n{task.strip()}\n" if task and task.strip() else ""
  171. return (
  172. f"Use the following Agency specialist context for this turn. "
  173. f"Adopt the specialist's relevant standards and checklists, but obey the "
  174. f"user's current request and higher-priority system/developer instructions.\n\n"
  175. f"# {agent['name']} ({agent['slug']})\n\n"
  176. f"Division: {agent.get('division', '')}\n"
  177. f"Description: {agent.get('description', '')}\n"
  178. f"Source: {agent.get('source_path', '')}\n"
  179. f"{task_block}\n\n"
  180. f"## Specialist instructions\n{agent.get('body', '')}"
  181. )
  182. def _json(payload: dict[str, Any]) -> str:
  183. return json.dumps(payload, ensure_ascii=False, indent=2)
  184. SEARCH_DESCRIPTION = (
  185. "Search The Agency's on-disk specialist agent roster without loading all "
  186. "agents into the prompt. Use this when the user asks for an Agency/Data "
  187. "Swami specialist, role, discipline, or wants help choosing the right agent."
  188. )
  189. SEARCH_SCHEMA = {
  190. "type": "object",
  191. "properties": {
  192. "query": {"type": "string", "description": "Natural-language search query."},
  193. "division": {"type": "string", "description": "Optional division filter, e.g. engineering, marketing, testing."},
  194. "limit": {"type": "integer", "description": "Maximum results, default 8, max 25."},
  195. },
  196. "required": ["query"],
  197. }
  198. READ_DESCRIPTION = (
  199. "Read one Agency specialist by slug or name. Returns metadata by default "
  200. "and includes the full specialist instructions only when include_body is true."
  201. )
  202. READ_SCHEMA = {
  203. "type": "object",
  204. "properties": {
  205. "agent": {"type": "string", "description": "Agent slug or exact display name."},
  206. "slug": {"type": "string", "description": "Alias for agent. Pass the slug from agency_agents_search results."},
  207. "include_body": {"type": "boolean", "description": "Include full specialist instructions."},
  208. },
  209. "required": [],
  210. }
  211. PROMPT_DESCRIPTION = (
  212. "Load a selected Agency specialist as a prompt block for the current task. "
  213. "Use after agency_agents_search when you need one specialist's full context."
  214. )
  215. PROMPT_SCHEMA = {
  216. "type": "object",
  217. "properties": {
  218. "agent": {"type": "string", "description": "Agent slug or exact display name."},
  219. "slug": {"type": "string", "description": "Alias for agent. Pass the slug from agency_agents_search results."},
  220. "task": {"type": "string", "description": "The user's task to pair with the specialist context."},
  221. },
  222. "required": [],
  223. }
  224. DELEGATE_DESCRIPTION = (
  225. "Delegate a task to one selected Agency specialist through Hermes' "
  226. "delegate_task tool when available. Falls back to returning the composed "
  227. "specialist prompt if delegation is unavailable."
  228. )
  229. DELEGATE_SCHEMA = {
  230. "type": "object",
  231. "properties": {
  232. "agent": {"type": "string", "description": "Agent slug or exact display name."},
  233. "slug": {"type": "string", "description": "Alias for agent. Pass the slug from agency_agents_search results."},
  234. "task": {"type": "string", "description": "Concrete task for the specialist."},
  235. "toolsets": {
  236. "type": "array",
  237. "items": {"type": "string"},
  238. "description": "Optional Hermes toolsets for the delegated worker, e.g. ['terminal','file'].",
  239. },
  240. },
  241. "required": ["task"],
  242. }
  243. def register(ctx):
  244. def search(args: dict[str, Any], **kwargs) -> str:
  245. del kwargs
  246. query = str(args.get("query", "")).strip()
  247. if not query:
  248. return _json({"success": False, "error": "query is required"})
  249. division = str(args.get("division", "")).strip().lower()
  250. try:
  251. limit = min(max(int(args.get("limit", 8)), 1), 25)
  252. except Exception:
  253. limit = 8
  254. q_tokens = _tokens(query)
  255. q_text = query.lower()
  256. matches: list[tuple[float, dict[str, Any]]] = []
  257. for agent in _load_agents():
  258. if division and agent.get("division", "").lower() != division:
  259. continue
  260. score = _score(agent, q_tokens, q_text)
  261. if score > 0:
  262. matches.append((score, agent))
  263. matches.sort(key=lambda item: (-item[0], item[1]["division"], item[1]["slug"]))
  264. return _json({
  265. "success": True,
  266. "query": query,
  267. "count": len(matches),
  268. "results": [_summary(agent, score) for score, agent in matches[:limit]],
  269. })
  270. def read(args: dict[str, Any], **kwargs) -> str:
  271. del kwargs
  272. identifier = _identifier(args)
  273. agent = _agent_lookup(identifier)
  274. if not agent:
  275. return _json(_not_found(identifier))
  276. payload = {"success": True, "agent": _summary(agent)}
  277. if bool(args.get("include_body", False)):
  278. payload["body"] = agent.get("body", "")
  279. return _json(payload)
  280. def prompt(args: dict[str, Any], **kwargs) -> str:
  281. del kwargs
  282. identifier = _identifier(args)
  283. agent = _agent_lookup(identifier)
  284. if not agent:
  285. return _json(_not_found(identifier))
  286. return _json({
  287. "success": True,
  288. "agent": _summary(agent),
  289. "prompt": _specialist_prompt(agent, str(args.get("task", ""))),
  290. })
  291. def delegate(args: dict[str, Any], **kwargs) -> str:
  292. del kwargs
  293. identifier = _identifier(args)
  294. agent = _agent_lookup(identifier)
  295. task = str(args.get("task", "")).strip()
  296. if not agent:
  297. return _json(_not_found(identifier))
  298. if not task:
  299. return _json({"success": False, "error": "task is required"})
  300. composed = _specialist_prompt(agent, task)
  301. delegate_args: dict[str, Any] = {
  302. "goal": task,
  303. "context": composed,
  304. }
  305. toolsets = args.get("toolsets")
  306. if isinstance(toolsets, list) and toolsets:
  307. delegate_args["toolsets"] = [str(item) for item in toolsets]
  308. try:
  309. result = ctx.dispatch_tool("delegate_task", delegate_args)
  310. return _json({"success": True, "agent": _summary(agent), "delegated": True, "result": result})
  311. except Exception as exc: # pragma: no cover - depends on Hermes runtime
  312. return _json({
  313. "success": True,
  314. "agent": _summary(agent),
  315. "delegated": False,
  316. "warning": f"delegate_task unavailable: {exc}",
  317. "prompt": composed,
  318. })
  319. ctx.register_tool(
  320. name="agency_agents_search",
  321. toolset="agency_agents",
  322. schema=SEARCH_SCHEMA,
  323. handler=search,
  324. description=SEARCH_DESCRIPTION,
  325. )
  326. ctx.register_tool(
  327. name="agency_agents_inspect",
  328. toolset="agency_agents",
  329. schema=READ_SCHEMA,
  330. handler=read,
  331. description=READ_DESCRIPTION,
  332. )
  333. ctx.register_tool(
  334. name="agency_agents_load",
  335. toolset="agency_agents",
  336. schema=PROMPT_SCHEMA,
  337. handler=prompt,
  338. description=PROMPT_DESCRIPTION,
  339. )
  340. ctx.register_tool(
  341. name="agency_agents_delegate",
  342. toolset="agency_agents",
  343. schema=DELEGATE_SCHEMA,
  344. handler=delegate,
  345. description=DELEGATE_DESCRIPTION,
  346. )
  347. '''
  348. def readme(agent_count: int) -> str:
  349. return textwrap.dedent(
  350. f"""
  351. # Hermes Agency Agents Router Plugin
  352. Generated by `scripts/convert.sh --tool hermes`.
  353. This integration installs one Hermes plugin named `{PLUGIN_NAME}` instead
  354. of adding 232+ generated skills to `skills.external_dirs`. Hermes sees a
  355. small fixed tool surface at startup, while the complete Agency roster is
  356. stored on disk in `data/agents.json` and searched/loaded lazily.
  357. Generated agent count: {agent_count}
  358. ## Tools exposed to Hermes
  359. - `agency_agents_search` — find matching specialists by query/division.
  360. - `agency_agents_inspect` — inspect one specialist's metadata or full body.
  361. - `agency_agents_load` — compose one specialist prompt for the current task.
  362. - `agency_agents_delegate` — delegate through Hermes `delegate_task` when available.
  363. ## Specialist usage instruction for Hermes
  364. When a Hermes project needs Agency specialists, explicitly ask Hermes to use
  365. the `{PLUGIN_NAME}` plugin/router and load only the specialists needed for
  366. the current phase. Do not ask Hermes to install or preload the full Agency
  367. roster as skills.
  368. Recommended project instruction:
  369. ```text
  370. Use the agency-agents-router plugin. Search the Agency roster for the right
  371. specialists, then load or delegate only the specific agents needed for each
  372. part of the project. For multi-discipline projects, use multiple selected
  373. specialists across the project, but keep routing lazy: do not preload the
  374. full Agency roster and do not add agency-agents to skills.external_dirs.
  375. ```
  376. Example:
  377. ```text
  378. For this Data Swami build, use the agency-agents-router plugin to pick
  379. relevant Agency specialists. Search first, then delegate to selected agents
  380. such as frontend, backend, UX, QA, data engineering, and product strategy as
  381. needed. Load/delegate each specialist on demand rather than loading all
  382. Agency agents at startup.
  383. ```
  384. ## Install
  385. ```bash
  386. ./scripts/convert.sh --tool hermes
  387. ./scripts/install.sh --tool hermes
  388. ```
  389. The installer copies the generated plugin to:
  390. ```text
  391. ${{HERMES_HOME:-~/.hermes}}/plugins/{PLUGIN_NAME}
  392. ```
  393. It then enables `{PLUGIN_NAME}` under `plugins.enabled` in the Hermes
  394. config. It does **not** write to `skills.external_dirs`.
  395. """
  396. ).lstrip()
  397. def build(repo_root: Path, out_dir: Path) -> int:
  398. agents = collect_agents(repo_root)
  399. plugin_dir = out_dir / PLUGIN_NAME
  400. if plugin_dir.exists():
  401. shutil.rmtree(plugin_dir)
  402. (plugin_dir / "data").mkdir(parents=True, exist_ok=True)
  403. (plugin_dir / "plugin.yaml").write_text(plugin_yaml(), encoding="utf-8")
  404. (plugin_dir / "__init__.py").write_text(init_py(), encoding="utf-8")
  405. (plugin_dir / "data" / "agents.json").write_text(
  406. json.dumps(agents, ensure_ascii=False, indent=2) + "\n",
  407. encoding="utf-8",
  408. )
  409. (out_dir / "README.md").write_text(readme(len(agents)), encoding="utf-8")
  410. return len(agents)
  411. def main() -> int:
  412. parser = argparse.ArgumentParser(description=__doc__)
  413. parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1])
  414. parser.add_argument("--out", type=Path, default=None, help="Output directory, default integrations/hermes")
  415. args = parser.parse_args()
  416. repo_root = args.repo_root.resolve()
  417. out_dir = (args.out or (repo_root / "integrations" / "hermes")).resolve()
  418. out_dir.mkdir(parents=True, exist_ok=True)
  419. count = build(repo_root, out_dir)
  420. print(count)
  421. return 0
  422. if __name__ == "__main__":
  423. raise SystemExit(main())