fleet-memory/hindsight-integrations/langgraph/hindsight_langgraph/store.py
DK09876 b4320254b2
feat: add LangGraph integration (#610)
* feat: add LangGraph integration with tools, nodes, and store patterns

Add hindsight-langgraph SDK providing three integration patterns:
- Tools: retain/recall/reflect as LangChain tools for ReAct agents
- Nodes: automatic memory injection and storage as graph steps
- Store: LangGraph BaseStore implementation for checkpoint-based memory

Fix: remove `from __future__ import annotations` in nodes.py which
prevented LangGraph from passing RunnableConfig to node functions
(runtime type inspection saw string annotations instead of actual types).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: register langgraph with independent versioning system

- Set version to 0.1.0 (integrations are versioned independently)
- Add langgraph to VALID_INTEGRATIONS in release-integration.sh
- Add changelog page for langgraph integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove manual cookbook recipe page

The sync-cookbook script will auto-generate this from the notebook
in hindsight-cookbook once PR #17 is merged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: comprehensive improvements to langgraph integration

Code fixes:
- Retain node only stores latest messages instead of all history (prevents duplicates)
- Handle multimodal msg.content (list type) in nodes
- Fix store docstring separator "/" → "."
- Apply search filters before pagination in store
- Add ttl parameter to store.aput for LangGraph BaseStore compat
- Fix _ensure_bank to not cache failed bank creations
- Fix falsy value bugs (or → is not None) in tools
- Remove from __future__ import annotations from all files
- Consistent default budget="mid" across tools/nodes/store
- Bump langgraph floor to >=0.3.0, remove duplicate dev deps

Docs fixes:
- Fix broken Cloud client example (base_url is required)
- Complete API reference tables with all parameters
- Add Limitations and Notes section (async-only store, etc.)
- Add Requirements section
- Fix broken cookbook link and Cloud claim in blog post

All 61 unit tests pass. E2E tested against Hindsight Cloud:
tools, nodes, store, configure(), multimodal content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove blog post (lives in hindsight-marketing-content)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove Hindsight Cloud section from langgraph docs

Keep OSS docs self-hosted-first, consistent with other integration
docs (crewai, pydantic-ai, agno). Cloud setup details live in the
cookbook notebooks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: explicitly mention LangChain compatibility in langgraph integration

The tools pattern (create_hindsight_tools) only depends on
langchain-core and works with plain LangChain via bind_tools() —
no LangGraph required. Update docs to make this clear with both
LangGraph and LangChain quick start examples.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review findings

1. Guard manual test files with if __name__ == "__main__" so pytest
   doesn't collect and execute them during test runs
2. Remove semantic fallback in HindsightStore.aget() — only return
   exact document_id matches, not unrelated semantic search hits
3. Make langgraph an optional dependency — tools pattern only needs
   langchain-core. Install with pip install hindsight-langgraph[langgraph]
   for nodes and store patterns. Lazy imports with clear error messages.
4. Clean up README to be self-hosted-first, consistent with other
   integration docs
5. Update docs requirements section to reflect optional langgraph dep

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review feedback for langgraph integration

- Fix #2: Add per-bank asyncio.Lock to _ensure_bank for concurrency safety
- Fix #3: Clamp search score to max(0.0, ...) to prevent negative values
- Fix #4: Implement suffix matching in _handle_list_namespaces
- Fix #5: Truncate namespaces to max_depth instead of filtering (per BaseStore contract)
- Fix #6: Remove list_namespaces/alist_namespaces overrides — let base class handle prefix=/suffix= kwargs
- Fix #7: Document ephemeral namespace tracking and get() limitations in class docstring
- Fix #8: Add stable ID to recall node SystemMessage, document ordering behavior
- Fix #9: Change budget/max_tokens/recall_tags_match defaults to None so global config fallback works
- Fix #10: Conditionally populate __all__ so import * works without langgraph installed
- Fix #11: Bump langgraph lower bound from >=0.3.0 to >=0.5.0
- Fix #12: Extract _resolve_client to shared _client.py module

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address remaining review gaps for langgraph integration

- Add output_key parameter to create_recall_node for prompt ordering control
- Add prefix/suffix/combined filter tests for list_namespaces
- Add output_key unit tests (memory text, none on empty, none on error)
- Remove unused imports and backward-compat alias in tools.py
- Update docs with output_key usage example and API reference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: relax langgraph version constraint to >=0.3.0

Research confirmed all required APIs (BaseStore, SearchItem, Result,
GetOp, PutOp, SearchOp, ListNamespacesOp) are available since
langgraph-checkpoint 2.0.7, which maps to langgraph >=0.2.63.
Using >=0.3.0 as a clean semver boundary — >=0.5.0 was unnecessarily
conservative and excluded many compatible versions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:36:57 +01:00

430 lines
15 KiB
Python

"""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