hermes-agent/plugins/memory/mem0/_backend.py
kshitijk4poor 9a322726ae fix(mem0): prune dead get_all, wire rerank config default, warn on MEM0_HOST env override
Review follow-ups on the salvage:

- get_all() pruned from the ABC and all three backends: mem0_list (its
  only caller) was removed by the recall-tuning commit, leaving new,
  tested, unreachable code — including SelfHostedBackend's _MAX_TOP_K
  over-fetch workaround. Tests for it dropped; fake-class stubs remain
  harmlessly. (The #52921 true-total fix lives on in the PR history if
  a lister ever returns.)
- The persisted rerank config key was write-only (setup prompted for it,
  nothing read it). initialize() now parses it into _rerank_default and
  mem0_search uses it when the model doesn't pass rerank explicitly;
  per-call args still win. Guard test added.
- Platform-mode setup now warns when MEM0_HOST is set in the environment:
  the json host-clear can't help there (_load_config seeds host from the
  env var, docs tell users to put it in .env) — the user would silently
  keep routing to the self-hosted server.
- SelfHostedBackend: connect-level retries (httpx.HTTPTransport(retries=2))
  so a single transient blip doesn't count toward the provider breaker;
  transport now injectable and the test helper uses the real __init__
  instead of mirroring it via __new__.
- plugin.yaml description no longer leads with reranking (off by default,
  platform-only); docs em-dash typo fixed.
2026-07-08 01:57:11 +05:30

298 lines
11 KiB
Python

