From 5d5deaa32ee972f63afd46901144107a3cd5a465 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sat, 2 May 2026 02:30:03 -0700 Subject: [PATCH] feat(gateway): platform adapter plugins via PluginContext.register_platform_adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds register_platform_adapter to PluginContext so plugins discovered via ~/.hermes/plugins/, ./.hermes/plugins/, or pip entry_points (group hermes_agent.plugins) can register custom messaging platform adapters the same way they register tools, hooks, CLI commands, and skills. Until now, messaging platforms were the only PluginContext capability still hardcoded — gateway/run.py:_create_adapter is a 19-branch if/elif chain with no extension point. This commit adds the registration surface but does NOT yet wire it into _create_adapter — that's a separate commit so the patch lands incrementally. Validated via .hermes-validation/test_register_platform_adapter.py in molecule-core: plugin discovery + registration + duplicate rejection + in-tree non-shadowing all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/plugins.py | 50 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 2385a5c9..b52cfbb2 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -341,6 +341,41 @@ class PluginContext: self._manager._hooks.setdefault(hook_name, []).append(callback) logger.debug("Plugin %s registered hook: %s", self.manifest.name, hook_name) + # -- platform adapter registration -------------------------------------- + + def register_platform_adapter( + self, + name: str, + adapter_class: type, + requirements_check: Callable[[], bool] | None = None, + ) -> None: + """Register a custom messaging platform adapter. + + ``name`` is the identifier the user puts under + ``gateway.platforms.`` in config.yaml. ``adapter_class`` + must be a subclass of ``gateway.platforms.base.BasePlatformAdapter``. + ``requirements_check`` is an optional callable returning False + when the adapter's dependencies aren't installed (mirrors the + existing ``check_telegram_requirements`` pattern). + + Plugin-registered adapters are consulted as a FALLBACK after + ``GatewayRunner._create_adapter`` exhausts its hardcoded chain + of in-tree platforms — in-tree wins on name collision so this + can't shadow Telegram, Slack, etc. + """ + if name in self._manager._plugin_platform_adapters: + logger.warning( + "Plugin '%s' tried to register platform adapter '%s' but it is " + "already registered by another plugin. Skipping.", + self.manifest.name, name, + ) + return + self._manager._plugin_platform_adapters[name] = (adapter_class, requirements_check) + logger.debug( + "Plugin %s registered platform adapter: %s", + self.manifest.name, name, + ) + # -- skill registration ------------------------------------------------- def register_skill( @@ -407,6 +442,10 @@ class PluginManager: self._cli_ref = None # Set by CLI after plugin discovery # Plugin skill registry: qualified name → metadata dict. self._plugin_skills: Dict[str, Dict[str, Any]] = {} + # Plugin platform adapter registry: name → (adapter_class, optional req_check). + # Consulted by GatewayRunner._create_adapter as a fallback after the + # in-tree if/elif chain. See PluginContext.register_platform_adapter. + self._plugin_platform_adapters: Dict[str, tuple] = {} # ----------------------------------------------------------------------- # Public @@ -790,6 +829,17 @@ def get_plugin_command_handler(name: str) -> Optional[Callable]: return entry["handler"] if entry else None +def get_plugin_platform_adapter(name: str) -> Optional[tuple]: + """Return ``(adapter_class, requirements_check_or_None)`` for a + plugin-registered platform adapter, or ``None`` if no plugin claims + that platform name. + + Used by ``GatewayRunner._create_adapter`` as a fallback after the + in-tree if/elif chain — see ``PluginContext.register_platform_adapter``. + """ + return get_plugin_manager()._plugin_platform_adapters.get(name) + + def get_plugin_commands() -> Dict[str, dict]: """Return the full plugin commands dict (name → {handler, description, plugin}).