From 95f2919f916e97dfbf86e9fb1f479f23fef84253 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:11:59 -0700 Subject: [PATCH] perf(startup): lazy-load gateway platform adapters (#54448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundled platform plugins (telegram, discord, feishu, teams, ...) were eagerly imported at plugin-discovery time on every `hermes` invocation, including plain `hermes chat` which never touches a gateway platform. Their modules import heavy platform SDKs at module level (lark_oapi, microsoft_teams, discord.py, slack_bolt, ...) — feishu alone pulled in lark_oapi (~2.6s), teams pulled microsoft_teams (~1.9s). Discovery now registers a cheap deferred loader per platform in the platform_registry; the adapter module is imported only when the gateway / cron / setup / send_message path actually asks for that platform. is_registered() and the iterate-all accessors stay correct (deferred counts as registered; plugin_entries()/all_entries() materialize all deferred loaders, since those paths genuinely need every adapter). Cold start: ~4.4s -> ~2.45s to banner. discover_and_load: 2.0s -> 0.3s (warm), and the heavy SDKs are no longer imported at all in CLI mode. Every shipped platform remains available out of the box — it just loads on first use. --- gateway/platform_registry.py | 74 ++++++++++++++++++++++++++++++- hermes_cli/plugins.py | 85 +++++++++++++++++++++++++++++++++--- 2 files changed, 153 insertions(+), 6 deletions(-) diff --git a/gateway/platform_registry.py b/gateway/platform_registry.py index 97f0c0e1d74..b3c19af1bcb 100644 --- a/gateway/platform_registry.py +++ b/gateway/platform_registry.py @@ -168,6 +168,65 @@ class PlatformRegistry: def __init__(self) -> None: self._entries: dict[str, PlatformEntry] = {} + # Deferred platform loaders: name -> zero-arg callable that imports the + # owning plugin module (which calls register() and populates _entries). + # + # Why this exists: platform adapter modules import heavy, platform- + # specific SDKs at module level (lark_oapi, microsoft_teams, discord.py, + # slack_bolt, ...). Eagerly loading all ~20 bundled platform plugins at + # plugin-discovery time added several seconds to *every* `hermes` + # invocation -- including plain `hermes chat`, which never touches any + # gateway platform. Discovery now registers a cheap deferred loader per + # platform; the real module is imported only when a registry lookup + # actually asks for that platform (gateway start, cron delivery, + # `hermes setup`/`gateway status`, send_message). + self._deferred: dict[str, Callable[[], None]] = {} + + # -- deferred loading ---------------------------------------------------- + + def register_deferred(self, name: str, loader: Callable[[], None]) -> None: + """Register a lazy loader for a platform that hasn't been imported yet. + + *loader* is a zero-arg callable that imports the owning plugin module, + which is expected to call :meth:`register` with the real entry for + *name*. The loader runs at most once, the first time *name* is looked + up (or when the full entry list is materialized). A real entry that is + registered directly (e.g. a built-in) takes precedence -- the deferred + loader is then dropped. + """ + if name in self._entries: + # Already concretely registered; no need to defer. + return + self._deferred[name] = loader + + def _resolve(self, name: str) -> None: + """Run the deferred loader for *name* if one is pending.""" + loader = self._deferred.pop(name, None) + if loader is None: + return + try: + loader() + except Exception as e: + logger.warning( + "Deferred load of platform '%s' failed: %s", + name, + e, + exc_info=True, + ) + + def _resolve_all(self) -> None: + """Run every pending deferred loader. + + Used by the iterate-all accessors (``all_entries``/``plugin_entries``), + which are only called by paths that genuinely need every adapter: + gateway startup, ``hermes setup``/``gateway status``, channel + directory. CLI chat never iterates the full set. + """ + if not self._deferred: + return + # Snapshot keys -- loaders mutate _deferred as they resolve. + for name in list(self._deferred): + self._resolve(name) def register(self, entry: PlatformEntry) -> None: """Register a platform adapter entry. @@ -175,6 +234,8 @@ class PlatformRegistry: If an entry with the same name exists, it is replaced (last writer wins -- this lets plugins override built-in adapters if desired). """ + # A concrete registration supersedes any pending deferred loader. + self._deferred.pop(entry.name, None) if entry.name in self._entries: prev = self._entries[entry.name] logger.info( @@ -188,22 +249,31 @@ class PlatformRegistry: def unregister(self, name: str) -> bool: """Remove a platform entry. Returns True if it existed.""" + self._deferred.pop(name, None) return self._entries.pop(name, None) is not None def get(self, name: str) -> Optional[PlatformEntry]: """Look up a platform entry by name.""" + if name not in self._entries: + self._resolve(name) return self._entries.get(name) def all_entries(self) -> list[PlatformEntry]: """Return all registered platform entries.""" + self._resolve_all() return list(self._entries.values()) def plugin_entries(self) -> list[PlatformEntry]: """Return only plugin-registered platform entries.""" + self._resolve_all() return [e for e in self._entries.values() if e.source == "plugin"] def is_registered(self, name: str) -> bool: - return name in self._entries + # A deferred (not-yet-imported) platform still counts as registered -- + # the loader will materialize it on first real use. This keeps cheap + # membership checks (toolset resolution, webhook deliver-target checks) + # from triggering a heavy import. + return name in self._entries or name in self._deferred def create_adapter(self, name: str, config: Any) -> Optional[Any]: """Create an adapter instance for the given platform name. @@ -214,6 +284,8 @@ class PlatformRegistry: - validate_config() returns False (misconfigured) - The factory raises an exception """ + if name not in self._entries: + self._resolve(name) entry = self._entries.get(name) if entry is None: return None diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index e4d0afd7c8b..b10f9ae1359 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -306,6 +306,10 @@ class LoadedPlugin: commands_registered: List[str] = field(default_factory=list) enabled: bool = False error: Optional[str] = None + # True for a bundled platform plugin recorded as a deferred (not-yet- + # imported) loader. The module loads on first real use via the + # platform_registry; see PluginManager._register_deferred_platform. + deferred: bool = False # --------------------------------------------------------------------------- @@ -1318,14 +1322,25 @@ class PluginManager: # just work. Selection among them (e.g. which image_gen backend # services calls) is driven by ``.provider`` config, # enforced by the tool wrapper. - # - # Bundled platform plugins (gateway adapters like IRC) auto-load - # for the same reason: every platform Hermes ships must be - # available out of the box without the user having to opt in. - if manifest.source == "bundled" and manifest.kind in {"backend", "platform"}: + if manifest.source == "bundled" and manifest.kind == "backend": self._load_plugin(manifest) continue + # Bundled platform plugins (gateway adapters: telegram, discord, + # feishu, teams, ...) are registered LAZILY. Their modules import + # heavy, platform-specific SDKs at module level (lark_oapi, + # microsoft_teams, discord.py, slack_bolt, ...), so eagerly loading + # all ~20 of them added several seconds to every `hermes` + # invocation — including plain `hermes chat`, which never touches a + # gateway platform. Instead we register a cheap deferred loader in + # the platform_registry keyed on the platform name; the real module + # is imported only when the gateway / cron / setup / send_message + # path actually asks for that platform. Every platform Hermes ships + # remains available out of the box — it just loads on first use. + if manifest.source == "bundled" and manifest.kind == "platform": + self._register_deferred_platform(manifest) + continue + # Everything else (standalone, user-installed backends, # entry-point plugins) is opt-in via plugins.enabled. # Accept both the path-derived key and the legacy bare name @@ -1564,6 +1579,66 @@ class PluginManager: # Loading # ----------------------------------------------------------------------- + def _platform_name_from_manifest(self, manifest: PluginManifest) -> str: + """Derive the gateway platform name (e.g. ``feishu``) for a platform plugin. + + The platform name registered via ``register_platform(name=...)`` lives + inside the adapter module (which we are explicitly trying NOT to import + early). It is not carried in ``plugin.yaml``. Across every bundled + platform plugin the manifest name is ``-platform`` and the + plugin directory basename is ````, so we derive the name + without importing: strip a trailing ``-platform`` from the manifest + name, falling back to the directory basename. This is also a sensible + convention for third-party platform plugins. + """ + name = manifest.name or "" + if name.endswith("-platform"): + return name[: -len("-platform")] + if manifest.path: + return Path(manifest.path).name + return name + + def _register_deferred_platform(self, manifest: PluginManifest) -> None: + """Register a lazy loader for a bundled platform plugin. + + The platform adapter module is imported only when the gateway / cron / + setup / send_message path first asks the ``platform_registry`` for this + platform. Until then we record a lightweight ``LoadedPlugin`` so + ``hermes plugins list`` still shows the platform as available, and we + hand the registry a loader that runs the normal eager-load path. + """ + lookup_key = manifest.key or manifest.name + platform_name = self._platform_name_from_manifest(manifest) + + # Record an enabled placeholder for introspection (`hermes plugins + # list`). The real module load swaps in a fully-populated LoadedPlugin + # (tools/hooks/commands attribution) when the loader fires. + loaded = LoadedPlugin(manifest=manifest, enabled=True) + loaded.deferred = True + self._plugins[lookup_key] = loaded + + def _loader(_manifest: PluginManifest = manifest) -> None: + self._load_plugin(_manifest) + + try: + from gateway.platform_registry import platform_registry + + platform_registry.register_deferred(platform_name, _loader) + logger.debug( + "Registered deferred platform loader: %s (plugin=%s)", + platform_name, + lookup_key, + ) + except Exception: + # If the registry import fails for any reason, fall back to eager + # loading so the platform is never silently lost. + logger.debug( + "Deferred platform registration failed for '%s'; eager-loading", + lookup_key, + exc_info=True, + ) + self._load_plugin(manifest) + def _load_plugin(self, manifest: PluginManifest) -> None: """Import a plugin module and call its ``register(ctx)`` function.""" loaded = LoadedPlugin(manifest=manifest)