Compare commits

...

6 Commits

Author SHA1 Message Date
Hongming Wang
18e4849e75 fix(cli): handle plugin platforms in _get_platform_tools toolset lookup
The static PLATFORMS registry only contains built-in platforms (slack,
discord, etc.). Plugin platforms registered via
PluginContext.register_platform_adapter aren't in PLATFORMS, so the
existing PLATFORMS[platform]["default_toolset"] lookup raised KeyError
during agent loop init for any plugin-platform message.

Caught by an end-to-end test that spawns hermes gateway run + a stub
OpenAI-compat LLM and routes a real message through a plugin platform
(molecule-a2a). Fall back to "hermes-cli" (most permissive in-tree
toolset) for plugin platforms; operators can override per-platform via
config.platform_toolsets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:08:10 -07:00
Hongming Wang
ece9e34ec6 fix(gateway): key plugin adapters by PluginPlatformIdentifier in self.adapters
Previously the plugin-platform boot loop did self.adapters[plugin_name]
= adapter (string key), while built-in adapters use a Platform enum
key. The mismatch escaped review because the in-process unit + E2E
tests don't exercise GatewayRunner.start() — they only call
_create_plugin_adapter directly.

Caught by a real `hermes gateway run` subprocess test:

    File "gateway/run.py", line 2027, in <listcomp>
        "platforms": [p.value for p in self.adapters.keys()],
    AttributeError: 'str' object has no attribute 'value'

Switching to self.adapters[adapter.platform] = adapter — adapter.platform
is the PluginPlatformIdentifier passed to BasePlatformAdapter.__init__,
which has the .value attribute every downstream consumer needs.

After the fix the same subprocess test passes 4/4 checkpoints (config
parse → plugin boot → /a2a/health 200 → /a2a/inbound 200).
2026-05-02 03:17:45 -07:00
Hongming Wang
047de4a668 feat(gateway): plugin-platform-safe deserialization via resolve_platform_id
Without this, plugin-registered platforms (like molecule-a2a) survive
the runtime hot path but die on daemon restart: SessionSource.from_dict
calls Platform(value) directly, which raises ValueError for any name
not in the closed enum. Restored sessions with plugin-platform origins
either crash the restore loop or silently drop the platform field.

Add resolve_platform_id() — Platform-first, falls back to
PluginPlatformIdentifier ONLY when a currently-loaded plugin actually
claims the name. Bare typos and corrupted state still raise so silent
state-corruption can't slip past restore. Wire it into the three
known from_dict call sites:

- gateway/session.py:SessionSource.from_dict
- gateway/session.py:SessionEntry.from_dict (drops the silent debug-log
  swallow that left platform=None on unknown values)
- gateway/config.py:HomeChannel.from_dict

All existing gateway/test_session.py round-trip tests still pass,
including test_invalid_platform_raises (the narrow fallback preserves
loud-fail semantics for genuinely unknown names).
2026-05-02 03:01:51 -07:00
Hongming Wang
17451dc77a feat(gateway): add PluginPlatformIdentifier helper for plugin adapters
Plugin adapters can't extend the closed Platform enum but
BasePlatformAdapter.__init__ requires a Platform-shaped second arg.
This helper quacks like a Platform enum value (.value, hashable,
equality) so plugin adapters can subclass BasePlatformAdapter cleanly:

    super().__init__(config, PluginPlatformIdentifier("my-platform"))

Distinct hash bucket prevents accidental collision with built-in
Platform members in dict keys (defense-in-depth — registration also
rejects names that shadow Platform values).
2026-05-02 02:49:34 -07:00
b4ce9e4ea8 feat(gateway): wire register_platform_adapter into config + boot path
GatewayConfig:
- Adds plugin_platforms: Dict[str, PlatformConfig] sibling to the
  existing platforms: Dict[Platform, PlatformConfig]. Held separately
  so the closed Platform enum stays closed (no synthetic enum members,
  no string-vs-enum sniffing across the codebase).
