"""Hermes Plugin System — discovers, loads, and manages plugins. Sources, later overriding earlier on key collision: bundled ``/plugins//`` (``memory/`` and ``context_engine/`` have their own discovery), user ``~/.hermes/plugins//``, project ``./.hermes/plugins//`` (opt-in via ``HERMES_ENABLE_PROJECT_PLUGINS``), and pip packages in the ``hermes_agent.plugins`` entry-point group. A directory plugin needs a ``plugin.yaml`` manifest and an ``__init__.py`` exposing ``register(ctx)``. Plugins register callbacks for ``VALID_HOOKS`` (core fires ``invoke_hook(name, **kwargs)``) and tools via ``PluginContext.register_tool()``. """ from __future__ import annotations import asyncio import builtins import contextvars import copy import hashlib import importlib.metadata import importlib.util import inspect import json import logging import os import queue import re import sys import threading import time import types from contextlib import contextmanager, 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 registration_lifecycle import replacement_coordinator from utils import env_var_enabled, fast_safe_load from hermes_cli.config import cfg_get, load_config_readonly from hermes_cli.middleware import OBSERVER_SCHEMA_VERSION, VALID_MIDDLEWARE from hermes_cli.plugin_capabilities import ( # noqa: F401 — re-exported CAPABILITY_REGISTRY, VALID_CAPABILITY_IDS, plugin_capability_granted, ) from hermes_cli.plugin_capabilities import ( parse_declared_capabilities as _parse_declared_capabilities, ) from hermes_cli.relay_plugin_cutover import ( LEGACY_RELAY_PLUGIN_KEYS, RELAY_PLUGINS_CONFIG_ENV, legacy_relay_plugin_keys, ) def get_bundled_plugins_dir() -> Path: """Locate the bundled ``plugins/`` directory. Honours ``HERMES_BUNDLED_PLUGINS`` (set by the Nix wrapper / packaged installs) so read-only store paths are consulted first. Falls back to the in-repo path used during development. """ env_override = os.getenv("HERMES_BUNDLED_PLUGINS") if env_override: return Path(env_override) return Path(__file__).resolve().parent.parent / "plugins" try: import yaml except ImportError: # pragma: no cover – yaml is optional at import time yaml = None # type: ignore[assignment] class PluginToolOverrideError(PermissionError): """Plugin tried to override a built-in tool without ``plugins.entries..allow_tool_override``.""" logger = logging.getLogger(__name__) # ``HERMES_PLUGINS_DEBUG=1`` tees verbose discovery logs to stderr in addition to agent.log. Read # once at import; tests flip it mid-process via ``_install_plugin_debug_handler(force=True)``. _PLUGINS_DEBUG = os.getenv("HERMES_PLUGINS_DEBUG", "").strip().lower() in {"1", "true", "yes", "on"} _DEBUG_HANDLER_INSTALLED = False def _install_plugin_debug_handler(force: bool = False) -> None: """When HERMES_PLUGINS_DEBUG is on, tee plugin logs to stderr at DEBUG (once per process).""" global _DEBUG_HANDLER_INSTALLED, _PLUGINS_DEBUG if force: _PLUGINS_DEBUG = os.getenv("HERMES_PLUGINS_DEBUG", "").strip().lower() in { "1", "true", "yes", "on", } if not _PLUGINS_DEBUG or _DEBUG_HANDLER_INSTALLED: return handler = logging.StreamHandler(sys.stderr) handler.setLevel(logging.DEBUG) handler.setFormatter(logging.Formatter("[plugins] %(levelname)s %(message)s")) logger.addHandler(handler) logger.setLevel(logging.DEBUG) logger.propagate = True _DEBUG_HANDLER_INSTALLED = True logger.debug("HERMES_PLUGINS_DEBUG=1 — verbose plugin discovery logging enabled") _install_plugin_debug_handler() # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- VALID_HOOKS: Set[str] = { "pre_tool_call", "post_tool_call", "transform_terminal_output", "transform_tool_result", # Return a string to replace the response text (first non-None wins) or None to leave it. "transform_llm_output", "pre_llm_call", "post_llm_call", # Streaming observers fired off the token path by agent.plugin_stream_hooks; payloads are # immutable normalized text/lifecycle and cannot transform the stream. "on_stream_start", "on_stream_delta", "on_stream_end", "on_interim_message", # Fired once per turn when the agent edited code and is about to verify/finish. Return # {"action": "continue", "message"} (or Claude-Code Stop shape {"decision": "block", "reason"}) # to keep going; anything else finishes. Bounded by agent.max_verify_nudges. "pre_verify", "pre_api_request", "post_api_request", "api_request_error", # Fired once per failed API call BEFORE agent/error_classifier.classify_api_error(). Kwargs: # provider, model, status_code, error_type, error_code, error_message, error_body, error, # approx_tokens, context_length, num_messages. Return None, or {"reason": # (required), "retryable"/"should_compress"/"should_rotate_credential"/"should_fallback": bool, # "message": str, "error_context": dict}. Run-all-then-pick-first (see # get_plugin_error_classification). Privacy: error_message/error_body may be unredacted. "transform_api_error_classification", "on_session_start", "on_session_end", "on_session_finalize", "on_session_reset", # Successful skill lifecycle facts (local skill name visible to plugins). "on_skill_lifecycle", "subagent_start", "subagent_stop", # Once per incoming MessageEvent, after the internal-event guard, BEFORE auth/pairing and # dispatch. Kwargs: event, gateway, session_store. Return {"action": "skip", "reason"} -> drop; # {"action": "rewrite", "text"} -> replace event.text; {"action": "allow"} / None -> normal. "pre_gateway_dispatch", # Approval observers (tools/approval.py). Return values ignored — plugins cannot veto or # pre-answer (use pre_tool_call). Kwargs: command, description, pattern_key, pattern_keys, # session_key, surface: "cli" | "gateway" | "smart"; post_approval_response adds choice # ("once"|"session"|"always"|"deny"|"timeout"|"smart_approve"|"smart_deny") and decided_by. "pre_approval_request", "post_approval_response", # Fired by transcribe_audio after provider resolution, BEFORE any backend runs. Kwargs: # file_path, provider, model, language, prompt, source. Return None or a dict mutating # prompt/language/model (registration order, last-writer-wins; file_path is read-only). "pre_transcription", # Kanban task observers (hermes_cli.kanban_db), fired AFTER the DB commit so a slow plugin never # holds the SQLite write lock. Return values ignored. Process matters: claimed fires in the # DISPATCHER right before spawn; completed/blocked fire in the WORKER (or whichever process # drove it). Kwargs: task_id, board, assignee, run_id, profile_name; completed adds summary, # blocked adds reason. "kanban_task_claimed", "kanban_task_completed", "kanban_task_blocked", # Kanban worker/mutation/tick observers; return values ignored; fire sites short-circuit on # has_hook(). Kwargs: task_id, profile_name, board, assignee, run_id plus: # worker_spawned (DISPATCHER, after PID persisted, inside the dispatch lock — stay fast): # worker_pid, workspace_path (privacy: project layout/usernames). "on_kanban_worker_spawned", # worker_exited (tick-derived on dead-PID reclaim): worker_pid, exit_kind ("clean_exit" | # "rate_limited" | "nonzero_exit" | "signaled" | "unknown"), exit_code, outcome, retry_status. "on_kanban_worker_exited", # worker_stale_claim (TTL-expired claim reclaimed; live-PID extensions do NOT fire): # worker_pid, heartbeat_stale, retry_status. "on_kanban_worker_stale_claim", # task_updated (committed task-row write outside claim/complete/block, in whichever process # committed it): changed_fields — field NAMES only, never values. "on_kanban_task_updated", # dispatch_tick: once per dispatch_once, strictly AFTER the dispatch lock is released. Kwargs: # board, profile_name, dry_run, outcome ("ok"|"skipped_locked"|"idle"), result: DispatchResult # (privacy: task ids, assignees, workspace paths). "on_kanban_dispatch_tick", # Gateway platform-boundary observer: normalized envelopes only, never raw SDK objects or # adapter handles. Kwargs: platform, event_type, payload (event_type-local; see hooks.md). # New event types land only together with real fire-sites. "gateway_platform_event", # Fired BEFORE a recognized slash command's handler on CLI and gateway canonical dispatch. # Return values IGNORED in v1. Deliberately NOT fired for the gateway's running-agent intercept # path (/stop, /approve, busy_policy) — a slow/hostile plugin must not touch the operator's # escape hatches. Kwargs: surface, command (canonical), alias_used, args_raw, session_key, # platform. "pre_command", } # Hooks whose directive the shell-hook response parser has no channel for. VALID_HOOKS doubles as # the shell-hook allow-list, so these are refused loudly instead of having output silently ignored. SHELL_UNSUPPORTED_HOOKS: Set[str] = {"transform_api_error_classification"} # Allowlist of agent-turn hot-path hooks bounded by plugins.hook_callback_timeout (fail-open: # abandon without join — joining reintroduced a shutdown hang). Unlisted hooks run synchronously. # Intentionally unbounded: on_session_finalize/reset (last-chance flush — abandon can lose state); # subagent_start (observer); pre_gateway_dispatch (policy gate — neither fail mode is acceptable); # pre/post_approval_* (approval UX has its own timeout); kanban_* (own heartbeat/stale reclaim). _HOOK_TIMEOUT_BOUNDED_HOOKS: Set[str] = { "post_tool_call", "transform_terminal_output", "transform_tool_result", "transform_llm_output", "pre_llm_call", "post_llm_call", "pre_api_request", "post_api_request", "api_request_error", "pre_verify", "on_session_start", "on_session_end", } # Policy hooks: timeout / still-running must fail closed (block the tool). _HOOK_TIMEOUT_FAIL_CLOSED_HOOKS: Set[str] = {"pre_tool_call"} # Documented parent-thread serialization contract — never run on a timeout worker (hooks.md). _HOOK_CALLER_THREAD_HOOKS: Set[str] = {"subagent_stop"} # After a timeout, suppress the same callback this long so a hung hook cannot pile up threads. _HOOK_TIMEOUT_SUPPRESSION_SECONDS = 60.0 _PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE = ( "pre_tool_call plugin callback timed out or is still running" ) ENTRY_POINTS_GROUP = "hermes_agent.plugins" ENTRY_POINT_CAPABILITIES_GROUP = "hermes_agent.plugin_capabilities" def _select_entry_point_group(entry_points: Any, group: str) -> list: """Return one metadata entry-point group across supported Python APIs.""" if hasattr(entry_points, "select"): return list(entry_points.select(group=group)) if isinstance(entry_points, dict): return list(entry_points.get(group, [])) return [ep for ep in entry_points if ep.group == group] def discover_entrypoint_manifests() -> List["PluginManifest"]: """Return metadata-only manifests for installed entry-point plugins. Kind comes from an import-free source scan (memory/model providers route to their own discovery). Capabilities come from the companion ``hermes_agent.plugin_capabilities`` group (``.`` entries pointing at the same object), so consent works without importing plugin code. Failures are isolated per entry point. """ manifests: List[PluginManifest] = [] try: eps = importlib.metadata.entry_points() group_eps = _select_entry_point_group(eps, ENTRY_POINTS_GROUP) capability_eps = _select_entry_point_group(eps, ENTRY_POINT_CAPABILITIES_GROUP) except Exception as exc: logger.debug("Entry-point scan failed: %s", exc) return manifests for ep in group_eps: try: capabilities = [] for capability in VALID_CAPABILITY_IDS: declaration_name = f"{ep.name}.{capability}" if any( declaration.name == declaration_name and declaration.value == ep.value for declaration in capability_eps ): capabilities.append(capability) dist = getattr(ep, "dist", None) metadata = getattr(dist, "metadata", None) manifest = PluginManifest( name=ep.name, version=str(getattr(dist, "version", "") or ""), description=( str(metadata.get("Summary", "") or "") if metadata is not None else "" ), source="entrypoint", path=ep.value, key=ep.name, capabilities=_parse_declared_capabilities( capabilities, ep.name ), ) manifest.kind = _classify_entrypoint_value_kind(ep.value) manifests.append(manifest) except Exception as exc: logger.debug("Entry-point manifest for %r skipped: %s", getattr(ep, "name", "?"), exc) return manifests def _classify_entrypoint_value_kind(value: str) -> str: """Classify an entry-point target by import-free source scan (unresolvable -> standalone).""" try: module_name = str(value).split(":", 1)[0].strip() if not module_name: return "standalone" return _detect_kind_from_source(_resolve_module_source(module_name)) or "standalone" except Exception: return "standalone" # System-prompt sections are tightly bounded: they become high-trust prompt bytes charged every turn. SYSTEM_PROMPT_SECTION_POSITIONS = frozenset({"after_memory"}) DEFAULT_SYSTEM_PROMPT_SECTION_MAX_CHARS = 4_000 MAX_SYSTEM_PROMPT_SECTION_CHARS = 4_000 MAX_SYSTEM_PROMPT_SECTIONS = 32 MAX_SYSTEM_PROMPT_SECTIONS_TOTAL_CHARS = 8_000 _SYSTEM_PROMPT_SECTION_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,127}$") _SYSTEM_PROMPT_SECTION_HEADING_PREFIX = "## Plugin Context: " PLUGIN_SECTIONS_START = "" PLUGIN_SECTIONS_END = "" def is_valid_system_prompt_section_id(value: Any) -> bool: """Return whether *value* is a stable, heading-safe section identifier.""" return isinstance(value, str) and bool(_SYSTEM_PROMPT_SECTION_ID_RE.fullmatch(value)) def format_system_prompt_section(section_id: str, content: str) -> str: """Render an auditable, length-framed block recoverable from the full prompt.""" return ( f"{_SYSTEM_PROMPT_SECTION_HEADING_PREFIX}{section_id}\n" f"\n\n" f"{content}" ) def format_system_prompt_sections(sections: list) -> str: """Render the canonical container used for persistence recovery.""" if not sections: return "" blocks = [format_system_prompt_section(item.id, item.content) for item in sections] return f"{PLUGIN_SECTIONS_START}\n" + "\n\n".join(blocks) + f"\n{PLUGIN_SECTIONS_END}" # Reserved event namespace prefix — only core may publish ``hermes:``. HERMES_EVENT_NAMESPACE = "hermes" # Event recursion depth cap (subscribers may emit); over-deep emits are dropped with a warning. _EVENT_EMIT_DEPTH_CAP = 8 # Max queued + running events per manager generation; emit never waits — a full budget drops. _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 def _get_disabled_plugins() -> set: """Read ``plugins.disabled`` — a deny-list that wins over ``plugins.enabled``.""" try: from hermes_cli.config import load_config config = load_config() disabled = cfg_get(config, "plugins", "disabled", default=[]) return set(disabled) if isinstance(disabled, list) else set() except Exception: return set() def _get_enabled_plugins() -> Optional[set]: """Read the ``plugins.enabled`` allow-list (plugins are opt-in). ``None`` = key missing/malformed ("nothing enabled yet"; the first ``migrate_config`` run grandfathers installed user plugins); ``set()`` = explicitly empty; else the allow-list. """ try: from hermes_cli.config import load_config plugins_cfg = load_config().get("plugins") enabled = plugins_cfg.get("enabled") if isinstance(plugins_cfg, dict) else None return set(enabled) if isinstance(enabled, list) else None except Exception: return None # --------------------------------------------------------------------------- # Data classes # --------------------------------------------------------------------------- _VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform", "model-provider"} def _portable_skill_namespace(key: str) -> str: """Return a readable, collision-resistant namespace for a portable plugin.""" slug = "".join( ch if ch.isascii() and (ch.isalnum() or ch in "_-") else "-" for ch in key.lower() ) slug = slug.strip("-_") or "plugin" digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:8] return f"agent-plugin-{slug}-{digest}" def _display_author(value: object) -> str: """Normalize a manifest author value for the string PluginManifest field.""" if isinstance(value, Mapping): return ", ".join( str(value[field]) for field in ("name", "email", "url") if value.get(field) ) return "" if value is None else str(value) # Manifest v2 parsing. Unknown plugin.yaml fields are forward-compat surface: warn (debug for v1 # files, warning for v2+) and continue loading. _KNOWN_MANIFEST_FIELDS: Set[str] = { # v1 "name", "version", "description", "author", "requires_env", "provides_tools", "provides_hooks", "kind", "hooks", "label", "optional_env", "platforms", "external_dependencies", "pip_dependencies", "provides_browser_providers", "provides_web_providers", # v2 "manifest_version", "api_version", "requires_plugins", "python_dependencies", "config_schema", "license", "homepage", "tags", # owned by sibling sub-issues but reserved so their manifests don't warn "capabilities", "emits", "listens", "hermes", "depends", } # Highest manifest schema version this Hermes understands. SUPPORTED_MANIFEST_VERSION = 2 _CONFIG_SCHEMA_TYPES: Dict[str, tuple] = { "str": (str,), "string": (str,), "int": (int,), "integer": (int,), "float": (int, float), "number": (int, float), "bool": (bool,), "boolean": (bool,), "list": (list,), "array": (list,), "dict": (dict,), "object": (dict,), } def _manifest_field_of_type(data: Mapping, key: str, field_name: str, typ, what: str): """Return ``data[field_name]`` when absent or of ``typ``; warn and return None otherwise.""" raw = data.get(field_name) if raw is not None and not isinstance(raw, typ): logger.warning("Plugin %s: %s must be %s; ignoring", key, field_name, what) return None return raw def _parse_manifest_v2_fields(data: Mapping, key: str) -> Dict[str, Any]: """Validate/normalize manifest v2 fields into PluginManifest kwargs (warnings, never failures).""" out: Dict[str, Any] = {} # manifest_version — absent means v1 (supported forever). raw_mv = data.get("manifest_version", 1) try: mv = int(raw_mv) except (TypeError, ValueError): logger.warning( "Plugin %s: manifest_version %r is not an integer; treating as 1", key, raw_mv, ) mv = 1 if mv > SUPPORTED_MANIFEST_VERSION: logger.warning( "Plugin %s: manifest_version %d is newer than this Hermes " "supports (%d); loading anyway and ignoring unknown fields", key, mv, SUPPORTED_MANIFEST_VERSION, ) out["manifest_version"] = mv # api_version — plugin API generation (independent of manifest_version). raw_api = data.get("api_version") out["api_version"] = None if raw_api is not None: try: out["api_version"] = int(raw_api) except (TypeError, ValueError): logger.warning("Plugin %s: api_version %r is not an integer; ignoring", key, raw_api) # requires_plugins — list of {id, version_range?} (str shorthand ok). deps: List[Dict[str, Any]] = [] for item in _manifest_field_of_type(data, key, "requires_plugins", list, "a list") or []: if isinstance(item, str): deps.append({"id": item, "version_range": None}) elif isinstance(item, Mapping) and isinstance(item.get("id"), str) and item["id"]: vr = item.get("version_range") deps.append({"id": item["id"], "version_range": str(vr) if vr is not None else None}) else: logger.warning( "Plugin %s: requires_plugins entry %r must be a plugin id " "string or a {id, version_range} mapping; skipping", key, item, ) out["requires_plugins"] = deps # python_dependencies — validated and surfaced ONLY; never auto-installed. pydeps: List[str] = [] raw_pydeps = _manifest_field_of_type( data, key, "python_dependencies", list, "a list of requirement strings" ) for item in raw_pydeps or []: if isinstance(item, str) and item.strip(): pydeps.append(item.strip()) else: logger.warning( "Plugin %s: python_dependencies entry %r must be a non-empty " "requirement string; skipping", key, item, ) out["python_dependencies"] = pydeps # config_schema — mapping of key -> {type?, default?, description?, required?}. schema: Dict[str, Any] = {} raw_schema = _manifest_field_of_type(data, key, "config_schema", Mapping, "a mapping") for skey, spec in (raw_schema or {}).items(): if not isinstance(spec, Mapping): logger.warning( "Plugin %s: config_schema entry %r must be a mapping " "(e.g. {type: str}); skipping", key, skey, ) continue stype = spec.get("type") if stype is not None and str(stype).lower() not in _CONFIG_SCHEMA_TYPES: logger.warning( "Plugin %s: config_schema key %r declares unknown type %r " "(known: %s); type check will be skipped for it", key, skey, stype, ", ".join(sorted(_CONFIG_SCHEMA_TYPES)), ) schema[str(skey)] = dict(spec) out["config_schema"] = schema # Standard metadata. out["license"] = str(data.get("license") or "") out["homepage"] = str(data.get("homepage") or "") raw_tags = _manifest_field_of_type(data, key, "tags", list, "a list") out["tags"] = [str(t) for t in (raw_tags or [])] # Forward compat: unknown fields warn (never fail); v1 manifests only at debug. unknown = sorted(set(data.keys()) - _KNOWN_MANIFEST_FIELDS) if unknown: log = logger.warning if mv >= 2 else logger.debug log( "Plugin %s: unknown manifest field(s) ignored: %s " "(newer manifest schema or typo; plugin still loads)", key, ", ".join(unknown), ) return out def validate_config_schema(plugin_id: str, schema: Mapping, settings: Mapping) -> List[str]: """Return actionable warning strings for settings vs config_schema mismatches (never raises).""" warnings: List[str] = [] if not isinstance(schema, Mapping) or not isinstance(settings, Mapping): return warnings for skey, spec in schema.items(): if not isinstance(spec, Mapping): continue present = skey in settings if not present: if spec.get("required") and "default" not in spec: warnings.append( f"plugins.entries.{plugin_id}.settings.{skey} is required " "by the plugin's config_schema but is not set" ) continue stype = spec.get("type") expected = _CONFIG_SCHEMA_TYPES.get(str(stype).lower()) if stype else None if expected is not None: value = settings[skey] # bool is an int subclass — don't let True satisfy int/float. ok = isinstance(value, expected) and not ( isinstance(value, bool) and bool not in expected ) if not ok: warnings.append( f"plugins.entries.{plugin_id}.settings.{skey} should be " f"{stype} (got {type(value).__name__})" ) return warnings def resolve_plugin_load_order(manifests: Mapping[str, "PluginManifest"]) -> List[str]: """Return plugin keys in dependency order: B before A when A requires B; alphabetical ties. A cycle warns and falls back to alphabetical order for all; a missing dependency warns once but never removes the dependent plugin (loads never hard-fail on advisory deps). """ import graphlib keys = sorted(manifests.keys()) by_name: Dict[str, str] = {} for k in keys: name = manifests[k].name if name and name not in by_name: by_name[name] = k def _resolve_dep(dep_id: str) -> Optional[str]: if dep_id in manifests: return dep_id return by_name.get(dep_id) edges: Dict[str, Set[str]] = {k: set() for k in keys} for k in keys: for dep in manifests[k].requires_plugins: dep_id = dep.get("id") if isinstance(dep, Mapping) else None if not dep_id: continue resolved = _resolve_dep(dep_id) if resolved is None: logger.warning( "Plugin %s requires plugin '%s' which is not enabled/" "installed; loading anyway (probe availability at runtime " "via ctx.has_plugin). Run `hermes plugins enable %s` if " "it is installed.", k, dep_id, dep_id, ) continue if resolved == k: logger.warning("Plugin %s declares a dependency on itself; ignoring", k) continue edges[k].add(resolved) sorter = graphlib.TopologicalSorter(edges) try: sorter.prepare() except graphlib.CycleError as exc: cycle = exc.args[1] if len(exc.args) > 1 else [] logger.warning( "Plugin dependency cycle detected (%s); falling back to " "alphabetical load order for all plugins", " -> ".join(str(c) for c in cycle), ) return keys ordered: List[str] = [] while sorter.is_active(): ready = sorted(sorter.get_ready()) ordered.extend(ready) sorter.done(*ready) return ordered def _detect_kind_from_source(source_text: str) -> Optional[str]: """Return the kind implied by source markers (mirrors plugins/memory ``_is_memory_provider_dir``). Memory-provider markers -> ``exclusive``; ``register_provider`` + ``ProviderProfile`` -> ``model-provider``; else ``None``. Keeps both kinds out of the general manager's eager import. """ if "register_memory_provider" in source_text or "MemoryProvider" in source_text: return "exclusive" if "register_provider" in source_text and "ProviderProfile" in source_text: return "model-provider" return None def _read_source_from_origin(origin: Optional[str], limit: int = 8192) -> str: """First ``limit`` chars of a module's source (``.pyc`` mapped back to ``.py``); "" on failure.""" if not origin: return "" if origin.endswith((".pyc", ".pyo")): try: origin = importlib.util.source_from_cache(origin) except Exception: return "" if not origin.endswith(".py"): return "" try: return Path(origin).read_text(encoding="utf-8", errors="replace")[:limit] except Exception: return "" def resolve_module_origin(module_name: str) -> Optional[str]: """Return a module's source path WITHOUT importing it, or ``None``. ``find_spec`` on a dotted name imports the parent package, so only the top-level name uses it; remaining segments are walked through ``submodule_search_locations`` by hand. Namespace/zipped/ extension modules return ``None``. Shared with ``plugins/memory/__init__.py``. """ parts = [p for p in module_name.split(".") if p] if not parts: return None try: spec = importlib.util.find_spec(parts[0]) if spec is None or not spec.origin: return None if len(parts) == 1: return spec.origin search_paths = spec.submodule_search_locations if not search_paths: return None for i, part in enumerate(parts[1:], start=2): found_origin = None next_paths = None for base in search_paths: base = Path(base) pkg_init = base / part / "__init__.py" if pkg_init.is_file(): found_origin = str(pkg_init) next_paths = [base / part] break mod_file = base / (part + ".py") if mod_file.is_file(): found_origin = str(mod_file) break if found_origin is None: return None if i == len(parts) or next_paths is None: return found_origin search_paths = next_paths return None except Exception: return None def _resolve_module_source(module_name: str, limit: int = 8192) -> str: """First ``limit`` chars of a module's source without importing it ("" when unresolvable).""" return _read_source_from_origin(resolve_module_origin(module_name), limit) @dataclass class PluginManifest: """Parsed representation of a plugin.yaml manifest.""" name: str version: str = "" description: str = "" author: str = "" requires_env: List[Union[str, Dict[str, Any]]] = field(default_factory=list) provides_tools: List[str] = field(default_factory=list) provides_hooks: List[str] = field(default_factory=list) source: str = "" # "bundled", "user", "project", or "entrypoint" path: Optional[str] = None # ``standalone`` (default; opt-in via plugins.enabled) | ``backend`` (pluggable backend for a # core tool; bundled auto-load, user-installed gated) | ``exclusive`` (one active provider, # selected via .provider; own discovery, general scanner skips) | ``platform`` # (gateway adapter; bundled auto-load, user-installed gated as untrusted code). kind: str = "standalone" # Path-derived registry key used by plugins.enabled/disabled and `hermes plugins list`: # ``disk-cleanup`` for a flat plugin, ``image_gen/openai`` for a category plugin. Empty -> name. key: str = "" portable: bool = False skill_namespace: str = "" # Declared capability ids, normalized to KNOWN ids. Declaration is consent metadata, NOT a # grant: live only via plugins.entries..granted_capabilities or the legacy allow_* key. capabilities: List[str] = field(default_factory=list) # Manifest v2 fields — all optional and additive. manifest_version versions the FILE FORMAT # (v1 supported forever); api_version is the runtime plugin API generation (None = current). manifest_version: int = 1 api_version: Optional[int] = None # Advisory deps [{"id", "version_range"}]: missing ones warn but load; they order the load. requires_plugins: List[Dict[str, Any]] = field(default_factory=list) # Declared pip deps — VALIDATED AND SURFACED ONLY, never auto-installed. python_dependencies: List[str] = field(default_factory=list) # Schema for plugins.entries..settings; mismatches warn, never fail. config_schema: Dict[str, Any] = field(default_factory=dict) license: str = "" homepage: str = "" tags: List[str] = field(default_factory=list) # Event-bus declarations, advisory (discoverability only): ``emits`` bare names published under # ``:``; ``listens`` fully-qualified ``:`` names. emits: List[str] = field(default_factory=list) listens: List[str] = field(default_factory=list) @dataclass(frozen=True) class PluginSystemPromptSection: """A plugin-owned section rendered once for each new session.""" id: str content: Union[str, Callable[[Mapping[str, Any]], str]] position: str max_chars: int plugin: str @dataclass(frozen=True) class RenderedPluginSystemPromptSection: """Validated prompt bytes frozen on the owning AIAgent.""" id: str content: str position: str plugin: str @dataclass(frozen=True) class _EventSubscription: """Host-owned subscription ledger entry.""" owner: str callback: Callable @dataclass(frozen=True) class _QueuedPluginEvent: """Immutable dispatch envelope consumed by the event worker.""" event: str payload: Dict[str, Any] subscriptions: tuple[_EventSubscription, ...] depth: int generation: int @dataclass class LoadedPlugin: """Runtime state for a single loaded plugin.""" manifest: PluginManifest module: Optional[types.ModuleType] = None tools_registered: List[str] = field(default_factory=list) hooks_registered: List[str] = field(default_factory=list) middleware_registered: List[str] = field(default_factory=list) commands_registered: List[str] = field(default_factory=list) enabled: bool = False error: Optional[str] = None # Bundled platform recorded as a not-yet-imported loader (see _register_deferred_platform). deferred: bool = False @dataclass class PluginRegistration: """One host-owned registration plus its inverse, so force reload unwinds registries in reverse order (including restoring the entry an override replaced).""" kind: str key: str release: Callable[[], None] plugin_key: str = "" # Process-global host infrastructure (e.g. dashboard-auth providers): kept out of # ``_registration_order`` so unload-all cannot dispose it, but still disposed by a *targeted* # unload and evicted on force re-discovery when the plugin no longer re-registers it. persistent: bool = False _disposed: bool = field(default=False, init=False, repr=False) _on_dispose: Optional[Callable[["PluginRegistration"], None]] = field( default=None, init=False, repr=False ) @property def active(self) -> bool: """Whether this handle still owns an active registration.""" return not self._disposed def dispose(self) -> None: """Release this registration once; repeated disposal is harmless.""" if self._disposed: return self._disposed = True try: self.release() finally: if self._on_dispose is not None: self._on_dispose(self) # --------------------------------------------------------------------------- # PluginContext – handed to each plugin's ``register()`` function # --------------------------------------------------------------------------- _PLUGIN_SETTING_SEGMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") _PLUGIN_SETTING_RESERVED_ROOTS = frozenset({"model", "plugins", "security", "settings"}) _PLUGIN_STATE_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$") _PLUGIN_STATE_QUOTA_BYTES = 10 * 1024 * 1024 _PLUGIN_STATE_LOCKS: Dict[str, threading.RLock] = {} _PLUGIN_STATE_LOCKS_GUARD = threading.Lock() def _plugin_relative_segments(key: str) -> tuple[str, ...]: """Validate/split a plugin-relative settings key; global paths, traversal, and core roots are rejected before any config read.""" if not isinstance(key, str): raise ValueError("Expected a plugin-relative config key string") segments = tuple(key.split(".")) if ( not key or "/" in key or "\\" in key or any( not _PLUGIN_SETTING_SEGMENT_RE.fullmatch(segment) for segment in segments ) or segments[0].lower() in _PLUGIN_SETTING_RESERVED_ROOTS ): raise ValueError( "Expected a plugin-relative config key such as 'endpoint' or " "'retry.policy'; global, cross-plugin, and traversal paths are forbidden" ) return segments def _nested_plugin_value(root: object, segments: tuple[str, ...], default: Any) -> Any: current = root for segment in segments: if not isinstance(current, Mapping) or segment not in current: return default current = current[segment] return current def _nested_plugin_mapping(segments: tuple[str, ...], value: Any) -> dict[str, Any]: nested: Any = value for segment in reversed(segments): nested = {segment: nested} return nested def _plugin_data_namespace(plugin_id: str, skill_namespace: str) -> str: """Return one Windows-safe directory component for plugin-owned data.""" candidate = skill_namespace or plugin_id if ( skill_namespace and candidate.startswith("agent-plugin-") and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,191}", candidate) ): # Portable Agent Plugins already receive this exact PLUGIN_DATA path. return candidate # Fixed prefix avoids Windows reserved device names; digest prevents fold collisions. return _portable_skill_namespace(candidate) def _state_thread_lock(path: Path) -> threading.RLock: key = str(path.resolve(strict=False)) with _PLUGIN_STATE_LOCKS_GUARD: return _PLUGIN_STATE_LOCKS.setdefault(key, threading.RLock()) @contextmanager def _locked_plugin_state(path: Path): """Serialize state read-modify-write across threads/processes (fcntl / msvcrt). The lock lives in a sibling file because atomic replacement changes the target's inode.""" lock_path = path.with_name(f".{path.name}.lock") thread_lock = _state_thread_lock(lock_path) with thread_lock: lock_path.parent.mkdir(parents=True, exist_ok=True) with open(lock_path, "a+b") as handle: if os.name == "nt": # pragma: no cover - exercised on Windows CI import msvcrt if handle.seek(0, os.SEEK_END) == 0: handle.write(b"\0") handle.flush() handle.seek(0) msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) else: import fcntl fcntl.flock(handle.fileno(), fcntl.LOCK_EX) try: yield finally: if os.name == "nt": # pragma: no cover - exercised on Windows CI handle.seek(0) msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) else: fcntl.flock(handle.fileno(), fcntl.LOCK_UN) class PluginState: """Atomic, quota-bounded JSON key/value state owned by one plugin.""" def __init__(self, plugin_id: str, skill_namespace: str = "") -> None: self._data_namespace = _plugin_data_namespace(plugin_id, skill_namespace) @property def data_dir(self) -> Path: """Profile-scoped directory matching portable plugins' PLUGIN_DATA.""" return get_hermes_home() / "plugin-data" / self._data_namespace @property def path(self) -> Path: return self.data_dir / "state.json" @property def quota_bytes(self) -> int: return _PLUGIN_STATE_QUOTA_BYTES @staticmethod def _validate_key(key: str) -> None: if ( not isinstance(key, str) or not _PLUGIN_STATE_KEY_RE.fullmatch(key) or ".." in key ): raise ValueError( "Plugin state keys must be 1-128 characters using letters, " "numbers, '_', '-', '.', or ':' (without '..')" ) def _read_unlocked(self) -> dict[str, Any]: try: with open(self.path, encoding="utf-8") as handle: data = json.load(handle) except FileNotFoundError: return {} except (OSError, ValueError) as exc: raise RuntimeError(f"Cannot parse plugin state {self.path}: {exc}") from exc if not isinstance(data, dict): raise RuntimeError(f"Cannot parse plugin state {self.path}: root must be an object") return data def get(self, key: str, default: Any = None) -> Any: """Read a JSON value, returning *default* when the key is absent.""" self._validate_key(key) with _locked_plugin_state(self.path): return self._read_unlocked().get(key, default) def set(self, key: str, value: Any) -> None: """Atomically set one JSON value without dropping concurrent updates.""" self._validate_key(key) with _locked_plugin_state(self.path): data = self._read_unlocked() data[key] = value try: encoded = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") except (TypeError, ValueError) as exc: raise ValueError( f"Plugin state value for {key!r} is not JSON-serializable" ) from exc if len(encoded) > self.quota_bytes: raise ValueError( f"Plugin state quota exceeded: {len(encoded)} bytes is greater " f"than the {self.quota_bytes}-byte per-plugin quota" ) from utils import atomic_json_write atomic_json_write(self.path, data, mode=0o600) class PluginContext: """Facade given to plugins so they can register tools and hooks.""" def __init__(self, manifest: PluginManifest, manager: "PluginManager"): self.manifest = manifest self._manager = manager # Lazy-built facades (see the matching properties). self._llm: Any = None self._subagent_lifecycle: Any = None self._state: PluginState | None = None self._platform_actions: Any = None @property def plugin_id(self) -> str: """Return the effective registry id used for this plugin's namespaces.""" return self.manifest.key or self.manifest.name def has_plugin(self, plugin_id: str) -> bool: """Return True when another plugin is loaded and enabled (runtime probe for advisory ``requires_plugins``). Matches on registry key or manifest name.""" return any( loaded.enabled and (key == plugin_id or loaded.manifest.name == plugin_id) for key, loaded in self._manager._plugins.items() ) # -- namespaced config and durable state -------------------------------- def get_config(self, key: str, default: Any = None) -> Any: """Read plugin-relative ``plugins.entries..settings.`` (falls back to the legacy ``config`` subtree for migration compatibility).""" try: segments = _plugin_relative_segments(key) except ValueError: logger.warning("Rejected config path %r from plugin %s", key, self.plugin_id) raise from hermes_cli.config import load_config_readonly config = load_config_readonly() or {} plugins = config.get("plugins") if isinstance(config, Mapping) else None entries = plugins.get("entries") if isinstance(plugins, Mapping) else None entry = entries.get(self.plugin_id) if isinstance(entries, Mapping) else None if not isinstance(entry, Mapping): return default missing = object() value = _nested_plugin_value(entry.get("settings"), segments, missing) if value is not missing: return value return _nested_plugin_value(entry.get("config"), segments, default) def set_config(self, key: str, value: Any) -> None: """Atomically write one value in this plugin's ``settings`` subtree.""" try: segments = _plugin_relative_segments(key) except ValueError: logger.warning("Rejected config path %r from plugin %s", key, self.plugin_id) raise from hermes_cli import config as config_mod if config_mod.is_managed(): raise PermissionError("Plugin settings cannot be changed in a managed install") from hermes_cli import managed_scope dotted_path = ".".join(("plugins", "entries", self.plugin_id, "settings", *segments)) if managed_scope.is_key_managed(dotted_path): raise PermissionError(f"Plugin setting {dotted_path!r} is administrator-managed") partial = { "plugins": { "entries": { self.plugin_id: { "settings": _nested_plugin_mapping(segments, value), } } } } full_path = ("plugins", "entries", self.plugin_id, "settings", *segments) # The lock covers merge-read plus atomic save so sibling plugin writes (threads or # processes) cannot race between the two steps. with _locked_plugin_state(config_mod.get_config_path()): with config_mod._CONFIG_LOCK: # Fail closed on malformed YAML: save_config degrades parse failures to {} — safe # for reads, destructive for read-modify-write. config_mod.read_user_config_raw() config_mod.save_config(partial, preserve_keys={full_path}, merge_existing=True) @property def state(self) -> PluginState: """Return this plugin's profile-scoped durable JSON state facade.""" if self._state is None: self._state = PluginState(self.plugin_id, self.manifest.skill_namespace) return self._state @property def platform_actions(self): """Capability-gated platform action facade (``add_reaction``, ``set_thread_title``). Every call re-checks ``gateway.platform_actions`` (legacy gate ``plugins.entries..allow_platform_actions``, default OFF) and returns ``{"ok": bool, ...}`` — verbs never raise into hook dispatch; no adapter handles or raw SDK objects. """ if self._platform_actions is None: from hermes_cli.platform_actions import PlatformActions self._platform_actions = PlatformActions(self.plugin_id) return self._platform_actions def _track( self, kind: str, key: str, release: Callable[[], None], *, persistent: bool = False, ) -> PluginRegistration: """Record host-owned cleanup for a successful registration (see :meth:`PluginManager._track_registration` for ``persistent``).""" return self._manager._track_registration( self.manifest, kind, key, release, persistent=persistent ) def _track_replacement( self, kind: str, key: str, *, slot: tuple, current: Any, previous: Any, restore: Callable[[Any], bool], ) -> PluginRegistration: """Track one generation in a replaceable manager-local registration slot.""" lease = replacement_coordinator.acquire( slot, current=current, previous=previous, restore=restore ) return self._track(kind, key, lease.dispose) def _track_mapping_entry( self, kind: str, key: str, mapping: Dict[str, Any], entry: Any, previous: Any ) -> PluginRegistration: """Store ``entry`` under ``key`` in a manager-local mapping and lease the slot. Unload restores ``previous`` (or removes the key) only while ``entry`` is still current. """ mapping[key] = entry return self._track_replacement( kind, key, slot=("manager_mapping", builtins.id(mapping), key), current=entry, previous=previous, restore=lambda replacement: self._manager._restore_mapping( mapping, key, entry, replacement ), ) def _register_scoped_provider( self, provider: Any, *, kind: str, base_class: type, registry: Any, label: str, article: str = "a", normalize: Optional[Callable[[str], str]] = lambda n: n.strip(), register: Optional[Callable[..., Any]] = None, reject_message: Optional[str] = None, ) -> Optional[PluginRegistration]: """Shared body of the ``register__provider`` methods. Type-check (warn + ignore on mismatch), register in the scope-keyed ``registry``, lease the slot so unload restores the displaced entry. Returns ``None`` when the registry refused or replaced the provider (``ValueError`` with ``reject_message`` set, or a falsy ``register``). """ if not isinstance(provider, base_class): logger.warning( "Plugin '%s' tried to register %s %s that does not inherit from %s. Ignoring.", self.manifest.name, article, label, base_class.__name__, ) return None registry_name = provider.name if normalize is None else normalize(provider.name) scope = self._manager.scope_key previous = registry.snapshot_registration(registry_name, scope=scope) register_fn = register or registry.register_provider try: accepted = register_fn(provider, scope=scope) except ValueError as exc: if reject_message is None: raise logger.warning(reject_message, self.manifest.name, exc) return None if register is not None and not accepted: return None if registry.snapshot_registration(registry_name, scope=scope) is not provider: return None handle = self._manager._track_scoped_registration( self.manifest, kind, registry_name, registry, provider, previous ) logger.info("Plugin '%s' registered %s: %s", self.manifest.name, label, registry_name) return handle # -- host-owned LLM access ---------------------------------------------- @property def llm(self) -> Any: """Host-owned :class:`agent.plugin_llm.PluginLlm` facade: completions on the user's active model/auth. Overrides (model, agent id, auth profile) are fail-closed, gated by ``plugins.entries..llm.*``.""" if self._llm is None: from agent.plugin_llm import PluginLlm plugin_id = self.manifest.key or self.manifest.name self._llm = PluginLlm(plugin_id=plugin_id) return self._llm @property def subagent_lifecycle(self) -> Any: """Plugin-safe subagent lifecycle service: serializable handles and immutable snapshots, never a live agent or private registry.""" if self._subagent_lifecycle is None: from agent.subagent_lifecycle import ( SubagentLifecycleService, get_active_subagent_parent, ) self._subagent_lifecycle = SubagentLifecycleService(get_active_subagent_parent) return self._subagent_lifecycle # -- profile awareness -------------------------------------------------- @property def profile_name(self) -> str: """Active profile name: ``"default"``, the ``~/.hermes/profiles/`` id, or ``"custom"``. Derived from ``HERMES_HOME`` (not ``_cli_ref``, which is None outside interactive CLI) so it works in the gateway and kanban workers too. """ try: from hermes_cli.profiles import get_active_profile_name return get_active_profile_name() except Exception: return "default" # -- lifecycle: unload callbacks and supervised tasks -------------------- def on_unload(self, callback: Callable[[], None]) -> PluginRegistration: """Register a cleanup callback for unload: runs in reverse acquisition order interleaved with registration teardown; exceptions are logged, never propagated.""" if not callable(callback): raise TypeError("on_unload callback must be callable") handle = self._track("on_unload", getattr(callback, "__name__", "callback"), callback) logger.debug("Plugin %s registered on_unload callback", self.manifest.name) return handle def spawn_task(self, coro, *, name: Optional[str] = None) -> "asyncio.Task": """Spawn a supervised asyncio task; unload/force reload cancels it. Needs a running loop.""" if not asyncio.iscoroutine(coro): raise TypeError("spawn_task expects a coroutine") loop = asyncio.get_running_loop() task_name = name or f"plugin:{self.plugin_id}:task" task = loop.create_task(coro, name=task_name) def _cancel_task() -> None: if not task.done(): task.cancel() handle = self._track("background_task", task_name, _cancel_task) task.add_done_callback(lambda _t: handle.dispose()) logger.debug("Plugin %s spawned supervised task: %s", self.manifest.name, task_name) return task # -- approval transport registration ------------------------------------ def register_approval_transport(self, name: str, present_fn: Callable) -> None: """Register a human approval transport, inactive until ``security.approval.transport: `` selects it. It receives a redacted ``ApprovalRequest`` and returns only a correlated decision; policy and persistence stay host-owned. ``present_fn`` may be async.""" self._manager.register_approval_transport( name, present_fn, plugin_id=self.manifest.key or self.manifest.name, ) # Record ownership so unload/force-reload removes this transport. Duplicate names are # rejected above (raise), so there is never a displaced previous entry to restore. clean = str(name).strip().lower() entry = self._manager._approval_transports.get(clean) if entry is not None: self._track_mapping_entry( "approval_transport", clean, self._manager._approval_transports, entry, None ) # -- tool registration -------------------------------------------------- @_serialized_replacement def register_tool( self, name: str, toolset: str, schema: dict, handler: Callable, check_fn: Callable | None = None, requires_env: list | None = None, is_async: bool = False, description: str = "", emoji: str = "", override: bool = False, ) -> Optional[PluginRegistration]: """Register a tool in the global registry and track it as plugin-provided. ``override=True`` replaces a same-named built-in; without it a name already claimed by another toolset is rejected. Overriding requires operator opt-in via ``plugins.entries..allow_tool_override: true`` — otherwise any enabled plugin could silently replace a privileged built-in like ``write_file``. """ if override and not self._tool_override_allowed(name): plugin_id = self.manifest.key or self.manifest.name raise PluginToolOverrideError( f"Plugin {self.manifest.name!r} cannot override built-in tool " f"{name!r}. Set " f"plugins.entries.{plugin_id}.allow_tool_override: true " f"in config.yaml to allow this plugin to replace built-in tools." ) from tools.registry import registry scope = self._manager.scope_key previous = registry.snapshot_registration(name, scope=scope) effective = registry.get_entry(name, scope=scope) if previous is None and effective is not None and not override: logger.warning( "Plugin %s tried to shadow global tool %s without override=True", self.manifest.name, name, ) return None registry.register( name=name, toolset=toolset, schema=schema, handler=handler, check_fn=check_fn, requires_env=requires_env, is_async=is_async, description=description, emoji=emoji, override=override, scope=scope, ) registered = registry.snapshot_registration(name, scope=scope) if ( registered is not None and registered is not previous and registered.handler is handler ): self._manager._plugin_tool_names.add(name) handle = self._manager._track_scoped_registration( self.manifest, "tool", name, registry, registered, previous, finalize=lambda: self._manager._remove_tool_name_if_unowned(name), ) else: handle = None logger.debug( "Plugin %s registered tool: %s%s", self.manifest.name, name, " (override)" if override else "", ) return handle # -- capability probing (#64228) ----------------------------------------- def has_capability(self, capability: str) -> bool: """Return True when *capability* is live for this plugin (probe, then degrade gracefully). Bundled plugins are trusted for ``tools.override``; otherwise the granted-capability set or the legacy ``allow_*`` key decides. Unknown ids / unreadable consent -> False (fail closed). """ source = getattr(self.manifest, "source", "") or "" if source == "bundled" and capability == "tools.override": return True plugin_id = self.manifest.key or self.manifest.name return plugin_capability_granted(plugin_id, capability) # -- capability-gated MCP access ---------------------------------------- def call_mcp( self, server: str, tool: str, arguments: Optional[Dict[str, Any]] = None, timeout: float = 30, ) -> Dict[str, Any]: """Call ``tool`` on MCP ``server`` synchronously through the native client in :mod:`tools.mcp_tool` (same trust gates, breaker, reconnect) — never a parallel connection. Default-off, per-server grant: unlisted servers (``plugins.entries..mcp_allowlist``) raise ``PermissionError``. ``timeout`` clamps to 1–600s. Returns ``{"ok": True, "result"}`` or ``{"ok": False, "error"}``; results over ~64KB are truncated with a marker. """ plugin_id = self.manifest.key or self.manifest.name allowlist = self._mcp_allowlist(plugin_id) if server not in allowlist: raise PermissionError( f"Plugin {self.manifest.name!r} is not allowed to call MCP " f"server {server!r}. Add it to " f"plugins.entries.{plugin_id}.mcp_allowlist in config.yaml " f"to grant access (default is no MCP access)." ) try: timeout = float(timeout) except (TypeError, ValueError): timeout = 30.0 timeout = max(1.0, min(timeout, 600.0)) from tools.mcp_tool import _make_tool_handler handler = _make_tool_handler(server, tool, timeout) raw = handler(dict(arguments or {})) logger.debug( "Plugin %s called MCP %s/%s (timeout=%ss, %d chars returned)", self.manifest.name, server, tool, timeout, len(raw or ""), ) return self._mcp_envelope(raw) _MCP_RESULT_CHAR_CAP = 65536 @classmethod def _mcp_envelope(cls, raw: Any) -> Dict[str, Any]: """Normalize an MCP handler result string into a stable envelope.""" if not isinstance(raw, str): raw = "" if raw is None else str(raw) if len(raw) > cls._MCP_RESULT_CHAR_CAP: raw = raw[: cls._MCP_RESULT_CHAR_CAP] + "… [truncated]" truncated = True else: truncated = False parsed: Any = None try: parsed = json.loads(raw) except (ValueError, TypeError): parsed = None if isinstance(parsed, dict) and "error" in parsed: envelope: Dict[str, Any] = {"ok": False, "error": parsed["error"]} elif isinstance(parsed, dict) and "result" in parsed: envelope = {"ok": True, "result": parsed["result"]} if "structuredContent" in parsed: envelope["structuredContent"] = parsed["structuredContent"] else: envelope = {"ok": True, "result": parsed if parsed is not None else raw} if truncated: envelope["truncated"] = True return envelope @staticmethod def _mcp_allowlist(plugin_id: str) -> List[str]: """Operator-granted MCP server allowlist; missing/unreadable -> [] (default-deny).""" try: from hermes_cli.config import load_config cfg = load_config() or {} except Exception: return [] entries = (cfg.get("plugins") or {}).get("entries") or {} entry = entries.get(plugin_id) or {} allowlist = entry.get("mcp_allowlist") if not isinstance(allowlist, list): return [] return [str(item) for item in allowlist] # -- override trust gate ------------------------------------------------ def _tool_override_allowed(self, tool_name: str) -> bool: """Return True if this plugin may override built-in tools. Bundled plugins are trusted (a maintainer choice, not privilege escalation). Others need the ``tools.override`` capability via :func:`plugin_capability_granted` — granted_capabilities OR the legacy ``allow_tool_override: true`` key. """ source = getattr(self.manifest, "source", "") or "" if source == "bundled": return True try: from hermes_cli.config import load_config with _plugin_home_scope(self._manager.home_path): cfg = load_config() or {} except Exception: return False # fail closed: better to break the override than silently grant it plugin_id = self.manifest.key or self.manifest.name # Pass THIS manager's profile-scoped config so a multi-profile process never consults the # active profile's consent state instead. return plugin_capability_granted(plugin_id, "tools.override", config=cfg) # -- message injection -------------------------------------------------- def inject_message( self, content: str, role: str = "user", *, session_key: str | None = None, ) -> bool: """Inject a message into a CLI or gateway conversation (new turn if idle, interrupt if running). Gateway injection requires an existing ``session_key`` and ``plugins.entries..allow_gateway_injection``. ``True`` means the gateway accepted the request for async dispatch, not that delivery completed. """ cli = self._manager._cli_ref msg = content if role == "user" else f"[{role}] {content}" if cli is not None: if getattr(cli, "_agent_running", False): cli._interrupt_queue.put(msg) else: cli._pending_input.put(msg) return True if not session_key: logger.warning("inject_message: gateway mode requires an existing session_key") return False if not self._gateway_injection_allowed(): plugin_id = self.manifest.key or self.manifest.name logger.warning( "inject_message: gateway injection denied for plugin %s; set " "plugins.entries.%s.allow_gateway_injection: true to allow it", plugin_id, plugin_id, ) return False if not self._manager.has_gateway_message_injector: logger.warning("inject_message: no live gateway is available") return False plugin_id = self.manifest.key or self.manifest.name try: return bool( self._manager.inject_gateway_message( session_key=session_key, content=msg, plugin_id=plugin_id, ) ) except Exception: logger.warning( "inject_message: gateway scheduling failed for plugin %s", plugin_id, exc_info=True, ) return False def _gateway_injection_allowed(self) -> bool: """Return whether this plugin may trigger gateway session turns.""" try: cfg = load_config_readonly() or {} except Exception: return False plugin_id = self.manifest.key or self.manifest.name return ( cfg_get( cfg, "plugins", "entries", plugin_id, "allow_gateway_injection", default=False, ) is True ) # -- CLI command registration -------------------------------------------- @_serialized_replacement def register_cli_command( self, name: str, help: str, setup_fn: Callable, handler_fn: Callable | None = None, description: str = "", ) -> PluginRegistration: """Register a CLI subcommand (``hermes ...``). *setup_fn* receives the argparse subparser; *handler_fn* becomes ``set_defaults(func=...)``.""" previous = self._manager._cli_commands.get(name) entry = { "name": name, "help": help, "description": description, "setup_fn": setup_fn, "handler_fn": handler_fn, "plugin": self.manifest.name, "plugin_key": self.manifest.key or self.manifest.name, } handle = self._track_mapping_entry( "cli_command", name, self._manager._cli_commands, entry, previous ) logger.debug("Plugin %s registered CLI command: %s", self.manifest.name, name) return handle # -- slash command registration ------------------------------------------- @_serialized_replacement def register_command( self, name: str, handler: Callable, description: str = "", args_hint: str = "", argument_mode: str | None = None, ) -> Optional[PluginRegistration]: """Register an in-session slash command (``/name``) for CLI and gateway sessions. Handler: ``fn(raw_args: str) -> str | None`` (sync or async). ``args_hint`` (e.g. ``""``) lets adapters like Discord surface an argument field; without it the command registers parameterless there but still accepts trailing text as free-form chat. """ clean = name.lower().strip().lstrip("/").replace(" ", "-") if not clean: logger.warning( "Plugin '%s' tried to register a command with an empty name.", self.manifest.name, ) return # Reject if it conflicts with a built-in command try: from hermes_cli.commands import resolve_command if resolve_command(clean) is not None: logger.warning( "Plugin '%s' tried to register command '/%s' which conflicts " "with a built-in command. Skipping.", self.manifest.name, clean, ) return except Exception: pass previous = self._manager._plugin_commands.get(clean) hint = (args_hint or "").strip() mode = argument_mode if argument_mode in {"options", "text", "mixed"} else ( "text" if hint else None ) entry = { "handler": handler, "description": description or "Plugin command", "plugin": self.manifest.name, "plugin_key": self.manifest.key or self.manifest.name, "args_hint": hint, "argument_mode": mode, } handle = self._track_mapping_entry( "command", clean, self._manager._plugin_commands, entry, previous ) logger.debug("Plugin %s registered command: /%s", self.manifest.name, clean) return handle # -- tool dispatch ------------------------------------------------------- def dispatch_tool(self, tool_name: str, args: dict, **kwargs) -> str: """Dispatch a tool call through the registry with the parent agent (when available) resolved automatically; returns the handler's JSON string. ``kwargs`` forward to dispatch.""" from tools.registry import registry # In gateway mode _cli_ref is None — tools degrade gracefully (no spinner, TERMINAL_CWD). if "parent_agent" not in kwargs: cli = self._manager._cli_ref agent = getattr(cli, "agent", None) if cli else None if agent is not None: kwargs["parent_agent"] = agent return registry.dispatch(tool_name, args, scope=self._manager.scope_key, **kwargs) # -- context engine registration ----------------------------------------- @_serialized_replacement def register_context_engine(self, engine) -> Optional[PluginRegistration]: """Register the (single) ``agent.context_engine.ContextEngine`` replacing the built-in ContextCompressor; a second registration is rejected with a warning.""" if self._manager._context_engine is not None: logger.warning( "Plugin '%s' tried to register a context engine, but one is " "already registered. Only one context engine plugin is allowed.", self.manifest.name, ) return from agent.context_engine import ContextEngine if not isinstance(engine, ContextEngine): logger.warning( "Plugin '%s' tried to register a context engine that does not " "inherit from ContextEngine. Ignoring.", self.manifest.name, ) return previous = self._manager._context_engine self._manager._context_engine = engine handle = self._track_replacement( "context_engine", engine.name, slot=("manager_value", id(self._manager), "_context_engine"), current=engine, previous=previous, restore=lambda replacement: self._manager._restore_value( "_context_engine", engine, replacement ), ) logger.info("Plugin '%s' registered context engine: %s", self.manifest.name, engine.name) return handle # -- context reference registration ------------------------------------- def register_context_reference(self, provider) -> None: """Register a :class:`agent.context_references.ContextReferenceProvider`; ``provider.prefix`` defines ``@:``. Built-in prefixes (diff, staged, file, folder, git, url) are rejected.""" from agent.context_references import ( ContextReferenceProvider as _CRP, register_context_reference_provider as _register, ) if not isinstance(provider, _CRP): logger.warning( "Plugin '%s' tried to register a context reference provider " "that does not inherit from ContextReferenceProvider. Ignoring.", self.manifest.name, ) return try: _register(provider) except ValueError as exc: logger.warning( "Plugin '%s' context reference registration failed: %s", self.manifest.name, exc, ) return logger.info( "Plugin '%s' registered context reference: @%s:", self.manifest.name, provider.prefix, ) # -- memory provider registration --------------------------------------- def register_memory_provider(self, provider) -> None: """Record a memory provider (inert). Activation is owned by ``plugins/memory`` via ``memory.provider``; a provider reaching here was loaded by the general manager, and without this method its ``register()`` would fail on a missing attribute.""" from agent.memory_provider import MemoryProvider if not isinstance(provider, MemoryProvider): logger.warning( "Plugin '%s' tried to register a memory provider that does not " "inherit from MemoryProvider. Ignoring.", self.manifest.name, ) return self._memory_provider = provider logger.debug( "Plugin '%s' registered memory provider: %s", self.manifest.name, getattr(provider, "name", "?"), ) # -- image gen provider registration ------------------------------------ @_serialized_replacement def register_image_gen_provider(self, provider) -> Optional[PluginRegistration]: """Register an :class:`agent.image_gen_provider.ImageGenProvider`; ``provider.name`` is matched by ``image_gen.provider``.""" from agent import image_gen_registry from agent.image_gen_provider import ImageGenProvider return self._register_scoped_provider( provider, kind="image_gen_provider", base_class=ImageGenProvider, registry=image_gen_registry, label="image_gen provider", article="an", ) # -- dashboard auth provider registration -------------------------------- @_serialized_replacement def register_dashboard_auth_provider(self, provider) -> Optional[PluginRegistration]: """Register a :class:`hermes_cli.dashboard_auth.DashboardAuthProvider` for the dashboard auth gate (non-loopback bind without ``--insecure``). Wrong type / duplicate name warn and are ignored, never raised.""" from hermes_cli.dashboard_auth import DashboardAuthProvider from hermes_cli.dashboard_auth.registry import ( register_global_provider, unregister_global_provider, ) if not isinstance(provider, DashboardAuthProvider): logger.warning( "Plugin '%s' tried to register a dashboard-auth provider " "that does not inherit from DashboardAuthProvider. Ignoring.", self.manifest.name, ) return registry_name = provider.name # The auth registry is process-global (lifetime = web server). Disposing it on a routine # per-home manager teardown emptied it for the WHOLE process and disabled sign-in until # restart — so upsert and keep it out of reverse-order teardown (``persistent=True``). try: register_global_provider(provider) except (TypeError, ValueError) as e: logger.warning( "Plugin '%s' failed to register dashboard-auth provider " "%r: %s", self.manifest.name, getattr(provider, "name", "?"), e, ) return handle = self._track( "dashboard_auth_provider", registry_name, lambda: unregister_global_provider(registry_name, provider), persistent=True, ) logger.info( "Plugin '%s' registered dashboard-auth provider: %s (%s)", self.manifest.name, registry_name, provider.display_name, ) return handle # -- video gen provider registration ------------------------------------- @_serialized_replacement def register_video_gen_provider(self, provider) -> Optional[PluginRegistration]: """Register an :class:`agent.video_gen_provider.VideoGenProvider`; ``provider.name`` is matched by ``video_gen.provider``.""" from agent import video_gen_registry from agent.video_gen_provider import VideoGenProvider return self._register_scoped_provider( provider, kind="video_gen_provider", base_class=VideoGenProvider, registry=video_gen_registry, label="video_gen provider", ) # -- web search/extract provider registration ---------------------------- @_serialized_replacement def register_web_search_provider(self, provider) -> Optional[PluginRegistration]: """Register an :class:`agent.web_search_provider.WebSearchProvider`; ``provider.name`` is matched by ``web.search_backend`` / ``web.extract_backend`` / ``web.backend``.""" from agent import web_search_registry from agent.web_search_provider import WebSearchProvider return self._register_scoped_provider( provider, kind="web_search_provider", base_class=WebSearchProvider, registry=web_search_registry, label="web provider", ) # -- browser provider registration --------------------------------------- @_serialized_replacement def register_browser_provider(self, provider) -> Optional[PluginRegistration]: """Register an :class:`agent.browser_provider.BrowserProvider`; ``provider.name`` is matched by ``browser.cloud_provider`` (consulted by ``tools.browser_tool._get_cloud_provider``).""" from agent import browser_registry from agent.browser_provider import BrowserProvider return self._register_scoped_provider( provider, kind="browser_provider", base_class=BrowserProvider, registry=browser_registry, label="browser provider", ) # -- terminal environment provider registration ---------------------------- @_serialized_replacement def register_terminal_environment_provider(self, provider) -> Optional[PluginRegistration]: """Register a :class:`agent.terminal_env_provider.TerminalEnvironmentProvider`; ``provider.name`` is matched by ``terminal.backend`` when no built-in backend has that name. Built-in names (local, docker, singularity, modal, daytona, vercel_sandbox, ssh) are rejected — plugins never shadow in-tree backends.""" from agent import terminal_env_registry from agent.terminal_env_provider import TerminalEnvironmentProvider return self._register_scoped_provider( provider, kind="terminal_environment_provider", base_class=TerminalEnvironmentProvider, registry=terminal_env_registry, label="terminal environment provider", normalize=lambda n: n.strip().lower(), reject_message="Plugin '%s' terminal environment provider rejected: %s", ) # -- secret source registration ------------------------------------------- @_serialized_replacement def register_secret_source(self, source) -> Optional[PluginRegistration]: """Register a :class:`agent.secret_sources.base.SecretSource`, run by ``load_hermes_dotenv()`` (after ``~/.hermes/.env``, before credentials are read) when ``secrets.`` is enabled. The orchestrator owns ordering/precedence/provenance; the source only fetches. Since dotenv usually loads before discovery, the manager re-pulls enabled plugin sources afterwards.""" import agent.secret_sources.registry as secret_registry from agent.secret_sources.base import SecretSource return self._register_scoped_provider( source, kind="secret_source", base_class=SecretSource, registry=secret_registry, label="secret source", normalize=None, register=secret_registry.register_source, ) # -- TTS provider registration ------------------------------------------- @_serialized_replacement def register_tts_provider(self, provider) -> Optional[PluginRegistration]: """Register an :class:`agent.tts_provider.TTSProvider`; ``provider.name`` is matched by ``tts.provider`` unless it is a built-in name (rejected with a warning) or a ``tts.providers.: type: command`` entry shares it (command-providers win).""" from agent import tts_registry from agent.tts_provider import TTSProvider return self._register_scoped_provider( provider, kind="tts_provider", base_class=TTSProvider, registry=tts_registry, label="TTS provider", normalize=lambda n: n.strip().lower(), ) # -- transcription (STT) provider registration --------------------------- @_serialized_replacement def register_transcription_provider(self, provider) -> Optional[PluginRegistration]: """Register an :class:`agent.transcription_provider.TranscriptionProvider`; ``provider.name`` is matched by ``stt.provider`` unless it is a built-in name (rejected) or a ``stt.providers.: type: command`` entry shares it (command-providers win).""" from agent import transcription_registry from agent.transcription_provider import TranscriptionProvider return self._register_scoped_provider( provider, kind="transcription_provider", base_class=TranscriptionProvider, registry=transcription_registry, label="transcription provider", normalize=lambda n: n.strip().lower(), ) # -- platform adapter registration --------------------------------------- @_serialized_replacement def register_platform( self, name: str, label: str, adapter_factory: Callable, check_fn: Callable, validate_config: Callable | None = None, required_env: list | None = None, install_hint: str = "", **entry_kwargs: Any, ) -> Optional[PluginRegistration]: """Register a gateway platform adapter (``adapter_factory(PlatformConfig) -> BasePlatformAdapter``). ``check_fn`` is a PASSIVE "deps importable?" probe that must never install — status displays call it freely; pass an ACTIVE installer as ``ensure_deps_fn`` (the gateway calls it from ``create_adapter()`` when ``check_fn`` is False). Extra kwargs (``setup_fn``, ``emoji``, ``allowed_users_env``, ``platform_hint``, ``ensure_deps_fn``) forward to ``PlatformEntry``; unknown keys raise TypeError. """ from gateway.platform_registry import platform_registry, PlatformEntry entry_kwargs.setdefault("plugin_name", self.manifest.name) entry = PlatformEntry( name=name, label=label, adapter_factory=adapter_factory, check_fn=check_fn, validate_config=validate_config, required_env=required_env or [], install_hint=install_hint, source="plugin", **entry_kwargs, ) scope = self._manager.scope_key previous = platform_registry.snapshot_registration(name, scope=scope) platform_registry.register(entry, scope=scope) current = platform_registry.snapshot_registration(name, scope=scope) if current[0] is not entry or current[1] is not None: return None self._manager._plugin_platform_names.add(name) handle = self._manager._track_scoped_registration( self.manifest, "platform", name, platform_registry, current, previous, finalize=lambda: self._manager._remove_platform_name_if_unowned(name), ) logger.debug("Plugin %s registered platform: %s", self.manifest.name, name) return handle # -- slack action handler registration ---------------------------------- def register_slack_action_handler( self, action_id: Any, callback: Callable, ) -> PluginRegistration: """Register a Slack Block Kit action handler, wired into ``slack_bolt.AsyncApp`` at connect. ``action_id`` is anything ``slack_bolt.App.action()`` accepts; ``callback`` is ``async def handler(ack, body, action)`` (``await ack()`` within 3s). Raises ``ValueError`` for a non-callable callback or empty ``action_id``. """ if not callable(callback): raise ValueError( f"Plugin '{self.manifest.name}' tried to register a Slack " f"action handler with a non-callable callback." ) if action_id is None or (isinstance(action_id, str) and not action_id.strip()): raise ValueError( f"Plugin '{self.manifest.name}' tried to register a Slack " f"action handler with an empty action_id." ) entry = (action_id, callback, self.manifest.name) self._manager._slack_action_handlers.append(entry) handle = self._track( "slack_action_handler", repr(action_id), lambda: self._manager._remove_identity( self._manager._slack_action_handlers, entry ), ) logger.debug("Plugin %s registered Slack action handler: %s", self.manifest.name, action_id) return handle # -- platform handler registration ---------------------------------------- def register_platform_handler(self, platform: str, factory: Callable) -> None: """Register a native-client handler factory for a gateway platform, invoked at ``connect()`` as ``factory(native, adapter)`` before/as the core handlers register (``adapter`` read-only). ``native``: telegram PTB ``Application``, discord ``commands.Bot``, slack ``AsyncApp``, matrix client, teams ``App``, dingtalk ``DingTalkStreamClient``, line aiohttp ``web.Application``, others ``None``. Keep SDK imports inside the factory; exceptions are logged and the platform still connects. Always scope handlers hooked into first-match dispatch tables so core flows keep working. Raises ``ValueError`` for a non-callable factory or empty platform. """ if not callable(factory): raise ValueError( f"Plugin '{self.manifest.name}' tried to register a platform " f"handler factory with a non-callable factory." ) key = (platform or "").strip().lower() if not key: raise ValueError( f"Plugin '{self.manifest.name}' tried to register a platform " f"handler factory with an empty platform name." ) self._manager._platform_handler_factories.setdefault(key, []).append( (factory, self.manifest.name) ) logger.debug( "Plugin %s registered %s handler factory: %s", self.manifest.name, key, getattr(factory, "__name__", repr(factory)), ) # -- telegram handler registration --------------------------------------- def register_telegram_handler(self, factory: Callable) -> None: """Alias of ``register_platform_handler("telegram", factory)``: ``factory(application, adapter)`` runs before the core handlers. PTB dispatches only the FIRST matching handler per group and core registers a catch-all ``CallbackQueryHandler`` — always scope with ``pattern=`` or you swallow the core button flows. Raises ``ValueError`` if not callable.""" self.register_platform_handler("telegram", factory) # -- auxiliary task registration --------------------------------------- @_serialized_replacement def register_auxiliary_task( self, key: str, *, display_name: str, description: str, defaults: Optional[Dict[str, Any]] = None, ) -> PluginRegistration: """Register an auxiliary LLM task with its own ``auxiliary.`` config block (picker entry, ``AUXILIARY__*`` env bridge, defaults merged into loaded configs). ``key`` is snake_case and must not shadow a built-in task. ``defaults`` may override provider/model/base_url/api_key/timeout/extra_body; unknown keys are preserved verbatim. Raises ``ValueError`` for an empty/invalid key, a built-in key, or another plugin's key. """ if not key or not isinstance(key, str): raise ValueError( f"Plugin '{self.manifest.name}' tried to register auxiliary task " f"with invalid key {key!r}" ) if not all(c.isalnum() or c == "_" for c in key): raise ValueError( f"Plugin '{self.manifest.name}' auxiliary task key {key!r} " f"must contain only alphanumeric characters and underscores" ) from hermes_cli.main import _AUX_TASKS as _BUILTIN_AUX_TASKS builtin_keys = {k for k, _name, _desc in _BUILTIN_AUX_TASKS} if key in builtin_keys: raise ValueError( f"Plugin '{self.manifest.name}' cannot register auxiliary task " f"{key!r} — that key is reserved for a built-in task. " f"Pick a plugin-namespaced key (e.g. '{self.manifest.name}_{key}')." ) # Owner is the canonical id ``ctx.llm`` is bound to, so agent/plugin_llm.py can match it. owner_id = self.manifest.key or self.manifest.name existing = self._manager._aux_tasks.get(key) if existing is not None and existing.get("plugin") != owner_id: raise ValueError( f"Plugin '{self.manifest.name}' cannot register auxiliary task " f"{key!r} — already registered by plugin " f"'{existing.get('plugin')}'" ) # Plugin owns the schema; routing fields are guaranteed present so consumers don't crash. merged_defaults: Dict[str, Any] = { "provider": "auto", "model": "", "base_url": "", "api_key": "", "timeout": 60, "extra_body": {}, } merged_defaults.update(defaults or {}) entry = { "key": key, "display_name": display_name, "description": description, "defaults": merged_defaults, "plugin": owner_id, "plugin_key": owner_id, } handle = self._track_mapping_entry( "auxiliary_task", key, self._manager._aux_tasks, entry, existing ) logger.debug( "Plugin %s registered auxiliary task: %s (%s)", self.manifest.name, key, display_name, ) return handle # -- redaction pattern registration -------------------------------------- def register_redaction_patterns(self, patterns) -> int: """Additively register secret-token regexes with :mod:`agent.redact`; returns the count accepted. Additive-only: plugins can over-redact, never weaken built-ins; ``security.redact_secrets: false`` applies equally. Each pattern must compile and start with >= 2 literal characters; invalid entries warn and are skipped. """ from agent.redact import register_redaction_patterns as _register try: count = _register(patterns, source=f"plugin:{self.manifest.name}") except Exception as exc: logger.warning( "Plugin '%s' redaction pattern registration failed: %s", self.manifest.name, exc, ) return 0 logger.debug("Plugin %s registered %d redaction pattern(s)", self.manifest.name, count) return count def register_hook(self, hook_name: str, callback: Callable) -> PluginRegistration: """Register a lifecycle hook callback (unknown names warn but are still stored).""" return self._track_callback( "hook", hook_name, callback, self._manager._hooks, VALID_HOOKS ) def _track_callback( self, kind: str, key: str, callback: Callable, mapping: Dict[str, List[Callable]], valid: Set[str], ) -> PluginRegistration: """Append ``callback`` under ``key`` (warning on unknown ``key``) and lease its removal.""" if key not in valid: logger.warning( "Plugin '%s' registered unknown %s '%s' (valid: %s)", self.manifest.name, kind, key, ", ".join(sorted(valid)), ) mapping.setdefault(key, []).append(callback) handle = self._track( kind, key, lambda: self._manager._remove_callback(mapping, key, callback), ) logger.debug("Plugin %s registered %s: %s", self.manifest.name, kind, key) return handle def register_system_prompt_section( self, id: str, content: Union[str, Callable[[Mapping[str, Any]], str]], *, position: str = "after_memory", max_chars: int = DEFAULT_SYSTEM_PROMPT_SECTION_MAX_CHARS, ) -> PluginRegistration: """Register bounded context frozen into each new session prompt. Callables receive a read-only session-info mapping; the rendered prompt is persisted by core verbatim.""" if not is_valid_system_prompt_section_id(id): raise ValueError( "system prompt section id must be 1-128 lowercase characters " "using letters, numbers, '.', '_', or '-'" ) if not isinstance(content, str) and not callable(content): raise TypeError("system prompt section content must be a string or callable") if position not in SYSTEM_PROMPT_SECTION_POSITIONS: raise ValueError( "system prompt section position must be one of: " + ", ".join(sorted(SYSTEM_PROMPT_SECTION_POSITIONS)) ) if ( isinstance(max_chars, bool) or not isinstance(max_chars, int) or not 0 < max_chars <= MAX_SYSTEM_PROMPT_SECTION_CHARS ): raise ValueError( "system prompt section max_chars must be between 1 and " f"{MAX_SYSTEM_PROMPT_SECTION_CHARS}" ) existing = self._manager._system_prompt_sections.get(id) if existing is not None: raise ValueError( f"system prompt section {id!r} is already registered by " f"plugin {existing.plugin!r}" ) plugin_id = self.manifest.key or self.manifest.name section = PluginSystemPromptSection( id=id, content=content, position=position, max_chars=max_chars, plugin=plugin_id, ) handle = self._track_mapping_entry( "system_prompt_section", id, self._manager._system_prompt_sections, section, existing ) logger.debug("Plugin %s registered system prompt section: %s", self.manifest.name, id) return handle # -- inter-plugin event bus -------------------------------------------- def emit(self, event: str, payload: Optional[dict] = None) -> int: """Publish bare *event* as ``:`` (namespace FORCED to this plugin); return the subscriber count scheduled. Any ``':'`` in the name (``hermes:x`` is reserved for core, foreign namespaces forbidden) raises ``ValueError``. Delivery is fire-and-forget via a single-worker queue: order preserved, a blocking subscriber cannot stall the emitter. """ plugin_key = self.manifest.key or self.manifest.name if not event or not isinstance(event, str): logger.warning("Plugin '%s' tried to emit an invalid event name %r", plugin_key, event) raise ValueError(f"Plugin '{plugin_key}' emit() requires a non-empty event name") if ":" in event: logger.warning( "Plugin '%s' tried to emit namespaced/reserved event '%s' — " "a plugin may only emit bare event names under its own '%s:' " "namespace (the '%s:' prefix is reserved for core, and foreign " "namespaces are forbidden)", plugin_key, event, plugin_key, HERMES_EVENT_NAMESPACE, ) raise ValueError( f"Plugin '{plugin_key}' may not emit '{event}': emit only the " f"bare event name; the namespace is forced to '{plugin_key}:' " f"and the '{HERMES_EVENT_NAMESPACE}:' prefix is reserved for core" ) if payload is not None and not isinstance(payload, dict): raise TypeError(f"Plugin '{plugin_key}' emit() payload must be a dict or None") full_event = f"{plugin_key}:{event}" return self._manager._dispatch_event(full_event, payload or {}) def subscribe(self, event: str, callback: Callable) -> None: """Subscribe to a fully-qualified ``:`` name (unrestricted — only emitting is namespace-gated). Owner-tagged so unload removes zombie callbacks.""" if not event or not isinstance(event, str): raise ValueError( f"Plugin '{self.manifest.name}' subscribe() requires a " f"non-empty event name" ) plugin_key = self.manifest.key or self.manifest.name self._manager._subscribe_event(plugin_key, event, callback) logger.debug("Plugin %s subscribed to event: %s", self.manifest.name, event) # -- middleware registration ------------------------------------------- def register_middleware(self, kind: str, callback: Callable) -> PluginRegistration: """Register behavior-changing middleware (request kinds rewrite the payload, execution kinds wrap the callback). Unknown kinds warn but are stored.""" return self._track_callback( "middleware", kind, callback, self._manager._middleware, VALID_MIDDLEWARE ) # -- skill registration ------------------------------------------------- @_serialized_replacement def register_skill( self, name: str, path: Path, description: str = "", frontmatter: Optional[Mapping[str, Any]] = None, ) -> PluginRegistration: """Register a read-only skill resolvable as ``':'`` via ``skill_view()``. Not in ``~/.hermes/skills/`` nor ```` — explicit loads only. Raises ``ValueError`` (``':'``/invalid chars) or ``FileNotFoundError``.""" from agent.skill_utils import _NAMESPACE_RE if ":" in name: raise ValueError( f"Skill name '{name}' must not contain ':' " f"(the namespace is derived from the plugin name " f"'{self.manifest.name}' automatically)." ) if not name or not _NAMESPACE_RE.match(name): raise ValueError(f"Invalid skill name '{name}'. Must match [a-zA-Z0-9_-]+.") if not path.exists(): raise FileNotFoundError(f"SKILL.md not found at {path}") namespace = self.manifest.skill_namespace or self.manifest.name qualified = f"{namespace}:{name}" if self.manifest.portable and qualified in self._manager._plugin_skills: raise ValueError(f"Plugin skill '{qualified}' is already registered") previous = self._manager._plugin_skills.get(qualified) entry = { "path": path, "plugin": namespace, "plugin_key": self.manifest.key or self.manifest.name, "bare_name": name, "description": description, "frontmatter": dict(frontmatter or {}), } handle = self._track_mapping_entry( "skill", qualified, self._manager._plugin_skills, entry, previous ) logger.debug("Plugin %s registered skill: %s", self.manifest.name, qualified) return handle # Hook callback timeout (non-blocking abandon). Default cap per Python hook callback; overridden by # ``plugins.hook_callback_timeout``. Shell hooks enforce their own subprocess timeout. _HOOK_CALLBACK_TIMEOUT_SECS = 30.0 _HOOK_SKIPPED = object() # returned by _run_hook_callback_bounded on skip/timeout _MAX_HOOK_CALLBACK_TIMEOUT_SECS = 600.0 def _resolve_hook_callback_timeout() -> float: """Effective hook-callback timeout from ``plugins.hook_callback_timeout`` (default 30s; ``<= 0`` disables the threaded path; clamped to ``_MAX_HOOK_CALLBACK_TIMEOUT_SECS``).""" timeout = _HOOK_CALLBACK_TIMEOUT_SECS try: from hermes_cli.config import load_config_readonly plugins_cfg = (load_config_readonly() or {}).get("plugins") if isinstance(plugins_cfg, dict) and "hook_callback_timeout" in plugins_cfg: raw = plugins_cfg.get("hook_callback_timeout") if raw is not None: timeout = float(raw) except (TypeError, ValueError): logger.warning( "plugins.hook_callback_timeout is not a number; using default %gs", _HOOK_CALLBACK_TIMEOUT_SECS, ) timeout = _HOOK_CALLBACK_TIMEOUT_SECS except Exception: timeout = _HOOK_CALLBACK_TIMEOUT_SECS if timeout < 0: logger.warning( "plugins.hook_callback_timeout=%g is negative; using default %gs", timeout, _HOOK_CALLBACK_TIMEOUT_SECS, ) return _HOOK_CALLBACK_TIMEOUT_SECS if timeout > _MAX_HOOK_CALLBACK_TIMEOUT_SECS: logger.warning( "plugins.hook_callback_timeout=%g exceeds max %gs; clamping", timeout, _MAX_HOOK_CALLBACK_TIMEOUT_SECS, ) return _MAX_HOOK_CALLBACK_TIMEOUT_SECS return timeout def _hook_uses_callback_timeout(hook_name: str, timeout: float) -> bool: """Whether *hook_name* should run under the non-blocking timeout path.""" if timeout <= 0 or hook_name in _HOOK_CALLER_THREAD_HOOKS: return False return ( hook_name in _HOOK_TIMEOUT_BOUNDED_HOOKS or hook_name in _HOOK_TIMEOUT_FAIL_CLOSED_HOOKS ) def _pre_tool_call_timeout_block() -> Dict[str, str]: """Fail-closed directive when a policy callback times out or is still running.""" return {"action": "block", "message": _PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE} class PluginManager: """Central manager that discovers, loads, and invokes plugins.""" def __init__(self, scope_key: Optional[str] = None) -> None: # Home is captured immutably: unload may run from another profile context, but every # inverse must target the registration's original scope. self.scope_key = scope_key or hermes_home_key() self.home_path = Path(self.scope_key) self._discovery_lock = threading.RLock() self._plugins: Dict[str, LoadedPlugin] = {} self._hooks: Dict[str, List[Callable]] = {} self._middleware: Dict[str, List[Callable]] = {} self._plugin_tool_names: Set[str] = set() self._plugin_platform_names: Set[str] = set() self._cli_commands: Dict[str, dict] = {} self._context_engine = None # Set by a plugin via register_context_engine() self._plugin_commands: Dict[str, dict] = {} # Slash commands registered by plugins self._system_prompt_sections: Dict[str, PluginSystemPromptSection] = {} self._discovered: bool = False self._cli_ref = None # Set by CLI after plugin discovery self._gateway_message_injector: tuple[object, Callable] | None = None self._plugin_skills: Dict[str, Dict[str, Any]] = {} # qualified name -> metadata self._portable_mcp_servers: Dict[str, Dict[str, Any]] = {} self._aux_tasks: Dict[str, Dict[str, Any]] = {} # see register_auxiliary_task self._approval_transports: Dict[str, Any] = {} # Event bus: owner-tagged subscriptions (unload removes zombies); one daemon worker keeps # registration order while emitters never block. self._subscriptions: Dict[str, List[_EventSubscription]] = {} self._event_lock = threading.RLock() self._event_idle = threading.Condition(self._event_lock) self._event_generation = 0 self._event_pending_by_generation: Dict[int, int] = {0: 0} self._event_queue: queue.Queue[Any] = queue.Queue(maxsize=_EVENT_PENDING_CAP) self._event_worker: Optional[threading.Thread] = None self._emit_depth = threading.local() # per-worker chain depth caps mutual emitters self._slack_action_handlers: List[tuple] = [] # (matcher, callback, plugin_name) # In-flight / recently-timed-out hook callbacks keyed by (hook_name, id(cb)) so a stuck # policy hook cannot spawn a new abandoned thread on every fire. self._hook_running_callbacks: Dict[tuple, object] = {} self._hook_timeout_suppressed_until: Dict[tuple, float] = {} self._hook_timeout_lock = threading.Lock() self._hook_timeout_suppression_seconds = _HOOK_TIMEOUT_SUPPRESSION_SECONDS # Ledger per plugin (ownership) plus global order (reverse teardown across plugins). # Process-global registries are shared across profiles while several managers coexist, so # the ledger is keyed per (hermes_home, plugin_id) and every inverse is identity-conditional # — one profile's unload can never clear another's registrations. self._ownership_ledger: Dict[str, List[PluginRegistration]] = {} self._registration_order: List[PluginRegistration] = [] # Persistent registrations that survived an unload-all; force re-discovery drains this via # _evict_stale_persistent_registrations(). self._persistent_carryover: List[PluginRegistration] = [] # Deferred platforms whose client tools registered at discovery (see # _register_deferred_platform_tools): imported package (don't re-execute on materialize) # and contributed tool names (so `hermes plugins list` still attributes them). self._predeclared_modules: Dict[str, types.ModuleType] = {} self._predeclared_tools: Dict[str, List[str]] = {} # Native platform handler factories keyed by lowercase platform name. self._platform_handler_factories: Dict[str, List[tuple]] = {} # -- registration ledger internals ---------------------------------------- def _track_registration( self, manifest: PluginManifest, kind: str, key: str, release: Callable[[], None], *, persistent: bool = False, ) -> PluginRegistration: """Record one registration under its canonical plugin key. ``persistent`` ones (process-global host infrastructure) stay in the ownership ledger for attribution but NOT in ``_registration_order``, so a routine unload cannot dispose them; the handle still releases on explicit ``dispose()``. """ plugin_key = manifest.key or manifest.name registration = PluginRegistration( kind=kind, key=key, release=release, plugin_key=plugin_key, persistent=persistent, ) registration._on_dispose = lambda disposed: self._forget_registrations([disposed]) self._ownership_ledger.setdefault(plugin_key, []).append(registration) if not persistent: self._registration_order.append(registration) return registration def _track_scoped_registration( self, manifest: PluginManifest, kind: str, name: str, registry: Any, current: Any, previous: Any, *, finalize: Optional[Callable[[], None]] = None, ) -> PluginRegistration: """Lease one ``(kind, scope, name)`` slot of a scope-keyed process-global registry. Unload calls ``registry.restore_registration(name, current, replacement, scope=...)`` — identity-conditional, so a later generation is never removed by an earlier owner. """ scope = self.scope_key lease = replacement_coordinator.acquire( (kind, scope, name), current=current, previous=previous, restore=lambda replacement: registry.restore_registration( name, current, replacement, scope=scope ), finalize=finalize, ) return self._track_registration(manifest, kind, name, lease.dispose) def _evict_stale_persistent_registrations(self) -> None: """After re-discovery, dispose parked persistent handles whose plugin did not re-register the same ``(kind, key)``. Re-registered ones are dropped WITHOUT disposing — the same object re-registered would pass the identity check and evict the live entry.""" if not self._persistent_carryover: return parked = self._persistent_carryover self._persistent_carryover = [] current = { (registration.kind, registration.key) for owned in self._ownership_ledger.values() for registration in owned if registration.persistent and registration.active } stale = [ registration for registration in parked if registration.active and (registration.kind, registration.key) not in current ] for registration in stale: logger.info( "Evicting persistent registration %s/%s: plugin '%s' no " "longer supplies it after re-discovery", registration.kind, registration.key, registration.plugin_key, ) self._dispose_registrations(stale) @staticmethod def _remove_identity(values: list, target: Any) -> bool: """Remove the last exact object match from a registration list.""" for index in range(len(values) - 1, -1, -1): if values[index] is target: del values[index] return True return False def _remove_callback( self, mapping: Dict[str, List[Callable]], key: str, callback: Callable, ) -> None: callbacks = mapping.get(key) if callbacks is None: return self._remove_identity(callbacks, callback) if not callbacks: mapping.pop(key, None) def _restore_mapping( self, mapping: Dict[str, Any], key: str, current: Any, previous: Optional[Any], ) -> bool: """Restore a manager-local mapping only when *current* is still present.""" if mapping.get(key) is not current: return False if previous is None: mapping.pop(key, None) else: mapping[key] = previous return True def _restore_value(self, attribute: str, current: Any, previous: Any) -> bool: """Restore a manager-local value only when *current* is still active.""" if getattr(self, attribute) is not current: return False setattr(self, attribute, previous) return True def _remove_name_if_unowned(self, kind: str, names: Set[str], name: str) -> None: """Drop *name* from the manager-local name set once no active ledger entry owns it.""" if not any( registration.active and registration.kind == kind and registration.key == name for registration in self._registration_order ): names.discard(name) def _remove_tool_name_if_unowned(self, name: str) -> None: self._remove_name_if_unowned("tool", self._plugin_tool_names, name) def _remove_platform_name_if_unowned(self, name: str) -> None: self._remove_name_if_unowned("platform", self._plugin_platform_names, name) def _forget_registrations(self, registrations: List[PluginRegistration]) -> None: if not registrations: return registration_ids = {id(registration) for registration in registrations} self._registration_order = [ registration for registration in self._registration_order if id(registration) not in registration_ids ] for plugin_key, owned in list(self._ownership_ledger.items()): remaining = [ registration for registration in owned if id(registration) not in registration_ids ] if remaining: self._ownership_ledger[plugin_key] = remaining else: self._ownership_ledger.pop(plugin_key, None) def _dispose_registrations(self, registrations: List[PluginRegistration]) -> None: """Dispose registrations in reverse acquisition order, best effort.""" for registration in reversed(registrations): try: registration.dispose() except Exception as exc: # pragma: no cover - defensive cleanup logger.warning( "Failed to unload plugin registration %s/%s: %s", registration.plugin_key, registration.key, exc, exc_info=_PLUGINS_DEBUG, ) @staticmethod def _resolve_plugin_key(plugin: Union[str, PluginManifest, LoadedPlugin]) -> str: if isinstance(plugin, LoadedPlugin): return plugin.manifest.key or plugin.manifest.name if isinstance(plugin, PluginManifest): return plugin.key or plugin.name return str(plugin) def unload(self, plugin: Union[str, PluginManifest, LoadedPlugin, None] = None) -> bool: """Unload registrations while excluding discovery/deferred loading.""" with self._discovery_lock, _plugin_home_scope(self.home_path): return self._unload_scoped(plugin) def _unload_scoped(self, plugin: Union[str, PluginManifest, LoadedPlugin, None] = None) -> bool: """Unload one plugin (or all when ``plugin=None``, as force rediscovery does). Every ledger registration — including on_unload callbacks and supervised tasks — is disposed in reverse acquisition order with identity-conditional inverses. Returns ``True`` when anything was found. """ unload_all = plugin is None if unload_all: target_keys = set(self._ownership_ledger) | set(self._plugins) registrations = list(self._registration_order) else: target_keys = self._unload_target_keys(self._resolve_plugin_key(plugin)) registrations = [ registration for registration in self._registration_order if registration.plugin_key in target_keys ] # Persistent registrations are absent from _registration_order (unload-all keeps them), # but a *targeted* unload is the disable/uninstall path: a disabled auth plugin's # provider must NOT stay live process-wide. registrations.extend( registration for key in target_keys for registration in self._ownership_ledger.get(key, []) if registration.persistent and registration.active ) found = bool(target_keys or registrations) self._dispose_registrations(registrations) self._forget_registrations(registrations) if unload_all: self._reset_after_unload_all(registrations) else: for key in target_keys: self._plugins.pop(key, None) return found def _unload_target_keys(self, requested: str) -> Set[str]: """Resolve a targeted-unload request to canonical plugin keys (exact key, else by name).""" if requested in self._ownership_ledger or requested in self._plugins: return {requested} return {key for key, loaded in self._plugins.items() if loaded.manifest.name == requested} def _reset_after_unload_all(self, registrations: List[PluginRegistration]) -> None: """Sweep pre-ledger global state and clear every manager-local container.""" # Handles are authoritative for global registries; names present in the manager-local sets # without a ledger entry (pre-ledger or manually set state) are swept here so they do not # survive a force reload as zombies. from gateway.platform_registry import platform_registry for platform_name in tuple(self._plugin_platform_names): platform_registry.unregister(platform_name) # Ledger-owned tool names are excluded: their handles already restored the previous entry, # and blanket deregistration would remove what the ledger just restored. ledger_tool_names = { registration.key for registration in registrations if registration.kind == "tool" } preledger_tools = tuple( name for name in self._plugin_tool_names if name not in ledger_tool_names ) if preledger_tools: try: from tools.registry import registry as tool_registry except Exception as exc: # pragma: no cover - defensive logger.debug("unload: tools.registry unavailable: %s", exc) else: for tool_name in preledger_tools: try: tool_registry.deregister(tool_name) except Exception as exc: logger.debug("unload: tool deregister %s failed: %s", tool_name, exc) # Persistent registrations survive unload-all but must not be orphaned by the ledger clear: # carry them over so force re-discovery can evict the ones whose plugin does not come back. carryover_ids = {id(registration) for registration in self._persistent_carryover} self._persistent_carryover.extend( registration for owned in self._ownership_ledger.values() for registration in owned if registration.persistent and registration.active and id(registration) not in carryover_ids ) for container in ( self._ownership_ledger, self._plugins, self._hooks, self._middleware, self._plugin_tool_names, self._plugin_platform_names, self._cli_commands, self._plugin_commands, self._plugin_skills, self._portable_mcp_servers, self._aux_tasks, self._system_prompt_sections, self._approval_transports, self._slack_action_handlers, self._predeclared_modules, self._predeclared_tools, self._platform_handler_factories, ): container.clear() self._context_engine = None with self._hook_timeout_lock: self._hook_running_callbacks.clear() self._hook_timeout_suppressed_until.clear() self._discovered = False # -- public ---------------------------------------------------------------- @property def has_gateway_message_injector(self) -> bool: """Return whether a live gateway can accept plugin-triggered turns.""" return self._gateway_message_injector is not None def set_gateway_message_injector(self, owner: object, injector: Callable[..., bool]) -> None: """Publish a live gateway injector and its lifecycle owner.""" self._gateway_message_injector = (owner, injector) def clear_gateway_message_injector(self, owner: object) -> None: """Clear the injector only when it still belongs to ``owner``.""" registered = self._gateway_message_injector if registered is not None and registered[0] is owner: self._gateway_message_injector = None def inject_gateway_message(self, **kwargs: Any) -> bool: """Submit a plugin-triggered turn to the live gateway.""" registered = self._gateway_message_injector if registered is None: return False return bool(registered[1](**kwargs)) def discover_and_load(self, force: bool = False) -> None: """Scan all plugin sources and load each plugin found; ``force`` unloads first so config changes / new bundled backends become visible in long-lived sessions.""" with self._discovery_lock, _plugin_home_scope(self.home_path): if self._discovered and not force: return if force: self.unload() # the ledger owns teardown of process-global registries if env_var_enabled("HERMES_SAFE_MODE"): logger.info("HERMES_SAFE_MODE=1 — plugin discovery skipped") self._discovered = True return # Flag set up front as a re-entrancy guard (register() can trigger discovery again) but # reset on failure so a failed scan is NOT cached as "discovered with an empty registry" # — callers swallow the exception and would be stranded on the early return above. self._discovered = True try: self._discover_and_load_inner() # Persistent registrations survived the unload-all; now that plugins re-registered, # dispose the ones whose plugin did not come back. self._evict_stale_persistent_registrations() # load_hermes_dotenv() ran at import, before plugin secret sources existed: re-pull. self._refresh_secret_sources_after_discovery() if force: # config.yaml shell hooks / outbound webhooks live in ``_hooks`` but are # config-owned; unload() wiped them and cannot restore them. self._re_register_config_hooks_after_force() except BaseException: self._discovered = False raise def _re_register_config_hooks_after_force(self) -> None: """Restore config-owned shell hooks/outbound webhooks after a force clear; each guarded independently so one failing does not skip the other.""" try: from agent.shell_hooks import re_register_config_hooks re_register_config_hooks() except Exception as exc: logger.debug("force-reload shell-hook re-register skipped: %s", exc) try: from agent.outbound_webhooks import ( re_register_config_hooks as re_register_outbound_webhooks, ) re_register_outbound_webhooks() except Exception as exc: logger.debug("force-reload outbound-webhook re-register skipped: %s", exc) def _refresh_secret_sources_after_discovery(self) -> None: """If any plugin secret source is enabled (per its own ``is_enabled(cfg)``, honoring custom activation), reset the cache and re-apply. Fail-open: never raises into discover_and_load.""" try: from agent.secret_sources.registry import list_plugin_sources from hermes_cli.env_loader import load_hermes_dotenv, reset_secret_source_cache except Exception: return try: plugin_sources = list_plugin_sources() except Exception: return if not plugin_sources: return try: from hermes_cli.config import load_config cfg = load_config() or {} secrets = cfg.get("secrets") or {} except Exception: secrets = {} enabled_names = [] for source in plugin_sources: name = getattr(source, "name", "") section = secrets.get(name) section = section if isinstance(section, dict) else {} try: if source.is_enabled(section): enabled_names.append(name) except Exception: continue # mirrors the orchestrator: a raising is_enabled() is skipped if not enabled_names: return try: reset_secret_source_cache() load_hermes_dotenv() logger.debug( "Re-applied secret sources after plugin discovery for: %s", ", ".join(sorted(enabled_names)), ) except Exception as exc: logger.debug("secret source re-apply after discovery failed: %s", exc) def _discover_and_load_inner(self) -> None: """The actual discovery sweep — see :meth:`discover_and_load`.""" manifests: List[PluginManifest] = self._collect_directory_manifests() # Entry points are separate from the directory scan: the startup MCP probe must not import # or register them. ep_manifests = self._scan_entry_points() logger.debug(" entrypoints: %d manifest(s)", len(ep_manifests)) manifests.extend(ep_manifests) disabled = _get_disabled_plugins() enabled = _get_enabled_plugins() # None = opt-in default (nothing enabled) stale_relay_keys = legacy_relay_plugin_keys(enabled) if stale_relay_keys: logger.warning( "Removed Hermes plugin %s is still listed in plugins.enabled; " "remove it and configure native Relay plugins with %s", ", ".join(stale_relay_keys), RELAY_PLUGINS_CONFIG_ENV, ) # Later sources win on key collision (project > user > bundled); gate the winners, then # load survivors in requires_plugins order (see resolve_plugin_load_order). winners = {m.key or m.name: m for m in manifests} to_load = { key: manifest for key, manifest in winners.items() if self._gate_manifest(manifest, disabled, enabled) } for lookup_key in resolve_plugin_load_order(to_load): manifest = to_load[lookup_key] self._warn_python_dependencies(manifest) self._validate_plugin_config_schema(manifest) self._load_plugin(manifest) if manifests: logger.info( "Plugin discovery complete: %d found, %d enabled", len(self._plugins), sum(1 for p in self._plugins.values() if p.enabled), ) def _record_placeholder( self, manifest: PluginManifest, *, enabled: bool, error: Optional[str] = None ) -> None: """Record a manifest that discovery will not import (introspection-only entry).""" loaded = LoadedPlugin(manifest=manifest, enabled=enabled) if error is not None: loaded.error = error self._plugins[manifest.key or manifest.name] = loaded def _gate_manifest( self, manifest: PluginManifest, disabled: Set[str], enabled: Optional[Set[str]], ) -> bool: """Route one winning manifest: load now, defer, or record as skipped. Returns True only for plugins that go through the dependency-ordered load pass. Gate order matters: legacy relay refusal, explicit disable, category-owned kinds, bundled auto-loads, then opt-in.""" lookup_key = manifest.key or manifest.name # Relay lifecycle is core-owned; an old plugin copy would compete for its registries. if lookup_key in LEGACY_RELAY_PLUGIN_KEYS or manifest.name in LEGACY_RELAY_PLUGIN_KEYS: error = ( "removed — Relay lifecycle is owned by Hermes core; configure " f"{RELAY_PLUGINS_CONFIG_ENV} instead" ) self._record_placeholder(manifest, enabled=False, error=error) logger.warning( "Refusing to load removed Hermes Relay plugin '%s'; %s", lookup_key, error, ) return False # Explicit disable always wins (key or legacy bare name). if lookup_key in disabled or manifest.name in disabled: self._record_placeholder(manifest, enabled=False, error="disabled via config") logger.debug("Skipping disabled plugin '%s'", lookup_key) return False # Exclusive plugins (memory providers) have their own activation path; record only. if manifest.kind == "exclusive": self._record_placeholder( manifest, enabled=False, error="exclusive plugin — activate via .provider config", ) logger.debug("Skipping '%s' (exclusive, handled by category discovery)", lookup_key) return False # Model providers load via providers/__init__.py; a second import here would create two # ProviderProfile instances and break the bundled-vs-user "last writer wins" override. if manifest.kind == "model-provider": self._record_placeholder(manifest, enabled=True) logger.debug( "Skipping '%s' (model-provider, handled by providers/ discovery)", lookup_key, ) return False # Bundled backends auto-load; selection among them is ``.provider`` config. if manifest.source == "bundled" and manifest.kind == "backend": self._load_plugin(manifest) return False # Bundled platforms register LAZILY: eagerly importing ~20 heavy SDKs added seconds to # every `hermes` invocation. A deferred loader keeps every platform available on first use. if manifest.source == "bundled" and manifest.kind == "platform": self._register_deferred_platform(manifest) return False # Everything else is opt-in via plugins.enabled (path-derived key or legacy bare name). if enabled is None or not (lookup_key in enabled or manifest.name in enabled): self._record_placeholder( manifest, enabled=False, error=f"not enabled in config (run `hermes plugins enable {lookup_key}` to activate)", ) logger.debug("Skipping '%s' (not in plugins.enabled)", lookup_key) return False return True def register_approval_transport( self, name: str, present_fn: Callable, *, plugin_id: str, ) -> None: """Register one plugin-owned approval transport for this profile.""" import re from hermes_cli.approval_transport import RegisteredApprovalTransport clean = str(name).strip().lower() if clean == "builtin": raise ValueError("approval transport name 'builtin' is reserved") if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", clean): raise ValueError("approval transport name must match [a-z0-9][a-z0-9_-]{0,63}") if not callable(present_fn): raise TypeError("approval transport present_fn must be callable") if clean in self._approval_transports: owner = self._approval_transports[clean].plugin_id raise ValueError(f"approval transport {clean!r} is already registered by {owner!r}") self._approval_transports[clean] = RegisteredApprovalTransport( name=clean, present=present_fn, plugin_id=plugin_id, profile_home=str(get_hermes_home().resolve()), ) logger.info("Plugin %s registered approval transport: %s", plugin_id, clean) def get_approval_transport(self, name: str): """Return a transport only inside the profile that registered it.""" registered = self._approval_transports.get(str(name).strip().lower()) if registered is None: return None if registered.profile_home != str(get_hermes_home().resolve()): return None return registered def _collect_directory_manifests(self) -> List[PluginManifest]: """Read directory manifests in full-discovery order without loading or mutating anything, so startup probes share the exact precedence/containment rules of ``_discover_and_load_inner``. """ manifests: List[PluginManifest] = [] # 1. Bundled; excluded top-level categories have their own discovery, platforms scan below. repo_plugins = get_bundled_plugins_dir() logger.debug("Scanning bundled plugins: %s", repo_plugins) bundled = self._scan_directory( repo_plugins, source="bundled", skip_names={"memory", "context_engine", "platforms", "model-providers"}, ) logger.debug(" bundled (top-level): %d manifest(s)", len(bundled)) manifests.extend(bundled) bundled_platforms = self._scan_directory(repo_plugins / "platforms", source="bundled") logger.debug(" bundled/platforms: %d manifest(s)", len(bundled_platforms)) manifests.extend(bundled_platforms) # 2. User plugins (~/.hermes/plugins/) user_dir = get_hermes_home() / "plugins" logger.debug("Scanning user plugins: %s", user_dir) user_manifests = self._scan_directory(user_dir, source="user") logger.debug(" user: %d manifest(s)", len(user_manifests)) manifests.extend(user_manifests) # 3. Project plugins, only when explicitly opted in (must match the full-discovery gate). if _env_enabled("HERMES_ENABLE_PROJECT_PLUGINS"): project_dir = Path.cwd() / ".hermes" / "plugins" logger.debug("Scanning project plugins: %s", project_dir) project_manifests = self._scan_directory(project_dir, source="project") logger.debug(" project: %d manifest(s)", len(project_manifests)) manifests.extend(project_manifests) else: logger.debug("Project plugins disabled (set HERMES_ENABLE_PROJECT_PLUGINS=1 to enable)") return manifests def has_enabled_portable_mcp(self, raw_config: Mapping[str, Any]) -> bool: """Probe enabled portable MCP packages without loading plugins (shares the full-discovery manifest collection so precedence/gating cannot diverge).""" if _env_enabled("HERMES_SAFE_MODE"): return False plugins_config = raw_config.get("plugins") if not isinstance(plugins_config, dict): return False def _names(value: Any) -> Set[str]: return {v for v in value if isinstance(v, str)} if isinstance(value, list) else set() enabled = _names(plugins_config.get("enabled")) disabled = _names(plugins_config.get("disabled", [])) if not enabled: return False winners = {m.key or m.name: m for m in self._collect_directory_manifests()} for manifest in winners.values(): if not manifest.portable: continue lookup_key = manifest.key or manifest.name if lookup_key in disabled or manifest.name in disabled: continue if lookup_key not in enabled and manifest.name not in enabled: continue try: from hermes_cli.agent_plugins import _discover_mcp if _discover_mcp( Path(manifest.path), get_hermes_home() / "plugin-data" / (manifest.skill_namespace or lookup_key), [], create_data=False, ): return True except (OSError, RuntimeError, ValueError): continue # fail closed on an unreadable package; full discovery reports it return False # -- directory scanning ----------------------------------------------------- def _scan_directory( self, path: Path, source: str, skip_names: Optional[Set[str]] = None, ) -> List[PluginManifest]: """Read manifests under *path*: flat ``//plugin.yaml`` (key ``name``) or category ``///plugin.yaml`` (key ``cat/name``, depth capped at two). *skip_names* ignores top-level names.""" return self._scan_directory_level(path, source, skip_names=skip_names, prefix="", depth=0) def _scan_directory_level( self, path: Path, source: str, *, skip_names: Optional[Set[str]], prefix: str, depth: int, ) -> List[PluginManifest]: """Recursive body of :meth:`_scan_directory` (``prefix`` = accumulated category path).""" manifests: List[PluginManifest] = [] if not path.is_dir(): return manifests for child in sorted(path.iterdir()): if not child.is_dir(): continue if depth == 0 and skip_names and child.name in skip_names: continue manifest_file = child / "plugin.yaml" if not manifest_file.exists(): manifest_file = child / "plugin.yml" if manifest_file.exists(): manifest = self._parse_manifest(manifest_file, child, source, prefix) if manifest is not None: manifests.append(manifest) continue portable_file = child / "plugin.json" if portable_file.exists() or portable_file.is_symlink(): try: from hermes_cli.agent_plugins import read_agent_plugin_manifest data, diagnostics = read_agent_plugin_manifest(child) for diagnostic in diagnostics: logger.warning("Agent Plugin '%s': %s", child, diagnostic.message) key = f"{prefix}/{child.name}" if prefix else data["name"] manifests.append( PluginManifest( name=data["name"], version=data.get("version", ""), description=data.get("description", ""), author=_display_author(data.get("author", "")), source=source, path=str(child), key=key, portable=True, skill_namespace=_portable_skill_namespace(key), ) ) except Exception as exc: logger.warning("Failed to parse %s: %s", portable_file, exc) continue # No manifest: treat as a category namespace and recurse one level (depth cap 2). if depth >= 1: logger.debug("Skipping %s (no plugin.yaml, depth cap reached)", child) continue sub_prefix = f"{prefix}/{child.name}" if prefix else child.name manifests.extend( self._scan_directory_level( child, source, skip_names=None, prefix=sub_prefix, depth=depth + 1, ) ) return manifests def _parse_manifest( self, manifest_file: Path, plugin_dir: Path, source: str, prefix: str, ) -> Optional[PluginManifest]: """Parse one ``plugin.yaml`` into a :class:`PluginManifest`; ``None`` (warned) on failure.""" try: if yaml is None: logger.warning("PyYAML not installed – cannot load %s", manifest_file) return None data = fast_safe_load(manifest_file.read_text(encoding="utf-8")) or {} name = data.get("name", plugin_dir.name) key = f"{prefix}/{plugin_dir.name}" if prefix else name raw_kind = data.get("kind", "standalone") if not isinstance(raw_kind, str): raw_kind = "standalone" kind = raw_kind.strip().lower() if kind not in _VALID_PLUGIN_KINDS: logger.warning( "Plugin %s: unknown kind '%s' (valid: %s); treating as 'standalone'", key, raw_kind, ", ".join(sorted(_VALID_PLUGIN_KINDS)), ) kind = "standalone" # Auto-coerce undeclared memory/model providers so they route to their own discovery # instead of the general manager (register_memory_provider here is a no-op). if kind == "standalone" and "kind" not in data: init_file = plugin_dir / "__init__.py" if init_file.exists(): with suppress(Exception): detected = _detect_kind_from_source( init_file.read_text(errors="replace", encoding="utf-8")[:8192] ) if detected: kind = detected logger.debug( "Plugin %s: detected %s, treating as kind='%s'", key, detected, detected, ) logger.debug( "Parsed manifest: key=%s name=%s kind=%s source=%s path=%s", key, name, kind, source, plugin_dir, ) v2_fields = _parse_manifest_v2_fields(data, key) return PluginManifest( name=name, version=str(data.get("version", "")), description=data.get("description", ""), author=_display_author(data.get("author", "")), requires_env=data.get("requires_env", []), provides_tools=data.get("provides_tools", []), provides_hooks=data.get("provides_hooks", []), source=source, path=str(plugin_dir), kind=kind, key=key, capabilities=_parse_declared_capabilities( data.get("capabilities"), name ), **v2_fields, emits=data.get("emits") or [], listens=data.get("listens") or [], ) except Exception as exc: logger.warning("Failed to parse %s: %s", manifest_file, exc, exc_info=_PLUGINS_DEBUG) return None def _scan_entry_points(self) -> List[PluginManifest]: """Read installed plugin entry points (see :func:`discover_entrypoint_manifests`).""" return discover_entrypoint_manifests() # -- 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 def _invoke_hook_callback(callback: Callable, payload: Dict[str, Any]) -> Any: """Invoke a hook while withholding additive fields from narrow legacy callbacks.""" try: parameters = inspect.signature(callback).parameters except (TypeError, ValueError): return callback(**payload) # no introspectable signature: historical behavior if any( parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values() ): return callback(**payload) accepted_payload = { name: value for name, value in payload.items() if name in parameters and parameters[name].kind in { inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY, } } return callback(**accepted_payload) def invoke_hook(self, hook_name: str, **kwargs: Any) -> List[Any]: """Call all callbacks for *hook_name*; return their non-``None`` results. Payloads evolve additively: ``**kwargs`` callbacks get everything, narrow signatures only what they declare. Each callback is isolated. Hooks in ``_HOOK_TIMEOUT_BOUNDED_HOOKS`` and ``pre_tool_call`` are bounded by ``plugins.hook_callback_timeout`` (worker abandoned, never joined); ``pre_tool_call`` fails closed with a block directive, others skip. ``_HOOK_CALLER_THREAD_HOOKS`` always run on the caller thread. ``pre_llm_call`` callbacks may return ``{"context": "..."}`` (or a plain string) to inject into the turn. """ # Gateway platform events define event-local envelopes; a bus-wide version here would turn # unrelated adapter payloads into one monolithic compatibility contract. if hook_name != "gateway_platform_event": kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION) callbacks = self._hooks.get(hook_name, []) results: List[Any] = [] timeout = _resolve_hook_callback_timeout() use_timeout = _hook_uses_callback_timeout(hook_name, timeout) fail_closed = hook_name in _HOOK_TIMEOUT_FAIL_CLOSED_HOOKS for cb in callbacks: try: if use_timeout: ret = self._run_hook_callback_bounded(hook_name, cb, kwargs, timeout) if ret is _HOOK_SKIPPED: if fail_closed: results.append(_pre_tool_call_timeout_block()) continue else: ret = self._invoke_hook_callback(cb, kwargs) if ret is not None: results.append(ret) except Exception as exc: logger.warning( "Hook '%s' callback %s raised: %s", hook_name, getattr(cb, "__name__", repr(cb)), exc, ) return results def _run_hook_callback_bounded( self, hook_name: str, cb: Callable, kwargs: Dict[str, Any], timeout: float ) -> Any: """Run one callback on a daemon worker with a wall-clock cap; ``_HOOK_SKIPPED`` when suppressed, still running, or timed out (worker abandoned, never joined). Exceptions propagate.""" callback_name = getattr(cb, "__name__", repr(cb)) callback_key = (hook_name, id(cb)) token = object() now = time.monotonic() with self._hook_timeout_lock: suppressed_until = self._hook_timeout_suppressed_until.get(callback_key) running = callback_key in self._hook_running_callbacks if (suppressed_until is not None and suppressed_until > now) or running: logger.warning( "Hook '%s' callback %s skipped after previous " "timeout or while still running", hook_name, callback_name, ) return _HOOK_SKIPPED if suppressed_until is not None: self._hook_timeout_suppressed_until.pop(callback_key, None) self._hook_running_callbacks[callback_key] = token context = contextvars.copy_context() done = threading.Event() outcome: Dict[str, Any] = {} failure: Dict[str, Exception] = {} def _runner() -> None: try: outcome["value"] = context.run(self._invoke_hook_callback, cb, kwargs) except Exception as exc: failure["exc"] = exc finally: with self._hook_timeout_lock: if self._hook_running_callbacks.get(callback_key) is token: self._hook_running_callbacks.pop(callback_key, None) done.set() thread = threading.Thread( target=_runner, name=f"hermes-hook-{callback_name}"[:40], daemon=True, ) thread.start() if not done.wait(timeout=timeout): # do not join — that would reintroduce the hang with self._hook_timeout_lock: self._hook_timeout_suppressed_until[callback_key] = ( time.monotonic() + self._hook_timeout_suppression_seconds ) logger.warning( "Hook '%s' callback %s timed out after %gs — skipping", hook_name, callback_name, timeout, ) return _HOOK_SKIPPED if "exc" in failure: raise failure["exc"] return outcome.get("value") def _subscribe_event(self, owner: str, event: str, callback: Callable) -> None: """Add an owner-tagged event subscription in registration order.""" if not callable(callback): raise TypeError("Event subscriber callback must be callable") entry = _EventSubscription(owner=owner, callback=callback) with self._event_lock: self._subscriptions.setdefault(event, []).append(entry) def _remove_plugin_subscriptions(self, owner: str) -> int: """Remove every subscription owned by *owner*; return the count. Queued envelopes re-check membership per callback, so this also cancels already-snapshotted deliveries.""" removed = 0 with self._event_lock: for event in list(self._subscriptions): entries = self._subscriptions[event] retained = [entry for entry in entries if entry.owner != owner] removed += len(entries) - len(retained) if retained: self._subscriptions[event] = retained else: del self._subscriptions[event] return removed def _ensure_event_worker_locked(self) -> None: worker = self._event_worker if worker is not None and worker.is_alive(): return dispatch_queue = self._event_queue worker = threading.Thread( target=self._event_worker_loop, args=(dispatch_queue,), name="hermes-plugin-events", daemon=True, ) self._event_worker = worker worker.start() def _event_worker_loop(self, dispatch_queue: queue.Queue[Any]) -> None: while True: item = dispatch_queue.get() try: if item is _EVENT_WORKER_STOP: return self._deliver_event(item) finally: if item is not _EVENT_WORKER_STOP: self._mark_event_done(item.generation) dispatch_queue.task_done() def _mark_event_done(self, generation: int) -> None: with self._event_idle: pending = self._event_pending_by_generation.get(generation, 0) if pending > 0: self._event_pending_by_generation[generation] = pending - 1 self._event_idle.notify_all() def _deliver_event(self, item: _QueuedPluginEvent) -> None: """Deliver one queued event on the host-owned worker thread.""" with self._event_lock: if item.generation != self._event_generation: return previous_depth = getattr(self._emit_depth, "value", 0) self._emit_depth.value = item.depth try: for subscription in item.subscriptions: with self._event_lock: if item.generation != self._event_generation: break # Owner unload may have removed this entry after the event was queued. if not any( current is subscription for current in self._subscriptions.get(item.event, []) ): continue callback = subscription.callback try: # Fresh deep copy per subscriber: no callback can mutate what the next sees. owned_payload = copy.deepcopy(item.payload) result = callback(**owned_payload) resolve_plugin_command_result(result) except Exception as exc: logger.warning( "Event '%s' subscriber %s raised: %s", item.event, getattr(callback, "__name__", repr(callback)), exc, ) finally: self._emit_depth.value = previous_depth def _wait_for_event_dispatch(self, timeout: float = 2.0) -> bool: """Wait for the current event generation to become idle (test helper).""" with self._event_idle: generation = self._event_generation return self._event_idle.wait_for( lambda: self._event_pending_by_generation.get(generation, 0) == 0, timeout=timeout, ) def _dispatch_event(self, event: str, payload: Dict[str, Any]) -> int: """Queue *event* without blocking; return the subscriber count scheduled. Pending work is bounded per generation so a blocking subscriber costs one worker and later emits drop.""" depth = getattr(self._emit_depth, "value", 0) if depth >= _EVENT_EMIT_DEPTH_CAP: logger.warning( "Event bus recursion cap (%d) exceeded while dispatching '%s' " "— dropping this emit to prevent an infinite loop", _EVENT_EMIT_DEPTH_CAP, event, ) return 0 with self._event_lock: subscriptions = tuple(self._subscriptions.get(event, [])) if not subscriptions: return 0 generation = self._event_generation pending = self._event_pending_by_generation.get(generation, 0) if pending >= _EVENT_PENDING_CAP: logger.warning( "Event bus pending budget (%d) exhausted while dispatching " "'%s' — dropping this emit", _EVENT_PENDING_CAP, event, ) return 0 item = _QueuedPluginEvent( event=event, payload=dict(payload), subscriptions=subscriptions, depth=depth + 1, generation=generation, ) try: self._event_queue.put_nowait(item) except queue.Full: logger.warning( "Event bus pending budget (%d) exhausted while dispatching " "'%s' — dropping this emit", _EVENT_PENDING_CAP, event, ) return 0 self._event_pending_by_generation[generation] = pending + 1 self._ensure_event_worker_locked() return len(subscriptions) def has_hook(self, hook_name: str) -> bool: """Return True when at least one callback is registered for a hook.""" return bool(self._hooks.get(hook_name)) def iter_hook_callbacks(self, hook_name: str) -> tuple[Callable, ...]: """Return a stable snapshot of callbacks registered for a hook.""" return tuple(self._hooks.get(hook_name, ())) def render_system_prompt_sections( self, session_info: Mapping[str, Any] ) -> List[RenderedPluginSystemPromptSection]: """Render all registered sections deterministically and fail open.""" frozen_info = types.MappingProxyType(dict(session_info)) rendered: List[RenderedPluginSystemPromptSection] = [] total_chars = len(PLUGIN_SECTIONS_START) + len(PLUGIN_SECTIONS_END) + 2 for section_id in sorted(self._system_prompt_sections): section = self._system_prompt_sections[section_id] if len(rendered) >= MAX_SYSTEM_PROMPT_SECTIONS: logger.warning( "Plugin system prompt section %s exceeded the section-count " "budget (%d) and was skipped", section.id, MAX_SYSTEM_PROMPT_SECTIONS, ) continue text = self._render_prompt_section_text(section, frozen_info) if text is None: continue rendered_chars = len(format_system_prompt_section(section.id, text)) if rendered: rendered_chars += 2 # canonical ``\n\n`` separator if total_chars + rendered_chars > MAX_SYSTEM_PROMPT_SECTIONS_TOTAL_CHARS: logger.warning( "Plugin system prompt section %s (%s) exceeded the aggregate " "session budget (%d chars) and was skipped", section.id, section.plugin, MAX_SYSTEM_PROMPT_SECTIONS_TOTAL_CHARS, ) continue rendered.append( RenderedPluginSystemPromptSection( id=section.id, content=text, position=section.position, plugin=section.plugin, ) ) total_chars += rendered_chars logger.info( "Session plugin prompt section: id=%s plugin=%s position=%s chars=%d", section.id, section.plugin, section.position, len(text), ) return rendered @staticmethod def _render_prompt_section_text( section: PluginSystemPromptSection, frozen_info: Mapping[str, Any] ) -> Optional[str]: """Evaluate one section; return its stripped text or None (with a warning) when skipped.""" def _skip(detail: str, *args: Any) -> None: logger.warning( "Plugin system prompt section %s (%s) " + detail, section.id, section.plugin, *args, ) try: value = section.content(frozen_info) if callable(section.content) else section.content except Exception as exc: _skip("raised and was skipped: %s", exc) return None if not isinstance(value, str): _skip("returned %s, not str; skipped", type(value).__name__) return None text = value.strip() if not text: return None if PLUGIN_SECTIONS_START in text or PLUGIN_SECTIONS_END in text: _skip("contained a reserved persistence marker and was skipped") return None if len(text) > section.max_chars: _skip("exceeded max_chars (%d > %d) and was skipped", len(text), section.max_chars) return None return text def has_middleware(self, kind: str) -> bool: """Return True when at least one callback is registered for middleware.""" return bool(self._middleware.get(kind)) def invoke_middleware(self, kind: str, **kwargs: Any) -> List[Any]: """Call middleware callbacks for *kind* (each isolated); return non-``None`` results.""" callbacks = self._middleware.get(kind, []) results: List[Any] = [] for cb in callbacks: try: ret = cb(**kwargs) if ret is not None: results.append(ret) except Exception as exc: logger.warning( "Middleware '%s' callback %s raised: %s", kind, getattr(cb, "__name__", repr(cb)), exc, ) return results # -- adapter accessors / introspection ----------------------------------------- def get_slack_action_handlers(self) -> List[tuple]: """``(action_id, callback, plugin_name)`` tuples for the Slack adapter to wire at connect.""" return list(self._slack_action_handlers) def get_platform_handler_factories(self, platform: str) -> List[tuple]: """``(factory, plugin_name)`` tuples for one platform; adapters call ``factory(native, adapter)`` at connect (see :meth:`PluginContext.register_platform_handler`).""" key = (platform or "").strip().lower() return list(self._platform_handler_factories.get(key, [])) def list_plugins(self) -> List[Dict[str, Any]]: """Return a list of info dicts for all discovered plugins.""" result: List[Dict[str, Any]] = [] for key, loaded in sorted(self._plugins.items()): result.append( { "name": loaded.manifest.name, "key": loaded.manifest.key or loaded.manifest.name, "kind": loaded.manifest.kind, "version": loaded.manifest.version, "description": loaded.manifest.description, "source": loaded.manifest.source, "enabled": loaded.enabled, "tools": len(loaded.tools_registered), "hooks": len(loaded.hooks_registered), "middleware": len(loaded.middleware_registered), "commands": len(loaded.commands_registered), "error": loaded.error, } ) return result def find_plugin_skill(self, qualified_name: str) -> Optional[Path]: """Return the ``Path`` to a plugin skill's SKILL.md, or ``None``.""" entry = self._plugin_skills.get(qualified_name) return entry["path"] if entry else None def list_plugin_skills(self, plugin_name: str) -> List[str]: """Return sorted bare names of all skills registered by *plugin_name*.""" prefix = f"{plugin_name}:" return sorted( e["bare_name"] for qn, e in self._plugin_skills.items() if qn.startswith(prefix) ) def list_plugin_skill_metadata(self) -> List[Dict[str, Any]]: """Return progressive-disclosure metadata for registered plugin skills.""" return [ { "name": qualified, "description": str(entry.get("description", "")), "category": "plugin", "frontmatter": dict(entry.get("frontmatter", {})), } for qualified, entry in sorted(self._plugin_skills.items()) ] def get_portable_mcp_servers(self) -> Dict[str, Dict[str, Any]]: """Return a defensive copy of enabled portable MCP server configs.""" return {name: dict(config) for name, config in self._portable_mcp_servers.items()} def remove_plugin_skill(self, qualified_name: str) -> None: """Remove a stale registry entry (silently ignores missing keys).""" self._plugin_skills.pop(qualified_name, None) # Module-level singleton & convenience functions. # Legacy single-slot "current" manager, kept so tests that monkeypatch ``_plugin_manager`` keep # working — ``get_plugin_manager()`` still reads/writes this name. _plugin_manager: Optional[PluginManager] = None # Resolved Hermes home -> PluginManager. A process can switch profiles via # ``set_hermes_home_override()``; a single slot would leak one profile's plugin/context-engine state # into another, and keying by resolved home lets a re-entered profile reuse its imported modules. _plugin_managers_by_home: Dict[Path, PluginManager] = {} _plugin_managers_lock = threading.RLock() def _plugin_home_key() -> Path: """Resolved active Hermes home — the key for per-profile plugin managers (plugins capture the home at registration, so a process serving several profiles cannot share one manager).""" try: return get_hermes_home().expanduser().resolve() except Exception: return get_hermes_home().expanduser() def _clear_plugin_submodules(manager: Optional[PluginManager]) -> None: """Purge ``sys.modules`` entries for this manager's directory plugins (package AND submodules — otherwise a same-slug plugin in another profile reuses the previous profile's submodule state). """ if manager is None: return for loaded in getattr(manager, "_plugins", {}).values(): module = getattr(loaded, "module", None) module_name = getattr(module, "__name__", None) if not module_name or not module_name.startswith(f"{_NS_PARENT}."): continue _evict_modules(module_name) with _MODULE_NAMESPACE_LOCK: if _BARE_MODULE_SCOPE.get(module_name) == manager.scope_key: _BARE_MODULE_SCOPE.pop(module_name, None) def get_plugin_manager() -> PluginManager: """Return the plugin manager for the active Hermes profile/home (cached per resolved home; a profile switch gets its own manager and plugin submodules).""" global _plugin_manager current_home = _plugin_home_key() with _plugin_managers_lock: # Tests/embedders monkeypatch ``_plugin_manager`` directly: adopt a single-slot manager the # keyed cache doesn't know about at all. if ( _plugin_manager is not None and _plugin_manager not in _plugin_managers_by_home.values() ): _plugin_managers_by_home[current_home] = _plugin_manager return _plugin_manager manager = _plugin_managers_by_home.get(current_home) if manager is None: manager = PluginManager(scope_key=hermes_home_key(current_home)) _plugin_managers_by_home[current_home] = manager _plugin_manager = manager return manager def _reset_plugin_managers_for_tests() -> None: """Test-only: drop every cached manager and its submodules for a fully clean slate.""" global _plugin_manager with _plugin_managers_lock: managers = list(dict.fromkeys(_plugin_managers_by_home.values())) if _plugin_manager is not None and _plugin_manager not in managers: managers.append(_plugin_manager) for manager in managers: _clear_plugin_submodules(manager) try: manager.unload() except Exception: logger.debug("test plugin-manager unload failed", exc_info=True) _plugin_managers_by_home.clear() _plugin_manager = None # Dashboard-auth providers are persistent and survive a routine unload, so the clean-slate # reset must clear that process-global registry explicitly or a test's provider leaks. try: from hermes_cli.dashboard_auth.registry import ( clear_providers as _clear_dashboard_auth_providers, ) _clear_dashboard_auth_providers() except Exception: logger.debug("dashboard-auth registry clear failed", exc_info=True) def has_enabled_agent_plugin_mcp(raw_config: Mapping[str, Any]) -> bool: """Whether config enables a portable package with MCP servers (manifest-only scan on a fresh manager; imports nothing, mutates no registry).""" return PluginManager().has_enabled_portable_mcp(raw_config) def discover_plugins(force: bool = False) -> None: """Discover and load all plugins (idempotent; ``force=True`` rescans). Joins an in-flight background discovery instead of racing a second scan.""" _join_background_discovery() get_plugin_manager().discover_and_load(force=force) _background_discovery_thread: Optional[threading.Thread] = None _background_discovery_lock = threading.Lock() def start_background_plugin_discovery() -> None: """Run discovery in a daemon thread to overlap the rest of CLI startup (~150ms). Every synchronous consumer joins it via :func:`discover_plugins`, so no one sees a half-loaded registry. No-op when already done or in flight.""" global _background_discovery_thread manager = get_plugin_manager() if manager._discovered: return with _background_discovery_lock: if _background_discovery_thread is not None and _background_discovery_thread.is_alive(): return def _run() -> None: try: manager.discover_and_load() _persist_plugin_toolset_keys() except Exception: logger.warning("background plugin discovery failed", exc_info=True) _background_discovery_thread = threading.Thread( target=_run, name="plugin-discovery", daemon=True ) _background_discovery_thread.start() def _join_background_discovery(timeout: float = 30.0) -> None: """Wait for an in-flight background discovery (no-op from its own thread).""" t = _background_discovery_thread if t is None or not t.is_alive() or t is threading.current_thread(): return t.join(timeout=timeout) def _plugin_toolset_keys_cache_path(): from hermes_constants import get_hermes_home return get_hermes_home() / "cache" / "plugin_toolset_keys.json" def _persist_plugin_toolset_keys() -> None: """Persist discovered plugin toolset keys + portable MCP names (best-effort).""" try: import tempfile keys = sorted({ts_key for ts_key, _, _ in get_plugin_toolsets()}) try: portable = sorted(get_plugin_manager().get_portable_mcp_servers()) except Exception: portable = [] path = _plugin_toolset_keys_cache_path() path.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".pt_keys.") with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump({"toolset_keys": keys, "portable_mcp": portable}, fh) os.replace(tmp, path) except Exception: logger.debug("plugin toolset key persist failed", exc_info=True) def _read_plugin_keys_cache() -> Optional[dict]: try: blob = json.loads(_plugin_toolset_keys_cache_path().read_text(encoding="utf-8")) if isinstance(blob, dict): return blob except Exception: pass return None def _nowait_plugin_set(cache_field: str, live: Callable[[PluginManager], "set[str]"]) -> "set[str]": """Shared body of the ``*_nowait`` probes: live registry, else last launch's cache, else block.""" manager = get_plugin_manager() t = _background_discovery_thread if manager._discovered and (t is None or not t.is_alive()): return live(manager) if t is not None and t.is_alive(): blob = _read_plugin_keys_cache() if blob is not None: values = blob.get(cache_field) if isinstance(values, list) and all(isinstance(v, str) for v in values): return set(values) discover_plugins() return live(manager) def get_plugin_toolset_keys_nowait() -> "set[str]": """Plugin toolset keys without blocking on in-flight discovery: live registry when done, last launch's persisted set while a background scan runs (callers only EXCLUDE these keys, so a stale set is harmless and self-heals), else block via discover_plugins().""" return _nowait_plugin_set( "toolset_keys", lambda _m: {ts_key for ts_key, _, _ in get_plugin_toolsets()} ) def get_portable_mcp_server_names_nowait() -> "set[str]": """Portable MCP server names; same contract as :func:`get_plugin_toolset_keys_nowait`.""" return _nowait_plugin_set("portable_mcp", lambda m: set(m.get_portable_mcp_servers())) def _delivery_manager() -> PluginManager: """Active manager, lazily discovering if it never ran — delivery must not depend on WHICH surface imported us (dashboards/TUI/cron never import model_tools). ``getattr`` default ``True`` leaves test doubles untouched.""" manager = get_plugin_manager() if not getattr(manager, "_discovered", True): _join_background_discovery() manager.discover_and_load() return manager def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]: """Invoke a lifecycle hook (lazy-discovers first); return non-``None`` callback results.""" return _delivery_manager().invoke_hook(hook_name, **kwargs) def render_system_prompt_sections( session_info: Mapping[str, Any], ) -> List[RenderedPluginSystemPromptSection]: """Render plugin prompt sections after idempotent plugin discovery.""" return _ensure_plugins_discovered().render_system_prompt_sections(session_info) def invoke_middleware(kind: str, **kwargs: Any) -> List[Any]: """Invoke registered middleware callbacks (lazy-discovers like :func:`invoke_hook`).""" return _delivery_manager().invoke_middleware(kind, **kwargs) def has_middleware(kind: str) -> bool: """True when middleware is registered for ``kind``; lazy-discovers first since callers gate :func:`invoke_middleware` on it.""" manager = get_plugin_manager() if not getattr(manager, "_discovered", True): manager = _delivery_manager() method = getattr(manager, "has_middleware", None) if callable(method): return bool(method(kind)) return bool(getattr(manager, "_middleware", {}).get(kind)) def has_hook(hook_name: str) -> bool: """True when a loaded plugin handles a hook (lazy-discovers first, like :func:`has_middleware`).""" return _delivery_manager().has_hook(hook_name) def iter_hook_callbacks(hook_name: str) -> tuple[Callable, ...]: """Return a stable snapshot of callbacks registered for a hook.""" return get_plugin_manager().iter_hook_callbacks(hook_name) def fire_pre_command_hook( *, surface: str, command: str, alias_used: str, args_raw: str, session_key: Optional[str] = None, platform: Optional[str] = None, ) -> None: """Fire the observer-only ``pre_command`` hook; never raises. Directive-shaped returns are logged at debug so future block/rewrite adopters are discoverable.""" try: manager = get_plugin_manager() if not manager.has_hook("pre_command"): return results = manager.invoke_hook( "pre_command", surface=surface, command=command, alias_used=alias_used, args_raw=args_raw, session_key=session_key, platform=platform, ) for result in results: if isinstance(result, dict) and ( "action" in result or "decision" in result ): logger.debug( "pre_command is observer-only in v1: ignoring directive " "%r for /%s (surface=%s). Block/rewrite will arrive with " "the command middleware variant (#64204/#64231).", result, command, surface, ) except Exception as exc: # pragma: no cover - defensive logger.debug("pre_command hook dispatch failed (non-fatal): %s", exc) _thread_tool_whitelist = threading.local() @dataclass(frozen=True) class _PreToolCallDirective: action: Optional[str] = None message: Optional[str] = None rule_key: Optional[str] = None modified_args: Optional[Dict[str, Any]] = None def set_thread_tool_whitelist( allowed: Optional[Set[str]], deny_msg_fmt: str = "Tool '{tool_name}' denied: not in this thread's tool whitelist", ) -> None: _thread_tool_whitelist.allowed = allowed _thread_tool_whitelist.fmt = deny_msg_fmt def clear_thread_tool_whitelist() -> None: _thread_tool_whitelist.allowed = None def _get_pre_tool_call_directive_details( tool_name: str, args: Optional[Dict[str, Any]], task_id: str = "", session_id: str = "", tool_call_id: str = "", turn_id: str = "", api_request_id: str = "", middleware_trace: Optional[List[Dict[str, Any]]] = None, ) -> _PreToolCallDirective: """Check ``pre_tool_call`` hooks for ``{"action": "block", "message"}`` (veto; message becomes the tool result) or ``{"action": "approve", "message", "rule_key"?}`` (escalate ANY tool to the human-approval gate; ``rule_key`` picks the ``[a]lways`` allowlist grain). First valid directive wins; irrelevant returns are ignored.""" allowed = getattr(_thread_tool_whitelist, "allowed", None) if allowed is not None and tool_name not in allowed: fmt = getattr(_thread_tool_whitelist, "fmt", "Tool '{tool_name}' denied") return _PreToolCallDirective(action="block", message=fmt.format(tool_name=tool_name)) from hermes_cli.lifecycle import invoke_hook as invoke_lifecycle_hook hook_results = invoke_lifecycle_hook( "pre_tool_call", tool_name=tool_name, args=args if isinstance(args, dict) else {}, task_id=task_id, session_id=session_id, tool_call_id=tool_call_id, turn_id=turn_id, api_request_id=api_request_id, middleware_trace=list(middleware_trace or []), ) modified_args: Optional[Dict[str, Any]] = None for result in hook_results: if not isinstance(result, dict): continue action = result.get("action") # "modify" — transform tool_input before dispatch. Processed before the block/approve gate # so modify directives are visible even when a later hook blocks. Each modify directive # shallow-merges its keys into one accumulated dict built from the original args. if action == "modify": partial = result.get("args") if isinstance(partial, dict) and partial: if modified_args is None: modified_args = dict(args) if isinstance(args, dict) else {} modified_args.update(partial) continue if action not in ("block", "approve"): continue message = result.get("message") message = message if isinstance(message, str) and message else None # A block directive requires a message (it becomes the tool result); approve's is optional. if action == "block" and not message: continue rule_key = result.get("rule_key") if action == "approve" else None rule_key = (rule_key.strip() or None) if isinstance(rule_key, str) else None return _PreToolCallDirective( action=action, message=message, rule_key=rule_key, modified_args=modified_args, ) return _PreToolCallDirective(modified_args=modified_args) def get_pre_tool_call_directive( tool_name: str, args: Optional[Dict[str, Any]], task_id: str = "", session_id: str = "", tool_call_id: str = "", turn_id: str = "", api_request_id: str = "", middleware_trace: Optional[List[Dict[str, Any]]] = None, ) -> tuple[Optional[str], Optional[str]]: """Back-compat: ``(directive, message)`` with directive ``"block"`` / ``"approve"`` / ``None``.""" details = _get_pre_tool_call_directive_details( tool_name, args, task_id=task_id, session_id=session_id, tool_call_id=tool_call_id, turn_id=turn_id, api_request_id=api_request_id, middleware_trace=middleware_trace, ) return (details.action, details.message) def get_pre_tool_call_block_message( tool_name: str, args: Optional[Dict[str, Any]], task_id: str = "", session_id: str = "", tool_call_id: str = "", turn_id: str = "", api_request_id: str = "", middleware_trace: Optional[List[Dict[str, Any]]] = None, ) -> Optional[str]: """Deprecated shim: only the ``block`` message (or ``None``); ``approve`` is invisible here.""" directive, message = get_pre_tool_call_directive( tool_name, args, task_id=task_id, session_id=session_id, tool_call_id=tool_call_id, turn_id=turn_id, api_request_id=api_request_id, middleware_trace=middleware_trace, ) return message if directive == "block" else None def resolve_pre_tool_block( tool_name: str, args: Optional[Dict[str, Any]], task_id: str = "", session_id: str = "", tool_call_id: str = "", turn_id: str = "", api_request_id: str = "", middleware_trace: Optional[List[Dict[str, Any]]] = None, ) -> Optional[str]: """Resolve the pre_tool_call directive to a final block message (or ``None`` to proceed), running the human-approval gate for ``approve``. See :func:`_resolve_block_from_details`.""" return _dispatch_pre_tool_call_hooks( tool_name, args, task_id=task_id, session_id=session_id, tool_call_id=tool_call_id, turn_id=turn_id, api_request_id=api_request_id, middleware_trace=middleware_trace, )[0] def _resolve_block_from_details( details: "_PreToolCallDirective", tool_name: str, *, turn_id: str = "", tool_call_id: str = "", session_id: str = "", ) -> Optional[str]: """The ONE place for the fail-closed approval logic: ``block`` blocks with its message; an ``approve`` whose gate errors, denies, or times out is blocked; anything else proceeds.""" if details.action == "block": return details.message if details.action != "approve": return None try: from tools.approval import ( request_tool_approval, reset_current_observability_context, set_current_observability_context, ) approval_tokens = None with suppress(Exception): approval_tokens = set_current_observability_context( turn_id=turn_id, tool_call_id=tool_call_id, session_id=session_id, ) try: result = request_tool_approval( tool_name, details.message or "", rule_key=details.rule_key or tool_name, ) finally: if approval_tokens is not None: with suppress(Exception): reset_current_observability_context(approval_tokens) except Exception: # Fail-closed: if the gate itself errors, block rather than silently execute an action a # plugin flagged for approval. return f"BLOCKED: plugin approval gate failed for {tool_name}" if not result.get("approved"): return str(result.get("message") or f"BLOCKED: plugin approval required for {tool_name}") return None def _dispatch_pre_tool_call_hooks( tool_name: str, args: Optional[Dict[str, Any]], task_id: str = "", session_id: str = "", tool_call_id: str = "", turn_id: str = "", api_request_id: str = "", middleware_trace: Optional[List[Dict[str, Any]]] = None, ) -> Tuple[Optional[str], Optional[Dict[str, Any]]]: """Invoke ``pre_tool_call`` hooks once; return ``(block_message, modified_args)`` — the resolved block/approve message (``None`` to proceed) and merged ``modify`` args (``None`` if none).""" details = _get_pre_tool_call_directive_details( tool_name, args, task_id=task_id, session_id=session_id, tool_call_id=tool_call_id, turn_id=turn_id, api_request_id=api_request_id, middleware_trace=middleware_trace, ) block_msg = _resolve_block_from_details( details, tool_name, turn_id=turn_id, tool_call_id=tool_call_id, session_id=session_id, ) return (block_msg, details.modified_args) def get_pre_verify_continue_message( *, session_id: str = "", platform: str = "", model: str = "", coding: bool = False, attempt: int = 0, final_response: str = "", changed_paths: Optional[List[str]] = None, ) -> Optional[str]: """Check ``pre_verify`` hooks for ``{"action": "continue", "message"}`` (or Claude-Code Stop ``{"decision": "block", "reason"}``) to keep the turn going; first non-empty message wins, any other return lets the turn finish. ``coding``/``attempt`` let hooks scope and self-throttle.""" hook_results = invoke_hook( "pre_verify", session_id=session_id, platform=platform, model=model, coding=coding, attempt=attempt, final_response=final_response, changed_paths=list(changed_paths or []), ) for result in hook_results: if not isinstance(result, dict): continue action = str(result.get("action") or result.get("decision") or "").strip().lower() if action not in ("continue", "block"): continue message = result.get("message") or result.get("reason") if isinstance(message, str) and message.strip(): return message.strip() return None def get_plugin_error_classification( *, provider: str = "", model: str = "", status_code: Optional[int] = None, error_type: str = "", error_code: str = "", error_message: str = "", error_body: Optional[Dict[str, Any]] = None, error: Optional[BaseException] = None, approx_tokens: int = 0, context_length: int = 0, num_messages: int = 0, ) -> Optional[Dict[str, Any]]: """Consult ``transform_api_error_classification`` hooks BEFORE the built-in classifier. Run-all-then-pick-first: every callback runs isolated, the first valid result in registration order wins, losing valid results warn (conflicts visible, not shadowed). Returns a sanitized dict (``reason`` -> ``FailoverReason``, hint flags -> bool, ``message`` capped at 500) or ``None``. Privacy: ``error_message``/``error_body`` may be unredacted. """ from agent.error_classifier import FailoverReason hook_results = invoke_hook( "transform_api_error_classification", provider=provider, model=model, status_code=status_code, error_type=error_type, error_code=error_code, error_message=error_message, error_body=error_body if isinstance(error_body, dict) else {}, error=error, approx_tokens=approx_tokens, context_length=context_length, num_messages=num_messages, ) winner: Optional[Dict[str, Any]] = None skipped_valid = 0 for result in hook_results: if not isinstance(result, dict): continue reason = result.get("reason") if isinstance(reason, str): try: reason = FailoverReason(reason.strip().lower()) except ValueError: continue if not isinstance(reason, FailoverReason): continue if winner is not None: skipped_valid += 1 continue out: Dict[str, Any] = {"reason": reason} for key in ( "retryable", "should_compress", "should_rotate_credential", "should_fallback", ): if key in result: out[key] = bool(result[key]) message = result.get("message") if isinstance(message, str) and message.strip(): out["message"] = message.strip()[:500] error_context = result.get("error_context") if isinstance(error_context, dict): out["error_context"] = error_context winner = out if winner is not None and skipped_valid: logger.warning( "transform_api_error_classification: skipped %d valid " "classification(s) after the first result in registration order " "won (run-all-then-pick-first)", skipped_valid, ) return winner def _ensure_plugins_discovered(force: bool = False) -> PluginManager: """Return the global manager after idempotent (or ``force``d) discovery.""" manager = get_plugin_manager() manager.discover_and_load(force=force) return manager def get_plugin_context_engine(): """Return the plugin-registered context engine, or None.""" return _ensure_plugins_discovered()._context_engine def get_plugin_command_handler(name: str) -> Optional[Callable]: """Return the handler for a plugin-registered slash command, or ``None``.""" entry = _ensure_plugins_discovered()._plugin_commands.get(name) return entry["handler"] if entry else None _PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS = 30.0 def resolve_plugin_command_result(result: Any) -> Any: """Resolve a plugin command result, awaiting async handlers: ``asyncio.run`` when no loop is running, else a helper thread with its own loop (30s bound so a hung handler cannot wedge the terminal).""" if not inspect.isawaitable(result): return result try: asyncio.get_running_loop() except RuntimeError: return asyncio.run(result) outcome: Dict[str, Any] = {} failure: Dict[str, BaseException] = {} done = threading.Event() def _runner() -> None: try: outcome["value"] = asyncio.run(result) except BaseException as exc: # pragma: no cover - re-raised below failure["exc"] = exc finally: done.set() thread = threading.Thread(target=_runner, name="hermes-plugin-command-await", daemon=True) thread.start() if not done.wait(timeout=_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS): raise TimeoutError( "Plugin command async handler did not complete within " f"{_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS:.0f}s" ) if "exc" in failure: raise failure["exc"] return outcome.get("value") def get_plugin_commands() -> Dict[str, dict]: """Plugin commands dict (name -> {handler, description, plugin}) after idempotent discovery.""" return _ensure_plugins_discovered()._plugin_commands def get_plugin_auxiliary_tasks() -> List[Dict[str, Any]]: """Plugin auxiliary-task registration dicts sorted by ``key`` (after idempotent discovery).""" manager = _ensure_plugins_discovered() return [manager._aux_tasks[k] for k in sorted(manager._aux_tasks)] def get_plugin_toolsets() -> List[tuple]: """Plugin toolsets as ``(key, label, description)`` tuples for the ``hermes tools`` TUI.""" manager = get_plugin_manager() if not manager._plugin_tool_names: return [] try: from tools.registry import registry except Exception: return [] # Group plugin tool names by their toolset toolset_tools: Dict[str, List[str]] = {} for tool_name in manager._plugin_tool_names: entry = registry.get_entry(tool_name) if entry: toolset_tools.setdefault(entry.toolset, []).append(entry.name) # Map toolsets back to the plugin that registered them toolset_plugin: Dict[str, LoadedPlugin] = {} for loaded in manager._plugins.values(): for tool_name in loaded.tools_registered: entry = registry.get_entry(tool_name) if entry and entry.toolset in toolset_tools: toolset_plugin.setdefault(entry.toolset, loaded) result = [] for ts_key in sorted(toolset_tools): plugin = toolset_plugin.get(ts_key) desc = (plugin.manifest.description if plugin else "") or ", ".join( sorted(toolset_tools[ts_key]) ) result.append((ts_key, f"🔌 {ts_key.replace('_', ' ').title()}", desc)) return result