membase-strands 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Membase memory for Strands Agents: a ``MemoryStore`` plus deliberate search/save/profile tools."""
|
|
2
|
+
|
|
3
|
+
from .store import (
|
|
4
|
+
DEFAULT_RECALL_TIMEOUT,
|
|
5
|
+
MembaseAccessError,
|
|
6
|
+
MembaseMemoryStore,
|
|
7
|
+
MembaseRecallTimeout,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DEFAULT_RECALL_TIMEOUT",
|
|
12
|
+
"MembaseAccessError",
|
|
13
|
+
"MembaseMemoryStore",
|
|
14
|
+
"MembaseRecallTimeout",
|
|
15
|
+
]
|
|
16
|
+
__version__ = "0.1.0"
|
membase_strands/py.typed
ADDED
|
File without changes
|
membase_strands/store.py
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""A Strands ``MemoryStore`` backed by Membase.
|
|
2
|
+
|
|
3
|
+
:class:`MembaseMemoryStore` plugs a person's Membase memory into
|
|
4
|
+
:class:`strands.memory.MemoryManager`:
|
|
5
|
+
|
|
6
|
+
* :meth:`MembaseMemoryStore.search` is what the manager calls before each user turn
|
|
7
|
+
(context injection) and from its built-in ``search_memory`` tool. It is time-boxed
|
|
8
|
+
(8 s by default) because a Membase search is a turn inside the account's own memory
|
|
9
|
+
container, and the first one after a quiet spell can take up to a minute while it
|
|
10
|
+
wakes. A recall that runs out of budget raises, and the manager logs it and carries
|
|
11
|
+
on without memory (fail open); the request keeps running in a daemon thread, so the
|
|
12
|
+
memory is warm for the next turn.
|
|
13
|
+
* :meth:`MembaseMemoryStore.add` / :meth:`MembaseMemoryStore.add_messages` are the
|
|
14
|
+
manager's write sinks. They only exist for the manager when ``capture=True``
|
|
15
|
+
(``writable`` follows ``capture``); the default is off, because Membase saves what
|
|
16
|
+
the user asked to keep, in their words, rather than every turn.
|
|
17
|
+
* :meth:`MembaseMemoryStore.get_tools` gives the model ``membase_search``,
|
|
18
|
+
``membase_save`` and ``membase_profile``: deliberate calls with the SDK's full
|
|
19
|
+
timeout, whose results cite the Membase memory (container) each passage came from.
|
|
20
|
+
|
|
21
|
+
Failure contract (mirrors the SDK's vended stores): ``search``, ``add`` and
|
|
22
|
+
``add_messages`` raise on failure, because the manager owns isolation. It logs a failed
|
|
23
|
+
search and continues, surfaces a failed ``add`` as ``AggregateMemoryError`` and retries a
|
|
24
|
+
failed ``add_messages`` batch. Only the model-facing tools turn errors into text.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import concurrent.futures
|
|
31
|
+
import logging
|
|
32
|
+
import os
|
|
33
|
+
import threading
|
|
34
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
35
|
+
from typing import Any, TypeVar
|
|
36
|
+
|
|
37
|
+
from membase import Membase
|
|
38
|
+
from membase.errors import (
|
|
39
|
+
APIConnectionError,
|
|
40
|
+
BadRequestError,
|
|
41
|
+
MembaseError,
|
|
42
|
+
PermissionDeniedError,
|
|
43
|
+
RateLimitError,
|
|
44
|
+
UnprocessableEntityError,
|
|
45
|
+
)
|
|
46
|
+
from strands import tool
|
|
47
|
+
from strands.memory import AddMessagesContext, MemoryEntry, MemoryStore, SearchOptions
|
|
48
|
+
from strands.types.content import Message
|
|
49
|
+
from strands.types.tools import AgentTool
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
T = TypeVar("T")
|
|
54
|
+
|
|
55
|
+
# The package's own default: it does not depend on which SDK release is installed.
|
|
56
|
+
DEFAULT_BASE_URL = "https://api.app.membase.com"
|
|
57
|
+
DEFAULT_RECALL_TIMEOUT = 8.0
|
|
58
|
+
DEFAULT_MAX_SEARCH_RESULTS = 5
|
|
59
|
+
DEFAULT_DESCRIPTION = (
|
|
60
|
+
"The user's Membase memory: what they told their assistants and the documents their "
|
|
61
|
+
"Memories have read. Each passage names the Memory it came from."
|
|
62
|
+
)
|
|
63
|
+
ALL_TOOLS = ("search", "save", "profile")
|
|
64
|
+
|
|
65
|
+
REVOKED_MESSAGE = (
|
|
66
|
+
"Membase refused this request (403): this Membase key was narrowed or revoked by its owner, "
|
|
67
|
+
"or it does not allow this operation. Ask the owner for access; do not retry with this key."
|
|
68
|
+
)
|
|
69
|
+
TIMEOUT_MESSAGE = (
|
|
70
|
+
"Membase did not answer in time. The memory may still be waking up after a quiet spell; try again in a minute."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class MembaseAccessError(PermissionError):
|
|
75
|
+
"""The key was refused (403): its owner narrowed or revoked it. Never retried."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class MembaseRecallTimeout(TimeoutError):
|
|
79
|
+
"""An automatic recall ran past its budget; the manager logs it and continues without memory."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class MembaseMemoryStore(MemoryStore):
|
|
83
|
+
"""One person's Membase memory as a Strands memory store.
|
|
84
|
+
|
|
85
|
+
Membase has no ``user_id``: one API key (or one local directory) is one person's
|
|
86
|
+
account, and the ``MemoryStore`` protocol passes no user or session to ``search``.
|
|
87
|
+
So one store serves one person. For a multi-user app, build the store (and the
|
|
88
|
+
agent's ``MemoryManager``) per user, from that user's own key or directory:
|
|
89
|
+
|
|
90
|
+
.. code-block:: python
|
|
91
|
+
|
|
92
|
+
def agent_for(user):
|
|
93
|
+
store = MembaseMemoryStore(api_key=keys.get(user.id))
|
|
94
|
+
return Agent(memory_manager=MemoryManager(stores=[store], search_tool_config=False))
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
client: A ready :class:`membase.Membase` client. Otherwise one is built from
|
|
98
|
+
``api_key`` / ``base_url`` / ``local`` (or ``MEMBASE_API_KEY``).
|
|
99
|
+
api_key: A Membase developer key (Read, or Read & write to save).
|
|
100
|
+
base_url: Overrides ``https://api.app.membase.com`` (or ``MEMBASE_BASE_URL``).
|
|
101
|
+
local: A directory for local mode (``pip install 'unibaseio-membase[local]'``).
|
|
102
|
+
container: The Memory (container id or name) to search and to write captured
|
|
103
|
+
turns to. Omit to search every Memory the key reaches; writes then need it
|
|
104
|
+
when the key reaches more than one.
|
|
105
|
+
name: Store name the manager and its tools use. Defaults to ``"membase"``.
|
|
106
|
+
description: Shown to the model in the manager's search tool description.
|
|
107
|
+
max_search_results: Default passages per search when the caller passes none.
|
|
108
|
+
recall_timeout: Budget in seconds for :meth:`search` (the manager's automatic
|
|
109
|
+
injection and its ``search_memory`` tool). ``None`` waits for the SDK's own
|
|
110
|
+
timeout. Defaults to 8 s.
|
|
111
|
+
capture: Let the manager write to Membase: makes the store ``writable`` so the
|
|
112
|
+
manager's ``add_memory`` tool and automatic ``extraction`` can use
|
|
113
|
+
:meth:`add` / :meth:`add_messages`. Defaults to ``False``.
|
|
114
|
+
extraction: The manager's automatic-extraction config (``True`` or an
|
|
115
|
+
``ExtractionConfig``); requires ``capture=True``. Without a custom extractor,
|
|
116
|
+
the manager hands raw turns to :meth:`add_messages`, which saves them as one
|
|
117
|
+
note for Membase to learn from.
|
|
118
|
+
capture_roles: Which messages :meth:`add_messages` saves. Defaults to the user's
|
|
119
|
+
own words only, so assistant answers are not learned as facts.
|
|
120
|
+
tools: Which model-facing tools :meth:`get_tools` returns, from
|
|
121
|
+
``("search", "save", "profile")``. Defaults to all three; drop ``"save"`` for
|
|
122
|
+
a Read key, ``"profile"`` when the owner did not share the profile.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
*,
|
|
128
|
+
client: Membase | None = None,
|
|
129
|
+
api_key: str | None = None,
|
|
130
|
+
base_url: str | None = None,
|
|
131
|
+
local: str | bool | None = None,
|
|
132
|
+
container: str | None = None,
|
|
133
|
+
name: str = "membase",
|
|
134
|
+
description: str | None = DEFAULT_DESCRIPTION,
|
|
135
|
+
max_search_results: int | None = None,
|
|
136
|
+
recall_timeout: float | None = DEFAULT_RECALL_TIMEOUT,
|
|
137
|
+
capture: bool = False,
|
|
138
|
+
extraction: Any = None,
|
|
139
|
+
capture_roles: Sequence[str] = ("user",),
|
|
140
|
+
tools: Iterable[str] = ALL_TOOLS,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Build the store. No network call happens here."""
|
|
143
|
+
if extraction and not capture:
|
|
144
|
+
raise ValueError("MembaseMemoryStore: extraction writes to Membase, so it needs capture=True")
|
|
145
|
+
if recall_timeout is not None and recall_timeout <= 0:
|
|
146
|
+
raise ValueError("MembaseMemoryStore: recall_timeout must be positive or None")
|
|
147
|
+
tool_names = tuple(tools)
|
|
148
|
+
unknown = sorted(set(tool_names) - set(ALL_TOOLS))
|
|
149
|
+
if unknown:
|
|
150
|
+
raise ValueError(f"MembaseMemoryStore: unknown tools {unknown}; choose from {ALL_TOOLS}")
|
|
151
|
+
|
|
152
|
+
if client is None:
|
|
153
|
+
base_url = base_url or os.environ.get("MEMBASE_BASE_URL") or DEFAULT_BASE_URL
|
|
154
|
+
client = Membase(api_key=api_key, base_url=base_url, local=local)
|
|
155
|
+
self._owns_client = True
|
|
156
|
+
else:
|
|
157
|
+
self._owns_client = False
|
|
158
|
+
self.client = client
|
|
159
|
+
|
|
160
|
+
# MemoryStore protocol attributes.
|
|
161
|
+
self.name = name
|
|
162
|
+
self.description = description
|
|
163
|
+
self.max_search_results = max_search_results
|
|
164
|
+
self.writable = capture
|
|
165
|
+
self.extraction = extraction
|
|
166
|
+
|
|
167
|
+
self.container = container
|
|
168
|
+
self.recall_timeout = recall_timeout
|
|
169
|
+
self.capture = capture
|
|
170
|
+
self.capture_roles = tuple(capture_roles)
|
|
171
|
+
self.tool_names = tool_names
|
|
172
|
+
|
|
173
|
+
self._container_ids: dict[str, str] = {}
|
|
174
|
+
self._recall_inflight: concurrent.futures.Future[Any] | None = None
|
|
175
|
+
self._lock = threading.Lock()
|
|
176
|
+
|
|
177
|
+
# ------------------------------------------------------------------ search
|
|
178
|
+
|
|
179
|
+
async def search(self, query: str, options: SearchOptions | None = None) -> list[MemoryEntry]:
|
|
180
|
+
"""Passages on ``query``, most relevant first, each citing its Membase memory.
|
|
181
|
+
|
|
182
|
+
Time-boxed by ``recall_timeout`` because the manager calls it on its own before a
|
|
183
|
+
user turn. Raises :class:`MembaseRecallTimeout` when the budget runs out (the
|
|
184
|
+
manager logs it and continues without memory) and :class:`MembaseAccessError` on
|
|
185
|
+
a 403. A Memory that could not answer comes back as an entry saying so, not as
|
|
186
|
+
silence.
|
|
187
|
+
"""
|
|
188
|
+
if not query or not query.strip():
|
|
189
|
+
return []
|
|
190
|
+
limit = options.get("max_search_results") if options else None
|
|
191
|
+
if limit is None:
|
|
192
|
+
limit = self.max_search_results or DEFAULT_MAX_SEARCH_RESULTS
|
|
193
|
+
|
|
194
|
+
def call() -> list[MemoryEntry]:
|
|
195
|
+
return _to_entries(self._search_raw(query, limit))
|
|
196
|
+
|
|
197
|
+
if self.recall_timeout is None:
|
|
198
|
+
return await asyncio.to_thread(_translate_403, call)
|
|
199
|
+
return await self._time_boxed(call)
|
|
200
|
+
|
|
201
|
+
async def _time_boxed(self, call: Callable[[], list[MemoryEntry]]) -> list[MemoryEntry]:
|
|
202
|
+
with self._lock:
|
|
203
|
+
pending = self._recall_inflight
|
|
204
|
+
if pending is not None and not pending.done():
|
|
205
|
+
logger.info("store=<%s> | previous Membase recall still waking the memory; skipping", self.name)
|
|
206
|
+
raise MembaseRecallTimeout(
|
|
207
|
+
"Membase recall skipped: the previous one is still waiting for the memory to wake"
|
|
208
|
+
)
|
|
209
|
+
future: concurrent.futures.Future[list[MemoryEntry]] = concurrent.futures.Future()
|
|
210
|
+
self._recall_inflight = future
|
|
211
|
+
|
|
212
|
+
def run() -> None:
|
|
213
|
+
if not future.set_running_or_notify_cancel():
|
|
214
|
+
return
|
|
215
|
+
try:
|
|
216
|
+
future.set_result(_translate_403(call))
|
|
217
|
+
except BaseException as error: # noqa: BLE001 - handed to the awaiting coroutine
|
|
218
|
+
future.set_exception(error)
|
|
219
|
+
|
|
220
|
+
# A daemon thread, not the loop's executor: Strands runs a sync ``agent(...)`` call
|
|
221
|
+
# under ``asyncio.run``, which waits for executor threads on exit, so a request left
|
|
222
|
+
# behind there would hold the turn hostage. A daemon thread is simply abandoned.
|
|
223
|
+
threading.Thread(target=run, name="membase-recall", daemon=True).start()
|
|
224
|
+
try:
|
|
225
|
+
return await asyncio.wait_for(asyncio.shield(asyncio.wrap_future(future)), self.recall_timeout)
|
|
226
|
+
except asyncio.TimeoutError:
|
|
227
|
+
future.add_done_callback(_drain)
|
|
228
|
+
logger.warning(
|
|
229
|
+
"store=<%s>, budget=<%ss> | Membase recall ran out of time (memory may be waking up); "
|
|
230
|
+
"continuing without memory",
|
|
231
|
+
self.name,
|
|
232
|
+
self.recall_timeout,
|
|
233
|
+
)
|
|
234
|
+
raise MembaseRecallTimeout(
|
|
235
|
+
f"Membase recall took longer than {self.recall_timeout}s; continuing without memory"
|
|
236
|
+
) from None
|
|
237
|
+
|
|
238
|
+
def _search_raw(self, query: str, limit: int | None, container: str | None = None) -> dict[str, Any]:
|
|
239
|
+
target = container if container is not None else self.container
|
|
240
|
+
container_id = self._resolve_container(target) if target else None
|
|
241
|
+
return self.client.search(query, container=container_id, limit=limit)
|
|
242
|
+
|
|
243
|
+
# ------------------------------------------------------------------ writes
|
|
244
|
+
|
|
245
|
+
async def add(self, content: str, metadata: dict[str, Any] | None = None) -> Any:
|
|
246
|
+
"""Save one note to Membase. Only reachable through the manager with ``capture=True``.
|
|
247
|
+
|
|
248
|
+
``metadata`` may carry ``title`` and ``static`` (a standing fact for the profile);
|
|
249
|
+
other keys are not sent, since Membase does not filter on metadata.
|
|
250
|
+
"""
|
|
251
|
+
self._require_capture("add")
|
|
252
|
+
if not content or not content.strip():
|
|
253
|
+
raise ValueError("MembaseMemoryStore: content must not be empty")
|
|
254
|
+
meta = metadata or {}
|
|
255
|
+
static = bool(meta.get("static", False))
|
|
256
|
+
title = str(meta.get("title") or "")
|
|
257
|
+
return await asyncio.to_thread(_translate_403, lambda: self._save(content, None, static, title))
|
|
258
|
+
|
|
259
|
+
async def add_messages(self, messages: list[Message], context: AddMessagesContext | None = None) -> Any:
|
|
260
|
+
"""Save a batch of turns as one note for Membase to learn from (``capture=True`` only).
|
|
261
|
+
|
|
262
|
+
Keeps the text of the roles in ``capture_roles`` (the user's own words by default)
|
|
263
|
+
and skips the batch when nothing is left. Errors propagate so the manager retries
|
|
264
|
+
the batch; a retry can save the same note twice.
|
|
265
|
+
"""
|
|
266
|
+
del context
|
|
267
|
+
self._require_capture("add_messages")
|
|
268
|
+
lines: list[str] = []
|
|
269
|
+
for message in messages:
|
|
270
|
+
if message.get("role") not in self.capture_roles:
|
|
271
|
+
continue
|
|
272
|
+
text = "\n".join(
|
|
273
|
+
block["text"] for block in message.get("content", []) if isinstance(block, dict) and block.get("text")
|
|
274
|
+
).strip()
|
|
275
|
+
if text:
|
|
276
|
+
lines.append(f"{message['role']}: {text}" if len(self.capture_roles) > 1 else text)
|
|
277
|
+
if not lines:
|
|
278
|
+
return None
|
|
279
|
+
note = "\n\n".join(lines)
|
|
280
|
+
return await asyncio.to_thread(
|
|
281
|
+
_translate_403, lambda: self._save(note, None, False, "Conversation with a Strands agent")
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def _require_capture(self, method: str) -> None:
|
|
285
|
+
if not self.capture:
|
|
286
|
+
raise ValueError(
|
|
287
|
+
f"MembaseMemoryStore.{method}: capture is off. Writes are deliberate by default: "
|
|
288
|
+
"let the model call membase_save, or build the store with capture=True."
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def _save(self, content: str, container: str | None, static: bool, title: str) -> dict[str, Any]:
|
|
292
|
+
target = container if container is not None else self.container
|
|
293
|
+
container_id = None if static or not target else self._resolve_container(target)
|
|
294
|
+
return self.client.memories.add(content, container=container_id, static=static, title=title)
|
|
295
|
+
|
|
296
|
+
# ------------------------------------------------------------------ containers
|
|
297
|
+
|
|
298
|
+
def _resolve_container(self, value: str) -> str:
|
|
299
|
+
"""A container id for ``value`` (an id or a Memory's name), from ``containers.list``."""
|
|
300
|
+
cached = self._container_ids.get(value)
|
|
301
|
+
if cached:
|
|
302
|
+
return cached
|
|
303
|
+
containers = self.client.containers.list().get("containers", [])
|
|
304
|
+
for c in containers:
|
|
305
|
+
if c.get("id") == value:
|
|
306
|
+
self._container_ids[value] = value
|
|
307
|
+
return value
|
|
308
|
+
wanted = value.strip().casefold()
|
|
309
|
+
for c in containers:
|
|
310
|
+
if str(c.get("name", "")).strip().casefold() == wanted:
|
|
311
|
+
self._container_ids[value] = c["id"]
|
|
312
|
+
return c["id"]
|
|
313
|
+
names = ", ".join(repr(c.get("name")) for c in containers) or "none"
|
|
314
|
+
raise ValueError(f"No Membase memory named {value!r} is in this key's reach (available: {names})")
|
|
315
|
+
|
|
316
|
+
# ------------------------------------------------------------------ tools
|
|
317
|
+
|
|
318
|
+
def get_tools(self) -> list[AgentTool]:
|
|
319
|
+
"""``membase_search`` / ``membase_save`` / ``membase_profile`` for deliberate calls.
|
|
320
|
+
|
|
321
|
+
These use the SDK's full timeout (a cold start can take up to a minute) and turn
|
|
322
|
+
errors into text the model can relay.
|
|
323
|
+
"""
|
|
324
|
+
store = self
|
|
325
|
+
tools: list[AgentTool] = []
|
|
326
|
+
|
|
327
|
+
if "search" in self.tool_names:
|
|
328
|
+
|
|
329
|
+
@tool(
|
|
330
|
+
name="membase_search",
|
|
331
|
+
description=(
|
|
332
|
+
"Search the user's Membase memory for what they told their assistants before and "
|
|
333
|
+
"what their Memories have read. Each passage names the Memory it came from; cite it. "
|
|
334
|
+
"Use when the answer may depend on the user's past, preferences or documents."
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
async def membase_search(query: str, container: str | None = None, limit: int | None = None) -> str:
|
|
338
|
+
"""Search Membase.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
query: What to look for, in plain words.
|
|
342
|
+
container: Optional name (or id) of one Memory to search; omit to search all.
|
|
343
|
+
limit: Optional maximum number of passages (1-50).
|
|
344
|
+
"""
|
|
345
|
+
return await _tool_call(lambda: _format_search(store._search_raw(query, limit, container), query))
|
|
346
|
+
|
|
347
|
+
tools.append(membase_search)
|
|
348
|
+
|
|
349
|
+
if "save" in self.tool_names:
|
|
350
|
+
|
|
351
|
+
@tool(
|
|
352
|
+
name="membase_save",
|
|
353
|
+
description=(
|
|
354
|
+
"Save something to the user's Membase memory. Only when the user asked you to remember "
|
|
355
|
+
"it or plainly meant it to be kept; save their words, not your summary of the chat. "
|
|
356
|
+
"Set standing_fact=true for a lasting fact about the user (goes to their profile). "
|
|
357
|
+
"If the user has several Memories, name the one to save to."
|
|
358
|
+
),
|
|
359
|
+
)
|
|
360
|
+
async def membase_save(content: str, container: str | None = None, standing_fact: bool = False) -> str:
|
|
361
|
+
"""Save to Membase.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
content: What to save, in the user's words.
|
|
365
|
+
container: Name (or id) of the Memory to save to; needed when there are several.
|
|
366
|
+
standing_fact: True for a lasting fact about the user, kept in their profile.
|
|
367
|
+
"""
|
|
368
|
+
if not content or not content.strip():
|
|
369
|
+
return "Nothing to save: content is empty."
|
|
370
|
+
|
|
371
|
+
def save() -> str:
|
|
372
|
+
try:
|
|
373
|
+
return _format_saved(store._save(content, container, standing_fact, ""))
|
|
374
|
+
except BadRequestError as error:
|
|
375
|
+
if standing_fact or container or store.container:
|
|
376
|
+
raise
|
|
377
|
+
names = [str(c.get("name")) for c in store.client.containers.list().get("containers", [])]
|
|
378
|
+
return (
|
|
379
|
+
f"Membase needs to know which Memory to save to ({error.message}). "
|
|
380
|
+
f"Ask the user, then call membase_save again with container set to one of: {names}"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
return await _tool_call(save)
|
|
384
|
+
|
|
385
|
+
tools.append(membase_save)
|
|
386
|
+
|
|
387
|
+
if "profile" in self.tool_names:
|
|
388
|
+
|
|
389
|
+
@tool(
|
|
390
|
+
name="membase_profile",
|
|
391
|
+
description=(
|
|
392
|
+
"Read the user's Membase profile: standing facts about who they are and recent changes. "
|
|
393
|
+
"Call once near the start of a conversation, optionally with a topic."
|
|
394
|
+
),
|
|
395
|
+
)
|
|
396
|
+
async def membase_profile(topic: str | None = None) -> str:
|
|
397
|
+
"""Read the Membase profile.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
topic: Optional topic to pull relevant profile facts for.
|
|
401
|
+
"""
|
|
402
|
+
return await _tool_call(lambda: _format_profile(store.client.profile(q=topic or None)))
|
|
403
|
+
|
|
404
|
+
tools.append(membase_profile)
|
|
405
|
+
|
|
406
|
+
return tools
|
|
407
|
+
|
|
408
|
+
def close(self) -> None:
|
|
409
|
+
"""Close the client if this store built it."""
|
|
410
|
+
if self._owns_client:
|
|
411
|
+
self.client.close()
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# ---------------------------------------------------------------------- helpers
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _drain(future: concurrent.futures.Future[Any]) -> None:
|
|
418
|
+
"""Log the outcome of a recall nobody waited for (and keep its exception retrieved)."""
|
|
419
|
+
if future.cancelled():
|
|
420
|
+
return
|
|
421
|
+
error = future.exception()
|
|
422
|
+
if error is not None:
|
|
423
|
+
logger.info("reason=<%s> | late Membase recall failed", error)
|
|
424
|
+
else:
|
|
425
|
+
logger.info("late Membase recall finished; the memory is awake for the next turn")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _translate_403(call: Callable[[], T]) -> T:
|
|
429
|
+
try:
|
|
430
|
+
return call()
|
|
431
|
+
except PermissionDeniedError as error:
|
|
432
|
+
raise MembaseAccessError(f"{REVOKED_MESSAGE} ({error.message})") from error
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _to_entries(payload: dict[str, Any]) -> list[MemoryEntry]:
|
|
436
|
+
entries: list[MemoryEntry] = []
|
|
437
|
+
for hit in payload.get("results") or []:
|
|
438
|
+
content = str(hit.get("content") or "").strip()
|
|
439
|
+
if not content:
|
|
440
|
+
continue
|
|
441
|
+
name = hit.get("container_name") or "Membase"
|
|
442
|
+
metadata = {k: v for k, v in hit.items() if k != "content" and v is not None}
|
|
443
|
+
entries.append(MemoryEntry(content=f"[{name}] {content}", metadata=metadata or None))
|
|
444
|
+
for c in _unavailable(payload):
|
|
445
|
+
logger.warning("container=<%s>, reason=<%s> | Membase memory could not answer", c["name"], c["error"])
|
|
446
|
+
entries.append(
|
|
447
|
+
MemoryEntry(
|
|
448
|
+
content=f"[{c['name']}] This Membase memory could not answer the search: {c['error']}",
|
|
449
|
+
metadata={"container_name": c["name"], "container_id": c["id"], "error": c["error"]},
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
return entries
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _unavailable(payload: dict[str, Any]) -> list[dict[str, str]]:
|
|
456
|
+
out = []
|
|
457
|
+
for c in payload.get("containers") or []:
|
|
458
|
+
if c.get("error"):
|
|
459
|
+
out.append(
|
|
460
|
+
{"id": str(c.get("id") or ""), "name": str(c.get("name") or c.get("id")), "error": str(c["error"])}
|
|
461
|
+
)
|
|
462
|
+
return out
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _format_search(payload: dict[str, Any], query: str) -> str:
|
|
466
|
+
hits = [h for h in payload.get("results") or [] if str(h.get("content") or "").strip()]
|
|
467
|
+
lines = [
|
|
468
|
+
f"{i}. [{h.get('container_name') or 'Membase'}] {str(h['content']).strip()}" for i, h in enumerate(hits, 1)
|
|
469
|
+
]
|
|
470
|
+
errors = [f"- Memory '{c['name']}' could not answer: {c['error']}" for c in _unavailable(payload)]
|
|
471
|
+
if not lines and not errors:
|
|
472
|
+
return f"No Membase memory matched {query!r}."
|
|
473
|
+
out = []
|
|
474
|
+
if lines:
|
|
475
|
+
out.append("Membase passages (cite the Memory in brackets):\n" + "\n".join(lines))
|
|
476
|
+
if errors:
|
|
477
|
+
out.append("Not searched, so missing results are not evidence of absence:\n" + "\n".join(errors))
|
|
478
|
+
return "\n\n".join(out)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _format_saved(result: dict[str, Any] | None) -> str:
|
|
482
|
+
result = result or {}
|
|
483
|
+
if result.get("static"):
|
|
484
|
+
return "Saved to the user's Membase profile as a standing fact."
|
|
485
|
+
return (
|
|
486
|
+
"Saved to Membase. It is queued for learning and shows up in search once the Memory has "
|
|
487
|
+
f"learned it (memory_id={result.get('memory_id')}, status={result.get('status', 'queued')})."
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _format_profile(result: dict[str, Any]) -> str:
|
|
492
|
+
sections = []
|
|
493
|
+
for key, title in (("static", "Standing facts"), ("dynamic", "Recently changed"), ("results", "On this topic")):
|
|
494
|
+
items = [_profile_item(x) for x in result.get(key) or []]
|
|
495
|
+
items = [x for x in items if x]
|
|
496
|
+
if items:
|
|
497
|
+
sections.append(f"{title}:\n" + "\n".join(f"- {x}" for x in items))
|
|
498
|
+
return "\n\n".join(sections) or "The Membase profile is empty."
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _profile_item(item: Any) -> str:
|
|
502
|
+
if isinstance(item, dict):
|
|
503
|
+
return str(item.get("content") or item.get("fact") or item.get("text") or "").strip()
|
|
504
|
+
return str(item).strip()
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
async def _tool_call(call: Callable[[], str]) -> str:
|
|
508
|
+
"""Run a blocking SDK call for a tool and turn Membase errors into a sentence."""
|
|
509
|
+
try:
|
|
510
|
+
return await asyncio.to_thread(call)
|
|
511
|
+
except PermissionDeniedError:
|
|
512
|
+
return REVOKED_MESSAGE
|
|
513
|
+
except APIConnectionError: # includes APITimeoutError
|
|
514
|
+
return TIMEOUT_MESSAGE
|
|
515
|
+
except BadRequestError as error:
|
|
516
|
+
return f"Membase rejected the request: {error.message}"
|
|
517
|
+
except UnprocessableEntityError as error:
|
|
518
|
+
return (
|
|
519
|
+
"The user's Membase memory cannot run right now (no working model or agent container; "
|
|
520
|
+
f"the owner can fix it under AI Setup): {error.message}"
|
|
521
|
+
)
|
|
522
|
+
except RateLimitError:
|
|
523
|
+
return "Membase is busy for this account right now (rate limited); try again shortly."
|
|
524
|
+
except MembaseError as error:
|
|
525
|
+
return f"Membase error: {error}"
|
|
526
|
+
except ValueError as error: # an unknown Memory name
|
|
527
|
+
return str(error)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: membase-strands
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Membase memory for Strands Agents: a MemoryStore with time-boxed recall and deliberate search/save/profile tools.
|
|
5
|
+
Project-URL: Homepage, https://membase.com
|
|
6
|
+
Project-URL: Documentation, https://github.com/unibaseio/membase/tree/main/integrations/strands#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/unibaseio/membase
|
|
8
|
+
Project-URL: Issues, https://github.com/unibaseio/membase/issues
|
|
9
|
+
Author: Unibase
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agents,long-term-memory,membase,memory,strands,strands-agents
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: strands-agents>=1.45.0
|
|
23
|
+
Requires-Dist: unibaseio-membase>=0.3.2
|
|
24
|
+
Provides-Extra: local
|
|
25
|
+
Requires-Dist: unibaseio-membase[local]>=0.3.2; extra == 'local'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# membase-strands
|
|
29
|
+
|
|
30
|
+
[Membase](https://membase.com) memory for [Strands Agents](https://strandsagents.com) (Python).
|
|
31
|
+
|
|
32
|
+
`MembaseMemoryStore` implements Strands' `MemoryStore` protocol, so it plugs into
|
|
33
|
+
`strands.memory.MemoryManager` like the other memory stores:
|
|
34
|
+
|
|
35
|
+
- **Recall before each user turn.** The manager's context injection calls `search`, and the
|
|
36
|
+
passages it returns are folded into that model call. Each one names the Membase memory
|
|
37
|
+
(container) it came from: `[Project decisions] We chose Postgres for the ledger.`
|
|
38
|
+
- **Time-boxed, fail-open recall.** A Membase search runs inside the account's own memory
|
|
39
|
+
container, and the first one after a quiet spell takes longer while it wakes. The
|
|
40
|
+
automatic recall gets 8 seconds by default (`recall_timeout`). If it runs out, the manager
|
|
41
|
+
logs it and the turn goes ahead without memory. The request keeps running in the
|
|
42
|
+
background, so the memory is awake for the next turn. While it is still running, later
|
|
43
|
+
recalls skip immediately instead of piling up.
|
|
44
|
+
- **Deliberate writes.** `get_tools()` gives the model `membase_search`, `membase_save` and
|
|
45
|
+
`membase_profile`. Saving is a tool the model calls when the user asked it to remember
|
|
46
|
+
something. Saving every turn automatically (`capture=True`) is opt-in.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install membase-strands
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
Create a developer key in the Membase app under **Connect › Developer keys**. Use
|
|
55
|
+
*Read & write* if the agent should save, and tick the profile if it should read it.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from strands import Agent
|
|
59
|
+
from strands.memory import MemoryManager
|
|
60
|
+
from membase_strands import MembaseMemoryStore
|
|
61
|
+
|
|
62
|
+
store = MembaseMemoryStore(api_key="mbk_...") # or set MEMBASE_API_KEY
|
|
63
|
+
|
|
64
|
+
agent = Agent(
|
|
65
|
+
memory_manager=MemoryManager(
|
|
66
|
+
stores=[store],
|
|
67
|
+
search_tool_config=False, # the model gets membase_search instead (see below)
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
agent("Which database did we pick for the ledger?")
|
|
72
|
+
# Before the model call, the manager searches Membase and injects:
|
|
73
|
+
# <memory>
|
|
74
|
+
# <entry source="membase">[Project decisions] We chose Postgres for the ledger.</entry>
|
|
75
|
+
# </memory>
|
|
76
|
+
|
|
77
|
+
agent("Remember that we moved the launch to May.")
|
|
78
|
+
# The model calls membase_save(content="We moved the launch to May.", container="Project decisions")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Why `search_tool_config=False`
|
|
82
|
+
|
|
83
|
+
The manager's built-in `search_memory` tool calls the same time-boxed `search` as injection.
|
|
84
|
+
On a cold start it would give up after 8 seconds and show the model an empty list. The
|
|
85
|
+
store's own `membase_search` tool waits for the SDK's full timeout (90 s), so a cold start
|
|
86
|
+
gets an answer. It also lets the model pick one Memory by name, and it says when a Memory
|
|
87
|
+
could not answer. You can keep both tools if you prefer.
|
|
88
|
+
|
|
89
|
+
## Tools
|
|
90
|
+
|
|
91
|
+
| Tool | What it does |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `membase_search(query, container?, limit?)` | Searches every Memory in reach, or one Memory by name or id. Each passage is cited as `[Memory name] …`. A Memory that could not answer is listed as such, so it is not mistaken for "no memory". |
|
|
94
|
+
| `membase_save(content, container?, standing_fact?)` | Saves a note in the user's words. `standing_fact=true` records a lasting fact in the user's profile. If the key reaches several Memories and none is named, the tool returns their names so the model can ask which one. |
|
|
95
|
+
| `membase_profile(topic?)` | Reads the user's standing facts and recent changes. This needs the owner to have ticked the profile for the key. |
|
|
96
|
+
|
|
97
|
+
Pick the tools with `tools=("search", "profile")`. For example, drop `"save"` for a Read key.
|
|
98
|
+
There is no delete or forget tool, and the store never confirms a destructive operation for
|
|
99
|
+
the user.
|
|
100
|
+
|
|
101
|
+
## Options
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
MembaseMemoryStore(
|
|
105
|
+
client=None, # a ready membase.Membase client, or:
|
|
106
|
+
api_key=None, # MEMBASE_API_KEY
|
|
107
|
+
base_url=None, # MEMBASE_BASE_URL, default https://api.app.membase.com
|
|
108
|
+
local=None, # a directory for local mode (pip install 'membase-strands[local]')
|
|
109
|
+
container=None, # one Memory (name or id) to search and write to; default: all in reach
|
|
110
|
+
name="membase", # store name in the manager
|
|
111
|
+
max_search_results=None, # passages per search when the caller passes none (default 5)
|
|
112
|
+
recall_timeout=8.0, # budget for the automatic recall; None = SDK timeout
|
|
113
|
+
capture=False, # let the manager write (add_memory tool, extraction)
|
|
114
|
+
extraction=None, # the manager's extraction config; needs capture=True
|
|
115
|
+
capture_roles=("user",), # which messages captured turns keep
|
|
116
|
+
tools=("search", "save", "profile"),
|
|
117
|
+
)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Automatic capture (opt-in)
|
|
121
|
+
|
|
122
|
+
By default the store is not `writable`, so the manager never writes to Membase. The only way
|
|
123
|
+
to save is the model calling `membase_save` when the user asks. Membase learns from what it
|
|
124
|
+
is given, and a transcript of every turn would teach it the assistant's own answers as facts.
|
|
125
|
+
|
|
126
|
+
To capture anyway:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
store = MembaseMemoryStore(container="Conversations", capture=True, extraction=True)
|
|
130
|
+
agent = Agent(memory_manager=MemoryManager(stores=[store], search_tool_config=False))
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
With `extraction=True` the manager hands batches of turns to `add_messages`, by default every
|
|
134
|
+
5 turns and at the end of each synchronous `agent(...)` call. The store saves the user's
|
|
135
|
+
messages from the batch as one note in `container`, and Membase learns from it in the
|
|
136
|
+
background. `capture_roles=("user", "assistant")` keeps both sides. `capture=True` also lets
|
|
137
|
+
you enable the manager's own `add_memory` tool (`MemoryManager(..., add_tool_config=True)`).
|
|
138
|
+
A retried batch can be saved twice.
|
|
139
|
+
|
|
140
|
+
## One store per person
|
|
141
|
+
|
|
142
|
+
Membase has no `user_id`. One key, or one local directory, is one person's account. The
|
|
143
|
+
`MemoryStore` protocol does not pass a user or session to `search` either. So a store built
|
|
144
|
+
from a single key suits **a personal agent**. In a multi-user app, build the store and the
|
|
145
|
+
agent per user, from that user's own key or directory, and never route one user's turn
|
|
146
|
+
through another user's key:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
def agent_for(user) -> Agent:
|
|
150
|
+
store = MembaseMemoryStore(api_key=keys.get(user.id)) # that user's key
|
|
151
|
+
# or: MembaseMemoryStore(local=f"/data/membase/{user.id}") # one directory per user
|
|
152
|
+
return Agent(memory_manager=MemoryManager(stores=[store], search_tool_config=False))
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Errors
|
|
156
|
+
|
|
157
|
+
- **403** means the key's owner narrowed or revoked it. Automatic recall raises
|
|
158
|
+
`MembaseAccessError`; the manager logs it and continues without memory. The tools return
|
|
159
|
+
"this Membase key was narrowed or revoked…" so the model can tell the user. Nothing retries
|
|
160
|
+
with that key.
|
|
161
|
+
- **A Memory that could not answer** (no working model, dormant) is reported. Recall returns
|
|
162
|
+
an entry that says so, and `membase_search` lists it under "Not searched".
|
|
163
|
+
- **A recall that runs out of time** raises `MembaseRecallTimeout` (a `TimeoutError`); the
|
|
164
|
+
manager logs it and continues. The tools use the SDK timeout and, if that runs out too,
|
|
165
|
+
return a "try again in a minute" sentence.
|
|
166
|
+
- Captured writes (`add`, `add_messages`) raise on failure so the manager can report or retry
|
|
167
|
+
them. The tools turn errors into sentences.
|
|
168
|
+
|
|
169
|
+
The key is never logged.
|
|
170
|
+
|
|
171
|
+
## Compatibility
|
|
172
|
+
|
|
173
|
+
Python 3.10+, `strands-agents>=1.45.0` (the first release with `strands.memory`) and
|
|
174
|
+
`unibaseio-membase>=0.3.2`. The offline tests pass on strands-agents 1.45.0, 1.50.0, 1.55.0
|
|
175
|
+
and 1.61.1.
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
uv venv && uv pip install -e . pytest pytest-asyncio ruff
|
|
181
|
+
.venv/bin/python -m pytest -q # offline: real Strands Agent, Membase SDK on httpx.MockTransport
|
|
182
|
+
MEMBASE_API_KEY=mbk_... .venv/bin/python examples/live_smoke.py
|
|
183
|
+
uv build
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
MIT licence.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
membase_strands/__init__.py,sha256=kLU8OKgG1g_larSC4YN5yPZdEjNVol9Mgzchy9eH6lo,375
|
|
2
|
+
membase_strands/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
membase_strands/store.py,sha256=hW1ibz84z13zbeY0b35PprvUGVy_TWd1RW1JDGyEg48,24260
|
|
4
|
+
membase_strands-0.1.0.dist-info/METADATA,sha256=QgwOq3gN73Gwu6U1KZELlMpE725xkAUXcJ8F9BPS8XQ,8907
|
|
5
|
+
membase_strands-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
membase_strands-0.1.0.dist-info/licenses/LICENSE,sha256=SWhYL0rjdALC-gYSOEAX-Z4LpxatwZx1kP6XwuOTSzs,1064
|
|
7
|
+
membase_strands-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Unibase
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|