Skip to content

Commit 909ee3c

Browse files
fix(doctor): only check the active memory provider, not all providers unconditionally (NousResearch#6285)
* fix(tools): skip camofox auto-cleanup when managed persistence is enabled When managed_persistence is enabled, cleanup_browser() was calling camofox_close() which destroys the server-side browser context via DELETE /sessions/{userId}, killing login sessions across cron runs. Add camofox_soft_cleanup() — a public wrapper that drops only the in-memory session entry when managed persistence is on, returning True. When persistence is off it returns False so the caller falls back to the full camofox_close(). The inactivity reaper still handles idle resource cleanup. Also surface a logger.warning() when _managed_persistence_enabled() fails to load config, replacing a silent except-and-return-False. Salvaged from NousResearch#6182 by el-analista (Eduardo Perea Fernandez). Added public API wrapper to avoid cross-module private imports, and test coverage for both persistence paths. Co-authored-by: Eduardo Perea Fernandez <el-analista@users.noreply.github.com> * fix(doctor): only check the active memory provider, not all providers unconditionally hermes doctor had hardcoded Honcho Memory and Mem0 Memory sections that always ran regardless of the user's memory.provider config setting. After the swappable memory provider update (NousResearch#4623), users with leftover Honcho config but no active provider saw false 'broken' errors. Replaced both sections with a single Memory Provider section that reads memory.provider from config.yaml and only checks the configured provider. Users with no external provider see a green 'Built-in memory active' check. Reported by community user michaelruiz001, confirmed by Eri (Honcho). --------- Co-authored-by: Eduardo Perea Fernandez <el-analista@users.noreply.github.com>
1 parent 0918a1e commit 909ee3c

6 files changed

Lines changed: 270 additions & 61 deletions

File tree

hermes_cli/doctor.py

Lines changed: 71 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -812,69 +812,83 @@ def run_doctor(args):
812812
check_warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)")
813813

814814
# =========================================================================
815-
# Honcho memory
815+
# Memory Provider (only check the active provider, if any)
816816
# =========================================================================
817817
print()
818-
print(color("◆ Honcho Memory", Colors.CYAN, Colors.BOLD))
818+
print(color("◆ Memory Provider", Colors.CYAN, Colors.BOLD))
819819

820+
_active_memory_provider = ""
820821
try:
821-
from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path
822-
hcfg = HonchoClientConfig.from_global_config()
823-
_honcho_cfg_path = resolve_config_path()
824-
825-
if not _honcho_cfg_path.exists():
826-
check_warn("Honcho config not found", "run: hermes memory setup")
827-
elif not hcfg.enabled:
828-
check_info(f"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)")
829-
elif not (hcfg.api_key or hcfg.base_url):
830-
check_fail("Honcho API key or base URL not set", "run: hermes memory setup")
831-
issues.append("No Honcho API key — run 'hermes memory setup'")
832-
else:
833-
from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client
834-
reset_honcho_client()
835-
try:
836-
get_honcho_client(hcfg)
837-
check_ok(
838-
"Honcho connected",
839-
f"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}",
840-
)
841-
except Exception as _e:
842-
check_fail("Honcho connection failed", str(_e))
843-
issues.append(f"Honcho unreachable: {_e}")
844-
except ImportError:
845-
check_warn("honcho-ai not installed", "pip install honcho-ai")
846-
except Exception as _e:
847-
check_warn("Honcho check failed", str(_e))
848-
849-
# =========================================================================
850-
# Mem0 memory
851-
# =========================================================================
852-
print()
853-
print(color("◆ Mem0 Memory", Colors.CYAN, Colors.BOLD))
822+
import yaml as _yaml
823+
_mem_cfg_path = HERMES_HOME / "config.yaml"
824+
if _mem_cfg_path.exists():
825+
with open(_mem_cfg_path) as _f:
826+
_raw_cfg = _yaml.safe_load(_f) or {}
827+
_active_memory_provider = (_raw_cfg.get("memory") or {}).get("provider", "")
828+
except Exception:
829+
pass
854830

