deepagents-graph-memory 0.1.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,847 @@
1
+ # - Keeps graph items and their connections in LadybugDB, and finds them again when asked.
2
+ # - Keeps each workspace's context separate when a scope is set.
3
+ # - Tests: test_ladybug_integration.py checks saving and reading a connection;
4
+ # test_ladybug_search.py checks search, and test_scope.py checks separation.
5
+
6
+ """LadybugDB adapter for graph memory."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from collections.abc import Sequence
13
+ from contextlib import contextmanager
14
+ from functools import wraps
15
+ from pathlib import Path
16
+ from threading import RLock
17
+ from typing import Any, cast
18
+ from weakref import WeakValueDictionary
19
+
20
+ from deepagents_graph_memory.errors import GraphMemoryConfigurationError, GraphMemoryValidationError
21
+ from deepagents_graph_memory.paths import node_path, validate_identifier, validate_node_id
22
+ from deepagents_graph_memory.stores import (
23
+ EdgeResult,
24
+ GraphEdge,
25
+ GraphNode,
26
+ LimitedResult,
27
+ NeighborhoodResult,
28
+ Properties,
29
+ SearchItem,
30
+ SearchResult,
31
+ finding_observed_timestamp,
32
+ lexical_search_score,
33
+ node_search_text,
34
+ utc_now,
35
+ valid_finding_link,
36
+ validate_properties,
37
+ )
38
+
39
+ try:
40
+ import ladybug
41
+ except ImportError as exc: # pragma: no cover - exercised when package is absent
42
+ raise ImportError("LadybugDB support requires the `ladybug` package.") from exc
43
+
44
+ # Guard against multiple writable handles to one file in this process.
45
+ _disk_lock = RLock()
46
+ _open_disk_stores: WeakValueDictionary[tuple[object, ...], LadybugGraphStore] = WeakValueDictionary()
47
+
48
+
49
+ class _LadybugGraph:
50
+ """Minimal query and schema adapter over a `ladybug.Connection`.
51
+
52
+ Uses the native connection for query execution and schema reflection.
53
+ """
54
+
55
+ def __init__(self, database: Any) -> None:
56
+ self.conn = ladybug.Connection(database)
57
+ self.schema = ""
58
+ try:
59
+ self.refresh_schema()
60
+ except BaseException:
61
+ self.conn.close()
62
+ raise
63
+
64
+ @property
65
+ def get_schema(self) -> str:
66
+ """Return the reflected graph schema text."""
67
+ return self.schema
68
+
69
+ def query(self, query: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
70
+ """Execute a Cypher query and return rows as dictionaries."""
71
+ # Ladybug 0.20.3's implicit statement cache can crash on repeated writes.
72
+ statement = ladybug.PreparedStatement(self.conn, query, params) if params else None
73
+ try:
74
+ result = self.conn.execute(statement if statement is not None else query, params or {})
75
+ try:
76
+ column_names = result.get_column_names()
77
+ rows: list[dict[str, Any]] = []
78
+ while result.has_next():
79
+ rows.append(dict(zip(column_names, result.get_next(), strict=False)))
80
+ return rows
81
+ finally:
82
+ result.close()
83
+ finally:
84
+ if statement is not None:
85
+ statement.close()
86
+
87
+ def refresh_schema(self) -> None:
88
+ """Reflect node and relationship tables into a schema string.
89
+
90
+ Uses public Cypher introspection (`SHOW_TABLES`, `TABLE_INFO`,
91
+ `SHOW_CONNECTION`) rather than ladybug's private `_get_*` methods, so the
92
+ adapter does not depend on unstable internals.
93
+ """
94
+ tables = self.query("CALL SHOW_TABLES() RETURN *;")
95
+ node_labels = [row["name"] for row in tables if row["type"] == "NODE"]
96
+ rel_labels = [row["name"] for row in tables if row["type"] == "REL"]
97
+
98
+ node_properties = [{"properties": self._table_properties(label), "label": label} for label in node_labels]
99
+ rel_properties = [{"properties": self._table_properties(label), "label": label} for label in rel_labels]
100
+ relationships = [
101
+ f"(:{connection['source table name']})-[:{label}]->(:{connection['destination table name']})"
102
+ for label in rel_labels
103
+ for connection in self.query(f"CALL SHOW_CONNECTION('{label}') RETURN *;")
104
+ ]
105
+
106
+ self.schema = f"Node properties: {node_properties}\nRelationships properties: {rel_properties}\nRelationships: {relationships}\n"
107
+
108
+ def _table_properties(self, table: str) -> list[tuple[str, str]]:
109
+ """Return ``(name, type)`` pairs for a node or relationship table."""
110
+ return [(row["name"], row["type"]) for row in self.query(f"CALL TABLE_INFO('{table}') RETURN *;")]
111
+
112
+
113
+ def _locked(method: Any) -> Any:
114
+ @wraps(method)
115
+ def wrapper(self: LadybugGraphStore, *args: Any, **kwargs: Any) -> Any:
116
+ with self._lock:
117
+ self._require_open()
118
+ return method(self, *args, **kwargs)
119
+
120
+ return wrapper
121
+
122
+
123
+ def _atomic(method: Any) -> Any:
124
+ @wraps(method)
125
+ def wrapper(self: LadybugGraphStore, *args: Any, **kwargs: Any) -> Any:
126
+ with self.transaction():
127
+ return method(self, *args, **kwargs)
128
+
129
+ return wrapper
130
+
131
+
132
+ class LadybugGraphStore:
133
+ """Internal adapter for the native LadybugDB graph connection."""
134
+
135
+ def __init__(self, graph: Any, *, database: Any = None) -> None:
136
+ """Initialize the adapter.
137
+
138
+ Args:
139
+ graph: Query and schema adapter over a LadybugDB connection.
140
+ database: Database owned by this store, if created by a factory.
141
+ """
142
+ self.graph = graph
143
+ self._database = database
144
+ self._closed = False
145
+ self._disk_keys: tuple[tuple[object, ...], ...] = ()
146
+ self._fts_ready_labels: set[str] = set()
147
+ # ponytail: one store lock serializes shared-connection work; split connections if throughput matters.
148
+ self._lock = RLock()
149
+ self._transaction_depth = 0
150
+ self._rollback_only = False
151
+
152
+ @contextmanager
153
+ def transaction(self) -> Any:
154
+ """Serialize and atomically group writes on the shared LadybugDB connection."""
155
+ with self._lock:
156
+ self._require_open()
157
+ outermost = self._transaction_depth == 0
158
+ if outermost:
159
+ self._query("BEGIN TRANSACTION;", {})
160
+ self._rollback_only = False
161
+ self._transaction_depth += 1
162
+ try:
163
+ yield
164
+ except BaseException:
165
+ self._rollback_only = True
166
+ raise
167
+ else:
168
+ if outermost and self._rollback_only:
169
+ msg = "A nested graph write failed; transaction was rolled back."
170
+ raise GraphMemoryConfigurationError(msg)
171
+ finally:
172
+ self._transaction_depth -= 1
173
+ if outermost:
174
+ if self._rollback_only:
175
+ try:
176
+ self._query("ROLLBACK;", {})
177
+ except GraphMemoryConfigurationError:
178
+ pass # Preserve the original write failure if LadybugDB already aborted.
179
+ self._fts_ready_labels.clear()
180
+ else:
181
+ try:
182
+ self._query("COMMIT;", {})
183
+ except GraphMemoryConfigurationError:
184
+ try:
185
+ self._query("ROLLBACK;", {})
186
+ except GraphMemoryConfigurationError:
187
+ pass
188
+ self._fts_ready_labels.clear()
189
+ self._rollback_only = False
190
+ raise
191
+ self._rollback_only = False
192
+
193
+ @classmethod
194
+ def memory(cls) -> LadybugGraphStore:
195
+ """Create an in-memory LadybugDB graph store."""
196
+ return cls._open(":memory:")
197
+
198
+ @classmethod
199
+ def disk(cls, path: str | Path) -> LadybugGraphStore:
200
+ """Create or reopen a graph at an existing filesystem parent."""
201
+ if not isinstance(path, str | Path):
202
+ msg = "Persistent graph path must be a string or pathlib.Path."
203
+ raise GraphMemoryConfigurationError(msg)
204
+ raw = str(path)
205
+ if not raw.strip() or raw == ":memory:" or "\x00" in raw or re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", raw):
206
+ msg = "Persistent graph path must be a nonempty local filesystem path, not a URL or :memory:."
207
+ raise GraphMemoryConfigurationError(msg)
208
+ filename = Path(path)
209
+ if not filename.parent.is_dir():
210
+ msg = f"Persistent graph path parent does not exist: {filename.parent}"
211
+ raise GraphMemoryConfigurationError(msg)
212
+ if filename.is_dir():
213
+ msg = f"Persistent graph path points to a directory, expected a database file: {filename}"
214
+ raise GraphMemoryConfigurationError(msg)
215
+ try:
216
+ with _disk_lock:
217
+ resolved = filename.resolve()
218
+ keys: tuple[tuple[object, ...], ...] = (("path", str(resolved)),)
219
+ if resolved.exists():
220
+ info = resolved.stat()
221
+ keys += (("inode", info.st_dev, info.st_ino),)
222
+ if any(key in _open_disk_stores for key in keys):
223
+ msg = f"LadybugDB graph at path {filename} is already open in this process; reuse the existing backend.store."
224
+ raise GraphMemoryConfigurationError(msg)
225
+ store = cls._open(str(resolved))
226
+ try:
227
+ info = resolved.stat()
228
+ store._disk_keys = (keys[0], ("inode", info.st_dev, info.st_ino))
229
+ for key in store._disk_keys:
230
+ _open_disk_stores[key] = store
231
+ return store
232
+ except BaseException:
233
+ store.close()
234
+ raise
235
+ except OSError as exc:
236
+ msg = f"Could not access persistent graph path {filename}: {exc}"
237
+ raise GraphMemoryConfigurationError(msg) from exc
238
+
239
+ @classmethod
240
+ def _open(cls, path: str) -> LadybugGraphStore:
241
+ try:
242
+ database = ladybug.Database(path)
243
+ except Exception as exc:
244
+ msg = f"Could not open LadybugDB graph at path {path!r}: {exc}"
245
+ raise GraphMemoryConfigurationError(msg) from exc
246
+ try:
247
+ return cls(_LadybugGraph(database), database=database)
248
+ except Exception as exc:
249
+ database.close()
250
+ msg = f"Could not initialize LadybugDB graph at path {path!r}: {exc}"
251
+ raise GraphMemoryConfigurationError(msg) from exc
252
+ except BaseException:
253
+ database.close()
254
+ raise
255
+
256
+ def close(self) -> None:
257
+ """Release an owned LadybugDB connection and database."""
258
+ with self._lock:
259
+ if self._closed:
260
+ return
261
+ if self._transaction_depth:
262
+ msg = "Cannot close a graph store during an active transaction."
263
+ raise GraphMemoryConfigurationError(msg)
264
+ self._closed = True
265
+ if self._database is not None:
266
+ try:
267
+ self.graph.conn.close()
268
+ finally:
269
+ try:
270
+ self._database.close()
271
+ finally:
272
+ with _disk_lock:
273
+ for key in self._disk_keys:
274
+ if _open_disk_stores.get(key) is self:
275
+ del _open_disk_stores[key]
276
+
277
+ def _require_open(self) -> None:
278
+ if self._closed:
279
+ msg = "Graph store is closed. Create or reopen a backend before querying or writing."
280
+ raise GraphMemoryConfigurationError(msg)
281
+
282
+ @_locked
283
+ def get_schema(self, *, scope_key: str | None = None) -> str:
284
+ """Return graph schema text."""
285
+ del scope_key
286
+ self._refresh_schema()
287
+ if not self._labels() and not self._relationships():
288
+ return "No graph schema has been created yet."
289
+ return self.graph.get_schema
290
+
291
+ @_locked
292
+ def list_labels(self, *, scope_key: str | None = None, limit: int = 50) -> LimitedResult:
293
+ """List known node labels."""
294
+ labels = [label for label in self._labels() if self._label_has_nodes(label, scope_key=scope_key)]
295
+ return LimitedResult(items=labels[:limit], truncated=len(labels) > limit)
296
+
297
+ @_locked
298
+ def list_node_ids(self, label: str, *, scope_key: str | None = None, limit: int = 50) -> LimitedResult:
299
+ """List ids for a node label."""
300
+ validate_identifier(label, field="label")
301
+ if label not in self._labels():
302
+ return LimitedResult(items=[])
303
+ scope_where, params = _scope_where("n", scope_key)
304
+ rows = self._query(
305
+ f"""
306
+ MATCH (n:{label})
307
+ WHERE {scope_where}
308
+ RETURN n.id AS id
309
+ ORDER BY id
310
+ LIMIT {int(limit) + 1}
311
+ """,
312
+ params,
313
+ )
314
+ ids = [str(row["id"]) for row in rows if row.get("id") is not None]
315
+ return LimitedResult(items=ids[:limit], truncated=len(ids) > limit)
316
+
317
+ @_locked
318
+ def list_subject_trace_ids(self, subject_id: str, *, scope_key: str | None = None, limit: int = 50) -> LimitedResult:
319
+ """Rank one subject's findings before applying the output limit."""
320
+ validate_node_id(subject_id)
321
+ relationships = set(self._relationships())
322
+ if "Subject" not in self._labels() or "Trace" not in self._labels() or "ABOUT" not in relationships:
323
+ return LimitedResult(items=[])
324
+ rows = self._query(
325
+ "MATCH (t:Trace)-[:ABOUT]->(s:Subject {pk: $pk}) RETURN t",
326
+ {"pk": _node_pk("Subject", subject_id, scope_key)},
327
+ )
328
+ traces = {node.id: node for row in rows if (node := _coerce_node(row.get("t"), default_label="Trace", default_id="")).id}
329
+ predecessors: set[str] = set()
330
+ # ponytail: sort one subject in Python; use indexed ranking if subject histories grow large enough to measure.
331
+ for relationship in ("SUPERSEDES", "RESOLVES"):
332
+ if relationship not in relationships:
333
+ continue
334
+ links = self._query(
335
+ f"MATCH (a:Trace)-[:ABOUT]->(s:Subject {{pk: $pk}}) MATCH (a)-[:{relationship}]->(b:Trace) RETURN a, b",
336
+ {"pk": _node_pk("Subject", subject_id, scope_key)},
337
+ )
338
+ targets_by_source: dict[str, set[str]] = {}
339
+ for row in links:
340
+ source_id = str(row["a"]["id"])
341
+ target_id = str(row["b"]["id"])
342
+ source = traces.get(source_id)
343
+ target = traces.get(target_id)
344
+ if source is not None and target is not None and source.properties.get("subject") == target.properties.get("subject"):
345
+ targets_by_source.setdefault(source_id, set()).add(target_id)
346
+ for source_id, targets in targets_by_source.items():
347
+ for target_id in targets:
348
+ if valid_finding_link(traces[source_id], traces[target_id], relationship, reviewed_count=len(targets)):
349
+ predecessors.add(target_id)
350
+
351
+ def rank(trace: GraphNode) -> tuple[bool, bool, float, str]:
352
+ timestamp = finding_observed_timestamp(trace)
353
+ return (trace.id in predecessors, timestamp is None, -(timestamp or 0), trace.id)
354
+
355
+ ordered = sorted(traces.values(), key=rank)
356
+ return LimitedResult(items=[trace.id for trace in ordered[:limit]], truncated=len(ordered) > limit)
357
+
358
+ @_locked
359
+ def get_node(self, label: str, node_id: str, *, scope_key: str | None = None) -> GraphNode | None:
360
+ """Return a single node."""
361
+ validate_identifier(label, field="label")
362
+ validate_node_id(node_id)
363
+ if label not in self._labels():
364
+ return None
365
+ rows = self._query(f"MATCH (n:{label} {{pk: $pk}}) RETURN n", {"pk": _node_pk(label, node_id, scope_key)})
366
+ if not rows:
367
+ return None
368
+ return _coerce_node(rows[0].get("n"), default_label=label, default_id=node_id)
369
+
370
+ @_locked
371
+ def get_neighbors(
372
+ self,
373
+ label: str,
374
+ node_id: str,
375
+ *,
376
+ scope_key: str | None = None,
377
+ depth: int = 1,
378
+ max_nodes: int = 50,
379
+ max_edges: int = 100,
380
+ ) -> NeighborhoodResult | None:
381
+ """Return a bounded node neighborhood."""
382
+ node = self.get_node(label, node_id, scope_key=scope_key)
383
+ if node is None:
384
+ return None
385
+ frontier = {(label, node_id)}
386
+ seen_nodes = {(label, node_id)}
387
+ collected: dict[tuple[str, str, str, str, str], GraphEdge] = {}
388
+ truncated_nodes = False
389
+ truncated_edges = False
390
+
391
+ for _level in range(max(depth, 1)):
392
+ next_frontier: set[tuple[str, str]] = set()
393
+ for frontier_label, frontier_id in frontier:
394
+ for edge in self._get_immediate_edges(frontier_label, frontier_id, scope_key=scope_key, limit=max_edges + 1):
395
+ key = (edge.source_label, edge.source_id, edge.relationship, edge.target_label, edge.target_id)
396
+ if key not in collected:
397
+ if len(collected) >= max_edges:
398
+ truncated_edges = True
399
+ continue
400
+ collected[key] = edge
401
+ for candidate in ((edge.source_label, edge.source_id), (edge.target_label, edge.target_id)):
402
+ if candidate in seen_nodes:
403
+ continue
404
+ if len(seen_nodes) >= max_nodes:
405
+ truncated_nodes = True
406
+ continue
407
+ seen_nodes.add(candidate)
408
+ next_frontier.add(candidate)
409
+ frontier = next_frontier
410
+ if not frontier:
411
+ break
412
+
413
+ edges = sorted(collected.values(), key=lambda edge: (edge.relationship, edge.source_label, edge.source_id, edge.target_label, edge.target_id))
414
+ return NeighborhoodResult(node=node, edges=edges, truncated_nodes=truncated_nodes, truncated_edges=truncated_edges)
415
+
416
+ @_locked
417
+ def list_trace_edges(
418
+ self, trace_id: str, relationship: str, *, incoming: bool = False, scope_key: str | None = None, limit: int = 50
419
+ ) -> EdgeResult:
420
+ """Read a trace relationship without spending its limit on component links."""
421
+ validate_node_id(trace_id)
422
+ validate_identifier(relationship, field="relationship")
423
+ if "Trace" not in self._labels() or relationship not in self._relationships():
424
+ return EdgeResult(items=[])
425
+ direction = (
426
+ f"(other:Trace)-[r:{relationship}]->(trace:Trace {{pk: $pk}})"
427
+ if incoming
428
+ else f"(trace:Trace {{pk: $pk}})-[r:{relationship}]->(other:Trace)"
429
+ )
430
+ source, target = ("other", "trace") if incoming else ("trace", "other")
431
+ rows = self._query(
432
+ f"MATCH {direction} RETURN {source} AS source, r, {target} AS target ORDER BY source.id, target.id LIMIT {int(limit) + 1}",
433
+ {"pk": _node_pk("Trace", trace_id, scope_key)},
434
+ )
435
+ edges = [edge for row in rows if (edge := _coerce_edge(row, source_key="source", target_key="target")) is not None]
436
+ edges.sort(key=lambda edge: (edge.source_id, edge.target_id))
437
+ return EdgeResult(items=edges[:limit], truncated=len(rows) > limit)
438
+
439
+ @_locked
440
+ def search(self, query: str, *, scope_key: str | None = None, limit: int = 20) -> SearchResult:
441
+ """Search graph metadata."""
442
+ if self._transaction_depth:
443
+ msg = "search requires LadybugDB auto transaction mode."
444
+ raise GraphMemoryConfigurationError(msg)
445
+ scored: list[tuple[float, SearchItem]] = []
446
+ seen_paths: set[str] = set()
447
+ for score, item in self._search_fts(query, scope_key=scope_key, limit=limit):
448
+ if item.path in seen_paths:
449
+ continue
450
+ scored.append((score + 1000, item))
451
+ seen_paths.add(item.path)
452
+ for score, item in self._search_relationships(query, scope_key=scope_key, limit=limit):
453
+ if item.path in seen_paths:
454
+ continue
455
+ scored.append((score, item))
456
+ seen_paths.add(item.path)
457
+ scored.sort(key=lambda item: (-item[0], item[1].path, item[1].title))
458
+ items = [item for _score, item in scored]
459
+ return SearchResult(items=items[:limit], truncated=len(items) > limit)
460
+
461
+ @_atomic
462
+ def add_node(self, label: str, node_id: str, *, properties: Properties | None = None, scope_key: str | None = None) -> None:
463
+ """Add or update a node."""
464
+ validate_identifier(label, field="label")
465
+ validate_node_id(node_id)
466
+ self._ensure_node_table(label)
467
+ existing = self.get_node(label, node_id, scope_key=scope_key)
468
+ if existing is not None and properties is None:
469
+ return
470
+ merged = dict(existing.properties) if existing else {}
471
+ merged.update(_scoped_properties(properties, scope_key))
472
+ if existing and "created_at" in existing.properties:
473
+ merged["created_at"] = existing.properties["created_at"]
474
+ merged["updated_at"] = utc_now()
475
+ props = validate_properties(merged)
476
+ search_text = node_search_text(label, node_id, props)
477
+ self._query(
478
+ f"""
479
+ MERGE (n:{label} {{pk: $pk}})
480
+ SET n.id = $id,
481
+ n.type = "entity",
482
+ n.search_text = $search_text,
483
+ n.properties = $properties,
484
+ n.scope_key = $scope_key
485
+ """,
486
+ {
487
+ "pk": _node_pk(label, node_id, scope_key),
488
+ "id": node_id,
489
+ "search_text": search_text,
490
+ "properties": json.dumps(props, sort_keys=True),
491
+ "scope_key": scope_key,
492
+ },
493
+ )
494
+
495
+ @_atomic
496
+ def add_edge(
497
+ self,
498
+ source_label: str,
499
+ source_id: str,
500
+ relationship: str,
501
+ target_label: str,
502
+ target_id: str,
503
+ *,
504
+ properties: Properties | None = None,
505
+ scope_key: str | None = None,
506
+ ) -> None:
507
+ """Add or update an edge."""
508
+ validate_identifier(source_label, field="source_label")
509
+ validate_identifier(target_label, field="target_label")
510
+ validate_identifier(relationship, field="relationship")
511
+ validate_node_id(source_id)
512
+ validate_node_id(target_id)
513
+ props = _scoped_properties(properties, scope_key)
514
+ self.add_node(source_label, source_id, scope_key=scope_key)
515
+ self.add_node(target_label, target_id, scope_key=scope_key)
516
+ self._ensure_rel_table(relationship, source_label, target_label)
517
+ rows = self._query(
518
+ f"MATCH (source:{source_label} {{pk: $source_pk}})-[rel:{relationship}]->(target:{target_label} {{pk: $target_pk}}) RETURN rel",
519
+ {"source_pk": _node_pk(source_label, source_id, scope_key), "target_pk": _node_pk(target_label, target_id, scope_key)},
520
+ )
521
+ if rows:
522
+ existing_properties = _decode_properties(rows[0]["rel"])
523
+ props = validate_properties({**existing_properties, **props})
524
+ if "created_at" in existing_properties:
525
+ props["created_at"] = existing_properties["created_at"]
526
+ props["updated_at"] = utc_now()
527
+ self._query(
528
+ f"""
529
+ MATCH (source:{source_label} {{pk: $source_pk}}),
530
+ (target:{target_label} {{pk: $target_pk}})
531
+ MERGE (source)-[rel:{relationship}]->(target)
532
+ SET rel.properties = $properties,
533
+ rel.scope_key = $scope_key
534
+ """,
535
+ {
536
+ "source_pk": _node_pk(source_label, source_id, scope_key),
537
+ "target_pk": _node_pk(target_label, target_id, scope_key),
538
+ "properties": json.dumps(props, sort_keys=True),
539
+ "scope_key": scope_key,
540
+ },
541
+ )
542
+
543
+ @_atomic
544
+ def add_graph_documents(self, documents: Sequence[Any], *, scope_key: str | None = None) -> None:
545
+ """Add graph documents through validated scoped writes."""
546
+ for document in documents:
547
+ nodes = getattr(document, "nodes", None)
548
+ relationships = getattr(document, "relationships", None)
549
+ if (
550
+ not isinstance(nodes, Sequence)
551
+ or isinstance(nodes, str | bytes)
552
+ or not isinstance(relationships, Sequence)
553
+ or isinstance(relationships, str | bytes)
554
+ ):
555
+ msg = "documents must contain LangChain GraphDocument-like objects."
556
+ raise GraphMemoryValidationError(msg)
557
+ for node in nodes:
558
+ label, node_id, properties = _document_node(node, scope_key)
559
+ self.add_node(label, node_id, properties=properties, scope_key=scope_key)
560
+ for relationship in relationships:
561
+ source_label, source_id, _ = _document_node(getattr(relationship, "source", None), scope_key)
562
+ target_label, target_id, _ = _document_node(getattr(relationship, "target", None), scope_key)
563
+ self.add_edge(
564
+ source_label,
565
+ source_id,
566
+ getattr(relationship, "type", None),
567
+ target_label,
568
+ target_id,
569
+ properties=_with_scope(getattr(relationship, "properties", None), scope_key),
570
+ scope_key=scope_key,
571
+ )
572
+
573
+ def _get_immediate_edges(self, label: str, node_id: str, *, scope_key: str | None, limit: int) -> list[GraphEdge]:
574
+ params = {"pk": _node_pk(label, node_id, scope_key)}
575
+ rows = self._query(
576
+ f"""
577
+ MATCH (n:{label} {{pk: $pk}})-[r]->(m)
578
+ RETURN n, r, m
579
+ LIMIT {int(limit)}
580
+ """,
581
+ params,
582
+ )
583
+ incoming_rows = self._query(
584
+ f"""
585
+ MATCH (m)-[r]->(n:{label} {{pk: $pk}})
586
+ RETURN m, r, n
587
+ LIMIT {int(limit)}
588
+ """,
589
+ params,
590
+ )
591
+ edges: list[GraphEdge] = []
592
+ for row in rows:
593
+ edge = _coerce_edge(row, source_key="n", target_key="m")
594
+ if edge is not None:
595
+ edges.append(edge)
596
+ for row in incoming_rows:
597
+ edge = _coerce_edge(row, source_key="m", target_key="n")
598
+ if edge is not None:
599
+ edges.append(edge)
600
+ return edges
601
+
602
+ def _search_fts(self, query: str, *, scope_key: str | None, limit: int) -> list[tuple[float, SearchItem]]:
603
+ scored: list[tuple[float, SearchItem]] = []
604
+ for label in self._labels():
605
+ self._ensure_fts_index(label)
606
+ scope_where, scope_params = _scope_where("node", scope_key)
607
+ try:
608
+ rows = self._query(
609
+ f"""
610
+ CALL QUERY_FTS_INDEX('{label}', 'graph_memory_fts', $query)
611
+ WHERE {scope_where}
612
+ RETURN node, score
613
+ ORDER BY score DESC
614
+ LIMIT {int(limit) + 1}
615
+ """,
616
+ {"query": query, **scope_params},
617
+ )
618
+ except GraphMemoryConfigurationError as exc:
619
+ msg = f"LadybugDB full-text search failed for label {label!r}. Ensure the LadybugDB fts extension is available."
620
+ raise GraphMemoryConfigurationError(msg) from exc
621
+ for row in rows:
622
+ node = _coerce_node(row.get("node"), default_label=label, default_id="")
623
+ if not node.id:
624
+ continue
625
+ score = float(row.get("score", 0.0) or 0.0)
626
+ scored.append(
627
+ (
628
+ score,
629
+ SearchItem(
630
+ path=node_path(node.label, node.id),
631
+ title=f"{node.label}: {node.id}",
632
+ text=_summarize_properties(node.properties),
633
+ ),
634
+ )
635
+ )
636
+ return scored
637
+
638
+ def _search_relationships(self, query: str, *, scope_key: str | None, limit: int) -> list[tuple[float, SearchItem]]:
639
+ scored: list[tuple[float, SearchItem]] = []
640
+ for relationship in self._relationships():
641
+ score = lexical_search_score(query, relationship.replace("_", " "))
642
+ if score <= 0:
643
+ continue
644
+ scope_where, params = _scope_where("r", scope_key)
645
+ rows = self._query(
646
+ f"""
647
+ MATCH (source)-[r:{relationship}]->(target)
648
+ WHERE {scope_where}
649
+ RETURN source, r, target
650
+ LIMIT {int(limit) + 1}
651
+ """,
652
+ params,
653
+ )
654
+ for row in rows:
655
+ edge = _coerce_edge(row, source_key="source", target_key="target")
656
+ if edge is None:
657
+ continue
658
+ item = SearchItem(
659
+ path=node_path(edge.source_label, edge.source_id),
660
+ title=f"{edge.source_id} {edge.relationship} {edge.target_id}",
661
+ text=_summarize_properties(edge.properties),
662
+ )
663
+ scored.append((float(score), item))
664
+ return scored
665
+
666
+ def _ensure_fts_index(self, label: str) -> bool:
667
+ if label in self._fts_ready_labels:
668
+ return True
669
+ validate_identifier(label, field="label")
670
+ try:
671
+ self._query("LOAD fts;", {})
672
+ except GraphMemoryConfigurationError as exc:
673
+ message = str(exc).casefold()
674
+ if "already" not in message and "loaded" not in message:
675
+ msg = (
676
+ "LadybugDB full-text search requires the `fts` extension, but loading it failed. "
677
+ "Provision it with `INSTALL fts;` using LadybugDB before running the agent; runtime search does not download extensions."
678
+ )
679
+ raise GraphMemoryConfigurationError(msg) from exc
680
+ try:
681
+ self._query(f"CALL CREATE_FTS_INDEX('{label}', 'graph_memory_fts', ['id', 'type', 'search_text', 'properties']);", {})
682
+ except GraphMemoryConfigurationError as exc:
683
+ message = str(exc).casefold()
684
+ if "already" not in message and "exist" not in message:
685
+ msg = f"LadybugDB full-text search index creation failed for label {label!r}."
686
+ raise GraphMemoryConfigurationError(msg) from exc
687
+ self._fts_ready_labels.add(label)
688
+ return True
689
+
690
+ def _labels(self) -> list[str]:
691
+ rows = self._query("CALL SHOW_TABLES() RETURN *;", {})
692
+ return sorted(str(row.get("name")) for row in rows if row.get("type") == "NODE")
693
+
694
+ def _relationships(self) -> list[str]:
695
+ rows = self._query("CALL SHOW_TABLES() RETURN *;", {})
696
+ return sorted(str(row.get("name")) for row in rows if row.get("type") == "REL")
697
+
698
+ def _refresh_schema(self) -> None:
699
+ self.graph.refresh_schema()
700
+
701
+ def _query(self, query: str, params: dict[str, Any]) -> list[dict[str, Any]]:
702
+ self._require_open()
703
+ if self._transaction_depth and self._rollback_only and query.strip().upper() != "ROLLBACK;":
704
+ msg = "Graph transaction already failed; it must roll back."
705
+ raise GraphMemoryConfigurationError(msg)
706
+ try:
707
+ return cast("list[dict[str, Any]]", self.graph.query(query, params))
708
+ except Exception as exc: # noqa: BLE001
709
+ if self._transaction_depth:
710
+ self._rollback_only = True
711
+ msg = f"LadybugDB graph query failed: {exc}"
712
+ raise GraphMemoryConfigurationError(msg) from exc
713
+
714
+ def _ensure_node_table(self, label: str) -> None:
715
+ self._reject_table_collision(label, "NODE")
716
+ self._query(
717
+ f"""
718
+ CREATE NODE TABLE IF NOT EXISTS {label} (
719
+ pk STRING,
720
+ id STRING,
721
+ type STRING,
722
+ search_text STRING,
723
+ properties STRING,
724
+ scope_key STRING,
725
+ PRIMARY KEY(pk)
726
+ );
727
+ """,
728
+ {},
729
+ )
730
+
731
+ def _ensure_rel_table(self, relationship: str, source_label: str, target_label: str) -> None:
732
+ self._reject_table_collision(relationship, "REL")
733
+ self._query(
734
+ f"""
735
+ CREATE REL TABLE IF NOT EXISTS {relationship} (
736
+ FROM {source_label} TO {target_label},
737
+ properties STRING,
738
+ scope_key STRING
739
+ );
740
+ """,
741
+ {},
742
+ )
743
+ connections = self._query(f"CALL SHOW_CONNECTION('{relationship}') RETURN *;", {})
744
+ if any(row["source table name"] == source_label and row["destination table name"] == target_label for row in connections):
745
+ return
746
+ self._query(f"ALTER TABLE {relationship} ADD FROM {source_label} TO {target_label};", {})
747
+
748
+ def _reject_table_collision(self, name: str, expected_type: str) -> None:
749
+ for row in self._query("CALL SHOW_TABLES() RETURN *;", {}):
750
+ if str(row["name"]).casefold() == name.casefold() and (row["name"] != name or row["type"] != expected_type):
751
+ msg = f"Graph table {name!r} conflicts with existing {row['type'].lower()} table {row['name']!r}."
752
+ raise GraphMemoryValidationError(msg)
753
+
754
+ def _label_has_nodes(self, label: str, *, scope_key: str | None) -> bool:
755
+ scope_where, params = _scope_where("n", scope_key)
756
+ rows = self._query(
757
+ f"""
758
+ MATCH (n:{label})
759
+ WHERE {scope_where}
760
+ RETURN n.id AS id
761
+ LIMIT 1
762
+ """,
763
+ params,
764
+ )
765
+ return bool(rows)
766
+
767
+
768
+ def _coerce_node(value: Any, *, default_label: str, default_id: str) -> GraphNode:
769
+ data = value if isinstance(value, dict) else {}
770
+ label = str(data.get("_LABEL", data.get("label", default_label)))
771
+ node_id = str(data.get("id", default_id))
772
+ properties = _decode_properties(data)
773
+ for key, item in data.items():
774
+ if key not in {"_ID", "_LABEL", "id", "pk", "properties", "search_text", "type"} and item is not None:
775
+ properties.setdefault(key, item)
776
+ return GraphNode(label=label, id=node_id, properties=validate_properties(properties))
777
+
778
+
779
+ def _coerce_edge(row: dict[str, Any], *, source_key: str, target_key: str) -> GraphEdge | None:
780
+ raw_edge = row.get("r")
781
+ if not isinstance(raw_edge, dict):
782
+ return None
783
+ source = _coerce_node(row.get(source_key), default_label="Node", default_id="")
784
+ target = _coerce_node(row.get(target_key), default_label="Node", default_id="")
785
+ relationship = str(raw_edge.get("_LABEL", raw_edge.get("label", raw_edge.get("type", "RELATED_TO"))))
786
+ properties = _decode_properties(raw_edge)
787
+ for key, item in raw_edge.items():
788
+ if not key.startswith("_") and key not in {"label", "type", "properties"} and item is not None:
789
+ properties.setdefault(key, item)
790
+ return GraphEdge(
791
+ source_label=source.label,
792
+ source_id=source.id,
793
+ relationship=relationship,
794
+ target_label=target.label,
795
+ target_id=target.id,
796
+ properties=validate_properties(properties),
797
+ )
798
+
799
+
800
+ def _decode_properties(data: dict[str, Any]) -> dict[str, Any]:
801
+ raw = data.get("properties")
802
+ if isinstance(raw, str):
803
+ try:
804
+ decoded = json.loads(raw)
805
+ except json.JSONDecodeError:
806
+ return {"properties": raw}
807
+ return decoded if isinstance(decoded, dict) else {"properties": decoded}
808
+ if isinstance(raw, dict):
809
+ return raw
810
+ return {}
811
+
812
+
813
+ def _summarize_properties(properties: dict[str, Any]) -> str:
814
+ public = {key: value for key, value in properties.items() if key not in {"pk", "scope_key", "search_text"}}
815
+ if not public:
816
+ return ""
817
+ return json.dumps(public, sort_keys=True)
818
+
819
+
820
+ def _with_scope(properties: dict[str, Any] | None, scope_key: str | None) -> Properties:
821
+ return _scoped_properties(properties, scope_key)
822
+
823
+
824
+ def _document_node(value: Any, scope_key: str | None) -> tuple[str, str, Properties]:
825
+ label = validate_identifier(getattr(value, "type", None), field="label")
826
+ node_id = validate_node_id(getattr(value, "id", None))
827
+ return label, node_id, _with_scope(getattr(value, "properties", None), scope_key)
828
+
829
+
830
+ def _scoped_properties(properties: dict[str, Any] | None, scope_key: str | None) -> Properties:
831
+ scoped = validate_properties(properties)
832
+ if "scope_key" in scoped and scoped["scope_key"] != scope_key:
833
+ msg = "scope_key cannot differ from the active namespace."
834
+ raise GraphMemoryValidationError(msg)
835
+ if scope_key is not None:
836
+ scoped["scope_key"] = scope_key
837
+ return scoped
838
+
839
+
840
+ def _node_pk(label: str, node_id: str, scope_key: str | None) -> str:
841
+ return json.dumps([scope_key, label, node_id], separators=(",", ":"))
842
+
843
+
844
+ def _scope_where(alias: str, scope_key: str | None) -> tuple[str, dict[str, Any]]:
845
+ if scope_key is None:
846
+ return f"{alias}.scope_key IS NULL", {}
847
+ return f"{alias}.scope_key = $scope_key", {"scope_key": scope_key}