- from_dict triggers plugin discovery before parsing platform names,
  then routes unknown-but-claimed names into plugin_platforms.
  Unclaimed unknown names continue to be silently skipped (preserves
  the original behavior).

GatewayRunner:
- New _create_plugin_adapter(name, config) — looks up the registered
  (adapter_class, requirements_check) tuple via
  hermes_cli.plugins.get_plugin_platform_adapter, validates
  requirements, instantiates. Distinct from _create_adapter so the
  in-tree if/elif chain stays free of plugin concerns.
- Boot loop in _run_async now iterates self.config.plugin_platforms
  after the in-tree platforms loop. Plugin platforms get the same
  message_handler / fatal_error_handler / session_store /
  busy_session_handler wiring as in-tree adapters; reconnection-queue
  + status-tracking is intentionally simpler for v1 (no retry loop).

Validation: 9/9 checks pass in
.hermes-validation/test_register_platform_adapter.py covering
registration, discovery, in-tree precedence, duplicate rejection,
config routing, and adapter instantiation. 48/48 hermes plugin tests
pass; gateway test failures in this branch are env-related (aiohttp,
websocket) and don't touch the patched code paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:41:09 -07:00
5d5deaa32e 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>
2026-05-02 02:30:03 -07:00
5 changed files with 253 additions and 11 deletions

View File

