fix(update): restore Hermes Tools dependencies

This commit is contained in:
Tachi
2026-08-07 16:16:36 +04:00
committed by Teknium
parent b67f202184
commit d1df111ccd
6 changed files with 227 additions and 3 deletions
+2
View File
@@ -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,
+40
View File
@@ -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
+116
View File
@@ -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).
@@ -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
@@ -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")},
)
]
+14
View File
@@ -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 (