mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 05:09:01 +08:00
Compare commits
1 Commits
plugin-sdk
...
feat/gatew
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d11efb9076 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user