refactor(integrations): rename dsh-mem0 to deepseek-plugin, strands-mem0 to mem0-strands (#7098)
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
"""Strands Mem0 -- persistent long-term memory for Strands agents, backed by Mem0.
|
||||
|
||||
:class:`Mem0MemoryStore` is a Strands ``MemoryStore`` that plugs into the agent
|
||||
loop via a :class:`~strands.memory.MemoryManager`, with automatic memory injection
|
||||
and extraction. It implements both write sinks, so ``extraction`` uses Mem0's
|
||||
server-side extraction (no extra model call).
|
||||
|
||||
For the explicit, model-called tool (``store`` / ``retrieve`` / ``get`` / ``delete``),
|
||||
use the ``mem0_memory`` tool from ``strands-agents-tools``; a store and the tool can
|
||||
share one Mem0 backend and namespace.
|
||||
"""
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
from mem0_strands.client import Mem0ServiceClient
|
||||
from mem0_strands.store import Mem0MemoryStore
|
||||
|
||||
__all__ = [
|
||||
"Mem0MemoryStore",
|
||||
"Mem0ServiceClient",
|
||||
]
|
||||
|
||||
try:
|
||||
__version__ = version("mem0-strands")
|
||||
except PackageNotFoundError: # pragma: no cover - only when running from a source tree
|
||||
__version__ = "0.0.0+unknown"
|
||||
@@ -0,0 +1,176 @@
|
||||
"""A thin wrapper around the Mem0 SDK used by :class:`~mem0_strands.store.Mem0MemoryStore`.
|
||||
|
||||
Both Mem0 backends -- the hosted platform (:class:`mem0.MemoryClient`) and
|
||||
self-hosted OSS (:class:`mem0.Memory`) -- expose the same call shape to the store:
|
||||
|
||||
- **search** takes the entity scope inside a ``filters`` dict plus ``top_k``.
|
||||
- **add** takes the entity scope as top-level keyword arguments.
|
||||
|
||||
The wrapper hides the two remaining differences:
|
||||
|
||||
- ``app_id`` is a platform-only scope; OSS ``Memory.add`` has no ``app_id``
|
||||
parameter, so it is rejected up front for the OSS backend rather than surfacing
|
||||
as a ``TypeError`` mid-call.
|
||||
- the telemetry ``source`` tag is attached to platform writes only (OSS
|
||||
``Memory.add`` has a fixed signature and would reject an unknown kwarg).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
# Only the synchronous platform client is supported. ``AsyncMemoryClient``'s
|
||||
# ``add`` / ``search`` are coroutine functions, so ``asyncio.to_thread`` would hand
|
||||
# back an un-awaited coroutine and every write would silently no-op; it is rejected
|
||||
# in ``__init__`` rather than listed here.
|
||||
_PLATFORM_CLIENTS = {"MemoryClient"}
|
||||
|
||||
# Tags platform writes so Mem0's backend attributes the memory to this integration
|
||||
# in telemetry (recognized values live in the backend's KNOWN_EVENT_SOURCES
|
||||
# allowlist; unknown ones bucket into "OTHERS"). Platform only.
|
||||
_SOURCE = "STRANDS"
|
||||
|
||||
|
||||
def _is_platform_client(client: Any) -> bool:
|
||||
"""Whether ``client`` is a hosted Mem0 platform client (vs an OSS ``Memory``)."""
|
||||
return type(client).__name__ in _PLATFORM_CLIENTS
|
||||
|
||||
|
||||
def _is_async_client(client: Any) -> bool:
|
||||
"""Whether ``client``'s ``add`` / ``search`` are coroutine functions."""
|
||||
return inspect.iscoroutinefunction(getattr(client, "add", None)) or inspect.iscoroutinefunction(
|
||||
getattr(client, "search", None)
|
||||
)
|
||||
|
||||
|
||||
class Mem0ServiceClient:
|
||||
"""Thin wrapper around the Mem0 SDK for the memory store.
|
||||
|
||||
Exactly one backend is selected at construction time:
|
||||
|
||||
- ``client`` given: use it as-is (a :class:`mem0.MemoryClient` or
|
||||
:class:`mem0.Memory`); mainly for testing and advanced/OSS setups.
|
||||
- ``config`` given: build a self-hosted :class:`mem0.Memory` from it.
|
||||
- otherwise: build a hosted :class:`mem0.MemoryClient` from ``api_key`` /
|
||||
``$MEM0_API_KEY`` (and optional ``host``).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
host: str | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
client: Any | None = None,
|
||||
) -> None:
|
||||
"""Initialize the Mem0 client.
|
||||
|
||||
Args:
|
||||
api_key: Mem0 platform API key. Falls back to ``$MEM0_API_KEY``.
|
||||
host: Mem0 platform base URL. Defaults to the SDK default
|
||||
(``https://api.mem0.ai``).
|
||||
config: A Mem0 OSS config dict; when given, a self-hosted
|
||||
:class:`mem0.Memory` is built instead of the platform client.
|
||||
client: A pre-built Mem0 client to use directly (platform or OSS).
|
||||
|
||||
Raises:
|
||||
ValueError: If ``client`` is an async Mem0 client (its coroutines
|
||||
would never be awaited off the worker thread).
|
||||
"""
|
||||
if client is not None:
|
||||
if _is_async_client(client):
|
||||
raise ValueError(
|
||||
"Async Mem0 clients are not supported. Pass a synchronous "
|
||||
"mem0.MemoryClient (or a mem0.Memory / config): the store runs the "
|
||||
"SDK in a worker thread, so an async client's coroutines would "
|
||||
"never be awaited and every write would silently no-op."
|
||||
)
|
||||
self.mem0 = client
|
||||
self.is_platform = _is_platform_client(client)
|
||||
return
|
||||
|
||||
if config is not None:
|
||||
try:
|
||||
from mem0 import Memory
|
||||
except ImportError as err: # pragma: no cover - exercised via install docs
|
||||
raise ImportError(
|
||||
"The mem0ai package is required. Install it with: pip install 'mem0-strands'"
|
||||
) from err
|
||||
self.mem0 = Memory.from_config(config)
|
||||
self.is_platform = False
|
||||
return
|
||||
|
||||
try:
|
||||
from mem0 import MemoryClient
|
||||
except ImportError as err: # pragma: no cover - exercised via install docs
|
||||
raise ImportError("The mem0ai package is required. Install it with: pip install 'mem0-strands'") from err
|
||||
api_key = api_key or os.environ.get("MEM0_API_KEY")
|
||||
# MemoryClient(host=None) would override the SDK default with None, so only
|
||||
# pass host when the caller actually set one.
|
||||
self.mem0 = MemoryClient(api_key=api_key, host=host) if host else MemoryClient(api_key=api_key)
|
||||
self.is_platform = True
|
||||
|
||||
def _check_scope(self, scope: dict[str, str]) -> None:
|
||||
"""Reject scope the selected backend cannot honor.
|
||||
|
||||
``app_id`` exists only on the platform; the OSS ``Memory`` API has no
|
||||
``app_id`` parameter, so we fail loudly here rather than let it surface as
|
||||
a ``TypeError`` on ``add`` or silently miss on ``search``.
|
||||
"""
|
||||
if not self.is_platform and "app_id" in scope:
|
||||
raise ValueError(
|
||||
"app_id is a Mem0 platform-only scope. The OSS backend supports "
|
||||
"user_id, agent_id, and run_id; drop app_id or use the platform client."
|
||||
)
|
||||
|
||||
def _write_extras(self) -> dict[str, str]:
|
||||
"""Extra kwargs attached to platform writes: the telemetry ``source`` tag."""
|
||||
return {"source": _SOURCE} if self.is_platform else {}
|
||||
|
||||
def store_memory(
|
||||
self,
|
||||
content: str,
|
||||
scope: dict[str, str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""Store one discrete fact verbatim (``infer=False``).
|
||||
|
||||
Used by the store's ``add`` sink -- the content is already a distilled fact
|
||||
(from the ``add_memory`` tool or a client-side extractor), so Mem0's own
|
||||
extraction is skipped to preserve it exactly.
|
||||
"""
|
||||
self._check_scope(scope)
|
||||
return self.mem0.add(content, metadata=metadata, infer=False, **self._write_extras(), **scope)
|
||||
|
||||
def store_messages(self, messages: list[dict[str, Any]], scope: dict[str, str]) -> Any:
|
||||
"""Hand rendered conversation turns to Mem0 for server-side extraction (``infer=True``).
|
||||
|
||||
Used by the store's ``add_messages`` sink. Mem0 extracts and de-duplicates
|
||||
facts on the server, so no client-side model call is needed.
|
||||
"""
|
||||
self._check_scope(scope)
|
||||
return self.mem0.add(messages, infer=True, **self._write_extras(), **scope)
|
||||
|
||||
def search_memories(self, query: str, scope: dict[str, str], top_k: int) -> list[dict[str, Any]]:
|
||||
"""Semantic recall scoped to the store's entity.
|
||||
|
||||
Both backends take the scope inside ``filters`` and honor ``top_k``; the
|
||||
response is normalized to a plain list of memory dicts.
|
||||
"""
|
||||
self._check_scope(scope)
|
||||
response = self.mem0.search(query, filters=dict(scope), top_k=top_k)
|
||||
return _extract_results(response)
|
||||
|
||||
|
||||
def _extract_results(response: Any) -> list[dict[str, Any]]:
|
||||
"""Normalize a Mem0 search response to a list of memory dicts.
|
||||
|
||||
Mem0 returns ``{"results": [...]}`` (v1.1) or, on older paths, a bare list.
|
||||
"""
|
||||
if isinstance(response, dict):
|
||||
results = response.get("results", [])
|
||||
return list(results) if isinstance(results, list) else []
|
||||
if isinstance(response, list):
|
||||
return response
|
||||
return []
|
||||
@@ -0,0 +1,222 @@
|
||||
"""A Strands ``MemoryStore`` backed by Mem0.
|
||||
|
||||
A memory store gives a Strands agent cross-session recall: a
|
||||
:class:`~strands.memory.MemoryManager` searches it to recall facts and, when
|
||||
writable, writes new ones -- either directly or via automatic extraction from the
|
||||
conversation. Unlike the ``mem0_memory`` tool (which the model calls explicitly),
|
||||
a store plugs into the agent loop out of the box, with memory injection and
|
||||
extraction triggers handled by the manager.
|
||||
|
||||
``Mem0MemoryStore`` implements both write sinks, which is what sets it apart from a
|
||||
vector-DB-style store:
|
||||
|
||||
- :meth:`add` writes a single distilled fact verbatim (``infer=False``). This is
|
||||
the sink for the ``add_memory`` tool and for a client-side extractor.
|
||||
- :meth:`add_messages` renders raw conversation turns to text and hands them to
|
||||
Mem0 for **server-side extraction** (``infer=True``). Because this sink exists,
|
||||
enabling ``extraction`` routes messages straight to Mem0's own extraction
|
||||
pipeline -- no extra client-side model call, and Mem0's de-duplication applies.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from strands import Agent
|
||||
from strands.memory import MemoryManager
|
||||
from mem0_strands import Mem0MemoryStore
|
||||
|
||||
# Recall + write, with Mem0 extracting facts from the conversation server-side.
|
||||
store = Mem0MemoryStore(user_id="alex", writable=True, extraction=True)
|
||||
agent = Agent(memory_manager=MemoryManager(stores=[store]))
|
||||
```
|
||||
|
||||
Configure the hosted platform via the ``api_key`` argument or the ``MEM0_API_KEY``
|
||||
environment variable, or pass a Mem0 OSS ``config`` dict for a self-hosted backend.
|
||||
``app_id`` scope is platform-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from strands.memory import AddMessagesContext, MemoryEntry, MemoryStore, SearchOptions
|
||||
from strands.types.content import Message
|
||||
|
||||
from mem0_strands.client import Mem0ServiceClient
|
||||
|
||||
DEFAULT_MAX_SEARCH_RESULTS = 5
|
||||
# Entity fields that scope a memory in Mem0. At least one must be set.
|
||||
_SCOPE_FIELDS = ("user_id", "agent_id", "run_id", "app_id")
|
||||
|
||||
|
||||
class Mem0MemoryStore(MemoryStore):
|
||||
"""A Strands :class:`~strands.memory.MemoryStore` backed by Mem0.
|
||||
|
||||
Implements :meth:`search` (semantic recall), :meth:`add` (a verbatim
|
||||
single-fact write sink) and :meth:`add_messages` (raw-message ingestion with
|
||||
Mem0 server-side extraction). Because ``add_messages`` is implemented, enabling
|
||||
``extraction`` uses Mem0's server-side extraction rather than a client-side
|
||||
model call.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
app_id: str | None = None,
|
||||
name: str = "mem0",
|
||||
description: str | None = "Persistent long-term memory backed by Mem0.",
|
||||
max_search_results: int | None = None,
|
||||
writable: bool = True,
|
||||
extraction: Any = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
api_key: str | None = None,
|
||||
host: str | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
client: Mem0ServiceClient | None = None,
|
||||
) -> None:
|
||||
"""Initialize the store.
|
||||
|
||||
Args:
|
||||
user_id: Mem0 user namespace that owns the memories.
|
||||
agent_id: Mem0 agent namespace.
|
||||
run_id: Mem0 run/session namespace.
|
||||
app_id: Mem0 app namespace (platform only).
|
||||
name: Unique store identifier, used to target it in tools.
|
||||
description: Human-readable description, included in tool descriptions.
|
||||
max_search_results: Default maximum results per search.
|
||||
writable: Whether the store accepts writes.
|
||||
extraction: Automatic-extraction config (``bool | ExtractionConfig``).
|
||||
metadata: Default metadata merged into every write.
|
||||
api_key: Mem0 platform API key (defaults to ``$MEM0_API_KEY``).
|
||||
host: Mem0 platform base URL.
|
||||
config: Mem0 OSS config dict for a self-hosted backend.
|
||||
client: A pre-built :class:`~mem0_strands.client.Mem0ServiceClient`
|
||||
(for testing, or to wrap your own raw Mem0 client via
|
||||
``Mem0ServiceClient(client=...)``); when omitted, one is
|
||||
constructed lazily on first use from ``api_key`` / ``config``.
|
||||
|
||||
Raises:
|
||||
ValueError: If no entity scope (``user_id`` / ``agent_id`` / ``run_id``
|
||||
/ ``app_id``) is provided.
|
||||
"""
|
||||
scope = {
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"run_id": run_id,
|
||||
"app_id": app_id,
|
||||
}
|
||||
self.scope = {key: value for key, value in scope.items() if value}
|
||||
if not self.scope:
|
||||
raise ValueError("Mem0MemoryStore requires at least one of user_id, agent_id, run_id, or app_id")
|
||||
# app_id is platform-only. When a self-hosted OSS backend is requested via
|
||||
# `config`, fail at construction rather than as a TypeError on the first
|
||||
# write (OSS Memory.add has no app_id). The injected-client OSS case is
|
||||
# caught in Mem0ServiceClient, which is the only place that knows the backend.
|
||||
if "app_id" in self.scope and config is not None:
|
||||
raise ValueError(
|
||||
"app_id is a Mem0 platform-only scope and cannot be used with a self-hosted "
|
||||
"config (OSS Memory has no app_id). Drop app_id or use the platform backend."
|
||||
)
|
||||
|
||||
# MemoryStore Protocol attributes.
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.max_search_results = max_search_results
|
||||
self.writable = writable
|
||||
self.extraction = extraction
|
||||
|
||||
# Mem0-specific configuration.
|
||||
self.metadata = metadata
|
||||
|
||||
self._api_key = api_key
|
||||
self._host = host
|
||||
self._config = config
|
||||
self._client = client
|
||||
|
||||
@property
|
||||
def client(self) -> Mem0ServiceClient:
|
||||
"""The Mem0 service client, constructed lazily on first use.
|
||||
|
||||
Note: constructing the underlying SDK client can block (the platform client
|
||||
validates the API key over HTTP; the OSS client builds embedders / vector
|
||||
stores), so first use is deferred and always happens inside a worker thread
|
||||
via :func:`asyncio.to_thread`, never on the event loop.
|
||||
"""
|
||||
if self._client is None:
|
||||
self._client = Mem0ServiceClient(api_key=self._api_key, host=self._host, config=self._config)
|
||||
return self._client
|
||||
|
||||
async def search(self, query: str, options: SearchOptions | None = None) -> list[MemoryEntry]:
|
||||
"""Search Mem0 for entries matching ``query``, ordered by relevance."""
|
||||
top_k = options.get("max_search_results") if options is not None else None
|
||||
if top_k is None:
|
||||
top_k = self.max_search_results
|
||||
if top_k is None:
|
||||
top_k = DEFAULT_MAX_SEARCH_RESULTS
|
||||
|
||||
# ``self.client`` is resolved inside the thread so lazy construction (a
|
||||
# blocking call) does not run on the event loop.
|
||||
memories = await asyncio.to_thread(lambda: self.client.search_memories(query, self.scope, top_k))
|
||||
return [self._to_entry(memory) for memory in memories]
|
||||
|
||||
async def add(self, content: str, metadata: dict[str, Any] | None = None) -> Any:
|
||||
"""Write a single distilled fact to Mem0 verbatim (``infer=False``).
|
||||
|
||||
Extraction writes are at-least-once, so this tolerates duplicate content;
|
||||
Mem0 de-duplicates on the server.
|
||||
"""
|
||||
merged = self._merge_metadata(metadata)
|
||||
return await asyncio.to_thread(lambda: self.client.store_memory(content, self.scope, merged))
|
||||
|
||||
async def add_messages(self, messages: list[Message], context: AddMessagesContext | None = None) -> Any:
|
||||
"""Ingest raw conversation turns for Mem0 server-side extraction (``infer=True``).
|
||||
|
||||
A Strands ``Message.content`` is a list of content blocks (a text block is
|
||||
``{"text": "..."}``); Mem0 keeps only ``{"type": "text"}`` parts, so the raw
|
||||
blocks would be dropped. We render each turn's text blocks to a string and
|
||||
skip turns that render empty (a pure tool-use / tool-result turn), so nothing
|
||||
silently no-ops.
|
||||
"""
|
||||
payload: list[dict[str, str]] = []
|
||||
for message in messages:
|
||||
text = self._render_content(message.get("content"))
|
||||
if text:
|
||||
payload.append({"role": message["role"], "content": text})
|
||||
if not payload:
|
||||
return None
|
||||
return await asyncio.to_thread(lambda: self.client.store_messages(payload, self.scope))
|
||||
|
||||
@staticmethod
|
||||
def _render_content(content: Any) -> str:
|
||||
"""Flatten a Strands message ``content`` to plain text.
|
||||
|
||||
Accepts either a string or a list of content blocks; joins the text of
|
||||
every ``{"text": ...}`` block and ignores tool-use / image / other blocks.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "\n".join(part["text"] for part in content if isinstance(part, dict) and part.get("text"))
|
||||
return ""
|
||||
|
||||
def _merge_metadata(self, metadata: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""Merge per-call metadata over the store's default metadata."""
|
||||
if self.metadata and metadata:
|
||||
return {**self.metadata, **metadata}
|
||||
return metadata or self.metadata
|
||||
|
||||
@staticmethod
|
||||
def _to_entry(memory: dict[str, Any]) -> MemoryEntry:
|
||||
"""Map a Mem0 memory dict to a Strands :class:`~strands.memory.MemoryEntry`."""
|
||||
content = memory.get("memory") or memory.get("content") or ""
|
||||
metadata: dict[str, Any] = {}
|
||||
for key in ("id", "score", "categories", "created_at", "updated_at", *_SCOPE_FIELDS):
|
||||
value = memory.get(key)
|
||||
if value is not None:
|
||||
metadata[key] = value
|
||||
extra = memory.get("metadata")
|
||||
if isinstance(extra, dict):
|
||||
metadata.update(extra)
|
||||
return MemoryEntry(content=content, metadata=metadata or None)
|
||||
Reference in New Issue
Block a user