fix(update): restore Hermes Tools dependencies
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user