855-
try:
856-
from plugins.memory.mem0 import _load_config as _load_mem0_config
857-
mem0_cfg = _load_mem0_config()
858-
mem0_key = mem0_cfg.get("api_key", "")
859-
if mem0_key:
860-
check_ok("Mem0 API key configured")
861-
check_info(f"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}")
862-
# Check if mem0.json exists but is missing api_key (the bug we fixed)
863-
mem0_json = HERMES_HOME / "mem0.json"
864-
if mem0_json.exists():
831+
if not _active_memory_provider:
832+
check_ok("Built-in memory active", "(no external provider configured — this is fine)")
833+
elif _active_memory_provider == "honcho":
834+
try:
835+
from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path
836+
hcfg = HonchoClientConfig.from_global_config()
837+
_honcho_cfg_path = resolve_config_path()
838+
839+
if not _honcho_cfg_path.exists():
840+
check_warn("Honcho config not found", "run: hermes memory setup")
841+
elif not hcfg.enabled:
842+
check_info(f"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)")
843+
elif not (hcfg.api_key or hcfg.base_url):
844+
check_fail("Honcho API key or base URL not set", "run: hermes memory setup")
845+
issues.append("No Honcho API key — run 'hermes memory setup'")
846+
else:
847+
from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client
848+
reset_honcho_client()
865849
try:
866-
import json as _json
867-
file_cfg = _json.loads(mem0_json.read_text())
868-
if not file_cfg.get("api_key") and mem0_key:
869-
check_info("api_key from .env (not in mem0.json) — this is fine")
870-
except Exception:
871-
pass
872-
else:
873-
check_warn("Mem0 not configured", "(set MEM0_API_KEY in .env or run hermes memory setup)")
874-
except ImportError:
875-
check_warn("Mem0 plugin not loadable", "(optional)")
876-
except Exception as _e:
877-
check_warn("Mem0 check failed", str(_e))
850+
get_honcho_client(hcfg)
851+
check_ok(
852+
"Honcho connected",
853+
f"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}",
854+
)
855+
except Exception as _e:
856+
check_fail("Honcho connection failed", str(_e))
857+
issues.append(f"Honcho unreachable: {_e}")
858+
except ImportError:
859+
check_fail("honcho-ai not installed", "pip install honcho-ai")
860+
issues.append("Honcho is set as memory provider but honcho-ai is not installed")
861+
except Exception as _e:
862+
check_warn("Honcho check failed", str(_e))
863+
elif _active_memory_provider == "mem0":
864+
try:
865+
from plugins.memory.mem0 import _load_config as _load_mem0_config
866+
mem0_cfg = _load_mem0_config()
867+
mem0_key = mem0_cfg.get("api_key", "")
868+
if mem0_key:
869+
check_ok("Mem0 API key configured")
870+
check_info(f"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}")
871+
else:
872+
check_fail("Mem0 API key not set", "(set MEM0_API_KEY in .env or run hermes memory setup)")
873+
issues.append("Mem0 is set as memory provider but API key is missing")
874+
except ImportError:
875+
check_fail("Mem0 plugin not loadable", "pip install mem0ai")
876+
issues.append("Mem0 is set as memory provider but mem0ai is not installed")
877+
except Exception as _e:
878+
check_warn("Mem0 check failed", str(_e))
879+
else:
880+
# Generic check for other memory providers (openviking, hindsight, etc.)
881+
try:
882+
from plugins.memory import load_memory_provider
883+
_provider = load_memory_provider(_active_memory_provider)
884+
if _provider and _provider.is_available():
885+
check_ok(f"{_active_memory_provider} provider active")
886+
elif _provider:
887+
check_warn(f"{_active_memory_provider} configured but not available", "run: hermes memory status")
888+
else:
889+
check_warn(f"{_active_memory_provider} plugin not found", "run: hermes memory setup")
890+
except Exception as _e:
891+
check_warn(f"{_active_memory_provider} check failed", str(_e))
878892

879893
# =========================================================================
880894
# Profiles

