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.
- langgraph_checkpoint_grafomem-1.0.0/PKG-INFO +14 -0
- langgraph_checkpoint_grafomem-1.0.0/grafomem_checkpoint/__init__.py +4 -0
- langgraph_checkpoint_grafomem-1.0.0/grafomem_checkpoint/saver.py +131 -0
- langgraph_checkpoint_grafomem-1.0.0/grafomem_checkpoint/serializer.py +70 -0
- langgraph_checkpoint_grafomem-1.0.0/langgraph_checkpoint_grafomem.egg-info/PKG-INFO +14 -0
- langgraph_checkpoint_grafomem-1.0.0/langgraph_checkpoint_grafomem.egg-info/SOURCES.txt +10 -0
- langgraph_checkpoint_grafomem-1.0.0/langgraph_checkpoint_grafomem.egg-info/dependency_links.txt +1 -0
- langgraph_checkpoint_grafomem-1.0.0/langgraph_checkpoint_grafomem.egg-info/requires.txt +6 -0
- langgraph_checkpoint_grafomem-1.0.0/langgraph_checkpoint_grafomem.egg-info/top_level.txt +1 -0
- langgraph_checkpoint_grafomem-1.0.0/pyproject.toml +27 -0
- langgraph_checkpoint_grafomem-1.0.0/setup.cfg +4 -0
- langgraph_checkpoint_grafomem-1.0.0/tests/test_adapter.py +173 -0
|
@@ -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,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
|
langgraph_checkpoint_grafomem-1.0.0/langgraph_checkpoint_grafomem.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
grafomem_checkpoint
|
|
@@ -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,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.")
|