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>
This commit is contained in:
Hongming Wang 2026-05-02 02:41:09 -07:00
parent 5d5deaa32e
commit b4ce9e4ea8
2 changed files with 115 additions and 4 deletions

View File

@ -222,11 +222,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 +375,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 +438,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,43 @@ 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:
# Plugin platforms keyed by string in self.adapters
# (built-ins use the Platform enum). Downstream code
# treats both as opaque dict keys for routing.
self.adapters[plugin_name] = 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 +2458,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."""