hermes-agent/tools/lazy_deps.py
Teknium 72b5dd8658
fix(update): refresh lazy-installed backends on hermes update (#25766)
Pyproject's [all] extra was slimmed down in May 2026 — ~20 optional
backends moved to tools/lazy_deps.py and only install on first use.
hermes update runs uv pip install -e .[all] which doesn't touch any of
them, so pin bumps in LAZY_DEPS (CVE response, transitive fixes) were
silently ignored on already-activated backends.

Two changes:

1. _is_satisfied() now parses the spec and checks the installed version
   against the constraint via packaging.specifiers. Previously it
   returned True the moment the package name was importable, which made
   ensure() a name-presence gate rather than a version-pin gate.

2. New active_features() / refresh_active_features() pair: lists every
   feature with at least one of its packages currently installed, then
   re-runs ensure() on each. Refresh is invoked at the end of
   _cmd_update_impl, right after the [all] install completes. Cold
   backends (never activated) stay quiet — no churn for them.

Output during update is one summary block:
  → Refreshing 4 active lazy backend(s)...
    ↑ 1 refreshed: provider.anthropic
    ✓ 3 already current
or
    ⚠ memory.honcho failed to refresh: <pip stderr>

Failures never raise out of update — backends keep their previously-
installed version and we tell the user to rerun once upstream is fixed.
security.allow_lazy_installs=false is honored: features get marked
"skipped" with the reason shown.

Tests: 18 new unit tests covering version-aware satisfaction (exact pin,
range, extras blocks, missing package, malformed spec), active feature
discovery, and refresh status reporting. All 61 lazy_deps tests pass.
2026-05-14 08:03:40 -07:00

603 lines
23 KiB
Python

"""
Lazy dependency installer for opt-in Hermes Agent backends.
Many Hermes features (Mistral TTS, ElevenLabs TTS, Honcho memory, Bedrock,
Slack, Matrix, etc.) require Python packages that not every user needs. The
historical approach was to bundle them all under ``pyproject.toml`` extras
(``hermes-agent[all]``) and install them eagerly at setup time. That has
two problems:
1. **Fragility.** When one extra's transitive dependency becomes
unavailable on PyPI (quarantined for malware, yanked, broken upload),
the *entire* ``[all]`` resolve fails and fresh installs silently fall
back to a stripped tier — losing 10+ unrelated extras at once.
2. **Bloat.** A user who only ever talks to one provider pulls hundreds
of packages they will never import.
The lazy-install pattern fixes both. Backends call :func:`ensure` at the
top of their first-import path. If the deps are missing, ``ensure`` checks
the ``security.allow_lazy_installs`` config flag (default true) and runs
a venv-scoped pip install. If the user has explicitly disabled lazy
installs, ``ensure`` raises :class:`FeatureUnavailable` with a clear
remediation hint pointing at ``hermes tools`` or the manual pip command.
Security model:
* **Venv-scoped only.** Installs target ``sys.executable`` in the active
venv. We never touch the system Python.
* **PyPI by package name only.** Specs may be ``"package>=1.0,<2"`` etc.
We do NOT support ``--index-url`` overrides, ``git+https://``, file:
paths, or any other input that could be hijacked by a malicious config.
* **Allowlist.** Only specs that appear in :data:`LAZY_DEPS` can be
installed via this path. A typo in feature name doesn't get the user
install-anything semantics.
* **Opt-out.** Setting ``security.allow_lazy_installs: false`` in
``config.yaml`` disables runtime installs. Users in restricted networks
or strict security postures can pin themselves to whatever was installed
at setup time.
* **Offline detection.** If the install fails (offline, mirror down,
PyPI 404 / quarantine), we surface the failure as
:class:`FeatureUnavailable` with the actual pip stderr — no silent
retries, no caching of bad state.
Adding a new backend:
1. Add an entry to :data:`LAZY_DEPS` with the package specs.
2. At the top of the backend module's import path, call
``ensure("feature.name")`` inside a try/except that converts
:class:`FeatureUnavailable` to a useful runtime error.
"""
from __future__ import annotations
import logging
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
# =============================================================================
# Allowlist of lazy-installable backends.
#
# Keys are dot-separated feature names ("namespace.backend"). Values are
# tuples of pip-installable specs that match the corresponding extra in
# pyproject.toml. The framework enforces that only specs from this map
# can flow into the pip install command.
# =============================================================================
LAZY_DEPS: dict[str, tuple[str, ...]] = {
# ─── Inference providers ───────────────────────────────────────────────
# Native Anthropic SDK — needed when provider=anthropic (not via
# OpenRouter / aggregators which use the openai SDK).
"provider.anthropic": ("anthropic==0.86.0",),
# AWS Bedrock provider
"provider.bedrock": ("boto3==1.42.89",),
# ─── Web search backends ───────────────────────────────────────────────
"search.exa": ("exa-py==2.10.2",),
"search.firecrawl": ("firecrawl-py==4.17.0",),
"search.parallel": ("parallel-web==0.4.2",),
# ─── TTS providers ─────────────────────────────────────────────────────
# Pinned to exact versions to match pyproject.toml's no-ranges policy
# (see comment at top of [project.dependencies]). When bumping, update
# both this map AND the corresponding extra in pyproject.toml.
#
# NOTE: tts.mistral / stt.mistral entries are intentionally absent —
# the `mistralai` PyPI project is quarantined as of 2026-05-12 (Mini
# Shai-Hulud worm). Re-add when PyPI restores a clean release; see
# comment in pyproject.toml above the (removed) `mistral` extra for
# the full restoration checklist.
"tts.edge": ("edge-tts==7.2.7",),
"tts.elevenlabs": ("elevenlabs==1.59.0",),
# ─── Speech-to-text providers ──────────────────────────────────────────
"stt.faster_whisper": (
"faster-whisper==1.2.1",
"sounddevice==0.5.5",
"numpy==2.4.3",
),
# ─── Image generation backends ─────────────────────────────────────────
"image.fal": ("fal-client==0.13.1",),
# ─── Memory providers ──────────────────────────────────────────────────
"memory.honcho": ("honcho-ai==2.0.1",),
"memory.hindsight": ("hindsight-client==0.6.1",),
# ─── Messaging platforms (lazy-installable on demand) ──────────────────
"platform.telegram": ("python-telegram-bot[webhooks]==22.6",),
"platform.discord": ("discord.py[voice]==2.7.1",),
"platform.slack": (
"slack-bolt==1.27.0",
"slack-sdk==3.40.1",
"aiohttp==3.13.3",
),
"platform.matrix": (
"mautrix[encryption]==0.21.0",
"Markdown==3.10.2",
"aiosqlite==0.22.1",
"asyncpg==0.31.0",
"aiohttp-socks==0.11.0",
),
"platform.dingtalk": (
"dingtalk-stream==0.24.3",
"alibabacloud-dingtalk==2.2.42",
"qrcode==7.4.2",
),
"platform.feishu": (
"lark-oapi==1.5.3",
"qrcode==7.4.2",
),
# ─── Terminal backends ─────────────────────────────────────────────────
"terminal.modal": ("modal==1.3.4",),
"terminal.daytona": ("daytona==0.155.0",),
"terminal.vercel": ("vercel==0.5.7",),
# ─── Skills ────────────────────────────────────────────────────────────
"skill.google_workspace": (
"google-api-python-client==2.194.0",
"google-auth-oauthlib==1.3.1",
"google-auth-httplib2==0.3.1",
),
"skill.youtube": ("youtube-transcript-api==1.2.4",),
# ─── Tools ─────────────────────────────────────────────────────────────
# ACP adapter (VS Code / Zed / JetBrains integration)
"tool.acp": ("agent-client-protocol==0.9.0",),
# Dashboard (`hermes dashboard`)
"tool.dashboard": (
"fastapi==0.133.1",
"uvicorn[standard]==0.41.0",
),
}
# Conservative regex for spec validation — package name plus optional
# version range. Reject anything that looks like a URL, file path, or shell
# metacharacter.
_SAFE_SPEC = re.compile(
r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*" # package name
r"(?:\[[A-Za-z0-9_,\-]+\])?" # optional [extras]
r"(?:[<>=!~]=?[A-Za-z0-9_.\-+,*<>=!~]+)?" # optional version specifier
r"$"
)
class FeatureUnavailable(RuntimeError):
"""A lazily-installable feature is missing and cannot be made available.
Either the deps were never installed and the user has disabled lazy
installs, or the install attempt failed.
"""
def __init__(self, feature: str, missing: tuple[str, ...], reason: str):
self.feature = feature
self.missing = missing
self.reason = reason
super().__init__(self._format())
def _format(self) -> str:
spec_list = " ".join(repr(s) for s in self.missing)
return (
f"Feature {self.feature!r} unavailable: {self.reason}. "
f"To enable manually: uv pip install {spec_list} "
f"(or: pip install {spec_list})."
)
@dataclass(frozen=True)
class _InstallResult:
success: bool
stdout: str
stderr: str
# =============================================================================
# Internals
# =============================================================================
def _allow_lazy_installs() -> bool:
"""Return the ``security.allow_lazy_installs`` config flag.
Defaults to True. If config is unreadable we fail open (allow), because
refusing to install would lock people out of their own backends; the
decision to block is an explicit user opt-in.
"""
if os.environ.get("HERMES_DISABLE_LAZY_INSTALLS") == "1":
return False
try:
from hermes_cli.config import load_config
cfg = load_config()
except Exception:
return True
sec = cfg.get("security") or {}
val = sec.get("allow_lazy_installs", True)
return bool(val)
def _spec_is_safe(spec: str) -> bool:
"""Reject pip specs that contain URLs, paths, or shell metacharacters."""
if not spec or len(spec) > 200:
return False
if any(ch in spec for ch in (";", "|", "&", "`", "$", "\n", "\r", "\t", "\\")):
return False
if spec.startswith(("-", "/", ".")) or "://" in spec or "@" in spec:
return False
return bool(_SAFE_SPEC.match(spec))
def _pkg_name_from_spec(spec: str) -> str:
"""Extract the bare package name from a pip spec.
``"slack-bolt>=1.18.0,<2"`` → ``"slack-bolt"``
``"mautrix[encryption]>=0.20"`` → ``"mautrix"``
"""
m = re.match(r"^([A-Za-z0-9_][A-Za-z0-9_.\-]*)", spec)
return m.group(1) if m else spec
def _specifier_from_spec(spec: str) -> str:
"""Extract just the version-specifier portion of a pip spec.
``"honcho-ai==2.0.1"`` → ``"==2.0.1"``
``"mautrix[encryption]>=0.20,<1"`` → ``">=0.20,<1"``
``"package"`` → ``""`` (no version constraint)
"""
# Strip the package name + optional [extras] block.
m = re.match(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*(?:\[[A-Za-z0-9_,\-]+\])?", spec)
if not m:
return ""
return spec[m.end():]
def _is_satisfied(spec: str) -> bool:
"""Is ``spec`` already satisfied in the current env?
Checks both presence AND version. If the package is installed at a
version outside the spec's range, returns False so the caller will
upgrade/downgrade to the pinned version. This is what makes
``hermes update`` propagate pin bumps in :data:`LAZY_DEPS` to already-
installed backends instead of silently leaving stale versions in place.
If ``packaging`` is unavailable for any reason (it's a transitive of
pip so this should never happen), we fall back to a presence-only check
so we err on the side of "don't churn".
"""
pkg = _pkg_name_from_spec(spec)
try:
from importlib.metadata import PackageNotFoundError, version
except ImportError:
return False
try:
installed = version(pkg)
except PackageNotFoundError:
return False
except Exception:
return False
spec_tail = _specifier_from_spec(spec)
if not spec_tail:
# Bare ``"package"`` — no version constraint, presence is enough.
return True
try:
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
except ImportError:
# packaging unavailable — fall back to "installed counts as satisfied".
return True
try:
return Version(installed) in SpecifierSet(spec_tail)
except (InvalidSpecifier, InvalidVersion, Exception):
# Malformed spec or installed version we can't parse — don't churn.
return True
def _is_present(spec: str) -> bool:
"""Cheap presence-only check (package name installed at any version).
Used by :func:`active_features` to detect backends the user has
previously activated, regardless of whether the version pin moved.
"""
pkg = _pkg_name_from_spec(spec)
try:
from importlib.metadata import PackageNotFoundError, version
except ImportError:
return False
try:
version(pkg)
return True
except PackageNotFoundError:
return False
except Exception:
return False
def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _InstallResult:
"""Install ``specs`` into the active venv using uv → pip → ensurepip ladder.
Mirrors the strategy in ``hermes_cli.tools_config._pip_install`` but
kept independent here so this module has no CLI dependency.
"""
if not specs:
return _InstallResult(True, "", "")
venv_root = Path(sys.executable).parent.parent
uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)}
# Tier 1: uv (preferred — fast, doesn't need pip in the venv)
uv_bin = shutil.which("uv")
if uv_bin:
try:
r = subprocess.run(
[uv_bin, "pip", "install", *specs],
capture_output=True, text=True, timeout=timeout, env=uv_env,
)
if r.returncode == 0:
return _InstallResult(True, r.stdout or "", r.stderr or "")
logger.debug("uv pip install failed: %s", r.stderr)
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
logger.debug("uv invocation failed: %s", e)
# Tier 2: python -m pip (with ensurepip bootstrap if needed)
pip_cmd = [sys.executable, "-m", "pip"]
try:
probe = subprocess.run(
pip_cmd + ["--version"],
capture_output=True, text=True, timeout=15,
)
if probe.returncode != 0:
raise FileNotFoundError("pip not in venv")
except (subprocess.TimeoutExpired, FileNotFoundError):
try:
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
capture_output=True, text=True, timeout=120, check=True,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
return _InstallResult(False, "",
f"pip not available and ensurepip failed: {e}")
try:
r = subprocess.run(
pip_cmd + ["install", *specs],
capture_output=True, text=True, timeout=timeout,
)
return _InstallResult(r.returncode == 0, r.stdout or "", r.stderr or "")
except subprocess.TimeoutExpired as e:
return _InstallResult(False, "", f"pip install timed out: {e}")
except Exception as e:
return _InstallResult(False, "", f"pip install failed: {e}")
# =============================================================================
# Public API
# =============================================================================
def feature_specs(feature: str) -> tuple[str, ...]:
"""Return the registered specs for a feature, or raise KeyError."""
if feature not in LAZY_DEPS:
raise KeyError(f"Unknown lazy feature: {feature!r}")
return LAZY_DEPS[feature]
def feature_missing(feature: str) -> tuple[str, ...]:
"""Return the subset of specs for ``feature`` not currently installed."""
return tuple(s for s in feature_specs(feature) if not _is_satisfied(s))
def ensure(feature: str, *, prompt: bool = True) -> None:
"""Make sure all packages for ``feature`` are importable.
If they're missing, attempts to install them in the active venv. Raises
:class:`FeatureUnavailable` if the user has disabled lazy installs or
if the install attempt fails.
``prompt``: when True (default) and stdin is a TTY, asks the user to
confirm before installing. Non-interactive callers (gateway, cron,
batch) get prompt=False and skip the confirmation — config flag is
the gate in that case.
"""
if feature not in LAZY_DEPS:
raise FeatureUnavailable(
feature, (), f"feature {feature!r} not in LAZY_DEPS allowlist"
)
missing = feature_missing(feature)
if not missing:
return
# Validate every spec against the allowlist + safety regex. Belt and
# braces — the keys-in-LAZY_DEPS check above already constrains this.
for spec in missing:
if not _spec_is_safe(spec):
raise FeatureUnavailable(
feature, missing,
f"refusing to install unsafe spec {spec!r}"
)
if not _allow_lazy_installs():
raise FeatureUnavailable(
feature, missing,
"lazy installs disabled (security.allow_lazy_installs=false)"
)
if prompt and sys.stdin.isatty() and sys.stdout.isatty():
spec_list = ", ".join(missing)
try:
answer = input(
f"\nFeature {feature!r} requires: {spec_list}\n"
f"Install into the active venv now? [Y/n] "
).strip().lower()
except (EOFError, KeyboardInterrupt):
answer = "n"
if answer and answer not in ("y", "yes"):
raise FeatureUnavailable(
feature, missing, "user declined install at prompt"
)
logger.info("Lazy-installing %s for feature %r", " ".join(missing), feature)
result = _venv_pip_install(missing)
if not result.success:
# Surface the actual pip error so the user can debug PyPI-side
# issues (404 quarantine, network down, etc.).
snippet = (result.stderr or result.stdout or "").strip()
if snippet:
# Clip to a readable size — pip can dump pages of resolution traces.
snippet = snippet[-2000:]
raise FeatureUnavailable(
feature, missing,
f"pip install failed: {snippet or 'no error output'}"
)
# Verify post-install. importlib.metadata caches per-process, so if we
# just installed something the cache may not see it without a refresh.
try:
import importlib.metadata as _md
if hasattr(_md, "_cache_clear"):
_md._cache_clear() # type: ignore[attr-defined]
except Exception:
pass
still_missing = feature_missing(feature)
if still_missing:
raise FeatureUnavailable(
feature, still_missing,
"install reported success but packages still not importable "
"(may require Python restart)"
)
logger.info("Lazy install complete for feature %r", feature)
def is_available(feature: str) -> bool:
"""Return True if the feature's deps are already satisfied."""
if feature not in LAZY_DEPS:
return False
return not feature_missing(feature)
def feature_install_command(feature: str) -> Optional[str]:
"""Return the ``pip install`` command a user could run manually, or None."""
if feature not in LAZY_DEPS:
return None
specs = LAZY_DEPS[feature]
return "uv pip install " + " ".join(repr(s) for s in specs)
def active_features() -> list[str]:
"""Return the list of features the user has ever lazy-installed.
A feature counts as "active" if at least one of its declared packages
is currently installed in the venv (presence check, ignoring version).
Features the user has never enabled stay quiet.
Used by ``hermes update`` to figure out which lazy backends need a
refresh pass when pins move in :data:`LAZY_DEPS`.
"""
active = []
for feature, specs in LAZY_DEPS.items():
if any(_is_present(s) for s in specs):
active.append(feature)
return active
def refresh_active_features(*, prompt: bool = False) -> dict[str, str]:
"""Re-run ``ensure`` for every feature the user has previously activated.
Returns a ``{feature: status}`` map where status is one of:
``"current"`` — pins already satisfied, no install run
``"refreshed"`` — pins were stale, reinstall succeeded
``"failed: <reason>"`` — install attempt failed; caller decides
whether to surface it (we don't raise)
``"skipped: <reason>"`` — gated off (config flag, user decline)
Intended for ``hermes update``. Never raises; lazy-install failures
here must not block the rest of the update flow.
"""
results: dict[str, str] = {}
for feature in active_features():
missing = feature_missing(feature)
if not missing:
results[feature] = "current"
continue
try:
ensure(feature, prompt=prompt)
results[feature] = "refreshed"
except FeatureUnavailable as e:
# Distinguish "user opted out" from "install failed" so the
# update command can render the right message.
if "lazy installs disabled" in str(e) or "declined" in str(e):
results[feature] = f"skipped: {e.reason}"
else:
results[feature] = f"failed: {e.reason}"
except Exception as e:
results[feature] = f"failed: {e}"
return results
def ensure_and_bind(
feature: str,
importer: Callable[[], dict[str, Any]],
target_globals: dict,
*,
prompt: bool = False,
) -> bool:
"""Ensure a feature is installed, then rebind names into the caller's globals.
Combines :func:`ensure` with a post-install import step that rebinds
module-level names. This eliminates the error-prone pattern of manually
listing every global that needs updating after lazy-install.
``importer`` is a zero-arg callable that returns a dict of
``{name: value}`` for all symbols the caller needs rebound. It is called
only after :func:`ensure` succeeds (or if the packages are already
installed).
Returns True on success, False if deps couldn't be installed or imported.
Example usage in a platform adapter::
def check_slack_requirements() -> bool:
if SLACK_AVAILABLE:
return True
def _import():
from slack_bolt.async_app import AsyncApp
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
from slack_sdk.web.async_client import AsyncWebClient
import aiohttp
return {
"AsyncApp": AsyncApp,
"AsyncSocketModeHandler": AsyncSocketModeHandler,
"AsyncWebClient": AsyncWebClient,
"aiohttp": aiohttp,
"SLACK_AVAILABLE": True,
}
return ensure_and_bind("platform.slack", _import, globals(), prompt=False)
"""
try:
ensure(feature, prompt=prompt)
except (FeatureUnavailable, Exception):
return False
try:
bindings = importer()
except ImportError:
return False
target_globals.update(bindings)
return True