langchain-membase 0.1.0__tar.gz
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-0.1.0/.gitignore +13 -0
- langchain_membase-0.1.0/LICENSE +21 -0
- langchain_membase-0.1.0/PKG-INFO +211 -0
- langchain_membase-0.1.0/README.md +184 -0
- langchain_membase-0.1.0/examples/live_smoke.py +108 -0
- langchain_membase-0.1.0/pyproject.toml +66 -0
- langchain_membase-0.1.0/src/langchain_membase/__init__.py +49 -0
- langchain_membase-0.1.0/src/langchain_membase/_common.py +248 -0
- langchain_membase-0.1.0/src/langchain_membase/middleware.py +246 -0
- langchain_membase-0.1.0/src/langchain_membase/py.typed +0 -0
- langchain_membase-0.1.0/src/langchain_membase/retrievers.py +129 -0
- langchain_membase-0.1.0/src/langchain_membase/tools.py +296 -0
- langchain_membase-0.1.0/tests/__init__.py +0 -0
- langchain_membase-0.1.0/tests/unit_tests/__init__.py +0 -0
- langchain_membase-0.1.0/tests/unit_tests/conftest.py +157 -0
- langchain_membase-0.1.0/tests/unit_tests/test_agent.py +210 -0
- langchain_membase-0.1.0/tests/unit_tests/test_live_smoke_offline.py +42 -0
- langchain_membase-0.1.0/tests/unit_tests/test_retriever.py +98 -0
- langchain_membase-0.1.0/tests/unit_tests/test_standard.py +97 -0
- langchain_membase-0.1.0/tests/unit_tests/test_tools.py +146 -0
|
@@ -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.
|
|
@@ -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,184 @@
|
|
|
1
|
+
# langchain-membase
|
|
2
|
+
|
|
3
|
+
[Membase](https://membase.com) long-term memory for LangChain and LangGraph (Python).
|
|
4
|
+
|
|
5
|
+
Membase keeps a person's memory in their own account: named **Memories** (containers) that
|
|
6
|
+
learn from their notes, files and conversations, plus a **profile** of standing facts. This
|
|
7
|
+
package gives a LangChain app three ways to use it:
|
|
8
|
+
|
|
9
|
+
| Piece | LangChain type | What it does |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `MembaseRetriever` | `BaseRetriever` | a Membase search as `Document`s, each citing its Memory in `metadata["container_name"]` |
|
|
12
|
+
| `MembaseToolkit` (`membase_tools()`): `MembaseSearchTool`, `MembaseSaveTool`, `MembaseProfileTool` | `BaseToolkit` / `BaseTool` | agent tools: search, save (only when the user asks), read the profile |
|
|
13
|
+
| `MembaseRecallMiddleware` | LangChain v1 `AgentMiddleware` | time-boxed recall added to the system message before the model call of a `create_agent` agent |
|
|
14
|
+
|
|
15
|
+
There is deliberately **no delete or forget tool**, and the package never confirms a
|
|
16
|
+
destructive action. It also does not implement LangGraph's `BaseStore`: Membase is a memory
|
|
17
|
+
that learns, not a key-value store (there is no get-by-key), so it is offered as retrieval and
|
|
18
|
+
tools instead.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install langchain-membase # retriever + tools (needs langchain-core)
|
|
24
|
+
pip install 'langchain-membase[agents]' # + the create_agent middleware (needs langchain>=1.1)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Python 3.10+. Get a key in the Membase app under **Connect › Developer keys** and set it:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
export MEMBASE_API_KEY=mbk_...
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A **Read** key can search and read the profile (if its owner ticked it); saving needs a
|
|
34
|
+
**Read & write** key. One key is one person's account.
|
|
35
|
+
|
|
36
|
+
## Retriever
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from langchain_membase import MembaseRetriever
|
|
40
|
+
|
|
41
|
+
retriever = MembaseRetriever(k=5) # MEMBASE_API_KEY from the environment
|
|
42
|
+
docs = retriever.invoke("what did we decide about the ledger")
|
|
43
|
+
for d in docs:
|
|
44
|
+
print(d.metadata["container_name"], "·", d.page_content)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
* `k` (1–50) is the number of passages in all; unset lets Membase cover every Memory in reach.
|
|
48
|
+
* `container="mv-…"` limits the search to one Memory (its id from `client.containers.list()`).
|
|
49
|
+
Both can also be passed per call: `retriever.invoke(q, k=3, container="mv-…")`.
|
|
50
|
+
* If no passage comes back **and** some Memory could not answer (no model, dormant), the
|
|
51
|
+
retriever raises `MembaseUnavailableError` instead of returning `[]`: that is not evidence of
|
|
52
|
+
"no memory". When some Memories answered, it returns their passages and logs the rest.
|
|
53
|
+
* A `403` (`membase.PermissionDeniedError`) means the key's owner narrowed or revoked it; do
|
|
54
|
+
not retry with that key.
|
|
55
|
+
|
|
56
|
+
It works anywhere a retriever does, e.g. in an LCEL chain:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
60
|
+
from langchain_core.runnables import RunnablePassthrough
|
|
61
|
+
|
|
62
|
+
def cite(docs):
|
|
63
|
+
return "\n".join(f"[{d.metadata['container_name']}] {d.page_content}" for d in docs)
|
|
64
|
+
|
|
65
|
+
prompt = ChatPromptTemplate.from_template("Memory:\n{memory}\n\nQuestion: {question}")
|
|
66
|
+
chain = {"memory": retriever | cite, "question": RunnablePassthrough()} | prompt | llm
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Agent tools
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from langchain.agents import create_agent
|
|
73
|
+
from langchain_membase import MembaseToolkit
|
|
74
|
+
|
|
75
|
+
agent = create_agent("openai:gpt-5", tools=MembaseToolkit().get_tools()) # or membase_tools()
|
|
76
|
+
agent.invoke({"messages": [{"role": "user", "content": "What did we pick for the ledger?"}]})
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| Tool | Arguments | Notes |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `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 |
|
|
82
|
+
| `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 |
|
|
83
|
+
| `membase_profile` | optional `topic` | standing and recently changed facts; only for a key whose owner ticked the profile |
|
|
84
|
+
|
|
85
|
+
`MembaseToolkit(save=False, profile=False)` (or `membase_tools(client, save=False, profile=False)`) leaves tools out; `container=` and `k=` set
|
|
86
|
+
defaults. Errors come back as the tool's answer (`handle_tool_error=True`), so the model can
|
|
87
|
+
tell the user, e.g. "this Membase key was narrowed or revoked".
|
|
88
|
+
|
|
89
|
+
Explicit tool calls use the SDK's own timeout (90 s): the first search after a quiet spell
|
|
90
|
+
takes longer while the user's memory wakes.
|
|
91
|
+
|
|
92
|
+
## Recall middleware (LangChain v1)
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from langchain.agents import create_agent
|
|
96
|
+
from langchain_membase import MembaseRecallMiddleware, membase_tools
|
|
97
|
+
|
|
98
|
+
agent = create_agent(
|
|
99
|
+
"openai:gpt-5",
|
|
100
|
+
tools=membase_tools(),
|
|
101
|
+
system_prompt="You are a helpful assistant.",
|
|
102
|
+
middleware=[MembaseRecallMiddleware(k=5, timeout=8.0)],
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Before the model call, the middleware searches Membase for the latest user message and appends
|
|
107
|
+
the passages (each with its Memory) to the system message of that request.
|
|
108
|
+
|
|
109
|
+
* **Time-boxed and fail-open**: it waits at most `timeout` seconds (8 by default). On a timeout,
|
|
110
|
+
a `403` or any other error it logs a warning on the `langchain_membase` logger and the model
|
|
111
|
+
runs without memory. A search that ran past the budget finishes in the background, which
|
|
112
|
+
wakes the memory for the next turn.
|
|
113
|
+
* **Once per user message**: model calls that follow tool calls in the same turn reuse the
|
|
114
|
+
result.
|
|
115
|
+
* **Nothing is persisted**: the recall lives in the model request only, never in the agent's
|
|
116
|
+
state or checkpoint.
|
|
117
|
+
* **No automatic writes by default**. `capture_turns=True` (opt-in) saves the user's own
|
|
118
|
+
message(s) of each turn, once, as a note in `container=`, in the background. Assistant
|
|
119
|
+
replies are never saved, so an AI answer is not learned as a fact about the user.
|
|
120
|
+
|
|
121
|
+
## Many users
|
|
122
|
+
|
|
123
|
+
A Membase key belongs to one person, so a multi-user app holds one key (or one local
|
|
124
|
+
directory) **per end user** and passes `client_for`, a function from user id to that user's
|
|
125
|
+
client. Every class and `membase_tools()` accept it. The user id is read from the run:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from functools import lru_cache
|
|
129
|
+
from membase import Membase
|
|
130
|
+
from langchain_membase import MembaseRecallMiddleware, MembaseRetriever, membase_tools
|
|
131
|
+
|
|
132
|
+
@lru_cache(maxsize=1024) # reuse each user's client (and its connection pool)
|
|
133
|
+
def client_for(user_id: str) -> Membase:
|
|
134
|
+
return Membase(api_key=load_user_key(user_id)) # that user's key, never another's
|
|
135
|
+
|
|
136
|
+
retriever = MembaseRetriever(client_for=client_for)
|
|
137
|
+
retriever.invoke("...", config={"configurable": {"user_id": "alice"}})
|
|
138
|
+
|
|
139
|
+
agent = create_agent(
|
|
140
|
+
model,
|
|
141
|
+
tools=membase_tools(client_for=client_for),
|
|
142
|
+
middleware=[MembaseRecallMiddleware(client_for=client_for)],
|
|
143
|
+
)
|
|
144
|
+
agent.invoke({"messages": [...]}, config={"configurable": {"user_id": "alice"}})
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The user id is taken from `config["configurable"]["user_id"]`, or from a LangGraph run
|
|
148
|
+
context with a `user_id` field (`create_agent(..., context_schema=Ctx)` and
|
|
149
|
+
`agent.invoke(..., context=Ctx(user_id="alice"))`). With `client_for` set, a call that carries
|
|
150
|
+
no user id raises `MissingUserIdError` (the middleware logs it and skips recall); it never
|
|
151
|
+
falls back to another client. `client_for` and `client`/`api_key`/`local` are mutually
|
|
152
|
+
exclusive.
|
|
153
|
+
|
|
154
|
+
**Without `client_for`, one client serves every call.** That suits a personal agent (your own
|
|
155
|
+
key, your own memory), not a multi-user app.
|
|
156
|
+
|
|
157
|
+
Local mode keeps each person in their own directory:
|
|
158
|
+
`client_for=lambda uid: Membase(local=f"/data/membase/{uid}")`
|
|
159
|
+
(`pip install 'unibaseio-membase[local]'`, Python 3.12+, and an LLM key such as
|
|
160
|
+
`OPENAI_API_KEY`).
|
|
161
|
+
|
|
162
|
+
## Configuration
|
|
163
|
+
|
|
164
|
+
Every class takes `client=Membase(...)`, or `api_key=` / `base_url=` / `local=`; otherwise
|
|
165
|
+
`MEMBASE_API_KEY` and `MEMBASE_BASE_URL` come from the environment. The API host defaults to
|
|
166
|
+
`https://api.app.membase.com`.
|
|
167
|
+
|
|
168
|
+
## Development
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
uv venv .venv && uv pip install --python .venv/bin/python -e . \
|
|
172
|
+
'langchain>=1.1' langgraph langchain-tests pytest pytest-asyncio ruff
|
|
173
|
+
.venv/bin/python -m pytest # offline: the real SDK over httpx.MockTransport
|
|
174
|
+
MEMBASE_API_KEY=mbk_... .venv/bin/python examples/live_smoke.py # against the real API
|
|
175
|
+
uv build
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The offline suite includes LangChain's standard tests (`langchain-tests`) for the retriever
|
|
179
|
+
and the three tools, and runs the tools and the middleware inside a real `create_agent`
|
|
180
|
+
(LangGraph) graph with a scripted chat model.
|
|
181
|
+
|
|
182
|
+
## Licence
|
|
183
|
+
|
|
184
|
+
MIT
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Live smoke test against the real Membase API.
|
|
2
|
+
|
|
3
|
+
MEMBASE_API_KEY=mbk_... python examples/live_smoke.py
|
|
4
|
+
|
|
5
|
+
Reads only, unless MEMBASE_SMOKE_WRITE=1 (then it saves one clearly labelled note through the
|
|
6
|
+
save tool; that needs a Read & write key, and a container id in MEMBASE_SMOKE_CONTAINER when
|
|
7
|
+
the key reaches several Memories). Without MEMBASE_API_KEY it says so and exits 0.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
|
|
18
|
+
from membase import Membase
|
|
19
|
+
from membase.errors import PermissionDeniedError
|
|
20
|
+
|
|
21
|
+
from langchain_membase import MembaseRetriever, membase_tools
|
|
22
|
+
from langchain_membase._common import DEFAULT_BASE_URL
|
|
23
|
+
|
|
24
|
+
QUERY = os.environ.get("MEMBASE_SMOKE_QUERY", "what do you know about me")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main() -> int:
|
|
28
|
+
if not os.environ.get("MEMBASE_API_KEY"):
|
|
29
|
+
print("MEMBASE_API_KEY is not set; skipping the live smoke test.")
|
|
30
|
+
return 0
|
|
31
|
+
logging.basicConfig(level=logging.WARNING)
|
|
32
|
+
# MEMBASE_API_KEY from the environment; the package's default host unless MEMBASE_BASE_URL.
|
|
33
|
+
client = Membase(base_url=os.environ.get("MEMBASE_BASE_URL") or DEFAULT_BASE_URL)
|
|
34
|
+
print("base url:", client.base_url)
|
|
35
|
+
|
|
36
|
+
containers = client.containers.list()["containers"]
|
|
37
|
+
print(f"containers in reach: {[c['name'] for c in containers]}")
|
|
38
|
+
|
|
39
|
+
t = time.monotonic()
|
|
40
|
+
docs = MembaseRetriever(client=client, k=5).invoke(QUERY)
|
|
41
|
+
print(f"retriever: {len(docs)} documents in {time.monotonic() - t:.1f}s")
|
|
42
|
+
for d in docs:
|
|
43
|
+
print(f" [{d.metadata['container_name']}] {d.page_content[:100]!r}")
|
|
44
|
+
|
|
45
|
+
search, save, profile = membase_tools(client)
|
|
46
|
+
msg = search.invoke(
|
|
47
|
+
{"type": "tool_call", "name": search.name, "args": {"query": QUERY}, "id": "smoke-1"}
|
|
48
|
+
)
|
|
49
|
+
print("search tool:\n" + msg.content[:600])
|
|
50
|
+
|
|
51
|
+
msg = profile.invoke({"type": "tool_call", "name": profile.name, "args": {}, "id": "smoke-2"})
|
|
52
|
+
print("profile tool:\n" + msg.content[:600])
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
from langchain.agents import create_agent # noqa: F401
|
|
56
|
+
|
|
57
|
+
_middleware_smoke(client)
|
|
58
|
+
except ImportError:
|
|
59
|
+
print("middleware: skipped (pip install 'langchain-membase[agents]')")
|
|
60
|
+
|
|
61
|
+
if os.environ.get("MEMBASE_SMOKE_WRITE") == "1":
|
|
62
|
+
stamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
63
|
+
args = {"content": f"langchain-membase live smoke note, {stamp}."}
|
|
64
|
+
if os.environ.get("MEMBASE_SMOKE_CONTAINER"):
|
|
65
|
+
args["container"] = os.environ["MEMBASE_SMOKE_CONTAINER"]
|
|
66
|
+
msg = save.invoke({"type": "tool_call", "name": save.name, "args": args, "id": "smoke-3"})
|
|
67
|
+
print("save tool:", msg.content)
|
|
68
|
+
else:
|
|
69
|
+
print("save tool: skipped (set MEMBASE_SMOKE_WRITE=1 to write one note)")
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _middleware_smoke(client: Membase) -> None:
|
|
74
|
+
from langchain.agents import create_agent
|
|
75
|
+
from langchain_core.language_models import BaseChatModel
|
|
76
|
+
from langchain_core.messages import AIMessage, SystemMessage
|
|
77
|
+
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
78
|
+
|
|
79
|
+
from langchain_membase import MembaseRecallMiddleware
|
|
80
|
+
|
|
81
|
+
seen: list[str] = []
|
|
82
|
+
|
|
83
|
+
class Echo(BaseChatModel):
|
|
84
|
+
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
|
85
|
+
seen.append("\n".join(m.text for m in messages if isinstance(m, SystemMessage)))
|
|
86
|
+
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def _llm_type(self) -> str:
|
|
90
|
+
return "echo"
|
|
91
|
+
|
|
92
|
+
timeout = float(os.environ.get("MEMBASE_SMOKE_RECALL_TIMEOUT", "8"))
|
|
93
|
+
agent = create_agent(Echo(), middleware=[MembaseRecallMiddleware(client, timeout=timeout)])
|
|
94
|
+
t = time.monotonic()
|
|
95
|
+
agent.invoke({"messages": [{"role": "user", "content": QUERY}]})
|
|
96
|
+
injected = "From the user's memory" in seen[0]
|
|
97
|
+
print(
|
|
98
|
+
f"middleware: recall injected={injected} in {time.monotonic() - t:.1f}s "
|
|
99
|
+
f"(budget {timeout:g}s; a cold memory may need a second run)"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
try:
|
|
105
|
+
sys.exit(main())
|
|
106
|
+
except PermissionDeniedError as e:
|
|
107
|
+
print(f"403: this Membase key was narrowed or revoked: {e}")
|
|
108
|
+
sys.exit(1)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-membase"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Membase long-term memory for LangChain and LangGraph: a retriever, agent tools and a recall middleware."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Unibase" }]
|
|
14
|
+
keywords = ["langchain", "langgraph", "memory", "membase", "retriever", "agents"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"unibaseio-membase>=0.3.2",
|
|
27
|
+
"langchain-core>=1.0,<2",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
# MembaseRecallMiddleware plugs into `langchain.agents.create_agent` (LangChain v1).
|
|
32
|
+
agents = ["langchain>=1.1,<2"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://membase.com"
|
|
36
|
+
Documentation = "https://github.com/unibaseio/membase/tree/main/integrations/langchain#readme"
|
|
37
|
+
Repository = "https://github.com/unibaseio/membase"
|
|
38
|
+
Issues = "https://github.com/unibaseio/membase/issues"
|
|
39
|
+
|
|
40
|
+
[dependency-groups]
|
|
41
|
+
test = [
|
|
42
|
+
"langchain>=1.1,<2",
|
|
43
|
+
"langgraph>=1.0",
|
|
44
|
+
"langchain-tests>=1.0",
|
|
45
|
+
"pytest>=8",
|
|
46
|
+
"pytest-asyncio>=0.23",
|
|
47
|
+
]
|
|
48
|
+
lint = ["ruff>=0.6"]
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["src/langchain_membase"]
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.sdist]
|
|
54
|
+
include = ["src/langchain_membase", "tests", "examples", "README.md", "LICENSE", "pyproject.toml"]
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
asyncio_mode = "auto"
|
|
58
|
+
testpaths = ["tests"]
|
|
59
|
+
addopts = "--strict-markers"
|
|
60
|
+
|
|
61
|
+
[tool.ruff]
|
|
62
|
+
line-length = 100
|
|
63
|
+
target-version = "py310"
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint]
|
|
66
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -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}")
|