betterdb-agent-cache 0.4.0__py3-none-any.whl

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,361 @@
1
+ """LangGraph checkpoint saver adapter.
2
+
3
+ Implements LangGraph's ``BaseCheckpointSaver`` backed by the AgentCache
4
+ session tier. Works on vanilla Valkey 7+ — no modules, no RedisJSON, no
5
+ RediSearch required.
6
+
7
+ Storage layout in the session tier:
8
+ ``{name}:session:{thread_id}:checkpoint:{checkpoint_id}`` = JSON(CheckpointTuple)
9
+ ``{name}:session:{thread_id}:__checkpoint_latest`` = JSON(latest CheckpointTuple)
10
+ ``{name}:session:{thread_id}:writes:{ckpt_id}|{task_id}|{channel}|{idx}`` = JSON(value)
11
+
12
+ Known limitations:
13
+ - The general ``alist()`` path loads all checkpoint data for a thread before
14
+ filtering (via ``get_all()``), which refreshes TTL via the sliding window.
15
+ For typical agent deployments this is fine; for threads with thousands of
16
+ large checkpoints consider a dedicated Redis 8+ checkpoint backend.
17
+
18
+ Usage::
19
+
20
+ from betterdb_agent_cache.adapters.langgraph import BetterDBSaver
21
+
22
+ saver = BetterDBSaver(cache=agent_cache)
23
+ graph = builder.compile(checkpointer=saver)
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from collections import ChainMap
29
+ from typing import TYPE_CHECKING, Any, AsyncIterator, Optional
30
+ from urllib.parse import quote, unquote
31
+
32
+
33
+ def _make_serializable(obj: Any) -> Any:
34
+ """Recursively convert to JSON-safe types.
35
+
36
+ - ChainMap → dict
37
+ - Unserializable leaf values are dropped (returns sentinel and caller skips them)
38
+ """
39
+ if isinstance(obj, ChainMap):
40
+ return _make_serializable(dict(obj))
41
+ if isinstance(obj, dict):
42
+ out = {}
43
+ for k, v in obj.items():
44
+ converted = _make_serializable(v)
45
+ if converted is not _SKIP:
46
+ out[k] = converted
47
+ return out
48
+ if isinstance(obj, (list, tuple)):
49
+ return [v for v in (_make_serializable(i) for i in obj) if v is not _SKIP]
50
+ try:
51
+ json.dumps(obj)
52
+ return obj
53
+ except (TypeError, ValueError):
54
+ return _SKIP
55
+
56
+
57
+ _SKIP = object() # sentinel for values that cannot be serialised
58
+
59
+
60
+ def _clean_config(config: Any) -> dict:
61
+ """Return a storable copy of a LangGraph RunnableConfig.
62
+
63
+ Strips internal ``__pregel_*`` keys from ``configurable`` — these hold
64
+ non-serialisable runtime objects (Runtime, callbacks, etc.) that must not
65
+ be persisted.
66
+ """
67
+ if not isinstance(config, dict):
68
+ return {}
69
+ configurable = dict(config.get("configurable") or {})
70
+ for key in list(configurable):
71
+ if key.startswith("__pregel_"):
72
+ del configurable[key]
73
+ return {**{k: v for k, v in config.items() if k != "configurable"},
74
+ "configurable": configurable}
75
+
76
+ try:
77
+ from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointTuple
78
+ from langchain_core.runnables import RunnableConfig
79
+ _LANGGRAPH_AVAILABLE = True
80
+ except ImportError:
81
+ _LANGGRAPH_AVAILABLE = False
82
+ BaseCheckpointSaver = object # type: ignore[assignment,misc]
83
+ CheckpointTuple = Any # type: ignore[misc,assignment]
84
+ RunnableConfig = Any # type: ignore[misc,assignment]
85
+
86
+ from ..errors import AgentCacheUsageError
87
+
88
+ if TYPE_CHECKING:
89
+ from ..agent_cache import AgentCache
90
+
91
+ _LATEST_FIELD = "__checkpoint_latest"
92
+
93
+
94
+ class BetterDBSaver(BaseCheckpointSaver):
95
+ """LangGraph checkpoint saver backed by AgentCache session storage."""
96
+
97
+ def __init__(self, cache: "AgentCache") -> None:
98
+ if not _LANGGRAPH_AVAILABLE:
99
+ raise ImportError(
100
+ "langgraph and langchain-core are required for BetterDBSaver. "
101
+ "Install them with: pip install betterdb-agent-cache[langgraph]"
102
+ )
103
+ super().__init__()
104
+ self._cache = cache
105
+
106
+ # ── Read ──────────────────────────────────────────────────────────────
107
+
108
+ async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
109
+ thread_id: str = (config.get("configurable") or {}).get("thread_id", "")
110
+ if not thread_id:
111
+ return None
112
+
113
+ checkpoint_id: str | None = (config.get("configurable") or {}).get("checkpoint_id")
114
+ field = f"checkpoint:{checkpoint_id}" if checkpoint_id else _LATEST_FIELD
115
+
116
+ data = await self._cache.session.get(thread_id, field)
117
+ if not data:
118
+ return None
119
+
120
+ try:
121
+ tuple_data: dict[str, Any] = json.loads(data)
122
+ except (json.JSONDecodeError, ValueError):
123
+ return None
124
+
125
+ resolved_id = checkpoint_id or (tuple_data.get("checkpoint") or {}).get("id")
126
+ if resolved_id:
127
+ write_fields = await self._cache.session.scan_fields_by_prefix(
128
+ thread_id, f"writes:{quote(resolved_id, safe='')}|"
129
+ )
130
+ pending = _extract_pending_writes(write_fields, resolved_id)
131
+ if pending:
132
+ tuple_data["pending_writes"] = pending
133
+
134
+ return _dict_to_tuple(tuple_data)
135
+
136
+ async def alist( # type: ignore[override]
137
+ self,
138
+ config: Optional[RunnableConfig],
139
+ *,
140
+ filter: Optional[dict[str, Any]] = None,
141
+ before: Optional[RunnableConfig] = None,
142
+ limit: Optional[int] = None,
143
+ ) -> AsyncIterator[CheckpointTuple]:
144
+ async for item in self._alist_impl(config, filter=filter, before=before, limit=limit):
145
+ yield item
146
+
147
+ async def _alist_impl(
148
+ self,
149
+ config: Optional[RunnableConfig],
150
+ *,
151
+ filter: Optional[dict[str, Any]] = None,
152
+ before: Optional[RunnableConfig] = None,
153
+ limit: Optional[int] = None,
154
+ ) -> AsyncIterator[CheckpointTuple]: # type: ignore[override]
155
+ thread_id: str = ((config or {}).get("configurable") or {}).get("thread_id", "")
156
+ if not thread_id:
157
+ return
158
+
159
+ # Fast path: limit=1, no before filter, no metadata filter → read the latest pointer directly
160
+ if limit == 1 and not before and not filter:
161
+ latest = await self._cache.session.get(thread_id, _LATEST_FIELD)
162
+ if latest:
163
+ try:
164
+ td: dict[str, Any] = json.loads(latest)
165
+ ckpt_id = (td.get("checkpoint") or {}).get("id")
166
+ if ckpt_id:
167
+ write_fields = await self._cache.session.scan_fields_by_prefix(
168
+ thread_id, f"writes:{quote(ckpt_id, safe='')}|"
169
+ )
170
+ pending = _extract_pending_writes(write_fields, ckpt_id)
171
+ if pending:
172
+ td["pending_writes"] = pending
173
+ yield _dict_to_tuple(td)
174
+ except (json.JSONDecodeError, ValueError):
175
+ pass
176
+ return
177
+
178
+ # General path: load all fields, partition into checkpoints and writes
179
+ all_fields = await self._cache.session.get_all(thread_id)
180
+ write_map: dict[str, str] = {}
181
+ checkpoints: list[dict[str, Any]] = []
182
+
183
+ for field_name, value in all_fields.items():
184
+ if field_name.startswith("writes:"):
185
+ write_map[field_name] = value
186
+ elif field_name.startswith("checkpoint:"):
187
+ try:
188
+ checkpoints.append(json.loads(value))
189
+ except (json.JSONDecodeError, ValueError):
190
+ pass
191
+
192
+ # Attach pending writes
193
+ for td in checkpoints:
194
+ ckpt_id = (td.get("checkpoint") or {}).get("id")
195
+ if ckpt_id:
196
+ pending = _extract_pending_writes(write_map, ckpt_id)
197
+ if pending:
198
+ td["pending_writes"] = pending
199
+
200
+ # Sort by timestamp descending
201
+ def _ts_key(td: dict[str, Any]) -> tuple[int, str]:
202
+ ts: str = (td.get("checkpoint") or {}).get("ts", "")
203
+ try:
204
+ from datetime import datetime, timezone
205
+ dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
206
+ return (1, dt.isoformat())
207
+ except (ValueError, AttributeError):
208
+ return (0, ts)
209
+
210
+ checkpoints.sort(key=_ts_key, reverse=True)
211
+
212
+ # Apply before filter
213
+ before_id: str | None = ((before or {}).get("configurable") or {}).get("checkpoint_id")
214
+ if before_id and not any((td.get("checkpoint") or {}).get("id") == before_id for td in checkpoints):
215
+ return
216
+
217
+ started = before_id is None
218
+ yielded = 0
219
+
220
+ for td in checkpoints:
221
+ if not started:
222
+ if (td.get("checkpoint") or {}).get("id") == before_id:
223
+ started = True
224
+ continue
225
+ if limit is not None and yielded >= limit:
226
+ break
227
+ # Apply metadata filter if provided
228
+ if filter:
229
+ meta = td.get("metadata") or {}
230
+ if not all(meta.get(k) == v for k, v in filter.items()):
231
+ continue
232
+ yield _dict_to_tuple(td)
233
+ yielded += 1
234
+
235
+ # ── Write ─────────────────────────────────────────────────────────────
236
+
237
+ async def aput(
238
+ self,
239
+ config: RunnableConfig,
240
+ checkpoint: Any,
241
+ metadata: Any,
242
+ new_versions: Any,
243
+ ) -> RunnableConfig:
244
+ thread_id = (config.get("configurable") or {}).get("thread_id")
245
+ if not thread_id:
246
+ raise AgentCacheUsageError("aput() requires config['configurable']['thread_id']")
247
+
248
+ checkpoint_id = checkpoint.get("id") if isinstance(checkpoint, dict) else getattr(checkpoint, "id", None)
249
+ parent_config = _clean_config(config)
250
+ parent_checkpoint_id = (parent_config.get("configurable") or {}).get("checkpoint_id")
251
+ clean = _clean_config(config)
252
+ clean.setdefault("configurable", {})["checkpoint_id"] = checkpoint_id
253
+ stored = {
254
+ "config": clean,
255
+ "checkpoint": checkpoint,
256
+ "metadata": metadata,
257
+ "parent_config": parent_config if parent_checkpoint_id else None,
258
+ }
259
+ serialised = json.dumps(_make_serializable(stored))
260
+
261
+ await self._cache.session.set(thread_id, f"checkpoint:{checkpoint_id}", serialised)
262
+ await self._cache.session.set(thread_id, _LATEST_FIELD, serialised)
263
+
264
+ return {
265
+ **config,
266
+ "configurable": {**(config.get("configurable") or {}), "checkpoint_id": checkpoint_id},
267
+ } # return original (with runtime objects) so LangGraph can continue using it
268
+
269
+ async def aput_writes(
270
+ self,
271
+ config: RunnableConfig,
272
+ writes: list[tuple[str, Any]],
273
+ task_id: str,
274
+ ) -> None:
275
+ thread_id = (config.get("configurable") or {}).get("thread_id")
276
+ checkpoint_id = (config.get("configurable") or {}).get("checkpoint_id")
277
+ if not thread_id or not checkpoint_id:
278
+ raise AgentCacheUsageError(
279
+ "aput_writes() requires both thread_id and checkpoint_id in config['configurable']"
280
+ )
281
+
282
+ encoded_ckpt = quote(checkpoint_id, safe="")
283
+ encoded_task = quote(task_id, safe="")
284
+ import asyncio
285
+
286
+ await asyncio.gather(*(
287
+ self._cache.session.set(
288
+ thread_id,
289
+ f"writes:{encoded_ckpt}|{encoded_task}|{quote(channel, safe='')}|{i}",
290
+ json.dumps(_make_serializable(value)),
291
+ )
292
+ for i, (channel, value) in enumerate(writes)
293
+ ))
294
+
295
+ # ── Delete ────────────────────────────────────────────────────────────
296
+
297
+ async def adelete_thread(self, thread_id: str) -> None:
298
+ await self._cache.session.destroy_thread(thread_id)
299
+
300
+ # ── Sync stubs (not supported) ────────────────────────────────────────
301
+
302
+ def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]: # type: ignore[override]
303
+ raise RuntimeError("BetterDBSaver is async-only. Use aget_tuple().")
304
+
305
+ def list(self, config: Any, **kwargs: Any) -> Any: # type: ignore[override]
306
+ raise RuntimeError("BetterDBSaver is async-only. Use alist().")
307
+
308
+ def put(self, *args: Any, **kwargs: Any) -> Any: # type: ignore[override]
309
+ raise RuntimeError("BetterDBSaver is async-only. Use aput().")
310
+
311
+ def put_writes(self, *args: Any, **kwargs: Any) -> None: # type: ignore[override]
312
+ raise RuntimeError("BetterDBSaver is async-only. Use aput_writes().")
313
+
314
+
315
+ # ── Helpers ───────────────────────────────────────────────────────────────────
316
+
317
+ def _extract_pending_writes(
318
+ all_fields: dict[str, str],
319
+ checkpoint_id: str,
320
+ ) -> list[tuple[str, str, Any]]:
321
+ """Reconstruct pending writes from session fields.
322
+
323
+ Key format: writes:{encoded_ckpt_id}|{encoded_task_id}|{encoded_channel}|{idx}
324
+ All components are URL-encoded; literal ``|`` inside values appears as ``%7C``.
325
+ """
326
+ prefix = f"writes:{quote(checkpoint_id, safe='')}|"
327
+ items: list[tuple[int, tuple[str, str, Any]]] = []
328
+
329
+ for field, raw_value in all_fields.items():
330
+ if not field.startswith(prefix):
331
+ continue
332
+ rest = field[len(prefix):]
333
+ parts = rest.split("|")
334
+ if len(parts) != 3:
335
+ continue
336
+ task_id = unquote(parts[0])
337
+ channel = unquote(parts[1])
338
+ try:
339
+ idx = int(parts[2])
340
+ value = json.loads(raw_value)
341
+ items.append((idx, (task_id, channel, value)))
342
+ except (ValueError, json.JSONDecodeError):
343
+ pass
344
+
345
+ items.sort(key=lambda x: x[0])
346
+ return [write for _, write in items]
347
+
348
+
349
+ def _dict_to_tuple(data: dict[str, Any]) -> Any:
350
+ """Convert a stored dict back to a CheckpointTuple."""
351
+ try:
352
+ return CheckpointTuple(
353
+ config=data.get("config"),
354
+ checkpoint=data.get("checkpoint"),
355
+ metadata=data.get("metadata"),
356
+ parent_config=data.get("parent_config"),
357
+ pending_writes=data.get("pending_writes"),
358
+ )
359
+ except Exception:
360
+ # Fallback: return as-is if CheckpointTuple constructor doesn't match
361
+ return data
@@ -0,0 +1,166 @@
1
+ """LlamaIndex adapter.
2
+
3
+ Converts a list of ``ChatMessage`` dicts (as used by LlamaIndex) into
4
+ ``LlmCacheParams``.
5
+
6
+ Usage::
7
+
8
+ from betterdb_agent_cache.adapters.llamaindex import prepare_params
9
+
10
+ params = await prepare_params(messages, model="gpt-4o")
11
+ result = await cache.llm.check(params)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ from ..normalizer import BinaryNormalizer, default_normalizer
20
+ from ..types import BinaryBlock, ContentBlock, LlmCacheParams, TextBlock, ToolCallBlock
21
+
22
+
23
+ @dataclass
24
+ class LlamaIndexPrepareOptions:
25
+ model: str = ""
26
+ normalizer: BinaryNormalizer = field(default_factory=lambda: default_normalizer)
27
+ temperature: float | None = None
28
+ top_p: float | None = None
29
+ max_tokens: int | None = None
30
+
31
+
32
+ def _parse_input(value: Any) -> Any:
33
+ if isinstance(value, str):
34
+ try:
35
+ return json.loads(value)
36
+ except (json.JSONDecodeError, ValueError):
37
+ return {"__raw": value}
38
+ return value
39
+
40
+
41
+ async def _normalize_detail(
42
+ part: dict[str, Any],
43
+ normalizer: BinaryNormalizer,
44
+ ) -> ContentBlock | None:
45
+ t = part.get("type")
46
+
47
+ if t == "text":
48
+ return {"type": "text", "text": part.get("text") or ""}
49
+
50
+ if t == "image_url" and part.get("image_url"):
51
+ url: str = part["image_url"]["url"]
52
+ media_type = "image/*"
53
+ if url.startswith("data:"):
54
+ semi = url.find(";")
55
+ if semi > 5:
56
+ media_type = url[5:semi]
57
+ source: dict[str, Any] = {"type": "base64", "data": url}
58
+ else:
59
+ source = {"type": "url", "url": url}
60
+ ref = await normalizer({"kind": "image", "source": source})
61
+ return {"type": "binary", "kind": "image", "mediaType": media_type, "ref": ref}
62
+
63
+ if t == "file" and part.get("data"):
64
+ ref = await normalizer({
65
+ "kind": "document",
66
+ "source": {"type": "base64", "data": part["data"]},
67
+ })
68
+ return {
69
+ "type": "binary", "kind": "document",
70
+ "mediaType": part.get("mime_type") or "application/octet-stream",
71
+ "ref": ref,
72
+ }
73
+
74
+ if t in ("audio", "image") and part.get("data"):
75
+ kind = "audio" if t == "audio" else "image"
76
+ ref = await normalizer({
77
+ "kind": kind,
78
+ "source": {"type": "base64", "data": part["data"]},
79
+ })
80
+ default_media = "audio/*" if kind == "audio" else "image/*"
81
+ return {
82
+ "type": "binary", "kind": kind,
83
+ "mediaType": part.get("mime_type") or default_media,
84
+ "ref": ref,
85
+ }
86
+
87
+ return None
88
+
89
+
90
+ async def prepare_params(
91
+ messages: list[dict[str, Any]],
92
+ opts: LlamaIndexPrepareOptions | None = None,
93
+ *,
94
+ model: str | None = None,
95
+ normalizer: BinaryNormalizer | None = None,
96
+ temperature: float | None = None,
97
+ top_p: float | None = None,
98
+ max_tokens: int | None = None,
99
+ ) -> LlmCacheParams:
100
+ """Normalise a LlamaIndex message list to ``LlmCacheParams``.
101
+
102
+ Either pass an ``LlamaIndexPrepareOptions`` instance or use the keyword
103
+ arguments directly::
104
+
105
+ params = await prepare_params(msgs, model="gpt-4o", temperature=0.7)
106
+ """
107
+ if opts is None:
108
+ opts = LlamaIndexPrepareOptions(
109
+ model=model or "",
110
+ normalizer=normalizer or default_normalizer,
111
+ temperature=temperature,
112
+ top_p=top_p,
113
+ max_tokens=max_tokens,
114
+ )
115
+
116
+ norm = opts.normalizer
117
+ out: list[Any] = []
118
+
119
+ for msg in messages:
120
+ options: dict[str, Any] = msg.get("options") or {}
121
+
122
+ if options.get("tool_result"):
123
+ tr = options["tool_result"]
124
+ out.append({
125
+ "role": "tool",
126
+ "toolCallId": tr["id"],
127
+ "content": [{"type": "text", "text": tr.get("result", "")}],
128
+ })
129
+ continue
130
+
131
+ raw_role: str = msg.get("role", "user")
132
+ role = "system" if raw_role in ("memory", "developer") else raw_role
133
+
134
+ blocks: list[ContentBlock] = []
135
+ content = msg.get("content")
136
+ if isinstance(content, str):
137
+ if content:
138
+ blocks.append({"type": "text", "text": content})
139
+ elif isinstance(content, list):
140
+ for part in content:
141
+ b = await _normalize_detail(part, norm)
142
+ if b is not None:
143
+ blocks.append(b)
144
+
145
+ tool_calls = options.get("tool_call")
146
+ if tool_calls:
147
+ calls = tool_calls if isinstance(tool_calls, list) else [tool_calls]
148
+ for tc in calls:
149
+ blocks.append({
150
+ "type": "tool_call",
151
+ "id": tc["id"],
152
+ "name": tc["name"],
153
+ "args": _parse_input(tc.get("input")),
154
+ })
155
+
156
+ out.append({"role": role, "content": blocks})
157
+
158
+ result: LlmCacheParams = {"model": opts.model, "messages": out}
159
+ if opts.temperature is not None:
160
+ result["temperature"] = opts.temperature
161
+ if opts.top_p is not None:
162
+ result["top_p"] = opts.top_p
163
+ if opts.max_tokens is not None:
164
+ result["max_tokens"] = opts.max_tokens
165
+
166
+ return result