From e59fedd351670a69c42f8738c23f832961a4ef44 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 10 Apr 2026 00:42:02 -0700 Subject: [PATCH] feat: auto-install missing agent deps on startup (#215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: auto-install missing agent deps on startup * fix: patch HERMES_HOME in test_skips_when_agent_dir_missing to prevent real agent fallback The test patched HERMES_WEBUI_AGENT_DIR to a nonexistent path but left HERMES_HOME unpatched. In the full test suite HERMES_HOME resolves to the real hermes agent dir, causing the fallback in _agent_dir() to find and use it — making auto_install_agent_deps() call pip instead of returning False. Fix: also patch HERMES_HOME to a nonexistent dir in env_overrides. --------- Co-authored-by: Nathan Esquenazi --- api/startup.py | 46 +++++++++++++++++++++++++++ tests/test_sprint32.py | 71 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 api/startup.py create mode 100644 tests/test_sprint32.py diff --git a/api/startup.py b/api/startup.py new file mode 100644 index 0000000..236fdd0 --- /dev/null +++ b/api/startup.py @@ -0,0 +1,46 @@ +"""Hermes Web UI -- startup helpers.""" +from __future__ import annotations +import os, subprocess, sys +from pathlib import Path + +def _agent_dir() -> Path | None: + hermes_home = Path(os.environ.get('HERMES_HOME', str(Path.home() / '.hermes'))) + for raw in [os.environ.get('HERMES_WEBUI_AGENT_DIR', '').strip(), str(hermes_home / 'hermes-agent')]: + if not raw: + continue + p = Path(raw).expanduser() + if p.is_dir(): + return p.resolve() + return None + +def auto_install_agent_deps() -> bool: + agent_dir = _agent_dir() + if agent_dir is None: + print('[!!] Auto-install skipped: agent directory not found.', flush=True) + return False + req_file = agent_dir / 'requirements.txt' + pyproject = agent_dir / 'pyproject.toml' + if req_file.exists(): + install_args = [sys.executable, '-m', 'pip', 'install', '--quiet', '-r', str(req_file)] + print(f' Installing from {req_file} ...', flush=True) + elif pyproject.exists(): + install_args = [sys.executable, '-m', 'pip', 'install', '--quiet', str(agent_dir)] + print(f' Installing from {agent_dir} (pyproject.toml) ...', flush=True) + else: + print('[!!] Auto-install skipped: no requirements.txt or pyproject.toml in agent dir.', flush=True) + return False + try: + result = subprocess.run(install_args, capture_output=True, text=True, timeout=120) + if result.returncode != 0: + print(f'[!!] pip install failed (exit {result.returncode}):', flush=True) + for line in (result.stderr or '').splitlines()[-10:]: + print(f' {line}', flush=True) + return False + print('[ok] pip install completed.', flush=True) + return True + except subprocess.TimeoutExpired: + print('[!!] Auto-install timed out after 120s.', flush=True) + return False + except Exception as e: + print(f'[!!] Auto-install error: {e}', flush=True) + return False diff --git a/tests/test_sprint32.py b/tests/test_sprint32.py new file mode 100644 index 0000000..63754b6 --- /dev/null +++ b/tests/test_sprint32.py @@ -0,0 +1,71 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch +import subprocess +from api.startup import auto_install_agent_deps + +class TestAutoInstallAgentDeps: + def test_installs_from_requirements_txt(self, tmp_path): + agent_dir = tmp_path / 'hermes-agent' + agent_dir.mkdir() + req = agent_dir / 'requirements.txt' + req.write_text('pyyaml\n') + with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False): + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr='') + assert auto_install_agent_deps() is True + args = mock_run.call_args[0][0] + assert '-r' in args and str(req) in args + + def test_falls_back_to_pyproject(self, tmp_path): + agent_dir = tmp_path / 'hermes-agent' + agent_dir.mkdir() + (agent_dir / 'pyproject.toml').write_text('[project]\nname="hermes-agent"\n') + with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False): + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr='') + assert auto_install_agent_deps() is True + args = mock_run.call_args[0][0] + assert str(agent_dir) in args and '-r' not in args + + def test_skips_when_agent_dir_missing(self, tmp_path, capsys): + missing = tmp_path / 'nonexistent-agent' + # Patch both HERMES_WEBUI_AGENT_DIR and HERMES_HOME so the fallback + # path (HERMES_HOME/hermes-agent) also resolves to a nonexistent dir, + # preventing the real agent dir from being found in the test environment. + env_overrides = { + 'HERMES_WEBUI_AGENT_DIR': str(missing), + 'HERMES_HOME': str(tmp_path / 'no-hermes-home'), + } + with patch.dict('os.environ', env_overrides, clear=False): + with patch('subprocess.run') as mock_run: + assert auto_install_agent_deps() is False + assert not mock_run.called + assert 'skipped' in capsys.readouterr().out.lower() + + def test_skips_when_no_install_file(self, tmp_path, capsys): + agent_dir = tmp_path / 'hermes-agent' + agent_dir.mkdir() + with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False): + with patch('subprocess.run') as mock_run: + assert auto_install_agent_deps() is False + assert not mock_run.called + assert 'skipped' in capsys.readouterr().out.lower() + + def test_tolerates_pip_failure(self, tmp_path, capsys): + agent_dir = tmp_path / 'hermes-agent' + agent_dir.mkdir() + (agent_dir / 'requirements.txt').write_text('somepkg\n') + with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False): + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=1, stderr='ERROR: could not find package') + assert auto_install_agent_deps() is False + assert 'failed' in capsys.readouterr().out.lower() or 'pip' in capsys.readouterr().out.lower() + + def test_tolerates_timeout(self, tmp_path, capsys): + agent_dir = tmp_path / 'hermes-agent' + agent_dir.mkdir() + (agent_dir / 'requirements.txt').write_text('somepkg\n') + with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False): + with patch('subprocess.run', side_effect=subprocess.TimeoutExpired('pip', 120)): + assert auto_install_agent_deps() is False + assert 'timed out' in capsys.readouterr().out.lower()