From 2efc2b0d9a6c31629f50209a26e7307633ddccbf Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:39:46 -0700 Subject: [PATCH] refactor(cli/plugins): move plugin loading/deferred-platform/portable methods into PluginLoaderMixin (plugins_loader.py) --- hermes_cli/plugins.py | 517 +-------------------------------- hermes_cli/plugins_loader.py | 535 +++++++++++++++++++++++++++++++++++ 2 files changed, 548 insertions(+), 504 deletions(-) create mode 100644 hermes_cli/plugins_loader.py diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 214d1fa8ca..8b4c1883a4 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -14,9 +14,7 @@ import asyncio import builtins import contextvars import copy -import hashlib import importlib.metadata -import importlib.util import inspect import json import logging @@ -27,18 +25,12 @@ import sys import threading import time import types -from contextlib import contextmanager, suppress +from contextlib import suppress from dataclasses import dataclass, field -from functools import wraps from pathlib import Path from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple, Union -from hermes_constants import ( - get_hermes_home, - hermes_home_key, - reset_hermes_home_override, - set_hermes_home_override, -) +from hermes_constants import get_hermes_home, hermes_home_key from registration_lifecycle import replacement_coordinator from utils import env_var_enabled from hermes_cli.config import cfg_get, load_config_readonly @@ -48,11 +40,7 @@ from hermes_cli.plugin_capabilities import ( # noqa: F401 — re-exported VALID_CAPABILITY_IDS, plugin_capability_granted, ) -from hermes_cli.relay_plugin_cutover import ( - LEGACY_RELAY_PLUGIN_KEYS, - RELAY_PLUGINS_CONFIG_ENV, - legacy_relay_plugin_keys, -) +from hermes_cli.relay_plugin_cutover import RELAY_PLUGINS_CONFIG_ENV, legacy_relay_plugin_keys from hermes_cli.plugins_manifest import ( # noqa: F401 — re-exported parse_manifest_file, portable_plugin_manifest, @@ -85,6 +73,15 @@ from hermes_cli.plugins_discovery import ( # noqa: F401 — re-exported _select_entry_point_group, discover_entrypoint_manifests, ) +from hermes_cli.plugins_loader import ( # noqa: F401 — re-exported + PluginLoaderMixin, + _NS_PARENT, + _MODULE_NAMESPACE_LOCK, + _BARE_MODULE_SCOPE, + _evict_modules, + _serialized_replacement, + _plugin_home_scope, +) from hermes_cli.plugins_state import ( # noqa: F401 — re-exported _PLUGIN_SETTING_RESERVED_ROOTS, _PLUGIN_SETTING_SEGMENT_RE, @@ -317,38 +314,6 @@ _EVENT_EMIT_DEPTH_CAP = 8 _EVENT_PENDING_CAP = 64 _EVENT_WORKER_STOP = object() -_NS_PARENT = "hermes_plugins" -_MODULE_NAMESPACE_LOCK = threading.RLock() -_BARE_MODULE_SCOPE: Dict[str, str] = {} - - -def _evict_modules(module_name: str) -> None: - """Drop ``module_name`` and every ``module_name.*`` submodule from ``sys.modules``.""" - prefix = f"{module_name}." - for name in [n for n in sys.modules if n == module_name or n.startswith(prefix)]: - del sys.modules[name] - - -def _serialized_replacement(method): - """Make snapshot → write → lease attachment one atomic transaction.""" - @wraps(method) - def wrapped(*args, **kwargs): - with replacement_coordinator.transaction(): - return method(*args, **kwargs) - - return wrapped - - -@contextmanager -def _plugin_home_scope(home: Path): - """Bind discovery and loading to the manager's immutable Hermes home.""" - token = set_hermes_home_override(home) - try: - yield - finally: - reset_hermes_home_override(token) - - _env_enabled = env_var_enabled # imported by plugins/memory @@ -1801,7 +1766,7 @@ def _pre_tool_call_timeout_block() -> Dict[str, str]: return {"action": "block", "message": _PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE} -class PluginManager: +class PluginManager(PluginLoaderMixin): """Central manager that discovers, loads, and invokes plugins.""" def __init__(self, scope_key: Optional[str] = None) -> None: @@ -2449,462 +2414,6 @@ class PluginManager: # -- loading --------------------------------------------------------------- - def _platform_name_from_manifest(self, manifest: PluginManifest) -> str: - """Derive the platform name without importing the adapter: strip a trailing ``-platform`` - from the manifest name, else the directory basename (the bundled convention).""" - name = manifest.name or "" - if name.endswith("-platform"): - return name[: -len("-platform")] - if manifest.path: - return Path(manifest.path).name - return name - - @_serialized_replacement - def _register_deferred_platform(self, manifest: PluginManifest) -> None: - """Register a lazy loader for a bundled platform: the adapter imports only when the - ``platform_registry`` is first asked for it; a placeholder ``LoadedPlugin`` keeps it visible - in ``hermes plugins list`` until then.""" - lookup_key = manifest.key or manifest.name - platform_name = self._platform_name_from_manifest(manifest) - - loaded = LoadedPlugin(manifest=manifest, enabled=True) - loaded.deferred = True - self._plugins[lookup_key] = loaded - - try: - from gateway.platform_registry import platform_registry - - scope = self.scope_key - - def _loader(_manifest: PluginManifest = manifest) -> None: - # Lock before checking cancellation: if an unload won the race it restored the - # predecessor and this loader must publish nothing; if loading won, unload waits - # and disposes the completed set. - with self._discovery_lock, _plugin_home_scope(self.home_path): - if platform_registry.is_deferred_load_cancelled( - platform_name, scope=scope - ): - return - self._load_plugin_scoped(_manifest) - - previous = platform_registry.snapshot_registration(platform_name, scope=scope) - platform_registry.register_deferred(platform_name, _loader, scope=scope) - current = platform_registry.snapshot_registration(platform_name, scope=scope) - if current[0] is None and current[1] is _loader: - self._plugin_platform_names.add(platform_name) - self._track_scoped_registration( - manifest, "platform", platform_name, platform_registry, current, previous, - finalize=lambda: self._remove_platform_name_if_unowned(platform_name), - ) - logger.debug( - "Registered deferred platform loader: %s (plugin=%s)", - platform_name, - lookup_key, - ) - except Exception: - # Fall back to eager loading so the platform is never silently lost. - logger.debug( - "Deferred platform registration failed for '%s'; eager-loading", - lookup_key, - exc_info=True, - ) - self._load_plugin(manifest) - return - - self._register_deferred_platform_tools(manifest, loaded) - - def _register_deferred_platform_tools( - self, manifest: PluginManifest, loaded: LoadedPlugin - ) -> None: - """Register a deferred platform's *client* tools without its adapter. - - Deferring the plugin would otherwise defer its outbound tools too, so in CLI/TUI processes - (which never materialize platforms) they would be missing from ``hermes tools`` and dropped - from ``platform_toolsets``. Opt-in is explicit via ``provides_tools``; tools live in a - ``tools`` submodule so ``__init__`` stays import-light. - """ - if not manifest.provides_tools: - return - - lookup_key = manifest.key or manifest.name - plugin_dir = Path(manifest.path) if manifest.path else None - if plugin_dir is None or not (plugin_dir / "tools.py").is_file(): - # Declared but undeliverable — staying quiet reproduces the very symptom this fixes. - logger.warning( - "Plugin '%s' declares provides_tools %s but has no tools.py; " - "those tools will not be available in CLI/TUI sessions.", - lookup_key, - list(manifest.provides_tools), - ) - return - - before = set(self._plugin_tool_names) # lets the failure path credit partial registrations - try: - module = self._load_directory_module(manifest) - # Record the module even if nothing registers: the package body has run, so - # materializing the adapter later must reuse it rather than execute it twice. - loaded.module = module - self._predeclared_modules[lookup_key] = module - - tools_module = importlib.import_module(f"{module.__name__}.tools") - register_tools = getattr(tools_module, "register_tools", None) - if register_tools is None: - logger.warning( - "Plugin '%s' declares provides_tools %s but its tools.py " - "has no register_tools(ctx); those tools will not be " - "available in CLI/TUI sessions.", - lookup_key, - list(manifest.provides_tools), - ) - return - - register_tools(PluginContext(manifest, self)) - registered = [t for t in self._plugin_tool_names if t not in before] - - loaded.tools_registered = registered - self._predeclared_tools[lookup_key] = registered - logger.debug( - "Deferred platform '%s': pre-registered %d client tool(s) %s", - lookup_key, - len(registered), - registered, - ) - except Exception as exc: - # Tools registered before the raise are live: credit them or `hermes plugins list` - # under-reports (and _load_plugin's later diff would miss them too). - partial = [t for t in self._plugin_tool_names if t not in before] - if partial: - loaded.tools_registered = partial - self._predeclared_tools[lookup_key] = partial - - # Never break discovery (the platform stays deferred), but a broken tools.py IS the - # symptom, so warn — and say where it failed, which is what the operator needs first. - declared = len(manifest.provides_tools) - if not partial: - scope = f"before registering any of its {declared} declared tool(s)" - elif len(partial) >= declared: - scope = f"after registering all {declared} declared tool(s)" - else: - scope = f"after registering {len(partial)} of {declared} declared tool(s)" - logger.warning( - "Plugin '%s': client-tool pre-registration failed %s (%s).%s", - lookup_key, - scope, - exc, - "" if len(partial) >= declared else - " The remainder will be missing from CLI/TUI sessions.", - exc_info=_PLUGINS_DEBUG, - ) - - def _warn_python_dependencies(self, manifest: PluginManifest) -> None: - """Warn about missing declared pip dependencies with an install hint — NEVER auto-install.""" - deps = manifest.python_dependencies - if not deps: - return - key = manifest.key or manifest.name - missing: List[str] = [] - for req in deps: - # Best-effort presence probe on the distribution name. - dist = re.split(r"[<>=!~\[;\s]", req, maxsplit=1)[0].strip() - if not dist: - continue - try: - importlib.metadata.version(dist) - except importlib.metadata.PackageNotFoundError: - missing.append(req) - except Exception: - continue - if missing: - logger.warning( - "Plugin %s declares Python dependencies that are not " - "installed: %s. Hermes does not install plugin dependencies " - "automatically; install them yourself, e.g.: pip install %s", - key, ", ".join(missing), - " ".join(f"'{m}'" for m in missing), - ) - else: - logger.debug("Plugin %s python_dependencies satisfied: %s", key, ", ".join(deps)) - - def _validate_plugin_config_schema(self, manifest: PluginManifest) -> None: - """Warn (never block) on plugins.entries. settings that violate config_schema.""" - if not manifest.config_schema: - return - plugin_id = manifest.key or manifest.name - settings: Mapping[str, Any] = {} - try: - from hermes_cli.config import load_config - - cfg = load_config() or {} - entries = (cfg.get("plugins") or {}).get("entries") or {} - entry = entries.get(plugin_id) if isinstance(entries, Mapping) else None - raw = entry.get("settings") if isinstance(entry, Mapping) else None - if not isinstance(raw, Mapping): - # Migration fallback mirroring ctx.get_config. - raw = entry.get("config") if isinstance(entry, Mapping) else None - settings = raw if isinstance(raw, Mapping) else {} - except Exception: - settings = {} - for warning in validate_config_schema( - plugin_id, manifest.config_schema, settings - ): - logger.warning("Plugin %s config: %s", plugin_id, warning) - - def _load_plugin(self, manifest: PluginManifest) -> None: - """Import a plugin module and call its ``register(ctx)`` function.""" - with self._discovery_lock, _plugin_home_scope(self.home_path): - self._load_plugin_scoped(manifest) - - def _load_plugin_scoped(self, manifest: PluginManifest) -> None: - """Load one plugin with the manager's home bound as current.""" - loaded = LoadedPlugin(manifest=manifest) - logger.debug( - "Loading plugin '%s' (source=%s, kind=%s, path=%s)", - manifest.key or manifest.name, manifest.source, manifest.kind, manifest.path, - ) - - if manifest.portable: - self._load_portable_plugin(manifest, loaded) - return - - registration_start = len(self._registration_order) - plugin_key = manifest.key or manifest.name - _module_name = self._policy_module_name(manifest) - self._track_tool_override_policy(manifest, _module_name) - try: - # Reuse a deferred platform's already-imported package so its body doesn't run twice. - preloaded = self._predeclared_modules.pop(plugin_key, None) - if preloaded is not None: - module = preloaded - elif manifest.source in {"user", "project", "bundled"}: - module = self._load_directory_module(manifest, module_name=_module_name) - else: - module = self._load_entrypoint_module(manifest) - - loaded.module = module - register_fn = getattr(module, "register", None) - if register_fn is None: - loaded.error = "no register() function" - logger.warning("Plugin '%s' has no register() function", manifest.name) - else: - register_fn(PluginContext(manifest, self)) - self._attribute_registrations(loaded, plugin_key, registration_start) - loaded.enabled = True - - except Exception as exc: - owned = [ - registration - for registration in self._registration_order - if registration.plugin_key == plugin_key - ] - self._dispose_registrations(owned) - self._forget_registrations(owned) - loaded.error = str(exc) - # register() may have subscribed before raising; a failed plugin must leave no callable - # reachable from later event dispatch. - self._remove_plugin_subscriptions(plugin_key) - logger.warning( - "Failed to load plugin '%s': %s", - manifest.name, exc, exc_info=_PLUGINS_DEBUG, - ) - # The failure path swept this plugin's whole ledger (not just the registration_start slice), - # so discovery-time pre-registrations are gone too. - if not loaded.enabled: - self._predeclared_tools.pop(plugin_key, None) - self._plugins[manifest.key or manifest.name] = loaded - - def _track_tool_override_policy(self, manifest: PluginManifest, module_name: str) -> None: - """Install the plugin's tool-override policy in tools.registry as a ledger-owned lease.""" - from tools.registry import registry as _registry - - with replacement_coordinator.transaction(): - previous_policy = _registry.snapshot_plugin_override_policy( - module_name, scope=self.scope_key - ) - current_policy = _registry.register_plugin_override_policy( - module_name, - PluginContext(manifest, self)._tool_override_allowed(""), - scope=self.scope_key, - ) - policy_lease = replacement_coordinator.acquire( - ("tool_override_policy", self.scope_key, module_name), - current=current_policy, - previous=previous_policy, - restore=lambda replacement: _registry.restore_plugin_override_policy( - module_name, - current_policy, - replacement, - scope=self.scope_key, - ), - ) - self._track_registration( - manifest, "tool_override_policy", module_name, policy_lease.dispose, - ) - - def _attribute_registrations( - self, loaded: LoadedPlugin, plugin_key: str, registration_start: int - ) -> None: - """Fill ``loaded.*_registered`` from the ledger slice this plugin's register() produced.""" - registrations = [ - registration - for registration in self._registration_order[registration_start:] - if registration.plugin_key == plugin_key and registration.active - ] - - def _keys(kind: str) -> List[str]: - return [r.key for r in registrations if r.kind == kind] - - # Discovery-time tools predate registration_start; credit them back or `hermes plugins - # list` under-reports once the deferred adapter materializes. - _predeclared = [ - t for t in self._predeclared_tools.pop(plugin_key, []) - if t in self._plugin_tool_names - ] - loaded.tools_registered = _predeclared + [ - key for key in _keys("tool") if key not in _predeclared - ] - loaded.hooks_registered = _keys("hook") - loaded.middleware_registered = _keys("middleware") - loaded.commands_registered = _keys("command") - logger.debug( - " registered: %d tool(s), %d hook(s), %d middleware, %d slash command(s), %d CLI command(s)", - len(loaded.tools_registered), - len(loaded.hooks_registered), - len(loaded.middleware_registered), - len(loaded.commands_registered), - sum(1 for c in self._cli_commands if c in _keys("cli_command")), - ) - - def _load_portable_plugin(self, manifest: PluginManifest, loaded: LoadedPlugin) -> None: - """Load validated portable components without importing Python code.""" - - lookup_key = manifest.key or manifest.name - try: - from hermes_cli.agent_plugins import load_agent_plugin - - package = load_agent_plugin( - Path(manifest.path), - get_hermes_home() / "plugin-data" / manifest.skill_namespace, - ) - ctx = PluginContext(manifest, self) - for diagnostic in package.diagnostics: - logger.warning( - "Agent Plugin '%s' [%s]: %s", - lookup_key, - diagnostic.scope, - diagnostic.message, - ) - for skill in package.skills: - try: - ctx.register_skill( - skill.name, - skill.skill_md, - skill.description, - skill.frontmatter, - ) - except Exception as exc: - logger.warning( - "Agent Plugin '%s' skill '%s' skipped: %s", - lookup_key, - skill.name, - exc, - ) - for server_name, config in package.mcp_servers.items(): - internal_name = f"{manifest.skill_namespace}__{server_name}" - if internal_name in self._portable_mcp_servers: - logger.warning( - "Agent Plugin '%s' MCP server collision: %s", - lookup_key, - internal_name, - ) - continue - self._portable_mcp_servers[internal_name] = dict(config) - loaded.enabled = True - except Exception as exc: - loaded.error = str(exc) - logger.warning("Failed to load Agent Plugin '%s': %s", lookup_key, exc) - self._plugins[lookup_key] = loaded - - def _directory_module_name(self, manifest: PluginManifest) -> str: - """Return a profile-safe import namespace for a directory plugin.""" - key = manifest.key or manifest.name - slug = key.replace("/", "__").replace("-", "_") - bare_name = f"{_NS_PARENT}.{slug}" - with _MODULE_NAMESPACE_LOCK: - owner = _BARE_MODULE_SCOPE.get(bare_name) - if owner is None: - _BARE_MODULE_SCOPE[bare_name] = self.scope_key - return bare_name - if owner == self.scope_key: - return bare_name - digest = hashlib.sha256(self.scope_key.encode("utf-8")).hexdigest()[:12] - return f"{bare_name}__home_{digest}" - - def _policy_module_name(self, manifest: PluginManifest) -> str: - """Return the module prefix whose callbacks inherit plugin policy.""" - if manifest.source == "entrypoint" and manifest.path: - module_name = str(manifest.path).partition(":")[0].strip() - if module_name: - return module_name - return self._directory_module_name(manifest) - - def _load_directory_module( - self, - manifest: PluginManifest, - *, - module_name: Optional[str] = None, - ) -> types.ModuleType: - """Import a directory plugin as ``hermes_plugins.`` (slug from ``manifest.key`` so - ``image_gen/openai`` cannot collide with ``tts/openai``).""" - plugin_dir = Path(manifest.path) # type: ignore[arg-type] - init_file = plugin_dir / "__init__.py" - if not init_file.exists(): - raise FileNotFoundError(f"No __init__.py in {plugin_dir}") - - if _NS_PARENT not in sys.modules: - ns_pkg = types.ModuleType(_NS_PARENT) - ns_pkg.__path__ = [] # type: ignore[attr-defined] - ns_pkg.__package__ = _NS_PARENT - sys.modules[_NS_PARENT] = ns_pkg - - module_name = module_name or self._directory_module_name(manifest) - - # Evict stale entries for this slug (same slug cached from another Hermes home, or an - # earlier force reload). Replacing only sys.modules[module_name] is not enough: the plugin's - # relative imports are cached as "module_name.sub" and resolve from sys.modules first, so a - # stale submodule would keep serving the previous load's code/state. - _evict_modules(module_name) - - spec = importlib.util.spec_from_file_location( - module_name, - init_file, - submodule_search_locations=[str(plugin_dir)], - ) - if spec is None or spec.loader is None: - raise ImportError(f"Cannot create module spec for {init_file}") - - module = importlib.util.module_from_spec(spec) - module.__package__ = module_name - module.__path__ = [str(plugin_dir)] # type: ignore[attr-defined] - sys.modules[module_name] = module - try: - spec.loader.exec_module(module) - except BaseException: - # Don't leave a half-initialized module (or its partially imported relative submodules) - # cached — a retry or a same-slug plugin in another profile would inherit broken state. - _evict_modules(module_name) - raise - return module - - def _load_entrypoint_module(self, manifest: PluginManifest) -> types.ModuleType: - """Load a pip-installed plugin via its entry-point reference.""" - for ep in _select_entry_point_group(importlib.metadata.entry_points(), ENTRY_POINTS_GROUP): - if ep.name == manifest.name: - return ep.load() - - raise ImportError( - f"Entry point '{manifest.name}' not found in group '{ENTRY_POINTS_GROUP}'" - ) - # -- hook invocation ---------------------------------------------------------- @staticmethod diff --git a/hermes_cli/plugins_loader.py b/hermes_cli/plugins_loader.py new file mode 100644 index 0000000000..3b2b5be554 --- /dev/null +++ b/hermes_cli/plugins_loader.py @@ -0,0 +1,535 @@ +"""Plugin loading: directory/entry-point module import, deferred bundled platforms, portable +packages, dependency/config-schema warnings. Mixed into :class:`hermes_cli.plugins.PluginManager`. + +Origin-internal names (``PluginContext``, ``LoadedPlugin``, ``_PLUGINS_DEBUG`` …) are imported lazily +through ``hermes_cli.plugins`` so tests that patch them on the origin keep working. +""" + +from __future__ import annotations + +import hashlib +import importlib +import importlib.metadata +import importlib.util +import logging +import re +import sys +import threading +import types +from contextlib import contextmanager +from functools import wraps +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional + +from hermes_constants import get_hermes_home, reset_hermes_home_override, set_hermes_home_override +from registration_lifecycle import replacement_coordinator +from hermes_cli.plugins_discovery import ENTRY_POINTS_GROUP, _select_entry_point_group +from hermes_cli.plugins_manifest import PluginManifest, validate_config_schema + +if TYPE_CHECKING: # pragma: no cover + from hermes_cli.plugins import LoadedPlugin + +logger = logging.getLogger("hermes_cli.plugins") + + +_NS_PARENT = "hermes_plugins" + + +_MODULE_NAMESPACE_LOCK = threading.RLock() + + +_BARE_MODULE_SCOPE: Dict[str, str] = {} + + +def _evict_modules(module_name: str) -> None: + """Drop ``module_name`` and every ``module_name.*`` submodule from ``sys.modules``.""" + prefix = f"{module_name}." + for name in [n for n in sys.modules if n == module_name or n.startswith(prefix)]: + del sys.modules[name] + + +def _serialized_replacement(method): + """Make snapshot → write → lease attachment one atomic transaction.""" + @wraps(method) + def wrapped(*args, **kwargs): + with replacement_coordinator.transaction(): + return method(*args, **kwargs) + + return wrapped + + +@contextmanager +def _plugin_home_scope(home: Path): + """Bind discovery and loading to the manager's immutable Hermes home.""" + token = set_hermes_home_override(home) + try: + yield + finally: + reset_hermes_home_override(token) + + +class PluginLoaderMixin: + def _platform_name_from_manifest(self, manifest: PluginManifest) -> str: + """Derive the platform name without importing the adapter: strip a trailing ``-platform`` + from the manifest name, else the directory basename (the bundled convention).""" + name = manifest.name or "" + if name.endswith("-platform"): + return name[: -len("-platform")] + if manifest.path: + return Path(manifest.path).name + return name + + @_serialized_replacement + def _register_deferred_platform(self, manifest: PluginManifest) -> None: + """Register a lazy loader for a bundled platform: the adapter imports only when the + ``platform_registry`` is first asked for it; a placeholder ``LoadedPlugin`` keeps it visible + in ``hermes plugins list`` until then.""" + from hermes_cli.plugins import LoadedPlugin + + lookup_key = manifest.key or manifest.name + platform_name = self._platform_name_from_manifest(manifest) + + loaded = LoadedPlugin(manifest=manifest, enabled=True) + loaded.deferred = True + self._plugins[lookup_key] = loaded + + try: + from gateway.platform_registry import platform_registry + + scope = self.scope_key + + def _loader(_manifest: PluginManifest = manifest) -> None: + # Lock before checking cancellation: if an unload won the race it restored the + # predecessor and this loader must publish nothing; if loading won, unload waits + # and disposes the completed set. + with self._discovery_lock, _plugin_home_scope(self.home_path): + if platform_registry.is_deferred_load_cancelled( + platform_name, scope=scope + ): + return + self._load_plugin_scoped(_manifest) + + previous = platform_registry.snapshot_registration(platform_name, scope=scope) + platform_registry.register_deferred(platform_name, _loader, scope=scope) + current = platform_registry.snapshot_registration(platform_name, scope=scope) + if current[0] is None and current[1] is _loader: + self._plugin_platform_names.add(platform_name) + self._track_scoped_registration( + manifest, "platform", platform_name, platform_registry, current, previous, + finalize=lambda: self._remove_platform_name_if_unowned(platform_name), + ) + logger.debug( + "Registered deferred platform loader: %s (plugin=%s)", + platform_name, + lookup_key, + ) + except Exception: + # Fall back to eager loading so the platform is never silently lost. + logger.debug( + "Deferred platform registration failed for '%s'; eager-loading", + lookup_key, + exc_info=True, + ) + self._load_plugin(manifest) + return + + self._register_deferred_platform_tools(manifest, loaded) + + def _register_deferred_platform_tools( + self, manifest: PluginManifest, loaded: LoadedPlugin + ) -> None: + """Register a deferred platform's *client* tools without its adapter. + + Deferring the plugin would otherwise defer its outbound tools too, so in CLI/TUI processes + (which never materialize platforms) they would be missing from ``hermes tools`` and dropped + from ``platform_toolsets``. Opt-in is explicit via ``provides_tools``; tools live in a + ``tools`` submodule so ``__init__`` stays import-light. + """ + from hermes_cli.plugins import PluginContext, _PLUGINS_DEBUG + + if not manifest.provides_tools: + return + + lookup_key = manifest.key or manifest.name + plugin_dir = Path(manifest.path) if manifest.path else None + if plugin_dir is None or not (plugin_dir / "tools.py").is_file(): + # Declared but undeliverable — staying quiet reproduces the very symptom this fixes. + logger.warning( + "Plugin '%s' declares provides_tools %s but has no tools.py; " + "those tools will not be available in CLI/TUI sessions.", + lookup_key, + list(manifest.provides_tools), + ) + return + + before = set(self._plugin_tool_names) # lets the failure path credit partial registrations + try: + module = self._load_directory_module(manifest) + # Record the module even if nothing registers: the package body has run, so + # materializing the adapter later must reuse it rather than execute it twice. + loaded.module = module + self._predeclared_modules[lookup_key] = module + + tools_module = importlib.import_module(f"{module.__name__}.tools") + register_tools = getattr(tools_module, "register_tools", None) + if register_tools is None: + logger.warning( + "Plugin '%s' declares provides_tools %s but its tools.py " + "has no register_tools(ctx); those tools will not be " + "available in CLI/TUI sessions.", + lookup_key, + list(manifest.provides_tools), + ) + return + + register_tools(PluginContext(manifest, self)) + registered = [t for t in self._plugin_tool_names if t not in before] + + loaded.tools_registered = registered + self._predeclared_tools[lookup_key] = registered + logger.debug( + "Deferred platform '%s': pre-registered %d client tool(s) %s", + lookup_key, + len(registered), + registered, + ) + except Exception as exc: + # Tools registered before the raise are live: credit them or `hermes plugins list` + # under-reports (and _load_plugin's later diff would miss them too). + partial = [t for t in self._plugin_tool_names if t not in before] + if partial: + loaded.tools_registered = partial + self._predeclared_tools[lookup_key] = partial + + # Never break discovery (the platform stays deferred), but a broken tools.py IS the + # symptom, so warn — and say where it failed, which is what the operator needs first. + declared = len(manifest.provides_tools) + if not partial: + scope = f"before registering any of its {declared} declared tool(s)" + elif len(partial) >= declared: + scope = f"after registering all {declared} declared tool(s)" + else: + scope = f"after registering {len(partial)} of {declared} declared tool(s)" + logger.warning( + "Plugin '%s': client-tool pre-registration failed %s (%s).%s", + lookup_key, + scope, + exc, + "" if len(partial) >= declared else + " The remainder will be missing from CLI/TUI sessions.", + exc_info=_PLUGINS_DEBUG, + ) + + def _warn_python_dependencies(self, manifest: PluginManifest) -> None: + """Warn about missing declared pip dependencies with an install hint — NEVER auto-install.""" + deps = manifest.python_dependencies + if not deps: + return + key = manifest.key or manifest.name + missing: List[str] = [] + for req in deps: + # Best-effort presence probe on the distribution name. + dist = re.split(r"[<>=!~\[;\s]", req, maxsplit=1)[0].strip() + if not dist: + continue + try: + importlib.metadata.version(dist) + except importlib.metadata.PackageNotFoundError: + missing.append(req) + except Exception: + continue + if missing: + logger.warning( + "Plugin %s declares Python dependencies that are not " + "installed: %s. Hermes does not install plugin dependencies " + "automatically; install them yourself, e.g.: pip install %s", + key, ", ".join(missing), + " ".join(f"'{m}'" for m in missing), + ) + else: + logger.debug("Plugin %s python_dependencies satisfied: %s", key, ", ".join(deps)) + + def _validate_plugin_config_schema(self, manifest: PluginManifest) -> None: + """Warn (never block) on plugins.entries. settings that violate config_schema.""" + if not manifest.config_schema: + return + plugin_id = manifest.key or manifest.name + settings: Mapping[str, Any] = {} + try: + from hermes_cli.config import load_config + + cfg = load_config() or {} + entries = (cfg.get("plugins") or {}).get("entries") or {} + entry = entries.get(plugin_id) if isinstance(entries, Mapping) else None + raw = entry.get("settings") if isinstance(entry, Mapping) else None + if not isinstance(raw, Mapping): + # Migration fallback mirroring ctx.get_config. + raw = entry.get("config") if isinstance(entry, Mapping) else None + settings = raw if isinstance(raw, Mapping) else {} + except Exception: + settings = {} + for warning in validate_config_schema( + plugin_id, manifest.config_schema, settings + ): + logger.warning("Plugin %s config: %s", plugin_id, warning) + + def _load_plugin(self, manifest: PluginManifest) -> None: + """Import a plugin module and call its ``register(ctx)`` function.""" + with self._discovery_lock, _plugin_home_scope(self.home_path): + self._load_plugin_scoped(manifest) + + def _load_plugin_scoped(self, manifest: PluginManifest) -> None: + """Load one plugin with the manager's home bound as current.""" + from hermes_cli.plugins import LoadedPlugin, PluginContext, _PLUGINS_DEBUG + + loaded = LoadedPlugin(manifest=manifest) + logger.debug( + "Loading plugin '%s' (source=%s, kind=%s, path=%s)", + manifest.key or manifest.name, manifest.source, manifest.kind, manifest.path, + ) + + if manifest.portable: + self._load_portable_plugin(manifest, loaded) + return + + registration_start = len(self._registration_order) + plugin_key = manifest.key or manifest.name + _module_name = self._policy_module_name(manifest) + self._track_tool_override_policy(manifest, _module_name) + try: + # Reuse a deferred platform's already-imported package so its body doesn't run twice. + preloaded = self._predeclared_modules.pop(plugin_key, None) + if preloaded is not None: + module = preloaded + elif manifest.source in {"user", "project", "bundled"}: + module = self._load_directory_module(manifest, module_name=_module_name) + else: + module = self._load_entrypoint_module(manifest) + + loaded.module = module + register_fn = getattr(module, "register", None) + if register_fn is None: + loaded.error = "no register() function" + logger.warning("Plugin '%s' has no register() function", manifest.name) + else: + register_fn(PluginContext(manifest, self)) + self._attribute_registrations(loaded, plugin_key, registration_start) + loaded.enabled = True + + except Exception as exc: + owned = [ + registration + for registration in self._registration_order + if registration.plugin_key == plugin_key + ] + self._dispose_registrations(owned) + self._forget_registrations(owned) + loaded.error = str(exc) + # register() may have subscribed before raising; a failed plugin must leave no callable + # reachable from later event dispatch. + self._remove_plugin_subscriptions(plugin_key) + logger.warning( + "Failed to load plugin '%s': %s", + manifest.name, exc, exc_info=_PLUGINS_DEBUG, + ) + # The failure path swept this plugin's whole ledger (not just the registration_start slice), + # so discovery-time pre-registrations are gone too. + if not loaded.enabled: + self._predeclared_tools.pop(plugin_key, None) + self._plugins[manifest.key or manifest.name] = loaded + + def _track_tool_override_policy(self, manifest: PluginManifest, module_name: str) -> None: + """Install the plugin's tool-override policy in tools.registry as a ledger-owned lease.""" + from hermes_cli.plugins import PluginContext + + from tools.registry import registry as _registry + + with replacement_coordinator.transaction(): + previous_policy = _registry.snapshot_plugin_override_policy( + module_name, scope=self.scope_key + ) + current_policy = _registry.register_plugin_override_policy( + module_name, + PluginContext(manifest, self)._tool_override_allowed(""), + scope=self.scope_key, + ) + policy_lease = replacement_coordinator.acquire( + ("tool_override_policy", self.scope_key, module_name), + current=current_policy, + previous=previous_policy, + restore=lambda replacement: _registry.restore_plugin_override_policy( + module_name, + current_policy, + replacement, + scope=self.scope_key, + ), + ) + self._track_registration( + manifest, "tool_override_policy", module_name, policy_lease.dispose, + ) + + def _attribute_registrations( + self, loaded: LoadedPlugin, plugin_key: str, registration_start: int + ) -> None: + """Fill ``loaded.*_registered`` from the ledger slice this plugin's register() produced.""" + registrations = [ + registration + for registration in self._registration_order[registration_start:] + if registration.plugin_key == plugin_key and registration.active + ] + + def _keys(kind: str) -> List[str]: + return [r.key for r in registrations if r.kind == kind] + + # Discovery-time tools predate registration_start; credit them back or `hermes plugins + # list` under-reports once the deferred adapter materializes. + _predeclared = [ + t for t in self._predeclared_tools.pop(plugin_key, []) + if t in self._plugin_tool_names + ] + loaded.tools_registered = _predeclared + [ + key for key in _keys("tool") if key not in _predeclared + ] + loaded.hooks_registered = _keys("hook") + loaded.middleware_registered = _keys("middleware") + loaded.commands_registered = _keys("command") + logger.debug( + " registered: %d tool(s), %d hook(s), %d middleware, %d slash command(s), %d CLI command(s)", + len(loaded.tools_registered), + len(loaded.hooks_registered), + len(loaded.middleware_registered), + len(loaded.commands_registered), + sum(1 for c in self._cli_commands if c in _keys("cli_command")), + ) + + def _load_portable_plugin(self, manifest: PluginManifest, loaded: LoadedPlugin) -> None: + """Load validated portable components without importing Python code.""" + from hermes_cli.plugins import PluginContext + + lookup_key = manifest.key or manifest.name + try: + from hermes_cli.agent_plugins import load_agent_plugin + + package = load_agent_plugin( + Path(manifest.path), + get_hermes_home() / "plugin-data" / manifest.skill_namespace, + ) + ctx = PluginContext(manifest, self) + for diagnostic in package.diagnostics: + logger.warning( + "Agent Plugin '%s' [%s]: %s", + lookup_key, + diagnostic.scope, + diagnostic.message, + ) + for skill in package.skills: + try: + ctx.register_skill( + skill.name, + skill.skill_md, + skill.description, + skill.frontmatter, + ) + except Exception as exc: + logger.warning( + "Agent Plugin '%s' skill '%s' skipped: %s", + lookup_key, + skill.name, + exc, + ) + for server_name, config in package.mcp_servers.items(): + internal_name = f"{manifest.skill_namespace}__{server_name}" + if internal_name in self._portable_mcp_servers: + logger.warning( + "Agent Plugin '%s' MCP server collision: %s", + lookup_key, + internal_name, + ) + continue + self._portable_mcp_servers[internal_name] = dict(config) + loaded.enabled = True + except Exception as exc: + loaded.error = str(exc) + logger.warning("Failed to load Agent Plugin '%s': %s", lookup_key, exc) + self._plugins[lookup_key] = loaded + + def _directory_module_name(self, manifest: PluginManifest) -> str: + """Return a profile-safe import namespace for a directory plugin.""" + key = manifest.key or manifest.name + slug = key.replace("/", "__").replace("-", "_") + bare_name = f"{_NS_PARENT}.{slug}" + with _MODULE_NAMESPACE_LOCK: + owner = _BARE_MODULE_SCOPE.get(bare_name) + if owner is None: + _BARE_MODULE_SCOPE[bare_name] = self.scope_key + return bare_name + if owner == self.scope_key: + return bare_name + digest = hashlib.sha256(self.scope_key.encode("utf-8")).hexdigest()[:12] + return f"{bare_name}__home_{digest}" + + def _policy_module_name(self, manifest: PluginManifest) -> str: + """Return the module prefix whose callbacks inherit plugin policy.""" + if manifest.source == "entrypoint" and manifest.path: + module_name = str(manifest.path).partition(":")[0].strip() + if module_name: + return module_name + return self._directory_module_name(manifest) + + def _load_directory_module( + self, + manifest: PluginManifest, + *, + module_name: Optional[str] = None, + ) -> types.ModuleType: + """Import a directory plugin as ``hermes_plugins.`` (slug from ``manifest.key`` so + ``image_gen/openai`` cannot collide with ``tts/openai``).""" + plugin_dir = Path(manifest.path) # type: ignore[arg-type] + init_file = plugin_dir / "__init__.py" + if not init_file.exists(): + raise FileNotFoundError(f"No __init__.py in {plugin_dir}") + + if _NS_PARENT not in sys.modules: + ns_pkg = types.ModuleType(_NS_PARENT) + ns_pkg.__path__ = [] # type: ignore[attr-defined] + ns_pkg.__package__ = _NS_PARENT + sys.modules[_NS_PARENT] = ns_pkg + + module_name = module_name or self._directory_module_name(manifest) + + # Evict stale entries for this slug (same slug cached from another Hermes home, or an + # earlier force reload). Replacing only sys.modules[module_name] is not enough: the plugin's + # relative imports are cached as "module_name.sub" and resolve from sys.modules first, so a + # stale submodule would keep serving the previous load's code/state. + _evict_modules(module_name) + + spec = importlib.util.spec_from_file_location( + module_name, + init_file, + submodule_search_locations=[str(plugin_dir)], + ) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot create module spec for {init_file}") + + module = importlib.util.module_from_spec(spec) + module.__package__ = module_name + module.__path__ = [str(plugin_dir)] # type: ignore[attr-defined] + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + except BaseException: + # Don't leave a half-initialized module (or its partially imported relative submodules) + # cached — a retry or a same-slug plugin in another profile would inherit broken state. + _evict_modules(module_name) + raise + return module + + def _load_entrypoint_module(self, manifest: PluginManifest) -> types.ModuleType: + """Load a pip-installed plugin via its entry-point reference.""" + for ep in _select_entry_point_group(importlib.metadata.entry_points(), ENTRY_POINTS_GROUP): + if ep.name == manifest.name: + return ep.load() + + raise ImportError( + f"Entry point '{manifest.name}' not found in group '{ENTRY_POINTS_GROUP}'" + )