Files
hermes-agent/agent/skill_bundles.py
Teknium be5c6a2fd8 refactor(agent/prompt): remove dead code, unify duplicated helpers, compact docstrings across prompt/skill/redaction modules
Dead (zero refs): coding_system_blocks, get_friendly_tool_labels, get_scan_ordered_skills_dirs,
_project_quarantine_cache_clear, clear_stable_prefixes, _redact_http_request_target_query_params,
_has_http_method_substring, PromptCachePlan.marker_count, display _diff_* colour thunks (-> _diff_ansi),
pass-through RedactingFormatter.__init__.
Unified: _slugify -> slugify_skill_name; reload diff -> diff_command_snapshots; _is_summary_item ->
is_compaction_summary_message alias; sanitizer walkers -> _sanitize_messages/_sanitize_structure;
assignment redaction passes -> _redact_assignments/_should_redact_assignment; quiet-mode tool lines -> _CUTE_LINES table.
2026-09-02 13:53:57 -07:00

293 lines
9.6 KiB
Python

"""Skill bundles — aliases that load multiple skills under one slash command.
Bundles are YAML files in ``<HERMES_HOME>/skill-bundles/`` (``name``,
``description``, ``skills: [...]``, optional ``instruction``; the file stem is
the fallback name). ``/<bundle>`` loads every member skill into one user
message. If a bundle and a skill share a slug, the bundle wins — slash dispatch
checks bundles first, on purpose.
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import yaml
from hermes_constants import get_hermes_home
from agent.skill_commands import diff_command_snapshots, slugify_skill_name as _slugify
logger = logging.getLogger(__name__)
_bundles_cache: Dict[str, Dict[str, Any]] = {}
_bundles_cache_mtime: Optional[float] = None
def _bundles_dir() -> Path:
"""Bundles directory: ``HERMES_BUNDLES_DIR`` override (tests) or ``<HERMES_HOME>/skill-bundles``."""
override = os.environ.get("HERMES_BUNDLES_DIR")
if override:
return Path(override).expanduser()
return get_hermes_home() / "skill-bundles"
def _iter_bundle_files() -> List[Path]:
base = _bundles_dir()
if not base.exists():
return []
files: List[Path] = []
for ext in ("*.yaml", "*.yml"):
files.extend(sorted(base.glob(ext)))
return files
def _max_mtime(files: List[Path]) -> float:
"""Highest mtime across the bundle files plus the dir itself (dir mtime catches deletions)."""
mtimes = []
for f in [_bundles_dir(), *files]:
try:
mtimes.append(f.stat().st_mtime)
except OSError:
continue
return max(mtimes) if mtimes else 0.0
def _load_bundle_file(path: Path) -> Optional[Dict[str, Any]]:
"""Parse one bundle YAML; ``None`` (logged) on any error so a broken bundle can't break discovery."""
try:
raw = path.read_text(encoding="utf-8")
except OSError as exc:
logger.warning("Could not read bundle %s: %s", path, exc)
return None
try:
data = yaml.safe_load(raw)
except yaml.YAMLError as exc:
logger.warning("Invalid YAML in bundle %s: %s", path, exc)
return None
if not isinstance(data, dict):
logger.warning("Bundle %s is not a mapping; skipping", path)
return None
name = str(data.get("name") or path.stem).strip()
if not name:
logger.warning("Bundle %s has no name; skipping", path)
return None
skills = data.get("skills") or []
if not isinstance(skills, list) or not skills:
logger.warning("Bundle %s has no skills list; skipping", path)
return None
skills = [str(s).strip() for s in skills if str(s).strip()]
if not skills:
logger.warning("Bundle %s has empty skills list; skipping", path)
return None
description = str(data.get("description") or "").strip()
instruction = str(data.get("instruction") or "").strip()
slug = _slugify(name)
if not slug:
logger.warning("Bundle %s yielded empty slug; skipping", path)
return None
return {
"name": name,
"slug": slug,
"description": description or f"Load {len(skills)} skills as a bundle",
"skills": skills,
"instruction": instruction,
"path": str(path),
}
def scan_bundles() -> Dict[str, Dict[str, Any]]:
"""Rebuild the ``"/slug"`` -> bundle info cache; duplicate slugs keep the first (alphabetical)."""
global _bundles_cache, _bundles_cache_mtime
files = _iter_bundle_files()
out: Dict[str, Dict[str, Any]] = {}
for f in files:
info = _load_bundle_file(f)
if not info:
continue
key = f"/{info['slug']}"
if key in out:
logger.warning(
"Duplicate bundle slug %s from %s; keeping %s",
key, f, out[key]["path"],
)
continue
out[key] = info
_bundles_cache = out
_bundles_cache_mtime = _max_mtime(files)
return out
def get_skill_bundles() -> Dict[str, Dict[str, Any]]:
"""Current bundle mapping; rescans only when a bundle file or the dir mtime changed."""
current_mtime = _max_mtime(_iter_bundle_files())
if not _bundles_cache or _bundles_cache_mtime != current_mtime:
scan_bundles()
return _bundles_cache
def resolve_bundle_command_key(command: str) -> Optional[str]:
"""Resolve a user-typed command to its ``/slug`` key (``_`` ≡ ``-``, as Telegram rewrites hyphens)."""
if not command:
return None
cmd_key = f"/{command.replace('_', '-')}"
return cmd_key if cmd_key in get_skill_bundles() else None
def reload_bundles() -> Dict[str, Any]:
"""Re-scan and return an ``added``/``removed``/``unchanged``/``total`` diff (same shape as reload_skills)."""
def _snapshot(cmds: Dict[str, Dict[str, Any]]) -> Dict[str, str]:
return {k.lstrip("/"): (v or {}).get("description", "") for k, v in cmds.items()}
before = _snapshot(_bundles_cache)
return diff_command_snapshots(before, _snapshot(scan_bundles()))
def list_bundles() -> List[Dict[str, Any]]:
"""Return a sorted list of bundle info dicts for display."""
return sorted(get_skill_bundles().values(), key=lambda b: b["slug"])
def build_bundle_invocation_message(
cmd_key: str,
user_instruction: str = "",
task_id: str | None = None,
platform: str | None = None,
) -> Optional[Tuple[str, List[str], List[str]]]:
"""Build the user message for a bundle invocation.
Returns ``(message, loaded_skill_names, missing_skill_names)`` or ``None``
if the bundle wasn't found. Uninstalled members are skipped with a note.
Disabled members are skipped too: bundles load via ``_load_skill_payload``,
bypassing the scan-time disabled filter, so the list is re-applied here.
``platform`` scopes that check (gateway passes it; None resolves from env).
"""
info = get_skill_bundles().get(cmd_key)
if not info:
return None
# Late import keeps skill_bundles cheap to import (no tools/* at import time).
from agent.skill_commands import _load_skill_payload, _render_skill_block, _scaffold_header
try:
from agent.skill_utils import get_disabled_skill_names
disabled_names = get_disabled_skill_names(platform=platform)
except Exception:
disabled_names = set()
loaded_names: List[str] = []
missing: List[str] = []
disabled: List[str] = []
skill_blocks: List[str] = []
seen: set[str] = set()
bundle_name = info["name"]
for skill_id in info["skills"]:
identifier = (skill_id or "").strip()
if not identifier or identifier in seen:
continue
seen.add(identifier)
loaded = _load_skill_payload(identifier, task_id=task_id)
if not loaded:
missing.append(identifier)
continue
skill_name = loaded[2]
# Gate on the loaded skill's canonical name (identifiers may be paths or aliases).
if skill_name in disabled_names or identifier in disabled_names:
disabled.append(skill_name or identifier)
continue
skill_blocks.append(_render_skill_block(
loaded,
f'[Loaded as part of the "{bundle_name}" skill bundle.]',
task_id,
))
loaded_names.append(skill_name)
if not skill_blocks:
return None
header = _scaffold_header(
f'"{bundle_name}" skill bundle',
loaded_names,
lead_lines=[f"Bundle: {bundle_name}"],
missing=missing,
disabled=disabled,
extra_instruction=info.get("instruction") or "",
user_instruction=user_instruction,
)
return ("\n\n".join([header, *skill_blocks]), loaded_names, missing)
# ── File-level CRUD — used by `hermes bundles` ─────────────────────────────
def bundle_path_for(name: str) -> Path:
"""Return the canonical filesystem path for a bundle name."""
slug = _slugify(name)
if not slug:
raise ValueError(f"Bundle name {name!r} normalizes to an empty slug")
return _bundles_dir() / f"{slug}.yaml"
def save_bundle(
name: str,
skills: List[str],
description: str = "",
instruction: str = "",
overwrite: bool = False,
) -> Path:
"""Write a bundle to disk and refresh the cache.
Raises ``FileExistsError`` if the target exists and not ``overwrite``;
``ValueError`` for unusable inputs.
"""
name = (name or "").strip()
if not name:
raise ValueError("Bundle name is required")
cleaned_skills = [str(s).strip() for s in skills if str(s).strip()]
if not cleaned_skills:
raise ValueError("Bundle must reference at least one skill")
path = bundle_path_for(name)
if path.exists() and not overwrite:
raise FileExistsError(f"Bundle already exists at {path}")
path.parent.mkdir(parents=True, exist_ok=True)
payload: Dict[str, Any] = {"name": name, "skills": cleaned_skills}
if description:
payload["description"] = description
if instruction:
payload["instruction"] = instruction
path.write_text(
yaml.safe_dump(payload, sort_keys=False, allow_unicode=True),
encoding="utf-8",
)
scan_bundles()
return path
def delete_bundle(name: str) -> Path:
"""Delete a bundle by name and return its path; ``FileNotFoundError`` if absent."""
path = bundle_path_for(name)
if not path.exists():
raise FileNotFoundError(f"No bundle at {path}")
path.unlink()
scan_bundles()
return path
def get_bundle(name: str) -> Optional[Dict[str, Any]]:
"""Look up a bundle by name (slug-normalized)."""
return get_skill_bundles().get(f"/{_slugify(name)}")