Compare commits

...

1 Commits

Author SHA1 Message Date
Ben
d11efb9076 perf(gateway): lazy-load bundled platform adapters
Importing gateway.run eagerly imported every bundled platform adapter
(discord.py, microsoft_teams, aiohttp.web, irc, line, mattermost, ntfy,
simplex) via the module-level plugin-discovery chain — even for a gateway
running no messaging platform at all (api_server-only / webhook-only, e.g.
on Modal). discord.py alone is the heaviest import on that path.

Register bundled 'kind: platform' plugins as cheap, import-free
LazyPlatformEntry placeholders built from manifest + directory-name
metadata (auth/cron env-var names derived as <PLATFORM_UPPER>_*). The
adapter module — and its SDK — is imported on first real use via
platform_registry.get() / create_adapter(). Mirrors the existing
model-provider deferral in hermes_cli/plugins.py.

- platform_registry: LazyPlatformEntry + lazy-aware get/create_adapter
  (materialise) vs is_registered/plugin_entries/all_entries (metadata-only,
  no import).
- hermes_cli/plugins: bundled platform plugins register lazily; bundled
  backends still load eagerly.
- gateway/config: apply_yaml_config_fn loop materialises only configured
  platforms; auto-enable gate materialises before probing check_fn /
  env_enablement_fn / is_connected (a sound pre-gate needs a declarative
  manifest predicate — deferred).
- hermes_cli/status: materialise before calling check_fn for display.

Result: 'import gateway.run' now pulls in zero adapter SDKs; a clean
api_server-only gateway imports none through load_gateway_config either.
Warm-cache import ~310ms vs ~435ms (~28% faster); larger on cold Modal.
2026-06-02 14:09:10 +10:00
4 changed files with 247 additions and 20 deletions

View File