tests/hermes_cli/test_doctor.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,73 @@ def test_check_gateway_service_linger_skips_when_service_not_installed(monkeypat
136136
out = capsys.readouterr().out
137137
assert out == ""
138138
assert issues == []
139+
140+
141+
# ── Memory provider section (doctor should only check the *active* provider) ──
142+
143+
144+
class TestDoctorMemoryProviderSection:
145+
"""The ◆ Memory Provider section should respect memory.provider config."""
146+
147+
def _make_hermes_home(self, tmp_path, provider=""):
148+
"""Create a minimal HERMES_HOME with config.yaml."""
149+
home = tmp_path / ".hermes"
150+
home.mkdir(parents=True, exist_ok=True)
151+
import yaml
152+
config = {"memory": {"provider": provider}} if provider else {"memory": {}}
153+
(home / "config.yaml").write_text(yaml.dump(config))
154+
return home
155+
156+
def _run_doctor_and_capture(self, monkeypatch, tmp_path, provider=""):
157+
"""Run doctor and capture stdout."""
158+
home = self._make_hermes_home(tmp_path, provider)
159+
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
160+
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
161+
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
162+
(tmp_path / "project").mkdir(exist_ok=True)
163+
164+
# Stub tool availability (returns empty) so doctor runs past it
165+
fake_model_tools = types.SimpleNamespace(
166+
check_tool_availability=lambda *a, **kw: ([], []),
167+
TOOLSET_REQUIREMENTS={},
168+
)
169+
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
170+
171+
# Stub auth checks to avoid real API calls
172+
try:
173+
from hermes_cli import auth as _auth_mod
174+
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
175+
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
176+
except Exception:
177+
pass
178+
179+
import io, contextlib
180+
buf = io.StringIO()
181+
with contextlib.redirect_stdout(buf):
182+
doctor_mod.run_doctor(Namespace(fix=False))
183+
return buf.getvalue()
184+
185+
def test_no_provider_shows_builtin_ok(self, monkeypatch, tmp_path):
186+
out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="")
187+
assert "Memory Provider" in out
188+
assert "Built-in memory active" in out
189+
# Should NOT mention Honcho or Mem0 errors
190+
assert "Honcho API key" not in out
191+
assert "Mem0" not in out
192+
193+
def test_honcho_provider_not_installed_shows_fail(self, monkeypatch, tmp_path):
194+
# Make honcho import fail
195+
monkeypatch.setitem(
196+
sys.modules, "plugins.memory.honcho.client", None
197+
)
198+
out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="honcho")
199+
assert "Memory Provider" in out
200+
# Should show failure since honcho is set but not importable
201+
assert "Built-in memory active" not in out
202+
203+
def test_mem0_provider_not_installed_shows_fail(self, monkeypatch, tmp_path):
204+
# Make mem0 import fail
205+
monkeypatch.setitem(sys.modules, "plugins.memory.mem0", None)
206+
out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="mem0")
207+
assert "Memory Provider" in out
208+
assert "Built-in memory active" not in out

tests/tools/test_browser_camofox_persistence.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
_managed_persistence_enabled,
1717
camofox_close,
1818
camofox_navigate,
19+
camofox_soft_cleanup,
1920
check_camofox_available,
2021
cleanup_all_camofox_sessions,
2122
get_vnc_url,
@@ -240,3 +241,50 @@ def test_navigate_includes_vnc_hint(self, tmp_path, monkeypatch):
240241

241242
assert result["vnc_url"] == "http://localhost:6080"
242243
assert "vnc_hint" in result
244+
245+
246+
class TestCamofoxSoftCleanup:
247+
"""camofox_soft_cleanup drops local state only when managed persistence is on."""
248+
249+
def test_returns_true_and_drops_session_when_enabled(self, tmp_path, monkeypatch):
250+
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
251+
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
252+
253+
with _enable_persistence():
254+
_get_session("task-1")
255+
result = camofox_soft_cleanup("task-1")
256+
257+
assert result is True
258+
# Session should have been dropped from in-memory store
259+
import tools.browser_camofox as mod
260+
with mod._sessions_lock:
261+
assert "task-1" not in mod._sessions
262+
263+
def test_returns_false_when_disabled(self, tmp_path, monkeypatch):
264+
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
265+
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
266+
267+
_get_session("task-1")
268+
config = {"browser": {"camofox": {"managed_persistence": False}}}
269+
with patch("tools.browser_camofox.load_config", return_value=config):
270+
result = camofox_soft_cleanup("task-1")
271+
272+
assert result is False
273+
# Session should still be present — not dropped
274+
import tools.browser_camofox as mod
275+
with mod._sessions_lock:
276+
assert "task-1" in mod._sessions
277+
278+
def test_does_not_call_server_delete(self, tmp_path, monkeypatch):
279+
"""Soft cleanup must never hit the Camofox /sessions DELETE endpoint."""
280+
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
281+
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
282+
283+
with (
284+
_enable_persistence(),
285+
patch("tools.browser_camofox.requests.delete") as mock_delete,
286+
):
287+
_get_session("task-1")
288+
camofox_soft_cleanup("task-1")
289+
290+
mock_delete.assert_not_called()

tests/tools/test_browser_cleanup.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,62 @@ def test_cleanup_browser_clears_tracking_state(self):
6565
mock_stop.assert_called_once_with("task-1")
6666
mock_run.assert_called_once_with("task-1", "close", [], timeout=10)
6767

