* 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>
430 lines
15 KiB
Python
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
|