mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-14 14:12:44 +00:00
feat(mem0): self-hosted dashboard backend + recall tuning (salvage #55614)
Salvage of #55614 by @kartik-mem0 (mem0 maintainer). Adds a SelfHostedBackend that talks to a self-hosted Mem0 Docker server over httpx (X-API-Key auth, /search + /memories routes), gated behind `host`. Also folds in the mem0 research-team recall tuning that rides with it: rerank defaults to false across all modes, the mem0_list tool is removed (5->4 tools), search guidance is de-shouted, and self-hosted get_all reports the true stored total (#52921). Supersedes the self-hosted portion of #52487 (@liuhao1024, first-submitted). Closes #52478 Fixes #52921
This commit is contained in:
parent
b2d6a512d5
commit
2a14205ff4
8 changed files with 421 additions and 119 deletions
|
|
@ -1,8 +1,13 @@
|
|||
"""Tests for Mem0Backend abstraction — PlatformBackend and OSSBackend."""
|
||||
"""Tests for Mem0Backend abstraction — PlatformBackend, OSSBackend, SelfHostedBackend."""
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.mem0._backend import Mem0Backend, PlatformBackend, OSSBackend
|
||||
from plugins.memory.mem0._backend import (
|
||||
Mem0Backend,
|
||||
PlatformBackend,
|
||||
OSSBackend,
|
||||
SelfHostedBackend,
|
||||
)
|
||||
|
||||
|
||||
class FakePlatformClient:
|
||||
|
|
@ -52,10 +57,10 @@ class TestPlatformBackend:
|
|||
backend.search("q", filters={}, rerank=False)
|
||||
assert client.calls[0][2]["rerank"] is False
|
||||
|
||||
def test_search_rerank_default_true(self):
|
||||
def test_search_rerank_default_false(self):
|
||||
backend, client = self._make()
|
||||
backend.search("q", filters={})
|
||||
assert client.calls[0][2]["rerank"] is True
|
||||
assert client.calls[0][2]["rerank"] is False
|
||||
|
||||
def test_search_returns_list(self):
|
||||
backend, _ = self._make()
|
||||
|
|
@ -207,3 +212,146 @@ class TestOSSBackend:
|
|||
backend, _ = self._make()
|
||||
result = backend.delete("m1")
|
||||
assert result == {"result": "Memory deleted.", "memory_id": "m1"}
|
||||
|
||||
|
||||
httpx = pytest.importorskip("httpx")
|
||||
|
||||
|
||||
class _StubServer:
|
||||
"""Records requests and serves the real self-hosted server's response shapes."""
|
||||
|
||||
def __init__(self, rows=10):
|
||||
self.requests = []
|
||||
self._rows = [{"id": f"m{i}", "memory": f"f{i}"} for i in range(rows)]
|
||||
|
||||
def handler(self, request):
|
||||
self.requests.append(request)
|
||||
path, method = request.url.path, request.method
|
||||
if path == "/search" and method == "POST":
|
||||
return httpx.Response(200, json={"results": [{"id": "m1", "memory": "tea", "score": 0.9}]})
|
||||
if path == "/memories" and method == "GET":
|
||||
top_k = int(request.url.params.get("top_k", len(self._rows)))
|
||||
return httpx.Response(200, json={"results": self._rows[:top_k]})
|
||||
if path == "/memories" and method == "POST":
|
||||
return httpx.Response(200, json={"results": [{"id": "new", "memory": "stored", "event": "ADD"}]})
|
||||
if path.startswith("/memories/") and method in ("PUT", "DELETE"):
|
||||
if path.endswith("/missing"): # server 404s unknown ids
|
||||
return httpx.Response(404, json={"detail": "Memory not found"})
|
||||
verb = "updated" if method == "PUT" else "Memory deleted successfully"
|
||||
return httpx.Response(200, json={"message": verb})
|
||||
return httpx.Response(404, json={"detail": "not found"})
|
||||
|
||||
|
||||
def _backend(server, api_key="adminkey", host="http://sh:8888"):
|
||||
"""Build a SelfHostedBackend whose client routes through the stub transport.
|
||||
|
||||
Mirrors __init__'s header setup but injects MockTransport so no real socket
|
||||
is opened.
|
||||
"""
|
||||
backend = SelfHostedBackend.__new__(SelfHostedBackend)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
backend._client = httpx.Client(
|
||||
base_url=host.rstrip("/"), headers=headers,
|
||||
transport=httpx.MockTransport(server.handler),
|
||||
)
|
||||
return backend
|
||||
|
||||
|
||||
class TestSelfHostedBackend:
|
||||
# --- constructor / auth setup (the crux of the bug) -------------------
|
||||
|
||||
def test_init_uses_x_api_key_not_token_auth(self):
|
||||
b = SelfHostedBackend("adminkey", "http://sh:8888")
|
||||
assert b._client.headers["x-api-key"] == "adminkey"
|
||||
assert "authorization" not in b._client.headers # NOT the cloud 'Token' scheme
|
||||
|
||||
def test_init_strips_trailing_slash(self):
|
||||
b = SelfHostedBackend("k", "http://sh:8888/")
|
||||
assert str(b._client.base_url) == "http://sh:8888"
|
||||
|
||||
def test_init_omits_api_key_header_when_blank(self):
|
||||
b = SelfHostedBackend("", "http://sh:8888") # AUTH_DISABLED server
|
||||
assert "x-api-key" not in b._client.headers
|
||||
|
||||
# --- search ----------------------------------------------------------
|
||||
|
||||
def test_search_posts_to_search_with_filters_in_body(self):
|
||||
s = _StubServer()
|
||||
results = _backend(s).search("drink", filters={"user_id": "u1"}, top_k=5)
|
||||
req = s.requests[-1]
|
||||
assert (req.method, req.url.path) == ("POST", "/search")
|
||||
import json
|
||||
body = json.loads(req.content)
|
||||
assert body == {"query": "drink", "top_k": 5, "filters": {"user_id": "u1"}}
|
||||
assert results == [{"id": "m1", "memory": "tea", "score": 0.9}]
|
||||
|
||||
def test_search_sends_x_api_key_header(self):
|
||||
s = _StubServer()
|
||||
_backend(s).search("q", filters={"user_id": "u1"})
|
||||
req = s.requests[-1]
|
||||
assert req.headers["x-api-key"] == "adminkey"
|
||||
assert "authorization" not in req.headers
|
||||
|
||||
# --- get_all / pagination -------------------------------------------
|
||||
|
||||
def test_get_all_passes_user_id_as_query_param(self):
|
||||
s = _StubServer()
|
||||
_backend(s).get_all(filters={"user_id": "u1"}, page=1, page_size=3)
|
||||
req = s.requests[-1]
|
||||
assert req.method == "GET" and req.url.path == "/memories"
|
||||
assert req.url.params["user_id"] == "u1"
|
||||
|
||||
def test_get_all_slices_requested_page(self):
|
||||
s = _StubServer(rows=10)
|
||||
out = _backend(s).get_all(filters={"user_id": "u1"}, page=2, page_size=3)
|
||||
# We over-fetch (top_k=_MAX_TOP_K) and slice the page locally...
|
||||
assert [r["id"] for r in out["results"]] == ["m3", "m4", "m5"]
|
||||
# ...so count is the TRUE total, not the page size (issue #52921).
|
||||
assert out["count"] == 10
|
||||
|
||||
def test_get_all_requests_max_top_k_for_accurate_count(self):
|
||||
# Always request _MAX_TOP_K (not page*page_size) so `count` reflects the
|
||||
# real total — self-hosted GET /memories returns no count field (#52921).
|
||||
s = _StubServer(rows=10)
|
||||
_backend(s).get_all(filters={"user_id": "u1"}, page=1, page_size=3)
|
||||
req = s.requests[-1]
|
||||
assert int(req.url.params["top_k"]) == SelfHostedBackend._MAX_TOP_K
|
||||
|
||||
# --- add / update / delete ------------------------------------------
|
||||
|
||||
def test_add_posts_messages_and_identity(self):
|
||||
s = _StubServer()
|
||||
msgs = [{"role": "user", "content": "likes tea"}]
|
||||
result = _backend(s).add(msgs, user_id="u1", agent_id="hermes", infer=False, metadata={"channel": "cli"})
|
||||
req = s.requests[-1]
|
||||
assert (req.method, req.url.path) == ("POST", "/memories")
|
||||
import json
|
||||
body = json.loads(req.content)
|
||||
assert body == {"messages": msgs, "user_id": "u1", "agent_id": "hermes",
|
||||
"infer": False, "metadata": {"channel": "cli"}}
|
||||
assert result["results"][0]["id"] == "new"
|
||||
|
||||
def test_update_puts_text_to_memory_id(self):
|
||||
s = _StubServer()
|
||||
result = _backend(s).update("abc", "new text")
|
||||
req = s.requests[-1]
|
||||
assert (req.method, req.url.path) == ("PUT", "/memories/abc")
|
||||
import json
|
||||
assert json.loads(req.content) == {"text": "new text"}
|
||||
assert result == {"result": "Memory updated.", "memory_id": "abc"}
|
||||
|
||||
def test_delete_calls_delete_endpoint(self):
|
||||
s = _StubServer()
|
||||
result = _backend(s).delete("abc")
|
||||
req = s.requests[-1]
|
||||
assert (req.method, req.url.path) == ("DELETE", "/memories/abc")
|
||||
assert result == {"result": "Memory deleted.", "memory_id": "abc"}
|
||||
|
||||
# --- error propagation (feeds the plugin's circuit breaker) ----------
|
||||
|
||||
def test_http_error_raises(self):
|
||||
s = _StubServer()
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
_backend(s).delete("missing") # 404 -> raise_for_status; 'not found' won't trip breaker
|
||||
|
|
|
|||
|
|
@ -52,33 +52,6 @@ class TestMem0V3Tools:
|
|||
provider._backend = backend
|
||||
return provider
|
||||
|
||||
def test_list_returns_paginated_with_ids(self, monkeypatch):
|
||||
backend = FakeBackend(all_results={
|
||||
"count": 2,
|
||||
"results": [
|
||||
{"id": "mem-1", "memory": "alpha"},
|
||||
{"id": "mem-2", "memory": "beta"},
|
||||
]
|
||||
})
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_list", {}))
|
||||
assert result["count"] == 2
|
||||
assert result["results"][0]["id"] == "mem-1"
|
||||
assert result["results"][0]["memory"] == "alpha"
|
||||
|
||||
def test_list_pagination_params(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
provider.handle_tool_call("mem0_list", {"page": 2, "page_size": 50})
|
||||
assert backend.captured[0][1]["page"] == 2
|
||||
assert backend.captured[0][1]["page_size"] == 50
|
||||
|
||||
def test_list_empty(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_list", {}))
|
||||
assert result["result"] == "No memories stored yet."
|
||||
|
||||
def test_search_returns_ids(self, monkeypatch):
|
||||
backend = FakeBackend(search_results=[{"id": "mem-1", "memory": "foo", "score": 0.9}])
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
|
|
@ -92,11 +65,11 @@ class TestMem0V3Tools:
|
|||
assert backend.captured[0][2]["filters"] == {"user_id": "u123"}
|
||||
assert backend.captured[0][2]["top_k"] == 3
|
||||
|
||||
def test_search_rerank_default_true(self, monkeypatch):
|
||||
def test_search_rerank_default_false(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
provider.handle_tool_call("mem0_search", {"query": "test"})
|
||||
assert backend.captured[0][2]["rerank"] is True
|
||||
assert backend.captured[0][2]["rerank"] is False
|
||||
|
||||
def test_search_rerank_override_false(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
|
|
@ -280,6 +253,8 @@ class TestMem0V3Internal:
|
|||
assert "error" in result
|
||||
result = json.loads(provider.handle_tool_call("mem0_conclude", {}))
|
||||
assert "error" in result
|
||||
result = json.loads(provider.handle_tool_call("mem0_list", {}))
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestMem0Prefetch:
|
||||
|
|
@ -308,7 +283,7 @@ class TestMem0Prefetch:
|
|||
assert query == "what theme do I like?"
|
||||
assert opts["filters"] == {"user_id": "u123"}
|
||||
assert opts["top_k"] == 10
|
||||
assert opts["rerank"] is True
|
||||
assert opts["rerank"] is False
|
||||
assert "## Mem0 Memory" in result
|
||||
assert "user prefers dark mode" in result
|
||||
|
||||
|
|
@ -368,11 +343,11 @@ class TestMem0Prefetch:
|
|||
|
||||
class TestMem0V3Config:
|
||||
|
||||
def test_tool_schemas_five_tools(self):
|
||||
def test_tool_schemas_four_tools(self):
|
||||
provider = Mem0MemoryProvider()
|
||||
schemas = provider.get_tool_schemas()
|
||||
names = [s["name"] for s in schemas]
|
||||
assert names == ["mem0_list", "mem0_search", "mem0_add", "mem0_update", "mem0_delete"]
|
||||
assert names == ["mem0_search", "mem0_add", "mem0_update", "mem0_delete"]
|
||||
|
||||
def test_system_prompt_new_tool_names(self):
|
||||
provider = Mem0MemoryProvider()
|
||||
|
|
@ -380,9 +355,9 @@ class TestMem0V3Config:
|
|||
block = provider.system_prompt_block()
|
||||
assert "mem0_search" in block
|
||||
assert "mem0_add" in block
|
||||
assert "mem0_list" in block
|
||||
assert "mem0_update" in block
|
||||
assert "mem0_delete" in block
|
||||
assert "mem0_list" not in block
|
||||
assert "mem0_profile" not in block
|
||||
assert "mem0_conclude" not in block
|
||||
|
||||
|
|
@ -455,7 +430,7 @@ class TestMem0ModeSwitch:
|
|||
provider = Mem0MemoryProvider()
|
||||
schemas = provider.get_tool_schemas()
|
||||
names = [s["name"] for s in schemas]
|
||||
assert names == ["mem0_list", "mem0_search", "mem0_add", "mem0_update", "mem0_delete"]
|
||||
assert names == ["mem0_search", "mem0_add", "mem0_update", "mem0_delete"]
|
||||
|
||||
def test_system_prompt_includes_mode(self):
|
||||
provider = Mem0MemoryProvider()
|
||||
|
|
@ -463,7 +438,6 @@ class TestMem0ModeSwitch:
|
|||
provider._mode = "oss"
|
||||
block = provider.system_prompt_block()
|
||||
assert "mem0_search" in block
|
||||
assert "mem0_list" in block
|
||||
assert "OSS" in block
|
||||
|
||||
|
||||
|
|
@ -547,3 +521,82 @@ class TestMem0WriteMetadata:
|
|||
adds = [c for c in provider._backend.captured if c[0] == "add"]
|
||||
assert adds, "expected an add call from sync_turn"
|
||||
assert adds[-1][2]["metadata"] == {"channel": "discord"}
|
||||
|
||||
|
||||
class _SentinelBackend:
|
||||
def __init__(self, *args):
|
||||
self.args = args
|
||||
|
||||
|
||||
class TestCreateBackendRouting:
|
||||
"""_create_backend() must pick the backend matching the configured mode/host."""
|
||||
|
||||
def _provider(self, monkeypatch, *, mode="platform", api_key="k", host=""):
|
||||
# Neutralize lazy-install so the routing decision is all we exercise.
|
||||
monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None, raising=False)
|
||||
provider = Mem0MemoryProvider()
|
||||
provider._mode = mode
|
||||
provider._api_key = api_key
|
||||
provider._host = host
|
||||
provider._config = {"oss": {"vector_store": {"provider": "qdrant"}}}
|
||||
return provider
|
||||
|
||||
def test_routes_to_selfhosted_when_host_set(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class SH(_SentinelBackend):
|
||||
def __init__(self, api_key, host):
|
||||
captured["args"] = (api_key, host)
|
||||
|
||||
monkeypatch.setattr("plugins.memory.mem0._backend.SelfHostedBackend", SH)
|
||||
provider = self._provider(monkeypatch, host="http://sh:8888", api_key="adminkey")
|
||||
backend = provider._create_backend()
|
||||
assert isinstance(backend, SH)
|
||||
assert captured["args"] == ("adminkey", "http://sh:8888")
|
||||
|
||||
def test_routes_to_platform_when_no_host(self, monkeypatch):
|
||||
class PB(_SentinelBackend):
|
||||
def __init__(self, api_key):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("plugins.memory.mem0._backend.PlatformBackend", PB)
|
||||
provider = self._provider(monkeypatch, host="")
|
||||
assert isinstance(provider._create_backend(), PB)
|
||||
|
||||
def test_routes_to_oss_when_mode_oss(self, monkeypatch):
|
||||
class OB(_SentinelBackend):
|
||||
def __init__(self, cfg):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("plugins.memory.mem0._backend.OSSBackend", OB)
|
||||
provider = self._provider(monkeypatch, mode="oss")
|
||||
assert isinstance(provider._create_backend(), OB)
|
||||
|
||||
def test_oss_mode_takes_precedence_over_host(self, monkeypatch):
|
||||
class OB(_SentinelBackend):
|
||||
def __init__(self, cfg):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("plugins.memory.mem0._backend.OSSBackend", OB)
|
||||
provider = self._provider(monkeypatch, mode="oss", host="http://sh:8888")
|
||||
assert isinstance(provider._create_backend(), OB)
|
||||
|
||||
|
||||
class TestSelfHostedConfig:
|
||||
"""Config plumbing for self-hosted (MEM0_HOST env + is_available)."""
|
||||
|
||||
def test_load_config_reads_mem0_host_env(self, monkeypatch):
|
||||
monkeypatch.setenv("MEM0_HOST", "http://localhost:8888")
|
||||
assert mem0_plugin._load_config()["host"] == "http://localhost:8888"
|
||||
|
||||
def test_is_available_true_with_host_only(self, monkeypatch):
|
||||
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
||||
monkeypatch.setenv("MEM0_MODE", "platform")
|
||||
monkeypatch.setenv("MEM0_HOST", "http://localhost:8888")
|
||||
assert Mem0MemoryProvider().is_available() is True
|
||||
|
||||
def test_is_available_false_without_key_or_host(self, monkeypatch):
|
||||
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
||||
monkeypatch.delenv("MEM0_HOST", raising=False)
|
||||
monkeypatch.setenv("MEM0_MODE", "platform")
|
||||
assert Mem0MemoryProvider().is_available() is False
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue