From b4ce9e4ea857eb3980238d485acec34a0c2506d8 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sat, 2 May 2026 02:41:09 -0700 Subject: [PATCH] feat(gateway): wire register_platform_adapter into config + boot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- gateway/config.py | 37 +++++++++++++++++++-- gateway/run.py | 82 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 5efd3672..2bf2bc67 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -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, diff --git a/gateway/run.py b/gateway/run.py index ba7ea43a..71cda70f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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."""