"""Backend abstraction for Mem0 Platform and OSS modes."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class Mem0Backend(ABC):
"""Unified interface over Platform (MemoryClient) and OSS (Memory) backends."""
@abstractmethod
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
...
@abstractmethod
def add(
self,
messages: list,
*,
user_id: str,
agent_id: str,
infer: bool = False,
metadata: dict | None = None,
) -> dict:
...
@abstractmethod
def update(self, memory_id: str, text: str) -> dict:
...
@abstractmethod
def delete(self, memory_id: str) -> dict:
...
def close(self) -> None:
pass
def _unwrap_results(response: Any) -> list:
"""Normalize API response — extract results list from dict or pass through."""
if isinstance(response, dict):
return response.get("results", [])
if isinstance(response, list):
return response
return []
class PlatformBackend(Mem0Backend):
"""Wraps mem0.MemoryClient for Mem0 Platform (cloud API)."""
def __init__(self, api_key: str):
from mem0 import MemoryClient
self._client = MemoryClient(api_key=api_key)
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
response = self._client.search(query, filters=filters, top_k=top_k, rerank=rerank)
return _unwrap_results(response)
def add(
self,
messages: list,
*,
user_id: str,
agent_id: str,
infer: bool = False,
metadata: dict | None = None,
) -> dict:
kwargs: dict[str, Any] = {"user_id": user_id, "agent_id": agent_id, "infer": infer}
if metadata:
kwargs["metadata"] = metadata
return self._client.add(messages, **kwargs)
def update(self, memory_id: str, text: str) -> dict:
self._client.update(memory_id=memory_id, text=text)
return {"result": "Memory updated.", "memory_id": memory_id}
def delete(self, memory_id: str) -> dict:
self._client.delete(memory_id=memory_id)
return {"result": "Memory deleted.", "memory_id": memory_id}
class SelfHostedBackend(Mem0Backend):
"""Direct HTTP backend for a self-hosted Mem0 server (the FastAPI ``server/``).
mem0.MemoryClient can't be reused for self-hosted: it is hardwired to the
cloud API — ``Authorization: Token`` auth and a ``GET /v1/ping/`` validation
call in ``__init__`` that the self-hosted server does not expose (it would
404 before any real request). This client talks to that server directly,
using its actual contract: ``X-API-Key`` auth and the ``/memories`` /
``/search`` routes.
"""
def __init__(self, api_key: str, host: str, transport=None):
import httpx
headers = {"Content-Type": "application/json"}
if api_key:
headers["X-API-Key"] = api_key # omitted only for AUTH_DISABLED servers
# Connect-level retries smooth over transient blips so a single
# dropped SYN doesn't count toward the provider failure breaker.
# ``transport`` is injectable for tests (httpx.MockTransport).
if transport is None:
transport = httpx.HTTPTransport(retries=2)
self._client = httpx.Client(
base_url=host.rstrip("/"), headers=headers, timeout=30.0,
transport=transport,
)
def _json(self, method: str, path: str, **kwargs) -> Any:
resp = self._client.request(method, path, **kwargs)
resp.raise_for_status()
return resp.json() if resp.content else {}
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
# rerank is a platform-only feature; the self-hosted /search ignores it.
body: dict[str, Any] = {"query": query, "top_k": top_k}
if filters:
body["filters"] = filters # user_id belongs in filters (top-level is deprecated)
return _unwrap_results(self._json("POST", "/search", json=body))
def add(
self,
messages: list,
*,
user_id: str,
agent_id: str,
infer: bool = False,
metadata: dict | None = None,
) -> dict:
body: dict[str, Any] = {
"messages": messages,
"user_id": user_id,
"agent_id": agent_id,
"infer": infer,
}
if metadata:
body["metadata"] = metadata
return self._json("POST", "/memories", json=body)
def update(self, memory_id: str, text: str) -> dict:
self._json("PUT", f"/memories/{memory_id}", json={"text": text})
return {"result": "Memory updated.", "memory_id": memory_id}
def delete(self, memory_id: str) -> dict:
self._json("DELETE", f"/memories/{memory_id}")
return {"result": "Memory deleted.", "memory_id": memory_id}
def close(self) -> None:
try:
self._client.close()
except Exception:
pass
class OSSBackend(Mem0Backend):
"""Wraps mem0.Memory for self-hosted (OSS) mode."""
def __init__(self, oss_config: dict):
import os
from mem0 import Memory
vector_store = dict(oss_config["vector_store"])
vs_config = dict(vector_store.get("config", {}))
if "path" in vs_config:
vs_config["path"] = os.path.expanduser(vs_config["path"])
embedder_config = oss_config.get("embedder", {}).get("config", {})
dims = embedder_config.get("embedding_dims")
if not dims:
from ._oss_providers import KNOWN_DIMS
model = embedder_config.get("model", "")
dims = KNOWN_DIMS.get(model)
if dims:
vs_config["embedding_model_dims"] = dims
self._recreate_collection_if_dims_changed(
vector_store.get("provider", "qdrant"), vs_config, dims,
)
vector_store["config"] = vs_config
config = {
"vector_store": vector_store,
"llm": oss_config["llm"],
"embedder": oss_config["embedder"],
"version": "v1.1",
}
self._memory = Memory.from_config(config)
@staticmethod
def _recreate_collection_if_dims_changed(provider: str, vs_config: dict, expected_dims: int) -> None:
"""Delete stale vector collection when embedding dimensions change."""
collection_name = vs_config.get("collection_name", "mem0")
if provider == "qdrant":
try:
from qdrant_client import QdrantClient
path = vs_config.get("path")
url = vs_config.get("url")
if path:
client = QdrantClient(path=path)
elif url:
client = QdrantClient(url=url, api_key=vs_config.get("api_key"))
else:
return
try:
if not client.collection_exists(collection_name):
return
info = client.get_collection(collection_name)
vectors = info.config.params.vectors
# Named-vector collections expose a dict; unnamed expose an object with .size.
if isinstance(vectors, dict):
first = next(iter(vectors.values()), None)
current_dims = first.size if first else None
else:
current_dims = getattr(vectors, "size", None)
if current_dims is not None and current_dims != expected_dims:
client.delete_collection(collection_name)
finally:
client.close()
except Exception:
pass
elif provider == "pgvector":
try:
import psycopg2
from psycopg2 import sql as pgsql
conn_params = {}
for k in ("host", "port", "user", "password", "dbname"):
if vs_config.get(k):
conn_params[k] = vs_config[k]
if vs_config.get("sslmode"):
conn_params["sslmode"] = vs_config["sslmode"]
conn = psycopg2.connect(**conn_params)
conn.autocommit = True
try:
cur = conn.cursor()
try:
cur.execute(
"SELECT atttypmod FROM pg_attribute "
"WHERE attrelid = %s::regclass AND attname = 'vector'",
(collection_name,),
)
row = cur.fetchone()
if row and row[0] > 0 and row[0] != expected_dims:
cur.execute(pgsql.SQL("DROP TABLE IF EXISTS {}").format(
pgsql.Identifier(collection_name)
))
finally:
cur.close()
finally:
conn.close()
except Exception:
pass
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
response = self._memory.search(query, filters=filters, top_k=top_k)
return _unwrap_results(response)
def add(
self,
messages: list,
*,
user_id: str,
agent_id: str,
infer: bool = False,
metadata: dict | None = None,
) -> dict:
kwargs: dict[str, Any] = {"user_id": user_id, "agent_id": agent_id, "infer": infer}
if metadata:
kwargs["metadata"] = metadata
return self._memory.add(messages, **kwargs)
def update(self, memory_id: str, text: str) -> dict:
self._memory.update(memory_id, data=text)
return {"result": "Memory updated.", "memory_id": memory_id}
def delete(self, memory_id: str) -> dict:
self._memory.delete(memory_id)
return {"result": "Memory deleted.", "memory_id": memory_id}
def close(self):
try:
telemetry = getattr(self._memory, "telemetry", None)
if telemetry and hasattr(telemetry, "posthog"):
try:
telemetry.posthog.shutdown()
except Exception:
pass
if hasattr(self._memory, "close"):
self._memory.close()
vs = getattr(self._memory, "vector_store", None)
if vs and hasattr(vs, "close"):
vs.close()
client = getattr(vs, "client", None)
if client and hasattr(client, "close"):
client.close()
except Exception:
pass