langchain-membase 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.
- langchain_membase/__init__.py +49 -0
- langchain_membase/_common.py +248 -0
- langchain_membase/middleware.py +246 -0
- langchain_membase/py.typed +0 -0
- langchain_membase/retrievers.py +129 -0
- langchain_membase/tools.py +296 -0
- langchain_membase-0.1.0.dist-info/METADATA +211 -0
- langchain_membase-0.1.0.dist-info/RECORD +10 -0
- langchain_membase-0.1.0.dist-info/WHEEL +4 -0
- langchain_membase-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Membase long-term memory for LangChain and LangGraph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from ._common import DEFAULT_RECALL_TIMEOUT, MissingUserIdError
|
|
8
|
+
from .retrievers import MembaseRetriever, MembaseUnavailableError
|
|
9
|
+
from .tools import (
|
|
10
|
+
MembaseProfileTool,
|
|
11
|
+
MembaseSaveTool,
|
|
12
|
+
MembaseSearchTool,
|
|
13
|
+
MembaseToolkit,
|
|
14
|
+
membase_tools,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .middleware import MembaseRecallMiddleware
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"DEFAULT_RECALL_TIMEOUT",
|
|
24
|
+
"MembaseProfileTool",
|
|
25
|
+
"MembaseRecallMiddleware",
|
|
26
|
+
"MembaseRetriever",
|
|
27
|
+
"MembaseSaveTool",
|
|
28
|
+
"MembaseSearchTool",
|
|
29
|
+
"MembaseToolkit",
|
|
30
|
+
"MembaseUnavailableError",
|
|
31
|
+
"MissingUserIdError",
|
|
32
|
+
"membase_tools",
|
|
33
|
+
"__version__",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def __getattr__(name: str) -> Any:
|
|
38
|
+
# The middleware needs `langchain` (the `agents` extra); importing the package must not.
|
|
39
|
+
if name == "MembaseRecallMiddleware":
|
|
40
|
+
try:
|
|
41
|
+
from .middleware import MembaseRecallMiddleware
|
|
42
|
+
except ImportError as e:
|
|
43
|
+
raise ImportError(
|
|
44
|
+
"MembaseRecallMiddleware needs LangChain v1: "
|
|
45
|
+
"pip install 'langchain-membase[agents]'"
|
|
46
|
+
) from e
|
|
47
|
+
|
|
48
|
+
return MembaseRecallMiddleware
|
|
49
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Shared plumbing: which client serves a call, how errors read, how recall is formatted."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
from collections.abc import Callable, Mapping
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, TypeVar
|
|
12
|
+
|
|
13
|
+
from membase import Membase
|
|
14
|
+
from membase.errors import (
|
|
15
|
+
APIConnectionError,
|
|
16
|
+
APITimeoutError,
|
|
17
|
+
MembaseError,
|
|
18
|
+
PermissionDeniedError,
|
|
19
|
+
)
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("langchain_membase")
|
|
23
|
+
|
|
24
|
+
ClientFor = Callable[[str], Membase]
|
|
25
|
+
"""Returns the Membase client of one end user: their own key, or their own local directory."""
|
|
26
|
+
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
29
|
+
# The package's own default: it does not depend on which SDK release is installed.
|
|
30
|
+
DEFAULT_BASE_URL = "https://api.app.membase.com"
|
|
31
|
+
|
|
32
|
+
DEFAULT_RECALL_TIMEOUT = 8.0
|
|
33
|
+
"""Budget, in seconds, for automatic per-turn recall. Explicit tool calls use the SDK's own."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MissingUserIdError(ValueError):
|
|
37
|
+
"""``client_for`` is set but the call carried no user id, so no client may serve it."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_client(
|
|
41
|
+
api_key: str | SecretStr | None = None,
|
|
42
|
+
base_url: str | None = None,
|
|
43
|
+
local: str | os.PathLike | bool | None = None,
|
|
44
|
+
) -> Membase:
|
|
45
|
+
"""A Membase client from the usual arguments, falling back to ``MEMBASE_API_KEY``."""
|
|
46
|
+
key = api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key
|
|
47
|
+
base_url = base_url or os.environ.get("MEMBASE_BASE_URL") or DEFAULT_BASE_URL
|
|
48
|
+
return Membase(api_key=key, base_url=base_url, local=local)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MembaseClientMixin(BaseModel):
|
|
52
|
+
"""Fields every Membase object takes: one ready client, or ``client_for`` for many users."""
|
|
53
|
+
|
|
54
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
55
|
+
|
|
56
|
+
client: Membase | None = Field(default=None, exclude=True, repr=False)
|
|
57
|
+
"""A ready Membase client. One client serves every call: right for a personal agent."""
|
|
58
|
+
client_for: ClientFor | None = Field(default=None, exclude=True, repr=False)
|
|
59
|
+
"""``user_id -> Membase`` for a multi-user app; the user id comes from the run's config."""
|
|
60
|
+
api_key: SecretStr | None = Field(default=None, exclude=True, repr=False)
|
|
61
|
+
"""Membase API key; defaults to ``MEMBASE_API_KEY``. Used when no ``client`` is given."""
|
|
62
|
+
base_url: str | None = None
|
|
63
|
+
"""API base URL; defaults to ``MEMBASE_BASE_URL`` or ``https://api.app.membase.com``."""
|
|
64
|
+
local: str | Path | bool | None = Field(default=None, exclude=True)
|
|
65
|
+
"""Run the Membase API in-process on this directory (``True`` = ``~/.membase``)."""
|
|
66
|
+
|
|
67
|
+
@model_validator(mode="after")
|
|
68
|
+
def _one_way_to_reach_memory(self) -> MembaseClientMixin:
|
|
69
|
+
if self.client_for is not None:
|
|
70
|
+
if self.client is not None or self.api_key is not None or self.local:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
"pass either client_for (one client per user) or client / api_key / local "
|
|
73
|
+
"(one client for every call), not both"
|
|
74
|
+
)
|
|
75
|
+
return self
|
|
76
|
+
if self.client is None:
|
|
77
|
+
self.client = build_client(self.api_key, self.base_url, self.local or None)
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def _membase(self, user_id: str | None) -> Membase:
|
|
81
|
+
return resolve_client(self.client, self.client_for, user_id)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_client(
|
|
85
|
+
client: Membase | None, client_for: ClientFor | None, user_id: str | None
|
|
86
|
+
) -> Membase:
|
|
87
|
+
"""The client for this call. With ``client_for`` a user id is mandatory: no fallback client."""
|
|
88
|
+
if client_for is not None:
|
|
89
|
+
if not user_id:
|
|
90
|
+
raise MissingUserIdError(
|
|
91
|
+
"client_for is set, so every call needs the end user's id: pass "
|
|
92
|
+
"config={'configurable': {'user_id': ...}} (or a LangGraph context with user_id)"
|
|
93
|
+
)
|
|
94
|
+
return client_for(user_id)
|
|
95
|
+
if client is None: # pragma: no cover - the validator always sets one
|
|
96
|
+
raise ValueError("no Membase client configured")
|
|
97
|
+
if user_id:
|
|
98
|
+
_warn_single_client_once()
|
|
99
|
+
return client
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_warned_single_client = False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _warn_single_client_once() -> None:
|
|
106
|
+
global _warned_single_client
|
|
107
|
+
if not _warned_single_client:
|
|
108
|
+
_warned_single_client = True
|
|
109
|
+
logger.warning(
|
|
110
|
+
"a user_id reached langchain-membase but no client_for is set, so one Membase key "
|
|
111
|
+
"serves every user. That suits a personal agent only; a multi-user app must pass "
|
|
112
|
+
"client_for so each person's call uses their own key."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def user_id_from(config: Mapping[str, Any] | None = None, context: Any = None) -> str | None:
|
|
117
|
+
"""The end user's id: ``configurable.user_id`` first, then a LangGraph runtime context."""
|
|
118
|
+
configurable = (config or {}).get("configurable") or {}
|
|
119
|
+
uid = configurable.get("user_id")
|
|
120
|
+
if uid in (None, "") and context is None:
|
|
121
|
+
context = _langgraph_context()
|
|
122
|
+
if uid in (None, "") and context is not None:
|
|
123
|
+
uid = (
|
|
124
|
+
context.get("user_id")
|
|
125
|
+
if isinstance(context, Mapping)
|
|
126
|
+
else getattr(context, "user_id", None)
|
|
127
|
+
)
|
|
128
|
+
return str(uid) if uid not in (None, "") else None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _langgraph_context() -> Any:
|
|
132
|
+
try:
|
|
133
|
+
from langgraph.runtime import get_runtime
|
|
134
|
+
except ImportError:
|
|
135
|
+
return None
|
|
136
|
+
try:
|
|
137
|
+
return get_runtime().context
|
|
138
|
+
except Exception: # noqa: BLE001 - outside a graph run there is simply no context
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def describe_error(e: BaseException) -> str:
|
|
143
|
+
"""One actionable sentence for the model and the logs."""
|
|
144
|
+
if isinstance(e, PermissionDeniedError):
|
|
145
|
+
return (
|
|
146
|
+
f"Membase refused this request (403): {e.message}. This Membase key was narrowed or "
|
|
147
|
+
"revoked by its owner (a Memory switched off, an access level too low for this "
|
|
148
|
+
"action, or the key revoked); do not retry with this key."
|
|
149
|
+
)
|
|
150
|
+
if isinstance(e, APITimeoutError):
|
|
151
|
+
return (
|
|
152
|
+
"Membase did not answer in time; the user's memory may still be waking up after a "
|
|
153
|
+
"quiet spell. Try again in a moment."
|
|
154
|
+
)
|
|
155
|
+
if isinstance(e, APIConnectionError):
|
|
156
|
+
return f"Could not reach Membase: {e.message}."
|
|
157
|
+
if isinstance(e, MembaseError):
|
|
158
|
+
return f"Membase error: {e}"
|
|
159
|
+
return f"{type(e).__name__}: {e}"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def unavailable(resp: Mapping[str, Any]) -> list[str]:
|
|
163
|
+
"""``name: error`` for every Memory that could not answer a search."""
|
|
164
|
+
out = []
|
|
165
|
+
for c in resp.get("containers") or []:
|
|
166
|
+
if c.get("error"):
|
|
167
|
+
out.append(f"{c.get('name') or c.get('id')}: {c['error']}")
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def format_passages(results: list[Mapping[str, Any]]) -> str:
|
|
172
|
+
"""One line per passage, each citing the Memory (container) it came from."""
|
|
173
|
+
lines = []
|
|
174
|
+
for r in results:
|
|
175
|
+
name = r.get("container_name") or r.get("container") or "Membase"
|
|
176
|
+
content = str(r.get("content") or "").strip()
|
|
177
|
+
if content:
|
|
178
|
+
lines.append(f"- [{name}] {content}")
|
|
179
|
+
return "\n".join(lines)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def format_search(resp: Mapping[str, Any]) -> str:
|
|
183
|
+
"""Passages with their Memory, plus any Memory that could not answer (never 'no memory')."""
|
|
184
|
+
body = format_passages(resp.get("results") or [])
|
|
185
|
+
failed = unavailable(resp)
|
|
186
|
+
note = ""
|
|
187
|
+
if failed:
|
|
188
|
+
note = "These Memories could not answer, so what they hold is unknown: " + "; ".join(failed)
|
|
189
|
+
if body:
|
|
190
|
+
return body + (f"\n\n{note}" if note else "")
|
|
191
|
+
if note:
|
|
192
|
+
return "No passages came back. " + note
|
|
193
|
+
return "No matching memories."
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def call_with_budget(fn: Callable[[], T], timeout: float) -> T:
|
|
197
|
+
"""Run ``fn`` but stop waiting after ``timeout`` seconds (raises ``TimeoutError``).
|
|
198
|
+
|
|
199
|
+
The call keeps running on a daemon thread; letting it finish wakes the user's memory, so
|
|
200
|
+
the next turn is fast. A daemon thread never holds up interpreter exit.
|
|
201
|
+
"""
|
|
202
|
+
box: dict[str, Any] = {}
|
|
203
|
+
done = threading.Event()
|
|
204
|
+
|
|
205
|
+
def run() -> None:
|
|
206
|
+
try:
|
|
207
|
+
box["value"] = fn()
|
|
208
|
+
except BaseException as e: # noqa: BLE001 - re-raised on the caller's thread
|
|
209
|
+
box["error"] = e
|
|
210
|
+
finally:
|
|
211
|
+
done.set()
|
|
212
|
+
|
|
213
|
+
threading.Thread(target=run, name="membase-recall", daemon=True).start()
|
|
214
|
+
if not done.wait(timeout):
|
|
215
|
+
raise TimeoutError(f"Membase recall took longer than {timeout:g}s")
|
|
216
|
+
if "error" in box:
|
|
217
|
+
raise box["error"]
|
|
218
|
+
return box["value"]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def acall_with_budget(fn: Callable[[], T], timeout: float) -> T:
|
|
222
|
+
"""``call_with_budget`` for an event loop: the blocking SDK call runs off the loop."""
|
|
223
|
+
loop = asyncio.get_running_loop()
|
|
224
|
+
fut: asyncio.Future[T] = loop.create_future()
|
|
225
|
+
|
|
226
|
+
def settle(value: Any = None, error: BaseException | None = None) -> None:
|
|
227
|
+
if fut.done():
|
|
228
|
+
return
|
|
229
|
+
if error is not None:
|
|
230
|
+
fut.set_exception(error)
|
|
231
|
+
else:
|
|
232
|
+
fut.set_result(value)
|
|
233
|
+
|
|
234
|
+
def run() -> None:
|
|
235
|
+
try:
|
|
236
|
+
value, error = fn(), None
|
|
237
|
+
except BaseException as e: # noqa: BLE001 - handed to the awaiting coroutine
|
|
238
|
+
value, error = None, e
|
|
239
|
+
try:
|
|
240
|
+
loop.call_soon_threadsafe(settle, value, error)
|
|
241
|
+
except RuntimeError: # the loop closed while the call was still running
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
threading.Thread(target=run, name="membase-recall", daemon=True).start()
|
|
245
|
+
try:
|
|
246
|
+
return await asyncio.wait_for(fut, timeout)
|
|
247
|
+
except asyncio.TimeoutError:
|
|
248
|
+
raise TimeoutError(f"Membase recall took longer than {timeout:g}s") from None
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""MembaseRecallMiddleware: time-boxed recall before the model call of a ``create_agent`` agent.
|
|
2
|
+
|
|
3
|
+
Needs ``langchain>=1.1`` (``pip install 'langchain-membase[agents]'``).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import threading
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse
|
|
15
|
+
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
|
16
|
+
from membase import Membase
|
|
17
|
+
|
|
18
|
+
from ._common import (
|
|
19
|
+
DEFAULT_RECALL_TIMEOUT,
|
|
20
|
+
ClientFor,
|
|
21
|
+
acall_with_budget,
|
|
22
|
+
build_client,
|
|
23
|
+
call_with_budget,
|
|
24
|
+
describe_error,
|
|
25
|
+
format_passages,
|
|
26
|
+
logger,
|
|
27
|
+
resolve_client,
|
|
28
|
+
unavailable,
|
|
29
|
+
user_id_from,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
RECALL_HEADER = (
|
|
33
|
+
"## From the user's memory (Membase)\n"
|
|
34
|
+
"Passages that may be relevant to the user's latest message, each with the Memory it came "
|
|
35
|
+
"from in [brackets]. Use them only if they help, and cite the Memory when you do."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_CACHE_SIZE = 512
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MembaseRecallMiddleware(AgentMiddleware):
|
|
42
|
+
"""Searches Membase once per user message and adds what it finds to the system message.
|
|
43
|
+
|
|
44
|
+
* Recall is automatic, so it is time-boxed (``timeout``, 8 s by default) and fails open:
|
|
45
|
+
on a timeout, a refused key or any other error it logs and the model runs without memory.
|
|
46
|
+
A search that ran past the budget keeps going in the background, which wakes the user's
|
|
47
|
+
memory for the next turn.
|
|
48
|
+
* The search runs on the first model call after a user message; the model calls that follow
|
|
49
|
+
tool calls in the same turn reuse its result.
|
|
50
|
+
* The recall goes into the request's system message only; it is never written to the
|
|
51
|
+
agent's state or checkpoint.
|
|
52
|
+
* ``capture_turns=True`` (off by default) saves the user's own message(s) of each turn,
|
|
53
|
+
once, as a note in ``container``; assistant replies are never saved. Writing is
|
|
54
|
+
otherwise left to ``MembaseSaveTool``.
|
|
55
|
+
|
|
56
|
+
Multi-user: pass ``client_for``; the user id comes from the run context
|
|
57
|
+
(``context_schema`` with ``user_id``) or ``config["configurable"]["user_id"]``.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
client: Membase | None = None,
|
|
63
|
+
*,
|
|
64
|
+
client_for: ClientFor | None = None,
|
|
65
|
+
api_key: str | None = None,
|
|
66
|
+
base_url: str | None = None,
|
|
67
|
+
local: str | os.PathLike | bool | None = None,
|
|
68
|
+
container: str | None = None,
|
|
69
|
+
k: int | None = 5,
|
|
70
|
+
timeout: float = DEFAULT_RECALL_TIMEOUT,
|
|
71
|
+
capture_turns: bool = False,
|
|
72
|
+
header: str = RECALL_HEADER,
|
|
73
|
+
) -> None:
|
|
74
|
+
super().__init__()
|
|
75
|
+
if client_for is not None and (client is not None or api_key or local):
|
|
76
|
+
raise ValueError("pass either client_for or client / api_key / local, not both")
|
|
77
|
+
if client_for is None and client is None:
|
|
78
|
+
client = build_client(api_key, base_url, local)
|
|
79
|
+
self.client = client
|
|
80
|
+
self.client_for = client_for
|
|
81
|
+
self.container = container
|
|
82
|
+
self.k = k
|
|
83
|
+
self.timeout = timeout
|
|
84
|
+
self.capture_turns = capture_turns
|
|
85
|
+
self.header = header
|
|
86
|
+
self._cache: OrderedDict[tuple[str, str], str] = OrderedDict()
|
|
87
|
+
self._lock = threading.Lock()
|
|
88
|
+
|
|
89
|
+
# -- model call -------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def wrap_model_call(
|
|
92
|
+
self,
|
|
93
|
+
request: ModelRequest,
|
|
94
|
+
handler: Callable[[ModelRequest], ModelResponse],
|
|
95
|
+
) -> ModelResponse:
|
|
96
|
+
key = self._turn_key(request)
|
|
97
|
+
if key is None:
|
|
98
|
+
return handler(request)
|
|
99
|
+
recall = self._cached(key)
|
|
100
|
+
if recall is None:
|
|
101
|
+
recall = self._recall(lambda: self._search(key[0], key[2]))
|
|
102
|
+
self._remember(key, recall)
|
|
103
|
+
return handler(self._with_recall(request, recall))
|
|
104
|
+
|
|
105
|
+
async def awrap_model_call(
|
|
106
|
+
self,
|
|
107
|
+
request: ModelRequest,
|
|
108
|
+
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
|
109
|
+
) -> ModelResponse:
|
|
110
|
+
key = self._turn_key(request)
|
|
111
|
+
if key is None:
|
|
112
|
+
return await handler(request)
|
|
113
|
+
recall = self._cached(key)
|
|
114
|
+
if recall is None:
|
|
115
|
+
try:
|
|
116
|
+
resp = await acall_with_budget(lambda: self._search(key[0], key[2]), self.timeout)
|
|
117
|
+
recall = self._format(resp)
|
|
118
|
+
except Exception as e: # noqa: BLE001 - automatic recall fails open
|
|
119
|
+
recall = self._failed(e)
|
|
120
|
+
self._remember(key, recall)
|
|
121
|
+
return await handler(self._with_recall(request, recall))
|
|
122
|
+
|
|
123
|
+
# -- optional capture -------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def after_agent(self, state: Any, runtime: Any) -> dict[str, Any] | None:
|
|
126
|
+
if self.capture_turns:
|
|
127
|
+
self._capture(state, runtime)
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
async def aafter_agent(self, state: Any, runtime: Any) -> dict[str, Any] | None:
|
|
131
|
+
if self.capture_turns:
|
|
132
|
+
self._capture(state, runtime)
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
def _capture(self, state: Any, runtime: Any) -> None:
|
|
136
|
+
"""Save the user's own message(s) of the turn, once. Assistant replies are never saved:
|
|
137
|
+
an AI answer must not be learned as a fact about the user."""
|
|
138
|
+
messages: list[BaseMessage] = list(state.get("messages") or [])
|
|
139
|
+
last_human = _last_index(messages, HumanMessage)
|
|
140
|
+
if last_human is None:
|
|
141
|
+
return
|
|
142
|
+
first = last_human
|
|
143
|
+
while first > 0 and isinstance(messages[first - 1], HumanMessage):
|
|
144
|
+
first -= 1
|
|
145
|
+
turn = messages[first : last_human + 1]
|
|
146
|
+
note = "\n\n".join(t for t in (m.text.strip() for m in turn) if t)
|
|
147
|
+
if not note:
|
|
148
|
+
return
|
|
149
|
+
user_id = _user_id(runtime)
|
|
150
|
+
turn_key = (user_id or "", "captured:" + ",".join(m.id or str(hash(m.text)) for m in turn))
|
|
151
|
+
with self._lock:
|
|
152
|
+
if turn_key in self._cache:
|
|
153
|
+
return
|
|
154
|
+
self._cache[turn_key] = ""
|
|
155
|
+
try:
|
|
156
|
+
client = resolve_client(self.client, self.client_for, user_id)
|
|
157
|
+
except Exception as e: # noqa: BLE001 - capture never breaks the run
|
|
158
|
+
logger.warning("Membase capture skipped: %s", describe_error(e))
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
def save() -> None:
|
|
162
|
+
try:
|
|
163
|
+
client.memories.add(note, container=self.container)
|
|
164
|
+
except Exception as e: # noqa: BLE001 - logged, the run already finished
|
|
165
|
+
logger.warning("Membase capture failed: %s", describe_error(e))
|
|
166
|
+
|
|
167
|
+
# Saving waits on the network (up to the SDK timeout); do not hold the agent's answer.
|
|
168
|
+
threading.Thread(target=save, name="membase-capture", daemon=True).start()
|
|
169
|
+
|
|
170
|
+
# -- helpers ----------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def _turn_key(self, request: ModelRequest) -> tuple[str, str, str] | None:
|
|
173
|
+
"""(user id, message id, query) of the latest user message, or None when there is none."""
|
|
174
|
+
messages = list(request.messages or [])
|
|
175
|
+
idx = _last_index(messages, HumanMessage)
|
|
176
|
+
if idx is None:
|
|
177
|
+
return None
|
|
178
|
+
msg = messages[idx]
|
|
179
|
+
query = msg.text.strip()
|
|
180
|
+
if not query:
|
|
181
|
+
return None
|
|
182
|
+
return (_user_id(request.runtime) or "", msg.id or f"{idx}:{hash(query)}", query)
|
|
183
|
+
|
|
184
|
+
def _search(self, user_id: str, query: str) -> dict[str, Any]:
|
|
185
|
+
client = resolve_client(self.client, self.client_for, user_id or None)
|
|
186
|
+
return client.search(query, container=self.container, limit=self.k)
|
|
187
|
+
|
|
188
|
+
def _recall(self, fn: Callable[[], dict[str, Any]]) -> str:
|
|
189
|
+
try:
|
|
190
|
+
return self._format(call_with_budget(fn, self.timeout))
|
|
191
|
+
except Exception as e: # noqa: BLE001 - automatic recall fails open
|
|
192
|
+
return self._failed(e)
|
|
193
|
+
|
|
194
|
+
def _format(self, resp: dict[str, Any]) -> str:
|
|
195
|
+
failed = unavailable(resp)
|
|
196
|
+
if failed:
|
|
197
|
+
logger.warning("Membase recall: some Memories could not answer: %s", "; ".join(failed))
|
|
198
|
+
return format_passages(resp.get("results") or [])
|
|
199
|
+
|
|
200
|
+
def _failed(self, e: BaseException) -> str:
|
|
201
|
+
logger.warning("Membase recall skipped, continuing without memory: %s", describe_error(e))
|
|
202
|
+
return ""
|
|
203
|
+
|
|
204
|
+
def _cached(self, key: tuple[str, str, str]) -> str | None:
|
|
205
|
+
with self._lock:
|
|
206
|
+
return self._cache.get(key[:2])
|
|
207
|
+
|
|
208
|
+
def _remember(self, key: tuple[str, str, str], recall: str) -> None:
|
|
209
|
+
with self._lock:
|
|
210
|
+
self._cache[key[:2]] = recall
|
|
211
|
+
self._cache.move_to_end(key[:2])
|
|
212
|
+
while len(self._cache) > _CACHE_SIZE:
|
|
213
|
+
self._cache.popitem(last=False)
|
|
214
|
+
|
|
215
|
+
def _with_recall(self, request: ModelRequest, recall: str) -> ModelRequest:
|
|
216
|
+
if not recall:
|
|
217
|
+
return request
|
|
218
|
+
block = f"{self.header}\n{recall}"
|
|
219
|
+
current = request.system_message
|
|
220
|
+
if current is None:
|
|
221
|
+
return request.override(system_message=SystemMessage(content=block))
|
|
222
|
+
if isinstance(current.content, str):
|
|
223
|
+
content: Any = f"{current.content}\n\n{block}" if current.content else block
|
|
224
|
+
else:
|
|
225
|
+
content = [*current.content, {"type": "text", "text": block}]
|
|
226
|
+
return request.override(system_message=SystemMessage(content=content))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _user_id(runtime: Any) -> str | None:
|
|
230
|
+
"""From the run context (``context_schema`` with ``user_id``), else ``configurable``."""
|
|
231
|
+
user_id = user_id_from(None, getattr(runtime, "context", None))
|
|
232
|
+
if user_id is None:
|
|
233
|
+
try:
|
|
234
|
+
from langgraph.config import get_config
|
|
235
|
+
|
|
236
|
+
user_id = user_id_from(get_config())
|
|
237
|
+
except Exception: # noqa: BLE001 - outside a run, or Python 3.10 async
|
|
238
|
+
user_id = None
|
|
239
|
+
return user_id
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _last_index(messages: list[BaseMessage], kind: type[BaseMessage]) -> int | None:
|
|
243
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
244
|
+
if isinstance(messages[i], kind):
|
|
245
|
+
return i
|
|
246
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""MembaseRetriever: a Membase search as LangChain ``Document``s."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from langchain_core.callbacks import (
|
|
8
|
+
AsyncCallbackManagerForRetrieverRun,
|
|
9
|
+
CallbackManagerForRetrieverRun,
|
|
10
|
+
)
|
|
11
|
+
from langchain_core.documents import Document
|
|
12
|
+
from langchain_core.retrievers import BaseRetriever
|
|
13
|
+
from langchain_core.runnables import RunnableConfig
|
|
14
|
+
from langchain_core.runnables.config import ensure_config, run_in_executor
|
|
15
|
+
from membase.errors import PermissionDeniedError
|
|
16
|
+
from pydantic import Field
|
|
17
|
+
|
|
18
|
+
from ._common import MembaseClientMixin, logger, unavailable, user_id_from
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MembaseUnavailableError(RuntimeError):
|
|
22
|
+
"""A search found nothing *and* some Memories could not answer: not evidence of no memory."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MembaseRetriever(MembaseClientMixin, BaseRetriever):
|
|
26
|
+
"""Passages from the user's Membase memory, most relevant first.
|
|
27
|
+
|
|
28
|
+
Each ``Document`` carries ``container_name`` (the Memory it came from) and ``container``
|
|
29
|
+
(its id) in ``metadata``, so an answer can cite where it came from.
|
|
30
|
+
|
|
31
|
+
Setup:
|
|
32
|
+
``pip install langchain-membase`` and set ``MEMBASE_API_KEY`` (Connect > Developer keys
|
|
33
|
+
in the Membase app), or pass ``client=Membase(...)``.
|
|
34
|
+
|
|
35
|
+
Instantiate:
|
|
36
|
+
.. code-block:: python
|
|
37
|
+
|
|
38
|
+
from langchain_membase import MembaseRetriever
|
|
39
|
+
|
|
40
|
+
retriever = MembaseRetriever(k=5)
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
.. code-block:: python
|
|
44
|
+
|
|
45
|
+
docs = retriever.invoke("what did we decide about the ledger")
|
|
46
|
+
docs[0].metadata["container_name"] # "Project decisions"
|
|
47
|
+
|
|
48
|
+
Many users:
|
|
49
|
+
.. code-block:: python
|
|
50
|
+
|
|
51
|
+
retriever = MembaseRetriever(client_for=lambda user_id: Membase(api_key=keys[user_id]))
|
|
52
|
+
retriever.invoke("...", config={"configurable": {"user_id": "alice"}})
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
k: int | None = Field(default=None, ge=1, le=50)
|
|
56
|
+
"""How many passages in all. Unset lets Membase cover every Memory in reach (at least 8)."""
|
|
57
|
+
container: str | None = None
|
|
58
|
+
"""One container (Memory) id to search; unset searches every Memory the key reaches."""
|
|
59
|
+
|
|
60
|
+
def invoke(
|
|
61
|
+
self, input: str, config: RunnableConfig | None = None, **kwargs: Any
|
|
62
|
+
) -> list[Document]:
|
|
63
|
+
config = ensure_config(config)
|
|
64
|
+
if kwargs.get("user_id") is None:
|
|
65
|
+
kwargs["user_id"] = user_id_from(config)
|
|
66
|
+
return super().invoke(input, config, **kwargs)
|
|
67
|
+
|
|
68
|
+
async def ainvoke(
|
|
69
|
+
self, input: str, config: RunnableConfig | None = None, **kwargs: Any
|
|
70
|
+
) -> list[Document]:
|
|
71
|
+
config = ensure_config(config)
|
|
72
|
+
if kwargs.get("user_id") is None:
|
|
73
|
+
kwargs["user_id"] = user_id_from(config)
|
|
74
|
+
return await super().ainvoke(input, config, **kwargs)
|
|
75
|
+
|
|
76
|
+
def _get_relevant_documents(
|
|
77
|
+
self,
|
|
78
|
+
query: str,
|
|
79
|
+
*,
|
|
80
|
+
run_manager: CallbackManagerForRetrieverRun,
|
|
81
|
+
k: int | None = None,
|
|
82
|
+
container: str | None = None,
|
|
83
|
+
user_id: str | None = None,
|
|
84
|
+
) -> list[Document]:
|
|
85
|
+
client = self._membase(user_id)
|
|
86
|
+
try:
|
|
87
|
+
resp = client.search(query, container=container or self.container, limit=k or self.k)
|
|
88
|
+
except PermissionDeniedError as e:
|
|
89
|
+
raise PermissionDeniedError(
|
|
90
|
+
f"{e.message} (this Membase key was narrowed or revoked by its owner; "
|
|
91
|
+
"do not retry with it)",
|
|
92
|
+
status=e.status,
|
|
93
|
+
body=e.body,
|
|
94
|
+
) from e
|
|
95
|
+
docs = [_document(r) for r in resp.get("results") or [] if r.get("content")]
|
|
96
|
+
failed = unavailable(resp)
|
|
97
|
+
if failed and not docs:
|
|
98
|
+
raise MembaseUnavailableError(
|
|
99
|
+
"no passages came back and these Memories could not answer: " + "; ".join(failed)
|
|
100
|
+
)
|
|
101
|
+
if failed:
|
|
102
|
+
logger.warning("Membase search: some Memories could not answer: %s", "; ".join(failed))
|
|
103
|
+
return docs
|
|
104
|
+
|
|
105
|
+
async def _aget_relevant_documents(
|
|
106
|
+
self,
|
|
107
|
+
query: str,
|
|
108
|
+
*,
|
|
109
|
+
run_manager: AsyncCallbackManagerForRetrieverRun,
|
|
110
|
+
k: int | None = None,
|
|
111
|
+
container: str | None = None,
|
|
112
|
+
user_id: str | None = None,
|
|
113
|
+
) -> list[Document]:
|
|
114
|
+
# The SDK is synchronous; keep the event loop free while it waits on the network.
|
|
115
|
+
return await run_in_executor(
|
|
116
|
+
None,
|
|
117
|
+
self._get_relevant_documents,
|
|
118
|
+
query,
|
|
119
|
+
run_manager=run_manager.get_sync(),
|
|
120
|
+
k=k,
|
|
121
|
+
container=container,
|
|
122
|
+
user_id=user_id,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _document(r: dict[str, Any]) -> Document:
|
|
127
|
+
metadata = {key: value for key, value in r.items() if key != "content"}
|
|
128
|
+
metadata.setdefault("container_name", r.get("container_name") or r.get("container"))
|
|
129
|
+
return Document(page_content=str(r["content"]), metadata=metadata)
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Agent tools: search, save (only when the user asked) and read the profile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from langchain_core.callbacks import CallbackManagerForToolRun
|
|
9
|
+
from langchain_core.runnables import RunnableConfig
|
|
10
|
+
from langchain_core.tools import BaseTool, BaseToolkit, ToolException
|
|
11
|
+
from membase import Membase
|
|
12
|
+
from membase.errors import BadRequestError, MembaseError, PermissionDeniedError
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from ._common import (
|
|
16
|
+
ClientFor,
|
|
17
|
+
MembaseClientMixin,
|
|
18
|
+
describe_error,
|
|
19
|
+
format_passages,
|
|
20
|
+
format_search,
|
|
21
|
+
user_id_from,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _MembaseTool(MembaseClientMixin, BaseTool):
|
|
26
|
+
handle_tool_error: bool = True
|
|
27
|
+
"""A Membase failure becomes the tool's answer, so the model can tell the user."""
|
|
28
|
+
|
|
29
|
+
def _client_for_call(self, config: RunnableConfig | None) -> Membase:
|
|
30
|
+
return self._membase(user_id_from(config))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _container_id(client: Membase, ref: str) -> str:
|
|
34
|
+
"""A container id from a name or id the model gave, or a ToolException naming the choices."""
|
|
35
|
+
containers = client.containers.list().get("containers") or []
|
|
36
|
+
for c in containers:
|
|
37
|
+
if c.get("id") == ref:
|
|
38
|
+
return ref
|
|
39
|
+
wanted = ref.strip().casefold()
|
|
40
|
+
matches = [c for c in containers if str(c.get("name") or "").strip().casefold() == wanted]
|
|
41
|
+
if len(matches) == 1:
|
|
42
|
+
return str(matches[0]["id"])
|
|
43
|
+
names = ", ".join(repr(c.get("name")) for c in containers) or "none"
|
|
44
|
+
raise ToolException(f"No single Memory called {ref!r} is in reach. Memories in reach: {names}.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MembaseSearchInput(BaseModel):
|
|
48
|
+
query: str = Field(description="What to look for, as a natural-language question or topic.")
|
|
49
|
+
container: str | None = Field(
|
|
50
|
+
default=None,
|
|
51
|
+
description="Name of one Memory to search. Omit to search every Memory in reach.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class MembaseSearchTool(_MembaseTool):
|
|
56
|
+
"""Search the user's Membase memory; each passage names the Memory it came from.
|
|
57
|
+
|
|
58
|
+
The tool answers with text for the model and keeps the raw search response as the
|
|
59
|
+
``ToolMessage`` artifact.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
name: str = "membase_search"
|
|
63
|
+
description: str = (
|
|
64
|
+
"Search the user's long-term memory (Membase) for passages relevant to a question: what "
|
|
65
|
+
"they told you before, their projects, decisions, preferences and documents. Each passage "
|
|
66
|
+
"starts with the name of the Memory it came from in [brackets]; cite it in your answer. "
|
|
67
|
+
"The first search after a quiet spell can take up to a minute."
|
|
68
|
+
)
|
|
69
|
+
args_schema: type[BaseModel] = MembaseSearchInput
|
|
70
|
+
response_format: Literal["content", "content_and_artifact"] = "content_and_artifact"
|
|
71
|
+
|
|
72
|
+
k: int | None = Field(default=None, ge=1, le=50)
|
|
73
|
+
"""How many passages in all; unset lets Membase cover every Memory in reach."""
|
|
74
|
+
container: str | None = None
|
|
75
|
+
"""Default container id when the model names none; unset searches every Memory."""
|
|
76
|
+
|
|
77
|
+
def _run(
|
|
78
|
+
self,
|
|
79
|
+
query: str,
|
|
80
|
+
container: str | None = None,
|
|
81
|
+
*,
|
|
82
|
+
config: RunnableConfig,
|
|
83
|
+
run_manager: CallbackManagerForToolRun | None = None,
|
|
84
|
+
) -> tuple[str, dict[str, Any]]:
|
|
85
|
+
client = self._client_for_call(config)
|
|
86
|
+
try:
|
|
87
|
+
target = _container_id(client, container) if container else self.container
|
|
88
|
+
resp = client.search(query, container=target, limit=self.k)
|
|
89
|
+
except MembaseError as e:
|
|
90
|
+
raise ToolException(describe_error(e)) from e
|
|
91
|
+
return format_search(resp), resp
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class MembaseSaveInput(BaseModel):
|
|
95
|
+
content: str = Field(
|
|
96
|
+
description="The fact or note to keep, in the user's own words. One fact per call."
|
|
97
|
+
)
|
|
98
|
+
static: bool = Field(
|
|
99
|
+
default=False,
|
|
100
|
+
description=(
|
|
101
|
+
"True for a lasting fact about the user themself (name, role, a standing "
|
|
102
|
+
"preference): it goes to their profile. False for a note about anything else."
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
container: str | None = Field(
|
|
106
|
+
default=None,
|
|
107
|
+
description="Name of the Memory that should keep a note (ignored when static is true).",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class MembaseSaveTool(_MembaseTool):
|
|
112
|
+
"""Save one fact to the user's Membase memory. Writes only when the model calls it.
|
|
113
|
+
|
|
114
|
+
Needs a Read & write key. There is deliberately no delete or forget tool.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
name: str = "membase_save"
|
|
118
|
+
description: str = (
|
|
119
|
+
"Save one fact or note to the user's long-term memory (Membase). Call this ONLY when the "
|
|
120
|
+
"user asks you to remember something or plainly means for it to be kept; never to log "
|
|
121
|
+
"the conversation. Pass the content in the user's own words. Set static=true for a "
|
|
122
|
+
"lasting fact about the user themself; otherwise it is a note kept by one Memory."
|
|
123
|
+
)
|
|
124
|
+
args_schema: type[BaseModel] = MembaseSaveInput
|
|
125
|
+
|
|
126
|
+
container: str | None = None
|
|
127
|
+
"""Container id that keeps notes when the model names none. Needed when the key reaches
|
|
128
|
+
several Memories (unless the model names one)."""
|
|
129
|
+
|
|
130
|
+
def _run(
|
|
131
|
+
self,
|
|
132
|
+
content: str,
|
|
133
|
+
static: bool = False,
|
|
134
|
+
container: str | None = None,
|
|
135
|
+
*,
|
|
136
|
+
config: RunnableConfig,
|
|
137
|
+
run_manager: CallbackManagerForToolRun | None = None,
|
|
138
|
+
) -> str:
|
|
139
|
+
client = self._client_for_call(config)
|
|
140
|
+
try:
|
|
141
|
+
target = None
|
|
142
|
+
if not static:
|
|
143
|
+
target = _container_id(client, container) if container else self.container
|
|
144
|
+
resp = client.memories.add(content, container=target, static=static)
|
|
145
|
+
except BadRequestError as e:
|
|
146
|
+
if not static and target is None:
|
|
147
|
+
raise ToolException(_which_container(client, e)) from e
|
|
148
|
+
raise ToolException(describe_error(e)) from e
|
|
149
|
+
except MembaseError as e:
|
|
150
|
+
raise ToolException(describe_error(e)) from e
|
|
151
|
+
return _saved(resp or {})
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _which_container(client: Membase, e: BadRequestError) -> str:
|
|
155
|
+
try:
|
|
156
|
+
names = [c.get("name") for c in client.containers.list().get("containers") or []]
|
|
157
|
+
except MembaseError:
|
|
158
|
+
names = []
|
|
159
|
+
listed = ", ".join(repr(n) for n in names) or "unknown"
|
|
160
|
+
return (
|
|
161
|
+
f"{e.message}. Several Memories are in reach; call again with container set to one "
|
|
162
|
+
f"of: {listed}."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _saved(resp: dict[str, Any]) -> str:
|
|
167
|
+
if resp.get("static"):
|
|
168
|
+
return "Recorded in the user's profile."
|
|
169
|
+
if resp.get("status") == "learned":
|
|
170
|
+
return "Saved and learned; a search can find it now."
|
|
171
|
+
where = resp.get("container_name") or resp.get("container") or "the Memory"
|
|
172
|
+
return (
|
|
173
|
+
f"Saved to {where}. It is learned in the background, so a search may not find it until "
|
|
174
|
+
"that finishes."
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class MembaseProfileInput(BaseModel):
|
|
179
|
+
topic: str | None = Field(
|
|
180
|
+
default=None,
|
|
181
|
+
description="Optional current topic, to also get the profile passages relevant to it.",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class MembaseProfileTool(_MembaseTool):
|
|
186
|
+
"""Who the user is: standing facts and recently changed ones, from the Membase profile.
|
|
187
|
+
|
|
188
|
+
Only works when the key's owner ticked the profile for it; otherwise Membase answers 403.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
name: str = "membase_profile"
|
|
192
|
+
description: str = (
|
|
193
|
+
"Read who the user is from their Membase profile: standing facts (name, role, lasting "
|
|
194
|
+
"preferences) and recently changed facts. Call it once near the start of a conversation, "
|
|
195
|
+
"optionally with the current topic."
|
|
196
|
+
)
|
|
197
|
+
args_schema: type[BaseModel] = MembaseProfileInput
|
|
198
|
+
|
|
199
|
+
def _run(
|
|
200
|
+
self,
|
|
201
|
+
topic: str | None = None,
|
|
202
|
+
*,
|
|
203
|
+
config: RunnableConfig,
|
|
204
|
+
run_manager: CallbackManagerForToolRun | None = None,
|
|
205
|
+
) -> str:
|
|
206
|
+
client = self._client_for_call(config)
|
|
207
|
+
try:
|
|
208
|
+
resp = client.profile(q=topic or None)
|
|
209
|
+
except PermissionDeniedError as e:
|
|
210
|
+
raise ToolException(
|
|
211
|
+
describe_error(e) + " (A key reads the profile only if its owner ticked it.)"
|
|
212
|
+
) from e
|
|
213
|
+
except MembaseError as e:
|
|
214
|
+
raise ToolException(describe_error(e)) from e
|
|
215
|
+
return _format_profile(resp or {})
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _text(item: Any) -> str:
|
|
219
|
+
if isinstance(item, str):
|
|
220
|
+
return item.strip()
|
|
221
|
+
if isinstance(item, dict):
|
|
222
|
+
return str(item.get("content") or item.get("description") or item.get("text") or "").strip()
|
|
223
|
+
return str(item).strip()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _format_profile(resp: dict[str, Any]) -> str:
|
|
227
|
+
parts = []
|
|
228
|
+
static = [t for t in map(_text, resp.get("static") or []) if t]
|
|
229
|
+
dynamic = [t for t in map(_text, resp.get("dynamic") or []) if t]
|
|
230
|
+
if static:
|
|
231
|
+
parts.append("Standing facts:\n" + "\n".join(f"- {t}" for t in static))
|
|
232
|
+
if dynamic:
|
|
233
|
+
parts.append("Recently changed:\n" + "\n".join(f"- {t}" for t in dynamic))
|
|
234
|
+
if resp.get("results"):
|
|
235
|
+
passages = format_passages(resp["results"])
|
|
236
|
+
if passages:
|
|
237
|
+
parts.append("Relevant to the topic:\n" + passages)
|
|
238
|
+
return "\n\n".join(parts) or "The profile is empty."
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class MembaseToolkit(MembaseClientMixin, BaseToolkit):
|
|
242
|
+
"""The Membase tools, sharing one client (or one ``client_for``).
|
|
243
|
+
|
|
244
|
+
.. code-block:: python
|
|
245
|
+
|
|
246
|
+
from langchain_membase import MembaseToolkit
|
|
247
|
+
|
|
248
|
+
tools = MembaseToolkit().get_tools() # MEMBASE_API_KEY from the environment
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
container: str | None = None
|
|
252
|
+
"""Default container id for search and save when the model names none."""
|
|
253
|
+
k: int | None = Field(default=None, ge=1, le=50)
|
|
254
|
+
"""How many passages a search returns in all."""
|
|
255
|
+
save: bool = True
|
|
256
|
+
"""Include ``membase_save`` (a Read key cannot write anyway)."""
|
|
257
|
+
profile: bool = True
|
|
258
|
+
"""Include ``membase_profile`` (only useful if the key's owner ticked the profile)."""
|
|
259
|
+
|
|
260
|
+
def get_tools(self) -> list[BaseTool]:
|
|
261
|
+
shared: dict[str, Any] = {"client": self.client, "client_for": self.client_for}
|
|
262
|
+
tools: list[BaseTool] = [MembaseSearchTool(**shared, container=self.container, k=self.k)]
|
|
263
|
+
if self.save:
|
|
264
|
+
tools.append(MembaseSaveTool(**shared, container=self.container))
|
|
265
|
+
if self.profile:
|
|
266
|
+
tools.append(MembaseProfileTool(**shared))
|
|
267
|
+
return tools
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def membase_tools(
|
|
271
|
+
client: Membase | None = None,
|
|
272
|
+
*,
|
|
273
|
+
client_for: ClientFor | None = None,
|
|
274
|
+
api_key: str | None = None,
|
|
275
|
+
base_url: str | None = None,
|
|
276
|
+
local: str | os.PathLike | bool | None = None,
|
|
277
|
+
container: str | None = None,
|
|
278
|
+
k: int | None = None,
|
|
279
|
+
save: bool = True,
|
|
280
|
+
profile: bool = True,
|
|
281
|
+
) -> list[BaseTool]:
|
|
282
|
+
"""``MembaseToolkit(...).get_tools()``: search, save and profile sharing one client.
|
|
283
|
+
|
|
284
|
+
``save=False`` leaves the write tool out; ``profile=False`` leaves the profile out.
|
|
285
|
+
"""
|
|
286
|
+
return MembaseToolkit(
|
|
287
|
+
client=client,
|
|
288
|
+
client_for=client_for,
|
|
289
|
+
api_key=api_key,
|
|
290
|
+
base_url=base_url,
|
|
291
|
+
local=os.fspath(local) if isinstance(local, os.PathLike) else local,
|
|
292
|
+
container=container,
|
|
293
|
+
k=k,
|
|
294
|
+
save=save,
|
|
295
|
+
profile=profile,
|
|
296
|
+
).get_tools()
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-membase
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Membase long-term memory for LangChain and LangGraph: a retriever, agent tools and a recall middleware.
|
|
5
|
+
Project-URL: Homepage, https://membase.com
|
|
6
|
+
Project-URL: Documentation, https://github.com/unibaseio/membase/tree/main/integrations/langchain#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,langchain,langgraph,membase,memory,retriever
|
|
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: langchain-core<2,>=1.0
|
|
23
|
+
Requires-Dist: unibaseio-membase>=0.3.2
|
|
24
|
+
Provides-Extra: agents
|
|
25
|
+
Requires-Dist: langchain<2,>=1.1; extra == 'agents'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# langchain-membase
|
|
29
|
+
|
|
30
|
+
[Membase](https://membase.com) long-term memory for LangChain and LangGraph (Python).
|
|
31
|
+
|
|
32
|
+
Membase keeps a person's memory in their own account: named **Memories** (containers) that
|
|
33
|
+
learn from their notes, files and conversations, plus a **profile** of standing facts. This
|
|
34
|
+
package gives a LangChain app three ways to use it:
|
|
35
|
+
|
|
36
|
+
| Piece | LangChain type | What it does |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `MembaseRetriever` | `BaseRetriever` | a Membase search as `Document`s, each citing its Memory in `metadata["container_name"]` |
|
|
39
|
+
| `MembaseToolkit` (`membase_tools()`): `MembaseSearchTool`, `MembaseSaveTool`, `MembaseProfileTool` | `BaseToolkit` / `BaseTool` | agent tools: search, save (only when the user asks), read the profile |
|
|
40
|
+
| `MembaseRecallMiddleware` | LangChain v1 `AgentMiddleware` | time-boxed recall added to the system message before the model call of a `create_agent` agent |
|
|
41
|
+
|
|
42
|
+
There is deliberately **no delete or forget tool**, and the package never confirms a
|
|
43
|
+
destructive action. It also does not implement LangGraph's `BaseStore`: Membase is a memory
|
|
44
|
+
that learns, not a key-value store (there is no get-by-key), so it is offered as retrieval and
|
|
45
|
+
tools instead.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install langchain-membase # retriever + tools (needs langchain-core)
|
|
51
|
+
pip install 'langchain-membase[agents]' # + the create_agent middleware (needs langchain>=1.1)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Python 3.10+. Get a key in the Membase app under **Connect › Developer keys** and set it:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
export MEMBASE_API_KEY=mbk_...
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A **Read** key can search and read the profile (if its owner ticked it); saving needs a
|
|
61
|
+
**Read & write** key. One key is one person's account.
|
|
62
|
+
|
|
63
|
+
## Retriever
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from langchain_membase import MembaseRetriever
|
|
67
|
+
|
|
68
|
+
retriever = MembaseRetriever(k=5) # MEMBASE_API_KEY from the environment
|
|
69
|
+
docs = retriever.invoke("what did we decide about the ledger")
|
|
70
|
+
for d in docs:
|
|
71
|
+
print(d.metadata["container_name"], "·", d.page_content)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
* `k` (1–50) is the number of passages in all; unset lets Membase cover every Memory in reach.
|
|
75
|
+
* `container="mv-…"` limits the search to one Memory (its id from `client.containers.list()`).
|
|
76
|
+
Both can also be passed per call: `retriever.invoke(q, k=3, container="mv-…")`.
|
|
77
|
+
* If no passage comes back **and** some Memory could not answer (no model, dormant), the
|
|
78
|
+
retriever raises `MembaseUnavailableError` instead of returning `[]`: that is not evidence of
|
|
79
|
+
"no memory". When some Memories answered, it returns their passages and logs the rest.
|
|
80
|
+
* A `403` (`membase.PermissionDeniedError`) means the key's owner narrowed or revoked it; do
|
|
81
|
+
not retry with that key.
|
|
82
|
+
|
|
83
|
+
It works anywhere a retriever does, e.g. in an LCEL chain:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
87
|
+
from langchain_core.runnables import RunnablePassthrough
|
|
88
|
+
|
|
89
|
+
def cite(docs):
|
|
90
|
+
return "\n".join(f"[{d.metadata['container_name']}] {d.page_content}" for d in docs)
|
|
91
|
+
|
|
92
|
+
prompt = ChatPromptTemplate.from_template("Memory:\n{memory}\n\nQuestion: {question}")
|
|
93
|
+
chain = {"memory": retriever | cite, "question": RunnablePassthrough()} | prompt | llm
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Agent tools
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from langchain.agents import create_agent
|
|
100
|
+
from langchain_membase import MembaseToolkit
|
|
101
|
+
|
|
102
|
+
agent = create_agent("openai:gpt-5", tools=MembaseToolkit().get_tools()) # or membase_tools()
|
|
103
|
+
agent.invoke({"messages": [{"role": "user", "content": "What did we pick for the ledger?"}]})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
| Tool | Arguments | Notes |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| `membase_search` | `query`, optional `container` (a Memory's **name**) | passages as `- [Memory name] text`; the raw response is the `ToolMessage` artifact; Memories that could not answer are reported |
|
|
109
|
+
| `membase_save` | `content` (the user's words), `static`, optional `container` | the description tells the model to save **only when the user asks**; `static=true` records a standing fact in the profile; with several Memories in reach it needs a `container` (or a default `container=` id on the tool) and says which names exist |
|
|
110
|
+
| `membase_profile` | optional `topic` | standing and recently changed facts; only for a key whose owner ticked the profile |
|
|
111
|
+
|
|
112
|
+
`MembaseToolkit(save=False, profile=False)` (or `membase_tools(client, save=False, profile=False)`) leaves tools out; `container=` and `k=` set
|
|
113
|
+
defaults. Errors come back as the tool's answer (`handle_tool_error=True`), so the model can
|
|
114
|
+
tell the user, e.g. "this Membase key was narrowed or revoked".
|
|
115
|
+
|
|
116
|
+
Explicit tool calls use the SDK's own timeout (90 s): the first search after a quiet spell
|
|
117
|
+
takes longer while the user's memory wakes.
|
|
118
|
+
|
|
119
|
+
## Recall middleware (LangChain v1)
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from langchain.agents import create_agent
|
|
123
|
+
from langchain_membase import MembaseRecallMiddleware, membase_tools
|
|
124
|
+
|
|
125
|
+
agent = create_agent(
|
|
126
|
+
"openai:gpt-5",
|
|
127
|
+
tools=membase_tools(),
|
|
128
|
+
system_prompt="You are a helpful assistant.",
|
|
129
|
+
middleware=[MembaseRecallMiddleware(k=5, timeout=8.0)],
|
|
130
|
+
)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Before the model call, the middleware searches Membase for the latest user message and appends
|
|
134
|
+
the passages (each with its Memory) to the system message of that request.
|
|
135
|
+
|
|
136
|
+
* **Time-boxed and fail-open**: it waits at most `timeout` seconds (8 by default). On a timeout,
|
|
137
|
+
a `403` or any other error it logs a warning on the `langchain_membase` logger and the model
|
|
138
|
+
runs without memory. A search that ran past the budget finishes in the background, which
|
|
139
|
+
wakes the memory for the next turn.
|
|
140
|
+
* **Once per user message**: model calls that follow tool calls in the same turn reuse the
|
|
141
|
+
result.
|
|
142
|
+
* **Nothing is persisted**: the recall lives in the model request only, never in the agent's
|
|
143
|
+
state or checkpoint.
|
|
144
|
+
* **No automatic writes by default**. `capture_turns=True` (opt-in) saves the user's own
|
|
145
|
+
message(s) of each turn, once, as a note in `container=`, in the background. Assistant
|
|
146
|
+
replies are never saved, so an AI answer is not learned as a fact about the user.
|
|
147
|
+
|
|
148
|
+
## Many users
|
|
149
|
+
|
|
150
|
+
A Membase key belongs to one person, so a multi-user app holds one key (or one local
|
|
151
|
+
directory) **per end user** and passes `client_for`, a function from user id to that user's
|
|
152
|
+
client. Every class and `membase_tools()` accept it. The user id is read from the run:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from functools import lru_cache
|
|
156
|
+
from membase import Membase
|
|
157
|
+
from langchain_membase import MembaseRecallMiddleware, MembaseRetriever, membase_tools
|
|
158
|
+
|
|
159
|
+
@lru_cache(maxsize=1024) # reuse each user's client (and its connection pool)
|
|
160
|
+
def client_for(user_id: str) -> Membase:
|
|
161
|
+
return Membase(api_key=load_user_key(user_id)) # that user's key, never another's
|
|
162
|
+
|
|
163
|
+
retriever = MembaseRetriever(client_for=client_for)
|
|
164
|
+
retriever.invoke("...", config={"configurable": {"user_id": "alice"}})
|
|
165
|
+
|
|
166
|
+
agent = create_agent(
|
|
167
|
+
model,
|
|
168
|
+
tools=membase_tools(client_for=client_for),
|
|
169
|
+
middleware=[MembaseRecallMiddleware(client_for=client_for)],
|
|
170
|
+
)
|
|
171
|
+
agent.invoke({"messages": [...]}, config={"configurable": {"user_id": "alice"}})
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The user id is taken from `config["configurable"]["user_id"]`, or from a LangGraph run
|
|
175
|
+
context with a `user_id` field (`create_agent(..., context_schema=Ctx)` and
|
|
176
|
+
`agent.invoke(..., context=Ctx(user_id="alice"))`). With `client_for` set, a call that carries
|
|
177
|
+
no user id raises `MissingUserIdError` (the middleware logs it and skips recall); it never
|
|
178
|
+
falls back to another client. `client_for` and `client`/`api_key`/`local` are mutually
|
|
179
|
+
exclusive.
|
|
180
|
+
|
|
181
|
+
**Without `client_for`, one client serves every call.** That suits a personal agent (your own
|
|
182
|
+
key, your own memory), not a multi-user app.
|
|
183
|
+
|
|
184
|
+
Local mode keeps each person in their own directory:
|
|
185
|
+
`client_for=lambda uid: Membase(local=f"/data/membase/{uid}")`
|
|
186
|
+
(`pip install 'unibaseio-membase[local]'`, Python 3.12+, and an LLM key such as
|
|
187
|
+
`OPENAI_API_KEY`).
|
|
188
|
+
|
|
189
|
+
## Configuration
|
|
190
|
+
|
|
191
|
+
Every class takes `client=Membase(...)`, or `api_key=` / `base_url=` / `local=`; otherwise
|
|
192
|
+
`MEMBASE_API_KEY` and `MEMBASE_BASE_URL` come from the environment. The API host defaults to
|
|
193
|
+
`https://api.app.membase.com`.
|
|
194
|
+
|
|
195
|
+
## Development
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
uv venv .venv && uv pip install --python .venv/bin/python -e . \
|
|
199
|
+
'langchain>=1.1' langgraph langchain-tests pytest pytest-asyncio ruff
|
|
200
|
+
.venv/bin/python -m pytest # offline: the real SDK over httpx.MockTransport
|
|
201
|
+
MEMBASE_API_KEY=mbk_... .venv/bin/python examples/live_smoke.py # against the real API
|
|
202
|
+
uv build
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The offline suite includes LangChain's standard tests (`langchain-tests`) for the retriever
|
|
206
|
+
and the three tools, and runs the tools and the middleware inside a real `create_agent`
|
|
207
|
+
(LangGraph) graph with a scripted chat model.
|
|
208
|
+
|
|
209
|
+
## Licence
|
|
210
|
+
|
|
211
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
langchain_membase/__init__.py,sha256=RcFEEfKDkPjThqeQ6k3w0DTG739KJ3BuHg7etOkWtsY,1349
|
|
2
|
+
langchain_membase/_common.py,sha256=nWphcafsGFULz9gPepxd2iGQUlmTNTIM-9pYH5J3fhY,9530
|
|
3
|
+
langchain_membase/middleware.py,sha256=gFpnF18k3ZT9Nc6e0kDtwgUQpSFpdhBCVDlO6MBVhyo,9946
|
|
4
|
+
langchain_membase/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
langchain_membase/retrievers.py,sha256=_O27S1Z8cND7BHzOcqoa1Yc2SQAT4IO7QXs90NIHHc8,4784
|
|
6
|
+
langchain_membase/tools.py,sha256=xxX_-Ww_ZxjUZxBLDkAPgdHgL6-1-Ju4OX82lAjMTjk,11036
|
|
7
|
+
langchain_membase-0.1.0.dist-info/METADATA,sha256=fKh7QfxWmK51LDpRSalonF55iPohBl5ZmcK8t5Q-e5A,9741
|
|
8
|
+
langchain_membase-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
langchain_membase-0.1.0.dist-info/licenses/LICENSE,sha256=SWhYL0rjdALC-gYSOEAX-Z4LpxatwZx1kP6XwuOTSzs,1064
|
|
10
|
+
langchain_membase-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.
|