mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-24 10:52:21 +00:00
* 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>
209 lines
7.5 KiB
Python
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"}
|