langgraph-checkpoint-grafomem 1.0.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.
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: langgraph-checkpoint-grafomem
3
+ Version: 1.0.0
4
+ Summary: Grafomem (Fair Source) adapter for LangGraph Checkpointers
5
+ Author-email: "Ulissy s.r.l." <hello@grafomem.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://grafomem.com
8
+ Project-URL: Repository, https://github.com/cayerbe/grafomem
9
+ Keywords: agent-memory,langgraph,checkpointer,governance,erasure,fair-source
10
+ Requires-Dist: langgraph-checkpoint
11
+ Requires-Dist: grafomem[runtime]>=0.4.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest; extra == "dev"
14
+ Requires-Dist: langgraph; extra == "dev"
@@ -0,0 +1,4 @@
1
+ from .serializer import GrafomemSerializer
2
+ from .saver import GrafomemCheckpointSaver
3
+
4
+ __all__ = ["GrafomemSerializer", "GrafomemCheckpointSaver"]
@@ -0,0 +1,131 @@
1
+ from typing import Any, AsyncIterator, Dict, Iterator, Mapping, Sequence, Collection
2
+ from datetime import datetime, timezone
3
+ import copy
4
+ from langgraph.checkpoint.base import (
5
+ BaseCheckpointSaver,
6
+ Checkpoint,
7
+ CheckpointMetadata,
8
+ CheckpointTuple,
9
+ ChannelVersions,
10
+ RunnableConfig,
11
+ )
12
+ from grafomem.runtime import Receipt
13
+ from .serializer import GrafomemSerializer
14
+
15
+ class GrafomemCheckpointSaver(BaseCheckpointSaver):
16
+ """
17
+ Layer 2: LangGraph Checkpoint Saver Decorator for Lethe.
18
+ Delegates all storage operations to an inner saver.
19
+ Emits out-of-band erasure Receipts on delete_thread.
20
+ """
21
+
22
+ def __init__(self, inner: BaseCheckpointSaver):
23
+ super().__init__(serde=inner.serde)
24
+ self.inner = inner
25
+ if not isinstance(self.inner.serde, GrafomemSerializer):
26
+ raise ValueError("Inner saver must use GrafomemSerializer as its serde")
27
+ self._receipts: Dict[str, Receipt] = {}
28
+
29
+ def last_receipt(self, thread_id: str) -> Receipt | None:
30
+ return self._receipts.get(thread_id)
31
+
32
+ def _create_receipt(self, thread_id: str) -> Receipt:
33
+ timestamp = datetime.now(timezone.utc).isoformat()
34
+
35
+ payload = f"thread_data|erased|{thread_id}|{self.inner.serde.key_id}|{timestamp}".encode('utf-8')
36
+ signature = self.inner.serde.private_key.sign(payload)
37
+
38
+ return Receipt(
39
+ before="thread_data",
40
+ after="erased",
41
+ scope=thread_id,
42
+ key_id=self.inner.serde.key_id,
43
+ timestamp=timestamp,
44
+ signature=signature
45
+ )
46
+
47
+ # --- Delegated Methods ---
48
+
49
+ def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
50
+ return self.inner.get_tuple(config)
51
+
52
+ def list(
53
+ self,
54
+ config: RunnableConfig | None,
55
+ *,
56
+ filter: dict[str, Any] | None = None,
57
+ before: RunnableConfig | None = None,
58
+ limit: int | None = None,
59
+ ) -> Iterator[CheckpointTuple]:
60
+ return self.inner.list(config, filter=filter, before=before, limit=limit)
61
+
62
+ def put(
63
+ self,
64
+ config: RunnableConfig,
65
+ checkpoint: Checkpoint,
66
+ metadata: CheckpointMetadata,
67
+ new_versions: ChannelVersions,
68
+ ) -> RunnableConfig:
69
+ content_hash = self.inner.serde.compute_content_hash(checkpoint)
70
+
71
+ # Inject content_hash into metadata to retain UUID7 keying for LangGraph
72
+ new_metadata = dict(metadata) if metadata else {}
73
+ new_metadata["grafomem_content_hash"] = content_hash
74
+
75
+ return self.inner.put(config, checkpoint, new_metadata, new_versions)
76
+
77
+ def put_writes(
78
+ self,
79
+ config: RunnableConfig,
80
+ writes: Sequence[tuple[str, Any]],
81
+ task_id: str,
82
+ task_path: str = "",
83
+ ) -> None:
84
+ return self.inner.put_writes(config, writes, task_id, task_path)
85
+
86
+ def delete_thread(self, thread_id: str) -> None:
87
+ self.inner.delete_thread(thread_id)
88
+ # Emit out-of-band erasure receipt
89
+ rcpt = self._create_receipt(thread_id)
90
+ self._receipts[thread_id] = rcpt
91
+
92
+ # --- Async Delegated Methods ---
93
+ async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
94
+ return await self.inner.aget_tuple(config)
95
+
96
+ async def alist(
97
+ self,
98
+ config: RunnableConfig | None,
99
+ *,
100
+ filter: dict[str, Any] | None = None,
101
+ before: RunnableConfig | None = None,
102
+ limit: int | None = None,
103
+ ) -> AsyncIterator[CheckpointTuple]:
104
+ async for t in self.inner.alist(config, filter=filter, before=before, limit=limit):
105
+ yield t
106
+
107
+ async def aput(
108
+ self,
109
+ config: RunnableConfig,
110
+ checkpoint: Checkpoint,
111
+ metadata: CheckpointMetadata,
112
+ new_versions: ChannelVersions,
113
+ ) -> RunnableConfig:
114
+ content_hash = self.inner.serde.compute_content_hash(checkpoint)
115
+ new_metadata = dict(metadata) if metadata else {}
116
+ new_metadata["grafomem_content_hash"] = content_hash
117
+ return await self.inner.aput(config, checkpoint, new_metadata, new_versions)
118
+
119
+ async def aput_writes(
120
+ self,
121
+ config: RunnableConfig,
122
+ writes: Sequence[tuple[str, Any]],
123
+ task_id: str,
124
+ task_path: str = "",
125
+ ) -> None:
126
+ return await self.inner.aput_writes(config, writes, task_id, task_path)
127
+
128
+ async def adelete_thread(self, thread_id: str) -> None:
129
+ await self.inner.adelete_thread(thread_id)
130
+ rcpt = self._create_receipt(thread_id)
131
+ self._receipts[thread_id] = rcpt
@@ -0,0 +1,70 @@
1
+ from typing import Any, Tuple
2
+ from cryptography.hazmat.primitives.asymmetric import ed25519
3
+ from langgraph.checkpoint.serde.base import SerializerProtocol
4
+ from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
5
+
6
+ from grafomem.cso import CSO
7
+
8
+ class GrafomemSerializer(SerializerProtocol):
9
+ """
10
+ Layer 1: Serializer decorator for LangGraph checkpointers.
11
+ Wraps the underlying serde to output signed .gfm blobs.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ private_key: ed25519.Ed25519PrivateKey,
17
+ inner: SerializerProtocol | None = None,
18
+ key_id: str = "grafomem_checkpoint",
19
+ trusted_keys: dict | None = None,
20
+ model_id: str = "grafomem-langgraph-v1",
21
+ capabilities: frozenset[str] | None = None
22
+ ):
23
+ self.private_key = private_key
24
+ self.inner = inner or JsonPlusSerializer()
25
+ self.key_id = key_id
26
+
27
+ if trusted_keys is None:
28
+ self.trusted_keys = {self.key_id: private_key.public_key()}
29
+ else:
30
+ self.trusted_keys = trusted_keys
31
+
32
+ self.model_id = model_id
33
+ self.capabilities = capabilities or frozenset(["namespace.checkpoint"])
34
+
35
+ def _create_cso(self, raw_bytes: bytes) -> CSO:
36
+ return CSO(
37
+ M=None,
38
+ blob=raw_bytes,
39
+ payload_type="blob",
40
+ model_id=self.model_id,
41
+ capabilities=self.capabilities,
42
+ key_id=self.key_id,
43
+ consent={"subject_id": "langgraph-checkpoint", "policy": "tenant"}
44
+ )
45
+
46
+ def compute_content_hash(self, obj: Any) -> str:
47
+ """Computes the content_hash without signing."""
48
+ _, raw_bytes = self.inner.dumps_typed(obj)
49
+ cso = self._create_cso(raw_bytes)
50
+ return cso.content_hash()
51
+
52
+ def dumps_typed(self, obj: Any) -> Tuple[str, bytes]:
53
+ type_, raw_bytes = self.inner.dumps_typed(obj)
54
+
55
+ # Wrap as a blob CSO
56
+ cso = self._create_cso(raw_bytes)
57
+
58
+ gfm_bytes = cso.to_gfm(self.private_key)
59
+ return (type_, gfm_bytes)
60
+
61
+ def loads_typed(self, data: Tuple[str, bytes]) -> Any:
62
+ type_, gfm_bytes = data
63
+
64
+ # Verify and unwrap
65
+ cso = CSO.from_gfm(gfm_bytes, self.trusted_keys)
66
+
67
+ if cso.payload_type != "blob" or cso.blob is None:
68
+ raise ValueError("GrafomemSerializer expected a blob payload")
69
+
70
+ return self.inner.loads_typed((type_, cso.blob))
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: langgraph-checkpoint-grafomem
3
+ Version: 1.0.0
4
+ Summary: Grafomem (Fair Source) adapter for LangGraph Checkpointers
5
+ Author-email: "Ulissy s.r.l." <hello@grafomem.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://grafomem.com
8
+ Project-URL: Repository, https://github.com/cayerbe/grafomem
9
+ Keywords: agent-memory,langgraph,checkpointer,governance,erasure,fair-source
10
+ Requires-Dist: langgraph-checkpoint
11
+ Requires-Dist: grafomem[runtime]>=0.4.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest; extra == "dev"
14
+ Requires-Dist: langgraph; extra == "dev"
@@ -0,0 +1,10 @@
1
+ pyproject.toml
2
+ grafomem_checkpoint/__init__.py
3
+ grafomem_checkpoint/saver.py
4
+ grafomem_checkpoint/serializer.py
5
+ langgraph_checkpoint_grafomem.egg-info/PKG-INFO
6
+ langgraph_checkpoint_grafomem.egg-info/SOURCES.txt
7
+ langgraph_checkpoint_grafomem.egg-info/dependency_links.txt
8
+ langgraph_checkpoint_grafomem.egg-info/requires.txt
9
+ langgraph_checkpoint_grafomem.egg-info/top_level.txt
10
+ tests/test_adapter.py
@@ -0,0 +1,6 @@
1
+ langgraph-checkpoint
2
+ grafomem[runtime]>=0.4.0
3
+
4
+ [dev]
5
+ pytest
6
+ langgraph
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "langgraph-checkpoint-grafomem"
7
+ version = "1.0.0"
8
+ description = "Grafomem (Fair Source) adapter for LangGraph Checkpointers"
9
+ dependencies = [
10
+ "langgraph-checkpoint",
11
+ "grafomem[runtime]>=0.4.0"
12
+ ]
13
+ license = { text = "Apache-2.0" }
14
+ authors = [
15
+ { name = "Ulissy s.r.l.", email = "hello@grafomem.com" }
16
+ ]
17
+ keywords = ["agent-memory", "langgraph", "checkpointer", "governance", "erasure", "fair-source"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://grafomem.com"
21
+ Repository = "https://github.com/cayerbe/grafomem"
22
+
23
+ [project.optional-dependencies]
24
+ dev = [
25
+ "pytest",
26
+ "langgraph"
27
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,173 @@
1
+ import pytest
2
+ import time
3
+ import copy
4
+ from typing import TypedDict, Annotated
5
+ import operator
6
+
7
+ from cryptography.hazmat.primitives.asymmetric import ed25519
8
+ from langgraph.graph import StateGraph, START, END
9
+ from langgraph.checkpoint.memory import MemorySaver
10
+ from grafomem.errors import SignatureMismatch
11
+
12
+ from grafomem_checkpoint.serializer import GrafomemSerializer
13
+ from grafomem_checkpoint.saver import GrafomemCheckpointSaver
14
+
15
+
16
+ class State(TypedDict):
17
+ messages: Annotated[list, operator.add]
18
+ count: int
19
+ large_data: list[dict]
20
+
21
+ def my_node(state: State):
22
+ return {"messages": ["hello"], "count": state.get("count", 0) + 1, "large_data": state.get("large_data", [])}
23
+
24
+ async def my_async_node(state: State):
25
+ return {"messages": ["hello"], "count": state.get("count", 0) + 1, "large_data": state.get("large_data", [])}
26
+
27
+ def build_graph(saver):
28
+ builder = StateGraph(State)
29
+ builder.add_node("A", my_node)
30
+ builder.add_node("B", my_node)
31
+ builder.add_edge(START, "A")
32
+ builder.add_edge("A", "B")
33
+ builder.add_edge("B", END)
34
+ return builder.compile(checkpointer=saver)
35
+
36
+ def test_real_graph_interrupt_and_resume():
37
+ priv = ed25519.Ed25519PrivateKey.generate()
38
+ serde = GrafomemSerializer(private_key=priv)
39
+ inner = MemorySaver(serde=serde)
40
+ saver = GrafomemCheckpointSaver(inner)
41
+
42
+ # Graph that suspends / interrupts
43
+ builder = StateGraph(State)
44
+ builder.add_node("A", my_node)
45
+ builder.add_edge(START, "A")
46
+ builder.add_edge("A", END)
47
+ graph = builder.compile(checkpointer=saver, interrupt_after=["A"])
48
+
49
+ config = {"configurable": {"thread_id": "thread-1"}}
50
+
51
+ # Invoke and interrupt
52
+ result1 = graph.invoke({"messages": ["start"], "count": 0, "large_data": [{"key": "value"}] * 10}, config)
53
+
54
+ # State should be at A
55
+ checkpoint_tuple = saver.get_tuple(config)
56
+ assert checkpoint_tuple is not None
57
+ assert checkpoint_tuple.checkpoint["id"] is not None
58
+ assert checkpoint_tuple.metadata.get("grafomem_content_hash") is not None
59
+
60
+ # Resume bit-identical
61
+ result2 = graph.invoke(None, config)
62
+ assert result2["count"] == 1
63
+ assert result2["messages"] == ["start", "hello"]
64
+
65
+ @pytest.mark.asyncio
66
+ async def test_async_real_graph_interrupt_resume_adelete():
67
+ priv = ed25519.Ed25519PrivateKey.generate()
68
+ serde = GrafomemSerializer(private_key=priv)
69
+ inner = MemorySaver(serde=serde)
70
+ saver = GrafomemCheckpointSaver(inner)
71
+
72
+ # Graph that suspends / interrupts
73
+ builder = StateGraph(State)
74
+ builder.add_node("A", my_async_node)
75
+ builder.add_edge(START, "A")
76
+ builder.add_edge("A", END)
77
+ graph = builder.compile(checkpointer=saver, interrupt_after=["A"])
78
+
79
+ config = {"configurable": {"thread_id": "thread-async-1"}}
80
+
81
+ # ainvoke and interrupt
82
+ await graph.ainvoke({"messages": ["start_async"], "count": 0, "large_data": []}, config)
83
+
84
+ # State should be at A
85
+ checkpoint_tuple = await saver.aget_tuple(config)
86
+ assert checkpoint_tuple is not None
87
+ assert checkpoint_tuple.checkpoint["id"] is not None
88
+ assert checkpoint_tuple.metadata.get("grafomem_content_hash") is not None
89
+
90
+ # Resume bit-identical
91
+ result2 = await graph.ainvoke(None, config)
92
+ assert result2["count"] == 1
93
+ assert result2["messages"] == ["start_async", "hello"]
94
+
95
+ # Async Delete Thread
96
+ await saver.adelete_thread("thread-async-1")
97
+
98
+ # Get receipt and verify
99
+ rcpt = saver.last_receipt("thread-async-1")
100
+ assert rcpt is not None
101
+ assert rcpt.scope == "thread-async-1"
102
+ assert rcpt.before == "thread_data"
103
+ assert rcpt.after == "erased"
104
+ assert rcpt.verify(priv.public_key())
105
+
106
+ def test_tamper_signature_mismatch():
107
+ priv = ed25519.Ed25519PrivateKey.generate()
108
+ serde = GrafomemSerializer(private_key=priv)
109
+ inner = MemorySaver(serde=serde)
110
+ saver = GrafomemCheckpointSaver(inner)
111
+
112
+ graph = build_graph(saver)
113
+ config = {"configurable": {"thread_id": "thread-2"}}
114
+ graph.invoke({"messages": ["hi"], "count": 0}, config)
115
+
116
+ type_, gfm_bytes = serde.dumps_typed({"id": "some_id", "v": 1})
117
+
118
+ # Flip a byte
119
+ b_arr = bytearray(gfm_bytes)
120
+ b_arr[-1] ^= 0x01
121
+
122
+ with pytest.raises(ValueError, match="signature mismatch"):
123
+ serde.loads_typed((type_, bytes(b_arr)))
124
+
125
+ def test_delete_thread_receipt():
126
+ priv = ed25519.Ed25519PrivateKey.generate()
127
+ serde = GrafomemSerializer(private_key=priv)
128
+ inner = MemorySaver(serde=serde)
129
+ saver = GrafomemCheckpointSaver(inner)
130
+
131
+ graph = build_graph(saver)
132
+ config = {"configurable": {"thread_id": "thread-3"}}
133
+ graph.invoke({"messages": ["hi"], "count": 0}, config)
134
+
135
+ # Erase
136
+ saver.delete_thread("thread-3")
137
+
138
+ # Get receipt
139
+ rcpt = saver.last_receipt("thread-3")
140
+ assert rcpt is not None
141
+ assert rcpt.scope == "thread-3"
142
+ assert rcpt.before == "thread_data"
143
+ assert rcpt.after == "erased"
144
+
145
+ # Verify receipt signature
146
+ assert rcpt.verify(priv.public_key())
147
+
148
+ def test_serialization_overhead():
149
+ priv = ed25519.Ed25519PrivateKey.generate()
150
+ serde = GrafomemSerializer(private_key=priv)
151
+ inner = MemorySaver(serde=serde)
152
+ saver = GrafomemCheckpointSaver(inner)
153
+
154
+ # Generate large state
155
+ large_data = [{"k": f"value_{i}", "data": "x" * 1000} for i in range(1000)] # ~1MB
156
+ state = {"v": 1, "id": "test", "ts": "2026", "channel_values": {"large_data": large_data}}
157
+
158
+ # Measure without Grafomem
159
+ from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
160
+ base_serde = JsonPlusSerializer()
161
+
162
+ t0 = time.time()
163
+ for _ in range(10):
164
+ base_serde.dumps_typed(state)
165
+ base_time = (time.time() - t0) / 10
166
+
167
+ t0 = time.time()
168
+ for _ in range(10):
169
+ serde.dumps_typed(state)
170
+ grafomem_time = (time.time() - t0) / 10
171
+
172
+ overhead = grafomem_time - base_time
173
+ print(f"\n[Overhead Report] Base serialization: {base_time*1000:.2f}ms, Grafomem serialization: {grafomem_time*1000:.2f}ms. Overhead: {overhead*1000:.2f}ms per step.")