feat(integrations): add Hermes provider with shared memory utilities
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user