Skip to content

fix(tools,cli): never downgrade lazy deps at runtime; truthful memory status - #80517

Open
Crqptx wants to merge 4 commits into
NousResearch:mainfrom
Crqptx:fix/lazy-deps-runtime-no-downgrade
Open

fix(tools,cli): never downgrade lazy deps at runtime; truthful memory status#80517
Crqptx wants to merge 4 commits into
NousResearch:mainfrom
Crqptx:fix/lazy-deps-runtime-no-downgrade

Conversation

@Crqptx

@Crqptx Crqptx commented Aug 6, 2026

Copy link
Copy Markdown

What does this PR do?

Fixes two related defects in the lazy-deps / memory-status path — #80390 is the root cause, #80388 its visible symptom:

#80390 — auto-downgrade on every retain. tools/lazy_deps.py pinned hindsight-client==0.6.1 and the strict _is_satisfied() treated any off-pin install as missing. An operator-installed, working, NEWER hindsight stack was reported missing, and ensure() attempted an unattended downgrade to the pin on every retain. This is destructive: the embedded hindsight stack migrates its Postgres (pg0) data directory forward with each release — once 0.8.x has run, the DB cannot go back to 0.6.1. The == pin was also the wrong shape for this backend (reporter suggestion #4, validated by the reporter on 0.8.6): hindsight ships outside the image's lockfile on a moving PyPI train, so the pin is now a floor (hindsight-client>=0.6.1) — absent/below-pin installs stay flagged, but ensure() installs the current train instead of stale 0.6.1, and the status hint no longer coaches toward the broken state.

#80388 — dishonest status. hermes memory status checked only the provider's static config, so it printed available ✓ while every retain was dropped on a version/install check the runtime enforces.

Approach — explicit runtime semantics, strict preserved. Rather than rewriting the strict predicate globally, this PR opts specific runtime paths into never-downgrade behavior:

  • feature_missing(feature, *, runtime=False)strict by default: byte-identical behavior for every caller that doesn't opt in (matrix adapter, doctor). runtime=True applies floor semantics via _is_satisfied_runtime: strict check first (same cost on the hot path), then installed ≥ floor (exclusive > bounds are honored — >1.0 with 1.0.0 installed is a genuine mismatch, an upgrade never a downgrade); a spec with no parseable floor means "installed ⇒ satisfied" (don't churn).
  • ensure() uses runtime=True for the pre-check AND post-install verify — newer installs are never touched, below-pin installs still upgrade (pin propagation intact), absent packages still install, install failure still raises FeatureUnavailable.
  • refresh_active_features() pre-checks with the same runtime semantics as ensure() (feature_missing(feature, runtime=True)): a newer-than-pin install is reported "current" — never "refreshed", which would claim a reinstall that never ran — while a genuinely below-pin install is still flagged and upgraded, so pin-bump propagation is preserved. A downgrade is impossible anywhere.
  • is_available() becomes a runtime-readiness predicate — the wake_word readiness path (tools/wake_word.py:851/877/901) never disables a working newer install in a sealed env.
  • _describe_missing() — prompts distinguish "absent" from "installed at a different version".
  • hermes memory status now uses the exact runtime predicate (_runtime_lazy_missingfeature_missing(f"memory.{provider}", runtime=True)), so status and runtime cannot disagree by construction. Healthy line byte-identical; env-var checklist only on static failure. Missing deps are listed with the canonical install hint from feature_install_command().

Note on competing PR #80496: @webtecnica's fix(memory): never auto-downgrade installed hindsight; honest status predicate fixed the same two issues with a different approach — rewriting _is_satisfied() globally so newer-than-pin counts as satisfied, plus changing the hindsight pin to >=0.6.1. That fix was valid and its tests passed. I tested both trees head-to-head (identical suite + behavioral probes, same venv) before filing: this PR preserves the strict/update-path contract for all existing callers (their version silently makes feature_missing("platform.matrix") and the hermes update refresh pre-check newer-tolerant at the check level), renders an actionable install command in status, and adds tests for the refresh-never-downgrades contract their PR doesn't cover. The final commit adopts their hindsight pin shape (>=0.6.1) — it implements the reporter's own suggestion #4 and was validated by the reporter on 0.8.6 — while every other lazy feature stays exactly pinned (==) and the CI hindsight extra is untouched. The difference that remains is architectural: ours keeps strict pin semantics for every existing caller by default, with runtime paths opting in. @webtecnica closed #80496 in favor of this PR after filing.

