/ hermes_cli / plugins.py
plugins.py
1 """ 2 Hermes Plugin System 3 ==================== 4 5 Discovers, loads, and manages plugins from four sources: 6 7 1. **Bundled plugins** – ``<repo>/plugins/<name>/`` (shipped with hermes-agent; 8 ``memory/`` and ``context_engine/`` subdirs are excluded — they have their 9 own discovery paths) 10 2. **User plugins** – ``~/.hermes/plugins/<name>/`` 11 3. **Project plugins** – ``./.hermes/plugins/<name>/`` (opt-in via 12 ``HERMES_ENABLE_PROJECT_PLUGINS``) 13 4. **Pip plugins** – packages that expose the ``hermes_agent.plugins`` 14 entry-point group. 15 16 Later sources override earlier ones on name collision, so a user or project 17 plugin with the same name as a bundled plugin replaces it. 18 19 Each directory plugin must contain a ``plugin.yaml`` manifest **and** an 20 ``__init__.py`` with a ``register(ctx)`` function. 21 22 Lifecycle hooks 23 --------------- 24 Plugins may register callbacks for any of the hooks in ``VALID_HOOKS``. 25 The agent core calls ``invoke_hook(name, **kwargs)`` at the appropriate 26 points. 27 28 Tool registration 29 ----------------- 30 ``PluginContext.register_tool()`` delegates to ``tools.registry.register()`` 31 so plugin-defined tools appear alongside the built-in tools. 32 """ 33 34 from __future__ import annotations 35 36 import asyncio 37 import importlib 38 import importlib.metadata 39 import importlib.util 40 import inspect 41 import logging 42 import os 43 import sys 44 import threading 45 import types 46 from dataclasses import dataclass, field 47 from pathlib import Path 48 from typing import Any, Callable, Dict, List, Optional, Set, Union 49 50 from hermes_constants import get_hermes_home 51 from utils import env_var_enabled 52 from hermes_cli.config import cfg_get 53 54 55 def get_bundled_plugins_dir() -> Path: 56 """Locate the bundled ``plugins/`` directory. 57 58 Honours ``HERMES_BUNDLED_PLUGINS`` (set by the Nix wrapper / packaged 59 installs) so read-only store paths are consulted first. Falls back to 60 the in-repo path used during development. 61 """ 62 env_override = os.getenv("HERMES_BUNDLED_PLUGINS") 63 if env_override: 64 return Path(env_override) 65 return Path(__file__).resolve().parent.parent / "plugins" 66 67 try: 68 import yaml 69 except ImportError: # pragma: no cover – yaml is optional at import time 70 yaml = None # type: ignore[assignment] 71 72 logger = logging.getLogger(__name__) 73 74 # --------------------------------------------------------------------------- 75 # Constants 76 # --------------------------------------------------------------------------- 77 78 VALID_HOOKS: Set[str] = { 79 "pre_tool_call", 80 "post_tool_call", 81 "transform_terminal_output", 82 "transform_tool_result", 83 "pre_llm_call", 84 "post_llm_call", 85 "pre_api_request", 86 "post_api_request", 87 "on_session_start", 88 "on_session_end", 89 "on_session_finalize", 90 "on_session_reset", 91 "subagent_stop", 92 # Gateway pre-dispatch hook. Fired once per incoming MessageEvent 93 # after the internal-event guard but BEFORE auth/pairing and agent 94 # dispatch. Plugins may return a dict to influence flow: 95 # {"action": "skip", "reason": "..."} -> drop message (no reply) 96 # {"action": "rewrite", "text": "..."} -> replace event.text, continue 97 # {"action": "allow"} / None -> normal dispatch 98 # Kwargs: event: MessageEvent, gateway: GatewayRunner, session_store. 99 "pre_gateway_dispatch", 100 # Approval lifecycle hooks. Fired by tools/approval.py when a dangerous 101 # command needs user approval -- fires BOTH for CLI-interactive prompts 102 # and for gateway/ACP approvals (Telegram, Discord, Slack, TUI, etc.). 103 # Observers only: return values are ignored. Plugins cannot veto or 104 # pre-answer an approval from these hooks (use pre_tool_call to block 105 # a tool before it reaches approval). 106 # 107 # Kwargs for pre_approval_request: 108 # command: str, description: str, pattern_key: str, pattern_keys: list[str], 109 # session_key: str, surface: "cli" | "gateway" 110 # Kwargs for post_approval_response: same as above plus 111 # choice: "once" | "session" | "always" | "deny" | "timeout" 112 "pre_approval_request", 113 "post_approval_response", 114 } 115 116 ENTRY_POINTS_GROUP = "hermes_agent.plugins" 117 118 _NS_PARENT = "hermes_plugins" 119 120 121 def _env_enabled(name: str) -> bool: 122 """Return True when an env var is set to a truthy opt-in value.""" 123 return env_var_enabled(name) 124 125 126 def _get_disabled_plugins() -> set: 127 """Read the disabled plugins list from config.yaml. 128 129 Kept for backward compat and explicit deny-list semantics. A plugin 130 name in this set will never load, even if it appears in 131 ``plugins.enabled``. 132 """ 133 try: 134 from hermes_cli.config import load_config 135 config = load_config() 136 disabled = cfg_get(config, "plugins", "disabled", default=[]) 137 return set(disabled) if isinstance(disabled, list) else set() 138 except Exception: 139 return set() 140 141 142 def _get_enabled_plugins() -> Optional[set]: 143 """Read the enabled-plugins allow-list from config.yaml. 144 145 Plugins are opt-in by default — only plugins whose name appears in 146 this set are loaded. Returns: 147 148 * ``None`` — the key is missing or malformed. Callers should treat 149 this as "nothing enabled yet" (the opt-in default); the first 150 ``migrate_config`` run populates the key with a grandfathered set 151 of currently-installed user plugins so existing setups don't 152 break on upgrade. 153 * ``set()`` — an empty list was explicitly set; nothing loads. 154 * ``set(...)`` — the concrete allow-list. 155 """ 156 try: 157 from hermes_cli.config import load_config 158 config = load_config() 159 plugins_cfg = config.get("plugins") 160 if not isinstance(plugins_cfg, dict): 161 return None 162 if "enabled" not in plugins_cfg: 163 return None 164 enabled = plugins_cfg.get("enabled") 165 if not isinstance(enabled, list): 166 return None 167 return set(enabled) 168 except Exception: 169 return None 170 171 172 # --------------------------------------------------------------------------- 173 # Data classes 174 # --------------------------------------------------------------------------- 175 176 _VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform"} 177 178 179 @dataclass 180 class PluginManifest: 181 """Parsed representation of a plugin.yaml manifest.""" 182 183 name: str 184 version: str = "" 185 description: str = "" 186 author: str = "" 187 requires_env: List[Union[str, Dict[str, Any]]] = field(default_factory=list) 188 provides_tools: List[str] = field(default_factory=list) 189 provides_hooks: List[str] = field(default_factory=list) 190 source: str = "" # "user", "project", or "entrypoint" 191 path: Optional[str] = None 192 # Plugin kind — see plugins.py module docstring for semantics. 193 # ``standalone`` (default): hooks/tools of its own; opt-in via 194 # ``plugins.enabled``. 195 # ``backend``: pluggable backend for an existing core tool (e.g. 196 # image_gen). Built-in (bundled) backends auto-load; 197 # user-installed still gated by ``plugins.enabled``. 198 # ``exclusive``: category with exactly one active provider (memory). 199 # Selection via ``<category>.provider`` config key; the 200 # category's own discovery system handles loading and the 201 # general scanner skips these. 202 # ``platform``: gateway messaging platform adapter (e.g. IRC). Bundled 203 # platform plugins auto-load so every shipped platform is 204 # available out of the box; user-installed platform plugins 205 # in ~/.hermes/plugins/ still gated by ``plugins.enabled`` 206 # (untrusted code). 207 kind: str = "standalone" 208 # Registry key — path-derived, used by ``plugins.enabled``/``disabled`` 209 # lookups and by ``hermes plugins list``. For a flat plugin at 210 # ``plugins/disk-cleanup/`` the key is ``disk-cleanup``; for a nested 211 # category plugin at ``plugins/image_gen/openai/`` the key is 212 # ``image_gen/openai``. When empty, falls back to ``name``. 213 key: str = "" 214 215 216 @dataclass 217 class LoadedPlugin: 218 """Runtime state for a single loaded plugin.""" 219 220 manifest: PluginManifest 221 module: Optional[types.ModuleType] = None 222 tools_registered: List[str] = field(default_factory=list) 223 hooks_registered: List[str] = field(default_factory=list) 224 commands_registered: List[str] = field(default_factory=list) 225 enabled: bool = False 226 error: Optional[str] = None 227 228 229 # --------------------------------------------------------------------------- 230 # PluginContext – handed to each plugin's ``register()`` function 231 # --------------------------------------------------------------------------- 232 233 class PluginContext: 234 """Facade given to plugins so they can register tools and hooks.""" 235 236 def __init__(self, manifest: PluginManifest, manager: "PluginManager"): 237 self.manifest = manifest 238 self._manager = manager 239 240 # -- tool registration -------------------------------------------------- 241 242 def register_tool( 243 self, 244 name: str, 245 toolset: str, 246 schema: dict, 247 handler: Callable, 248 check_fn: Callable | None = None, 249 requires_env: list | None = None, 250 is_async: bool = False, 251 description: str = "", 252 emoji: str = "", 253 ) -> None: 254 """Register a tool in the global registry **and** track it as plugin-provided.""" 255 from tools.registry import registry 256 257 registry.register( 258 name=name, 259 toolset=toolset, 260 schema=schema, 261 handler=handler, 262 check_fn=check_fn, 263 requires_env=requires_env, 264 is_async=is_async, 265 description=description, 266 emoji=emoji, 267 ) 268 self._manager._plugin_tool_names.add(name) 269 logger.debug("Plugin %s registered tool: %s", self.manifest.name, name) 270 271 # -- message injection -------------------------------------------------- 272 273 def inject_message(self, content: str, role: str = "user") -> bool: 274 """Inject a message into the active conversation. 275 276 If the agent is idle (waiting for user input), this starts a new turn. 277 If the agent is running, this interrupts and injects the message. 278 279 This enables plugins (e.g. remote control viewers, messaging bridges) 280 to send messages into the conversation from external sources. 281 282 Returns True if the message was queued successfully. 283 """ 284 cli = self._manager._cli_ref 285 if cli is None: 286 logger.warning("inject_message: no CLI reference (not available in gateway mode)") 287 return False 288 289 msg = content if role == "user" else f"[{role}] {content}" 290 291 if getattr(cli, "_agent_running", False): 292 # Agent is mid-turn — interrupt with the message 293 cli._interrupt_queue.put(msg) 294 else: 295 # Agent is idle — queue as next input 296 cli._pending_input.put(msg) 297 return True 298 299 # -- CLI command registration -------------------------------------------- 300 301 def register_cli_command( 302 self, 303 name: str, 304 help: str, 305 setup_fn: Callable, 306 handler_fn: Callable | None = None, 307 description: str = "", 308 ) -> None: 309 """Register a CLI subcommand (e.g. ``hermes honcho ...``). 310 311 The *setup_fn* receives an argparse subparser and should add any 312 arguments/sub-subparsers. If *handler_fn* is provided it is set 313 as the default dispatch function via ``set_defaults(func=...)``.""" 314 self._manager._cli_commands[name] = { 315 "name": name, 316 "help": help, 317 "description": description, 318 "setup_fn": setup_fn, 319 "handler_fn": handler_fn, 320 "plugin": self.manifest.name, 321 } 322 logger.debug("Plugin %s registered CLI command: %s", self.manifest.name, name) 323 324 # -- slash command registration ------------------------------------------- 325 326 def register_command( 327 self, 328 name: str, 329 handler: Callable, 330 description: str = "", 331 args_hint: str = "", 332 ) -> None: 333 """Register a slash command (e.g. ``/lcm``) available in CLI and gateway sessions. 334 335 The handler signature is ``fn(raw_args: str) -> str | None``. 336 It may also be an async callable — the gateway dispatch handles both. 337 338 Unlike ``register_cli_command()`` (which creates ``hermes <subcommand>`` 339 terminal commands), this registers in-session slash commands that users 340 invoke during a conversation. 341 342 ``args_hint`` is an optional short string (e.g. ``"<file>"`` or 343 ``"dias:7 formato:json"``) used by gateway adapters to surface the 344 command with an argument field — for example Discord's native slash 345 command picker. Plugin commands without ``args_hint`` register as 346 parameterless in Discord and still accept trailing text when invoked 347 as free-form chat. 348 349 Names conflicting with built-in commands are rejected with a warning. 350 """ 351 clean = name.lower().strip().lstrip("/").replace(" ", "-") 352 if not clean: 353 logger.warning( 354 "Plugin '%s' tried to register a command with an empty name.", 355 self.manifest.name, 356 ) 357 return 358 359 # Reject if it conflicts with a built-in command 360 try: 361 from hermes_cli.commands import resolve_command 362 if resolve_command(clean) is not None: 363 logger.warning( 364 "Plugin '%s' tried to register command '/%s' which conflicts " 365 "with a built-in command. Skipping.", 366 self.manifest.name, clean, 367 ) 368 return 369 except Exception: 370 pass # If commands module isn't available, skip the check 371 372 self._manager._plugin_commands[clean] = { 373 "handler": handler, 374 "description": description or "Plugin command", 375 "plugin": self.manifest.name, 376 "args_hint": (args_hint or "").strip(), 377 } 378 logger.debug("Plugin %s registered command: /%s", self.manifest.name, clean) 379 380 # -- tool dispatch ------------------------------------------------------- 381 382 def dispatch_tool(self, tool_name: str, args: dict, **kwargs) -> str: 383 """Dispatch a tool call through the registry, with parent agent context. 384 385 This is the public interface for plugin slash commands that need to call 386 tools like ``delegate_task`` without reaching into framework internals. 387 The parent agent (if available) is resolved automatically — plugins never 388 need to access the agent directly. 389 390 Args: 391 tool_name: Registry name of the tool (e.g. ``"delegate_task"``). 392 args: Tool arguments dict (same as what the model would pass). 393 **kwargs: Extra keyword args forwarded to the registry dispatch. 394 395 Returns: 396 JSON string from the tool handler (same format as model tool calls). 397 """ 398 from tools.registry import registry 399 400 # Wire up parent agent context when available (CLI mode). 401 # In gateway mode _cli_ref is None — tools degrade gracefully 402 # (workspace hints fall back to TERMINAL_CWD, no spinner). 403 if "parent_agent" not in kwargs: 404 cli = self._manager._cli_ref 405 agent = getattr(cli, "agent", None) if cli else None 406 if agent is not None: 407 kwargs["parent_agent"] = agent 408 409 return registry.dispatch(tool_name, args, **kwargs) 410 411 # -- context engine registration ----------------------------------------- 412 413 def register_context_engine(self, engine) -> None: 414 """Register a context engine to replace the built-in ContextCompressor. 415 416 Only one context engine plugin is allowed. If a second plugin tries 417 to register one, it is rejected with a warning. 418 419 The engine must be an instance of ``agent.context_engine.ContextEngine``. 420 """ 421 if self._manager._context_engine is not None: 422 logger.warning( 423 "Plugin '%s' tried to register a context engine, but one is " 424 "already registered. Only one context engine plugin is allowed.", 425 self.manifest.name, 426 ) 427 return 428 # Defer the import to avoid circular deps at module level 429 from agent.context_engine import ContextEngine 430 if not isinstance(engine, ContextEngine): 431 logger.warning( 432 "Plugin '%s' tried to register a context engine that does not " 433 "inherit from ContextEngine. Ignoring.", 434 self.manifest.name, 435 ) 436 return 437 self._manager._context_engine = engine 438 logger.info( 439 "Plugin '%s' registered context engine: %s", 440 self.manifest.name, engine.name, 441 ) 442 443 # -- image gen provider registration ------------------------------------ 444 445 def register_image_gen_provider(self, provider) -> None: 446 """Register an image generation backend. 447 448 ``provider`` must be an instance of 449 :class:`agent.image_gen_provider.ImageGenProvider`. The 450 ``provider.name`` attribute is what ``image_gen.provider`` in 451 ``config.yaml`` matches against when routing ``image_generate`` 452 tool calls. 453 """ 454 from agent.image_gen_provider import ImageGenProvider 455 from agent.image_gen_registry import register_provider 456 457 if not isinstance(provider, ImageGenProvider): 458 logger.warning( 459 "Plugin '%s' tried to register an image_gen provider that does " 460 "not inherit from ImageGenProvider. Ignoring.", 461 self.manifest.name, 462 ) 463 return 464 register_provider(provider) 465 logger.info( 466 "Plugin '%s' registered image_gen provider: %s", 467 self.manifest.name, provider.name, 468 ) 469 470 # -- platform adapter registration --------------------------------------- 471 472 def register_platform( 473 self, 474 name: str, 475 label: str, 476 adapter_factory: Callable, 477 check_fn: Callable, 478 validate_config: Callable | None = None, 479 required_env: list | None = None, 480 install_hint: str = "", 481 **entry_kwargs: Any, 482 ) -> None: 483 """Register a gateway platform adapter. 484 485 The adapter_factory receives a ``PlatformConfig`` and returns a 486 ``BasePlatformAdapter`` subclass instance. The gateway calls 487 ``check_fn()`` before instantiation to verify dependencies. 488 489 Extra keyword arguments are forwarded to ``PlatformEntry`` (e.g. 490 ``setup_fn``, ``emoji``, ``allowed_users_env``, ``platform_hint``). 491 Unknown keys raise TypeError from the dataclass constructor. 492 493 Example:: 494 495 ctx.register_platform( 496 name="irc", 497 label="IRC", 498 adapter_factory=lambda cfg: IRCAdapter(cfg), 499 check_fn=lambda: True, 500 emoji="💬", 501 setup_fn=irc_interactive_setup, 502 ) 503 """ 504 from gateway.platform_registry import platform_registry, PlatformEntry 505 506 entry_kwargs.setdefault("plugin_name", self.manifest.name) 507 entry = PlatformEntry( 508 name=name, 509 label=label, 510 adapter_factory=adapter_factory, 511 check_fn=check_fn, 512 validate_config=validate_config, 513 required_env=required_env or [], 514 install_hint=install_hint, 515 source="plugin", 516 **entry_kwargs, 517 ) 518 platform_registry.register(entry) 519 self._manager._plugin_platform_names.add(name) 520 logger.debug( 521 "Plugin %s registered platform: %s", 522 self.manifest.name, 523 name, 524 ) 525 526 # -- hook registration -------------------------------------------------- 527 528 def register_hook(self, hook_name: str, callback: Callable) -> None: 529 """Register a lifecycle hook callback. 530 531 Unknown hook names produce a warning but are still stored so 532 forward-compatible plugins don't break. 533 """ 534 if hook_name not in VALID_HOOKS: 535 logger.warning( 536 "Plugin '%s' registered unknown hook '%s' " 537 "(valid: %s)", 538 self.manifest.name, 539 hook_name, 540 ", ".join(sorted(VALID_HOOKS)), 541 ) 542 self._manager._hooks.setdefault(hook_name, []).append(callback) 543 logger.debug("Plugin %s registered hook: %s", self.manifest.name, hook_name) 544 545 # -- skill registration ------------------------------------------------- 546 547 def register_skill( 548 self, 549 name: str, 550 path: Path, 551 description: str = "", 552 ) -> None: 553 """Register a read-only skill provided by this plugin. 554 555 The skill becomes resolvable as ``'<plugin_name>:<name>'`` via 556 ``skill_view()``. It does **not** enter the flat 557 ``~/.hermes/skills/`` tree and is **not** listed in the system 558 prompt's ``<available_skills>`` index — plugin skills are 559 opt-in explicit loads only. 560 561 Raises: 562 ValueError: if *name* contains ``':'`` or invalid characters. 563 FileNotFoundError: if *path* does not exist. 564 """ 565 from agent.skill_utils import _NAMESPACE_RE 566 567 if ":" in name: 568 raise ValueError( 569 f"Skill name '{name}' must not contain ':' " 570 f"(the namespace is derived from the plugin name " 571 f"'{self.manifest.name}' automatically)." 572 ) 573 if not name or not _NAMESPACE_RE.match(name): 574 raise ValueError( 575 f"Invalid skill name '{name}'. Must match [a-zA-Z0-9_-]+." 576 ) 577 if not path.exists(): 578 raise FileNotFoundError(f"SKILL.md not found at {path}") 579 580 qualified = f"{self.manifest.name}:{name}" 581 self._manager._plugin_skills[qualified] = { 582 "path": path, 583 "plugin": self.manifest.name, 584 "bare_name": name, 585 "description": description, 586 } 587 logger.debug( 588 "Plugin %s registered skill: %s", 589 self.manifest.name, qualified, 590 ) 591 592 593 # --------------------------------------------------------------------------- 594 # PluginManager 595 # --------------------------------------------------------------------------- 596 597 class PluginManager: 598 """Central manager that discovers, loads, and invokes plugins.""" 599 600 def __init__(self) -> None: 601 self._plugins: Dict[str, LoadedPlugin] = {} 602 self._hooks: Dict[str, List[Callable]] = {} 603 self._plugin_tool_names: Set[str] = set() 604 self._plugin_platform_names: Set[str] = set() 605 self._cli_commands: Dict[str, dict] = {} 606 self._context_engine = None # Set by a plugin via register_context_engine() 607 self._plugin_commands: Dict[str, dict] = {} # Slash commands registered by plugins 608 self._discovered: bool = False 609 self._cli_ref = None # Set by CLI after plugin discovery 610 # Plugin skill registry: qualified name → metadata dict. 611 self._plugin_skills: Dict[str, Dict[str, Any]] = {} 612 613 # ----------------------------------------------------------------------- 614 # Public 615 # ----------------------------------------------------------------------- 616 617 def discover_and_load(self, force: bool = False) -> None: 618 """Scan all plugin sources and load each plugin found. 619 620 When ``force`` is true, clear cached discovery state first so config 621 changes or newly-added bundled backends become visible in long-lived 622 sessions without requiring a full agent restart. 623 """ 624 if self._discovered and not force: 625 return 626 if force: 627 self._plugins.clear() 628 self._hooks.clear() 629 self._plugin_tool_names.clear() 630 self._cli_commands.clear() 631 self._plugin_commands.clear() 632 self._plugin_skills.clear() 633 self._context_engine = None 634 self._discovered = True 635 636 manifests: List[PluginManifest] = [] 637 638 # 1. Bundled plugins (<repo>/plugins/<name>/) 639 # 640 # Repo-shipped plugins live next to hermes_cli/. Two layouts are 641 # supported (see ``_scan_directory`` for details): 642 # 643 # - flat: ``plugins/disk-cleanup/plugin.yaml`` (standalone) 644 # - category: ``plugins/image_gen/openai/plugin.yaml`` (backend) 645 # 646 # ``memory/`` and ``context_engine/`` are skipped at the top level — 647 # they have their own discovery systems. ``platforms/`` is a category 648 # holding platform adapters (scanned one level deeper below). 649 repo_plugins = get_bundled_plugins_dir() 650 manifests.extend( 651 self._scan_directory( 652 repo_plugins, 653 source="bundled", 654 skip_names={"memory", "context_engine", "platforms"}, 655 ) 656 ) 657 manifests.extend( 658 self._scan_directory(repo_plugins / "platforms", source="bundled") 659 ) 660 661 # 2. User plugins (~/.hermes/plugins/) 662 user_dir = get_hermes_home() / "plugins" 663 manifests.extend(self._scan_directory(user_dir, source="user")) 664 665 # 3. Project plugins (./.hermes/plugins/) 666 if _env_enabled("HERMES_ENABLE_PROJECT_PLUGINS"): 667 project_dir = Path.cwd() / ".hermes" / "plugins" 668 manifests.extend(self._scan_directory(project_dir, source="project")) 669 670 # 4. Pip / entry-point plugins 671 manifests.extend(self._scan_entry_points()) 672 673 # Load each manifest (skip user-disabled plugins). 674 # Later sources override earlier ones on key collision — user 675 # plugins take precedence over bundled, project plugins take 676 # precedence over user. Dedup here so we only load the final 677 # winner. Keys are path-derived (``image_gen/openai``, 678 # ``disk-cleanup``) so ``tts/openai`` and ``image_gen/openai`` 679 # don't collide even when both manifests say ``name: openai``. 680 disabled = _get_disabled_plugins() 681 enabled = _get_enabled_plugins() # None = opt-in default (nothing enabled) 682 winners: Dict[str, PluginManifest] = {} 683 for manifest in manifests: 684 winners[manifest.key or manifest.name] = manifest 685 for manifest in winners.values(): 686 lookup_key = manifest.key or manifest.name 687 688 # Explicit disable always wins (matches on key or on legacy 689 # bare name for back-compat with existing user configs). 690 if lookup_key in disabled or manifest.name in disabled: 691 loaded = LoadedPlugin(manifest=manifest, enabled=False) 692 loaded.error = "disabled via config" 693 self._plugins[lookup_key] = loaded 694 logger.debug("Skipping disabled plugin '%s'", lookup_key) 695 continue 696 697 # Exclusive plugins (memory providers) have their own 698 # discovery/activation path. The general loader records the 699 # manifest for introspection but does not load the module. 700 if manifest.kind == "exclusive": 701 loaded = LoadedPlugin(manifest=manifest, enabled=False) 702 loaded.error = ( 703 "exclusive plugin — activate via <category>.provider config" 704 ) 705 self._plugins[lookup_key] = loaded 706 logger.debug( 707 "Skipping '%s' (exclusive, handled by category discovery)", 708 lookup_key, 709 ) 710 continue 711 712 # Built-in backends auto-load — they ship with hermes and must 713 # just work. Selection among them (e.g. which image_gen backend 714 # services calls) is driven by ``<category>.provider`` config, 715 # enforced by the tool wrapper. 716 # 717 # Bundled platform plugins (gateway adapters like IRC) auto-load 718 # for the same reason: every platform Hermes ships must be 719 # available out of the box without the user having to opt in. 720 if manifest.source == "bundled" and manifest.kind in ("backend", "platform"): 721 self._load_plugin(manifest) 722 continue 723 724 # Everything else (standalone, user-installed backends, 725 # entry-point plugins) is opt-in via plugins.enabled. 726 # Accept both the path-derived key and the legacy bare name 727 # so existing configs keep working. 728 is_enabled = ( 729 enabled is not None 730 and (lookup_key in enabled or manifest.name in enabled) 731 ) 732 if not is_enabled: 733 loaded = LoadedPlugin(manifest=manifest, enabled=False) 734 loaded.error = ( 735 "not enabled in config (run `hermes plugins enable {}` to activate)" 736 .format(lookup_key) 737 ) 738 self._plugins[lookup_key] = loaded 739 logger.debug( 740 "Skipping '%s' (not in plugins.enabled)", lookup_key 741 ) 742 continue 743 self._load_plugin(manifest) 744 745 if manifests: 746 logger.info( 747 "Plugin discovery complete: %d found, %d enabled", 748 len(self._plugins), 749 sum(1 for p in self._plugins.values() if p.enabled), 750 ) 751 752 # ----------------------------------------------------------------------- 753 # Directory scanning 754 # ----------------------------------------------------------------------- 755 756 def _scan_directory( 757 self, 758 path: Path, 759 source: str, 760 skip_names: Optional[Set[str]] = None, 761 ) -> List[PluginManifest]: 762 """Read ``plugin.yaml`` manifests from subdirectories of *path*. 763 764 Supports two layouts, mixed freely: 765 766 * **Flat** — ``<root>/<plugin-name>/plugin.yaml``. Key is 767 ``<plugin-name>`` (e.g. ``disk-cleanup``). 768 * **Category** — ``<root>/<category>/<plugin-name>/plugin.yaml``, 769 where the ``<category>`` directory itself has no ``plugin.yaml``. 770 Key is ``<category>/<plugin-name>`` (e.g. ``image_gen/openai``). 771 Depth is capped at two segments. 772 773 *skip_names* is an optional allow-list of names to ignore at the 774 top level (kept for back-compat; the current call sites no longer 775 pass it now that categories are first-class). 776 """ 777 return self._scan_directory_level( 778 path, source, skip_names=skip_names, prefix="", depth=0 779 ) 780 781 def _scan_directory_level( 782 self, 783 path: Path, 784 source: str, 785 *, 786 skip_names: Optional[Set[str]], 787 prefix: str, 788 depth: int, 789 ) -> List[PluginManifest]: 790 """Recursive implementation of :meth:`_scan_directory`. 791 792 ``prefix`` is the category path already accumulated ("" at root, 793 "image_gen" one level in). ``depth`` is the recursion depth; we 794 cap at 2 so ``<root>/a/b/c/`` is ignored. 795 """ 796 manifests: List[PluginManifest] = [] 797 if not path.is_dir(): 798 return manifests 799 800 for child in sorted(path.iterdir()): 801 if not child.is_dir(): 802 continue 803 if depth == 0 and skip_names and child.name in skip_names: 804 continue 805 manifest_file = child / "plugin.yaml" 806 if not manifest_file.exists(): 807 manifest_file = child / "plugin.yml" 808 809 if manifest_file.exists(): 810 manifest = self._parse_manifest( 811 manifest_file, child, source, prefix 812 ) 813 if manifest is not None: 814 manifests.append(manifest) 815 continue 816 817 # No manifest at this level. If we're still within the depth 818 # cap, treat this directory as a category namespace and recurse 819 # one level in looking for children with manifests. 820 if depth >= 1: 821 logger.debug("Skipping %s (no plugin.yaml, depth cap reached)", child) 822 continue 823 824 sub_prefix = f"{prefix}/{child.name}" if prefix else child.name 825 manifests.extend( 826 self._scan_directory_level( 827 child, 828 source, 829 skip_names=None, 830 prefix=sub_prefix, 831 depth=depth + 1, 832 ) 833 ) 834 835 return manifests 836 837 def _parse_manifest( 838 self, 839 manifest_file: Path, 840 plugin_dir: Path, 841 source: str, 842 prefix: str, 843 ) -> Optional[PluginManifest]: 844 """Parse a single ``plugin.yaml`` into a :class:`PluginManifest`. 845 846 Returns ``None`` on parse failure (logs a warning). 847 """ 848 try: 849 if yaml is None: 850 logger.warning("PyYAML not installed – cannot load %s", manifest_file) 851 return None 852 data = yaml.safe_load(manifest_file.read_text()) or {} 853 854 name = data.get("name", plugin_dir.name) 855 key = f"{prefix}/{plugin_dir.name}" if prefix else name 856 857 raw_kind = data.get("kind", "standalone") 858 if not isinstance(raw_kind, str): 859 raw_kind = "standalone" 860 kind = raw_kind.strip().lower() 861 if kind not in _VALID_PLUGIN_KINDS: 862 logger.warning( 863 "Plugin %s: unknown kind '%s' (valid: %s); treating as 'standalone'", 864 key, raw_kind, ", ".join(sorted(_VALID_PLUGIN_KINDS)), 865 ) 866 kind = "standalone" 867 868 # Auto-coerce user-installed memory providers to kind="exclusive" 869 # so they're routed to plugins/memory discovery instead of being 870 # loaded by the general PluginManager (which has no 871 # register_memory_provider on PluginContext). Mirrors the 872 # heuristic in plugins/memory/__init__.py:_is_memory_provider_dir. 873 # Bundled memory providers are already skipped via skip_names. 874 if kind == "standalone" and "kind" not in data: 875 init_file = plugin_dir / "__init__.py" 876 if init_file.exists(): 877 try: 878 source_text = init_file.read_text(errors="replace")[:8192] 879 if ( 880 "register_memory_provider" in source_text 881 or "MemoryProvider" in source_text 882 ): 883 kind = "exclusive" 884 logger.debug( 885 "Plugin %s: detected memory provider, " 886 "treating as kind='exclusive'", 887 key, 888 ) 889 except Exception: 890 pass 891 892 return PluginManifest( 893 name=name, 894 version=str(data.get("version", "")), 895 description=data.get("description", ""), 896 author=data.get("author", ""), 897 requires_env=data.get("requires_env", []), 898 provides_tools=data.get("provides_tools", []), 899 provides_hooks=data.get("provides_hooks", []), 900 source=source, 901 path=str(plugin_dir), 902 kind=kind, 903 key=key, 904 ) 905 except Exception as exc: 906 logger.warning("Failed to parse %s: %s", manifest_file, exc) 907 return None 908 909 # ----------------------------------------------------------------------- 910 # Entry-point scanning 911 # ----------------------------------------------------------------------- 912 913 def _scan_entry_points(self) -> List[PluginManifest]: 914 """Check ``importlib.metadata`` for pip-installed plugins.""" 915 manifests: List[PluginManifest] = [] 916 try: 917 eps = importlib.metadata.entry_points() 918 # Python 3.12+ returns a SelectableGroups; earlier returns dict 919 if hasattr(eps, "select"): 920 group_eps = eps.select(group=ENTRY_POINTS_GROUP) 921 elif isinstance(eps, dict): 922 group_eps = eps.get(ENTRY_POINTS_GROUP, []) 923 else: 924 group_eps = [ep for ep in eps if ep.group == ENTRY_POINTS_GROUP] 925 926 for ep in group_eps: 927 manifest = PluginManifest( 928 name=ep.name, 929 source="entrypoint", 930 path=ep.value, 931 key=ep.name, 932 ) 933 manifests.append(manifest) 934 except Exception as exc: 935 logger.debug("Entry-point scan failed: %s", exc) 936 937 return manifests 938 939 # ----------------------------------------------------------------------- 940 # Loading 941 # ----------------------------------------------------------------------- 942 943 def _load_plugin(self, manifest: PluginManifest) -> None: 944 """Import a plugin module and call its ``register(ctx)`` function.""" 945 loaded = LoadedPlugin(manifest=manifest) 946 947 try: 948 if manifest.source in ("user", "project", "bundled"): 949 module = self._load_directory_module(manifest) 950 else: 951 module = self._load_entrypoint_module(manifest) 952 953 loaded.module = module 954 955 # Call register() 956 register_fn = getattr(module, "register", None) 957 if register_fn is None: 958 loaded.error = "no register() function" 959 logger.warning("Plugin '%s' has no register() function", manifest.name) 960 else: 961 ctx = PluginContext(manifest, self) 962 register_fn(ctx) 963 loaded.tools_registered = [ 964 t for t in self._plugin_tool_names 965 if t not in { 966 n 967 for name, p in self._plugins.items() 968 for n in p.tools_registered 969 } 970 ] 971 loaded.hooks_registered = list( 972 { 973 h 974 for h, cbs in self._hooks.items() 975 if cbs # non-empty 976 } 977 - { 978 h 979 for name, p in self._plugins.items() 980 for h in p.hooks_registered 981 } 982 ) 983 loaded.commands_registered = [ 984 c for c in self._plugin_commands 985 if self._plugin_commands[c].get("plugin") == manifest.name 986 ] 987 loaded.enabled = True 988 989 except Exception as exc: 990 loaded.error = str(exc) 991 logger.warning("Failed to load plugin '%s': %s", manifest.name, exc) 992 993 self._plugins[manifest.key or manifest.name] = loaded 994 995 def _load_directory_module(self, manifest: PluginManifest) -> types.ModuleType: 996 """Import a directory-based plugin as ``hermes_plugins.<slug>``. 997 998 The module slug is derived from ``manifest.key`` so category-namespaced 999 plugins (``image_gen/openai``) import as 1000 ``hermes_plugins.image_gen__openai`` without colliding with any 1001 future ``tts/openai``. 1002 """ 1003 plugin_dir = Path(manifest.path) # type: ignore[arg-type] 1004 init_file = plugin_dir / "__init__.py" 1005 if not init_file.exists(): 1006 raise FileNotFoundError(f"No __init__.py in {plugin_dir}") 1007 1008 # Ensure the namespace parent package exists 1009 if _NS_PARENT not in sys.modules: 1010 ns_pkg = types.ModuleType(_NS_PARENT) 1011 ns_pkg.__path__ = [] # type: ignore[attr-defined] 1012 ns_pkg.__package__ = _NS_PARENT 1013 sys.modules[_NS_PARENT] = ns_pkg 1014 1015 key = manifest.key or manifest.name 1016 slug = key.replace("/", "__").replace("-", "_") 1017 module_name = f"{_NS_PARENT}.{slug}" 1018 spec = importlib.util.spec_from_file_location( 1019 module_name, 1020 init_file, 1021 submodule_search_locations=[str(plugin_dir)], 1022 ) 1023 if spec is None or spec.loader is None: 1024 raise ImportError(f"Cannot create module spec for {init_file}") 1025 1026 module = importlib.util.module_from_spec(spec) 1027 module.__package__ = module_name 1028 module.__path__ = [str(plugin_dir)] # type: ignore[attr-defined] 1029 sys.modules[module_name] = module 1030 spec.loader.exec_module(module) 1031 return module 1032 1033 def _load_entrypoint_module(self, manifest: PluginManifest) -> types.ModuleType: 1034 """Load a pip-installed plugin via its entry-point reference.""" 1035 eps = importlib.metadata.entry_points() 1036 if hasattr(eps, "select"): 1037 group_eps = eps.select(group=ENTRY_POINTS_GROUP) 1038 elif isinstance(eps, dict): 1039 group_eps = eps.get(ENTRY_POINTS_GROUP, []) 1040 else: 1041 group_eps = [ep for ep in eps if ep.group == ENTRY_POINTS_GROUP] 1042 1043 for ep in group_eps: 1044 if ep.name == manifest.name: 1045 return ep.load() 1046 1047 raise ImportError( 1048 f"Entry point '{manifest.name}' not found in group '{ENTRY_POINTS_GROUP}'" 1049 ) 1050 1051 # ----------------------------------------------------------------------- 1052 # Hook invocation 1053 # ----------------------------------------------------------------------- 1054 1055 def invoke_hook(self, hook_name: str, **kwargs: Any) -> List[Any]: 1056 """Call all registered callbacks for *hook_name*. 1057 1058 Each callback is wrapped in its own try/except so a misbehaving 1059 plugin cannot break the core agent loop. 1060 1061 Returns a list of non-``None`` return values from callbacks. 1062 1063 For ``pre_llm_call``, callbacks may return a dict describing 1064 context to inject into the current turn's user message:: 1065 1066 {"context": "recalled text..."} 1067 "recalled text..." # plain string, equivalent 1068 1069 Context is ALWAYS injected into the user message, never the 1070 system prompt. This preserves the prompt cache prefix — the 1071 system prompt stays identical across turns so cached tokens 1072 are reused. All injected context is ephemeral — never 1073 persisted to session DB. 1074 """ 1075 callbacks = self._hooks.get(hook_name, []) 1076 results: List[Any] = [] 1077 for cb in callbacks: 1078 try: 1079 ret = cb(**kwargs) 1080 if ret is not None: 1081 results.append(ret) 1082 except Exception as exc: 1083 logger.warning( 1084 "Hook '%s' callback %s raised: %s", 1085 hook_name, 1086 getattr(cb, "__name__", repr(cb)), 1087 exc, 1088 ) 1089 return results 1090 1091 # ----------------------------------------------------------------------- 1092 # Introspection 1093 # ----------------------------------------------------------------------- 1094 1095 def list_plugins(self) -> List[Dict[str, Any]]: 1096 """Return a list of info dicts for all discovered plugins.""" 1097 result: List[Dict[str, Any]] = [] 1098 for key, loaded in sorted(self._plugins.items()): 1099 result.append( 1100 { 1101 "name": loaded.manifest.name, 1102 "key": loaded.manifest.key or loaded.manifest.name, 1103 "kind": loaded.manifest.kind, 1104 "version": loaded.manifest.version, 1105 "description": loaded.manifest.description, 1106 "source": loaded.manifest.source, 1107 "enabled": loaded.enabled, 1108 "tools": len(loaded.tools_registered), 1109 "hooks": len(loaded.hooks_registered), 1110 "commands": len(loaded.commands_registered), 1111 "error": loaded.error, 1112 } 1113 ) 1114 return result 1115 1116 # ----------------------------------------------------------------------- 1117 # Plugin skill lookups 1118 # ----------------------------------------------------------------------- 1119 1120 def find_plugin_skill(self, qualified_name: str) -> Optional[Path]: 1121 """Return the ``Path`` to a plugin skill's SKILL.md, or ``None``.""" 1122 entry = self._plugin_skills.get(qualified_name) 1123 return entry["path"] if entry else None 1124 1125 def list_plugin_skills(self, plugin_name: str) -> List[str]: 1126 """Return sorted bare names of all skills registered by *plugin_name*.""" 1127 prefix = f"{plugin_name}:" 1128 return sorted( 1129 e["bare_name"] 1130 for qn, e in self._plugin_skills.items() 1131 if qn.startswith(prefix) 1132 ) 1133 1134 def remove_plugin_skill(self, qualified_name: str) -> None: 1135 """Remove a stale registry entry (silently ignores missing keys).""" 1136 self._plugin_skills.pop(qualified_name, None) 1137 1138 1139 # --------------------------------------------------------------------------- 1140 # Module-level singleton & convenience functions 1141 # --------------------------------------------------------------------------- 1142 1143 _plugin_manager: Optional[PluginManager] = None 1144 1145 1146 def get_plugin_manager() -> PluginManager: 1147 """Return (and lazily create) the global PluginManager singleton.""" 1148 global _plugin_manager 1149 if _plugin_manager is None: 1150 _plugin_manager = PluginManager() 1151 return _plugin_manager 1152 1153 1154 def discover_plugins(force: bool = False) -> None: 1155 """Discover and load all plugins. 1156 1157 Default behavior is idempotent. Pass ``force=True`` to rescan plugin 1158 manifests and reload state in the current process. 1159 """ 1160 get_plugin_manager().discover_and_load(force=force) 1161 1162 1163 def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]: 1164 """Invoke a lifecycle hook on all loaded plugins. 1165 1166 Returns a list of non-``None`` return values from plugin callbacks. 1167 """ 1168 return get_plugin_manager().invoke_hook(hook_name, **kwargs) 1169 1170 1171 1172 def get_pre_tool_call_block_message( 1173 tool_name: str, 1174 args: Optional[Dict[str, Any]], 1175 task_id: str = "", 1176 session_id: str = "", 1177 tool_call_id: str = "", 1178 ) -> Optional[str]: 1179 """Check ``pre_tool_call`` hooks for a blocking directive. 1180 1181 Plugins that need to enforce policy (rate limiting, security 1182 restrictions, approval workflows) can return:: 1183 1184 {"action": "block", "message": "Reason the tool was blocked"} 1185 1186 from their ``pre_tool_call`` callback. The first valid block 1187 directive wins. Invalid or irrelevant hook return values are 1188 silently ignored so existing observer-only hooks are unaffected. 1189 """ 1190 hook_results = invoke_hook( 1191 "pre_tool_call", 1192 tool_name=tool_name, 1193 args=args if isinstance(args, dict) else {}, 1194 task_id=task_id, 1195 session_id=session_id, 1196 tool_call_id=tool_call_id, 1197 ) 1198 1199 for result in hook_results: 1200 if not isinstance(result, dict): 1201 continue 1202 if result.get("action") != "block": 1203 continue 1204 message = result.get("message") 1205 if isinstance(message, str) and message: 1206 return message 1207 1208 return None 1209 1210 1211 def _ensure_plugins_discovered(force: bool = False) -> PluginManager: 1212 """Return the global manager after ensuring plugin discovery has run. 1213 1214 Pass ``force=True`` to rescan in the current process. 1215 """ 1216 manager = get_plugin_manager() 1217 manager.discover_and_load(force=force) 1218 return manager 1219 1220 1221 def get_plugin_context_engine(): 1222 """Return the plugin-registered context engine, or None.""" 1223 return _ensure_plugins_discovered()._context_engine 1224 1225 1226 def get_plugin_command_handler(name: str) -> Optional[Callable]: 1227 """Return the handler for a plugin-registered slash command, or ``None``.""" 1228 entry = _ensure_plugins_discovered()._plugin_commands.get(name) 1229 return entry["handler"] if entry else None 1230 1231 1232 _PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS = 30.0 1233 1234 1235 def resolve_plugin_command_result(result: Any) -> Any: 1236 """Resolve a plugin command return value, awaiting async handlers when needed. 1237 1238 Sync CLI/TUI dispatch sites call plugin handlers from plain functions. 1239 If a handler is async, await it directly when no loop is running; if 1240 we're already inside an active loop, run it in a helper thread with its 1241 own loop so the caller still gets a concrete result synchronously. The 1242 threaded path is bounded by a 30s timeout so a hung async handler cannot 1243 wedge the terminal indefinitely. 1244 """ 1245 if not inspect.isawaitable(result): 1246 return result 1247 1248 try: 1249 asyncio.get_running_loop() 1250 except RuntimeError: 1251 return asyncio.run(result) 1252 1253 outcome: Dict[str, Any] = {} 1254 failure: Dict[str, BaseException] = {} 1255 done = threading.Event() 1256 1257 def _runner() -> None: 1258 try: 1259 outcome["value"] = asyncio.run(result) 1260 except BaseException as exc: # pragma: no cover - re-raised below 1261 failure["exc"] = exc 1262 finally: 1263 done.set() 1264 1265 thread = threading.Thread( 1266 target=_runner, 1267 name="hermes-plugin-command-await", 1268 daemon=True, 1269 ) 1270 thread.start() 1271 if not done.wait(timeout=_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS): 1272 raise TimeoutError( 1273 "Plugin command async handler did not complete within " 1274 f"{_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS:.0f}s" 1275 ) 1276 if "exc" in failure: 1277 raise failure["exc"] 1278 return outcome.get("value") 1279 1280 1281 def get_plugin_commands() -> Dict[str, dict]: 1282 """Return the full plugin commands dict (name → {handler, description, plugin}). 1283 1284 Triggers idempotent plugin discovery so callers can use plugin commands 1285 before any explicit discover_plugins() call. 1286 """ 1287 return _ensure_plugins_discovered()._plugin_commands 1288 1289 1290 def get_plugin_toolsets() -> List[tuple]: 1291 """Return plugin toolsets as ``(key, label, description)`` tuples. 1292 1293 Used by the ``hermes tools`` TUI so plugin-provided toolsets appear 1294 alongside the built-in ones and can be toggled on/off per platform. 1295 """ 1296 manager = get_plugin_manager() 1297 if not manager._plugin_tool_names: 1298 return [] 1299 1300 try: 1301 from tools.registry import registry 1302 except Exception: 1303 return [] 1304 1305 # Group plugin tool names by their toolset 1306 toolset_tools: Dict[str, List[str]] = {} 1307 toolset_plugin: Dict[str, LoadedPlugin] = {} 1308 for tool_name in manager._plugin_tool_names: 1309 entry = registry.get_entry(tool_name) 1310 if not entry: 1311 continue 1312 ts = entry.toolset 1313 toolset_tools.setdefault(ts, []).append(entry.name) 1314 1315 # Map toolsets back to the plugin that registered them 1316 for _name, loaded in manager._plugins.items(): 1317 for tool_name in loaded.tools_registered: 1318 entry = registry.get_entry(tool_name) 1319 if entry and entry.toolset in toolset_tools: 1320 toolset_plugin.setdefault(entry.toolset, loaded) 1321 1322 result = [] 1323 for ts_key in sorted(toolset_tools): 1324 plugin = toolset_plugin.get(ts_key) 1325 label = f"🔌 {ts_key.replace('_', ' ').title()}" 1326 if plugin and plugin.manifest.description: 1327 desc = plugin.manifest.description 1328 else: 1329 desc = ", ".join(sorted(toolset_tools[ts_key])) 1330 result.append((ts_key, label, desc)) 1331 1332 return result