fleet-memory/hindsight-integrations/langgraph/hindsight_langgraph/nodes.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

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