mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-02 12:13:05 +00:00
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:
parent
b0b7ff0d75
commit
95f2919f91
2 changed files with 153 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue