perf(startup): lazy-load gateway platform adapters (#54448)

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.
This commit is contained in:
Teknium 2026-06-28 15:11:59 -07:00 committed by GitHub
parent b0b7ff0d75
commit 95f2919f91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 153 additions and 6 deletions

View file

@ -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 ``<category>.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>-platform`` and the
plugin directory basename is ``<platform>``, 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)