mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-14 04:02:26 +00:00
feat(gateway): generic plugin hooks for env enablement + cron delivery
Widen the platform-plugin surface so plugins can self-configure from env
vars and opt into cron home-channel delivery without editing core files.
Closes the scope gap that forced every new platform (Google Chat, Teams,
IRC, future) to either touch gateway/config.py, cron/scheduler.py, and
hermes_cli/config.py or live without env-only setup.
Changes:
- gateway/platform_registry.py: two new optional PlatformEntry fields.
- env_enablement_fn: () -> Optional[dict]. Called during
_apply_env_overrides BEFORE the adapter is constructed. Returned
dict fields are merged into PlatformConfig.extra; the special
'home_channel' key (if present) becomes a proper HomeChannel
dataclass on the PlatformConfig.
- cron_deliver_env_var: name of the *_HOME_CHANNEL env var. When set,
the plugin platform is a valid cron deliver= target and cron reads
the env var to resolve the default chat/room ID.
- gateway/config.py: the existing plugin-platform enable pass at the
bottom of _apply_env_overrides now calls env_enablement_fn and seeds
extras/home_channel. No effect on plugins that don't set the new
field.
- cron/scheduler.py: _is_known_delivery_platform and
_resolve_home_env_var fall through to the registry when the platform
isn't in the hardcoded built-in sets. New _iter_home_target_platforms
helper iterates built-ins + plugin platforms for the deliver=origin
fallback.
- gateway/run.py: _home_target_env_var now consults the new resolver so
plugin-defined home channels work for non-cron call sites too.
- hermes_cli/config.py: new _inject_platform_plugin_env_vars() sibling
of _inject_profile_env_vars(). Scans plugins/platforms/*/plugin.yaml
at import time and contributes entries to OPTIONAL_ENV_VARS so
'hermes config' UI discovers them. Supports bare-string and rich-dict
requires_env entries plus a new optional_env list for non-required
vars (home channels, allowlists).
All additions are strictly opt-in. Existing plugins (IRC, Teams,
image_gen, memory) see zero behavior change until they adopt the new
fields.
This commit is contained in:
parent
c8e3e39185
commit
af9336d575
5 changed files with 220 additions and 11 deletions
|
|
@ -152,9 +152,54 @@ def _resolve_origin(job: dict) -> Optional[dict]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_cron_env_var(platform_name: str) -> str:
|
||||||
|
"""Return the cron home-channel env var registered by a plugin platform.
|
||||||
|
|
||||||
|
Falls through the platform registry so plugins that set
|
||||||
|
``cron_deliver_env_var`` on their ``PlatformEntry`` get cron delivery
|
||||||
|
support without editing this module.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from hermes_cli.plugins import discover_plugins
|
||||||
|
discover_plugins() # idempotent
|
||||||
|
from gateway.platform_registry import platform_registry
|
||||||
|
entry = platform_registry.get(platform_name.lower())
|
||||||
|
if entry and entry.cron_deliver_env_var:
|
||||||
|
return entry.cron_deliver_env_var
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_known_delivery_platform(platform_name: str) -> bool:
|
||||||
|
"""Whether ``platform_name`` is a valid cron delivery target.
|
||||||
|
|
||||||
|
Hardcoded built-ins in ``_KNOWN_DELIVERY_PLATFORMS`` are checked first;
|
||||||
|
plugin platforms registered via ``PlatformEntry`` are accepted if they
|
||||||
|
provide a ``cron_deliver_env_var``.
|
||||||
|
"""
|
||||||
|
name = platform_name.lower()
|
||||||
|
if name in _KNOWN_DELIVERY_PLATFORMS:
|
||||||
|
return True
|
||||||
|
return bool(_plugin_cron_env_var(name))
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_home_env_var(platform_name: str) -> str:
|
||||||
|
"""Return the env var name for a platform's cron home channel.
|
||||||
|
|
||||||
|
Built-in platforms are in ``_HOME_TARGET_ENV_VARS``; plugin platforms are
|
||||||
|
resolved from the platform registry.
|
||||||
|
"""
|
||||||
|
name = platform_name.lower()
|
||||||
|
env_var = _HOME_TARGET_ENV_VARS.get(name)
|
||||||
|
if env_var:
|
||||||
|
return env_var
|
||||||
|
return _plugin_cron_env_var(name)
|
||||||
|
|
||||||
|
|
||||||
def _get_home_target_chat_id(platform_name: str) -> str:
|
def _get_home_target_chat_id(platform_name: str) -> str:
|
||||||
"""Return the configured home target chat/room ID for a delivery platform."""
|
"""Return the configured home target chat/room ID for a delivery platform."""
|
||||||
env_var = _HOME_TARGET_ENV_VARS.get(platform_name.lower())
|
env_var = _resolve_home_env_var(platform_name)
|
||||||
if not env_var:
|
if not env_var:
|
||||||
return ""
|
return ""
|
||||||
value = os.getenv(env_var, "")
|
value = os.getenv(env_var, "")
|
||||||
|
|
@ -167,7 +212,7 @@ def _get_home_target_chat_id(platform_name: str) -> str:
|
||||||
|
|
||||||
def _get_home_target_thread_id(platform_name: str) -> Optional[str]:
|
def _get_home_target_thread_id(platform_name: str) -> Optional[str]:
|
||||||
"""Return the optional thread/topic ID for a platform home target."""
|
"""Return the optional thread/topic ID for a platform home target."""
|
||||||
env_var = _HOME_TARGET_ENV_VARS.get(platform_name.lower())
|
env_var = _resolve_home_env_var(platform_name)
|
||||||
if not env_var:
|
if not env_var:
|
||||||
return None
|
return None
|
||||||
value = os.getenv(f"{env_var}_THREAD_ID", "").strip()
|
value = os.getenv(f"{env_var}_THREAD_ID", "").strip()
|
||||||
|
|
@ -178,6 +223,24 @@ def _get_home_target_thread_id(platform_name: str) -> Optional[str]:
|
||||||
return value or None
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_home_target_platforms():
|
||||||
|
"""Iterate built-in + plugin platform names that expose a home channel.
|
||||||
|
|
||||||
|
Used by the ``deliver=origin`` fallback when the job has no origin.
|
||||||
|
"""
|
||||||
|
for name in _HOME_TARGET_ENV_VARS:
|
||||||
|
yield name
|
||||||
|
try:
|
||||||
|
from hermes_cli.plugins import discover_plugins
|
||||||
|
discover_plugins() # idempotent
|
||||||
|
from gateway.platform_registry import platform_registry
|
||||||
|
for entry in platform_registry.plugin_entries():
|
||||||
|
if entry.cron_deliver_env_var and entry.name not in _HOME_TARGET_ENV_VARS:
|
||||||
|
yield entry.name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[dict]:
|
def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[dict]:
|
||||||
"""Resolve one concrete auto-delivery target for a cron job."""
|
"""Resolve one concrete auto-delivery target for a cron job."""
|
||||||
|
|
||||||
|
|
@ -195,7 +258,7 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d
|
||||||
}
|
}
|
||||||
# Origin missing (e.g. job created via API/script) — try each
|
# Origin missing (e.g. job created via API/script) — try each
|
||||||
# platform's home channel as a fallback instead of silently dropping.
|
# platform's home channel as a fallback instead of silently dropping.
|
||||||
for platform_name in _HOME_TARGET_ENV_VARS:
|
for platform_name in _iter_home_target_platforms():
|
||||||
chat_id = _get_home_target_chat_id(platform_name)
|
chat_id = _get_home_target_chat_id(platform_name)
|
||||||
if chat_id:
|
if chat_id:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -251,7 +314,7 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d
|
||||||
"thread_id": origin.get("thread_id"),
|
"thread_id": origin.get("thread_id"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if platform_name.lower() not in _KNOWN_DELIVERY_PLATFORMS:
|
if not _is_known_delivery_platform(platform_name):
|
||||||
return None
|
return None
|
||||||
chat_id = _get_home_target_chat_id(platform_name)
|
chat_id = _get_home_target_chat_id(platform_name)
|
||||||
if not chat_id:
|
if not chat_id:
|
||||||
|
|
|
||||||
|
|
@ -1664,7 +1664,10 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||||
# Registry-driven enable for plugin platforms. Built-ins have explicit
|
# Registry-driven enable for plugin platforms. Built-ins have explicit
|
||||||
# blocks above; plugins expose check_fn() which is the single source of
|
# blocks above; plugins expose check_fn() which is the single source of
|
||||||
# truth for "are my env vars set?". When it returns True, ensure the
|
# truth for "are my env vars set?". When it returns True, ensure the
|
||||||
# platform is enabled so start() will create its adapter.
|
# platform is enabled so start() will create its adapter. Plugins that
|
||||||
|
# need to seed ``PlatformConfig.extra`` from env vars (e.g. Google Chat's
|
||||||
|
# project_id / subscription_name) can supply ``env_enablement_fn`` on
|
||||||
|
# their PlatformEntry — called here BEFORE adapter construction.
|
||||||
try:
|
try:
|
||||||
from hermes_cli.plugins import discover_plugins
|
from hermes_cli.plugins import discover_plugins
|
||||||
discover_plugins() # idempotent
|
discover_plugins() # idempotent
|
||||||
|
|
@ -1680,5 +1683,31 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||||
if platform not in config.platforms:
|
if platform not in config.platforms:
|
||||||
config.platforms[platform] = PlatformConfig()
|
config.platforms[platform] = PlatformConfig()
|
||||||
config.platforms[platform].enabled = True
|
config.platforms[platform].enabled = True
|
||||||
|
# Seed extras from env if the plugin opted in.
|
||||||
|
if entry.env_enablement_fn is not None:
|
||||||
|
try:
|
||||||
|
seed = entry.env_enablement_fn()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
"env_enablement_fn for %s raised: %s", entry.name, e
|
||||||
|
)
|
||||||
|
seed = None
|
||||||
|
if isinstance(seed, dict) and seed:
|
||||||
|
# Extract the home_channel dict (if provided) so we wire it
|
||||||
|
# up as a proper HomeChannel dataclass. Everything else is
|
||||||
|
# merged into ``extra``.
|
||||||
|
home = seed.pop("home_channel", None)
|
||||||
|
config.platforms[platform].extra.update(seed)
|
||||||
|
if isinstance(home, dict) and home.get("chat_id"):
|
||||||
|
config.platforms[platform].home_channel = HomeChannel(
|
||||||
|
platform=platform,
|
||||||
|
chat_id=str(home["chat_id"]),
|
||||||
|
name=str(home.get("name") or "Home"),
|
||||||
|
thread_id=(
|
||||||
|
str(home["thread_id"])
|
||||||
|
if home.get("thread_id")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Plugin platform enable pass failed: %s", e)
|
logger.debug("Plugin platform enable pass failed: %s", e)
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,21 @@ class PlatformEntry:
|
||||||
# Do not use markdown."). Empty string = no hint.
|
# Do not use markdown."). Empty string = no hint.
|
||||||
platform_hint: str = ""
|
platform_hint: str = ""
|
||||||
|
|
||||||
|
# ── Env-driven auto-configuration ──
|
||||||
|
# Optional: read env vars, return a dict of ``PlatformConfig.extra`` fields
|
||||||
|
# to seed when the platform is auto-enabled. Called during
|
||||||
|
# ``_apply_env_overrides`` BEFORE the adapter is constructed, so
|
||||||
|
# ``gateway status`` etc. can reflect env-only configuration without
|
||||||
|
# instantiating the adapter. Return ``None`` (or an empty dict) to skip.
|
||||||
|
# Signature: () -> Optional[dict[str, Any]]
|
||||||
|
env_enablement_fn: Optional[Callable[[], Optional[dict]]] = None
|
||||||
|
|
||||||
|
# Optional: home-channel env var name for cron/notification delivery
|
||||||
|
# (e.g. ``"IRC_HOME_CHANNEL"``). When set, ``cron.scheduler`` treats this
|
||||||
|
# platform as a valid ``deliver=<name>`` target and reads the env var to
|
||||||
|
# resolve the default chat/room ID. Empty = no cron home-channel support.
|
||||||
|
cron_deliver_env_var: str = ""
|
||||||
|
|
||||||
|
|
||||||
class PlatformRegistry:
|
class PlatformRegistry:
|
||||||
"""Central registry of platform adapters.
|
"""Central registry of platform adapters.
|
||||||
|
|
|
||||||
|
|
@ -258,13 +258,18 @@ def _ensure_ssl_certs() -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
def _home_target_env_var(platform_name: str) -> str:
|
def _home_target_env_var(platform_name: str) -> str:
|
||||||
"""Return the configured home-target env var for a platform."""
|
"""Return the configured home-target env var for a platform.
|
||||||
from cron.scheduler import _HOME_TARGET_ENV_VARS
|
|
||||||
|
|
||||||
return _HOME_TARGET_ENV_VARS.get(
|
Consults built-in ``_HOME_TARGET_ENV_VARS`` first, then the plugin
|
||||||
platform_name.lower(),
|
registry via ``cron.scheduler._resolve_home_env_var``, then falls back
|
||||||
f"{platform_name.upper()}_HOME_CHANNEL",
|
to ``<PLATFORM>_HOME_CHANNEL`` for unknown names.
|
||||||
)
|
"""
|
||||||
|
from cron.scheduler import _resolve_home_env_var
|
||||||
|
|
||||||
|
resolved = _resolve_home_env_var(platform_name)
|
||||||
|
if resolved:
|
||||||
|
return resolved
|
||||||
|
return f"{platform_name.upper()}_HOME_CHANNEL"
|
||||||
|
|
||||||
|
|
||||||
def _home_thread_env_var(platform_name: str) -> str:
|
def _home_thread_env_var(platform_name: str) -> str:
|
||||||
|
|
|
||||||
|
|
@ -4961,3 +4961,100 @@ def _inject_profile_env_vars() -> None:
|
||||||
|
|
||||||
# Eagerly inject so that OPTIONAL_ENV_VARS is fully populated at import time.
|
# Eagerly inject so that OPTIONAL_ENV_VARS is fully populated at import time.
|
||||||
_inject_profile_env_vars()
|
_inject_profile_env_vars()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Platform-plugin env var injection ────────────────────────────────────────
|
||||||
|
# Bundled platform plugins under ``plugins/platforms/*/plugin.yaml`` declare
|
||||||
|
# their required env vars via ``requires_env``. This mirror of
|
||||||
|
# ``_inject_profile_env_vars`` surfaces them in ``hermes config`` UI so users
|
||||||
|
# can configure Teams / IRC / Google Chat without the core repo ever needing
|
||||||
|
# to know they exist.
|
||||||
|
#
|
||||||
|
# Each ``requires_env`` entry may be a bare string (name only) or a dict:
|
||||||
|
#
|
||||||
|
# requires_env:
|
||||||
|
# - TEAMS_CLIENT_ID # minimal
|
||||||
|
# - name: TEAMS_CLIENT_SECRET # rich
|
||||||
|
# description: "Teams bot client secret"
|
||||||
|
# url: "https://portal.azure.com/"
|
||||||
|
# password: true
|
||||||
|
# prompt: "Teams client secret"
|
||||||
|
#
|
||||||
|
# An optional ``optional_env`` block surfaces non-required vars the same way
|
||||||
|
# (e.g. allowlist, home channel).
|
||||||
|
|
||||||
|
_platform_plugin_env_vars_injected = False
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_platform_plugin_env_vars() -> None:
|
||||||
|
"""Populate OPTIONAL_ENV_VARS from bundled platform plugin manifests.
|
||||||
|
|
||||||
|
Called once at module load time. Idempotent — repeated calls are no-ops.
|
||||||
|
Failures are swallowed so a malformed plugin.yaml can't break CLI import.
|
||||||
|
"""
|
||||||
|
global _platform_plugin_env_vars_injected
|
||||||
|
if _platform_plugin_env_vars_injected:
|
||||||
|
return
|
||||||
|
_platform_plugin_env_vars_injected = True
|
||||||
|
try:
|
||||||
|
import yaml # type: ignore
|
||||||
|
|
||||||
|
# Resolve the bundled plugins dir from this file's location so the
|
||||||
|
# injector works regardless of CWD.
|
||||||
|
repo_root = Path(__file__).resolve().parents[1]
|
||||||
|
platforms_dir = repo_root / "plugins" / "platforms"
|
||||||
|
if not platforms_dir.is_dir():
|
||||||
|
return
|
||||||
|
for child in platforms_dir.iterdir():
|
||||||
|
if not child.is_dir():
|
||||||
|
continue
|
||||||
|
manifest_path = child / "plugin.yaml"
|
||||||
|
if not manifest_path.exists():
|
||||||
|
manifest_path = child / "plugin.yml"
|
||||||
|
if not manifest_path.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||||
|
manifest = yaml.safe_load(f) or {}
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
label = manifest.get("label") or manifest.get("name") or child.name
|
||||||
|
# Merge required + optional env var declarations.
|
||||||
|
entries = list(manifest.get("requires_env") or [])
|
||||||
|
entries.extend(manifest.get("optional_env") or [])
|
||||||
|
for entry in entries:
|
||||||
|
if isinstance(entry, str):
|
||||||
|
name = entry
|
||||||
|
meta: dict = {}
|
||||||
|
elif isinstance(entry, dict) and entry.get("name"):
|
||||||
|
name = entry["name"]
|
||||||
|
meta = entry
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if name in OPTIONAL_ENV_VARS:
|
||||||
|
continue # hardcoded entry wins (back-compat)
|
||||||
|
# Heuristic: anything named *TOKEN, *SECRET, *KEY, *PASSWORD
|
||||||
|
# is a password field unless explicitly overridden.
|
||||||
|
name_upper = name.upper()
|
||||||
|
is_secret = bool(meta.get("password") or meta.get("secret"))
|
||||||
|
if not is_secret and not meta.get("password") is False:
|
||||||
|
is_secret = any(
|
||||||
|
name_upper.endswith(suf)
|
||||||
|
for suf in ("_TOKEN", "_SECRET", "_KEY", "_PASSWORD", "_JSON")
|
||||||
|
)
|
||||||
|
OPTIONAL_ENV_VARS[name] = {
|
||||||
|
"description": (
|
||||||
|
meta.get("description")
|
||||||
|
or f"{label} configuration"
|
||||||
|
),
|
||||||
|
"prompt": meta.get("prompt") or name,
|
||||||
|
"url": meta.get("url") or None,
|
||||||
|
"password": is_secret,
|
||||||
|
"category": meta.get("category") or "messaging",
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Eagerly inject so that platform plugin env vars show up in the setup wizard.
|
||||||
|
_inject_platform_plugin_env_vars()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue