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

@ -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

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)