Files
hermes-agent/agent/skill_bundles.py
ethernet 284dbaf537 fix(pm): isolate bootstrap dependencies and unify YAML on ruamel
Activation reaches plugin discovery before the application dependencies
exist. Give PM its own locked Python project and runtime so it can install
or repair the application without importing that dependency tree.

Keep PM outside the application workspace. A shared uv workspace resolves
the application graph and cannot provide this isolation. Route mutations
through an isolated worker and preserve transaction callbacks, cancellation,
custom package registrations, and correlated receipts.

Use the same runtime builder for source installs and packaged payloads.
Keep offline wheelhouse support in that builder. Nix builds the independent
PM lock as a separate derivation. Refuse lazy-disabled bootstrap before
installing tools or dependencies.

Move first-party YAML readers and writers to ruamel. Keep the application
lock's transitive PyYAML requirements for third-party packages.

Verification:
- Focused canonical Python suite: 177 passed, 1 host-gated skip.
- Electron backend probes: 12 passed. Electron typecheck passed.
- Both uv locks, scoped lint, Bash syntax, and whitespace checks passed.
- Cold activation, corrupt-app repair, offline staging, and relocation ran.
- Built and exercised the Nix PM runtime and standalone YAML merge script.

Six broader caller test files retain the same 24 failing test IDs as an
archive of HEAD. The existing real-home guard blocks those tests before
they can exercise the affected paths. No full-suite pass is claimed.
Native Windows signing and full Bionic package execution remain unverified.
2026-09-11 12:23:51 -04:00

215 lines
9.0 KiB
Python

"""Skill bundles — aliases that load multiple skills under one slash command.
YAML files in ``<HERMES_HOME>/skill-bundles/`` (``name``, ``description``,
``skills: [...]``, optional ``instruction``; file stem = 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 hermes_yaml as yaml
from hermes_constants import get_hermes_home
from agent.skill_commands import command_snapshot, diff_command_snapshots, resolve_slash_key, 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")
return Path(override).expanduser() if override else get_hermes_home() / "skill-bundles"
def _iter_bundle_files() -> List[Path]:
base = _bundles_dir()
return [f for ext in ("*.yaml", "*.yml") for f in sorted(base.glob(ext))] if base.exists() else []
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, default=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:
data = yaml.safe_load(path.read_text(encoding="utf-8-sig"))
except OSError as exc:
logger.warning("Could not read bundle %s: %s", path, exc)
return None
except yaml.YAMLError as exc:
logger.warning("Invalid YAML in bundle %s: %s", path, exc)
return None
def _skip(reason: str) -> None:
logger.warning("Bundle %s %s; skipping", path, reason)
if not isinstance(data, dict):
return _skip("is not a mapping")
name = str(data.get("name") or path.stem).strip()
if not name:
return _skip("has no name")
raw_skills = data.get("skills") or []
if not isinstance(raw_skills, list) or not raw_skills:
return _skip("has no skills list")
skills = [str(s).strip() for s in raw_skills if str(s).strip()]
if not skills:
return _skip("has empty skills list")
slug = _slugify(name)
if not slug:
return _skip("yielded empty slug")
return {
"name": name, "slug": slug, "skills": skills, "path": str(path),
"description": str(data.get("description") or "").strip() or f"Load {len(skills)} skills as a bundle",
"instruction": str(data.get("instruction") or "").strip(),
}
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)."""
return resolve_slash_key(command, get_skill_bundles())
def reload_bundles() -> Dict[str, Any]:
"""Re-scan and return an ``added``/``removed``/``unchanged``/``total`` diff (same shape as reload_skills)."""
before = command_snapshot(_bundles_cache)
return diff_command_snapshots(before, command_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: ``(message,
loaded_skill_names, missing_skill_names)`` or ``None`` if the bundle wasn't
found. Uninstalled members are skipped with a note; disabled ones too, since
``_load_skill_payload`` bypasses the scan-time filter (``platform`` scopes
that check — gateway passes it, None resolves from env).
Disabled skills are also skipped: bundles load members via ``_load_skill_payload`` directly, bypassing
the scan-time disabled filter in ``get_skill_commands()``, so the disabled list must be re-applied here.
``platform`` scopes the check to a specific platform's ``skills.platform_disabled`` config (gateway
dispatch passes it explicitly because the gateway handles multiple platforms in one process); when
*None*, the platform resolves from session env vars and the global disabled list still applies. Mirrors
the stacked-skill gate in gateway dispatch (#58888).
"""
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 _disabled_skill_names, _load_skill_blocks, _load_skill_payload, _scaffold_header
bundle_name = info["name"]
loaded_names, missing, disabled, skill_blocks = _load_skill_blocks(
[(skill_id or "").strip() for skill_id in info["skills"]],
lambda identifier: _load_skill_payload(identifier, task_id=task_id),
lambda _name: f'[Loaded as part of the "{bundle_name}" skill bundle.]',
task_id,
disabled_names=_disabled_skill_names(platform),
)
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}
payload.update({k: v for k, v in (("description", description), ("instruction", instruction)) if v})
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)}")
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import re # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----