@@ -913,8 +913,13 @@ def load_gateway_config() -> GatewayConfig:
# ``_apply_env_overrides()`` after ``GatewayConfig.from_dict``.
if _pr is not None:
for entry in _pr.all_entries():
if entry.apply_yaml_config_fn is None:
continue
# ``entry`` may be a cheap LazyPlatformEntry placeholder.
# Determine the platform's config block from its NAME
# (available without importing the adapter) and only
# materialise the real entry — via ``get(name)`` — when
# the user has actually configured this platform. This
# keeps unconfigured platforms lazy so load_gateway_config
# doesn't import every adapter SDK.
platform_cfg = yaml_cfg.get(entry.name)
# Fall back to the platform's block under ``platforms`` /
# ``gateway.platforms`` so adapter hooks still run when the
@@ -930,8 +935,13 @@ def load_gateway_config() -> GatewayConfig:
break
if not isinstance(platform_cfg, dict):
continue
# Configured → materialise (no-op if already real) so the
# live apply_yaml_config_fn hook is available.
real = _pr.get(entry.name)
if real is None or real.apply_yaml_config_fn is None:
continue
try:
seeded = entry.apply_yaml_config_fn(yaml_cfg, platform_cfg)
seeded = real.apply_yaml_config_fn(yaml_cfg, platform_cfg)
except Exception as e:
logger.debug(
"apply_yaml_config_fn for %s raised: %s",
@@ -1870,6 +1880,26 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
discover_plugins() # idempotent
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
# ``entry`` may be a lazy placeholder. This registry-driven
# auto-enable gate fundamentally needs the adapter's real
# ``check_fn`` / ``env_enablement_fn`` / ``is_connected`` to decide
# whether the user configured the platform — and a plugin can be
# auto-enabled purely via ``env_enablement_fn`` (which may read
# non-conventional env vars, files, etc.), so we cannot soundly
# pre-filter on credential env-var presence without importing.
# Materialise the real entry here.
#
# NOTE: this runs at config-load (gateway start), NOT at
# ``import gateway.run`` — so the import-time win (no adapter SDKs
# pulled in by merely importing the gateway module) is preserved.
# At ``start()`` the probe still imports every plugin adapter to
# evaluate auto-enablement; see the spike writeup for why a sound
# lazy auto-enable gate needs a declarative manifest-level
# ``enable_when`` predicate (deferred).
real = platform_registry.get(entry.name)
if real is None:
continue
entry = real
try:
if not entry.check_fn():
continue

View File

@@ -159,22 +159,121 @@ class PlatformEntry:
standalone_sender_fn: Optional[Callable[..., Awaitable[dict]]] = None
@dataclass
class LazyPlatformEntry:
"""Import-free placeholder for a bundled platform adapter.
Holds only the cheap, manifest-derived metadata the gateway/setup UI
needs *before* a platform is actually used (name, label, required_env,
install_hint, emoji, plugin_name). The heavy adapter module — and the
SDK it pulls in (e.g. ``discord.py``, ``microsoft_teams``, aiohttp) — is
NOT imported until the first time a *live* capability is required:
``create_adapter()``, ``check_fn``, ``setup_fn``, ``apply_yaml_config_fn``,
``standalone_sender_fn``, etc.
The ``loader`` callable imports the adapter module and calls its
``register(ctx)`` entry point, which re-registers a real
:class:`PlatformEntry` under the same ``name`` (last-writer-wins), thereby
materialising the lazy entry in place. See ``PlatformRegistry.get`` /
``_materialise``.
This mirrors the model-provider deferral already used in
``hermes_cli/plugins.py`` (manifest recorded at discovery, module imported
on first real use) so that a gateway running without any messaging
platform (e.g. api_server-only on Modal) never pays the adapter import
cost at ``import gateway.run`` time.
"""
# Registry key (platform value, e.g. "discord") — equals the plugin
# directory name by convention.
name: str
# Human-readable label for status/setup display.
label: str = ""
# Imports the adapter module and triggers its register(ctx); raises on
# failure (callers wrap in try/except and fall through to legacy paths).
loader: Callable[[], None] = lambda: None
# Cheap metadata mirrored from the manifest so status/setup UIs don't
# force a load. Names intentionally match PlatformEntry fields.
required_env: list = field(default_factory=list)
install_hint: str = ""
emoji: str = "🔌"
plugin_name: str = ""
source: str = "plugin"
# Auth/cron env-var names — derived mechanically from the platform value
# (``<PLATFORM_UPPER>_ALLOWED_USERS`` etc.) so the gateway's startup
# allowlist-warning scan and cron deliver-target enumeration work off the
# placeholder without importing the adapter. Mirror PlatformEntry.
allowed_users_env: str = ""
allow_all_env: str = ""
cron_deliver_env_var: str = ""
class PlatformRegistry:
"""Central registry of platform adapters.
Thread-safe for reads (dict lookups are atomic under GIL).
Writes happen at startup during sequential discovery.
Entries are either fully-materialised :class:`PlatformEntry` objects or
cheap :class:`LazyPlatformEntry` placeholders. Lazy entries are
transparently materialised on first access that needs a live callable.
"""
def __init__(self) -> None:
self._entries: dict[str, PlatformEntry] = {}
# Lazy placeholders keyed by platform name. Kept separate from
# ``_entries`` so metadata-only enumeration (status, setup) never
# triggers a materialisation.
self._lazy: dict[str, LazyPlatformEntry] = {}
# -- lazy registration ---------------------------------------------------
def register_lazy(self, lazy: LazyPlatformEntry) -> None:
"""Register an import-free placeholder for a bundled platform.
A subsequent ``register()`` of a real :class:`PlatformEntry` with the
same name supersedes the placeholder (materialisation). Conversely a
placeholder never overwrites an already-materialised entry.
"""
if lazy.name in self._entries:
# Already materialised — keep the real entry.
return
self._lazy[lazy.name] = lazy
logger.debug("Registered lazy platform placeholder: %s", lazy.name)
def _materialise(self, name: str) -> Optional[PlatformEntry]:
"""Force-load a lazy entry's adapter module and return the real entry.
Returns the materialised :class:`PlatformEntry`, or ``None`` if the
name is unknown or the loader failed. Idempotent: once materialised
the lazy placeholder is dropped.
"""
if name in self._entries:
return self._entries[name]
lazy = self._lazy.get(name)
if lazy is None:
return None
try:
lazy.loader() # imports adapter module; its register() repopulates _entries
except Exception as e:
logger.error(
"Failed to materialise platform '%s' (lazy import): %s",
lazy.label or name, e, exc_info=True,
)
return None
finally:
self._lazy.pop(name, None)
return self._entries.get(name)
def register(self, entry: PlatformEntry) -> None:
"""Register a platform adapter entry.
If an entry with the same name exists, it is replaced (last writer
wins -- this lets plugins override built-in adapters if desired).
Registering a real entry supersedes any lazy placeholder for the
same name.
"""
self._lazy.pop(entry.name, None)
if entry.name in self._entries:
prev = self._entries[entry.name]
logger.info(
@@ -188,22 +287,52 @@ class PlatformRegistry:
def unregister(self, name: str) -> bool:
"""Remove a platform entry. Returns True if it existed."""
return self._entries.pop(name, None) is not None
had_lazy = self._lazy.pop(name, None) is not None
had_real = self._entries.pop(name, None) is not None
return had_real or had_lazy
def get(self, name: str) -> Optional[PlatformEntry]:
"""Look up a platform entry by name."""
return self._entries.get(name)
"""Look up a platform entry by name, materialising if lazy.
def all_entries(self) -> list[PlatformEntry]:
"""Return all registered platform entries."""
return list(self._entries.values())
Callers read live attributes (``standalone_sender_fn``,
``apply_yaml_config_fn``, ``adapter_factory`` …) off the returned
entry, so a lazy placeholder must be force-loaded here.
"""
if name in self._entries:
return self._entries[name]
if name in self._lazy:
return self._materialise(name)
return None
def plugin_entries(self) -> list[PlatformEntry]:
"""Return only plugin-registered platform entries."""
return [e for e in self._entries.values() if e.source == "plugin"]
def all_entries(self) -> list:
"""Return all registered platform entries (materialised + lazy).
Materialised :class:`PlatformEntry` objects and import-free
:class:`LazyPlatformEntry` placeholders are returned side by side.
Enumeration intentionally does NOT materialise — callers that only
read cheap metadata (name, label, required_env, install_hint, emoji,
plugin_name, the auth/cron env-var names) work against either type.
Code that needs a live callable should call ``get(name)`` to force a
load for that specific platform.
"""
merged: dict[str, Any] = dict(self._lazy)
merged.update(self._entries) # materialised wins over placeholder
return list(merged.values())
def plugin_entries(self) -> list:
"""Return only plugin-registered platform entries (materialised + lazy).
See :meth:`all_entries` for the no-materialise contract.
"""
return [e for e in self.all_entries() if getattr(e, "source", "plugin") == "plugin"]
def is_registered(self, name: str) -> bool:
return name in self._entries
"""True if *name* is registered — does NOT materialise a lazy entry.
A cheap existence check (used to decide whether the gateway handles a
platform at all) must not pay the adapter import cost.
"""
return name in self._entries or name in self._lazy
def create_adapter(self, name: str, config: Any) -> Optional[Any]:
"""Create an adapter instance for the given platform name.
@@ -213,8 +342,12 @@ class PlatformRegistry:
- check_fn() returns False (missing deps)
- validate_config() returns False (misconfigured)
- The factory raises an exception
Materialises a lazy entry on demand — this is the canonical "the
platform is actually being used now" path, so importing the adapter
module (and its SDK) here is exactly the intended cost.
"""
entry = self._entries.get(name)
entry = self.get(name)
if entry is None:
return None

View File

@@ -1157,10 +1157,23 @@ class PluginManager:
# 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"}:
# Bundled platform plugins (gateway adapters like IRC) must be
# available out of the box without the user opting in. But
# importing every adapter module here eagerly pulls in heavy
# SDKs (discord.py, microsoft_teams, aiohttp) at
# ``import gateway.run`` time — even for a gateway running no
# messaging platform at all (e.g. api_server-only on Modal).
# Instead, register a cheap import-free LAZY placeholder; the
# adapter module is imported on first real use via
# ``platform_registry.get()`` / ``create_adapter()``. Mirrors the
# model-provider deferral above.
if manifest.source == "bundled" and manifest.kind == "platform":
self._register_lazy_platform(manifest)
continue
# Bundled backends auto-load eagerly — they ship with hermes and
# must just work; selection is driven by ``<category>.provider``.
if manifest.source == "bundled" and manifest.kind == "backend":
self._load_plugin(manifest)
continue
@@ -1402,6 +1415,53 @@ class PluginManager:
# Loading
# -----------------------------------------------------------------------
def _register_lazy_platform(self, manifest: PluginManifest) -> None:
"""Register an import-free placeholder for a bundled platform plugin.
Builds a :class:`~gateway.platform_registry.LazyPlatformEntry` from
cheap manifest/path metadata so the gateway never imports the heavy
adapter module (and its SDK) until the platform is actually used.
The registry key (platform value, e.g. ``discord``) is the plugin
directory name by convention. The auth/cron env-var names follow the
mechanical ``<PLATFORM_UPPER>_{ALLOWED_USERS,ALLOW_ALL_USERS,HOME_CHANNEL}``
pattern shared by every bundled adapter, so they can be derived
without importing. The ``loader`` imports the module and calls its
``register(ctx)``, which registers the real ``PlatformEntry`` and
thereby materialises the placeholder in place.
"""
from gateway.platform_registry import platform_registry, LazyPlatformEntry
# Platform value == plugin directory name.
plat = Path(manifest.path).name if manifest.path else (manifest.key or manifest.name)
upper = plat.upper()
def _loader(_man: PluginManifest = manifest) -> None:
# Import the adapter module and run its register(ctx); this calls
# ctx.register_platform(...) → platform_registry.register(real),
# superseding the lazy placeholder.
self._load_plugin(_man)
platform_registry.register_lazy(
LazyPlatformEntry(
name=plat,
label=getattr(manifest, "label", "") or plat.replace("_", " ").title(),
loader=_loader,
required_env=list(manifest.requires_env or []),
install_hint="pip install 'hermes-agent[messaging]'",
plugin_name=manifest.name,
source="plugin",
allowed_users_env=f"{upper}_ALLOWED_USERS",
allow_all_env=f"{upper}_ALLOW_ALL_USERS",
cron_deliver_env_var=f"{upper}_HOME_CHANNEL",
)
)
self._plugin_platform_names.add(plat)
logger.debug(
"Registered lazy platform placeholder for bundled plugin '%s' (%s)",
manifest.name, plat,
)
def _load_plugin(self, manifest: PluginManifest) -> None:
"""Import a plugin module and call its ``register(ctx)`` function."""
loaded = LoadedPlugin(manifest=manifest)

View File

@@ -462,9 +462,13 @@ def show_status(args):
try:
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
configured = entry.check_fn()
# ``entry`` may be a lazy placeholder; status display needs the
# real ``check_fn`` to report configured/not — materialise it.
real = platform_registry.get(entry.name) or entry
check_fn = getattr(real, "check_fn", None)
configured = bool(check_fn()) if callable(check_fn) else False
status_str = "configured" if configured else "not configured"
label = entry.label
label = real.label
print(f" {label:<12} {check_mark(configured)} {status_str} (plugin)")
except Exception:
pass