feat(gateway): platform adapter plugins via PluginContext.register_platform_adapter

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) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-05-02 02:30:03 -07:00
parent 816e3e3774
commit 5d5deaa32e

View File

@ -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.<name>`` 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}).