68+
def test_cleanup_camofox_managed_persistence_skips_close(self):
69+
"""When camofox mode + managed persistence, soft_cleanup fires instead of close."""
70+
browser_tool = self.browser_tool
71+
browser_tool._active_sessions["task-1"] = {
72+
"session_name": "sess-1",
73+
"bb_session_id": None,
74+
}
75+
browser_tool._session_last_activity["task-1"] = 123.0
76+
77+
with (
78+
patch("tools.browser_tool._is_camofox_mode", return_value=True),
79+
patch("tools.browser_tool._maybe_stop_recording") as mock_stop,
80+
patch(
81+
"tools.browser_tool._run_browser_command",
82+
return_value={"success": True},
83+
),
84+
patch("tools.browser_tool.os.path.exists", return_value=False),
85+
patch(
86+
"tools.browser_camofox.camofox_soft_cleanup",
87+
return_value=True,
88+
) as mock_soft,
89+
patch("tools.browser_camofox.camofox_close") as mock_close,
90+
):
91+
browser_tool.cleanup_browser("task-1")
92+
93+
mock_soft.assert_called_once_with("task-1")
94+
mock_close.assert_not_called()
95+
96+
def test_cleanup_camofox_no_persistence_calls_close(self):
97+
"""When camofox mode but managed persistence is off, camofox_close fires."""
98+
browser_tool = self.browser_tool
99+
browser_tool._active_sessions["task-1"] = {
100+
"session_name": "sess-1",
101+
"bb_session_id": None,
102+
}
103+
browser_tool._session_last_activity["task-1"] = 123.0
104+
105+
with (
106+
patch("tools.browser_tool._is_camofox_mode", return_value=True),
107+
patch("tools.browser_tool._maybe_stop_recording") as mock_stop,
108+
patch(
109+
"tools.browser_tool._run_browser_command",
110+
return_value={"success": True},
111+
),
112+
patch("tools.browser_tool.os.path.exists", return_value=False),
113+
patch(
114+
"tools.browser_camofox.camofox_soft_cleanup",
115+
return_value=False,
116+
) as mock_soft,
117+
patch("tools.browser_camofox.camofox_close") as mock_close,
118+
):
119+
browser_tool.cleanup_browser("task-1")
120+
121+
mock_soft.assert_called_once_with("task-1")
122+
mock_close.assert_called_once_with("task-1")
123+
68124
def test_emergency_cleanup_clears_all_tracking_state(self):
69125
browser_tool = self.browser_tool
70126
browser_tool._cleanup_done = False

tools/browser_camofox.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ def _managed_persistence_enabled() -> bool:
101101
"""
102102
try:
103103
camofox_cfg = load_config().get("browser", {}).get("camofox", {})
104-
except Exception:
104+
except Exception as exc:
105+
logger.warning("managed_persistence check failed, defaulting to disabled: %s", exc)
105106
return False
106107
return bool(camofox_cfg.get("managed_persistence"))
107108

@@ -172,6 +173,22 @@ def _drop_session(task_id: Optional[str]) -> Optional[Dict[str, Any]]:
172173
return _sessions.pop(task_id, None)
173174

174175

176+
def camofox_soft_cleanup(task_id: Optional[str] = None) -> bool:
177+
"""Release the in-memory session without destroying the server-side context.
178+
179+
When managed persistence is enabled the browser profile (and its cookies)
180+
must survive across agent tasks. This helper drops only the local tracking
181+
entry and returns ``True``. When managed persistence is *not* enabled it
182+
does nothing and returns ``False`` so the caller can fall back to
183+
:func:`camofox_close`.
184+
"""
185+
if _managed_persistence_enabled():
186+
_drop_session(task_id)
187+
logger.debug("Camofox soft cleanup for task %s (managed persistence)", task_id)
188+
return True
189+
return False
190+
191+
175192
# ---------------------------------------------------------------------------
176193
# HTTP helpers
177194
# ---------------------------------------------------------------------------

tools/browser_tool.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1935,11 +1935,15 @@ def cleanup_browser(task_id: Optional[str] = None) -> None:
19351935
if task_id is None:
19361936
task_id = "default"
19371937

1938-
# Also clean up Camofox session if running in Camofox mode
1938+
# Also clean up Camofox session if running in Camofox mode.
1939+
# Skip full close when managed persistence is enabled — the browser
1940+
# profile (and its session cookies) must survive across agent tasks.
1941+
# The inactivity reaper still frees idle resources.
19391942
if _is_camofox_mode():
19401943
try:
1941-
from tools.browser_camofox import camofox_close
1942-
camofox_close(task_id)
1944+
from tools.browser_camofox import camofox_close, camofox_soft_cleanup
1945+
if not camofox_soft_cleanup(task_id):
1946+
camofox_close(task_id)
19431947
except Exception as e:
19441948
logger.debug("Camofox cleanup for task %s: %s", task_id, e)
19451949

0 commit comments

Comments
 (0)