@ -90,8 +90,9 @@ class HomeChannel:
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "HomeChannel":
from hermes_cli.plugins import resolve_platform_id
return cls(
platform=Platform(data["platform"]),
platform=resolve_platform_id(data["platform"]),
chat_id=str(data["chat_id"]),
name=data.get("name", "Home"),
)
@ -222,11 +223,25 @@ class StreamingConfig:
class GatewayConfig:
"""
Main gateway configuration.
Manages all platform connections, session policies, and delivery settings.
"""
# Platform configurations
platforms: Dict[Platform, PlatformConfig] = field(default_factory=dict)
# Plugin-registered platform configurations.
#
# Keyed by string platform name (matching what
# PluginContext.register_platform_adapter was called with). Populated
# by from_dict() when a platform name in config.yaml doesn't map to
# a built-in Platform enum value but IS claimed by a loaded plugin.
# The gateway runner iterates this dict alongside `platforms` and
# creates adapters via _create_plugin_adapter.
#
# Held as a separate dict (rather than mixing with `platforms`) so
# the closed Platform enum stays closed — no synthetic enum members,
# no string-vs-enum sniffing throughout the codebase.
plugin_platforms: Dict[str, PlatformConfig] = field(default_factory=dict)
# Session reset policies by type
default_reset_policy: SessionResetPolicy = field(default_factory=SessionResetPolicy)
@ -361,13 +376,31 @@ class GatewayConfig:
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig":
# Trigger plugin discovery so register_platform_adapter calls
# have populated the plugin manager's adapter registry by the
# time we look up unknown platform names below. Idempotent.
# Imported inside the method to avoid pulling hermes_cli into
# gateway.config's import-time dep graph.
try:
from hermes_cli.plugins import discover_plugins, get_plugin_platform_adapter
discover_plugins()
except Exception:
# Plugin discovery is optional. If hermes_cli isn't on the
# path (e.g. some embedded test scenarios), silently fall
# back to the original "skip unknown" behavior.
get_plugin_platform_adapter = lambda _name: None # type: ignore[assignment]
platforms = {}
plugin_platforms: Dict[str, PlatformConfig] = {}
for platform_name, platform_data in data.get("platforms", {}).items():
try:
platform = Platform(platform_name)
platforms[platform] = PlatformConfig.from_dict(platform_data)
except ValueError:
pass # Skip unknown platforms
# Not a built-in platform. Check if a plugin claims it.
if get_plugin_platform_adapter(platform_name) is not None:
plugin_platforms[platform_name] = PlatformConfig.from_dict(platform_data)
# Else: silently skip — original behavior preserved.
reset_by_type = {}
for type_name, policy_data in data.get("reset_by_type", {}).items():
@ -406,6 +439,7 @@ class GatewayConfig:
return cls(
platforms=platforms,
plugin_platforms=plugin_platforms,
default_reset_policy=default_policy,
reset_by_type=reset_by_type,
reset_by_platform=reset_by_platform,

View File

@ -1953,6 +1953,47 @@ class GatewayRunner:
"next_retry": time.monotonic() + 30,
}
# Plugin-registered platforms (after the in-tree loop above —
# in-tree wins on name collision because it ran first). Plugin
# platforms are NOT in the Platform enum, so they don't get the
# full reconnection-queue + status-tracking treatment yet —
# that's a v1 simplification. If a plugin platform fails on
# connect we log + skip without queueing.
for plugin_name, platform_config in self.config.plugin_platforms.items():
if not platform_config.enabled:
continue
enabled_platform_count += 1
adapter = self._create_plugin_adapter(plugin_name, platform_config)
if not adapter:
logger.warning("No adapter available for plugin platform %s", plugin_name)
continue
adapter.set_message_handler(self._handle_message)
adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)
adapter.set_session_store(self.session_store)
adapter.set_busy_session_handler(self._handle_active_session_busy_message)
logger.info("Connecting to plugin platform %s...", plugin_name)
try:
success = await adapter.connect()
if success:
# Key by adapter.platform (a PluginPlatformIdentifier
# instance). Downstream code does ``[p.value for p in
# self.adapters.keys()]`` and similar — keying by the
# identifier (which has a .value attribute) keeps
# plugin platforms structurally compatible with the
# rest of the dict instead of mixing strings and
# enum members.
self.adapters[adapter.platform] = adapter
connected_count += 1
logger.info("%s (plugin) connected", plugin_name)
else:
logger.warning("%s (plugin) failed to connect", plugin_name)
startup_retryable_errors.append(
f"{plugin_name}: failed to connect"
)
except Exception as e:
logger.exception("Plugin platform %s: connect raised", plugin_name)
startup_retryable_errors.append(f"{plugin_name}: {e}")
if connected_count == 0:
if startup_nonretryable_errors:
reason = "; ".join(startup_nonretryable_errors)
@ -2421,9 +2462,50 @@ class GatewayRunner:
"""Wait for shutdown signal."""
await self._shutdown_event.wait()
def _create_plugin_adapter(
self,
name: str,
config: Any,
) -> Optional[BasePlatformAdapter]:
"""Create an adapter from a plugin-registered platform.
Looks up the plugin's registered (adapter_class, requirements_check)
tuple via PluginContext.register_platform_adapter, validates
requirements, and instantiates. Returns None on any failure
(logs the cause).
Distinct from _create_adapter to keep the in-tree if/elif chain
free of plugin concerns; plugin platforms always go through this
path.
"""
from hermes_cli.plugins import get_plugin_platform_adapter
entry = get_plugin_platform_adapter(name)
if entry is None:
logger.warning(
"Plugin platform %s: no plugin claims this name. "
"Did the plugin's register() function call "
"ctx.register_platform_adapter(name=%r, ...)?",
name, name,
)
return None
adapter_class, req_check = entry
if req_check and not req_check():
logger.warning(
"Plugin platform %s: requirements_check returned False",
name,
)
return None
try:
return adapter_class(config)
except Exception:
logger.exception(
"Plugin platform %s: adapter __init__ raised", name,
)
return None
def _create_adapter(
self,
platform: Platform,
self,
platform: Platform,
config: Any
) -> Optional[BasePlatformAdapter]:
"""Create the appropriate adapter for a platform."""

View File

