diff --git a/contributors/emails/hbasheer@student.42abudhabi.ae b/contributors/emails/hbasheer@student.42abudhabi.ae new file mode 100644 index 000000000000..9eb2d7a8490e --- /dev/null +++ b/contributors/emails/hbasheer@student.42abudhabi.ae @@ -0,0 +1 @@ +hxwvaa diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e0371049fdab..05c79a169510 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5189,6 +5189,8 @@ def _clear_bytecode_cache(root: Path) -> int: from hermes_cli.update_cmd import ( # noqa: F401 _add_upstream_remote, _atomic_replace_dir, + _capture_active_lazy_features, + _capture_active_tool_dependencies, _capture_head_sha, _cmd_update_check, _cmd_update_impl, @@ -5237,6 +5239,7 @@ def _clear_bytecode_cache(root: Path) -> int: _resolve_pre_update_backup_mode, _resolve_stash_selector, _restart_phase_failure_is_incomplete, + _restore_active_tool_dependencies, _restore_stashed_changes, _resume_windows_gateways_after_update, _run_logged_subprocess, diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 65e9f9e36e52..5858bf950e77 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -3303,6 +3303,46 @@ def _module_installed(module_name: str) -> bool: return False +# Python dependencies installed explicitly through ``hermes tools`` are not +# part of the managed runtime's locked ``all`` sync. A runtime replacement +# therefore needs a small, static allowlist that can be snapshotted before the +# old site-packages disappears and restored afterward. Keep these install +# arguments in sync with the corresponding ``_run_post_setup`` branches. +_RESTORABLE_PYTHON_TOOL_DEPENDENCIES: dict[str, tuple[str, tuple[str, ...]]] = { + "faster_whisper": ("faster_whisper", ("-U", "faster-whisper")), + "kittentts": ( + "kittentts", + ( + "-U", + "https://github.com/KittenML/KittenTTS/releases/download/" + "0.8.1/kittentts-0.8.1-py3-none-any.whl", + "soundfile", + ), + ), + "piper": ("piper", ("-U", "piper-tts")), + "ddgs": ("ddgs", ("-U", "ddgs")), + "langfuse": ("langfuse", ("langfuse",)), +} + + +def active_restorable_python_tool_dependencies() -> list[str]: + """Return ``hermes tools`` Python dependencies present in this runtime.""" + return [ + name + for name, (module_name, _install_args) in ( + _RESTORABLE_PYTHON_TOOL_DEPENDENCIES.items() + ) + if _module_installed(module_name) + ] + + +def restorable_python_tool_dependency( + name: str, +) -> tuple[str, tuple[str, ...]] | None: + """Return the import probe and pip arguments for an allowlisted tool.""" + return _RESTORABLE_PYTHON_TOOL_DEPENDENCIES.get(name) + + def _agent_browser_installed() -> bool: """True when everything ``_run_post_setup("agent_browser")`` installs is present: the agent-browser CLI *and* the Chromium build it drives (or the diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 9a5c994eb6f6..a84f5b53344f 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -779,6 +779,8 @@ def _update_via_zip(args): Used on Windows when git file I/O is broken (antivirus, NTFS filter drivers causing 'Invalid argument' errors on file creation). """ + active_tool_dependencies = _m()._capture_active_tool_dependencies() + import tempfile import zipfile from urllib.request import urlretrieve @@ -983,6 +985,14 @@ def _update_via_zip(args): ) _m()._install_python_dependencies_with_optional_fallback(pip_cmd) + install_prefix = [uv_bin, "pip"] if uv_bin else pip_cmd + install_env = uv_env if uv_bin else None + _m()._restore_active_tool_dependencies( + active_tool_dependencies, + install_prefix, + env=install_env, + ) + # ZIP path parity: heal the active memory provider's bridge packages # after the dependency reinstall, same as the git-pull path (#53272, # #70636). @@ -1786,10 +1796,113 @@ def _upgrade_pip_before_lazy_refresh( except subprocess.CalledProcessError as exc: logger.debug("pip upgrade before lazy refresh failed: %s", exc) + +def _capture_active_lazy_features() -> list[str]: + """Snapshot active lazy backends before a managed runtime is replaced.""" + try: + from tools import lazy_deps + + return lazy_deps.active_features() + except Exception as exc: + logger.debug("Could not snapshot active lazy features: %s", exc) + return [] + + +def _capture_active_tool_dependencies() -> list[str]: + """Snapshot Python dependencies installed explicitly through ``hermes tools``.""" + try: + from hermes_cli import tools_config + + return tools_config.active_restorable_python_tool_dependencies() + except Exception as exc: + logger.debug("Could not snapshot active Hermes Tools dependencies: %s", exc) + return [] + + +def _restore_active_tool_dependencies( + dependencies: list[str], + install_cmd_prefix: list[str], + *, + env: dict[str, str] | None = None, +) -> None: + """Restore allowlisted ``hermes tools`` dependencies into a rebuilt venv. + + The dependency names came from a pre-rebuild import probe and are resolved + through a static package allowlist. Never raises: a failed optional tool + must not block the core update, but the user must be told what stayed + unavailable. + """ + if not dependencies: + return + + try: + from hermes_cli import tools_config + except Exception as exc: + logger.debug("Hermes Tools dependency restore skipped (import failed): %s", exc) + return + + target_python = _m()._resolve_install_target_python(install_cmd_prefix, env) + missing: list[tuple[str, tuple[str, ...]]] = [] + for name in dependencies: + spec = tools_config.restorable_python_tool_dependency(name) + if spec is None: + continue + module_name, install_args = spec + if target_python is not None: + try: + probe = subprocess.run( + [ + str(target_python), + "-c", + "import importlib.util,sys; " + "raise SystemExit(0 if importlib.util.find_spec(sys.argv[1]) else 1)", + module_name, + ], + capture_output=True, + env=env, + check=False, + ) + if probe.returncode == 0: + continue + except (subprocess.SubprocessError, OSError): + # An indeterminate probe is safer to repair than to treat as + # proof that a pre-rebuild dependency survived. + pass + missing.append((name, install_args)) + + if not missing: + return + + print() + print(f"→ Restoring {len(missing)} Hermes Tools dependency set(s)...") + restored: list[str] = [] + failed: list[tuple[str, str]] = [] + for name, install_args in missing: + try: + _m()._run_package_only_install( + install_cmd_prefix + ["install", *install_args, "--quiet"], + env=env, + ) + restored.append(name) + except Exception as exc: + # This is best-effort recovery for optional tooling. Unexpected + # installer failures must be surfaced without aborting the core + # runtime update. + failed.append((name, str(exc))) + + if restored: + print(f" ✓ {len(restored)} restored: {', '.join(restored)}") + for name, reason in failed: + if len(reason) > 200: + reason = reason[:200] + "..." + print(f" ⚠ {name} failed to restore: {reason}") + + def _refresh_active_lazy_features( install_cmd_prefix: list[str] | None = None, *, env: dict[str, str] | None = None, + features: list[str] | None = None, ) -> bool: """Refresh lazy-installed backends after a code update. @@ -1817,11 +1930,14 @@ def _refresh_active_lazy_features( logger.debug("Lazy refresh skipped (import failed): %s", exc) return True - try: - active = lazy_deps.active_features() - except Exception as exc: - logger.debug("Lazy refresh skipped (active_features failed): %s", exc) - return True + if features is None: + try: + active = lazy_deps.active_features() + except Exception as exc: + logger.debug("Lazy refresh skipped (active_features failed): %s", exc) + return True + else: + active = features if not active: return True @@ -1831,7 +1947,10 @@ def _refresh_active_lazy_features( unexpected_failure = False try: - results = lazy_deps.refresh_active_features(prompt=False) + if features is None: + results = lazy_deps.refresh_active_features(prompt=False) + else: + results = lazy_deps.restore_features(active) except Exception as exc: # refresh_active_features is documented as never-raise, but defend # the update flow against future regressions. @@ -1839,7 +1958,7 @@ def _refresh_active_lazy_features( results = {} unexpected_failure = True - refreshed = [f for f, s in results.items() if s == "refreshed"] + refreshed = [f for f, s in results.items() if s in {"refreshed", "restored"}] current = [f for f, s in results.items() if s == "current"] failed = [(f, s) for f, s in results.items() if s.startswith("failed:")] skipped = [(f, s) for f, s in results.items() if s.startswith("skipped:")] @@ -3984,6 +4103,12 @@ def _eol_only(): def _cmd_update_impl(args, gateway_mode: bool): """Body of ``cmd_update`` — kept separate so the wrapper can always restore stdio even on ``sys.exit``.""" + # A managed-runtime refresh can replace site-packages before the normal + # ``.[all]`` install runs. Snapshot while the old environment can still + # prove which optional backends the user had activated. + active_lazy_features = _m()._capture_active_lazy_features() + active_tool_dependencies = _m()._capture_active_tool_dependencies() + # In gateway mode, use file-based IPC for prompts instead of stdin gw_input_fn = ( (lambda prompt, default="": _gateway_prompt(prompt, default)) @@ -4404,10 +4529,28 @@ def _cmd_update_impl(args, gateway_mode: bool): _m()._install_python_dependencies_with_optional_fallback( [repair_uv, "pip"], env=repair_env, group="all" ) + _m()._refresh_active_lazy_features( + [repair_uv, "pip"], + env=repair_env, + features=active_lazy_features, + ) + _m()._restore_active_tool_dependencies( + active_tool_dependencies, + [repair_uv, "pip"], + env=repair_env, + ) else: _m()._install_python_dependencies_with_optional_fallback( [sys.executable, "-m", "pip"], group="all" ) + _m()._refresh_active_lazy_features( + [sys.executable, "-m", "pip"], + features=active_lazy_features, + ) + _m()._restore_active_tool_dependencies( + active_tool_dependencies, + [sys.executable, "-m", "pip"], + ) _m()._clear_update_incomplete_marker() healthy_after, detail_after = _venv_core_imports_healthy() if healthy_after: @@ -4686,7 +4829,11 @@ def _cmd_update_impl(args, gateway_mode: bool): # Lazy refresh can corrupt the venv when a backend install fails. # Clear the lazy marker only when refresh/repair is confirmed healthy. - lazy_ok = _m()._refresh_active_lazy_features(install_prefix, env=lazy_env) + lazy_ok = _m()._refresh_active_lazy_features( + install_prefix, + env=lazy_env, + features=active_lazy_features, + ) if lazy_ok: _m()._clear_lazy_refresh_incomplete_marker() else: @@ -4695,6 +4842,12 @@ def _cmd_update_impl(args, gateway_mode: bool): "to finish import-based venv repair." ) + _m()._restore_active_tool_dependencies( + active_tool_dependencies, + install_prefix, + env=lazy_env, + ) + # Heal the active memory provider's bridge packages last — the core # reinstall + lazy refresh above may have stripped or downgraded # plugin.yaml-declared deps that aren't in extras (#53272, #70636). diff --git a/plugins/observability/langfuse/__init__.py b/plugins/observability/langfuse/__init__.py index 5387cc0a9440..4454fd578896 100644 --- a/plugins/observability/langfuse/__init__.py +++ b/plugins/observability/langfuse/__init__.py @@ -280,6 +280,11 @@ def _get_langfuse() -> Optional[Langfuse]: return _LANGFUSE_CLIENT if Langfuse is None: + logger.warning( + "Langfuse plugin is enabled but the langfuse SDK is unavailable; " + "tracing is disabled. Run `hermes tools` and configure Langfuse " + "Observability to reinstall it." + ) _LANGFUSE_CLIENT = _INIT_FAILED return None diff --git a/tests/hermes_cli/test_lazy_refresh_venv_repair.py b/tests/hermes_cli/test_lazy_refresh_venv_repair.py index 6f7b9cf94032..3c18fd959955 100644 --- a/tests/hermes_cli/test_lazy_refresh_venv_repair.py +++ b/tests/hermes_cli/test_lazy_refresh_venv_repair.py @@ -4,9 +4,11 @@ import textwrap from pathlib import Path +from types import SimpleNamespace from unittest.mock import MagicMock, patch import hermes_cli.main as m +import pytest @@ -110,6 +112,142 @@ def fake_repair(prefix, packages, *, env=None): assert "Backends keep their previously-installed version" not in out +def test_refresh_uses_pre_rebuild_snapshot_when_provided(monkeypatch): + """Replacement runtimes must not re-detect features after packages vanish.""" + import tools.lazy_deps as lazy_deps_mod + + monkeypatch.setattr( + lazy_deps_mod, + "active_features", + lambda: pytest.fail("post-rebuild detection must not run"), + ) + restored = [] + monkeypatch.setattr( + lazy_deps_mod, + "restore_features", + lambda features: restored.append(features) or {"platform.telegram": "restored"}, + ) + + assert m._refresh_active_lazy_features( + ["uv", "pip"], features=["platform.telegram"] + ) is True + assert restored == [["platform.telegram"]] + + +def test_capture_active_tool_dependencies_uses_tools_status_probes(monkeypatch): + from hermes_cli import tools_config + + monkeypatch.setattr( + tools_config, + "_module_installed", + lambda module: module in {"langfuse", "ddgs"}, + ) + + assert m._capture_active_tool_dependencies() == ["ddgs", "langfuse"] + + +def test_restore_active_tool_dependencies_uses_static_allowlist(monkeypatch): + calls = [] + monkeypatch.setattr( + m, + "_run_package_only_install", + lambda cmd, *, env=None: calls.append((cmd, env)), + ) + + env = {"VIRTUAL_ENV": "/tmp/venv"} + m._restore_active_tool_dependencies( + ["langfuse", "not-allowlisted"], + ["uv", "pip"], + env=env, + ) + + assert calls == [(["uv", "pip", "install", "langfuse", "--quiet"], env)] + + +def test_cmd_update_captures_and_propagates_pre_rebuild_snapshot( + tmp_path, monkeypatch +): + """The updater must carry pre-rebuild state into its repair refresh.""" + from hermes_cli import managed_uv, update_cmd + + (tmp_path / ".git").mkdir() + snapshot = ["platform.telegram"] + tool_snapshot = ["langfuse"] + refresh_calls = [] + restore_calls = [] + + class RestoreReached(Exception): + pass + + def fake_run(cmd, **kwargs): + if "rev-parse" in cmd: + return SimpleNamespace(returncode=0, stdout="main\n", stderr="") + if "rev-list" in cmd: + return SimpleNamespace(returncode=0, stdout="0\n", stderr="") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + def fake_refresh(prefix, *, env=None, features=None): + refresh_calls.append((prefix, env, features)) + return True + + def fake_restore(dependencies, prefix, *, env=None): + restore_calls.append((dependencies, prefix, env)) + raise RestoreReached + + monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path) + monkeypatch.setattr(m, "_capture_active_lazy_features", lambda: snapshot.copy()) + monkeypatch.setattr( + m, "_capture_active_tool_dependencies", lambda: tool_snapshot.copy() + ) + monkeypatch.setattr(m, "_is_windows", lambda: False) + monkeypatch.setattr(m, "_run_pre_update_backup", lambda args: None) + monkeypatch.setattr(m, "_pause_windows_gateways_for_update", lambda: None) + monkeypatch.setattr(m, "_resume_windows_gateways_after_update", lambda state: None) + monkeypatch.setattr(update_cmd, "_discard_lockfile_churn", lambda *args: None) + monkeypatch.setattr(m, "_get_origin_url", lambda *args: "https://github.com/NousResearch/hermes-agent.git") + monkeypatch.setattr(m, "_resolve_update_branch", lambda args: "main") + monkeypatch.setattr(m, "_stash_local_changes_if_needed", lambda *args: None) + monkeypatch.setattr(update_cmd, "_invalidate_update_cache", lambda: None) + monkeypatch.setattr( + update_cmd, "_venv_core_imports_healthy", lambda: (False, "broken") + ) + monkeypatch.setattr(update_cmd, "_write_update_incomplete_marker", lambda: None) + monkeypatch.setattr( + m, "_install_python_dependencies_with_optional_fallback", lambda *a, **k: None + ) + monkeypatch.setattr(m, "_refresh_active_lazy_features", fake_refresh) + monkeypatch.setattr(m, "_restore_active_tool_dependencies", fake_restore) + monkeypatch.setattr(m.subprocess, "run", fake_run) + monkeypatch.setattr(managed_uv, "update_managed_uv", lambda **kwargs: None) + monkeypatch.setattr(managed_uv, "ensure_uv", lambda **kwargs: "uv") + + args = SimpleNamespace( + yes=True, + force=False, + force_venv=False, + no_backup=True, + backup=False, + branch=None, + ) + with pytest.raises(RestoreReached): + m._cmd_update_impl(args, gateway_mode=False) + + assert refresh_calls == [ + ( + ["uv", "pip"], + {**m.os.environ, "VIRTUAL_ENV": str(tmp_path / "venv")}, + snapshot, + ) + ] + assert restore_calls == [ + ( + tool_snapshot, + ["uv", "pip"], + {**m.os.environ, "VIRTUAL_ENV": str(tmp_path / "venv")}, + ) + ] + + diff --git a/tests/plugins/test_langfuse_plugin.py b/tests/plugins/test_langfuse_plugin.py index d53a549e9d40..7772b0477f3b 100644 --- a/tests/plugins/test_langfuse_plugin.py +++ b/tests/plugins/test_langfuse_plugin.py @@ -91,6 +91,20 @@ def test_get_langfuse_returns_none_without_credentials(self, monkeypatch): langfuse_plugin = self._fresh_plugin() assert langfuse_plugin._get_langfuse() is None + def test_missing_sdk_logs_one_warning(self, monkeypatch, caplog): + langfuse_plugin = self._fresh_plugin() + monkeypatch.setattr(langfuse_plugin, "Langfuse", None) + langfuse_plugin._LANGFUSE_CLIENT = None + + with caplog.at_level(logging.WARNING, logger=langfuse_plugin.__name__): + assert langfuse_plugin._get_langfuse() is None + assert langfuse_plugin._get_langfuse() is None + + messages = [record.getMessage() for record in caplog.records] + assert len(messages) == 1 + assert "SDK is unavailable" in messages[0] + assert "tracing is disabled" in messages[0] + def test_get_langfuse_caches_failure_no_config_load(self, monkeypatch): """A miss must be cached — no per-hook config.yaml reads, no env re-reads.""" for k in ( diff --git a/tests/tools/test_lazy_deps.py b/tests/tools/test_lazy_deps.py index 774a5855bd04..74838a69a388 100644 --- a/tests/tools/test_lazy_deps.py +++ b/tests/tools/test_lazy_deps.py @@ -358,6 +358,41 @@ def test_matrix_probe_reports_unsupported_on_real_windows(self): ld._unsupported_feature_reason("platform.matrix") or "" ) + def test_restore_snapshot_skips_telegram_with_lazy_installs_disabled( + self, monkeypatch + ): + """The security opt-out also blocks updater-driven restoration.""" + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: False) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) + monkeypatch.setattr( + ld, + "_venv_pip_install", + lambda *args, **kwargs: pytest.fail( + "pip must not run when lazy installs are disabled" + ), + ) + + result = ld.restore_features(["platform.telegram"]) + + assert result == { + "platform.telegram": ( + "skipped: lazy installs disabled " + "(security.allow_lazy_installs=false)" + ) + } + + def test_restore_snapshot_does_not_install_never_activated_features( + self, monkeypatch + ): + monkeypatch.setattr( + ld, + "_venv_pip_install", + lambda *args, **kwargs: pytest.fail( + "cold features must stay uninstalled" + ), + ) + + assert ld.restore_features([]) == {} def test_mixed_results_returns_per_feature_status(self, monkeypatch): monkeypatch.setattr(ld, "active_features", lambda: ["a.ok", "b.fail"]) diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 9cddb9a04260..88cf6e78b58d 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -1133,8 +1133,27 @@ def refresh_active_features(*, prompt: bool = False) -> dict[str, str]: Intended for ``hermes update``. Never raises; lazy-install failures here must not block the rest of the update flow. """ + return _refresh_features(active_features(), prompt=prompt, restoring=False) + + +def restore_features(features: list[str]) -> dict[str, str]: + """Restore features captured before an explicit managed-runtime rebuild. + + Feature names are checked against :data:`LAZY_DEPS`, and installs remain + subject to ``security.allow_lazy_installs``. An explicit opt-out therefore + leaves the captured feature absent and reports it as skipped. + """ + return _refresh_features(features, prompt=False, restoring=True) + + +def _refresh_features( + features: list[str], *, prompt: bool, restoring: bool +) -> dict[str, str]: + """Refresh or restore a known set of allowlisted lazy features.""" results: dict[str, str] = {} - for feature in active_features(): + for feature in features: + if feature not in LAZY_DEPS: + continue missing = feature_missing(feature) if not missing: results[feature] = "current" @@ -1146,8 +1165,12 @@ def refresh_active_features(*, prompt: bool = False) -> dict[str, str]: continue try: - ensure(feature, prompt=prompt) - results[feature] = "refreshed" + if restoring: + ensure(feature, prompt=False) + results[feature] = "restored" + else: + ensure(feature, prompt=prompt) + results[feature] = "refreshed" except FeatureUnavailable as e: # Distinguish "user opted out" or platform-incompatible features # from install failures so the update command can render the