* 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>
259 lines
9.3 KiB
Python
259 lines
9.3 KiB
Python
"""Pre-built LangGraph nodes for Hindsight memory operations.
|
|
|
|
Provides node functions that can be added directly to a StateGraph to
|
|
inject memories at conversation start and store new memories after responses.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any, Optional
|
|
|
|
from hindsight_client import Hindsight
|
|
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
|
from langchain_core.runnables import RunnableConfig
|
|
from langgraph.graph import MessagesState
|
|
|
|
from ._client import resolve_client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _extract_text_content(content: Any) -> str:
|
|
"""Extract text from a message content field.
|
|
|
|
Handles both plain string content and multimodal content lists
|
|
(where each item may be a dict with "type" and "text" keys).
|
|
Returns the concatenated text parts, or an empty string if no
|
|
text content is found.
|
|
"""
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
parts = []
|
|
for part in content:
|
|
if isinstance(part, str):
|
|
parts.append(part)
|
|
elif isinstance(part, dict) and part.get("type") == "text":
|
|
parts.append(part.get("text", ""))
|
|
return " ".join(parts)
|
|
return str(content) if content else ""
|
|
|
|
|
|
def create_recall_node(
|
|
*,
|
|
bank_id: Optional[str] = None,
|
|
client: Optional[Hindsight] = None,
|
|
hindsight_api_url: Optional[str] = None,
|
|
api_key: Optional[str] = None,
|
|
budget: str = "mid",
|
|
max_tokens: int = 4096,
|
|
max_results: int = 10,
|
|
tags: Optional[list[str]] = None,
|
|
tags_match: str = "any",
|
|
bank_id_from_config: str = "user_id",
|
|
output_key: Optional[str] = None,
|
|
):
|
|
"""Create a node that injects relevant memories into the conversation.
|
|
|
|
This node extracts the latest user message, recalls relevant memories
|
|
from Hindsight, and returns them either as a SystemMessage in the
|
|
``messages`` list (default) or as a plain string under a custom state
|
|
key via ``output_key``.
|
|
|
|
**Message ordering:** When using the default ``messages`` output,
|
|
``MessagesState`` uses ``add_messages`` as its reducer, which appends.
|
|
The memory SystemMessage will appear after existing messages, not at
|
|
position 0. If your LLM provider requires system messages first, use
|
|
``output_key`` to write the memory text to a separate state field and
|
|
inject it into your prompt in the agent node.
|
|
|
|
Example with ``output_key`` (recommended for correct ordering)::
|
|
|
|
from typing import Optional
|
|
from langgraph.graph import MessagesState
|
|
|
|
class AgentState(MessagesState):
|
|
memory_context: Optional[str] = None
|
|
|
|
recall = create_recall_node(
|
|
client=client, bank_id="user-123", output_key="memory_context"
|
|
)
|
|
# In your agent node, read state["memory_context"] and prepend
|
|
# it to the system prompt.
|
|
|
|
The bank_id can be provided directly or resolved dynamically from
|
|
the graph's RunnableConfig via the ``bank_id_from_config`` key.
|
|
|
|
Args:
|
|
bank_id: Static Hindsight memory bank ID.
|
|
client: Pre-configured Hindsight client.
|
|
hindsight_api_url: API URL (used if no client provided).
|
|
api_key: API key (used if no client provided).
|
|
budget: Recall budget level (low/mid/high).
|
|
max_tokens: Maximum tokens for recall results.
|
|
max_results: Maximum number of memories to inject.
|
|
tags: Tags to filter recall results.
|
|
tags_match: Tag matching mode.
|
|
bank_id_from_config: Config key to read bank_id from at runtime.
|
|
Looked up in ``config["configurable"][bank_id_from_config]``.
|
|
Only used when ``bank_id`` is not provided.
|
|
output_key: If set, write the memory text to this state key as a
|
|
plain string instead of appending a SystemMessage to ``messages``.
|
|
Use this with a custom state type to control where memory context
|
|
appears in your prompt.
|
|
|
|
Returns:
|
|
An async node function compatible with LangGraph StateGraph.
|
|
"""
|
|
resolved_client = resolve_client(client, hindsight_api_url, api_key)
|
|
|
|
async def recall_node(
|
|
state: MessagesState, config: Optional[RunnableConfig] = None
|
|
) -> dict[str, Any]:
|
|
resolved_bank_id = bank_id
|
|
if resolved_bank_id is None and config:
|
|
configurable = config.get("configurable", {})
|
|
resolved_bank_id = configurable.get(bank_id_from_config)
|
|
|
|
if not resolved_bank_id:
|
|
logger.warning(
|
|
"No bank_id available for recall node, skipping memory injection."
|
|
)
|
|
if output_key:
|
|
return {output_key: None}
|
|
return {"messages": []}
|
|
|
|
# Extract query from the latest human message
|
|
query = None
|
|
for msg in reversed(state["messages"]):
|
|
if isinstance(msg, HumanMessage):
|
|
query = _extract_text_content(msg.content)
|
|
break
|
|
|
|
if not query:
|
|
if output_key:
|
|
return {output_key: None}
|
|
return {"messages": []}
|
|
|
|
try:
|
|
recall_kwargs: dict[str, Any] = {
|
|
"bank_id": resolved_bank_id,
|
|
"query": query,
|
|
"budget": budget,
|
|
"max_tokens": max_tokens,
|
|
}
|
|
if tags:
|
|
recall_kwargs["tags"] = tags
|
|
recall_kwargs["tags_match"] = tags_match
|
|
|
|
response = await resolved_client.arecall(**recall_kwargs)
|
|
results = response.results[:max_results] if response.results else []
|
|
|
|
if not results:
|
|
if output_key:
|
|
return {output_key: None}
|
|
return {"messages": []}
|
|
|
|
lines = ["Relevant memories about this user:"]
|
|
for i, result in enumerate(results, 1):
|
|
lines.append(f"{i}. {result.text}")
|
|
memory_text = "\n".join(lines)
|
|
|
|
if output_key:
|
|
return {output_key: memory_text}
|
|
return {
|
|
"messages": [
|
|
SystemMessage(content=memory_text, id="hindsight_memory_context")
|
|
]
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Recall node failed: {e}")
|
|
if output_key:
|
|
return {output_key: None}
|
|
return {"messages": []}
|
|
|
|
return recall_node
|
|
|
|
|
|
def create_retain_node(
|
|
*,
|
|
bank_id: Optional[str] = None,
|
|
client: Optional[Hindsight] = None,
|
|
hindsight_api_url: Optional[str] = None,
|
|
api_key: Optional[str] = None,
|
|
tags: Optional[list[str]] = None,
|
|
bank_id_from_config: str = "user_id",
|
|
retain_human: bool = True,
|
|
retain_ai: bool = False,
|
|
):
|
|
"""Create a node that stores conversation messages as memories.
|
|
|
|
This node extracts messages from the conversation and stores them
|
|
via Hindsight retain. It should be placed after the LLM response
|
|
node in your graph.
|
|
|
|
Args:
|
|
bank_id: Static Hindsight memory bank ID.
|
|
client: Pre-configured Hindsight client.
|
|
hindsight_api_url: API URL (used if no client provided).
|
|
api_key: API key (used if no client provided).
|
|
tags: Tags to apply to stored memories.
|
|
bank_id_from_config: Config key to read bank_id from at runtime.
|
|
retain_human: Store human messages as memories.
|
|
retain_ai: Store AI responses as memories.
|
|
|
|
Returns:
|
|
An async node function compatible with LangGraph StateGraph.
|
|
"""
|
|
resolved_client = resolve_client(client, hindsight_api_url, api_key)
|
|
|
|
async def retain_node(
|
|
state: MessagesState, config: Optional[RunnableConfig] = None
|
|
) -> dict[str, Any]:
|
|
resolved_bank_id = bank_id
|
|
if resolved_bank_id is None and config:
|
|
configurable = config.get("configurable", {})
|
|
resolved_bank_id = configurable.get(bank_id_from_config)
|
|
|
|
if not resolved_bank_id:
|
|
logger.warning(
|
|
"No bank_id available for retain node, skipping memory storage."
|
|
)
|
|
return {"messages": []}
|
|
|
|
# Only retain the latest human and/or AI message to avoid
|
|
# duplicating memories that were already stored in prior calls.
|
|
messages_to_retain = []
|
|
if retain_human:
|
|
for msg in reversed(state["messages"]):
|
|
if isinstance(msg, HumanMessage):
|
|
text = _extract_text_content(msg.content)
|
|
if text:
|
|
messages_to_retain.append(text)
|
|
break
|
|
if retain_ai:
|
|
for msg in reversed(state["messages"]):
|
|
if isinstance(msg, AIMessage):
|
|
text = _extract_text_content(msg.content)
|
|
if text:
|
|
messages_to_retain.append(text)
|
|
break
|
|
|
|
if not messages_to_retain:
|
|
return {"messages": []}
|
|
|
|
content = "\n\n".join(messages_to_retain)
|
|
|
|
try:
|
|
retain_kwargs: dict[str, Any] = {
|
|
"bank_id": resolved_bank_id,
|
|
"content": content,
|
|
}
|
|
if tags:
|
|
retain_kwargs["tags"] = tags
|
|
await resolved_client.aretain(**retain_kwargs)
|
|
except Exception as e:
|
|
logger.error(f"Retain node failed: {e}")
|
|
|
|
return {"messages": []}
|
|
|
|
return retain_node
|