feat(integrations): add Hermes provider with shared memory utilities

This commit is contained in:
kartik-mem0
2026-09-18 13:21:43 +05:30
parent 84bf468176
commit 4284ef0d2a
40 changed files with 4168 additions and 954 deletions
@@ -0,0 +1,53 @@
"""Offline contract smoke against a real Hermes checkout.
HERMES_SOURCE=/path/to/hermes-agent python tests/smoke_hermes.py
Host modules are real; only the Mem0 backend is mocked. Uses a temporary profile.
"""
import json
import os
import pathlib
import shutil
import sys
import tempfile
from unittest.mock import Mock
hermes_source = pathlib.Path(os.environ["HERMES_SOURCE"]).resolve()
plugin_source = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(hermes_source))
with tempfile.TemporaryDirectory() as d:
os.environ["HERMES_HOME"] = d
os.environ["MEM0_API_KEY"] = "test-key"
import plugins.memory as pm
from agent.memory_manager import MemoryManager
destination = pathlib.Path(d) / "plugins" / "mem0"
shutil.copytree(plugin_source, destination)
assert pm.find_provider_dir("mem0") == hermes_source / "plugins" / "memory" / "mem0"
pm._MEMORY_PLUGINS_DIR = pathlib.Path(d) / "empty-bundled"
pm._MEMORY_PLUGINS_DIR.mkdir()
provider = pm.load_memory_provider("mem0", register_skills=False)
assert provider is not None
assert provider.__class__.__module__.startswith("_hermes_user_memory.")
backend = Mock()
backend.search.return_value = []
backend.add.return_value = {}
provider._create_backend = lambda: backend
manager = MemoryManager()
manager.add_provider(provider)
manager.initialize_all("s1", platform="cli", user_id="alice")
result = manager.handle_tool_call("mem0_search", {"query": "favorite programming language"})
assert json.loads(result)["result"] == "No relevant memories found."
manager.sync_all(
"I prefer Python for tooling. " * 100 + "UNIQUE_END_MARKER",
"I will remember that.",
session_id="s2",
messages=[{"role": "tool", "content": "NOT_FOR_CAPTURE"}],
turn_author={"id": "alice"},
)
manager.shutdown_all()
assert backend.add.call_count >= 1
sent = str(backend.add.call_args_list)
assert "UNIQUE_END_MARKER" in sent and "NOT_FOR_CAPTURE" not in sent
assert backend.add.call_args.kwargs["run_id"] == "s2"
print("PASS:", hermes_source, "real external loader + MemoryManager lifecycle/tools/full capture/session ID")
@@ -0,0 +1,86 @@
"""Offline backend contracts; no Hermes install, credentials, or database needed."""
import importlib.util
import json
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
import httpx
spec = importlib.util.spec_from_file_location("hermes_backend_test._backend", Path(__file__).parents[1] / "_backend.py")
backend = importlib.util.module_from_spec(spec)
spec.loader.exec_module(backend)
class BackendTests(unittest.TestCase):
def test_cloud_and_oss_preserve_scope_and_accept_optional_session(self):
for backend_type, client_attr in ((backend.PlatformBackend, "_client"), (backend.OSSBackend, "_memory")):
with self.subTest(backend=backend_type.__name__):
instance = backend_type.__new__(backend_type)
client = Mock()
setattr(instance, client_attr, client)
client.search.return_value = {"results": [{"id": "old", "memory": "Legacy memory"}]}
instance.add([{"role": "user", "content": "fact"}], user_id="user", agent_id="hermes")
self.assertNotIn("run_id", client.add.call_args.kwargs)
instance.add([], user_id="user", agent_id="hermes", run_id="session", metadata={"channel": "cli"})
self.assertEqual(client.add.call_args.kwargs["run_id"], "session")
self.assertEqual(client.add.call_args.kwargs["metadata"], {"channel": "cli"})
self.assertEqual(instance.search("fact", filters={"user_id": "user"}, top_k=3)[0]["id"], "old")
self.assertEqual(client.search.call_args.kwargs["filters"], {"user_id": "user"})
self.assertEqual(client.search.call_args.kwargs["top_k"], 3)
def test_selfhosted_http_contract(self):
requests = []
def respond(request):
requests.append(request)
return httpx.Response(200, json={"results": [{"id": "legacy", "memory": "fact"}]})
instance = backend.SelfHostedBackend(
"test-key", "http://localhost:8888/", transport=httpx.MockTransport(respond)
)
try:
instance.add([], user_id="user", agent_id="hermes", run_id="session", infer=True)
self.assertEqual(json.loads(requests[-1].content)["run_id"], "session")
self.assertEqual(requests[-1].headers["X-API-Key"], "test-key")
instance.search("fact", filters={"user_id": "user"}, top_k=7, rerank=True)
self.assertEqual(str(requests[-1].url), "http://localhost:8888/search")
self.assertEqual(
json.loads(requests[-1].content), {"query": "fact", "filters": {"user_id": "user"}, "top_k": 7}
)
instance.update("legacy", "new fact")
self.assertEqual((requests[-1].method, json.loads(requests[-1].content)), ("PUT", {"text": "new fact"}))
instance.delete("legacy")
self.assertEqual(requests[-1].method, "DELETE")
finally:
instance.close()
def test_qdrant_dimension_change_never_deletes_memories(self):
client = Mock()
client.collection_exists.return_value = True
client.get_collection.return_value.config.params.vectors = SimpleNamespace(size=1536)
with patch.dict(sys.modules, {"qdrant_client": SimpleNamespace(QdrantClient=Mock(return_value=client))}):
with self.assertRaisesRegex(ValueError, "1536.*768"):
backend.OSSBackend._recreate_collection_if_dims_changed("qdrant", {"path": "/unused"}, 768)
client.delete_collection.assert_not_called()
client.close.assert_called_once()
def test_pgvector_dimension_change_never_drops_table(self):
cursor = Mock()
cursor.fetchone.return_value = (1536,)
connection = Mock()
connection.cursor.return_value = cursor
driver = SimpleNamespace(connect=Mock(return_value=connection), sql=Mock())
with patch.dict(sys.modules, {"psycopg2": driver}):
with self.assertRaisesRegex(ValueError, "1536.*768"):
backend.OSSBackend._recreate_collection_if_dims_changed("pgvector", {"user": "test"}, 768)
self.assertEqual(cursor.execute.call_count, 1)
self.assertTrue(cursor.execute.call_args.args[0].startswith("SELECT"))
connection.close.assert_called_once()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,225 @@
"""Offline contracts for the native Hermes provider and legacy configuration."""
import contextvars
import importlib.util
import json
import sys
import threading
import types
from pathlib import Path
from unittest.mock import Mock
import pytest
ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture
def plugin(monkeypatch, tmp_path):
def spawn(target, *, name):
context = contextvars.copy_context()
return threading.Thread(target=context.run, args=(target,), name=name)
modules = {
"agent": {},
"agent.memory_provider": {"MemoryProvider": object, "spawn_context_thread": spawn},
"agent.secret_scope": {"get_secret": lambda key, default="": default},
"tools": {},
"tools.registry": {"tool_error": lambda text: json.dumps({"error": text})},
"utils": {
"read_json_or_empty": lambda p: json.loads(p.read_text()) if p.exists() else {},
"atomic_json_write": lambda p, value, **kw: p.write_text(json.dumps(value)),
},
"hermes_constants": {"get_hermes_home": lambda: tmp_path},
}
for name, values in modules.items():
module = types.ModuleType(name)
module.__dict__.update(values)
monkeypatch.setitem(sys.modules, name, module)
name = "hermes_test_mem0"
spec = importlib.util.spec_from_file_location(name, ROOT / "__init__.py", submodule_search_locations=[str(ROOT)])
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, name, module)
spec.loader.exec_module(module)
monkeypatch.setattr(module.atexit, "register", lambda *args: None)
return module
def provider(plugin, monkeypatch, config=None, **identity):
backend = Mock()
backend.add.return_value = {"event_id": "event-1"}
backend.search.return_value = []
monkeypatch.setattr(plugin, "_load_config", lambda: config or {})
monkeypatch.setattr(plugin.Mem0MemoryProvider, "_create_backend", lambda self: backend)
instance = plugin.Mem0MemoryProvider()
instance.initialize("session-1", **identity)
return instance, backend
def test_long_turns_preserve_tail_and_redact_before_chunking(plugin, monkeypatch):
instance, backend = provider(plugin, monkeypatch, {"sync_max_chars": 450})
text = "Opening. " + "x" * 1100 + " api_key=secret-value lasting preference at the end."
instance.sync_turn(text, "Noted.", session_id="turn-session")
instance.shutdown()
messages = [m for call in backend.add.call_args_list for m in call.args[0]]
assert "".join(m["content"] for m in messages if m["role"] == "user") == plugin.redact(text)
assert all(len(m["content"]) <= 450 for m in messages)
assert "secret-value" not in repr(backend.add.call_args_list)
assert all(call.kwargs["run_id"] == "turn-session" for call in backend.add.call_args_list)
def test_busy_capture_queues_each_turn_with_its_profile_context(plugin, monkeypatch):
instance, backend = provider(plugin, monkeypatch)
started, release = threading.Event(), threading.Event()
profile = contextvars.ContextVar("profile", default="first")
seen = []
def add(messages, **kwargs):
if not seen:
started.set()
assert release.wait(3)
seen.append((messages[0]["content"], profile.get()))
return {}
backend.add.side_effect = add
instance.sync_turn("first turn", "reply")
assert started.wait(3)
token = profile.set("second")
try:
instance.sync_turn("second turn", "reply")
finally:
profile.reset(token)
release.set()
instance.shutdown()
assert seen == [("first turn", "first"), ("second turn", "second")]
backend.close.assert_called_once()
@pytest.mark.parametrize(
"configured,gateway,expected",
[
(None, "telegram-42", "telegram-42"),
("hermes-user", "telegram-42", "telegram-42"),
("existing-account", "telegram-42", "existing-account"),
(None, None, "hermes-user"),
],
)
def test_legacy_identity_and_tool_contract(plugin, monkeypatch, configured, gateway, expected):
instance, backend = provider(plugin, monkeypatch, {"user_id": configured}, user_id=gateway)
assert instance.name == "mem0"
assert {s["name"] for s in instance.get_tool_schemas()} == {"mem0_search", "mem0_add", "mem0_update", "mem0_delete"}
instance.handle_tool_call("mem0_search", {"query": "api_key=secret-value"})
assert backend.search.call_args.kwargs["filters"] == {"user_id": expected}
assert backend.search.call_args.args[0] == "api_key=[REDACTED]"
instance.handle_tool_call("mem0_add", {"content": "password=secret-value"})
assert backend.add.call_args.kwargs["infer"] is False
assert backend.add.call_args.args[0][0]["content"] == "password=[REDACTED]"
instance.handle_tool_call("mem0_update", {"memory_id": "old-id", "text": "password=secret-value"})
backend.update.assert_called_once_with("old-id", "password=[REDACTED]")
instance.shutdown()
def test_config_keeps_legacy_file_over_env_precedence(plugin, monkeypatch, tmp_path):
env = {"MEM0_API_KEY": "env-key", "MEM0_USER_ID": "env-user", "MEM0_HOST": "http://localhost:8888"}
monkeypatch.setattr(plugin, "get_secret", lambda key, default="": env.get(key, default))
(tmp_path / "mem0.json").write_text(json.dumps({"user_id": "existing-user", "rerank": True}))
cfg = plugin._load_config()
assert (cfg["api_key"], cfg["user_id"], cfg["host"], cfg["rerank"]) == (
"env-key",
"existing-user",
"http://localhost:8888",
True,
)
def test_prefetch_respects_rerank_and_redacts_recalled_context(plugin, monkeypatch):
instance, backend = provider(plugin, monkeypatch, {"rerank": True})
backend.search.return_value = [{"memory": "password=old-secret"}]
assert "old-secret" not in instance.prefetch("preference")
assert backend.search.call_args.kwargs["rerank"] is True
instance.shutdown()
def test_session_switch_updates_writes_without_narrowing_recall(plugin, monkeypatch):
instance, backend = provider(plugin, monkeypatch)
instance.on_session_switch("resumed-session")
instance.handle_tool_call("mem0_add", {"content": "fact"})
assert backend.add.call_args.kwargs["run_id"] == "resumed-session"
instance.handle_tool_call("mem0_search", {"query": "fact"})
assert backend.search.call_args.kwargs["filters"] == {"user_id": "hermes-user"}
instance.shutdown()
def test_invalid_tool_input_never_reaches_backend_or_trips_breaker(plugin, monkeypatch):
instance, backend = provider(plugin, monkeypatch)
for args in ({"query": []}, {"query": "fact", "top_k": "not-a-number"}, None):
assert "error" in json.loads(instance.handle_tool_call("mem0_search", args))
backend.search.assert_not_called()
assert instance._consecutive_failures == 0
instance.shutdown()
def test_setup_writes_private_env_and_preserves_existing_values(plugin, tmp_path):
import importlib
setup = importlib.import_module(f"{plugin.__name__}._setup")
path = tmp_path / ".env"
path.write_text("EXISTING=value\nMEM0_API_KEY=old\n")
setup._write_env(path, {"MEM0_API_KEY": "new"})
assert path.read_text() == "EXISTING=value\nMEM0_API_KEY=new\n"
assert path.stat().st_mode & 0o777 == 0o600
def test_redaction_marker_split_at_chunk_boundary_is_lossless(plugin, monkeypatch):
instance, backend = provider(plugin, monkeypatch, {"sync_max_chars": 450})
text = "x" * 438 + " password=secret-value tail"
instance.sync_turn(text, "")
instance.shutdown()
stored = "".join(m["content"] for call in backend.add.call_args_list for m in call.args[0])
assert stored == plugin.redact(text)
def test_sync_queue_stops_network_calls_when_breaker_opens(plugin, monkeypatch, caplog):
instance, backend = provider(plugin, monkeypatch)
started, release = threading.Event(), threading.Event()
def unavailable(messages, **kwargs):
started.set()
assert release.wait(3)
raise RuntimeError("server unavailable")
backend.add.side_effect = unavailable
instance.sync_turn("first turn", "reply")
assert started.wait(3)
try:
for number in range(9):
instance.sync_turn(f"queued turn {number}", "reply")
finally:
release.set()
instance.shutdown()
assert backend.add.call_count == plugin._BREAKER_THRESHOLD
assert any("not synced" in record.message.lower() for record in caplog.records)
def test_shutdown_is_bounded_and_defers_close_until_sync_finishes(plugin, monkeypatch, caplog):
instance, backend = provider(plugin, monkeypatch)
started, release = threading.Event(), threading.Event()
monkeypatch.setattr(plugin, "_SHUTDOWN_WAIT_SECS", 0.01)
def add(*args, **kwargs):
started.set()
assert release.wait(3)
return {}
backend.add.side_effect = add
instance.sync_turn("fact", "reply")
assert started.wait(3)
worker = instance._sync_thread
try:
instance.shutdown()
backend.close.assert_not_called()
assert "shutdown timed out" in caplog.text
finally:
release.set()
worker.join(timeout=3)
backend.close.assert_called_once()