@ -123,8 +123,9 @@ class SessionSource:
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SessionSource":
from hermes_cli.plugins import resolve_platform_id
return cls(
platform=Platform(data["platform"]),
platform=resolve_platform_id(data["platform"]),
chat_id=str(data["chat_id"]),
chat_name=data.get("chat_name"),
chat_type=data.get("chat_type", "dm"),
@ -409,10 +410,8 @@ class SessionEntry:
platform = None
if data.get("platform"):
try:
platform = Platform(data["platform"])
except ValueError as e:
logger.debug("Unknown platform value %r: %s", data["platform"], e)
from hermes_cli.plugins import resolve_platform_id
platform = resolve_platform_id(data["platform"])
return cls(
session_key=data["session_key"],

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,86 @@ 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)
class PluginPlatformIdentifier:
"""Platform-enum-shaped identifier for plugin-registered platforms.
Built-in adapters pass ``Platform.TELEGRAM`` etc. as the second arg
to ``BasePlatformAdapter.__init__``. Plugin adapters can't extend
the closed Platform enum, so they pass an instance of this class
instead it quacks like ``Platform`` enough for the base class
(``.value`` attribute, hashable, equality on value).
Usage in a plugin adapter::
from hermes_cli.plugins import PluginPlatformIdentifier
from gateway.platforms.base import BasePlatformAdapter
class MyAdapter(BasePlatformAdapter):
def __init__(self, config):
super().__init__(config, PluginPlatformIdentifier("my-platform"))
The ``name`` should match what was passed to
``ctx.register_platform_adapter(name=..., ...)``.
"""
__slots__ = ("value",)
def __init__(self, name: str):
self.value = name
def __hash__(self) -> int:
# Distinct hash bucket so a plugin platform named "telegram"
# (impossible — registration is rejected — but defensively)
# never collides with Platform.TELEGRAM in dict keys.
return hash(("__plugin_platform__", self.value))
def __eq__(self, other: object) -> bool:
return (
isinstance(other, PluginPlatformIdentifier)
and self.value == other.value
)
def __repr__(self) -> str:
return f"PluginPlatformIdentifier({self.value!r})"
def resolve_platform_id(value: str):
"""Resolve a platform-name string to a ``Platform`` enum member or
a ``PluginPlatformIdentifier``.
Used by ``from_dict`` deserializers for ``SessionSource``,
``SessionEntry``, ``HomeChannel`` etc. Without this fallback,
daemon restart would lose every session whose platform was
contributed by a plugin (e.g., ``molecule-a2a``) because the
bare ``Platform(value)`` call raises ``ValueError`` for unknown
enum members.
The fallback is intentionally narrow: an unknown name is only
accepted as a plugin identifier if a currently-loaded plugin has
actually claimed that name via ``register_platform_adapter``. Bare
typos and corrupted state still raise ``ValueError`` as before, so
silent state-corruption doesn't slip past restore.
"""
from gateway.config import Platform # late import to avoid cycle
try:
return Platform(value)
except ValueError:
if get_plugin_platform_adapter(value) is not None:
return PluginPlatformIdentifier(value)
raise
def get_plugin_commands() -> Dict[str, dict]:
"""Return the full plugin commands dict (name → {handler, description, plugin}).

View File

@ -516,7 +516,15 @@ def _get_platform_tools(
toolset_names = platform_toolsets.get(platform)
if toolset_names is None or not isinstance(toolset_names, list):
default_ts = PLATFORMS[platform]["default_toolset"]
# Plugin platforms (registered via PluginContext.register_platform_adapter)
# aren't in the static PLATFORMS registry — fall back to the cli
# toolset, which is the most permissive in-tree default. Operators
# can override per-platform via config.platform_toolsets.
platform_info = PLATFORMS.get(platform)
if platform_info is None:
default_ts = "hermes-cli"
else:
default_ts = platform_info["default_toolset"]
toolset_names = [default_ts]
# YAML may parse bare numeric names (e.g. ``12306:``) as int.