Related Issue

Fixes #80390, Fixes #80388

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • tools/lazy_deps.py_spec_floor_version (floor + exclusive > bounds), _is_satisfied_runtime, feature_missing(runtime=), ensure() runtime semantics, is_available() runtime, _describe_missing, hindsight floor pin (+140)
  • tests/tools/test_lazy_deps.pyTestRuntimeNeverDowngrade, 14 tests (+162)
  • tests/tools/test_lazy_deps_managed.py — fixture compat for the new kwarg (+7)
  • hermes_cli/memory_setup.py_runtime_lazy_missing + truthful status block (+66)
  • tests/hermes_cli/test_memory_status.pyTestStatusUsesRuntimePredicate, 6 tests (+93)

How to Test

  1. Reproduce lazy_deps ensure() treats a newer installed hindsight-client as missing and attempts an unattended downgrade on every retain #80390: with hindsight-client installed NEWER than the floor pin (e.g. 0.8.6 vs >=0.6.1), the old code treated it as missing and ensure() attempted to install the pin (a downgrade). Now: feature_missing("memory.hindsight", runtime=True)(), ensure() no-ops, is_available() → True.
  2. Reproduce hermes memory status reports "available ✓" while every retain fails — status and runtime use different availability predicates #80388: with lazy deps below the pin or absent, hermes memory status now prints not available ✗ + Missing runtime deps: + the specs + the canonical uv pip install 'hindsight-client>=0.6.1' hint (current train, not stale 0.6.1) — instead of available ✓.
  3. Targeted suite (change surface + integration):
    python scripts/run_tests_parallel.py -j 8 tests/tools/test_lazy_deps.py tests/tools/test_lazy_deps_managed.py tests/plugins/memory/test_memory_lazy_install.py tests/hermes_cli/test_memory_status.py tests/hermes_cli/test_memory_setup.py tests/hermes_cli/test_memory_setup_provider_arg.py tests/hermes_cli/test_lazy_refresh_venv_repair.py tests/tools/test_wake_word.py
    135 passed, 0 failed (1 skip: importorskip("numpy") — numpy absent from the dev venv).
  4. Behavior probes (never-downgrade / below-pin upgrade / absent install / strict-default preservation / status-runtime agreement): 9/9 PASS.

Checklist

Documentation & Housekeeping

  • Docstrings updated on all changed functions — or N/A
  • cli-config.yaml.example — N/A (no config keys added)
  • CONTRIBUTING.md/AGENTS.md — N/A (no architecture or workflow changes)
  • Cross-platform impact considered — scripts/check-windows-footguns.py clean on the diff (5 files); no new POSIX-only calls; Linux/macOS not tested locally (CI covers Ubuntu/Python 3.11)
  • Tool descriptions/schemas — N/A (no model-tool schema changes)

Full-suite caveat: on this Windows 11 box the full suite has pre-existing environmental failures unrelated to this change — temp-dir symlink fixtures fail with WinError 1314 (Windows symlink privilege, the documented AGENTS.md §3.4 footgun), optional-SDK tests fail on packages CI installs via extras (e.g. hindsight_client_api via --extra hindsight), and test_npm_engine trips warnings-as-errors. The targeted suite above plus CI (Linux) are the authoritative gates.

Security impact

  • Shell execution: no change to command construction. The existing uv pip install path runs specs from LAZY_DEPS constants (maintainer-controlled) via subprocess list-form; no user input reaches a shell. _describe_missing renders spec strings into a prompt message only.
  • File/path handling: none added.
  • Credentials: none touched; status output prints package specs only, never secrets.
  • Network/telemetry: none added.
  • Cross-platform: no new POSIX-only syscalls; footguns clean; tested Windows 11 only.

Screenshots / Logs

Behavior probe output and repro scripts available on request.

