mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-16 23:41:10 +00:00
* feat(integrations): add dual-mode Hermes memory provider * style(integrations): apply repository formatting * fix(integrations): address Hermes provider review * fix(integrations): bound embedded recall startup cleanup * fix(integrations): finish embedded application cleanup * docs(integrations): expand Hermes verification guide
569 lines
18 KiB
Python
569 lines
18 KiB
Python
"""Focused tests for the external Hermes memory-provider plugin."""
|
|
|
|
# pylint: disable=missing-class-docstring,missing-function-docstring
|
|
# pylint: disable=protected-access,wrong-import-position,unused-import
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import types
|
|
import asyncio
|
|
import importlib
|
|
import threading
|
|
import time
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_PLUGIN_PARENT = Path(__file__).resolve().parents[2] / "integrations"
|
|
if str(_PLUGIN_PARENT) not in sys.path:
|
|
sys.path.insert(0, str(_PLUGIN_PARENT))
|
|
|
|
|
|
try:
|
|
import agent.memory_provider # type: ignore[import-not-found] # noqa: F401
|
|
except ImportError:
|
|
agent_module = types.ModuleType("agent")
|
|
memory_provider_module = types.ModuleType("agent.memory_provider")
|
|
|
|
class MemoryProvider:
|
|
"""Minimal Hermes contract used when hermes-agent is not installed."""
|
|
|
|
@dataclass(frozen=True)
|
|
class RecallStatus:
|
|
provider_label: str
|
|
count: int
|
|
glyph: str = "🧠"
|
|
|
|
memory_provider_module.MemoryProvider = MemoryProvider
|
|
memory_provider_module.RecallStatus = RecallStatus
|
|
agent_module.memory_provider = memory_provider_module
|
|
sys.modules["agent"] = agent_module
|
|
sys.modules["agent.memory_provider"] = memory_provider_module
|
|
|
|
PLUGIN_MODULE = importlib.import_module("hermes_agent")
|
|
BACKEND_MODULE = importlib.import_module("hermes_agent.backend")
|
|
CONFIG_MODULE = importlib.import_module("hermes_agent.config")
|
|
EMBEDDED_MODULE = importlib.import_module("hermes_agent.embedded_backend")
|
|
CLIENT_MODULE = importlib.import_module("hermes_agent.client")
|
|
|
|
ReMeMemoryProvider = PLUGIN_MODULE.ReMeMemoryProvider
|
|
ReMeBackendError = BACKEND_MODULE.ReMeBackendError
|
|
ReMeConfig = CONFIG_MODULE.ReMeConfig
|
|
ReMeConfigError = CONFIG_MODULE.ReMeConfigError
|
|
config_path = CONFIG_MODULE.config_path
|
|
load_config = CONFIG_MODULE.load_config
|
|
parse_config = CONFIG_MODULE.parse_config
|
|
save_config = CONFIG_MODULE.save_config
|
|
EmbeddedReMeBackend = EMBEDDED_MODULE.EmbeddedReMeBackend
|
|
ReMeHttpClient = CLIENT_MODULE.ReMeHttpClient
|
|
scoped_session_id = PLUGIN_MODULE._scoped_session_id
|
|
|
|
|
|
def test_config_defaults_to_http(tmp_path):
|
|
config = load_config(tmp_path)
|
|
|
|
assert config.mode == "http"
|
|
assert config.endpoint == "http://127.0.0.1:2333"
|
|
|
|
|
|
def test_provider_schema_exposes_mode_specific_and_advanced_fields():
|
|
fields = {field["key"]: field for field in ReMeMemoryProvider().get_config_schema()}
|
|
|
|
assert set(fields) == {
|
|
"mode",
|
|
"endpoint",
|
|
"workspace_dir",
|
|
"reme_config",
|
|
"recall_limit",
|
|
"recall_timeout",
|
|
"request_timeout",
|
|
"health_timeout",
|
|
"health_retry_seconds",
|
|
"shutdown_timeout",
|
|
}
|
|
assert fields["endpoint"]["when"] == {"mode": "http"}
|
|
assert fields["workspace_dir"]["when"] == {"mode": "embedded"}
|
|
|
|
|
|
def test_current_config_precedes_legacy(tmp_path):
|
|
(tmp_path / "reme.json").write_text(
|
|
'{"endpoint": "http://legacy:1"}',
|
|
encoding="utf-8",
|
|
)
|
|
current = config_path(tmp_path)
|
|
current.parent.mkdir()
|
|
current.write_text('{"endpoint": "http://current:2"}', encoding="utf-8")
|
|
|
|
assert load_config(tmp_path).endpoint == "http://current:2"
|
|
|
|
|
|
def test_sparse_dashboard_config_inherits_legacy_values(tmp_path):
|
|
(tmp_path / "reme.json").write_text(
|
|
'{"endpoint": "http://legacy:2444", "recall_limit": 3}',
|
|
encoding="utf-8",
|
|
)
|
|
current = config_path(tmp_path)
|
|
current.parent.mkdir()
|
|
current.write_text('{"recall_limit": 7}', encoding="utf-8")
|
|
|
|
config = load_config(tmp_path)
|
|
|
|
assert config.endpoint == "http://legacy:2444"
|
|
assert config.recall_limit == 7
|
|
|
|
|
|
def test_embedded_config_normalizes_workspace(tmp_path):
|
|
config = parse_config(
|
|
{"mode": " EMBEDDED ", "workspace_dir": str(tmp_path / "workspace")},
|
|
hermes_home=tmp_path,
|
|
)
|
|
|
|
assert config.mode == "embedded"
|
|
assert config.workspace_dir == str((tmp_path / "workspace").absolute())
|
|
|
|
|
|
def test_embedded_config_requires_workspace(tmp_path):
|
|
with pytest.raises(ReMeConfigError, match="workspace_dir"):
|
|
parse_config({"mode": "embedded"}, hermes_home=tmp_path)
|
|
|
|
|
|
@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")])
|
|
def test_config_rejects_non_finite_timeouts(tmp_path, value):
|
|
with pytest.raises(ReMeConfigError, match="positive number"):
|
|
parse_config({"request_timeout": value}, hermes_home=tmp_path)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"endpoint",
|
|
[
|
|
"http://user:secret@127.0.0.1:2333",
|
|
"http://127.0.0.1:2333?token=secret",
|
|
"http://127.0.0.1:2333#fragment",
|
|
"http://127.0.0.1:invalid",
|
|
],
|
|
)
|
|
def test_config_and_client_reject_unsafe_endpoint_shapes(tmp_path, endpoint):
|
|
with pytest.raises(ReMeConfigError, match="absolute http"):
|
|
parse_config({"endpoint": endpoint}, hermes_home=tmp_path)
|
|
with pytest.raises(ValueError, match="absolute http"):
|
|
ReMeHttpClient(endpoint, timeout=1)
|
|
|
|
|
|
def test_save_config_uses_dashboard_layout_and_private_permissions(tmp_path):
|
|
saved = save_config({"mode": "http", "recall_limit": 7}, tmp_path)
|
|
path = config_path(tmp_path)
|
|
|
|
assert saved.recall_limit == 7
|
|
assert path.is_file()
|
|
assert path.stat().st_mode & 0o777 == 0o600
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, answer="", metadata=None, success=True):
|
|
self.answer = answer
|
|
self.metadata = metadata or {}
|
|
self.success = success
|
|
|
|
def model_dump(self):
|
|
return {
|
|
"answer": self.answer,
|
|
"metadata": self.metadata,
|
|
"success": self.success,
|
|
}
|
|
|
|
|
|
class _FakeApplication:
|
|
instances = []
|
|
|
|
def __init__(self, **config):
|
|
self.config = config
|
|
self.started = False
|
|
self.closed = False
|
|
self.calls = []
|
|
self.__class__.instances.append(self)
|
|
|
|
async def start(self):
|
|
self.started = True
|
|
|
|
async def close(self):
|
|
self.closed = True
|
|
|
|
async def run_job(self, name, **kwargs):
|
|
self.calls.append((name, kwargs))
|
|
if name == "health_check":
|
|
return _FakeResponse(metadata={"health": {"healthy": True}})
|
|
return _FakeResponse(answer=name, metadata={"counts": {"returned": 1}})
|
|
|
|
|
|
def test_embedded_backend_owns_application_lifecycle(monkeypatch, tmp_path):
|
|
import reme
|
|
import reme.config
|
|
|
|
_FakeApplication.instances.clear()
|
|
monkeypatch.setattr(reme, "Application", _FakeApplication)
|
|
monkeypatch.setattr(
|
|
reme.config,
|
|
"resolve_app_config",
|
|
lambda **kwargs: {
|
|
"workspace_dir": kwargs["workspace_dir"],
|
|
"marker": kwargs["config"],
|
|
},
|
|
)
|
|
backend = EmbeddedReMeBackend(str(tmp_path), start_timeout=2)
|
|
|
|
backend.start()
|
|
assert backend.health(timeout=2)["success"] is True
|
|
assert backend.search("needle", limit=3, timeout=2)["answer"] == "search"
|
|
backend.auto_memory("session", [{"role": "user", "content": "hello"}], timeout=2)
|
|
backend.close(timeout=2)
|
|
|
|
app = _FakeApplication.instances[0]
|
|
assert app.started is True
|
|
assert app.closed is True
|
|
assert app.config == {
|
|
"workspace_dir": str(tmp_path),
|
|
"marker": "default",
|
|
}
|
|
assert [name for name, _ in app.calls] == ["health_check", "search", "auto_memory"]
|
|
assert backend.state == "closed"
|
|
|
|
|
|
class _FakeBackend:
|
|
label = "fake"
|
|
|
|
def __init__(self):
|
|
self.writes = []
|
|
self.closed = False
|
|
|
|
def start(self):
|
|
return None
|
|
|
|
def health(self, *, timeout):
|
|
del timeout
|
|
return {"success": True, "metadata": {"health": {"healthy": True}}}
|
|
|
|
def search(self, query, *, limit, timeout):
|
|
del query, limit, timeout
|
|
return {
|
|
"success": True,
|
|
"answer": " remembered ",
|
|
"metadata": {"counts": {"returned": 2}},
|
|
}
|
|
|
|
def auto_memory(self, session_id, messages, *, timeout):
|
|
del timeout
|
|
self.writes.append((session_id, messages))
|
|
return {"success": True}
|
|
|
|
def close(self, *, timeout):
|
|
del timeout
|
|
self.closed = True
|
|
|
|
|
|
def test_provider_selects_backend_recalls_and_writes(monkeypatch, tmp_path):
|
|
backend = _FakeBackend()
|
|
monkeypatch.setattr(PLUGIN_MODULE, "load_config", lambda home=None: ReMeConfig())
|
|
monkeypatch.setattr(PLUGIN_MODULE, "_backend_for", lambda config: backend)
|
|
provider = ReMeMemoryProvider()
|
|
provider.initialize("session/one", hermes_home=tmp_path, agent_identity="work")
|
|
|
|
assert provider.prefetch("project decision") == "remembered"
|
|
assert provider.recall_status().count == 2
|
|
provider.sync_turn("hello", "hi")
|
|
provider.shutdown()
|
|
|
|
assert len(backend.writes) == 1
|
|
assert backend.writes[0][0].startswith("hermes-work-session-one-")
|
|
assert backend.closed is True
|
|
|
|
|
|
def test_session_scope_distinguishes_profiles_and_ambiguous_names():
|
|
assert scoped_session_id("profile/a", "session") != scoped_session_id(
|
|
"profile-a",
|
|
"session",
|
|
)
|
|
assert scoped_session_id("profile", "session/a") != scoped_session_id(
|
|
"profile",
|
|
"session-a",
|
|
)
|
|
|
|
|
|
def test_reinitialize_closes_previous_backend(monkeypatch, tmp_path):
|
|
backends = [_FakeBackend(), _FakeBackend()]
|
|
monkeypatch.setattr(PLUGIN_MODULE, "load_config", lambda home=None: ReMeConfig())
|
|
monkeypatch.setattr(PLUGIN_MODULE, "_backend_for", lambda config: backends.pop(0))
|
|
provider = ReMeMemoryProvider()
|
|
|
|
provider.initialize("first", hermes_home=tmp_path)
|
|
first = provider._backend
|
|
provider.initialize("second", hermes_home=tmp_path)
|
|
|
|
assert first is not None and first.closed is True
|
|
assert provider._backend is not first
|
|
provider.shutdown()
|
|
|
|
|
|
def test_provider_failure_does_not_escape_model_path(monkeypatch, tmp_path):
|
|
backend = _FakeBackend()
|
|
backend.search = lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
ReMeBackendError("offline"),
|
|
)
|
|
monkeypatch.setattr(PLUGIN_MODULE, "load_config", lambda home=None: ReMeConfig())
|
|
monkeypatch.setattr(PLUGIN_MODULE, "_backend_for", lambda config: backend)
|
|
provider = ReMeMemoryProvider()
|
|
provider.initialize("session", hermes_home=tmp_path)
|
|
|
|
assert provider.prefetch("query") == ""
|
|
assert provider.recall_status() is None
|
|
provider.shutdown()
|
|
|
|
|
|
def test_provider_reports_embedded_workspace_for_hermes_backup(monkeypatch, tmp_path):
|
|
workspace = tmp_path / "workspace"
|
|
monkeypatch.setattr(
|
|
PLUGIN_MODULE,
|
|
"load_config",
|
|
lambda home=None: ReMeConfig(mode="embedded", workspace_dir=str(workspace)),
|
|
)
|
|
|
|
assert ReMeMemoryProvider().backup_paths() == [str(workspace)]
|
|
|
|
|
|
def test_backend_creation_is_serialized(monkeypatch):
|
|
provider = ReMeMemoryProvider()
|
|
provider._config = ReMeConfig()
|
|
created = []
|
|
|
|
def factory(config):
|
|
del config
|
|
time.sleep(0.05)
|
|
backend = _FakeBackend()
|
|
created.append(backend)
|
|
return backend
|
|
|
|
monkeypatch.setattr(PLUGIN_MODULE, "_backend_for", factory)
|
|
gate = threading.Barrier(3)
|
|
results = []
|
|
|
|
def ensure():
|
|
gate.wait()
|
|
results.append(provider._ensure_backend())
|
|
|
|
threads = [threading.Thread(target=ensure) for _ in range(2)]
|
|
for thread in threads:
|
|
thread.start()
|
|
gate.wait()
|
|
for thread in threads:
|
|
thread.join(timeout=2)
|
|
|
|
assert results == [True, True]
|
|
assert len(created) == 1
|
|
provider.shutdown()
|
|
|
|
|
|
def test_shutdown_defers_close_until_inflight_recall_finishes():
|
|
entered = threading.Event()
|
|
release = threading.Event()
|
|
|
|
class BlockingBackend(_FakeBackend):
|
|
def search(self, query, *, limit, timeout):
|
|
del query, limit, timeout
|
|
entered.set()
|
|
release.wait(timeout=2)
|
|
return {"success": True, "answer": "remembered", "metadata": {}}
|
|
|
|
backend = BlockingBackend()
|
|
provider = ReMeMemoryProvider()
|
|
provider._config = ReMeConfig()
|
|
provider._backend = backend
|
|
provider._backend_available = True
|
|
provider._shutdown_timeout = 0.05
|
|
recall = threading.Thread(target=provider.prefetch, args=("query",))
|
|
recall.start()
|
|
assert entered.wait(timeout=1)
|
|
|
|
provider.shutdown()
|
|
assert backend.closed is False
|
|
release.set()
|
|
recall.join(timeout=2)
|
|
|
|
assert recall.is_alive() is False
|
|
assert backend.closed is True
|
|
|
|
|
|
def test_shutdown_drains_all_accepted_writes_before_closing_backend():
|
|
entered = threading.Event()
|
|
release = threading.Event()
|
|
|
|
class BlockingBackend(_FakeBackend):
|
|
def auto_memory(self, session_id, messages, *, timeout):
|
|
del timeout
|
|
self.writes.append((session_id, messages))
|
|
if len(self.writes) == 1:
|
|
entered.set()
|
|
release.wait(timeout=2)
|
|
|
|
backend = BlockingBackend()
|
|
provider = ReMeMemoryProvider()
|
|
provider._config = ReMeConfig()
|
|
provider._backend = backend
|
|
provider._backend_available = True
|
|
provider._shutdown_timeout = 1
|
|
for index in range(3):
|
|
provider.sync_turn(f"user {index}", f"assistant {index}", session_id=f"session-{index}")
|
|
assert entered.wait(timeout=1)
|
|
|
|
shutdown = threading.Thread(target=provider.shutdown)
|
|
shutdown.start()
|
|
deadline = time.monotonic() + 1
|
|
while not provider._shutdown_started and time.monotonic() < deadline:
|
|
time.sleep(0.001)
|
|
assert provider._shutdown_started is True
|
|
release.set()
|
|
shutdown.join(timeout=2)
|
|
|
|
assert shutdown.is_alive() is False
|
|
assert len(backend.writes) == 3
|
|
assert backend.closed is True
|
|
|
|
|
|
def test_recall_timeout_includes_waiting_for_background_write():
|
|
write_entered = threading.Event()
|
|
write_release = threading.Event()
|
|
search_entered = threading.Event()
|
|
|
|
class BlockingBackend(_FakeBackend):
|
|
def auto_memory(self, session_id, messages, *, timeout):
|
|
del session_id, messages, timeout
|
|
write_entered.set()
|
|
write_release.wait(timeout=2)
|
|
|
|
def search(self, query, *, limit, timeout):
|
|
del query, limit, timeout
|
|
search_entered.set()
|
|
return {"success": True, "answer": "remembered", "metadata": {}}
|
|
|
|
backend = BlockingBackend()
|
|
provider = ReMeMemoryProvider()
|
|
provider._config = ReMeConfig()
|
|
provider._backend = backend
|
|
provider._backend_available = True
|
|
provider._recall_timeout = 0.05
|
|
provider.sync_turn("user", "assistant", session_id="session")
|
|
assert write_entered.wait(timeout=1)
|
|
|
|
started = time.monotonic()
|
|
assert provider.prefetch("query") == ""
|
|
elapsed = time.monotonic() - started
|
|
|
|
assert elapsed < 0.2
|
|
assert search_entered.is_set() is False
|
|
write_release.set()
|
|
provider.shutdown()
|
|
|
|
|
|
def test_recall_timeout_includes_embedded_startup_failure_cleanup(monkeypatch, tmp_path):
|
|
import reme
|
|
import reme.config
|
|
|
|
class SlowApplication:
|
|
instances = []
|
|
|
|
def __init__(self, **config):
|
|
del config
|
|
self.closed = threading.Event()
|
|
self.thread_pool = object()
|
|
self.__class__.instances.append(self)
|
|
|
|
async def start(self):
|
|
time.sleep(0.25)
|
|
|
|
async def close(self):
|
|
await asyncio.sleep(0.4)
|
|
self.thread_pool = None
|
|
self.closed.set()
|
|
|
|
monkeypatch.setattr(reme, "Application", SlowApplication)
|
|
monkeypatch.setattr(reme.config, "resolve_app_config", lambda **kwargs: kwargs)
|
|
backend = EmbeddedReMeBackend(str(tmp_path), start_timeout=1)
|
|
monkeypatch.setattr(PLUGIN_MODULE, "_backend_for", lambda config: backend)
|
|
provider = ReMeMemoryProvider()
|
|
provider._config = ReMeConfig(
|
|
mode="embedded",
|
|
workspace_dir=str(tmp_path),
|
|
request_timeout=1,
|
|
recall_timeout=0.15,
|
|
shutdown_timeout=0.6,
|
|
)
|
|
provider._recall_timeout = 0.15
|
|
provider._shutdown_timeout = 0.6
|
|
|
|
started = time.monotonic()
|
|
assert provider.prefetch("query") == ""
|
|
elapsed = time.monotonic() - started
|
|
|
|
assert elapsed < 0.25
|
|
assert backend._thread is not None
|
|
backend._thread.join(timeout=1)
|
|
assert backend._thread.is_alive() is False
|
|
app = SlowApplication.instances[0]
|
|
assert app.closed.is_set()
|
|
assert app.thread_pool is None
|
|
|
|
|
|
def test_embedded_start_timeout_closes_real_application_resources(monkeypatch, tmp_path):
|
|
import reme
|
|
import reme.config
|
|
|
|
from reme.application import Application
|
|
from reme.components import BaseComponent
|
|
|
|
component_closed = threading.Event()
|
|
|
|
class SlowComponent(BaseComponent):
|
|
component_type = "slow_test"
|
|
|
|
async def _start(self):
|
|
time.sleep(0.25)
|
|
|
|
async def _close(self):
|
|
component_closed.set()
|
|
|
|
app = Application(
|
|
workspace_dir=str(tmp_path),
|
|
service={"backend": "cli"},
|
|
thread_pool_max_workers=1,
|
|
enable_logo=False,
|
|
log_to_console=False,
|
|
log_to_file=False,
|
|
)
|
|
component = SlowComponent(name="slow", app_context=app.context)
|
|
app.context.components["slow_test"] = {"slow": component}
|
|
monkeypatch.setattr(reme, "Application", lambda **config: app)
|
|
monkeypatch.setattr(reme.config, "resolve_app_config", lambda **kwargs: kwargs)
|
|
backend = EmbeddedReMeBackend(str(tmp_path), start_timeout=1)
|
|
|
|
started = time.monotonic()
|
|
with pytest.raises(ReMeBackendError, match="timed out"):
|
|
backend.start(deadline=started + 0.15)
|
|
elapsed = time.monotonic() - started
|
|
|
|
assert elapsed < 0.25
|
|
assert backend._thread is not None
|
|
backend._thread.join(timeout=1)
|
|
assert backend._thread.is_alive() is False
|
|
assert component_closed.is_set()
|
|
assert component.is_started is False
|
|
assert app.context.thread_pool is None
|
|
|
|
|
|
def test_shutdown_discard_keeps_sentinel_for_inflight_writer():
|
|
provider = ReMeMemoryProvider()
|
|
provider._write_queue.put({"session_id": "queued"})
|
|
provider._write_queue.put(None)
|
|
|
|
assert provider._discard_queued_writes() == 1
|
|
assert provider._write_queue.get_nowait() is None
|