From 634f90a80768fe415c322710a5e7ccf1bd63e4c2 Mon Sep 17 00:00:00 2001 From: milo Date: Thu, 30 Apr 2026 16:06:16 +0800 Subject: [PATCH] fix: validate WebUI launcher can import agent --- bootstrap.py | 51 +++++++++++++++++++++--- tests/conftest.py | 1 + tests/test_bootstrap_python_selection.py | 42 +++++++++++++++++++ 3 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 tests/test_bootstrap_python_selection.py diff --git a/bootstrap.py b/bootstrap.py index ccdbee9a..7fb13194 100644 --- a/bootstrap.py +++ b/bootstrap.py @@ -124,15 +124,50 @@ def discover_launcher_python(agent_dir: Path | None) -> str: return shutil.which("python3") or shutil.which("python") or sys.executable -def ensure_python_has_webui_deps(python_exe: str) -> str: +def _python_can_run_webui_and_agent(python_exe: str, agent_dir: Path | None = None) -> bool: + script = "import yaml\nfrom run_agent import AIAgent\n" + env = os.environ.copy() + if agent_dir: + env["PYTHONPATH"] = ( + str(agent_dir) + if not env.get("PYTHONPATH") + else f"{agent_dir}{os.pathsep}{env['PYTHONPATH']}" + ) check = subprocess.run( - [python_exe, "-c", "import yaml"], + [python_exe, "-c", script], capture_output=True, text=True, + env=env, ) - if check.returncode == 0: + return check.returncode == 0 + + +def ensure_python_has_webui_deps(python_exe: str, agent_dir: Path | None = None) -> str: + """Return a Python executable that can run both WebUI and Hermes Agent. + + The WebUI can be launched directly with its local .venv. That venv has the + WebUI dependencies (for example PyYAML), but may not have Hermes Agent on its + import path. In that case the server starts healthy, then chat fails later + with "AIAgent not available". Prefer the agent venv when it is usable, and + validate the final interpreter before starting the server. + """ + if _python_can_run_webui_and_agent(python_exe, agent_dir): return python_exe + agent_candidates: list[Path] = [] + if agent_dir: + for rel in ( + "venv/bin/python", + "venv/Scripts/python.exe", + ".venv/bin/python", + ".venv/Scripts/python.exe", + ): + agent_candidates.append(agent_dir / rel) + for candidate in agent_candidates: + if str(candidate) != python_exe and candidate.exists(): + if _python_can_run_webui_and_agent(str(candidate), agent_dir): + return str(candidate) + venv_dir = REPO_ROOT / ".venv" venv_python = venv_dir / ( "Scripts/python.exe" if platform.system() == "Windows" else "bin/python" @@ -158,7 +193,13 @@ def ensure_python_has_webui_deps(python_exe: str) -> str: ], check=True, ) - return str(venv_python) + if _python_can_run_webui_and_agent(str(venv_python), agent_dir): + return str(venv_python) + raise RuntimeError( + "Python environment cannot import both WebUI dependencies and Hermes Agent. " + "Set HERMES_WEBUI_PYTHON to the Hermes Agent venv Python or install the " + "WebUI requirements into that environment." + ) def hermes_command_exists() -> bool: @@ -298,7 +339,7 @@ def main() -> int: install_hermes_agent() agent_dir = discover_agent_dir() - python_exe = ensure_python_has_webui_deps(discover_launcher_python(agent_dir)) + python_exe = ensure_python_has_webui_deps(discover_launcher_python(agent_dir), agent_dir) state_dir = Path( os.getenv("HERMES_WEBUI_STATE_DIR", str(Path.home() / ".hermes" / "webui")) ).expanduser() diff --git a/tests/conftest.py b/tests/conftest.py index ef707679..386c3fb8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -305,6 +305,7 @@ def test_server(): # causing onboarding writes (config.yaml, .env) to land in the production # ~/.hermes/profiles/webui/ and overwrite real API keys. "HERMES_BASE_HOME": str(TEST_STATE_DIR), + "HERMES_WEBUI_PASSWORD": "", }) # Pass agent dir if discovered so server.py doesn't have to re-discover diff --git a/tests/test_bootstrap_python_selection.py b/tests/test_bootstrap_python_selection.py new file mode 100644 index 00000000..65f7146b --- /dev/null +++ b/tests/test_bootstrap_python_selection.py @@ -0,0 +1,42 @@ +import pathlib + +import bootstrap + + +def test_ensure_python_prefers_agent_venv_when_launcher_cannot_import_agent(monkeypatch, tmp_path): + """Avoid starting WebUI with a local venv that later cannot import AIAgent.""" + local_python = tmp_path / "webui" / ".venv" / "bin" / "python" + agent_python = tmp_path / "agent" / "venv" / "bin" / "python" + agent_python.parent.mkdir(parents=True) + agent_python.write_text("", encoding="utf-8") + + probes = [] + + def fake_can_run(python_exe: str, agent_dir: pathlib.Path | None = None) -> bool: + probes.append(pathlib.Path(python_exe)) + return pathlib.Path(python_exe) == agent_python + + monkeypatch.setattr(bootstrap, "_python_can_run_webui_and_agent", fake_can_run) + + selected = bootstrap.ensure_python_has_webui_deps(str(local_python), tmp_path / "agent") + + assert selected == str(agent_python) + assert probes == [local_python, agent_python] + + +def test_ensure_python_fails_loudly_when_no_interpreter_can_import_agent(monkeypatch, tmp_path): + """Do not report health OK when chat would fail with missing AIAgent.""" + local_python = tmp_path / "webui" / ".venv" / "bin" / "python" + agent_python = tmp_path / "agent" / "venv" / "bin" / "python" + agent_python.parent.mkdir(parents=True) + agent_python.write_text("", encoding="utf-8") + + monkeypatch.setattr(bootstrap, "_python_can_run_webui_and_agent", lambda *a, **k: False) + monkeypatch.setattr(bootstrap.subprocess, "run", lambda *a, **k: None) + + try: + bootstrap.ensure_python_has_webui_deps(str(local_python), tmp_path / "agent") + except RuntimeError as exc: + assert "cannot import both WebUI dependencies and Hermes Agent" in str(exc) + else: + raise AssertionError("expected RuntimeError")