hermes-agent/tests/plugins/memory/test_mem0_backend.py
Kartik 2e779d11a0
feat(mem0): v3 API, OSS mode, update/delete tools, telemetry & review fixes (#15624)
* fix: update to version 3 endpoints and adding update and delete tool

* chore: removing the test md file

* fix: prevent circuit breaker on client errors in Mem0 provider

* chore: add telemetry for platform version

* feat: add OSS mode support to Mem0 memory provider

* chore: bump mem0ai dependency to >=2.0.1 in memory plugin

* refactor: enhance dependency checks and embedder config in mem0 backend

* refactor: adjust fact storage message for OSS mode

* refactor: expand user paths, add collection recreation on dimension change for Qdrant

* fix(mem0): make MEM0_USER_ID override gateway-native ids and tag writes with channel

When MEM0_USER_ID was configured (env or mem0.json), the gateway-native id
from kwargs (Telegram numeric id, Discord snowflake, ...) still won, so the
same human ended up under different user_ids per channel and memories never
merged across CLI / Telegram / Slack / Discord. Mirrors openclaw's cfg.userId
pattern: configured override wins, gateway-native id is the fallback.

The legacy "hermes-user" placeholder default written by the setup wizard is
treated as unset to avoid silently bucketing every gateway user together.

Also tag every write with metadata.channel (cli/telegram/discord/...) so the
dashboard can offer per-channel filtered views without coupling identity to
the channel; document the read/write filter asymmetry as intentional
(reads scope to user_id only for cross-agent recall).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: improve Mem0 memory provider backend, pagination, config, and error handling

* refactor: update mem0 telemetry code, docs, and bump version

* fix(mem0): make get_config_schema() return unified schema with mode-aware required flag

Schema always includes api_key field so picker shows "API key / local" for
both modes. In OSS mode api_key.required=False so status won't mislead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: improve mem0 telemetry, add env var key and OSS mode detection

* chore: bump mem0ai lower bound to 2.0.4 (latest SDK release)

* refactor: set telemetry sample rate to 1.0 and update docs for opt‑out

* fix(mem0): resolve 15 correctness, thread-safety, and resource bugs

Thread safety:
- Protect circuit breaker counters with _breaker_lock (race between
  prefetch/sync daemon threads and main thread)
- Wrap sync_turn thread creation in _sync_lock; skip if previous sync
  is still alive after 5 s join to prevent duplicate memory ingestion
- Guard _schedule_flush timer creation under _queue_lock (TOCTOU race)
- Capture local `backend` reference in prefetch/sync closures so
  shutdown() nulling self._backend cannot crash in-flight threads

Correctness:
- Fix bool("false")==True for rerank param; parse string values explicitly
- Guard page/top_k with max(1,...) and move int() inside try blocks
- Fix fact_count=0 always in OSS mode (Memory.add returns list, not dict)
- Fix prefetch() not clearing result when thread still alive after timeout
- Fix atexit.register accumulating on repeated initialize() calls

Backend / setup:
- Handle Qdrant named-vector collections in _recreate_collection_if_dims_changed
  (vectors is a dict; .size access raised AttributeError, swallowed silently)
- Wrap QdrantClient and psycopg2 conn/cursor in try/finally to prevent leaks
- Resolve ollama_bin at top of _ensure_ollama; use it for ollama pull
- Fix embedder key lookup when LLM provider has no env_var (e.g. ollama)

Also: remove _telemetry_enabled cache (env var check is cheap), bump
required mem0ai to >=2.0.7, minor README wording fix.

* fix(mem0): fix brittle qdrant path test + add telemetry sample-rate docs

- Replace generator-throw lambda with a proper def in
  test_qdrant_path_not_writable; use tmp_path instead of a hardcoded
  /nonexistent path so the test is root-safe
- Add MEM0_TELEMETRY_SAMPLE_RATE to memory-providers.md (was only
  in the plugin README, not the user-guide docs)

* revert: remove MEM0_TELEMETRY_SAMPLE_RATE from user-guide docs

* refactor: remove telemetry from mem0 plugin and update documentation

* fix(mem0): set stdin=DEVNULL on setup subprocess calls

The TUI stdin guard (scripts/check_subprocess_stdin.py) requires every
subprocess call in plugin code to set stdin= so it can't inherit the
gateway's JSON-RPC stdin fd. Muzzle the docker/ollama calls in the OSS
setup wizard with stdin=subprocess.DEVNULL (none need interactive input).
Also covers the docker-inspect call the linter's regex misses.

---------

Co-authored-by: chaithanyak42 <chaithanya.kumar42a@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-22 12:30:47 +00:00

209 lines
7.5 KiB
Python

"""Tests for Mem0Backend abstraction — PlatformBackend and OSSBackend."""
import pytest
from plugins.memory.mem0._backend import Mem0Backend, PlatformBackend, OSSBackend
class FakePlatformClient:
"""Fake MemoryClient for PlatformBackend tests."""
def __init__(self):
self.calls = []
def search(self, query, **kwargs):
self.calls.append(("search", query, kwargs))
return {"results": [{"id": "m1", "memory": "fact1", "score": 0.9}]}
def get_all(self, **kwargs):
self.calls.append(("get_all", kwargs))
return {"count": 1, "next": None, "results": [{"id": "m1", "memory": "fact1"}]}
def add(self, messages, **kwargs):
self.calls.append(("add", messages, kwargs))
return {"status": "PENDING", "event_id": "evt-1"}
def update(self, **kwargs):
self.calls.append(("update", kwargs))
return {"id": kwargs["memory_id"], "text": kwargs["text"]}
def delete(self, **kwargs):
self.calls.append(("delete", kwargs))
class TestPlatformBackend:
def _make(self):
client = FakePlatformClient()
backend = PlatformBackend.__new__(PlatformBackend)
backend._client = client
return backend, client
def test_search_forwards_params(self):
backend, client = self._make()
result = backend.search("test query", filters={"user_id": "u1"}, top_k=5)
assert client.calls[0][0] == "search"
assert client.calls[0][1] == "test query"
assert client.calls[0][2]["filters"] == {"user_id": "u1"}
assert client.calls[0][2]["top_k"] == 5
def test_search_forwards_rerank(self):
backend, client = self._make()
backend.search("q", filters={}, rerank=False)
assert client.calls[0][2]["rerank"] is False
def test_search_rerank_default_true(self):
backend, client = self._make()
backend.search("q", filters={})
assert client.calls[0][2]["rerank"] is True
def test_search_returns_list(self):
backend, _ = self._make()
result = backend.search("q", filters={})
assert isinstance(result, list)
assert result[0]["id"] == "m1"
def test_get_all_forwards_pagination(self):
backend, client = self._make()
result = backend.get_all(filters={"user_id": "u1"}, page=2, page_size=50)
assert client.calls[0][1]["page"] == 2
assert client.calls[0][1]["page_size"] == 50
assert "count" in result
def test_add_forwards_kwargs(self):
backend, client = self._make()
msgs = [{"role": "user", "content": "hi"}]
result = backend.add(msgs, user_id="u1", agent_id="hermes", infer=False)
call = client.calls[0]
assert call[2]["user_id"] == "u1"
assert call[2]["infer"] is False
# metadata kwarg should be omitted entirely when not provided so we
# don't surprise older mem0 client versions with an unknown kwarg.
assert "metadata" not in call[2]
def test_add_forwards_metadata_when_present(self):
backend, client = self._make()
msgs = [{"role": "user", "content": "hi"}]
backend.add(
msgs,
user_id="u1",
agent_id="hermes",
infer=False,
metadata={"channel": "telegram"},
)
assert client.calls[0][2]["metadata"] == {"channel": "telegram"}
def test_add_omits_empty_metadata(self):
backend, client = self._make()
msgs = [{"role": "user", "content": "hi"}]
backend.add(msgs, user_id="u1", agent_id="hermes", infer=False, metadata={})
assert "metadata" not in client.calls[0][2]
def test_update_forwards(self):
backend, client = self._make()
backend.update("m1", "new text")
assert client.calls[0][1] == {"memory_id": "m1", "text": "new text"}
def test_delete_forwards(self):
backend, client = self._make()
backend.delete("m1")
assert client.calls[0][1] == {"memory_id": "m1"}
class FakeOSSMemory:
"""Fake mem0.Memory for OSSBackend tests."""
def __init__(self):
self.calls = []
def search(self, query, **kwargs):
self.calls.append(("search", query, kwargs))
return {"results": [{"id": "m1", "memory": "fact1", "score": 0.8}]}
def get_all(self, **kwargs):
self.calls.append(("get_all", kwargs))
return {"results": [{"id": "m1", "memory": "fact1"}]}
def add(self, messages, **kwargs):
self.calls.append(("add", messages, kwargs))
return {"results": [{"id": "m1", "memory": "fact1", "event": "ADD"}]}
def update(self, memory_id, **kwargs):
self.calls.append(("update", memory_id, kwargs))
return {"message": "Memory updated successfully!"}
def delete(self, memory_id):
self.calls.append(("delete", memory_id))
return {"message": "Memory deleted successfully!"}
class TestOSSBackend:
def _make(self):
memory = FakeOSSMemory()
backend = OSSBackend.__new__(OSSBackend)
backend._memory = memory
return backend, memory
def test_search_returns_list(self):
backend, _ = self._make()
result = backend.search("test", filters={"user_id": "u1"})
assert isinstance(result, list)
assert result[0]["id"] == "m1"
def test_search_passes_filters(self):
backend, memory = self._make()
backend.search("q", filters={"user_id": "u1"}, top_k=3)
assert memory.calls[0][2]["filters"] == {"user_id": "u1"}
assert memory.calls[0][2]["top_k"] == 3
def test_search_ignores_rerank(self):
"""OSS backend accepts rerank param but does not forward it to Memory."""
backend, memory = self._make()
backend.search("q", filters={}, rerank=True)
assert "rerank" not in memory.calls[0][2]
def test_get_all_ignores_pagination(self):
"""OSSBackend accepts page/page_size but does NOT forward to Memory.get_all()."""
backend, memory = self._make()
result = backend.get_all(filters={"user_id": "u1"}, page=2, page_size=50)
call_kwargs = memory.calls[0][1]
assert "page" not in call_kwargs
assert "page_size" not in call_kwargs
assert result["count"] == 1
def test_get_all_returns_envelope(self):
backend, _ = self._make()
result = backend.get_all(filters={"user_id": "u1"})
assert "results" in result
assert "count" in result
def test_add_forwards_kwargs(self):
backend, memory = self._make()
msgs = [{"role": "user", "content": "hi"}]
backend.add(msgs, user_id="u1", agent_id="hermes", infer=False)
assert memory.calls[0][2]["user_id"] == "u1"
assert memory.calls[0][2]["infer"] is False
def test_update_maps_text_to_data(self):
"""OSS Memory.update uses `data=` param, not `text=`."""
backend, memory = self._make()
backend.update("m1", "new text")
assert memory.calls[0][0] == "update"
assert memory.calls[0][1] == "m1"
assert memory.calls[0][2] == {"data": "new text"}
def test_delete_positional_arg(self):
backend, memory = self._make()
backend.delete("m1")
assert memory.calls[0] == ("delete", "m1")
def test_update_normalizes_response(self):
backend, _ = self._make()
result = backend.update("m1", "text")
assert result == {"result": "Memory updated.", "memory_id": "m1"}
def test_delete_normalizes_response(self):
backend, _ = self._make()
result = backend.delete("m1")
assert result == {"result": "Memory deleted.", "memory_id": "m1"}