tikuu-memory 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.
- tikuu_memory-0.1.0/PKG-INFO +89 -0
- tikuu_memory-0.1.0/README.md +78 -0
- tikuu_memory-0.1.0/pyproject.toml +25 -0
- tikuu_memory-0.1.0/pyproject.toml.orig +24 -0
- tikuu_memory-0.1.0/src/tikuu_memory/__init__.py +17 -0
- tikuu_memory-0.1.0/src/tikuu_memory/client.py +284 -0
- tikuu_memory-0.1.0/src/tikuu_memory/config.py +47 -0
- tikuu_memory-0.1.0/src/tikuu_memory/constants.py +5 -0
- tikuu_memory-0.1.0/src/tikuu_memory/errors.py +17 -0
- tikuu_memory-0.1.0/src/tikuu_memory/models.py +47 -0
- tikuu_memory-0.1.0/src/tikuu_memory/saver.py +314 -0
- tikuu_memory-0.1.0/src/tikuu_memory/serde.py +40 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: tikuu-memory
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tikuu memory SDK: HTTP client and LangGraph snapshot checkpointer
|
|
5
|
+
Author: Utkarsh Tiwari
|
|
6
|
+
Author-email: Utkarsh Tiwari <tiwariutkarsh66@gmail.com>
|
|
7
|
+
Requires-Dist: httpx>=0.28.0
|
|
8
|
+
Requires-Dist: langgraph>=1.0.0
|
|
9
|
+
Requires-Python: >=3.13
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# tikuu-memory
|
|
13
|
+
|
|
14
|
+
Python SDK for Tikuu short-term memory: HTTP client for
|
|
15
|
+
[`tikuu-memory-service`](../tikuu-memory-service/) and a LangGraph
|
|
16
|
+
`BaseCheckpointSaver` (`TikuuMemorySaver`).
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Python 3.13+
|
|
21
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
22
|
+
- A running `tikuu-memory-service`
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv add tikuu-memory
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Env (required)
|
|
31
|
+
|
|
32
|
+
| Variable | Meaning |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `TIKUU_MEMORY_SERVICE_URL` | Memory service base URL (no trailing slash) |
|
|
35
|
+
| `TIKUU_AGENT_ID` | Agent UUID used as the storage partition |
|
|
36
|
+
|
|
37
|
+
`TikuuMemorySaver.from_env()` / `TikuuMemoryClient.from_env()` raise
|
|
38
|
+
`MemoryConfigurationError` if either is missing or blank.
|
|
39
|
+
|
|
40
|
+
Inside Docker agent containers, tikuu-api-server injects both. For host-local
|
|
41
|
+
runs use e.g. `http://127.0.0.1:8100`.
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from tikuu_memory import TikuuMemorySaver
|
|
47
|
+
|
|
48
|
+
checkpointer = TikuuMemorySaver.from_env()
|
|
49
|
+
# pass checkpointer into create_agent(..., checkpointer=checkpointer)
|
|
50
|
+
|
|
51
|
+
config = {"configurable": {"thread_id": session_id}}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The CLI template sets `thread_id` to the Tikuu `X-Session-Id`. The saver
|
|
55
|
+
never forces that mapping; any `thread_id` string is accepted.
|
|
56
|
+
|
|
57
|
+
Storage key: `(agent_id, thread_id, checkpoint_ns)`.
|
|
58
|
+
|
|
59
|
+
## Session-id-as-thread-id caveats
|
|
60
|
+
|
|
61
|
+
When using the Tikuu session UUID as `thread_id`:
|
|
62
|
+
|
|
63
|
+
- Omitting `X-Session-Id` on a later call mints a new session → new empty thread.
|
|
64
|
+
- One session ⇒ one LangGraph thread ⇒ one container `{agent_id}--{session_id}`.
|
|
65
|
+
- Concurrent invokes on the same session race (last snapshot wins); use one
|
|
66
|
+
in-flight invoke per session.
|
|
67
|
+
- Custom `thread_id` values are allowed in user code.
|
|
68
|
+
|
|
69
|
+
## V1 limitations
|
|
70
|
+
|
|
71
|
+
- **Snapshot-only** storage (intentional).
|
|
72
|
+
- **`put_writes` is a no-op** so LangGraph does not crash; interrupt/resume
|
|
73
|
+
and mid-step crash recovery are not supported yet.
|
|
74
|
+
- Metadata `filter` on `list` is ignored.
|
|
75
|
+
- No auth headers.
|
|
76
|
+
|
|
77
|
+
## Deferred work
|
|
78
|
+
|
|
79
|
+
- **TODO(auth):** Send session token header when API server injects one.
|
|
80
|
+
- **TODO(writes):** Persist pending writes for interrupt/resume.
|
|
81
|
+
- **TODO(actor_id):** Optional end-user / tenant key.
|
|
82
|
+
- **TODO(legacy):** Non-snapshot event-log formats.
|
|
83
|
+
- **TODO(serde):** Prefer JSON-only serde if pickle via JsonPlus is undesirable.
|
|
84
|
+
|
|
85
|
+
## Docs
|
|
86
|
+
|
|
87
|
+
- [Usage](docs/USAGE.md)
|
|
88
|
+
- [Architecture](docs/ARCHITECTURE.md)
|
|
89
|
+
- [End-to-end smoke path](../docs/MEMORY_SMOKE.md)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# tikuu-memory
|
|
2
|
+
|
|
3
|
+
Python SDK for Tikuu short-term memory: HTTP client for
|
|
4
|
+
[`tikuu-memory-service`](../tikuu-memory-service/) and a LangGraph
|
|
5
|
+
`BaseCheckpointSaver` (`TikuuMemorySaver`).
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Python 3.13+
|
|
10
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
11
|
+
- A running `tikuu-memory-service`
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv add tikuu-memory
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Env (required)
|
|
20
|
+
|
|
21
|
+
| Variable | Meaning |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `TIKUU_MEMORY_SERVICE_URL` | Memory service base URL (no trailing slash) |
|
|
24
|
+
| `TIKUU_AGENT_ID` | Agent UUID used as the storage partition |
|
|
25
|
+
|
|
26
|
+
`TikuuMemorySaver.from_env()` / `TikuuMemoryClient.from_env()` raise
|
|
27
|
+
`MemoryConfigurationError` if either is missing or blank.
|
|
28
|
+
|
|
29
|
+
Inside Docker agent containers, tikuu-api-server injects both. For host-local
|
|
30
|
+
runs use e.g. `http://127.0.0.1:8100`.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from tikuu_memory import TikuuMemorySaver
|
|
36
|
+
|
|
37
|
+
checkpointer = TikuuMemorySaver.from_env()
|
|
38
|
+
# pass checkpointer into create_agent(..., checkpointer=checkpointer)
|
|
39
|
+
|
|
40
|
+
config = {"configurable": {"thread_id": session_id}}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The CLI template sets `thread_id` to the Tikuu `X-Session-Id`. The saver
|
|
44
|
+
never forces that mapping; any `thread_id` string is accepted.
|
|
45
|
+
|
|
46
|
+
Storage key: `(agent_id, thread_id, checkpoint_ns)`.
|
|
47
|
+
|
|
48
|
+
## Session-id-as-thread-id caveats
|
|
49
|
+
|
|
50
|
+
When using the Tikuu session UUID as `thread_id`:
|
|
51
|
+
|
|
52
|
+
- Omitting `X-Session-Id` on a later call mints a new session → new empty thread.
|
|
53
|
+
- One session ⇒ one LangGraph thread ⇒ one container `{agent_id}--{session_id}`.
|
|
54
|
+
- Concurrent invokes on the same session race (last snapshot wins); use one
|
|
55
|
+
in-flight invoke per session.
|
|
56
|
+
- Custom `thread_id` values are allowed in user code.
|
|
57
|
+
|
|
58
|
+
## V1 limitations
|
|
59
|
+
|
|
60
|
+
- **Snapshot-only** storage (intentional).
|
|
61
|
+
- **`put_writes` is a no-op** so LangGraph does not crash; interrupt/resume
|
|
62
|
+
and mid-step crash recovery are not supported yet.
|
|
63
|
+
- Metadata `filter` on `list` is ignored.
|
|
64
|
+
- No auth headers.
|
|
65
|
+
|
|
66
|
+
## Deferred work
|
|
67
|
+
|
|
68
|
+
- **TODO(auth):** Send session token header when API server injects one.
|
|
69
|
+
- **TODO(writes):** Persist pending writes for interrupt/resume.
|
|
70
|
+
- **TODO(actor_id):** Optional end-user / tenant key.
|
|
71
|
+
- **TODO(legacy):** Non-snapshot event-log formats.
|
|
72
|
+
- **TODO(serde):** Prefer JSON-only serde if pickle via JsonPlus is undesirable.
|
|
73
|
+
|
|
74
|
+
## Docs
|
|
75
|
+
|
|
76
|
+
- [Usage](docs/USAGE.md)
|
|
77
|
+
- [Architecture](docs/ARCHITECTURE.md)
|
|
78
|
+
- [End-to-end smoke path](../docs/MEMORY_SMOKE.md)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tikuu-memory"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Tikuu memory SDK: HTTP client and LangGraph snapshot checkpointer"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"httpx>=0.28.0",
|
|
9
|
+
"langgraph>=1.0.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[[project.authors]]
|
|
13
|
+
name = "Utkarsh Tiwari"
|
|
14
|
+
email = "tiwariutkarsh66@gmail.com"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.12.1,<0.13.0"]
|
|
18
|
+
build-backend = "uv_build"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=8.0.0",
|
|
23
|
+
"pytest-asyncio>=0.24.0",
|
|
24
|
+
"respx>=0.21.0",
|
|
25
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tikuu-memory"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Tikuu memory SDK: HTTP client and LangGraph snapshot checkpointer"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Utkarsh Tiwari", email = "tiwariutkarsh66@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"httpx>=0.28.0",
|
|
12
|
+
"langgraph>=1.0.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.12.1,<0.13.0"]
|
|
17
|
+
build-backend = "uv_build"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=8.0.0",
|
|
22
|
+
"pytest-asyncio>=0.24.0",
|
|
23
|
+
"respx>=0.21.0",
|
|
24
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Tikuu memory SDK: HTTP client and LangGraph snapshot checkpointer."""
|
|
2
|
+
|
|
3
|
+
from tikuu_memory.client import TikuuMemoryClient
|
|
4
|
+
from tikuu_memory.errors import (
|
|
5
|
+
MemoryClientError,
|
|
6
|
+
MemoryConfigurationError,
|
|
7
|
+
MemoryNotFoundError,
|
|
8
|
+
)
|
|
9
|
+
from tikuu_memory.saver import TikuuMemorySaver
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"MemoryClientError",
|
|
13
|
+
"MemoryConfigurationError",
|
|
14
|
+
"MemoryNotFoundError",
|
|
15
|
+
"TikuuMemoryClient",
|
|
16
|
+
"TikuuMemorySaver",
|
|
17
|
+
]
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""HTTP client for tikuu-memory-service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from tikuu_memory.config import MemorySettings, settings_from_env
|
|
10
|
+
from tikuu_memory.errors import MemoryClientError, MemoryNotFoundError
|
|
11
|
+
from tikuu_memory.models import CheckpointSnapshot
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TikuuMemoryClient:
|
|
15
|
+
"""Thin sync/async HTTP client for checkpoint snapshot routes.
|
|
16
|
+
|
|
17
|
+
TODO(auth): Send a session token header when tikuu-api-server starts
|
|
18
|
+
injecting one into agent containers.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
base_url: str,
|
|
25
|
+
agent_id: str,
|
|
26
|
+
timeout: float = 30.0,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.base_url = base_url.rstrip("/")
|
|
29
|
+
self.agent_id = agent_id
|
|
30
|
+
self._timeout = timeout
|
|
31
|
+
self._sync: httpx.Client | None = None
|
|
32
|
+
self._async: httpx.AsyncClient | None = None
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_env(cls, environ: dict[str, str] | None = None) -> TikuuMemoryClient:
|
|
36
|
+
"""Build a client from ``TIKUU_*`` environment variables."""
|
|
37
|
+
settings = settings_from_env(environ)
|
|
38
|
+
return cls.from_settings(settings)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_settings(cls, settings: MemorySettings) -> TikuuMemoryClient:
|
|
42
|
+
"""Build a client from resolved settings."""
|
|
43
|
+
return cls(base_url=settings.base_url, agent_id=settings.agent_id)
|
|
44
|
+
|
|
45
|
+
def _sync_client(self) -> httpx.Client:
|
|
46
|
+
if self._sync is None:
|
|
47
|
+
self._sync = httpx.Client(
|
|
48
|
+
base_url=self.base_url,
|
|
49
|
+
timeout=self._timeout,
|
|
50
|
+
)
|
|
51
|
+
return self._sync
|
|
52
|
+
|
|
53
|
+
def _async_client(self) -> httpx.AsyncClient:
|
|
54
|
+
if self._async is None:
|
|
55
|
+
self._async = httpx.AsyncClient(
|
|
56
|
+
base_url=self.base_url,
|
|
57
|
+
timeout=self._timeout,
|
|
58
|
+
)
|
|
59
|
+
return self._async
|
|
60
|
+
|
|
61
|
+
def close(self) -> None:
|
|
62
|
+
"""Close the sync HTTP client if open."""
|
|
63
|
+
if self._sync is not None:
|
|
64
|
+
self._sync.close()
|
|
65
|
+
self._sync = None
|
|
66
|
+
|
|
67
|
+
async def aclose(self) -> None:
|
|
68
|
+
"""Close the async HTTP client if open."""
|
|
69
|
+
if self._async is not None:
|
|
70
|
+
await self._async.aclose()
|
|
71
|
+
self._async = None
|
|
72
|
+
|
|
73
|
+
def put_checkpoint(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
thread_id: str,
|
|
77
|
+
checkpoint_ns: str,
|
|
78
|
+
checkpoint_id: str,
|
|
79
|
+
parent_checkpoint_id: str | None,
|
|
80
|
+
metadata_blob: str,
|
|
81
|
+
checkpoint_blob: str,
|
|
82
|
+
) -> CheckpointSnapshot:
|
|
83
|
+
"""Upsert one snapshot (sync)."""
|
|
84
|
+
response = self._sync_client().put(
|
|
85
|
+
f"/v1/agents/{self.agent_id}/threads/{thread_id}/checkpoints",
|
|
86
|
+
json={
|
|
87
|
+
"checkpoint_ns": checkpoint_ns,
|
|
88
|
+
"checkpoint_id": checkpoint_id,
|
|
89
|
+
"parent_checkpoint_id": parent_checkpoint_id,
|
|
90
|
+
"metadata_blob": metadata_blob,
|
|
91
|
+
"checkpoint_blob": checkpoint_blob,
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
return self._parse_snapshot(response)
|
|
95
|
+
|
|
96
|
+
async def aput_checkpoint(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
thread_id: str,
|
|
100
|
+
checkpoint_ns: str,
|
|
101
|
+
checkpoint_id: str,
|
|
102
|
+
parent_checkpoint_id: str | None,
|
|
103
|
+
metadata_blob: str,
|
|
104
|
+
checkpoint_blob: str,
|
|
105
|
+
) -> CheckpointSnapshot:
|
|
106
|
+
"""Upsert one snapshot (async)."""
|
|
107
|
+
response = await self._async_client().put(
|
|
108
|
+
f"/v1/agents/{self.agent_id}/threads/{thread_id}/checkpoints",
|
|
109
|
+
json={
|
|
110
|
+
"checkpoint_ns": checkpoint_ns,
|
|
111
|
+
"checkpoint_id": checkpoint_id,
|
|
112
|
+
"parent_checkpoint_id": parent_checkpoint_id,
|
|
113
|
+
"metadata_blob": metadata_blob,
|
|
114
|
+
"checkpoint_blob": checkpoint_blob,
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
return self._parse_snapshot(response)
|
|
118
|
+
|
|
119
|
+
def get_latest(
|
|
120
|
+
self,
|
|
121
|
+
*,
|
|
122
|
+
thread_id: str,
|
|
123
|
+
checkpoint_ns: str = "",
|
|
124
|
+
) -> CheckpointSnapshot:
|
|
125
|
+
"""Fetch the latest snapshot (sync)."""
|
|
126
|
+
response = self._sync_client().get(
|
|
127
|
+
f"/v1/agents/{self.agent_id}/threads/{thread_id}/checkpoints/latest",
|
|
128
|
+
params={"checkpoint_ns": checkpoint_ns},
|
|
129
|
+
)
|
|
130
|
+
return self._parse_snapshot(response)
|
|
131
|
+
|
|
132
|
+
async def aget_latest(
|
|
133
|
+
self,
|
|
134
|
+
*,
|
|
135
|
+
thread_id: str,
|
|
136
|
+
checkpoint_ns: str = "",
|
|
137
|
+
) -> CheckpointSnapshot:
|
|
138
|
+
"""Fetch the latest snapshot (async)."""
|
|
139
|
+
response = await self._async_client().get(
|
|
140
|
+
f"/v1/agents/{self.agent_id}/threads/{thread_id}/checkpoints/latest",
|
|
141
|
+
params={"checkpoint_ns": checkpoint_ns},
|
|
142
|
+
)
|
|
143
|
+
return self._parse_snapshot(response)
|
|
144
|
+
|
|
145
|
+
def get_checkpoint(
|
|
146
|
+
self,
|
|
147
|
+
*,
|
|
148
|
+
thread_id: str,
|
|
149
|
+
checkpoint_id: str,
|
|
150
|
+
checkpoint_ns: str = "",
|
|
151
|
+
) -> CheckpointSnapshot:
|
|
152
|
+
"""Fetch one snapshot by id (sync)."""
|
|
153
|
+
response = self._sync_client().get(
|
|
154
|
+
f"/v1/agents/{self.agent_id}/threads/{thread_id}/checkpoints/"
|
|
155
|
+
f"{checkpoint_id}",
|
|
156
|
+
params={"checkpoint_ns": checkpoint_ns},
|
|
157
|
+
)
|
|
158
|
+
return self._parse_snapshot(response)
|
|
159
|
+
|
|
160
|
+
async def aget_checkpoint(
|
|
161
|
+
self,
|
|
162
|
+
*,
|
|
163
|
+
thread_id: str,
|
|
164
|
+
checkpoint_id: str,
|
|
165
|
+
checkpoint_ns: str = "",
|
|
166
|
+
) -> CheckpointSnapshot:
|
|
167
|
+
"""Fetch one snapshot by id (async)."""
|
|
168
|
+
response = await self._async_client().get(
|
|
169
|
+
f"/v1/agents/{self.agent_id}/threads/{thread_id}/checkpoints/"
|
|
170
|
+
f"{checkpoint_id}",
|
|
171
|
+
params={"checkpoint_ns": checkpoint_ns},
|
|
172
|
+
)
|
|
173
|
+
return self._parse_snapshot(response)
|
|
174
|
+
|
|
175
|
+
def list_checkpoints(
|
|
176
|
+
self,
|
|
177
|
+
*,
|
|
178
|
+
thread_id: str,
|
|
179
|
+
checkpoint_ns: str = "",
|
|
180
|
+
before: str | None = None,
|
|
181
|
+
limit: int | None = None,
|
|
182
|
+
) -> list[CheckpointSnapshot]:
|
|
183
|
+
"""List snapshots newest-first (sync)."""
|
|
184
|
+
params: dict[str, Any] = {"checkpoint_ns": checkpoint_ns}
|
|
185
|
+
if before is not None:
|
|
186
|
+
params["before"] = before
|
|
187
|
+
if limit is not None:
|
|
188
|
+
params["limit"] = limit
|
|
189
|
+
response = self._sync_client().get(
|
|
190
|
+
f"/v1/agents/{self.agent_id}/threads/{thread_id}/checkpoints",
|
|
191
|
+
params=params,
|
|
192
|
+
)
|
|
193
|
+
return self._parse_list(response)
|
|
194
|
+
|
|
195
|
+
async def alist_checkpoints(
|
|
196
|
+
self,
|
|
197
|
+
*,
|
|
198
|
+
thread_id: str,
|
|
199
|
+
checkpoint_ns: str = "",
|
|
200
|
+
before: str | None = None,
|
|
201
|
+
limit: int | None = None,
|
|
202
|
+
) -> list[CheckpointSnapshot]:
|
|
203
|
+
"""List snapshots newest-first (async)."""
|
|
204
|
+
params: dict[str, Any] = {"checkpoint_ns": checkpoint_ns}
|
|
205
|
+
if before is not None:
|
|
206
|
+
params["before"] = before
|
|
207
|
+
if limit is not None:
|
|
208
|
+
params["limit"] = limit
|
|
209
|
+
response = await self._async_client().get(
|
|
210
|
+
f"/v1/agents/{self.agent_id}/threads/{thread_id}/checkpoints",
|
|
211
|
+
params=params,
|
|
212
|
+
)
|
|
213
|
+
return self._parse_list(response)
|
|
214
|
+
|
|
215
|
+
def delete_thread(
|
|
216
|
+
self,
|
|
217
|
+
*,
|
|
218
|
+
thread_id: str,
|
|
219
|
+
checkpoint_ns: str | None = None,
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Delete all snapshots for a thread (sync)."""
|
|
222
|
+
params: dict[str, Any] = {}
|
|
223
|
+
if checkpoint_ns is not None:
|
|
224
|
+
params["checkpoint_ns"] = checkpoint_ns
|
|
225
|
+
response = self._sync_client().delete(
|
|
226
|
+
f"/v1/agents/{self.agent_id}/threads/{thread_id}",
|
|
227
|
+
params=params,
|
|
228
|
+
)
|
|
229
|
+
self._raise_for_status(response, allow_204=True)
|
|
230
|
+
|
|
231
|
+
async def adelete_thread(
|
|
232
|
+
self,
|
|
233
|
+
*,
|
|
234
|
+
thread_id: str,
|
|
235
|
+
checkpoint_ns: str | None = None,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Delete all snapshots for a thread (async)."""
|
|
238
|
+
params: dict[str, Any] = {}
|
|
239
|
+
if checkpoint_ns is not None:
|
|
240
|
+
params["checkpoint_ns"] = checkpoint_ns
|
|
241
|
+
response = await self._async_client().delete(
|
|
242
|
+
f"/v1/agents/{self.agent_id}/threads/{thread_id}",
|
|
243
|
+
params=params,
|
|
244
|
+
)
|
|
245
|
+
self._raise_for_status(response, allow_204=True)
|
|
246
|
+
|
|
247
|
+
def _parse_snapshot(self, response: httpx.Response) -> CheckpointSnapshot:
|
|
248
|
+
self._raise_for_status(response)
|
|
249
|
+
return CheckpointSnapshot.from_api(response.json())
|
|
250
|
+
|
|
251
|
+
def _parse_list(self, response: httpx.Response) -> list[CheckpointSnapshot]:
|
|
252
|
+
self._raise_for_status(response)
|
|
253
|
+
payload = response.json()
|
|
254
|
+
items = payload.get("items", [])
|
|
255
|
+
return [CheckpointSnapshot.from_api(item) for item in items]
|
|
256
|
+
|
|
257
|
+
@staticmethod
|
|
258
|
+
def _raise_for_status(
|
|
259
|
+
response: httpx.Response,
|
|
260
|
+
*,
|
|
261
|
+
allow_204: bool = False,
|
|
262
|
+
) -> None:
|
|
263
|
+
if allow_204 and response.status_code == 204:
|
|
264
|
+
return
|
|
265
|
+
if response.status_code == 404:
|
|
266
|
+
detail = _detail(response)
|
|
267
|
+
raise MemoryNotFoundError(detail or "Checkpoint not found")
|
|
268
|
+
if response.is_success:
|
|
269
|
+
return
|
|
270
|
+
detail = _detail(response)
|
|
271
|
+
raise MemoryClientError(
|
|
272
|
+
detail or f"Memory service error (HTTP {response.status_code})",
|
|
273
|
+
status_code=response.status_code,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _detail(response: httpx.Response) -> str | None:
|
|
278
|
+
try:
|
|
279
|
+
data = response.json()
|
|
280
|
+
except Exception:
|
|
281
|
+
return response.text or None
|
|
282
|
+
if isinstance(data, dict) and "detail" in data:
|
|
283
|
+
return str(data["detail"])
|
|
284
|
+
return None
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Load memory client settings from the process environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from tikuu_memory.constants import ENV_AGENT_ID, ENV_MEMORY_SERVICE_URL
|
|
9
|
+
from tikuu_memory.errors import MemoryConfigurationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class MemorySettings:
|
|
14
|
+
"""Resolved settings for talking to tikuu-memory-service.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
base_url: Memory service origin without a trailing slash.
|
|
18
|
+
agent_id: Agent identity used as the storage partition key.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
base_url: str
|
|
22
|
+
agent_id: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def settings_from_env(
|
|
26
|
+
environ: dict[str, str] | None = None,
|
|
27
|
+
) -> MemorySettings:
|
|
28
|
+
"""Read ``TIKUU_MEMORY_SERVICE_URL`` and ``TIKUU_AGENT_ID``.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
environ: Mapping to read; defaults to ``os.environ``.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
MemoryConfigurationError: Either value is missing or blank.
|
|
35
|
+
"""
|
|
36
|
+
env = environ if environ is not None else os.environ
|
|
37
|
+
raw_url = (env.get(ENV_MEMORY_SERVICE_URL) or "").strip()
|
|
38
|
+
agent_id = (env.get(ENV_AGENT_ID) or "").strip()
|
|
39
|
+
if not raw_url:
|
|
40
|
+
raise MemoryConfigurationError(
|
|
41
|
+
f"{ENV_MEMORY_SERVICE_URL} is required and must be non-empty"
|
|
42
|
+
)
|
|
43
|
+
if not agent_id:
|
|
44
|
+
raise MemoryConfigurationError(
|
|
45
|
+
f"{ENV_AGENT_ID} is required and must be non-empty"
|
|
46
|
+
)
|
|
47
|
+
return MemorySettings(base_url=raw_url.rstrip("/"), agent_id=agent_id)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Errors raised by the tikuu-memory SDK."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MemoryConfigurationError(Exception):
|
|
5
|
+
"""Raised when required memory configuration is missing or invalid."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MemoryClientError(Exception):
|
|
9
|
+
"""Raised when the memory service returns an unexpected HTTP error."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
|
12
|
+
self.status_code = status_code
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MemoryNotFoundError(Exception):
|
|
17
|
+
"""Raised when a requested checkpoint does not exist (HTTP 404)."""
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Dataclasses mirroring memory-service checkpoint payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class CheckpointSnapshot:
|
|
13
|
+
"""One checkpoint snapshot as returned by the memory service."""
|
|
14
|
+
|
|
15
|
+
id: UUID
|
|
16
|
+
agent_id: str
|
|
17
|
+
thread_id: str
|
|
18
|
+
checkpoint_ns: str
|
|
19
|
+
checkpoint_id: str
|
|
20
|
+
parent_checkpoint_id: str | None
|
|
21
|
+
metadata_blob: str
|
|
22
|
+
checkpoint_blob: str
|
|
23
|
+
created_at: datetime
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_api(cls, data: dict[str, Any]) -> CheckpointSnapshot:
|
|
27
|
+
"""Build a snapshot from a decoded JSON object."""
|
|
28
|
+
created_raw = data["created_at"]
|
|
29
|
+
if isinstance(created_raw, str):
|
|
30
|
+
created_at = datetime.fromisoformat(created_raw.replace("Z", "+00:00"))
|
|
31
|
+
else:
|
|
32
|
+
created_at = created_raw
|
|
33
|
+
return cls(
|
|
34
|
+
id=UUID(str(data["id"])),
|
|
35
|
+
agent_id=str(data["agent_id"]),
|
|
36
|
+
thread_id=str(data["thread_id"]),
|
|
37
|
+
checkpoint_ns=str(data.get("checkpoint_ns", "")),
|
|
38
|
+
checkpoint_id=str(data["checkpoint_id"]),
|
|
39
|
+
parent_checkpoint_id=(
|
|
40
|
+
str(data["parent_checkpoint_id"])
|
|
41
|
+
if data.get("parent_checkpoint_id") is not None
|
|
42
|
+
else None
|
|
43
|
+
),
|
|
44
|
+
metadata_blob=str(data["metadata_blob"]),
|
|
45
|
+
checkpoint_blob=str(data["checkpoint_blob"]),
|
|
46
|
+
created_at=created_at,
|
|
47
|
+
)
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""LangGraph ``BaseCheckpointSaver`` backed by tikuu-memory-service.
|
|
2
|
+
|
|
3
|
+
V1 is snapshot-only: each ``put`` stores a complete checkpoint including
|
|
4
|
+
``channel_values``. Pending writes are not persisted.
|
|
5
|
+
|
|
6
|
+
TODO(writes): Persist ``put_writes`` for interrupt/resume and mid-step
|
|
7
|
+
crash recovery. The no-op implementation exists so LangGraph's default
|
|
8
|
+
durability does not crash.
|
|
9
|
+
|
|
10
|
+
TODO(actor_id): Optional end-user / tenant partition key.
|
|
11
|
+
|
|
12
|
+
TODO(legacy): Non-snapshot event-log reconstruction.
|
|
13
|
+
|
|
14
|
+
TODO(auth): Forward session token header via the HTTP client.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import random
|
|
20
|
+
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from langchain_core.runnables import RunnableConfig
|
|
24
|
+
from langgraph.checkpoint.base import (
|
|
25
|
+
BaseCheckpointSaver,
|
|
26
|
+
ChannelVersions,
|
|
27
|
+
Checkpoint,
|
|
28
|
+
CheckpointMetadata,
|
|
29
|
+
CheckpointTuple,
|
|
30
|
+
SerializerProtocol,
|
|
31
|
+
get_checkpoint_id,
|
|
32
|
+
get_checkpoint_metadata,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from tikuu_memory.client import TikuuMemoryClient
|
|
36
|
+
from tikuu_memory.errors import MemoryConfigurationError, MemoryNotFoundError
|
|
37
|
+
from tikuu_memory.models import CheckpointSnapshot
|
|
38
|
+
from tikuu_memory.serde import SnapshotSerde
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TikuuMemorySaver(BaseCheckpointSaver[str]):
|
|
42
|
+
"""Snapshot checkpointer that stores state in tikuu-memory-service."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
client: TikuuMemoryClient,
|
|
47
|
+
*,
|
|
48
|
+
serde: SerializerProtocol | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
super().__init__(serde=serde)
|
|
51
|
+
self.client = client
|
|
52
|
+
self._snapshot_serde = SnapshotSerde(self.serde)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_env(
|
|
56
|
+
cls,
|
|
57
|
+
environ: dict[str, str] | None = None,
|
|
58
|
+
*,
|
|
59
|
+
serde: SerializerProtocol | None = None,
|
|
60
|
+
) -> TikuuMemorySaver:
|
|
61
|
+
"""Build a saver from ``TIKUU_MEMORY_SERVICE_URL`` and ``TIKUU_AGENT_ID``.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
MemoryConfigurationError: Required env vars are missing/blank.
|
|
65
|
+
"""
|
|
66
|
+
return cls(TikuuMemoryClient.from_env(environ), serde=serde)
|
|
67
|
+
|
|
68
|
+
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
|
|
69
|
+
"""Load a checkpoint tuple (exact id or latest)."""
|
|
70
|
+
thread_id, checkpoint_ns, checkpoint_id = _parse_config(config)
|
|
71
|
+
try:
|
|
72
|
+
if checkpoint_id:
|
|
73
|
+
snapshot = self.client.get_checkpoint(
|
|
74
|
+
thread_id=thread_id,
|
|
75
|
+
checkpoint_id=checkpoint_id,
|
|
76
|
+
checkpoint_ns=checkpoint_ns,
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
snapshot = self.client.get_latest(
|
|
80
|
+
thread_id=thread_id,
|
|
81
|
+
checkpoint_ns=checkpoint_ns,
|
|
82
|
+
)
|
|
83
|
+
except MemoryNotFoundError:
|
|
84
|
+
return None
|
|
85
|
+
return self._to_tuple(snapshot, thread_id=thread_id, checkpoint_ns=checkpoint_ns)
|
|
86
|
+
|
|
87
|
+
def list(
|
|
88
|
+
self,
|
|
89
|
+
config: RunnableConfig | None,
|
|
90
|
+
*,
|
|
91
|
+
filter: dict[str, Any] | None = None,
|
|
92
|
+
before: RunnableConfig | None = None,
|
|
93
|
+
limit: int | None = None,
|
|
94
|
+
) -> Iterator[CheckpointTuple]:
|
|
95
|
+
"""List checkpoints newest-first.
|
|
96
|
+
|
|
97
|
+
Metadata ``filter`` is ignored in v1 (documented; does not raise).
|
|
98
|
+
"""
|
|
99
|
+
del filter # unsupported in v1
|
|
100
|
+
if config is None:
|
|
101
|
+
return
|
|
102
|
+
thread_id, checkpoint_ns, _ = _parse_config(config)
|
|
103
|
+
before_id = get_checkpoint_id(before) if before else None
|
|
104
|
+
snapshots = self.client.list_checkpoints(
|
|
105
|
+
thread_id=thread_id,
|
|
106
|
+
checkpoint_ns=checkpoint_ns,
|
|
107
|
+
before=before_id,
|
|
108
|
+
limit=limit,
|
|
109
|
+
)
|
|
110
|
+
for snapshot in snapshots:
|
|
111
|
+
yield self._to_tuple(
|
|
112
|
+
snapshot,
|
|
113
|
+
thread_id=thread_id,
|
|
114
|
+
checkpoint_ns=checkpoint_ns,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def put(
|
|
118
|
+
self,
|
|
119
|
+
config: RunnableConfig,
|
|
120
|
+
checkpoint: Checkpoint,
|
|
121
|
+
metadata: CheckpointMetadata,
|
|
122
|
+
new_versions: ChannelVersions,
|
|
123
|
+
) -> RunnableConfig:
|
|
124
|
+
"""Persist a complete checkpoint snapshot."""
|
|
125
|
+
del new_versions # snapshot stores full channel_values
|
|
126
|
+
thread_id, checkpoint_ns, parent_checkpoint_id = _parse_config(config)
|
|
127
|
+
checkpoint_id = checkpoint["id"]
|
|
128
|
+
metadata_blob = self._snapshot_serde.dumps_blob(
|
|
129
|
+
dict(get_checkpoint_metadata(config, metadata))
|
|
130
|
+
)
|
|
131
|
+
checkpoint_blob = self._snapshot_serde.dumps_blob(dict(checkpoint))
|
|
132
|
+
self.client.put_checkpoint(
|
|
133
|
+
thread_id=thread_id,
|
|
134
|
+
checkpoint_ns=checkpoint_ns,
|
|
135
|
+
checkpoint_id=checkpoint_id,
|
|
136
|
+
parent_checkpoint_id=parent_checkpoint_id,
|
|
137
|
+
metadata_blob=metadata_blob,
|
|
138
|
+
checkpoint_blob=checkpoint_blob,
|
|
139
|
+
)
|
|
140
|
+
return {
|
|
141
|
+
"configurable": {
|
|
142
|
+
"thread_id": thread_id,
|
|
143
|
+
"checkpoint_ns": checkpoint_ns,
|
|
144
|
+
"checkpoint_id": checkpoint_id,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
def put_writes(
|
|
149
|
+
self,
|
|
150
|
+
config: RunnableConfig,
|
|
151
|
+
writes: Sequence[tuple[str, Any]],
|
|
152
|
+
task_id: str,
|
|
153
|
+
task_path: str = "",
|
|
154
|
+
) -> None:
|
|
155
|
+
"""No-op in v1.
|
|
156
|
+
|
|
157
|
+
TODO(writes): Persist pending writes for interrupt/resume.
|
|
158
|
+
"""
|
|
159
|
+
del config, writes, task_id, task_path
|
|
160
|
+
|
|
161
|
+
def delete_thread(self, thread_id: str) -> None:
|
|
162
|
+
"""Delete all snapshots for ``thread_id`` under this agent."""
|
|
163
|
+
self.client.delete_thread(thread_id=thread_id)
|
|
164
|
+
|
|
165
|
+
def get_next_version(
|
|
166
|
+
self,
|
|
167
|
+
current: str | int | None,
|
|
168
|
+
channel: None = None,
|
|
169
|
+
) -> str:
|
|
170
|
+
"""Generate the next monotonic channel version string."""
|
|
171
|
+
del channel
|
|
172
|
+
if current is None:
|
|
173
|
+
current_v = 0
|
|
174
|
+
elif isinstance(current, int):
|
|
175
|
+
current_v = current
|
|
176
|
+
else:
|
|
177
|
+
current_v = int(str(current).split(".")[0])
|
|
178
|
+
next_v = current_v + 1
|
|
179
|
+
next_h = random.random()
|
|
180
|
+
return f"{next_v:032}.{next_h:016}"
|
|
181
|
+
|
|
182
|
+
async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
|
|
183
|
+
thread_id, checkpoint_ns, checkpoint_id = _parse_config(config)
|
|
184
|
+
try:
|
|
185
|
+
if checkpoint_id:
|
|
186
|
+
snapshot = await self.client.aget_checkpoint(
|
|
187
|
+
thread_id=thread_id,
|
|
188
|
+
checkpoint_id=checkpoint_id,
|
|
189
|
+
checkpoint_ns=checkpoint_ns,
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
snapshot = await self.client.aget_latest(
|
|
193
|
+
thread_id=thread_id,
|
|
194
|
+
checkpoint_ns=checkpoint_ns,
|
|
195
|
+
)
|
|
196
|
+
except MemoryNotFoundError:
|
|
197
|
+
return None
|
|
198
|
+
return self._to_tuple(snapshot, thread_id=thread_id, checkpoint_ns=checkpoint_ns)
|
|
199
|
+
|
|
200
|
+
async def alist(
|
|
201
|
+
self,
|
|
202
|
+
config: RunnableConfig | None,
|
|
203
|
+
*,
|
|
204
|
+
filter: dict[str, Any] | None = None,
|
|
205
|
+
before: RunnableConfig | None = None,
|
|
206
|
+
limit: int | None = None,
|
|
207
|
+
) -> AsyncIterator[CheckpointTuple]:
|
|
208
|
+
del filter
|
|
209
|
+
if config is None:
|
|
210
|
+
return
|
|
211
|
+
thread_id, checkpoint_ns, _ = _parse_config(config)
|
|
212
|
+
before_id = get_checkpoint_id(before) if before else None
|
|
213
|
+
snapshots = await self.client.alist_checkpoints(
|
|
214
|
+
thread_id=thread_id,
|
|
215
|
+
checkpoint_ns=checkpoint_ns,
|
|
216
|
+
before=before_id,
|
|
217
|
+
limit=limit,
|
|
218
|
+
)
|
|
219
|
+
for snapshot in snapshots:
|
|
220
|
+
yield self._to_tuple(
|
|
221
|
+
snapshot,
|
|
222
|
+
thread_id=thread_id,
|
|
223
|
+
checkpoint_ns=checkpoint_ns,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
async def aput(
|
|
227
|
+
self,
|
|
228
|
+
config: RunnableConfig,
|
|
229
|
+
checkpoint: Checkpoint,
|
|
230
|
+
metadata: CheckpointMetadata,
|
|
231
|
+
new_versions: ChannelVersions,
|
|
232
|
+
) -> RunnableConfig:
|
|
233
|
+
del new_versions
|
|
234
|
+
thread_id, checkpoint_ns, parent_checkpoint_id = _parse_config(config)
|
|
235
|
+
checkpoint_id = checkpoint["id"]
|
|
236
|
+
metadata_blob = self._snapshot_serde.dumps_blob(
|
|
237
|
+
dict(get_checkpoint_metadata(config, metadata))
|
|
238
|
+
)
|
|
239
|
+
checkpoint_blob = self._snapshot_serde.dumps_blob(dict(checkpoint))
|
|
240
|
+
await self.client.aput_checkpoint(
|
|
241
|
+
thread_id=thread_id,
|
|
242
|
+
checkpoint_ns=checkpoint_ns,
|
|
243
|
+
checkpoint_id=checkpoint_id,
|
|
244
|
+
parent_checkpoint_id=parent_checkpoint_id,
|
|
245
|
+
metadata_blob=metadata_blob,
|
|
246
|
+
checkpoint_blob=checkpoint_blob,
|
|
247
|
+
)
|
|
248
|
+
return {
|
|
249
|
+
"configurable": {
|
|
250
|
+
"thread_id": thread_id,
|
|
251
|
+
"checkpoint_ns": checkpoint_ns,
|
|
252
|
+
"checkpoint_id": checkpoint_id,
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async def aput_writes(
|
|
257
|
+
self,
|
|
258
|
+
config: RunnableConfig,
|
|
259
|
+
writes: Sequence[tuple[str, Any]],
|
|
260
|
+
task_id: str,
|
|
261
|
+
task_path: str = "",
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Async no-op; see ``put_writes``."""
|
|
264
|
+
del config, writes, task_id, task_path
|
|
265
|
+
|
|
266
|
+
async def adelete_thread(self, thread_id: str) -> None:
|
|
267
|
+
await self.client.adelete_thread(thread_id=thread_id)
|
|
268
|
+
|
|
269
|
+
def _to_tuple(
|
|
270
|
+
self,
|
|
271
|
+
snapshot: CheckpointSnapshot,
|
|
272
|
+
*,
|
|
273
|
+
thread_id: str,
|
|
274
|
+
checkpoint_ns: str,
|
|
275
|
+
) -> CheckpointTuple:
|
|
276
|
+
checkpoint = self._snapshot_serde.loads_blob(snapshot.checkpoint_blob)
|
|
277
|
+
metadata = self._snapshot_serde.loads_blob(snapshot.metadata_blob)
|
|
278
|
+
parent_config = None
|
|
279
|
+
if snapshot.parent_checkpoint_id:
|
|
280
|
+
parent_config = {
|
|
281
|
+
"configurable": {
|
|
282
|
+
"thread_id": thread_id,
|
|
283
|
+
"checkpoint_ns": checkpoint_ns,
|
|
284
|
+
"checkpoint_id": snapshot.parent_checkpoint_id,
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return CheckpointTuple(
|
|
288
|
+
config={
|
|
289
|
+
"configurable": {
|
|
290
|
+
"thread_id": thread_id,
|
|
291
|
+
"checkpoint_ns": checkpoint_ns,
|
|
292
|
+
"checkpoint_id": snapshot.checkpoint_id,
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
checkpoint=checkpoint,
|
|
296
|
+
metadata=metadata,
|
|
297
|
+
parent_config=parent_config,
|
|
298
|
+
pending_writes=[],
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _parse_config(config: RunnableConfig) -> tuple[str, str, str | None]:
|
|
303
|
+
"""Extract thread_id, checkpoint_ns, and optional checkpoint_id."""
|
|
304
|
+
configurable = config.get("configurable") or {}
|
|
305
|
+
thread_id = configurable.get("thread_id")
|
|
306
|
+
if not thread_id:
|
|
307
|
+
raise MemoryConfigurationError(
|
|
308
|
+
"RunnableConfig must contain configurable.thread_id"
|
|
309
|
+
)
|
|
310
|
+
checkpoint_ns = configurable.get("checkpoint_ns") or ""
|
|
311
|
+
checkpoint_id = configurable.get("checkpoint_id")
|
|
312
|
+
return str(thread_id), str(checkpoint_ns), (
|
|
313
|
+
str(checkpoint_id) if checkpoint_id else None
|
|
314
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Serialize checkpoint and metadata values for opaque blob storage.
|
|
2
|
+
|
|
3
|
+
Uses LangGraph's ``JsonPlusSerializer`` (typed base64 inside a small JSON
|
|
4
|
+
envelope), matching the AgentCore checkpointer approach.
|
|
5
|
+
|
|
6
|
+
TODO(serde): JsonPlus can embed pickle for some Python types. Prefer a
|
|
7
|
+
JSON-only serializer later if agents only need standard LangGraph state.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from langgraph.checkpoint.serde.base import SerializerProtocol
|
|
17
|
+
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SnapshotSerde:
|
|
21
|
+
"""Encode/decode checkpoint and metadata blobs for the memory service."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, serde: SerializerProtocol | None = None) -> None:
|
|
24
|
+
self.serde = serde or JsonPlusSerializer()
|
|
25
|
+
|
|
26
|
+
def dumps_blob(self, value: Any) -> str:
|
|
27
|
+
"""Serialize ``value`` to a JSON string for ``*_blob`` columns."""
|
|
28
|
+
type_tag, binary_data = self.serde.dumps_typed(value)
|
|
29
|
+
payload = {
|
|
30
|
+
"type": type_tag,
|
|
31
|
+
"data": base64.b64encode(binary_data).decode("utf-8"),
|
|
32
|
+
}
|
|
33
|
+
return json.dumps(payload)
|
|
34
|
+
|
|
35
|
+
def loads_blob(self, blob: str) -> Any:
|
|
36
|
+
"""Deserialize a JSON blob string produced by ``dumps_blob``."""
|
|
37
|
+
payload = json.loads(blob)
|
|
38
|
+
type_tag = payload["type"]
|
|
39
|
+
binary_data = base64.b64decode(payload["data"])
|
|
40
|
+
return self.serde.loads_typed((type_tag, binary_data))
|