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.
This commit is contained in:
Teknium 2026-05-14 08:03:40 -07:00 committed by GitHub
parent 436a0a271e
commit 72b5dd8658
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 362 additions and 5 deletions

View file

@ -248,12 +248,69 @@ def _pkg_name_from_spec(spec: str) -> str:
return m.group(1) if m else spec
def _is_satisfied(spec: str) -> bool:
"""Best-effort check: is ``spec`` already satisfied in the current env?
def _specifier_from_spec(spec: str) -> str:
"""Extract just the version-specifier portion of a pip spec.
We don't enforce the version range — if the package is importable
we assume the user knows what they're doing. This matches how the
lazy-import sites already behave.
``"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:
@ -442,6 +499,57 @@ def feature_install_command(feature: str) -> Optional[str]:
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]],