Files
hermes-agent/tests/hermes_cli/test_memory_provider_migration.py
Teknium 6a2909c6b1 fix(memory): a failed agent-start provider install is not retried by every process start
recover_at_startup tried the configured provider's catalog install once per
PROCESS, so a home whose install fails (PM resolve/clone error, offline) retried
and failed again on every `hermes chat -q`, cron run, gateway agent and Desktop
restart. Each attempt is a recorded hermes.extension.install.count failure:
hindsight logged 1048 failures over 204 failing install-days (~5 per day per
install, the retry-loop shape) after it left core.

The left-core migration already had the fix: a failure stamp under
<home>/cache gates startup attempts for an hour while `hermes update` always
retries. Move that stamp into memory_provider_migration and use it in
recover_at_startup too; the backed-off start still tells the user memory is
off and names the install command.
2026-10-09 02:42:26 -07:00

318 lines
16 KiB
Python

"""A memory provider that left core is installed from the catalog, config untouched; a provider the
catalog does not know is reported with the one-liner instead of silently dropping memory."""
from pathlib import Path
import pytest
from hermes_cli import memory_provider_migration as mig
@pytest.fixture
def home(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text("memory:\n provider: honcho\n honcho:\n workspace: keep-me\n")
monkeypatch.setattr(mig, "provider_present", lambda name, home: (home / "plugins" / name).is_dir())
return tmp_path
def test_missing_provider_installs_its_catalog_plugin_and_keeps_config(home, monkeypatch):
monkeypatch.setattr(mig, "catalog_source", lambda name: name)
calls: list[str] = []
said: list[str] = []
def fake_install(name: str) -> dict:
calls.append(name)
(home / "plugins" / name).mkdir(parents=True)
return {"ok": True}
assert mig.migrate_home(home, install=fake_install, say=said.append) == "honcho"
assert calls == ["honcho"]
# Must not imply config.yaml's memory.<name> keys still apply: the plugin reads its own (#124038).
assert "memory.honcho" not in said[0] and "hermes memory status" in said[0]
assert "workspace: keep-me" in (home / "config.yaml").read_text()
# present now → nothing to do, nothing said
assert mig.migrate_home(home, install=fake_install, say=said.append) is None
assert calls == ["honcho"]
def test_presence_is_checked_in_the_home_being_migrated(tmp_path, monkeypatch):
"""The update hook walks several profile homes from one process; a provider installed in profile B
must count as present for B even when the process-level home (A) lacks it. Real lookup, no mock."""
a, b = tmp_path / "a", tmp_path / "b"
for h in (a, b):
h.mkdir(); (h / "config.yaml").write_text("memory:\n provider: twin\n")
(b / "plugins" / "twin").mkdir(parents=True)
(b / "plugins" / "twin" / "__init__.py").write_text("class Twin(MemoryProvider): ...\n")
monkeypatch.setenv("HERMES_HOME", str(a))
monkeypatch.setattr(mig, "catalog_source", lambda name: name)
installs: list[Path] = []
assert mig.migrate_home(b, install=lambda n: installs.append(b) or {"ok": True}, say=lambda s: None) is None
assert installs == []
assert mig.migrate_home(a, install=lambda n: installs.append(a) or {"ok": True}, say=lambda s: None) == "twin"
def test_provider_unknown_to_catalog_is_reported_not_installed(home, monkeypatch):
monkeypatch.setattr(mig, "catalog_source", lambda name: None)
said: list[str] = []
assert mig.migrate_home(home, install=lambda n: pytest.fail("must not install"), say=said.append) is None
assert "not in the plugin catalog" in said[0] and "memory.provider" in said[0]
def test_startup_recovery_attempts_each_profile_home(tmp_path, monkeypatch):
"""One multiplexed process can start agents for two homes missing the same provider."""
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from pm import install as pm_install
homes = [tmp_path / "a", tmp_path / "b"]
for profile_home in homes:
profile_home.mkdir()
(profile_home / "config.yaml").write_text("memory:\n provider: twin\n", encoding="utf-8")
monkeypatch.setattr(mig, "_attempted", set())
monkeypatch.setattr(mig, "catalog_source", lambda name: name)
monkeypatch.setattr(mig, "_LEFT_CORE", frozenset({"twin"}))
monkeypatch.setattr(pm_install, "lazy_installs_allowed", lambda: True)
installed = []
def fake_installer(profile_home, **_kw):
def install(name):
plugin_dir = profile_home / "plugins" / name
plugin_dir.mkdir(parents=True)
(plugin_dir / "__init__.py").write_text("class Twin(MemoryProvider): ...\n", encoding="utf-8")
installed.append(profile_home)
return {"ok": True}
return install
monkeypatch.setattr(mig, "_install_into", fake_installer)
outcomes = []
for profile_home in (homes[0], homes[1], homes[0]):
token = set_hermes_home_override(profile_home)
try:
outcomes.append(mig.recover_at_startup("twin"))
finally:
reset_hermes_home_override(token)
assert outcomes == [True, True, False]
assert installed == homes
@pytest.mark.parametrize(("name", "tty", "lazy", "consent"), [
("hindsight", False, True, True), # Desktop/gateway/scripted update: nobody can answer the prompt
("hindsight", False, False, False), # allow_lazy_installs off: still refused, with the install hint
("hindsight", True, True, False), # a terminal: the user is asked
("someplugin", False, True, False), # never bundled: a config naming it is not consent to install it
])
def test_unattended_migration_install_carries_lazy_install_consent(tmp_path, monkeypatch, name, tty, lazy, consent):
"""Every provider still in core declares Python deps; without this, no non-interactive migration
(the whole point of the agent-start hook) can ever publish one."""
import io
from hermes_cli import plugins_cmd
from pm import install as pm_install
class _Stream(io.StringIO):
def isatty(self):
return tty
monkeypatch.setattr("sys.stdin", _Stream())
monkeypatch.setattr("sys.stdout", _Stream())
monkeypatch.setattr(pm_install, "lazy_installs_allowed", lambda: lazy)
seen = {}
monkeypatch.setattr(plugins_cmd, "dashboard_install_plugin", lambda *a, **kw: seen.update(kw) or {"ok": True})
assert mig._install_into(tmp_path)(name) == {"ok": True}
assert seen["catalog_name"] == name
assert seen.get("assume_deps_consent", False) is consent
@pytest.mark.parametrize(("name", "installs"), [("hindsight", True), ("someplugin", False)])
def test_startup_recovery_never_asks_a_dependency_question(tmp_path, monkeypatch, name, installs):
"""Agent init cannot answer a prompt: in the CLI the question hangs the turn behind the chat input,
with no terminal it is refused on every process start. A provider that shipped in core installs
with its built-in's consent even on a terminal; any other one is not attempted and names the command."""
import io
from hermes_cli import plugins_cmd
from pm import install as pm_install
class _Tty(io.StringIO):
def isatty(self):
return True
(tmp_path / "config.yaml").write_text(f"memory:\n provider: {name}\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr("sys.stdin", _Tty())
monkeypatch.setattr("sys.stdout", _Tty())
monkeypatch.setattr(mig, "_attempted", set())
monkeypatch.setattr(mig, "catalog_source", lambda n: n)
monkeypatch.setattr(pm_install, "lazy_installs_allowed", lambda: True)
monkeypatch.setattr("builtins.input", lambda *_a: pytest.fail("agent init asked a question"))
seen: list[dict] = []
monkeypatch.setattr(plugins_cmd, "dashboard_install_plugin", lambda *a, **kw: seen.append(kw) or {"ok": False})
said: list[str] = []
mig.recover_at_startup(name, say=said.append)
assert [kw["assume_deps_consent"] for kw in seen] == ([True] if installs else [])
assert installs or f"`hermes plugins install {name}`" in said[-1]
def test_update_asks_once_and_names_each_profile(tmp_path, monkeypatch):
"""Profiles sharing one environment face one dependency question for a migrating provider
(#125794); every line names its profile, and a decline names the rest once with their commands."""
from hermes_cli import plugins_cmd_install
root = tmp_path / ".hermes"
homes = [root, root / "profiles" / "work-a", root / "profiles" / "work-b"]
for profile_home in homes:
profile_home.mkdir(parents=True, exist_ok=True)
(profile_home / "config.yaml").write_text("memory:\n provider: twin\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(root))
monkeypatch.setattr("hermes_constants.get_default_hermes_root", lambda: root)
monkeypatch.setattr("pm.plugins_state.dependency_homes", lambda: homes)
monkeypatch.setattr(mig, "provider_present", lambda name, home: (home / "plugins" / name).is_dir())
monkeypatch.setattr(mig, "catalog_source", lambda name: name)
monkeypatch.setattr(plugins_cmd_install.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(plugins_cmd_install.sys.stdout, "isatty", lambda: True)
class Console:
def print(self, *args, **kwargs):
pass
def installer(profile_home):
def install(name): # the real consent gate every catalog install passes through
consented, reason = plugins_cmd_install._consent_python_deps(name, ("twin-client",), Console())
if not consented:
return {"ok": False, "error": reason}
(profile_home / "plugins" / name).mkdir(parents=True)
return {"ok": True}
return install
monkeypatch.setattr(mig, "_install_into", installer)
for answer, expected in (("n", []), ("y", ["twin"] * 3)):
prompts, said = [], []
monkeypatch.setattr("builtins.input", lambda prompt, a=answer: prompts.append(prompt) or a)
assert mig.migrate_all_homes(say=said.append) == expected
assert len(prompts) == 1
if answer == "n":
assert "[profile 'default']" in said[1] and "dependency install declined" in said[1]
assert "hermes -p work-a plugins install twin" in said[2]
assert "hermes -p work-b plugins install twin" in said[2]
assert not any((h / "plugins").exists() for h in homes)
else:
assert [line.split("]")[0] for line in said[1:]] == [
" [profile 'default'", " [profile 'work-a'", " [profile 'work-b'"]
def _profile_homes(tmp_path, monkeypatch, *names):
root = tmp_path / ".hermes"
homes = [root, *(root / "profiles" / n for n in names)]
for profile_home in homes:
profile_home.mkdir(parents=True, exist_ok=True)
(profile_home / "config.yaml").write_text("memory:\n provider: twin\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(root))
monkeypatch.setattr("hermes_constants.get_default_hermes_root", lambda: root)
monkeypatch.setattr(mig, "provider_present", lambda name, home: (home / "plugins" / name).is_dir())
monkeypatch.setattr(mig, "catalog_source", lambda name: name)
monkeypatch.setattr(mig, "_LEFT_CORE", frozenset({"twin"})) # stands in for a formerly bundled provider
return homes
def test_unattended_update_refusal_in_one_profile_does_not_skip_the_others(tmp_path, monkeypatch):
"""Unattended consent is each home's own allow_lazy_installs: the default home refusing says
nothing about a profile that allows it, which must still migrate (not be reported as failed)."""
import io
from hermes_cli import plugins_cmd
from hermes_constants import get_hermes_home
from pm import install as pm_install
default, work = _profile_homes(tmp_path, monkeypatch, "work")
monkeypatch.setattr("pm.plugins_state.dependency_homes", lambda: [default, work])
monkeypatch.setattr("sys.stdin", io.StringIO()) # Desktop / scripted `hermes update`: no terminal
monkeypatch.setattr(pm_install, "lazy_installs_allowed", lambda: get_hermes_home() == work)
def install(*_a, catalog_name, assume_deps_consent, **_kw):
if not assume_deps_consent:
return {"ok": False, "error": "dependency install skipped (non-interactive)"}
(get_hermes_home() / "plugins" / catalog_name).mkdir(parents=True)
return {"ok": True}
monkeypatch.setattr(plugins_cmd, "dashboard_install_plugin", install)
said: list[str] = []
assert mig.migrate_all_homes(say=said.append) == ["twin"]
assert (work / "plugins" / "twin").is_dir() and not (default / "plugins").exists()
assert not any("either" in line for line in said)
def test_startup_hint_installs_into_the_profile_that_printed_it(tmp_path, monkeypatch):
"""A bare `hermes plugins install` from a shell targets the sticky profile, so the hint an agent
started for a named profile (Desktop, gateway, `hermes -p`) prints must carry `-p`."""
from pm import install as pm_install
default, work = _profile_homes(tmp_path, monkeypatch, "work")
monkeypatch.setattr(pm_install, "lazy_installs_allowed", lambda: False)
for profile_home, command in ((work, "hermes -p work plugins install twin"),
(default, "hermes plugins install twin")):
monkeypatch.setattr(mig, "_attempted", set())
monkeypatch.setenv("HERMES_HOME", str(profile_home))
said: list[str] = []
assert mig.recover_at_startup("twin", say=said.append) is False
assert f"`{command}`" in said[0]
def test_missing_catalog_provider_recovery_names_the_profile_install_command(tmp_path, monkeypatch, capsys):
"""Offline, lazy installs off, or a failed migration: every surface a user turns to (doctor,
``memory status``, ``memory setup honcho``, ``hermes honcho``) names the one command that installs
the catalog plugin into THIS profile. Real in-tree catalog, nothing installed."""
from types import SimpleNamespace
from hermes_cli import doctor_state, memory_setup
from hermes_cli._parser import build_top_level_parser
home = tmp_path / "profiles" / "work"
home.mkdir(parents=True)
(home / "config.yaml").write_text("memory:\n provider: honcho\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(home))
want = "hermes -p work plugins install honcho"
doctor_state._memory_provider_generic("honcho")
memory_setup.cmd_status(SimpleNamespace())
memory_setup.cmd_setup_provider("honcho")
assert capsys.readouterr().out.count(want) == 3
parser, _subparsers, _chat = build_top_level_parser()
with pytest.raises(SystemExit):
parser.parse_args(["honcho", "status"])
assert want in capsys.readouterr().err
def test_recovery_copy_without_a_catalog_memory_entry_keeps_the_generic_hint(tmp_path, monkeypatch, capsys):
from hermes_cli import doctor_state
monkeypatch.setenv("HERMES_HOME", str(tmp_path)) # not a profile home: no -p to add
assert mig.catalog_install_hint("honcho", category="memory") == "hermes plugins install honcho"
assert mig.catalog_install_hint("honcho", category="tools") is None
assert mig.catalog_install_hint("no-such-provider") is None
doctor_state._memory_provider_generic("no-such-provider")
assert "run: hermes memory setup" in capsys.readouterr().out
def test_startup_recovery_backs_off_after_a_failed_install(tmp_path, monkeypatch):
"""A failed agent-start install is not retried by every following process start (each `hermes chat`,
cron run, Desktop restart): one failure, then a quiet hour; `hermes update` still retries."""
from pm import install as pm_install
(tmp_path / "config.yaml").write_text("memory:\n provider: hindsight\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(mig, "catalog_source", lambda n: n)
monkeypatch.setattr(pm_install, "lazy_installs_allowed", lambda: True)
attempts: list[str] = []
monkeypatch.setattr(mig, "_install_into", lambda home, **_kw: (lambda n: attempts.append(n) or {"ok": False, "error": "uv lock exited 1"}))
said: list[str] = []
for _process in range(3):
monkeypatch.setattr(mig, "_attempted", set()) # a fresh process each time
assert mig.recover_at_startup("hindsight", say=said.append) is False
assert attempts == ["hindsight"]
assert "`hermes plugins install hindsight`" in said[-1] # still told memory is off, and the fix
monkeypatch.setattr(mig, "STARTUP_RETRY_SECONDS", 0.0)
monkeypatch.setattr(mig, "_attempted", set())
mig.recover_at_startup("hindsight")
assert attempts == ["hindsight", "hindsight"]