Crqptx added 2 commits August 6, 2026 20:05
ensure() used strict spec satisfaction, so a package installed NEWER than its pinned floor was reported missing and pip was asked to install the pin — an unattended downgrade on every retain (NousResearch#80390). The embedded hindsight stack migrates its Postgres (pg0) data directory forward per release; once 0.8.x has run, the DB cannot go back to 0.6.1.

- feature_missing(feature, *, runtime=False): strict by default — unchanged for every existing caller; runtime=True applies floor semantics via _is_satisfied_runtime (strict first, then installed >= floor; no parseable floor -> installed means satisfied, don't churn).

- ensure(): runtime semantics for pre-check and post-install verify, so a newer install is never touched, a below-pin install still upgrades, and an absent package still installs (install failure still raises FeatureUnavailable).

- refresh_active_features(): untouched — the strict pre-check still flags off-pin installs so 'hermes update' keeps propagating pin bumps, but the only installer no-ops on newer, so a downgrade is impossible anywhere.

- is_available(): runtime-readiness predicate (not feature_missing(f, runtime=True)) so the wake_word readiness path never disables a working newer install in a sealed env.

- _describe_missing(): prompt distinguishes absent from installed-at-wrong-version.

Tests: TestRuntimeNeverDowngrade (12 cases) + managed-suite fixture compat for the new kwarg.

Fixes NousResearch#80390
'hermes memory status' reported 'available ✓' whenever the provider's static config was healthy, even when its lazy deps were pinned below the floor — so status and the retain path (ensure("memory.<provider>")) could disagree and the CLI green-lit a provider whose deps would fail at runtime (NousResearch#80388).

- _runtime_lazy_missing(provider): feature_missing("memory.<provider>", runtime=True) — the exact predicate the retain path gates on; fails open for unknown providers and import errors.

- cmd_status: healthy iff plugin_ok and not runtime_missing; otherwise 'not available ✗' + 'Missing runtime deps:' with the specs and the canonical install hint from feature_install_command(). Env-var checklist only on static failure. Healthy line byte-identical.

Tests: TestStatusUsesRuntimePredicate (6 cases); _run_cmd_status gained a providers= param.

Fixes NousResearch#80388
@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard comp/tools Tool registry, model_tools, toolsets tool/memory Memory tool and memory providers area/memory Memory subsystem: store, providers, sync, background reviews dependencies Pull requests that update a dependency file sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades P3 Low — cosmetic, nice to have labels Aug 6, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related to #80496: both address the Hindsight downgrade/status failures, but this PR confines never-downgrade behavior to runtime paths while #80496 changes the shared dependency predicate.

Issue NousResearch#80390 suggested a floor pin (>=0.6.1) for the hindsight backend,
which ships outside the image lockfile on a moving PyPI train and whose
data dir ratchets forward with each release; the reporter validated the
relaxation on 0.8.6. The old ==0.6.1 pin made ensure() install the stale
0.6.1 train for below-pin users, and the status hint coached fresh users
toward the exact state the issue calls broken. Every other lazy feature
stays exactly pinned (==) and the pyproject 'hindsight' extra (CI) is
untouched. Also restore exclusive (>) floor tracking so >1.0 with 1.0.0
installed is a genuine runtime mismatch (an upgrade, never a downgrade).
@Crqptx

Crqptx commented Aug 6, 2026

Copy link
Copy Markdown
Author

Status update for maintainers: @webtecnica closed #80496 in favor of this PR ("the better implementation: strict-by-default contract preserved, runtime floor semantics opted-in explicitly"). Two later PRs (#80559, #80555) were filed against #80390/#80388 after this one and remain open; this PR already discloses both in its description. Added one commit since the original status update: refresh_active_features() now pre-checks with the same runtime semantics as ensure(), so a newer-than-pin install is reported "current" — never "refreshed" for a no-op (the refresh-never-downgrades contract now has a truthful status label too). The only outstanding item is the required CI check, which is pending because the workflow run awaits maintainer approval (first-time fork contributor gate) — no code changes outstanding.

@webtecnica

Copy link
Copy Markdown
Contributor

Thanks @Crqptx for the kind words and for documenting the comparison in the PR body — the credit flows both ways, and I'm glad the exclusive/range cases in _newer_than_allowed() helped close the floor-helper gap. The reporter's 0.8.6 validation sealing the pin change was the best possible confirmation. No co-authorship needed on my side — the fix landing cleanly under your PR is what matters. Good luck with the CI approval; the implementation is solid.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/memory Memory subsystem: store, providers, sync, background reviews comp/cli CLI entry point, hermes_cli/, setup wizard comp/tools Tool registry, model_tools, toolsets dependencies Pull requests that update a dependency file P3 Low — cosmetic, nice to have sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades tool/memory Memory tool and memory providers type/bug Something isn't working

Projects

None yet

3 participants