Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contributors/emails/hbasheer@student.42abudhabi.ae
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
hxwvaa
3 changes: 3 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions hermes_cli/tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
169 changes: 161 additions & 8 deletions hermes_cli/update_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -1831,15 +1947,18 @@ 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.
print(f" ⚠ Lazy refresh failed unexpectedly: {exc}")
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:")]
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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).
Expand Down
5 changes: 5 additions & 0 deletions plugins/observability/langfuse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading