Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion gateway/platform_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,74 @@ 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.

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(
Expand All @@ -188,22 +249,31 @@ def register(self, entry: PlatformEntry) -> None:

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.
Expand All @@ -214,6 +284,8 @@ def create_adapter(self, name: str, config: Any) -> Optional[Any]:
- 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
Expand Down
85 changes: 80 additions & 5 deletions hermes_cli/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1318,14 +1322,25 @@ def _discover_and_load_inner(self) -> None:
# 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
Expand Down Expand Up @@ -1564,6 +1579,66 @@ def _scan_entry_points(self) -> List[PluginManifest]:
# 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)
Expand Down
Loading