From b5d462812c8291da0e572d60832ab93c0e93f6d4 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 26 Jun 2026 09:43:37 -0400 Subject: [PATCH 1/5] fix(#4700): recognize pip-style agent roots for cron imports --- api/config.py | 11 +- tests/test_issue4700_agent_dir_discovery.py | 137 ++++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue4700_agent_dir_discovery.py diff --git a/api/config.py b/api/config.py index 9c2ec86e..ad73c9c3 100644 --- a/api/config.py +++ b/api/config.py @@ -172,12 +172,21 @@ def _discover_agent_dir() -> Path: candidates.append(Path(sys_prefix) / "hermes-agent") for path in candidates: - if path.exists() and (path / "run_agent.py").exists(): + if path.exists() and _looks_like_agent_source_root(path): return path.resolve() return None +def _looks_like_agent_source_root(path: Path) -> bool: + """Return True when a directory resembles a hermes-agent source root.""" + if (path / "run_agent.py").exists(): + return True + if not (path / "cron" / "jobs.py").exists(): + return False + return any((path / marker).exists() for marker in ("hermes", "hermes_cli", "plugins")) + + def _discover_python(agent_dir: Path) -> str: """ Locate a Python executable that has the Hermes agent dependencies installed. diff --git a/tests/test_issue4700_agent_dir_discovery.py b/tests/test_issue4700_agent_dir_discovery.py new file mode 100644 index 00000000..1c0f156b --- /dev/null +++ b/tests/test_issue4700_agent_dir_discovery.py @@ -0,0 +1,137 @@ +"""Regression tests for issue #4700. + +Discovery of a pip-style hermes-agent checkout must accept ``cron/jobs.py`` +with core markers even when ``run_agent.py`` is missing. +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + + +def _isolate_discovery_inputs(config, monkeypatch, tmp_path: Path) -> None: + """Force `_discover_agent_dir()` to only use test-controlled candidates.""" + monkeypatch.delenv("HERMES_WEBUI_AGENT_DIR", raising=False) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home")) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg-data")) + monkeypatch.setattr(config, "HOME", tmp_path / "home") + monkeypatch.setattr(config, "_DEFAULT_HERMES_HOME", tmp_path / "default-hermes-home") + monkeypatch.setattr(config, "REPO_ROOT", tmp_path / "webui-repo") + + +def _make_pip_style_agent_root(root: Path) -> Path: + """Create a pip-style root with cron/jobs.py and a core marker.""" + marker = root / "hermes" + cron_jobs = root / "cron" / "jobs.py" + marker.mkdir(parents=True) + cron_jobs.parent.mkdir(parents=True) + cron_jobs.write_text("", encoding="utf-8") + return root + + +def _make_cron_only_root(root: Path) -> Path: + """Create a cron-only lookalike that must stay rejected.""" + cron_jobs = root / "cron" / "jobs.py" + cron_jobs.parent.mkdir(parents=True) + cron_jobs.write_text("", encoding="utf-8") + return root + + +def _make_legacy_agent_root(root: Path) -> Path: + """Create the legacy launcher root with run_agent.py.""" + root.mkdir(parents=True, exist_ok=True) + (root / "run_agent.py").write_text("", encoding="utf-8") + return root + + +def _drop_cron_modules() -> None: + for name in list(sys.modules): + if name == "cron" or name.startswith("cron."): + sys.modules.pop(name, None) + + +def _reset_agent_cron_import_path_state(routes) -> None: + routes._AGENT_CRON_IMPORT_PATH_READY = None + + +def test_discover_agent_dir_accepts_pip_style_root_without_run_agent(monkeypatch, tmp_path): + """The primary regression row for #4700: a pip-style root now resolves.""" + import api.config as config + + pip_root = _make_pip_style_agent_root(tmp_path / "pip-style-agent") + _isolate_discovery_inputs(config, monkeypatch, tmp_path) + monkeypatch.setenv("HERMES_WEBUI_AGENT_DIR", str(pip_root)) + + assert config._discover_agent_dir() == pip_root + + +def test_discover_agent_dir_rejects_cron_only_directory_without_agent_markers( + monkeypatch, tmp_path +): + """A cron-only directory without a core marker must remain rejected.""" + import api.config as config + + cron_only = _make_cron_only_root(tmp_path / "cron-only") + _isolate_discovery_inputs(config, monkeypatch, tmp_path) + monkeypatch.setenv("HERMES_WEBUI_AGENT_DIR", str(cron_only)) + + assert config._discover_agent_dir() is None + + +def test_legacy_run_agent_layout_still_wins(monkeypatch, tmp_path): + """Legacy `run_agent.py` discovery is unchanged and still takes precedence.""" + import api.config as config + + legacy = _make_legacy_agent_root(tmp_path / "legacy-agent") + _make_pip_style_agent_root(tmp_path / "fallback-pip-agent") + _isolate_discovery_inputs(config, monkeypatch, tmp_path) + monkeypatch.setenv("HERMES_WEBUI_AGENT_DIR", str(legacy)) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home")) + (tmp_path / "hermes-home" / "hermes-agent").mkdir(parents=True, exist_ok=True) + _make_pip_style_agent_root(tmp_path / "hermes-home" / "hermes-agent") + + assert config._discover_agent_dir() == legacy + + +def test_routes_shadow_helper_can_recover_once_agent_dir_resolves(monkeypatch, tmp_path): + """Once `_AGENT_DIR` resolves, `_ensure_agent_cron_import_path()` drops shadow + modules and rewires imports to the agent cron package.""" + import api.config as config + import api.routes as routes + + agent_dir = _make_pip_style_agent_root(tmp_path / "hermes-agent") + shadow_site_packages = tmp_path / "shadow-site-packages" + shadow_cron = shadow_site_packages / "cron" + (shadow_cron / "__init__.py").parent.mkdir(parents=True, exist_ok=True) + (shadow_cron / "__init__.py").write_text("SHADOW = True", encoding="utf-8") + (agent_dir / "cron").mkdir(parents=True, exist_ok=True) + (agent_dir / "cron" / "__init__.py").write_text("", encoding="utf-8") + (agent_dir / "cron" / "jobs.py").write_text( + "def list_jobs(*_args, **_kwargs):\n" + " return [{\"id\": \"agent-cron\"}]\n", + encoding="utf-8", + ) + + monkeypatch.setattr(config, "_AGENT_DIR", agent_dir) + monkeypatch.setattr(routes, "_AGENT_CRON_IMPORT_PATH_READY", None) + monkeypatch.setattr(sys, "path", [str(shadow_site_packages)]) + _reset_agent_cron_import_path_state(routes) + + try: + _drop_cron_modules() + shadowed = importlib.import_module("cron") + assert Path(shadowed.__file__).resolve() == (shadow_cron / "__init__.py").resolve() + + routes._ensure_agent_cron_import_path() + assert "cron" not in sys.modules + + cron_jobs = importlib.import_module("cron.jobs") + assert Path(cron_jobs.__file__).resolve() == ( + agent_dir / "cron" / "jobs.py" + ).resolve() + assert cron_jobs.list_jobs() == [{"id": "agent-cron"}] + finally: + _drop_cron_modules() + _reset_agent_cron_import_path_state(routes) From b4dadc993912f1254a138e807d4f20992bf29ba2 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 26 Jun 2026 11:03:04 -0400 Subject: [PATCH 2/5] fix(#4700): recognize nested pip-style agent roots --- api/config.py | 2 +- tests/test_issue4700_agent_dir_discovery.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/api/config.py b/api/config.py index ad73c9c3..6fe27a8a 100644 --- a/api/config.py +++ b/api/config.py @@ -154,7 +154,7 @@ def _discover_agent_dir() -> Path: candidates.append(REPO_ROOT.parent / "hermes-agent") # 4. Parent is the agent repo itself (repo cloned inside hermes-agent/) - if (REPO_ROOT.parent / "run_agent.py").exists(): + if _looks_like_agent_source_root(REPO_ROOT.parent): candidates.append(REPO_ROOT.parent) # 5. ~/.hermes/hermes-agent (explicit common path) diff --git a/tests/test_issue4700_agent_dir_discovery.py b/tests/test_issue4700_agent_dir_discovery.py index 1c0f156b..c42c9c2b 100644 --- a/tests/test_issue4700_agent_dir_discovery.py +++ b/tests/test_issue4700_agent_dir_discovery.py @@ -95,6 +95,19 @@ def test_legacy_run_agent_layout_still_wins(monkeypatch, tmp_path): assert config._discover_agent_dir() == legacy +def test_discover_agent_dir_accepts_pip_style_parent_of_webui_repo(monkeypatch, tmp_path): + """Nested webui-inside-agent layouts should also accept pip-style parents.""" + import api.config as config + + pip_root = _make_pip_style_agent_root(tmp_path / "pip-style-parent") + _isolate_discovery_inputs(config, monkeypatch, tmp_path) + nested_webui_repo = pip_root / "webui-repo" + nested_webui_repo.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(config, "REPO_ROOT", nested_webui_repo) + + assert config._discover_agent_dir() == pip_root + + def test_routes_shadow_helper_can_recover_once_agent_dir_resolves(monkeypatch, tmp_path): """Once `_AGENT_DIR` resolves, `_ensure_agent_cron_import_path()` drops shadow modules and rewires imports to the agent cron package.""" From 998c88a7e1255993615ead5e1790f2ce3e9f825a Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 26 Jun 2026 11:25:09 -0400 Subject: [PATCH 3/5] test(#4700): tighten agent discovery regression wording --- tests/test_issue4700_agent_dir_discovery.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_issue4700_agent_dir_discovery.py b/tests/test_issue4700_agent_dir_discovery.py index c42c9c2b..8ae066c1 100644 --- a/tests/test_issue4700_agent_dir_discovery.py +++ b/tests/test_issue4700_agent_dir_discovery.py @@ -80,8 +80,10 @@ def test_discover_agent_dir_rejects_cron_only_directory_without_agent_markers( assert config._discover_agent_dir() is None -def test_legacy_run_agent_layout_still_wins(monkeypatch, tmp_path): - """Legacy `run_agent.py` discovery is unchanged and still takes precedence.""" +def test_explicit_legacy_agent_dir_override_still_beats_pip_style_fallback( + monkeypatch, tmp_path +): + """An explicit legacy override still wins over later pip-style fallback paths.""" import api.config as config legacy = _make_legacy_agent_root(tmp_path / "legacy-agent") @@ -89,7 +91,6 @@ def test_legacy_run_agent_layout_still_wins(monkeypatch, tmp_path): _isolate_discovery_inputs(config, monkeypatch, tmp_path) monkeypatch.setenv("HERMES_WEBUI_AGENT_DIR", str(legacy)) monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home")) - (tmp_path / "hermes-home" / "hermes-agent").mkdir(parents=True, exist_ok=True) _make_pip_style_agent_root(tmp_path / "hermes-home" / "hermes-agent") assert config._discover_agent_dir() == legacy @@ -119,7 +120,6 @@ def test_routes_shadow_helper_can_recover_once_agent_dir_resolves(monkeypatch, t shadow_cron = shadow_site_packages / "cron" (shadow_cron / "__init__.py").parent.mkdir(parents=True, exist_ok=True) (shadow_cron / "__init__.py").write_text("SHADOW = True", encoding="utf-8") - (agent_dir / "cron").mkdir(parents=True, exist_ok=True) (agent_dir / "cron" / "__init__.py").write_text("", encoding="utf-8") (agent_dir / "cron" / "jobs.py").write_text( "def list_jobs(*_args, **_kwargs):\n" From 781223fa18993a34763987b0977e6983ebb83192 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 26 Jun 2026 12:32:55 -0400 Subject: [PATCH 4/5] fix(agent): keep real checkouts ahead of pip-style lookalikes (#4700) --- api/config.py | 32 +++++++++++++++------ tests/test_issue4700_agent_dir_discovery.py | 24 ++++++++++++---- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/api/config.py b/api/config.py index 6fe27a8a..dbb46057 100644 --- a/api/config.py +++ b/api/config.py @@ -138,13 +138,13 @@ def _discover_agent_dir() -> Path: 5. Common install paths -- ~/.hermes/hermes-agent (again as fallback) 6. HOME / hermes-agent -- ~/hermes-agent (simple flat layout) """ - candidates = [] + explicit_override = os.getenv("HERMES_WEBUI_AGENT_DIR") + if explicit_override: + explicit_path = Path(explicit_override).expanduser().resolve() + if explicit_path.exists() and _looks_like_agent_source_root(explicit_path): + return explicit_path - # 1. Explicit env var - if os.getenv("HERMES_WEBUI_AGENT_DIR"): - candidates.append( - Path(os.getenv("HERMES_WEBUI_AGENT_DIR")).expanduser().resolve() - ) + candidates = [] # 2. HERMES_HOME / hermes-agent hermes_home = os.getenv("HERMES_HOME", str(_DEFAULT_HERMES_HOME)) @@ -171,8 +171,13 @@ def _discover_agent_dir() -> Path: for sys_prefix in ("/opt", "/usr/local", "/usr/local/share"): candidates.append(Path(sys_prefix) / "hermes-agent") + # Prefer real source checkouts before pip-style roots so lookalikes cannot preempt them. for path in candidates: - if path.exists() and _looks_like_agent_source_root(path): + if path.exists() and (path / "run_agent.py").exists(): + return path.resolve() + + for path in candidates: + if path.exists() and _looks_like_pip_style_agent_source_root(path): return path.resolve() return None @@ -182,9 +187,20 @@ def _looks_like_agent_source_root(path: Path) -> bool: """Return True when a directory resembles a hermes-agent source root.""" if (path / "run_agent.py").exists(): return True + return _looks_like_pip_style_agent_source_root(path) + + +def _looks_like_pip_style_agent_source_root(path: Path) -> bool: + """Return True for pip-style agent roots with a real agent package signal.""" if not (path / "cron" / "jobs.py").exists(): return False - return any((path / marker).exists() for marker in ("hermes", "hermes_cli", "plugins")) + if (path / "hermes").exists(): + return True + hermes_cli_dir = path / "hermes_cli" + return ( + (hermes_cli_dir / "__init__.py").exists() + or (hermes_cli_dir / "main.py").exists() + ) def _discover_python(agent_dir: Path) -> str: diff --git a/tests/test_issue4700_agent_dir_discovery.py b/tests/test_issue4700_agent_dir_discovery.py index 8ae066c1..207b1265 100644 --- a/tests/test_issue4700_agent_dir_discovery.py +++ b/tests/test_issue4700_agent_dir_discovery.py @@ -31,10 +31,11 @@ def _make_pip_style_agent_root(root: Path) -> Path: return root -def _make_cron_only_root(root: Path) -> Path: - """Create a cron-only lookalike that must stay rejected.""" +def _make_plugins_lookalike_root(root: Path) -> Path: + """Create a cron/jobs.py + plugins/ lookalike that must stay rejected.""" cron_jobs = root / "cron" / "jobs.py" - cron_jobs.parent.mkdir(parents=True) + (root / "plugins").mkdir(parents=True, exist_ok=True) + cron_jobs.parent.mkdir(parents=True, exist_ok=True) cron_jobs.write_text("", encoding="utf-8") return root @@ -70,16 +71,29 @@ def test_discover_agent_dir_accepts_pip_style_root_without_run_agent(monkeypatch def test_discover_agent_dir_rejects_cron_only_directory_without_agent_markers( monkeypatch, tmp_path ): - """A cron-only directory without a core marker must remain rejected.""" + """A pip-style lookalike without an agent-specific marker must stay rejected.""" import api.config as config - cron_only = _make_cron_only_root(tmp_path / "cron-only") + cron_only = _make_plugins_lookalike_root(tmp_path / "cron-only") _isolate_discovery_inputs(config, monkeypatch, tmp_path) monkeypatch.setenv("HERMES_WEBUI_AGENT_DIR", str(cron_only)) assert config._discover_agent_dir() is None +def test_discover_agent_dir_prefers_later_run_agent_root_over_earlier_pip_lookalike( + monkeypatch, tmp_path +): + """A real source checkout must beat an earlier pip-style lookalike candidate.""" + import api.config as config + + _make_plugins_lookalike_root(tmp_path / "hermes-home" / "hermes-agent") + later_legacy = _make_legacy_agent_root(tmp_path / "hermes-agent") + _isolate_discovery_inputs(config, monkeypatch, tmp_path) + + assert config._discover_agent_dir() == later_legacy + + def test_explicit_legacy_agent_dir_override_still_beats_pip_style_fallback( monkeypatch, tmp_path ): From 9ffb90795188ceb55c05c0186f11e61f9298e7bf Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 26 Jun 2026 17:35:41 +0000 Subject: [PATCH 5/5] Release YM (v0.51.683): pip-style agent-root discovery (#4998, fixes #4700) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48f8bba7..c597f76c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.683] — 2026-06-26 — Release YM (WebUI finds the agent for pip-style installs — no more cron.jobs import error) + +### Fixed + +- **WebUI now locates the agent on pip-style installs, fixing a `ModuleNotFoundError: No module named 'cron.jobs'`.** Agent-directory discovery only accepted a source checkout containing `run_agent.py`, so a pip-style layout that ships the cron jobs module without that entrypoint left the agent dir unset and the cron import unrepaired. Discovery now also recognizes a pip-style root (the cron jobs module plus a real `hermes_cli` package signal), in an ordered search that always prefers a genuine `run_agent.py` checkout before any pip-style candidate — so a real checkout can never be preempted by a lookalike — and the pip-style marker is strict enough (requires `hermes_cli`, not a bare generic directory) that a non-agent folder can't false-match. Thanks @rodboev. (#4998, fixes #4700) + ## [v0.51.682] — 2026-06-26 — Release YL (faster long-session open — indexed compression-continuation lookup) ### Fixed