From d1df111ccdc61f73039cc134d9910617c1edbb43 Mon Sep 17 00:00:00 2001 From: Tachi Date: Fri, 7 Aug 2026 16:16:36 +0400 Subject: [PATCH] fix(update): restore Hermes Tools dependencies --- hermes_cli/main.py | 2 + hermes_cli/tools_config.py | 40 ++++++ hermes_cli/update_cmd.py | 116 ++++++++++++++++++ plugins/observability/langfuse/__init__.py | 5 + .../test_lazy_refresh_venv_repair.py | 53 +++++++- tests/plugins/test_langfuse_plugin.py | 14 +++ 6 files changed, 227 insertions(+), 3 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 775920652c..05c79a1695 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5190,6 +5190,7 @@ 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, @@ -5238,6 +5239,7 @@ from hermes_cli.update_cmd import ( # noqa: F401 _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 65e9f9e36e..5858bf950e 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 4f6fbf8a4a..9ba5091ee5 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -779,6 +779,8 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False): 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, *, had_desktop_app_before_update: bool = False): ) _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). @@ -1802,6 +1812,96 @@ def _capture_active_lazy_features() -> list[str]: 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, *, @@ -4088,6 +4188,7 @@ def _cmd_update_impl(args, gateway_mode: bool): # ``.[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 = ( @@ -4522,6 +4623,11 @@ def _cmd_update_impl(args, gateway_mode: bool): 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" @@ -4530,6 +4636,10 @@ def _cmd_update_impl(args, gateway_mode: bool): [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: @@ -4821,6 +4931,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 5387cc0a94..4454fd5788 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 aef31c2848..3c18fd9599 100644 --- a/tests/hermes_cli/test_lazy_refresh_venv_repair.py +++ b/tests/hermes_cli/test_lazy_refresh_venv_repair.py @@ -134,6 +134,36 @@ def test_refresh_uses_pre_rebuild_snapshot_when_provided(monkeypatch): 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 ): @@ -142,9 +172,11 @@ def test_cmd_update_captures_and_propagates_pre_rebuild_snapshot( (tmp_path / ".git").mkdir() snapshot = ["platform.telegram"] + tool_snapshot = ["langfuse"] refresh_calls = [] + restore_calls = [] - class RefreshReached(Exception): + class RestoreReached(Exception): pass def fake_run(cmd, **kwargs): @@ -156,10 +188,17 @@ def test_cmd_update_captures_and_propagates_pre_rebuild_snapshot( def fake_refresh(prefix, *, env=None, features=None): refresh_calls.append((prefix, env, features)) - raise RefreshReached + 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) @@ -177,6 +216,7 @@ def test_cmd_update_captures_and_propagates_pre_rebuild_snapshot( 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") @@ -189,7 +229,7 @@ def test_cmd_update_captures_and_propagates_pre_rebuild_snapshot( backup=False, branch=None, ) - with pytest.raises(RefreshReached): + with pytest.raises(RestoreReached): m._cmd_update_impl(args, gateway_mode=False) assert refresh_calls == [ @@ -199,6 +239,13 @@ def test_cmd_update_captures_and_propagates_pre_rebuild_snapshot( 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 d53a549e9d..7772b0477f 100644 --- a/tests/plugins/test_langfuse_plugin.py +++ b/tests/plugins/test_langfuse_plugin.py @@ -91,6 +91,20 @@ class TestRuntimeGate: 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 (