"""LangGraph BaseStore adapter backed by Hindsight. Maps LangGraph's key-value store interface to Hindsight's memory operations. Namespace tuples are joined to form bank IDs, and values are stored/retrieved via retain/recall. """ import asyncio import hashlib import json import logging from datetime import datetime, timezone from typing import Any, Iterable, Optional from hindsight_client import Hindsight from langgraph.store.base import ( BaseStore, GetOp, Item, ListNamespacesOp, PutOp, Result, SearchItem, SearchOp, ) from ._client import resolve_client from .errors import HindsightError logger = logging.getLogger(__name__) def _namespace_to_bank_id(namespace: tuple[str, ...]) -> str: """Convert a namespace tuple to a Hindsight bank ID. Uses "." as separator since "/" is not valid in Hindsight bank IDs (interpreted as URL path segments). """ return ".".join(namespace) if namespace else "default" def _make_item( namespace: tuple[str, ...], key: str, value: dict, created_at: Optional[datetime] = None, ) -> Item: """Create a LangGraph Item from Hindsight data.""" now = datetime.now(timezone.utc) return Item( namespace=namespace, key=key, value=value, created_at=created_at or now, updated_at=now, ) def _make_search_item( namespace: tuple[str, ...], key: str, value: dict, score: float, created_at: Optional[datetime] = None, ) -> SearchItem: """Create a LangGraph SearchItem from Hindsight recall results.""" now = datetime.now(timezone.utc) return SearchItem( namespace=namespace, key=key, value=value, score=score, created_at=created_at or now, updated_at=now, ) class HindsightStore(BaseStore): """LangGraph BaseStore implementation backed by Hindsight. Maps LangGraph's namespace/key-value model to Hindsight memory banks: - Namespace tuples are joined with "." to form bank IDs - ``put()`` stores values via Hindsight retain with the key as document_id - ``search()`` uses Hindsight recall for semantic search - ``get()`` uses recall with the key as a targeted query, returning only exact ``document_id`` matches. If the stored document does not surface in the recall window, ``get()`` returns ``None`` even though the item exists. Hindsight does not currently expose a direct document-lookup endpoint. **Known limitations:** - **Async-only.** All sync methods (``batch``, ``get``, ``put``, ``delete``, ``search``, ``list_namespaces``) raise ``NotImplementedError``. Use the async variants (``abatch``, ``aget``, ``aput``, ``adelete``, ``asearch``, ``alist_namespaces``) instead. - **``list_namespaces`` is session-scoped.** It only tracks namespaces that have been written to via ``aput()`` during the current process. After a restart, ``list_namespaces`` returns empty even though data still exists in Hindsight. Hindsight does not currently provide a bank-listing API. - **``delete`` is a no-op.** Calling ``adelete()`` logs a debug message but does not remove data. Hindsight's memory model is append-oriented; fact superseding is handled automatically during retain. - **``get()`` relies on recall.** There is no direct key lookup — the key is used as a recall query and only exact ``document_id`` matches are returned. Items that do not rank in the top recall results will appear missing. Example:: from hindsight_client import Hindsight from hindsight_langgraph import HindsightStore store = HindsightStore(client=Hindsight(base_url="http://localhost:8888")) graph = builder.compile(checkpointer=checkpointer, store=store) """ def __init__( self, *, client: Optional[Hindsight] = None, hindsight_api_url: Optional[str] = None, api_key: Optional[str] = None, tags: Optional[list[str]] = None, ): self._client = resolve_client(client, hindsight_api_url, api_key) self._tags = tags # Track known namespaces for list_namespaces (session-scoped only) self._known_namespaces: set[tuple[str, ...]] = set() # Track banks that have been created to avoid repeated create calls self._created_banks: set[str] = set() # Per-bank locks for concurrency-safe bank creation self._bank_locks: dict[str, asyncio.Lock] = {} def batch( self, ops: Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp] ) -> list[Result]: raise NotImplementedError("Use abatch() for async operation.") async def abatch( self, ops: Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp] ) -> list[Result]: results: list[Result] = [] for op in ops: if isinstance(op, GetOp): results.append(await self._handle_get(op)) elif isinstance(op, PutOp): await self._handle_put(op) results.append(None) elif isinstance(op, SearchOp): results.append(await self._handle_search(op)) elif isinstance(op, ListNamespacesOp): results.append(await self._handle_list_namespaces(op)) else: results.append(None) return results async def _handle_get(self, op: GetOp) -> Optional[Item]: """Handle a get operation by recalling with the key as query.""" bank_id = _namespace_to_bank_id(op.namespace) try: await self._ensure_bank(bank_id) response = await self._client.arecall( bank_id=bank_id, query=op.key, budget="low", max_tokens=1024, ) if not response.results: return None # Only return a result if the document_id matches the requested key exactly. # Do NOT fall back to semantic search — that would violate key-value store semantics. for result in response.results: doc_id = getattr(result, "document_id", None) if doc_id == op.key: value = _parse_value(result.text) ts = getattr(result, "occurred_start", None) return _make_item(op.namespace, op.key, value, created_at=ts) return None except Exception as e: logger.error(f"Store get failed for {op.namespace}/{op.key}: {e}") return None async def _ensure_bank(self, bank_id: str) -> None: """Create a bank if it hasn't been created yet in this session. Uses per-bank locking to prevent concurrent creation races. """ if bank_id in self._created_banks: return lock = self._bank_locks.setdefault(bank_id, asyncio.Lock()) async with lock: # Double-check after acquiring the lock if bank_id in self._created_banks: return try: await self._client.acreate_bank(bank_id, name=bank_id) self._created_banks.add(bank_id) except Exception as e: error_str = str(e).lower() if ( "already exists" in error_str or "conflict" in error_str or "409" in error_str ): # Bank already exists — safe to cache self._created_banks.add(bank_id) else: logger.error(f"Failed to create bank '{bank_id}': {e}") raise async def _handle_put(self, op: PutOp) -> None: """Handle a put operation by retaining the value.""" bank_id = _namespace_to_bank_id(op.namespace) self._known_namespaces.add(op.namespace) if op.value is None: # LangGraph uses value=None as delete logger.debug(f"Delete not supported for {op.namespace}/{op.key}, skipping.") return try: await self._ensure_bank(bank_id) content = ( json.dumps(op.value) if isinstance(op.value, dict) else str(op.value) ) retain_kwargs: dict[str, Any] = { "bank_id": bank_id, "content": content, "document_id": op.key, } if self._tags: retain_kwargs["tags"] = self._tags await self._client.aretain(**retain_kwargs) except Exception as e: logger.error(f"Store put failed for {op.namespace}/{op.key}: {e}") raise HindsightError(f"Store put failed: {e}") from e async def _handle_search(self, op: SearchOp) -> list[SearchItem]: """Handle a search operation via Hindsight recall.""" bank_id = _namespace_to_bank_id(op.namespace_prefix) query = op.query or "*" try: await self._ensure_bank(bank_id) recall_kwargs: dict[str, Any] = { "bank_id": bank_id, "query": query, "budget": "mid", "max_tokens": 4096, } response = await self._client.arecall(**recall_kwargs) if not response.results: return [] # Build all candidate items first all_items = [] for i, result in enumerate(response.results): value = _parse_value(result.text) doc_id = getattr(result, "document_id", None) or _content_key( result.text ) score = max( 0.0, 1.0 - (i * 0.01) ) # Approximate score from rank position ts = getattr(result, "occurred_start", None) all_items.append( _make_search_item( op.namespace_prefix, doc_id, value, score=score, created_at=ts ) ) # Apply filters BEFORE pagination so offset/limit operate on # the filtered set rather than discarding matching items. if op.filter: all_items = [ item for item in all_items if _matches_filter(item.value, op.filter) ] limit = op.limit or 10 offset = op.offset or 0 return all_items[offset : offset + limit] except Exception as e: logger.error(f"Store search failed for {op.namespace_prefix}: {e}") return [] async def _handle_list_namespaces( self, op: ListNamespacesOp ) -> list[tuple[str, ...]]: """List known namespaces. Limited to namespaces seen via put() in this session.""" namespaces = list(self._known_namespaces) if op.match_conditions: filtered = [] for ns in namespaces: match = True for cond in op.match_conditions: match_type = getattr(cond, "match_type", "prefix") if match_type == "prefix": if not _namespace_starts_with(ns, cond.path): match = False break elif match_type == "suffix": if not _namespace_ends_with(ns, cond.path): match = False break if match: filtered.append(ns) namespaces = filtered if op.max_depth is not None: # Truncate namespaces to max_depth and deduplicate, per BaseStore contract. namespaces = list(dict.fromkeys(ns[: op.max_depth] for ns in namespaces)) limit = op.limit or 100 offset = op.offset or 0 return namespaces[offset : offset + limit] # Sync convenience methods that delegate to async def get(self, namespace: tuple[str, ...], key: str) -> Optional[Item]: raise NotImplementedError("Use aget() for async operation.") async def aget(self, namespace: tuple[str, ...], key: str) -> Optional[Item]: result = await self.abatch([GetOp(namespace=namespace, key=key)]) return result[0] def put( self, namespace: tuple[str, ...], key: str, value: dict, index: Optional[Any] = None, ) -> None: raise NotImplementedError("Use aput() for async operation.") async def aput( self, namespace: tuple[str, ...], key: str, value: dict, index: Optional[Any] = None, ttl: Optional[float] = None, ) -> None: # ttl is accepted for BaseStore compatibility but not used; # Hindsight does not support TTL-based expiration natively. await self.abatch([PutOp(namespace=namespace, key=key, value=value)]) def delete(self, namespace: tuple[str, ...], key: str) -> None: raise NotImplementedError("Use adelete() for async operation.") async def adelete(self, namespace: tuple[str, ...], key: str) -> None: await self.abatch([PutOp(namespace=namespace, key=key, value=None)]) def search( self, namespace_prefix: tuple[str, ...], *, query: Optional[str] = None, filter: Optional[dict] = None, limit: int = 10, offset: int = 0, ) -> list[SearchItem]: raise NotImplementedError("Use asearch() for async operation.") async def asearch( self, namespace_prefix: tuple[str, ...], *, query: Optional[str] = None, filter: Optional[dict] = None, limit: int = 10, offset: int = 0, ) -> list[SearchItem]: result = await self.abatch( [ SearchOp( namespace_prefix=namespace_prefix, query=query, filter=filter, limit=limit, offset=offset, ) ] ) return result[0] # list_namespaces / alist_namespaces are NOT overridden here. # The base class converts prefix=/suffix= kwargs into MatchCondition # objects and calls abatch() -> _handle_list_namespaces(). Overriding # with a different signature (match_conditions=) would break callers. def _parse_value(text: str) -> dict: """Try to parse stored text as JSON, fallback to wrapping in a dict.""" try: parsed = json.loads(text) if isinstance(parsed, dict): return parsed except (json.JSONDecodeError, TypeError): pass return {"text": text} def _content_key(text: str) -> str: """Generate a stable key from content text.""" return hashlib.sha256(text.encode()).hexdigest()[:12] def _matches_filter(value: dict, filter_dict: dict) -> bool: """Check if a value dict matches all filter conditions.""" for key, expected in filter_dict.items(): if value.get(key) != expected: return False return True def _namespace_starts_with(namespace: tuple[str, ...], prefix: tuple[str, ...]) -> bool: """Check if namespace starts with the given prefix.""" if len(prefix) > len(namespace): return False return namespace[: len(prefix)] == prefix def _namespace_ends_with(namespace: tuple[str, ...], suffix: tuple[str, ...]) -> bool: """Check if namespace ends with the given suffix.""" if len(suffix) > len(namespace): return False return namespace[len(namespace) - len(suffix) :] == suffix