* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule The PLUGIN-COMPAT layer (2776813df3+d63e380324+0a5164cebe) kept pre-#102117 import paths alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader has already been skipping plugins that use the old paths. This removes the layer itself: - 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party names, restored dead definitions) and the three re-export stub modules (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils) - COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step - the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section, the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the `plugins.allow_deprecated_imports` escape hatch An external plugin that still imports an old path now fails to load with its ImportError as the reason in `hermes plugins list`, the same path as any broken plugin. hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect, summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout swap (tests/compat/old_updater_surface.json). In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its `shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed `run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the seam production reads, like every sibling test in that file. * chore: retrigger CI (zero-job startup_failure phantom) * test: drop resolution allowlist rows for the two deleted which() sites hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available (a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires their allowlist entries go with them.
122 lines
5.6 KiB
Python
122 lines
5.6 KiB
Python
"""Context engine plugin discovery: bundled ``plugins/context_engine/<name>/`` then user
|
|
``$HERMES_HOME/plugins/<name>/`` (bundled wins on collision) → ``ContextEngine``. Separate from the
|
|
general plugin system: ``context.engine`` in config.yaml names the active engine (default
|
|
``"compressor"``, the built-in ContextCompressor), so a user-installed engine needs no
|
|
``plugins.enabled`` entry to be selectable."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import List, Optional, Tuple
|
|
|
|
from plugins import plugin_loader as _loader
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_CONTEXT_ENGINE_PLUGINS_DIR = Path(__file__).parent
|
|
# Synthetic parent package for user-installed engines (keeps them out of the bundled namespace).
|
|
_USER_NAMESPACE = "_hermes_user_context_engine"
|
|
|
|
|
|
def _is_context_engine_dir(path: Path) -> bool:
|
|
"""Cheap text heuristic: ``__init__.py`` mentions the context engine contract."""
|
|
init_file = path / "__init__.py"
|
|
try:
|
|
source = init_file.read_text(errors="replace", encoding="utf-8-sig")[:8192]
|
|
except OSError:
|
|
return False
|
|
return "register_context_engine" in source or "ContextEngine" in source
|
|
|
|
|
|
def _iter_engine_dirs() -> List[Tuple[str, Path]]:
|
|
"""``(name, path)`` for bundled then user engines; bundled wins on collisions."""
|
|
dirs = [(child.name, child) for child in _loader.iter_plugin_dirs(_CONTEXT_ENGINE_PLUGINS_DIR)]
|
|
seen = {name for name, _ in dirs}
|
|
user_dir = _loader.user_plugins_dir()
|
|
if user_dir:
|
|
dirs.extend((child.name, child) for child in _loader.iter_plugin_dirs(user_dir)
|
|
if child.name not in seen and _is_context_engine_dir(child))
|
|
return dirs
|
|
|
|
|
|
def discover_context_engines() -> List[Tuple[str, str, bool]]:
|
|
"""Return ``[(name, description, is_available), ...]`` for every bundled and user engine."""
|
|
return [(name, _loader.read_plugin_description(child),
|
|
_loader.probe_availability(lambda c=child: _load_engine_from_dir(c)))
|
|
for name, child in _iter_engine_dirs()]
|
|
|
|
|
|
def find_engine_dir(name: str) -> Optional[Path]:
|
|
"""Resolve an engine name to its directory (bundled first, then user-installed)."""
|
|
bundled = _CONTEXT_ENGINE_PLUGINS_DIR / name
|
|
if bundled.is_dir():
|
|
return bundled
|
|
user_dir = _loader.user_plugins_dir()
|
|
user = user_dir / name if user_dir else None
|
|
return user if user and user.is_dir() and _is_context_engine_dir(user) else None
|
|
|
|
|
|
def load_context_engine(name: str) -> Optional["ContextEngine"]: # noqa: F821
|
|
"""Load a ContextEngine instance by name; None if not found or it fails to load."""
|
|
engine_dir = find_engine_dir(name)
|
|
if engine_dir is None:
|
|
logger.debug("Context engine '%s' not found in bundled or user plugins", name)
|
|
return None
|
|
return _loader.load_named(
|
|
name, engine_dir, _load_engine_from_dir, kind="Context engine", noun="engine", logger=logger
|
|
)
|
|
|
|
|
|
def _load_engine_from_dir(engine_dir: Path) -> Optional["ContextEngine"]: # noqa: F821
|
|
"""Import an engine module and extract its ContextEngine (register(ctx) or subclass)."""
|
|
from agent.context_engine import ContextEngine
|
|
name = engine_dir.name
|
|
is_bundled = engine_dir.parent == _CONTEXT_ENGINE_PLUGINS_DIR
|
|
module_name = f"plugins.context_engine.{name}" if is_bundled else f"{_USER_NAMESPACE}.{name}"
|
|
mod = _loader.load_plugin_module(
|
|
module_name, engine_dir, parents=("plugins", "plugins.context_engine"), logger=logger,
|
|
synthetic_namespace=None if is_bundled else _USER_NAMESPACE)
|
|
return mod and _loader.instance_from_module(
|
|
mod, collector=_EngineCollector(engine_name=name), collected_attr="engine",
|
|
base_cls=ContextEngine, name=name, logger=logger)
|
|
|
|
|
|
class _EngineCollector(_loader.NoopPluginContext):
|
|
"""Captures register_context_engine; forwards register_command to the global plugin command
|
|
registry so engine slash commands behave like plugin ones."""
|
|
|
|
def __init__(self, engine_name: str = ""):
|
|
self.engine = None
|
|
self._engine_name = engine_name or "context_engine"
|
|
|
|
def register_context_engine(self, engine):
|
|
self.engine = engine
|
|
|
|
def register_command(self, name: str, handler, description: str = "", args_hint: str = "") -> None:
|
|
clean = (name or "").lower().strip().lstrip("/").replace(" ", "-")
|
|
if not clean:
|
|
logger.warning("Context engine '%s' tried to register a command with an empty name.",
|
|
self._engine_name)
|
|
return
|
|
conflict = "Context engine '%s' tried to register command '/%s' which %s Skipping."
|
|
try:
|
|
from hermes_cli.commands import resolve_command
|
|
if resolve_command(clean) is not None:
|
|
logger.warning(conflict, self._engine_name, clean, "conflicts with a built-in command.")
|
|
return
|
|
except Exception:
|
|
pass
|
|
try:
|
|
from hermes_cli.plugins import get_plugin_manager
|
|
manager = get_plugin_manager()
|
|
if clean in manager._plugin_commands:
|
|
logger.warning(conflict, self._engine_name, clean, "is already registered by a plugin.")
|
|
return
|
|
manager._plugin_commands[clean] = {
|
|
"handler": handler, "description": description or "Context engine command",
|
|
"plugin": f"context-engine:{self._engine_name}", "args_hint": (args_hint or "").strip()}
|
|
logger.debug("Context engine '%s' registered command: /%s", self._engine_name, clean)
|
|
except Exception as exc:
|
|
logger.debug("Context engine '%s' could not register /%s: %s", self._engine_name, clean, exc)
|