maximem-synap-langgraph 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.
- maximem_synap_langgraph-0.1.0/PKG-INFO +74 -0
- maximem_synap_langgraph-0.1.0/README.md +58 -0
- maximem_synap_langgraph-0.1.0/maximem_synap_langgraph.egg-info/PKG-INFO +74 -0
- maximem_synap_langgraph-0.1.0/maximem_synap_langgraph.egg-info/SOURCES.txt +10 -0
- maximem_synap_langgraph-0.1.0/maximem_synap_langgraph.egg-info/dependency_links.txt +1 -0
- maximem_synap_langgraph-0.1.0/maximem_synap_langgraph.egg-info/requires.txt +7 -0
- maximem_synap_langgraph-0.1.0/maximem_synap_langgraph.egg-info/top_level.txt +1 -0
- maximem_synap_langgraph-0.1.0/pyproject.toml +35 -0
- maximem_synap_langgraph-0.1.0/setup.cfg +4 -0
- maximem_synap_langgraph-0.1.0/synap_langgraph/__init__.py +22 -0
- maximem_synap_langgraph-0.1.0/synap_langgraph/checkpointer.py +421 -0
- maximem_synap_langgraph-0.1.0/synap_langgraph/store.py +316 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: maximem-synap-langgraph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Synap memory integration for LangGraph — BaseStore + BaseCheckpointSaver
|
|
5
|
+
Author: Synap Team
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Keywords: synap,memory,langgraph,ai,agents,checkpointer,store
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: maximem-synap>=0.2.0
|
|
11
|
+
Requires-Dist: maximem-synap-integrations-common>=0.1.0
|
|
12
|
+
Requires-Dist: langgraph>=1.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# maximem-synap-langgraph
|
|
18
|
+
|
|
19
|
+
Synap memory integration for [LangGraph](https://langchain-ai.github.io/langgraph/).
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install maximem-synap-langgraph
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Requires `langgraph>=1.0`, `maximem-synap>=0.2.0`.
|
|
28
|
+
|
|
29
|
+
## What's in the box
|
|
30
|
+
|
|
31
|
+
- **`SynapStore`** — implements LangGraph's `BaseStore` for cross-thread long-term memory. Semantic search via `store.search(namespace, query=...)` routes to `sdk.fetch(...)`, so your graph nodes get Synap-powered recall out of the box.
|
|
32
|
+
|
|
33
|
+
- **`SynapCheckpointSaver`** — implements `BaseCheckpointSaver` with **best-effort fuzzy retrieval**. Checkpoint writes succeed durably; reads use `sdk.fetch` which is semantic-search shaped rather than exact KV. Use for observability/audit and demo flows. For production checkpoint durability, pair with `SqliteSaver` / `PostgresSaver`.
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from langgraph.graph import StateGraph, START, END
|
|
39
|
+
from maximem_synap import MaximemSynapSDK
|
|
40
|
+
from synap_langgraph import SynapStore, SynapCheckpointSaver
|
|
41
|
+
|
|
42
|
+
sdk = MaximemSynapSDK(api_key="sk-...")
|
|
43
|
+
|
|
44
|
+
store = SynapStore(sdk, user_id="alice", customer_id="acme")
|
|
45
|
+
saver = SynapCheckpointSaver(sdk, user_id="alice", customer_id="acme")
|
|
46
|
+
|
|
47
|
+
graph = StateGraph(MyState)
|
|
48
|
+
# ... add nodes / edges ...
|
|
49
|
+
app = graph.compile(checkpointer=saver, store=store)
|
|
50
|
+
|
|
51
|
+
# Store usage inside a node:
|
|
52
|
+
async def remember(state, runtime):
|
|
53
|
+
await runtime.store.aput(
|
|
54
|
+
("alice", "preferences"),
|
|
55
|
+
"language",
|
|
56
|
+
{"value": "English"},
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Error policy
|
|
61
|
+
|
|
62
|
+
- **Writes** (`SynapStore.put`, `SynapCheckpointSaver.put`, `put_writes`) surface SDK failures as `SynapIntegrationError`. Silent drops would hide ingestion outages.
|
|
63
|
+
- **Reads** (`get`, `search`, `get_tuple`, `list`) degrade gracefully — they log at `ERROR` and return `None`/`[]` rather than crashing the graph.
|
|
64
|
+
- **Deletes** (`SynapStore.delete`, `SynapCheckpointSaver.delete_thread`) warn once and no-op — Synap has no public delete API.
|
|
65
|
+
|
|
66
|
+
## When to use which checkpointer
|
|
67
|
+
|
|
68
|
+
| Goal | Saver |
|
|
69
|
+
| ---- | ----- |
|
|
70
|
+
| Durable thread checkpoints, exact restore | LangGraph's `SqliteSaver` or `PostgresSaver` |
|
|
71
|
+
| Thread state surfaced in Synap for observability/audit/cross-thread analysis | `SynapCheckpointSaver` |
|
|
72
|
+
| Both | Use Sqlite/Postgres as primary; layer `SynapCheckpointSaver` for the Synap view |
|
|
73
|
+
|
|
74
|
+
Cross-thread long-term memory (`BaseStore`) maps cleanly to Synap — use `SynapStore` as your default.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# maximem-synap-langgraph
|
|
2
|
+
|
|
3
|
+
Synap memory integration for [LangGraph](https://langchain-ai.github.io/langgraph/).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install maximem-synap-langgraph
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires `langgraph>=1.0`, `maximem-synap>=0.2.0`.
|
|
12
|
+
|
|
13
|
+
## What's in the box
|
|
14
|
+
|
|
15
|
+
- **`SynapStore`** — implements LangGraph's `BaseStore` for cross-thread long-term memory. Semantic search via `store.search(namespace, query=...)` routes to `sdk.fetch(...)`, so your graph nodes get Synap-powered recall out of the box.
|
|
16
|
+
|
|
17
|
+
- **`SynapCheckpointSaver`** — implements `BaseCheckpointSaver` with **best-effort fuzzy retrieval**. Checkpoint writes succeed durably; reads use `sdk.fetch` which is semantic-search shaped rather than exact KV. Use for observability/audit and demo flows. For production checkpoint durability, pair with `SqliteSaver` / `PostgresSaver`.
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from langgraph.graph import StateGraph, START, END
|
|
23
|
+
from maximem_synap import MaximemSynapSDK
|
|
24
|
+
from synap_langgraph import SynapStore, SynapCheckpointSaver
|
|
25
|
+
|
|
26
|
+
sdk = MaximemSynapSDK(api_key="sk-...")
|
|
27
|
+
|
|
28
|
+
store = SynapStore(sdk, user_id="alice", customer_id="acme")
|
|
29
|
+
saver = SynapCheckpointSaver(sdk, user_id="alice", customer_id="acme")
|
|
30
|
+
|
|
31
|
+
graph = StateGraph(MyState)
|
|
32
|
+
# ... add nodes / edges ...
|
|
33
|
+
app = graph.compile(checkpointer=saver, store=store)
|
|
34
|
+
|
|
35
|
+
# Store usage inside a node:
|
|
36
|
+
async def remember(state, runtime):
|
|
37
|
+
await runtime.store.aput(
|
|
38
|
+
("alice", "preferences"),
|
|
39
|
+
"language",
|
|
40
|
+
{"value": "English"},
|
|
41
|
+
)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Error policy
|
|
45
|
+
|
|
46
|
+
- **Writes** (`SynapStore.put`, `SynapCheckpointSaver.put`, `put_writes`) surface SDK failures as `SynapIntegrationError`. Silent drops would hide ingestion outages.
|
|
47
|
+
- **Reads** (`get`, `search`, `get_tuple`, `list`) degrade gracefully — they log at `ERROR` and return `None`/`[]` rather than crashing the graph.
|
|
48
|
+
- **Deletes** (`SynapStore.delete`, `SynapCheckpointSaver.delete_thread`) warn once and no-op — Synap has no public delete API.
|
|
49
|
+
|
|
50
|
+
## When to use which checkpointer
|
|
51
|
+
|
|
52
|
+
| Goal | Saver |
|
|
53
|
+
| ---- | ----- |
|
|
54
|
+
| Durable thread checkpoints, exact restore | LangGraph's `SqliteSaver` or `PostgresSaver` |
|
|
55
|
+
| Thread state surfaced in Synap for observability/audit/cross-thread analysis | `SynapCheckpointSaver` |
|
|
56
|
+
| Both | Use Sqlite/Postgres as primary; layer `SynapCheckpointSaver` for the Synap view |
|
|
57
|
+
|
|
58
|
+
Cross-thread long-term memory (`BaseStore`) maps cleanly to Synap — use `SynapStore` as your default.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: maximem-synap-langgraph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Synap memory integration for LangGraph — BaseStore + BaseCheckpointSaver
|
|
5
|
+
Author: Synap Team
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Keywords: synap,memory,langgraph,ai,agents,checkpointer,store
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: maximem-synap>=0.2.0
|
|
11
|
+
Requires-Dist: maximem-synap-integrations-common>=0.1.0
|
|
12
|
+
Requires-Dist: langgraph>=1.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# maximem-synap-langgraph
|
|
18
|
+
|
|
19
|
+
Synap memory integration for [LangGraph](https://langchain-ai.github.io/langgraph/).
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install maximem-synap-langgraph
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Requires `langgraph>=1.0`, `maximem-synap>=0.2.0`.
|
|
28
|
+
|
|
29
|
+
## What's in the box
|
|
30
|
+
|
|
31
|
+
- **`SynapStore`** — implements LangGraph's `BaseStore` for cross-thread long-term memory. Semantic search via `store.search(namespace, query=...)` routes to `sdk.fetch(...)`, so your graph nodes get Synap-powered recall out of the box.
|
|
32
|
+
|
|
33
|
+
- **`SynapCheckpointSaver`** — implements `BaseCheckpointSaver` with **best-effort fuzzy retrieval**. Checkpoint writes succeed durably; reads use `sdk.fetch` which is semantic-search shaped rather than exact KV. Use for observability/audit and demo flows. For production checkpoint durability, pair with `SqliteSaver` / `PostgresSaver`.
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from langgraph.graph import StateGraph, START, END
|
|
39
|
+
from maximem_synap import MaximemSynapSDK
|
|
40
|
+
from synap_langgraph import SynapStore, SynapCheckpointSaver
|
|
41
|
+
|
|
42
|
+
sdk = MaximemSynapSDK(api_key="sk-...")
|
|
43
|
+
|
|
44
|
+
store = SynapStore(sdk, user_id="alice", customer_id="acme")
|
|
45
|
+
saver = SynapCheckpointSaver(sdk, user_id="alice", customer_id="acme")
|
|
46
|
+
|
|
47
|
+
graph = StateGraph(MyState)
|
|
48
|
+
# ... add nodes / edges ...
|
|
49
|
+
app = graph.compile(checkpointer=saver, store=store)
|
|
50
|
+
|
|
51
|
+
# Store usage inside a node:
|
|
52
|
+
async def remember(state, runtime):
|
|
53
|
+
await runtime.store.aput(
|
|
54
|
+
("alice", "preferences"),
|
|
55
|
+
"language",
|
|
56
|
+
{"value": "English"},
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Error policy
|
|
61
|
+
|
|
62
|
+
- **Writes** (`SynapStore.put`, `SynapCheckpointSaver.put`, `put_writes`) surface SDK failures as `SynapIntegrationError`. Silent drops would hide ingestion outages.
|
|
63
|
+
- **Reads** (`get`, `search`, `get_tuple`, `list`) degrade gracefully — they log at `ERROR` and return `None`/`[]` rather than crashing the graph.
|
|
64
|
+
- **Deletes** (`SynapStore.delete`, `SynapCheckpointSaver.delete_thread`) warn once and no-op — Synap has no public delete API.
|
|
65
|
+
|
|
66
|
+
## When to use which checkpointer
|
|
67
|
+
|
|
68
|
+
| Goal | Saver |
|
|
69
|
+
| ---- | ----- |
|
|
70
|
+
| Durable thread checkpoints, exact restore | LangGraph's `SqliteSaver` or `PostgresSaver` |
|
|
71
|
+
| Thread state surfaced in Synap for observability/audit/cross-thread analysis | `SynapCheckpointSaver` |
|
|
72
|
+
| Both | Use Sqlite/Postgres as primary; layer `SynapCheckpointSaver` for the Synap view |
|
|
73
|
+
|
|
74
|
+
Cross-thread long-term memory (`BaseStore`) maps cleanly to Synap — use `SynapStore` as your default.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
maximem_synap_langgraph.egg-info/PKG-INFO
|
|
4
|
+
maximem_synap_langgraph.egg-info/SOURCES.txt
|
|
5
|
+
maximem_synap_langgraph.egg-info/dependency_links.txt
|
|
6
|
+
maximem_synap_langgraph.egg-info/requires.txt
|
|
7
|
+
maximem_synap_langgraph.egg-info/top_level.txt
|
|
8
|
+
synap_langgraph/__init__.py
|
|
9
|
+
synap_langgraph/checkpointer.py
|
|
10
|
+
synap_langgraph/store.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
synap_langgraph
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "maximem-synap-langgraph"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Synap memory integration for LangGraph — BaseStore + BaseCheckpointSaver"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Synap Team"}
|
|
14
|
+
]
|
|
15
|
+
keywords = ["synap", "memory", "langgraph", "ai", "agents", "checkpointer", "store"]
|
|
16
|
+
|
|
17
|
+
dependencies = [
|
|
18
|
+
"maximem-synap>=0.2.0",
|
|
19
|
+
"maximem-synap-integrations-common>=0.1.0",
|
|
20
|
+
"langgraph>=1.0",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=7.0",
|
|
26
|
+
"pytest-asyncio>=0.21",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["."]
|
|
31
|
+
include = ["synap_langgraph*"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Synap integration for LangGraph.
|
|
2
|
+
|
|
3
|
+
Exposes:
|
|
4
|
+
|
|
5
|
+
- :class:`SynapStore` — a ``BaseStore`` implementation backed by Synap's
|
|
6
|
+
semantic memory (``sdk.memories.create`` + ``sdk.fetch``). Drop it into
|
|
7
|
+
``StateGraph.compile(store=SynapStore(...))`` for cross-thread long-term
|
|
8
|
+
memory. Semantic search via ``store.asearch`` is the natural fit for our
|
|
9
|
+
retrieval model.
|
|
10
|
+
|
|
11
|
+
- :class:`SynapCheckpointSaver` — a ``BaseCheckpointSaver`` that persists
|
|
12
|
+
thread execution state to Synap. **Best-effort fuzzy retrieval**: reads
|
|
13
|
+
use ``sdk.fetch`` with metadata filters, which is semantic-search-shaped
|
|
14
|
+
rather than exact KV. Pair with a real KV saver (SqliteSaver, PostgresSaver)
|
|
15
|
+
for production-grade checkpoint fidelity; use ``SynapCheckpointSaver`` for
|
|
16
|
+
observability/audit and demo flows.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from synap_langgraph.store import SynapStore
|
|
20
|
+
from synap_langgraph.checkpointer import SynapCheckpointSaver
|
|
21
|
+
|
|
22
|
+
__all__ = ["SynapStore", "SynapCheckpointSaver"]
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""SynapCheckpointSaver — best-effort fuzzy ``BaseCheckpointSaver``.
|
|
2
|
+
|
|
3
|
+
Synap's retrieval is semantic-search shaped, not exact key-value. This saver
|
|
4
|
+
trades off checkpoint fidelity for the benefits of storing thread state in
|
|
5
|
+
Synap: observability, audit, and cross-thread memory sitting next to the
|
|
6
|
+
execution history.
|
|
7
|
+
|
|
8
|
+
For production durability, pair with a proper KV-backed saver
|
|
9
|
+
(``SqliteSaver`` / ``PostgresSaver`` in a subgraph or a composite saver).
|
|
10
|
+
|
|
11
|
+
Storage strategy:
|
|
12
|
+
|
|
13
|
+
- ``aput(config, checkpoint, metadata, new_versions)`` serialises the
|
|
14
|
+
checkpoint blob via ``self.serde.dumps_typed`` → ``(type, bytes)``, then
|
|
15
|
+
ingests as a Synap memory via ``sdk.memories.create`` with the raw bytes
|
|
16
|
+
base64-encoded in ``document`` and metadata tagging the thread/checkpoint
|
|
17
|
+
coordinates.
|
|
18
|
+
- ``aget_tuple(config)`` fetches by thread id and returns the latest
|
|
19
|
+
matching checkpoint.
|
|
20
|
+
- ``alist(...)`` yields the filtered matches from a single fetch.
|
|
21
|
+
- ``aput_writes`` ingests pending writes with a distinguishing metadata type.
|
|
22
|
+
- ``adelete_thread`` warns + no-ops (Synap has no public delete API).
|
|
23
|
+
|
|
24
|
+
Error policy: writes surface ``SynapIntegrationError`` on SDK failure. Reads
|
|
25
|
+
degrade gracefully (return ``None`` / empty iterator).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import base64
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
from typing import Any, AsyncIterator, Iterator, Optional, Sequence
|
|
34
|
+
|
|
35
|
+
from langchain_core.runnables import RunnableConfig
|
|
36
|
+
from langgraph.checkpoint.base import (
|
|
37
|
+
BaseCheckpointSaver,
|
|
38
|
+
ChannelVersions,
|
|
39
|
+
Checkpoint,
|
|
40
|
+
CheckpointMetadata,
|
|
41
|
+
CheckpointTuple,
|
|
42
|
+
)
|
|
43
|
+
from maximem_synap import MaximemSynapSDK
|
|
44
|
+
from synap_integrations_common import (
|
|
45
|
+
run_async,
|
|
46
|
+
wrap_sdk_errors_async,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_MARKER = "lg_cp"
|
|
53
|
+
_WRITE_MARKER = "lg_cp_writes"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SynapCheckpointSaver(BaseCheckpointSaver):
|
|
57
|
+
"""Synap-backed LangGraph checkpoint saver (best-effort fuzzy)."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
sdk: MaximemSynapSDK,
|
|
62
|
+
user_id: str,
|
|
63
|
+
customer_id: str = "",
|
|
64
|
+
*,
|
|
65
|
+
mode: str = "accurate",
|
|
66
|
+
) -> None:
|
|
67
|
+
if sdk is None:
|
|
68
|
+
raise ValueError("SynapCheckpointSaver requires a non-None sdk")
|
|
69
|
+
if not user_id:
|
|
70
|
+
raise ValueError("SynapCheckpointSaver requires a non-empty user_id")
|
|
71
|
+
super().__init__()
|
|
72
|
+
self.sdk = sdk
|
|
73
|
+
self.user_id = user_id
|
|
74
|
+
self.customer_id = customer_id
|
|
75
|
+
self.mode = mode
|
|
76
|
+
self._delete_warned = False
|
|
77
|
+
|
|
78
|
+
# ── put ─────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
async def aput(
|
|
81
|
+
self,
|
|
82
|
+
config: RunnableConfig,
|
|
83
|
+
checkpoint: Checkpoint,
|
|
84
|
+
metadata: CheckpointMetadata,
|
|
85
|
+
new_versions: ChannelVersions,
|
|
86
|
+
) -> RunnableConfig:
|
|
87
|
+
configurable = dict(config.get("configurable", {}))
|
|
88
|
+
thread_id = str(configurable.get("thread_id") or "")
|
|
89
|
+
checkpoint_ns = str(configurable.get("checkpoint_ns") or "")
|
|
90
|
+
checkpoint_id = str(checkpoint.get("id") or configurable.get("checkpoint_id") or "")
|
|
91
|
+
parent_checkpoint_id = str(configurable.get("checkpoint_id") or "")
|
|
92
|
+
|
|
93
|
+
serde_type, raw_bytes = self.serde.dumps_typed(checkpoint)
|
|
94
|
+
document = _encode(serde_type, raw_bytes, metadata, new_versions)
|
|
95
|
+
|
|
96
|
+
meta = {
|
|
97
|
+
_MARKER: True,
|
|
98
|
+
"thread_id": thread_id,
|
|
99
|
+
"checkpoint_ns": checkpoint_ns,
|
|
100
|
+
"checkpoint_id": checkpoint_id,
|
|
101
|
+
"parent_checkpoint_id": parent_checkpoint_id,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async with wrap_sdk_errors_async(
|
|
105
|
+
"langgraph.checkpointer.put",
|
|
106
|
+
logger,
|
|
107
|
+
thread_id=thread_id,
|
|
108
|
+
checkpoint_id=checkpoint_id,
|
|
109
|
+
):
|
|
110
|
+
await self.sdk.memories.create(
|
|
111
|
+
document=document,
|
|
112
|
+
user_id=self.user_id,
|
|
113
|
+
customer_id=self.customer_id or None,
|
|
114
|
+
metadata=meta,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"configurable": {
|
|
119
|
+
"thread_id": thread_id,
|
|
120
|
+
"checkpoint_ns": checkpoint_ns,
|
|
121
|
+
"checkpoint_id": checkpoint_id,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
def put(
|
|
126
|
+
self,
|
|
127
|
+
config: RunnableConfig,
|
|
128
|
+
checkpoint: Checkpoint,
|
|
129
|
+
metadata: CheckpointMetadata,
|
|
130
|
+
new_versions: ChannelVersions,
|
|
131
|
+
) -> RunnableConfig:
|
|
132
|
+
return run_async(self.aput(config, checkpoint, metadata, new_versions))
|
|
133
|
+
|
|
134
|
+
# ── put_writes ──────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
async def aput_writes(
|
|
137
|
+
self,
|
|
138
|
+
config: RunnableConfig,
|
|
139
|
+
writes: Sequence[tuple[str, Any]],
|
|
140
|
+
task_id: str,
|
|
141
|
+
task_path: str = "",
|
|
142
|
+
) -> None:
|
|
143
|
+
configurable = dict(config.get("configurable", {}))
|
|
144
|
+
thread_id = str(configurable.get("thread_id") or "")
|
|
145
|
+
checkpoint_ns = str(configurable.get("checkpoint_ns") or "")
|
|
146
|
+
checkpoint_id = str(configurable.get("checkpoint_id") or "")
|
|
147
|
+
|
|
148
|
+
# Serialise each write individually so deserialisation fidelity survives
|
|
149
|
+
# per-channel. Each write is (channel, value).
|
|
150
|
+
encoded_writes: list[dict[str, Any]] = []
|
|
151
|
+
for channel, value in writes:
|
|
152
|
+
t, b = self.serde.dumps_typed(value)
|
|
153
|
+
encoded_writes.append({
|
|
154
|
+
"channel": channel,
|
|
155
|
+
"type": t,
|
|
156
|
+
"value_b64": base64.b64encode(b).decode("ascii"),
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
document = json.dumps({
|
|
160
|
+
"task_id": task_id,
|
|
161
|
+
"task_path": task_path,
|
|
162
|
+
"writes": encoded_writes,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
meta = {
|
|
166
|
+
_WRITE_MARKER: True,
|
|
167
|
+
"thread_id": thread_id,
|
|
168
|
+
"checkpoint_ns": checkpoint_ns,
|
|
169
|
+
"checkpoint_id": checkpoint_id,
|
|
170
|
+
"task_id": task_id,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async with wrap_sdk_errors_async(
|
|
174
|
+
"langgraph.checkpointer.put_writes",
|
|
175
|
+
logger,
|
|
176
|
+
thread_id=thread_id,
|
|
177
|
+
checkpoint_id=checkpoint_id,
|
|
178
|
+
task_id=task_id,
|
|
179
|
+
):
|
|
180
|
+
await self.sdk.memories.create(
|
|
181
|
+
document=document,
|
|
182
|
+
user_id=self.user_id,
|
|
183
|
+
customer_id=self.customer_id or None,
|
|
184
|
+
metadata=meta,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def put_writes(
|
|
188
|
+
self,
|
|
189
|
+
config: RunnableConfig,
|
|
190
|
+
writes: Sequence[tuple[str, Any]],
|
|
191
|
+
task_id: str,
|
|
192
|
+
task_path: str = "",
|
|
193
|
+
) -> None:
|
|
194
|
+
run_async(self.aput_writes(config, writes, task_id, task_path))
|
|
195
|
+
|
|
196
|
+
# ── get_tuple ───────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
|
199
|
+
configurable = dict(config.get("configurable", {}))
|
|
200
|
+
thread_id = str(configurable.get("thread_id") or "")
|
|
201
|
+
if not thread_id:
|
|
202
|
+
return None
|
|
203
|
+
checkpoint_ns = str(configurable.get("checkpoint_ns") or "")
|
|
204
|
+
target_cp_id = configurable.get("checkpoint_id")
|
|
205
|
+
|
|
206
|
+
tuples = await self._fetch_checkpoint_tuples(
|
|
207
|
+
thread_id=thread_id,
|
|
208
|
+
checkpoint_ns=checkpoint_ns,
|
|
209
|
+
target_cp_id=str(target_cp_id) if target_cp_id else None,
|
|
210
|
+
limit=10,
|
|
211
|
+
)
|
|
212
|
+
if not tuples:
|
|
213
|
+
return None
|
|
214
|
+
return tuples[0]
|
|
215
|
+
|
|
216
|
+
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
|
217
|
+
return run_async(self.aget_tuple(config))
|
|
218
|
+
|
|
219
|
+
# ── list ────────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
async def alist(
|
|
222
|
+
self,
|
|
223
|
+
config: Optional[RunnableConfig],
|
|
224
|
+
*,
|
|
225
|
+
filter: Optional[dict[str, Any]] = None,
|
|
226
|
+
before: Optional[RunnableConfig] = None,
|
|
227
|
+
limit: Optional[int] = None,
|
|
228
|
+
) -> AsyncIterator[CheckpointTuple]:
|
|
229
|
+
configurable = dict((config or {}).get("configurable", {}))
|
|
230
|
+
thread_id = str(configurable.get("thread_id") or "")
|
|
231
|
+
if not thread_id:
|
|
232
|
+
return
|
|
233
|
+
checkpoint_ns = str(configurable.get("checkpoint_ns") or "")
|
|
234
|
+
before_id: Optional[str] = None
|
|
235
|
+
if before:
|
|
236
|
+
before_id = str(
|
|
237
|
+
(before.get("configurable") or {}).get("checkpoint_id") or ""
|
|
238
|
+
) or None
|
|
239
|
+
|
|
240
|
+
tuples = await self._fetch_checkpoint_tuples(
|
|
241
|
+
thread_id=thread_id,
|
|
242
|
+
checkpoint_ns=checkpoint_ns,
|
|
243
|
+
target_cp_id=None,
|
|
244
|
+
limit=max(limit or 25, 25),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
count = 0
|
|
248
|
+
for tup in tuples:
|
|
249
|
+
if before_id and tup.config["configurable"].get("checkpoint_id") == before_id:
|
|
250
|
+
break
|
|
251
|
+
if filter and not _metadata_matches_filter(tup.metadata, filter):
|
|
252
|
+
continue
|
|
253
|
+
yield tup
|
|
254
|
+
count += 1
|
|
255
|
+
if limit and count >= limit:
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
def list(
|
|
259
|
+
self,
|
|
260
|
+
config: Optional[RunnableConfig],
|
|
261
|
+
*,
|
|
262
|
+
filter: Optional[dict[str, Any]] = None,
|
|
263
|
+
before: Optional[RunnableConfig] = None,
|
|
264
|
+
limit: Optional[int] = None,
|
|
265
|
+
) -> Iterator[CheckpointTuple]:
|
|
266
|
+
async def _collect() -> list[CheckpointTuple]:
|
|
267
|
+
out: list[CheckpointTuple] = []
|
|
268
|
+
async for t in self.alist(config, filter=filter, before=before, limit=limit):
|
|
269
|
+
out.append(t)
|
|
270
|
+
return out
|
|
271
|
+
|
|
272
|
+
return iter(run_async(_collect()))
|
|
273
|
+
|
|
274
|
+
# ── delete_thread ───────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
async def adelete_thread(self, thread_id: str) -> None:
|
|
277
|
+
if not self._delete_warned:
|
|
278
|
+
logger.warning(
|
|
279
|
+
"SynapCheckpointSaver.delete_thread: Synap has no public delete "
|
|
280
|
+
"API; this is a no-op. This warning fires once."
|
|
281
|
+
)
|
|
282
|
+
self._delete_warned = True
|
|
283
|
+
|
|
284
|
+
def delete_thread(self, thread_id: str) -> None:
|
|
285
|
+
run_async(self.adelete_thread(thread_id))
|
|
286
|
+
|
|
287
|
+
# ── fetch helpers ───────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
async def _fetch_checkpoint_tuples(
|
|
290
|
+
self,
|
|
291
|
+
*,
|
|
292
|
+
thread_id: str,
|
|
293
|
+
checkpoint_ns: str,
|
|
294
|
+
target_cp_id: Optional[str],
|
|
295
|
+
limit: int,
|
|
296
|
+
) -> list[CheckpointTuple]:
|
|
297
|
+
try:
|
|
298
|
+
response = await self.sdk.fetch(
|
|
299
|
+
user_id=self.user_id,
|
|
300
|
+
customer_id=self.customer_id or None,
|
|
301
|
+
search_query=[f"{thread_id}"],
|
|
302
|
+
max_results=limit,
|
|
303
|
+
mode=self.mode,
|
|
304
|
+
include_conversation_context=False,
|
|
305
|
+
)
|
|
306
|
+
except Exception as exc: # noqa: BLE001
|
|
307
|
+
logger.error(
|
|
308
|
+
"SynapCheckpointSaver: sdk.fetch failed thread_id=%s error=%s",
|
|
309
|
+
thread_id, exc, exc_info=True,
|
|
310
|
+
)
|
|
311
|
+
return []
|
|
312
|
+
|
|
313
|
+
# Collect candidate facts: must be our checkpoint marker + same thread.
|
|
314
|
+
candidates: list[tuple[Any, dict[str, Any]]] = []
|
|
315
|
+
for fact in getattr(response, "facts", None) or []:
|
|
316
|
+
md = getattr(fact, "metadata", None) or {}
|
|
317
|
+
if not md.get(_MARKER):
|
|
318
|
+
continue
|
|
319
|
+
if md.get("thread_id") != thread_id:
|
|
320
|
+
continue
|
|
321
|
+
if checkpoint_ns and md.get("checkpoint_ns") != checkpoint_ns:
|
|
322
|
+
continue
|
|
323
|
+
if target_cp_id and md.get("checkpoint_id") != target_cp_id:
|
|
324
|
+
continue
|
|
325
|
+
candidates.append((fact, md))
|
|
326
|
+
|
|
327
|
+
# Most recent first by extracted_at.
|
|
328
|
+
candidates.sort(
|
|
329
|
+
key=lambda pair: getattr(pair[0], "extracted_at", 0) or 0,
|
|
330
|
+
reverse=True,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
tuples: list[CheckpointTuple] = []
|
|
334
|
+
for fact, md in candidates:
|
|
335
|
+
try:
|
|
336
|
+
checkpoint, cp_metadata = _decode(
|
|
337
|
+
getattr(fact, "content", "") or "",
|
|
338
|
+
self.serde,
|
|
339
|
+
)
|
|
340
|
+
except Exception as exc: # noqa: BLE001
|
|
341
|
+
logger.error(
|
|
342
|
+
"SynapCheckpointSaver: failed to decode checkpoint "
|
|
343
|
+
"thread_id=%s id=%s error=%s",
|
|
344
|
+
thread_id, md.get("checkpoint_id"), exc, exc_info=True,
|
|
345
|
+
)
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
cfg: RunnableConfig = {
|
|
349
|
+
"configurable": {
|
|
350
|
+
"thread_id": thread_id,
|
|
351
|
+
"checkpoint_ns": md.get("checkpoint_ns") or "",
|
|
352
|
+
"checkpoint_id": md.get("checkpoint_id") or "",
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
parent_cfg: Optional[RunnableConfig] = None
|
|
356
|
+
parent_id = md.get("parent_checkpoint_id")
|
|
357
|
+
if parent_id:
|
|
358
|
+
parent_cfg = {
|
|
359
|
+
"configurable": {
|
|
360
|
+
"thread_id": thread_id,
|
|
361
|
+
"checkpoint_ns": md.get("checkpoint_ns") or "",
|
|
362
|
+
"checkpoint_id": parent_id,
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
tuples.append(
|
|
366
|
+
CheckpointTuple(
|
|
367
|
+
config=cfg,
|
|
368
|
+
checkpoint=checkpoint,
|
|
369
|
+
metadata=cp_metadata,
|
|
370
|
+
parent_config=parent_cfg,
|
|
371
|
+
pending_writes=None,
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
return tuples
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# ── encode / decode helpers ─────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _encode(
|
|
381
|
+
serde_type: str,
|
|
382
|
+
raw_bytes: bytes,
|
|
383
|
+
metadata: CheckpointMetadata,
|
|
384
|
+
new_versions: ChannelVersions,
|
|
385
|
+
) -> str:
|
|
386
|
+
"""Pack the checkpoint + metadata into a single JSON string document."""
|
|
387
|
+
return json.dumps({
|
|
388
|
+
"serde_type": serde_type,
|
|
389
|
+
"checkpoint_b64": base64.b64encode(raw_bytes).decode("ascii"),
|
|
390
|
+
"metadata": _safe_json(dict(metadata or {})),
|
|
391
|
+
"new_versions": _safe_json(dict(new_versions or {})),
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _decode(document: str, serde: Any) -> tuple[Checkpoint, CheckpointMetadata]:
|
|
396
|
+
payload = json.loads(document)
|
|
397
|
+
t = payload["serde_type"]
|
|
398
|
+
b = base64.b64decode(payload["checkpoint_b64"])
|
|
399
|
+
checkpoint = serde.loads_typed((t, b))
|
|
400
|
+
metadata: CheckpointMetadata = payload.get("metadata") or {} # type: ignore[assignment]
|
|
401
|
+
return checkpoint, metadata
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _safe_json(obj: Any) -> Any:
|
|
405
|
+
"""Coerce an arbitrary dict into json-serialisable form (best effort)."""
|
|
406
|
+
try:
|
|
407
|
+
json.dumps(obj)
|
|
408
|
+
return obj
|
|
409
|
+
except TypeError:
|
|
410
|
+
return {k: str(v) for k, v in obj.items()} if isinstance(obj, dict) else str(obj)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _metadata_matches_filter(
|
|
414
|
+
metadata: CheckpointMetadata,
|
|
415
|
+
filter_: dict[str, Any],
|
|
416
|
+
) -> bool:
|
|
417
|
+
md = dict(metadata or {})
|
|
418
|
+
for k, v in filter_.items():
|
|
419
|
+
if md.get(k) != v:
|
|
420
|
+
return False
|
|
421
|
+
return True
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""SynapStore — LangGraph ``BaseStore`` backed by Synap semantic memory.
|
|
2
|
+
|
|
3
|
+
Implements ``batch`` / ``abatch`` (the only abstract methods) and dispatches
|
|
4
|
+
individual ops (:class:`PutOp`, :class:`GetOp`, :class:`SearchOp`,
|
|
5
|
+
:class:`ListNamespacesOp`) to async helpers. The convenience API
|
|
6
|
+
(``put``/``get``/``search``/``delete``/``list_namespaces`` + async variants)
|
|
7
|
+
is inherited unchanged from ``BaseStore`` and rides through ``(a)batch``.
|
|
8
|
+
|
|
9
|
+
Storage strategy:
|
|
10
|
+
|
|
11
|
+
- Each ``put`` is ingested as a Synap memory via ``sdk.memories.create`` with
|
|
12
|
+
the value JSON-encoded in ``document`` and ``metadata`` tagging the
|
|
13
|
+
namespace tuple (joined by ``/``) plus the key.
|
|
14
|
+
- ``get`` retrieves via ``sdk.fetch`` with ``search_query=[namespace+key]``
|
|
15
|
+
and filters the returned facts by the namespace/key metadata markers.
|
|
16
|
+
- ``search`` uses ``sdk.fetch(search_query=[query])`` and filters by
|
|
17
|
+
namespace-prefix in metadata. Scores flow through from ``confidence``.
|
|
18
|
+
- ``delete`` and ``list_namespaces`` warn + no-op (Synap has no public
|
|
19
|
+
delete API, same pattern as synap-crewai).
|
|
20
|
+
|
|
21
|
+
Error policy:
|
|
22
|
+
|
|
23
|
+
- Writes surface ``SynapIntegrationError`` on SDK failure — silent drops
|
|
24
|
+
would hide ingestion outages.
|
|
25
|
+
- Reads degrade gracefully — return ``None``/``[]`` with an ERROR log so an
|
|
26
|
+
SDK blip doesn't poison an agent turn.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from typing import Any, Iterable, Optional
|
|
35
|
+
|
|
36
|
+
from langgraph.store.base import (
|
|
37
|
+
BaseStore,
|
|
38
|
+
GetOp,
|
|
39
|
+
Item,
|
|
40
|
+
ListNamespacesOp,
|
|
41
|
+
Op,
|
|
42
|
+
PutOp,
|
|
43
|
+
Result,
|
|
44
|
+
SearchItem,
|
|
45
|
+
SearchOp,
|
|
46
|
+
)
|
|
47
|
+
from maximem_synap import MaximemSynapSDK
|
|
48
|
+
from synap_integrations_common import (
|
|
49
|
+
SynapIntegrationError,
|
|
50
|
+
run_async,
|
|
51
|
+
wrap_sdk_errors_async,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
# Metadata keys we stamp on every Synap memory we create through SynapStore
|
|
57
|
+
# so we can identify / filter them on retrieval.
|
|
58
|
+
_MARKER = "lg_store"
|
|
59
|
+
_NS = "lg_store_ns"
|
|
60
|
+
_KEY = "lg_store_key"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _ns_str(namespace: tuple[str, ...]) -> str:
|
|
64
|
+
"""Stringify a namespace tuple for metadata / search tokens."""
|
|
65
|
+
return "/".join(namespace)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _matches_namespace_prefix(
|
|
69
|
+
item_ns: str,
|
|
70
|
+
prefix: tuple[str, ...],
|
|
71
|
+
) -> bool:
|
|
72
|
+
"""Return True iff ``item_ns`` sits at or below ``prefix``."""
|
|
73
|
+
if not prefix:
|
|
74
|
+
return True
|
|
75
|
+
pref_str = _ns_str(prefix)
|
|
76
|
+
return item_ns == pref_str or item_ns.startswith(pref_str + "/")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SynapStore(BaseStore):
|
|
80
|
+
"""LangGraph cross-thread long-term memory store backed by Synap.
|
|
81
|
+
|
|
82
|
+
Example::
|
|
83
|
+
|
|
84
|
+
from langgraph.graph import StateGraph, START, END
|
|
85
|
+
from maximem_synap import MaximemSynapSDK
|
|
86
|
+
from synap_langgraph import SynapStore
|
|
87
|
+
|
|
88
|
+
sdk = MaximemSynapSDK(api_key="sk-...")
|
|
89
|
+
store = SynapStore(sdk, user_id="alice", customer_id="acme")
|
|
90
|
+
|
|
91
|
+
graph = StateGraph(MyState)
|
|
92
|
+
# ... add nodes / edges ...
|
|
93
|
+
app = graph.compile(store=store)
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
sdk: MaximemSynapSDK,
|
|
99
|
+
user_id: str,
|
|
100
|
+
customer_id: str = "",
|
|
101
|
+
*,
|
|
102
|
+
mode: str = "accurate",
|
|
103
|
+
) -> None:
|
|
104
|
+
if sdk is None:
|
|
105
|
+
raise ValueError("SynapStore requires a non-None sdk")
|
|
106
|
+
if not user_id:
|
|
107
|
+
raise ValueError("SynapStore requires a non-empty user_id")
|
|
108
|
+
|
|
109
|
+
super().__init__()
|
|
110
|
+
self.sdk = sdk
|
|
111
|
+
self.user_id = user_id
|
|
112
|
+
self.customer_id = customer_id
|
|
113
|
+
self.mode = mode
|
|
114
|
+
self._delete_warned = False
|
|
115
|
+
self._listns_warned = False
|
|
116
|
+
|
|
117
|
+
# ── abstract methods ────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
|
|
120
|
+
return [await self._dispatch(op) for op in ops]
|
|
121
|
+
|
|
122
|
+
def batch(self, ops: Iterable[Op]) -> list[Result]:
|
|
123
|
+
return run_async(self.abatch(list(ops)))
|
|
124
|
+
|
|
125
|
+
# ── dispatcher ──────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
async def _dispatch(self, op: Op) -> Result:
|
|
128
|
+
if isinstance(op, PutOp):
|
|
129
|
+
# value=None on PutOp signals delete (matches the base-class pattern)
|
|
130
|
+
if op.value is None:
|
|
131
|
+
return await self._adelete(op.namespace, op.key)
|
|
132
|
+
return await self._aput(op.namespace, op.key, op.value, op.index)
|
|
133
|
+
if isinstance(op, GetOp):
|
|
134
|
+
return await self._aget(op.namespace, op.key)
|
|
135
|
+
if isinstance(op, SearchOp):
|
|
136
|
+
return await self._asearch(
|
|
137
|
+
op.namespace_prefix, op.query, op.filter, op.limit, op.offset,
|
|
138
|
+
)
|
|
139
|
+
if isinstance(op, ListNamespacesOp):
|
|
140
|
+
return await self._alist_namespaces(op)
|
|
141
|
+
raise ValueError(f"SynapStore: unsupported op type {type(op).__name__}")
|
|
142
|
+
|
|
143
|
+
# ── op implementations ─────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
async def _aput(
|
|
146
|
+
self,
|
|
147
|
+
namespace: tuple[str, ...],
|
|
148
|
+
key: str,
|
|
149
|
+
value: dict[str, Any],
|
|
150
|
+
index: Any = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
ns = _ns_str(namespace)
|
|
153
|
+
document = json.dumps(value, default=str)
|
|
154
|
+
metadata = {
|
|
155
|
+
_MARKER: True,
|
|
156
|
+
_NS: ns,
|
|
157
|
+
_KEY: key,
|
|
158
|
+
}
|
|
159
|
+
async with wrap_sdk_errors_async(
|
|
160
|
+
"langgraph.store.put",
|
|
161
|
+
logger,
|
|
162
|
+
namespace=ns,
|
|
163
|
+
key=key,
|
|
164
|
+
):
|
|
165
|
+
await self.sdk.memories.create(
|
|
166
|
+
document=document,
|
|
167
|
+
user_id=self.user_id,
|
|
168
|
+
customer_id=self.customer_id or None,
|
|
169
|
+
metadata=metadata,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def _aget(
|
|
173
|
+
self,
|
|
174
|
+
namespace: tuple[str, ...],
|
|
175
|
+
key: str,
|
|
176
|
+
) -> Optional[Item]:
|
|
177
|
+
ns = _ns_str(namespace)
|
|
178
|
+
try:
|
|
179
|
+
response = await self.sdk.fetch(
|
|
180
|
+
user_id=self.user_id,
|
|
181
|
+
customer_id=self.customer_id or None,
|
|
182
|
+
search_query=[f"{ns} {key}"],
|
|
183
|
+
max_results=50,
|
|
184
|
+
mode=self.mode,
|
|
185
|
+
include_conversation_context=False,
|
|
186
|
+
)
|
|
187
|
+
except Exception as exc: # noqa: BLE001 — read-side degrades gracefully
|
|
188
|
+
logger.error(
|
|
189
|
+
"SynapStore.get: sdk.fetch failed namespace=%s key=%s error=%s",
|
|
190
|
+
ns, key, exc, exc_info=True,
|
|
191
|
+
)
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
for fact in getattr(response, "facts", None) or []:
|
|
195
|
+
md = getattr(fact, "metadata", None) or {}
|
|
196
|
+
if md.get(_MARKER) and md.get(_NS) == ns and md.get(_KEY) == key:
|
|
197
|
+
return _fact_to_item(fact, namespace, key)
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
async def _asearch(
|
|
201
|
+
self,
|
|
202
|
+
namespace_prefix: tuple[str, ...],
|
|
203
|
+
query: Optional[str],
|
|
204
|
+
filter_: Optional[dict[str, Any]],
|
|
205
|
+
limit: int,
|
|
206
|
+
offset: int,
|
|
207
|
+
) -> list[SearchItem]:
|
|
208
|
+
q = query or _ns_str(namespace_prefix) or ""
|
|
209
|
+
try:
|
|
210
|
+
response = await self.sdk.fetch(
|
|
211
|
+
user_id=self.user_id,
|
|
212
|
+
customer_id=self.customer_id or None,
|
|
213
|
+
search_query=[q] if q else None,
|
|
214
|
+
max_results=max(limit + offset, 10),
|
|
215
|
+
mode=self.mode,
|
|
216
|
+
include_conversation_context=False,
|
|
217
|
+
)
|
|
218
|
+
except Exception as exc: # noqa: BLE001 — read-side degrades gracefully
|
|
219
|
+
logger.error(
|
|
220
|
+
"SynapStore.search: sdk.fetch failed namespace_prefix=%s query=%s error=%s",
|
|
221
|
+
_ns_str(namespace_prefix), query, exc, exc_info=True,
|
|
222
|
+
)
|
|
223
|
+
return []
|
|
224
|
+
|
|
225
|
+
matches: list[SearchItem] = []
|
|
226
|
+
for fact in getattr(response, "facts", None) or []:
|
|
227
|
+
md = getattr(fact, "metadata", None) or {}
|
|
228
|
+
if not md.get(_MARKER):
|
|
229
|
+
continue
|
|
230
|
+
item_ns_str = md.get(_NS) or ""
|
|
231
|
+
if not _matches_namespace_prefix(item_ns_str, namespace_prefix):
|
|
232
|
+
continue
|
|
233
|
+
if filter_ and not _filter_matches(md, filter_):
|
|
234
|
+
continue
|
|
235
|
+
item_ns = tuple(item_ns_str.split("/")) if item_ns_str else ()
|
|
236
|
+
key = md.get(_KEY) or str(fact.id)
|
|
237
|
+
matches.append(_fact_to_search_item(fact, item_ns, key))
|
|
238
|
+
|
|
239
|
+
return matches[offset : offset + limit]
|
|
240
|
+
|
|
241
|
+
async def _adelete(
|
|
242
|
+
self,
|
|
243
|
+
namespace: tuple[str, ...],
|
|
244
|
+
key: str,
|
|
245
|
+
) -> None:
|
|
246
|
+
if not self._delete_warned:
|
|
247
|
+
logger.warning(
|
|
248
|
+
"SynapStore.delete: Synap has no public delete API. Delete/update "
|
|
249
|
+
"operations are no-ops; data is write-only. This warning fires once."
|
|
250
|
+
)
|
|
251
|
+
self._delete_warned = True
|
|
252
|
+
|
|
253
|
+
async def _alist_namespaces(self, op: ListNamespacesOp) -> list[tuple[str, ...]]:
|
|
254
|
+
if not self._listns_warned:
|
|
255
|
+
logger.warning(
|
|
256
|
+
"SynapStore.list_namespaces: Synap does not expose a namespace "
|
|
257
|
+
"enumeration API; returning []. This warning fires once."
|
|
258
|
+
)
|
|
259
|
+
self._listns_warned = True
|
|
260
|
+
return []
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ── helpers ────────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _fact_to_item(fact: Any, namespace: tuple[str, ...], key: str) -> Item:
|
|
267
|
+
value = _parse_value(getattr(fact, "content", "") or "")
|
|
268
|
+
now = _as_datetime(getattr(fact, "extracted_at", None))
|
|
269
|
+
return Item(
|
|
270
|
+
value=value,
|
|
271
|
+
key=key,
|
|
272
|
+
namespace=namespace,
|
|
273
|
+
created_at=now,
|
|
274
|
+
updated_at=now,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _fact_to_search_item(
|
|
279
|
+
fact: Any,
|
|
280
|
+
namespace: tuple[str, ...],
|
|
281
|
+
key: str,
|
|
282
|
+
) -> SearchItem:
|
|
283
|
+
value = _parse_value(getattr(fact, "content", "") or "")
|
|
284
|
+
now = _as_datetime(getattr(fact, "extracted_at", None))
|
|
285
|
+
score = getattr(fact, "confidence", None)
|
|
286
|
+
return SearchItem(
|
|
287
|
+
namespace=namespace,
|
|
288
|
+
key=key,
|
|
289
|
+
value=value,
|
|
290
|
+
created_at=now,
|
|
291
|
+
updated_at=now,
|
|
292
|
+
score=float(score) if isinstance(score, (int, float)) else None,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _parse_value(document: str) -> dict[str, Any]:
|
|
297
|
+
if not document:
|
|
298
|
+
return {}
|
|
299
|
+
try:
|
|
300
|
+
parsed = json.loads(document)
|
|
301
|
+
except (ValueError, TypeError):
|
|
302
|
+
return {"_raw": document}
|
|
303
|
+
if isinstance(parsed, dict):
|
|
304
|
+
return parsed
|
|
305
|
+
return {"_value": parsed}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _as_datetime(value: Any) -> datetime:
|
|
309
|
+
if isinstance(value, datetime):
|
|
310
|
+
return value
|
|
311
|
+
return datetime.now(timezone.utc)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# Silence the unused-import complaint — SynapIntegrationError is re-exported
|
|
315
|
+
# via wrap_sdk_errors_async raising it. Keep the symbol visible for typing.
|
|
316
|
+
_ = SynapIntegrationError
|