Compare commits

...
Author SHA1 Message Date
DK09876andClaude Opus 4.6 aa74841f3a 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 <[email protected]>
2026-03-19 14:01:01 -07:00
DK09876andClaude Opus 4.6 f8304a1631 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 <[email protected]>
2026-03-19 13:59:47 -07:00
DK09876andClaude Opus 4.6 9814240835 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 <[email protected]>
2026-03-19 12:19:06 -07:00
DK09876andClaude Opus 4.6 9ef886c4b3 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 <[email protected]>
2026-03-18 15:46:49 -07:00
DK09876andClaude Opus 4.6 11a8242b5e 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 <[email protected]>
2026-03-18 13:25:14 -07:00
DK09876andClaude Opus 4.6 73184dd0fa 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 <[email protected]>
2026-03-18 13:14:03 -07:00
DK09876andClaude Opus 4.6 c5a3e4ab93 chore: remove blog post (lives in hindsight-marketing-content)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-18 12:25:51 -07:00
DK09876andClaude Opus 4.6 54c187d792 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 <[email protected]>
2026-03-18 12:07:43 -07:00
DK09876andClaude Opus 4.6 7e0aebddf8 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 <[email protected]>
2026-03-18 11:48:04 -07:00
DK09876andClaude Opus 4.6 d22d745ecd 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 <[email protected]>
2026-03-18 11:43:13 -07:00
DK09876andClaude Opus 4.6 b6c6f5d90b 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 <[email protected]>
2026-03-18 11:18:36 -07:00
21 changed files with 4637 additions and 1 deletions
@@ -0,0 +1,315 @@
---
sidebar_position: 7
---
# LangGraph / LangChain
Persistent long-term memory for [LangGraph](https://langchain-ai.github.io/langgraph/) and [LangChain](https://python.langchain.com/) agents via Hindsight. Three integration patterns at different abstraction levels — the tools pattern works with both LangChain and LangGraph, while nodes and the BaseStore adapter are LangGraph-specific.
## Features
- **Memory Tools** — retain, recall, and reflect as LangChain `@tool` functions compatible with `bind_tools()` and `ToolNode`. Works with **both LangChain and LangGraph** — no LangGraph dependency required for this pattern.
- **Graph Nodes** *(LangGraph)* — Pre-built nodes that auto-inject memories before LLM calls and auto-store after responses
- **BaseStore Adapter** *(LangGraph)* — Drop-in `BaseStore` implementation backed by Hindsight, for LangGraph's native memory patterns
- **Dynamic Banks** — Resolve bank IDs per-request from `RunnableConfig` for per-user memory
- **Async-Native** — Uses `aretain`, `arecall`, `areflect` directly — no thread-pool workarounds
## Installation
```bash
pip install hindsight-langgraph
```
## Quick Start: Tools (LangChain & LangGraph)
The tools pattern creates standard LangChain `@tool` functions that work with any LangChain-compatible model via `bind_tools()`. You can use them with a LangGraph agent or with plain LangChain — no LangGraph required.
**With LangGraph (recommended):**
```python
from hindsight_client import Hindsight
from hindsight_langgraph import create_hindsight_tools
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
client = Hindsight(base_url="http://localhost:8888")
tools = create_hindsight_tools(client=client, bank_id="user-123")
agent = create_react_agent(ChatOpenAI(model="gpt-4o"), tools=tools)
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": "Remember that I prefer dark mode"}]}
)
```
**With plain LangChain:**
```python
from hindsight_client import Hindsight
from hindsight_langgraph import create_hindsight_tools
from langchain_openai import ChatOpenAI
client = Hindsight(base_url="http://localhost:8888")
tools = create_hindsight_tools(client=client, bank_id="user-123")
model = ChatOpenAI(model="gpt-4o").bind_tools(tools)
response = await model.ainvoke("Remember that I prefer dark mode")
```
When using plain LangChain, you handle the tool execution loop yourself — call the model, check for `tool_calls`, execute them, and feed results back. LangGraph automates this loop for you.
The agent gets three tools it can call:
- **`hindsight_retain`** — Store information to long-term memory
- **`hindsight_recall`** — Search long-term memory for relevant facts
- **`hindsight_reflect`** — Synthesize a reasoned answer from memories
## Quick Start: Memory Nodes (LangGraph)
Add recall and retain nodes to your graph for automatic memory injection and storage.
```python
from hindsight_client import Hindsight
from hindsight_langgraph import create_recall_node, create_retain_node
from langgraph.graph import StateGraph, MessagesState, START, END
client = Hindsight(base_url="http://localhost:8888")
recall = create_recall_node(client=client, bank_id="user-123")
retain = create_retain_node(client=client, bank_id="user-123")
builder = StateGraph(MessagesState)
builder.add_node("recall", recall)
builder.add_node("agent", agent_node) # your LLM node
builder.add_node("retain", retain)
builder.add_edge(START, "recall")
builder.add_edge("recall", "agent")
builder.add_edge("agent", "retain")
builder.add_edge("retain", END)
graph = builder.compile()
```
The recall node extracts the latest user message, searches Hindsight, and injects matching memories as a `SystemMessage`. The retain node stores human messages (optionally AI messages too) after the response.
## Quick Start: BaseStore (LangGraph)
Use Hindsight as a LangGraph `BaseStore` for cross-thread persistent memory with semantic search.
```python
from hindsight_client import Hindsight
from hindsight_langgraph import HindsightStore
client = Hindsight(base_url="http://localhost:8888")
store = HindsightStore(client=client)
graph = builder.compile(checkpointer=checkpointer, store=store)
# Store and search via the store API
await store.aput(("user", "123", "prefs"), "theme", {"value": "dark mode"})
results = await store.asearch(("user", "123", "prefs"), query="theme preference")
```
Namespace tuples are mapped to Hindsight bank IDs with `.` as separator (e.g., `("user", "123")` becomes bank `user.123`). Banks are auto-created on first access.
## Dynamic Bank IDs
Both nodes and the store support per-user bank resolution from `RunnableConfig`:
```python
recall = create_recall_node(client=client, bank_id_from_config="user_id")
retain = create_retain_node(client=client, bank_id_from_config="user_id")
# Bank ID resolved at runtime from config
result = await graph.ainvoke(
{"messages": [{"role": "user", "content": "hello"}]},
config={"configurable": {"user_id": "user-456"}},
)
```
## Selecting Tools
Include only the tools you need:
```python
tools = create_hindsight_tools(
client=client,
bank_id="user-123",
include_retain=True,
include_recall=True,
include_reflect=False, # Omit reflect
)
```
## Global Configuration
Instead of passing a client to every call, configure once:
```python
from hindsight_langgraph import configure, create_hindsight_tools
configure(
hindsight_api_url="http://localhost:8888",
api_key="your-api-key", # Or set HINDSIGHT_API_KEY env var
budget="mid", # Recall budget: low/mid/high
max_tokens=4096, # Max tokens for recall results
tags=["env:prod"], # Tags for stored memories
recall_tags=["scope:global"], # Tags to filter recall
recall_tags_match="any", # Tag match mode: any/all/any_strict/all_strict
)
# Now create tools without passing client — uses global config
tools = create_hindsight_tools(bank_id="user-123")
```
## Retain Node Options
```python
retain = create_retain_node(
client=client,
bank_id="user-123",
retain_human=True, # Store human messages (default: True)
retain_ai=False, # Store AI responses (default: False)
tags=["source:chat"], # Tags applied to stored memories
)
```
## Recall Node Options
```python
recall = create_recall_node(
client=client,
bank_id="user-123",
budget="low", # Recall budget: low/mid/high
max_results=10, # Max memories injected
max_tokens=4096, # Max tokens for recall
tags=["scope:user"], # Filter by tags
tags_match="all", # Tag match mode
)
```
### Using `output_key` for Prompt Control
By default, the recall node appends a `SystemMessage` to `messages`. Use `output_key` to write memory text to a custom state field instead, giving you full control over prompt ordering:
```python
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 before calling the model.
```
## Limitations and Notes
### HindsightStore
- **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.
- **`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 may appear missing.
- **`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.
- **`delete` is a no-op.** Calling `adelete()` logs a debug message but does not remove data from Hindsight. Hindsight's memory model is append-oriented; fact superseding is handled automatically during retain.
### Memory Nodes
- **SystemMessage ordering.** The recall node adds a `SystemMessage` with recalled memories. Because `MessagesState` uses `add_messages` (which appends), this message appears after existing messages rather than at position 0. The message has a stable ID (`hindsight_memory_context`) so it is updated rather than duplicated across invocations. If your LLM provider requires system messages first, sort or filter messages in your agent node before passing them to the model.
### Error Handling
- **Tools** raise `HindsightError` on failure, which surfaces to the agent as a tool error.
- **Nodes** silently log errors and return empty messages, so a Hindsight outage does not crash your graph.
## API Reference
### `create_hindsight_tools()`
| Parameter | Default | Description |
|---|---|---|
| `bank_id` | *required* | Hindsight memory bank ID |
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `budget` | `"mid"` | Recall/reflect budget level (low/mid/high) |
| `max_tokens` | `4096` | Maximum tokens for recall results |
| `tags` | `None` | Tags applied when storing memories |
| `recall_tags` | `None` | Tags to filter when searching |
| `recall_tags_match` | `"any"` | Tag matching mode (any/all/any\_strict/all\_strict) |
| `retain_metadata` | `None` | Default metadata dict for retain operations |
| `retain_document_id` | `None` | Default document\_id for retain (groups/upserts memories) |
| `recall_types` | `None` | Fact types to filter (world, experience, opinion, observation) |
| `recall_include_entities` | `False` | Include entity information in recall results |
| `reflect_context` | `None` | Additional context for reflect operations |
| `reflect_max_tokens` | `None` | Max tokens for reflect results (defaults to `max_tokens`) |
| `reflect_response_schema` | `None` | JSON schema to constrain reflect output format |
| `reflect_tags` | `None` | Tags to filter memories used in reflect (defaults to `recall_tags`) |
| `reflect_tags_match` | `None` | Tag matching for reflect (defaults to `recall_tags_match`) |
| `include_retain` | `True` | Include the retain (store) tool |
| `include_recall` | `True` | Include the recall (search) tool |
| `include_reflect` | `True` | Include the reflect (synthesize) tool |
### `create_recall_node()`
| Parameter | Default | Description |
|---|---|---|
| `bank_id` | `None` | Static bank ID (or use `bank_id_from_config`) |
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `budget` | `"mid"` | Recall budget level |
| `max_tokens` | `4096` | Max tokens for recall results |
| `max_results` | `10` | Max memories to inject |
| `tags` | `None` | Tags to filter recall results |
| `tags_match` | `"any"` | Tag matching mode |
| `bank_id_from_config` | `"user_id"` | Config key to resolve bank ID at runtime |
| `output_key` | `None` | If set, write memory text to this state key instead of appending a SystemMessage to `messages` |
### `create_retain_node()`
| Parameter | Default | Description |
|---|---|---|
| `bank_id` | `None` | Static bank ID (or use `bank_id_from_config`) |
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `tags` | `None` | Tags applied to stored memories |
| `bank_id_from_config` | `"user_id"` | Config key to resolve bank ID at runtime |
| `retain_human` | `True` | Store human messages |
| `retain_ai` | `False` | Store AI responses |
### `HindsightStore()`
| Parameter | Default | Description |
|---|---|---|
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `tags` | `None` | Tags applied to all retain operations |
### `configure()`
| Parameter | Default | Description |
|---|---|---|
| `hindsight_api_url` | Production API | Hindsight API URL |
| `api_key` | `HINDSIGHT_API_KEY` env | API key for authentication |
| `budget` | `"mid"` | Default recall budget level |
| `max_tokens` | `4096` | Default max tokens for recall |
| `tags` | `None` | Default tags for retain operations |
| `recall_tags` | `None` | Default tags to filter recall |
| `recall_tags_match` | `"any"` | Default tag matching mode |
| `verbose` | `False` | Enable verbose logging |
## Requirements
- Python >= 3.10
- langchain-core >= 0.3.0
- hindsight-client >= 0.4.0
- langgraph >= 0.3.0 *(only for nodes and store patterns — install with `pip install hindsight-langgraph[langgraph]`)*
+6
View File
@@ -226,6 +226,12 @@ const sidebars: SidebarsConfig = {
label: 'Hermes Agent',
customProps: { icon: '/img/icons/hermes.png' },
},
{
type: 'doc',
id: 'sdks/integrations/langgraph',
label: 'LangGraph / LangChain',
customProps: { icon: '/img/icons/langgraph.png' },
},
{
type: 'doc',
id: 'sdks/integrations/skills',
@@ -0,0 +1,11 @@
---
hide_table_of_contents: true
---
# LangGraph Integration Changelog
Changelog for [`hindsight-langgraph`](https://pypi.org/project/hindsight-langgraph/) — LangGraph memory integration with tools, nodes, and store patterns.
For the source code, see [`hindsight-integrations/langgraph`](https://github.com/vectorize-io/hindsight/tree/main/hindsight-integrations/langgraph).
← [Back to main changelog](/changelog)
+144
View File
@@ -0,0 +1,144 @@
# hindsight-langgraph
LangGraph and LangChain integration for [Hindsight](https://github.com/vectorize-io/hindsight) — persistent long-term memory for AI agents.
Provides three integration patterns:
- **Tools** — retain/recall/reflect as LangChain `@tool` functions for agent-driven memory. Works with **both LangChain and LangGraph**.
- **Nodes** *(LangGraph)* — pre-built graph nodes for automatic memory injection and storage
- **BaseStore** *(LangGraph)* — drop-in `BaseStore` adapter for LangGraph's built-in memory system
## Prerequisites
- A running Hindsight instance ([self-hosted via Docker](https://github.com/vectorize-io/hindsight#quick-start) or [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup))
- Python 3.10+
## Installation
```bash
pip install hindsight-langgraph
```
## Quick Start: Tools
Bind Hindsight memory tools to your LangGraph agent so it can store and retrieve memories on demand.
```python
from hindsight_client import Hindsight
from hindsight_langgraph import create_hindsight_tools
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
client = Hindsight(base_url="http://localhost:8888")
tools = create_hindsight_tools(client=client, bank_id="user-123")
agent = create_react_agent(
ChatOpenAI(model="gpt-4o"),
tools=tools,
)
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": "Remember that I prefer dark mode"}]}
)
```
## Quick Start: Memory Nodes
Add recall and retain nodes to your graph for automatic memory injection before LLM calls and storage after responses.
```python
from hindsight_client import Hindsight
from hindsight_langgraph import create_recall_node, create_retain_node
from langgraph.graph import StateGraph, MessagesState, START, END
client = Hindsight(base_url="http://localhost:8888")
recall = create_recall_node(client=client, bank_id="user-123")
retain = create_retain_node(client=client, bank_id="user-123")
builder = StateGraph(MessagesState)
builder.add_node("recall", recall)
builder.add_node("agent", agent_node) # your LLM node
builder.add_node("retain", retain)
builder.add_edge(START, "recall")
builder.add_edge("recall", "agent")
builder.add_edge("agent", "retain")
builder.add_edge("retain", END)
graph = builder.compile()
```
### Dynamic Bank IDs
Use `bank_id_from_config` to resolve the bank per-request from the graph's config:
```python
recall = create_recall_node(client=client, bank_id_from_config="user_id")
retain = create_retain_node(client=client, bank_id_from_config="user_id")
# Bank ID resolved at runtime
result = await graph.ainvoke(
{"messages": [{"role": "user", "content": "hello"}]},
config={"configurable": {"user_id": "user-456"}},
)
```
## Quick Start: BaseStore
Use Hindsight as a LangGraph `BaseStore` for cross-thread persistent memory with semantic search.
```python
from hindsight_client import Hindsight
from hindsight_langgraph import HindsightStore
client = Hindsight(base_url="http://localhost:8888")
store = HindsightStore(client=client)
graph = builder.compile(checkpointer=checkpointer, store=store)
# Store and search memories via the store API
await store.aput(("user", "123", "prefs"), "theme", {"value": "dark mode"})
results = await store.asearch(("user", "123", "prefs"), query="theme preference")
```
## Configuration
### Global config
```python
from hindsight_langgraph import configure
configure(
hindsight_api_url="http://localhost:8888",
api_key="your-api-key", # or set HINDSIGHT_API_KEY env var
budget="mid",
tags=["source:langgraph"],
)
```
### Per-call overrides
All factory functions accept `client`, `hindsight_api_url`, and `api_key` to override the global config.
| Parameter | Description | Default |
|-----------|-------------|---------|
| `hindsight_api_url` | Hindsight API URL | `https://api.hindsight.vectorize.io` |
| `api_key` | API key (or `HINDSIGHT_API_KEY` env var) | `None` |
| `budget` | Recall budget: `low`, `mid`, `high` | `mid` |
| `max_tokens` | Max tokens for recall results | `4096` |
| `tags` | Tags applied to retain operations | `None` |
| `recall_tags` | Tags to filter recall results | `None` |
| `recall_tags_match` | Tag matching: `any`, `all`, `any_strict`, `all_strict` | `any` |
## Requirements
- Python 3.10+
- `langchain-core >= 0.3.0`
- `hindsight-client >= 0.4.0`
- `langgraph >= 0.3.0` *(only for nodes and store patterns — install with `pip install hindsight-langgraph[langgraph]`)*
## Documentation
- [Integration docs](https://docs.hindsight.vectorize.io/docs/sdks/integrations/langgraph)
- [Cookbook: ReAct agent with memory](https://docs.hindsight.vectorize.io/cookbook/recipes/langgraph-react-agent)
- [Hindsight API docs](https://docs.hindsight.vectorize.io)
@@ -0,0 +1,93 @@
"""Hindsight-LangGraph: Persistent memory for LangGraph and LangChain agents.
Provides Hindsight-backed tools, nodes, and a BaseStore adapter,
giving agents long-term memory across conversations.
The **tools** pattern works with both LangChain and LangGraph — only
``langchain-core`` is required. The **nodes** and **store** patterns
require ``langgraph`` (install with ``pip install hindsight-langgraph[langgraph]``).
Basic usage with tools (LangChain or LangGraph)::
from hindsight_client import Hindsight
from hindsight_langgraph import create_hindsight_tools
client = Hindsight(base_url="http://localhost:8888")
tools = create_hindsight_tools(client=client, bank_id="user-123")
# Bind tools to your model
model = ChatOpenAI(model="gpt-4o").bind_tools(tools)
Usage with memory nodes (requires langgraph)::
from hindsight_langgraph import create_recall_node, create_retain_node
recall = create_recall_node(client=client, bank_id="user-123")
retain = create_retain_node(client=client, bank_id="user-123")
builder.add_node("recall", recall)
builder.add_node("agent", agent_node)
builder.add_node("retain", retain)
builder.add_edge("recall", "agent")
builder.add_edge("agent", "retain")
Usage with BaseStore (requires langgraph)::
from hindsight_langgraph import HindsightStore
store = HindsightStore(client=client)
graph = builder.compile(checkpointer=checkpointer, store=store)
"""
from .config import (
HindsightLangGraphConfig,
configure,
get_config,
reset_config,
)
from .errors import HindsightError
from .tools import create_hindsight_tools
def __getattr__(name: str):
"""Lazy-import LangGraph-specific modules so langgraph is optional."""
if name == "create_recall_node" or name == "create_retain_node":
try:
from .nodes import create_recall_node, create_retain_node
except ImportError:
raise ImportError(
f"'{name}' requires langgraph. Install with: pip install hindsight-langgraph[langgraph]"
) from None
return (
create_recall_node if name == "create_recall_node" else create_retain_node
)
if name == "HindsightStore":
try:
from .store import HindsightStore
except ImportError:
raise ImportError(
"HindsightStore requires langgraph. Install with: pip install hindsight-langgraph[langgraph]"
) from None
return HindsightStore
raise AttributeError(f"module 'hindsight_langgraph' has no attribute {name!r}")
__version__ = "0.1.0"
__all__ = [
"configure",
"get_config",
"reset_config",
"HindsightLangGraphConfig",
"HindsightError",
"create_hindsight_tools",
]
try:
import langgraph # noqa: F401
__all__ += ["create_recall_node", "create_retain_node", "HindsightStore"]
except ImportError:
pass
@@ -0,0 +1,32 @@
"""Shared Hindsight client resolution logic."""
from typing import Any, Optional
from hindsight_client import Hindsight
from .config import get_config
from .errors import HindsightError
def resolve_client(
client: Optional[Hindsight],
hindsight_api_url: Optional[str],
api_key: Optional[str],
) -> Hindsight:
"""Resolve a Hindsight client from explicit args or global config."""
if client is not None:
return client
config = get_config()
url = hindsight_api_url or (config.hindsight_api_url if config else None)
key = api_key or (config.api_key if config else None)
if url is None:
raise HindsightError(
"No Hindsight API URL configured. Pass client= or hindsight_api_url=, or call configure() first."
)
kwargs: dict[str, Any] = {"base_url": url, "timeout": 30.0}
if key:
kwargs["api_key"] = key
return Hindsight(**kwargs)
@@ -0,0 +1,91 @@
"""Global configuration for Hindsight-LangGraph integration."""
import os
from dataclasses import dataclass
from typing import Optional
DEFAULT_HINDSIGHT_API_URL = "https://api.hindsight.vectorize.io"
HINDSIGHT_API_KEY_ENV = "HINDSIGHT_API_KEY"
@dataclass
class HindsightLangGraphConfig:
"""Connection and default settings for the LangGraph integration.
Attributes:
hindsight_api_url: URL of the Hindsight API server.
api_key: API key for Hindsight authentication.
budget: Default recall budget level (low/mid/high).
max_tokens: Default maximum tokens for recall results.
tags: Default tags applied when storing memories.
recall_tags: Default tags to filter when searching memories.
recall_tags_match: Tag matching mode (any/all/any_strict/all_strict).
verbose: Enable verbose logging.
"""
hindsight_api_url: str = DEFAULT_HINDSIGHT_API_URL
api_key: Optional[str] = None
budget: str = "mid"
max_tokens: int = 4096
tags: Optional[list[str]] = None
recall_tags: Optional[list[str]] = None
recall_tags_match: str = "any"
verbose: bool = False
_global_config: Optional[HindsightLangGraphConfig] = None
def configure(
hindsight_api_url: Optional[str] = None,
api_key: Optional[str] = None,
budget: str = "mid",
max_tokens: int = 4096,
tags: Optional[list[str]] = None,
recall_tags: Optional[list[str]] = None,
recall_tags_match: str = "any",
verbose: bool = False,
) -> HindsightLangGraphConfig:
"""Configure Hindsight connection and default settings.
Args:
hindsight_api_url: Hindsight API URL (default: production).
api_key: API key. Falls back to HINDSIGHT_API_KEY env var.
budget: Default recall budget (low/mid/high).
max_tokens: Default max tokens for recall.
tags: Default tags for retain operations.
recall_tags: Default tags to filter recall/search.
recall_tags_match: Tag matching mode.
verbose: Enable verbose logging.
Returns:
The configured HindsightLangGraphConfig.
"""
global _global_config
resolved_url = hindsight_api_url or DEFAULT_HINDSIGHT_API_URL
resolved_key = api_key or os.environ.get(HINDSIGHT_API_KEY_ENV)
_global_config = HindsightLangGraphConfig(
hindsight_api_url=resolved_url,
api_key=resolved_key,
budget=budget,
max_tokens=max_tokens,
tags=tags,
recall_tags=recall_tags,
recall_tags_match=recall_tags_match,
verbose=verbose,
)
return _global_config
def get_config() -> Optional[HindsightLangGraphConfig]:
"""Get the current global configuration."""
return _global_config
def reset_config() -> None:
"""Reset global configuration to None."""
global _global_config
_global_config = None
@@ -0,0 +1,7 @@
"""Hindsight-LangGraph error types."""
class HindsightError(Exception):
"""Exception raised when a Hindsight memory operation fails."""
pass
@@ -0,0 +1,259 @@
"""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
@@ -0,0 +1,430 @@
"""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
@@ -0,0 +1,217 @@
"""LangGraph tool definitions for Hindsight memory operations.
Provides factory functions that create LangGraph-compatible tool functions
backed by Hindsight's retain/recall/reflect APIs. These tools can be bound
to a ChatModel via `model.bind_tools()` or used in a ToolNode.
"""
import logging
from typing import Any, Optional
from hindsight_client import Hindsight
from langchain_core.tools import tool
from ._client import resolve_client
from .config import get_config
from .errors import HindsightError
logger = logging.getLogger(__name__)
def create_hindsight_tools(
*,
bank_id: str,
client: Optional[Hindsight] = None,
hindsight_api_url: Optional[str] = None,
api_key: Optional[str] = None,
budget: Optional[str] = None,
max_tokens: Optional[int] = None,
tags: Optional[list[str]] = None,
recall_tags: Optional[list[str]] = None,
recall_tags_match: Optional[str] = None,
# Retain options
retain_metadata: Optional[dict[str, str]] = None,
retain_document_id: Optional[str] = None,
# Recall options
recall_types: Optional[list[str]] = None,
recall_include_entities: bool = False,
# Reflect options
reflect_context: Optional[str] = None,
reflect_max_tokens: Optional[int] = None,
reflect_response_schema: Optional[dict[str, Any]] = None,
reflect_tags: Optional[list[str]] = None,
reflect_tags_match: Optional[str] = None,
include_retain: bool = True,
include_recall: bool = True,
include_reflect: bool = True,
) -> list:
"""Create Hindsight memory tools for a LangGraph agent.
Returns a list of LangChain tool instances compatible with LangGraph's
ToolNode and ChatModel.bind_tools().
Args:
bank_id: The Hindsight memory bank to operate on.
client: Pre-configured Hindsight client (preferred).
hindsight_api_url: API URL (used if no client provided).
api_key: API key (used if no client provided).
budget: Recall/reflect budget level (low/mid/high).
max_tokens: Maximum tokens for recall results.
tags: Tags applied when storing memories via retain.
recall_tags: Tags to filter when searching memories.
recall_tags_match: Tag matching mode (any/all/any_strict/all_strict).
retain_metadata: Default metadata dict for retain operations.
retain_document_id: Default document_id for retain (groups/upserts memories).
recall_types: Fact types to filter (world, experience, opinion, observation).
recall_include_entities: Include entity information in recall results.
reflect_context: Additional context for reflect operations.
reflect_max_tokens: Max tokens for reflect results (defaults to max_tokens).
reflect_response_schema: JSON schema to constrain reflect output format.
reflect_tags: Tags to filter memories used in reflect (defaults to recall_tags).
reflect_tags_match: Tag matching for reflect (defaults to recall_tags_match).
include_retain: Include the retain (store) tool.
include_recall: Include the recall (search) tool.
include_reflect: Include the reflect (synthesize) tool.
Returns:
List of LangChain tool instances.
Raises:
HindsightError: If no client or API URL can be resolved.
"""
resolved_client = resolve_client(client, hindsight_api_url, api_key)
config = get_config()
effective_tags = tags if tags is not None else (config.tags if config else None)
effective_recall_tags = (
recall_tags
if recall_tags is not None
else (config.recall_tags if config else None)
)
effective_recall_tags_match = (
recall_tags_match
if recall_tags_match is not None
else (config.recall_tags_match if config else "any")
)
effective_budget = (
budget if budget is not None else (config.budget if config else "mid")
)
effective_max_tokens = (
max_tokens
if max_tokens is not None
else (config.max_tokens if config else 4096)
)
tools: list = []
if include_retain:
@tool
async def hindsight_retain(content: str) -> str:
"""Store information to long-term memory for later retrieval.
Use this to save important facts, user preferences, decisions,
or any information that should be remembered across conversations.
Args:
content: The information to store in memory.
"""
try:
retain_kwargs: dict[str, Any] = {"bank_id": bank_id, "content": content}
if effective_tags:
retain_kwargs["tags"] = effective_tags
if retain_metadata:
retain_kwargs["metadata"] = retain_metadata
if retain_document_id:
retain_kwargs["document_id"] = retain_document_id
await resolved_client.aretain(**retain_kwargs)
return "Memory stored successfully."
except Exception as e:
logger.error(f"Retain failed: {e}")
raise HindsightError(f"Retain failed: {e}") from e
tools.append(hindsight_retain)
if include_recall:
@tool
async def hindsight_recall(query: str) -> str:
"""Search long-term memory for relevant information.
Use this to find previously stored facts, preferences, or context.
Returns a numbered list of matching memories.
Args:
query: What to search for in memory.
"""
try:
recall_kwargs: dict[str, Any] = {
"bank_id": bank_id,
"query": query,
"budget": effective_budget,
"max_tokens": effective_max_tokens,
}
if effective_recall_tags:
recall_kwargs["tags"] = effective_recall_tags
recall_kwargs["tags_match"] = effective_recall_tags_match
if recall_types:
recall_kwargs["types"] = recall_types
if recall_include_entities:
recall_kwargs["include_entities"] = True
response = await resolved_client.arecall(**recall_kwargs)
if not response.results:
return "No relevant memories found."
lines = []
for i, result in enumerate(response.results, 1):
lines.append(f"{i}. {result.text}")
return "\n".join(lines)
except Exception as e:
logger.error(f"Recall failed: {e}")
raise HindsightError(f"Recall failed: {e}") from e
tools.append(hindsight_recall)
if include_reflect:
@tool
async def hindsight_reflect(query: str) -> str:
"""Synthesize a thoughtful answer from long-term memories.
Use this when you need a coherent summary or reasoned response
about what you know, rather than raw memory facts.
Args:
query: The question to reflect on using stored memories.
"""
try:
reflect_kwargs: dict[str, Any] = {
"bank_id": bank_id,
"query": query,
"budget": effective_budget,
}
if reflect_context:
reflect_kwargs["context"] = reflect_context
effective_reflect_max = reflect_max_tokens or effective_max_tokens
if effective_reflect_max:
reflect_kwargs["max_tokens"] = effective_reflect_max
if reflect_response_schema:
reflect_kwargs["response_schema"] = reflect_response_schema
# Reflect tags: use reflect-specific or fall back to recall tags
effective_reflect_tags = (
reflect_tags if reflect_tags is not None else effective_recall_tags
)
effective_reflect_tags_match = (
reflect_tags_match or effective_recall_tags_match
)
if effective_reflect_tags:
reflect_kwargs["tags"] = effective_reflect_tags
reflect_kwargs["tags_match"] = effective_reflect_tags_match
response = await resolved_client.areflect(**reflect_kwargs)
return response.text or "No relevant memories found."
except Exception as e:
logger.error(f"Reflect failed: {e}")
raise HindsightError(f"Reflect failed: {e}") from e
tools.append(hindsight_reflect)
return tools
@@ -0,0 +1,63 @@
[project]
name = "hindsight-langgraph"
version = "0.1.0"
description = "LangGraph integration for Hindsight - persistent memory tools, nodes, and store for AI agents"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [
{ name = "Vectorize", email = "[email protected]" }
]
keywords = [
"ai",
"memory",
"langgraph",
"langchain",
"agents",
"hindsight",
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
dependencies = [
"langchain-core>=0.3.0",
"hindsight-client>=0.4.0",
]
[project.optional-dependencies]
langgraph = [
"langgraph>=0.3.0",
]
all = [
"langgraph>=0.3.0",
]
[project.urls]
Homepage = "https://github.com/vectorize-io/hindsight"
Documentation = "https://github.com/vectorize-io/hindsight/tree/main/hindsight-integrations/langgraph"
Repository = "https://github.com/vectorize-io/hindsight"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["hindsight_langgraph"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
[dependency-groups]
dev = [
"hindsight-langgraph[langgraph]",
"pytest>=9.0.2",
"pytest-asyncio>=0.23.0",
]
@@ -0,0 +1,45 @@
"""Manual integration test — requires running Hindsight API on localhost:8888."""
import asyncio
from hindsight_client import Hindsight
from hindsight_langgraph import create_hindsight_tools
async def main():
client = Hindsight(base_url="http://localhost:8888")
# Create a test bank
await client.acreate_bank("langgraph-test", name="LangGraph Test")
tools = create_hindsight_tools(client=client, bank_id="langgraph-test")
retain, recall, reflect = tools
# Test retain
print("--- Retain ---")
result = await retain.ainvoke("The user's favorite language is Python")
print(result)
result = await retain.ainvoke("The user lives in San Francisco")
print(result)
# Give the engine a moment to process
await asyncio.sleep(2)
# Test recall
print("\n--- Recall ---")
result = await recall.ainvoke("What programming language does the user like?")
print(result)
# Test reflect
print("\n--- Reflect ---")
result = await reflect.ainvoke("What do you know about the user?")
print(result)
# Cleanup
await client.adelete_bank("langgraph-test")
print("\n--- Done, bank cleaned up ---")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,56 @@
"""Manual test of recall/retain nodes with a real graph."""
import asyncio
from hindsight_client import Hindsight
from hindsight_langgraph import create_recall_node, create_retain_node
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.graph import END, START, MessagesState, StateGraph
async def main():
client = Hindsight(base_url="http://localhost:8888")
await client.acreate_bank("langgraph-nodes-test", name="Nodes Test")
recall = create_recall_node(client=client, bank_id="langgraph-nodes-test")
retain = create_retain_node(client=client, bank_id="langgraph-nodes-test")
# Fake agent node that just echoes
async def agent_node(state: MessagesState):
last = state["messages"][-1]
return {"messages": [AIMessage(content=f"I heard: {last.content}")]}
builder = StateGraph(MessagesState)
builder.add_node("recall", recall)
builder.add_node("agent", agent_node)
builder.add_node("retain", retain)
builder.add_edge(START, "recall")
builder.add_edge("recall", "agent")
builder.add_edge("agent", "retain")
builder.add_edge("retain", END)
graph = builder.compile()
# First invocation — no memories yet
print("--- First call (no memories) ---")
result = await graph.ainvoke(
{"messages": [HumanMessage(content="I love hiking in the mountains")]}
)
for msg in result["messages"]:
print(f" [{msg.type}] {msg.content[:100]}")
await asyncio.sleep(2)
# Second invocation — should recall the hiking memory
print("\n--- Second call (should recall hiking) ---")
result = await graph.ainvoke(
{"messages": [HumanMessage(content="What outdoor activities do I enjoy?")]}
)
for msg in result["messages"]:
print(f" [{msg.type}] {msg.content[:100]}")
await client.adelete_bank("langgraph-nodes-test")
print("\n--- Done, bank cleaned up ---")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,47 @@
"""Manual test of HindsightStore as a LangGraph BaseStore."""
import asyncio
from hindsight_client import Hindsight
from hindsight_langgraph import HindsightStore
async def main():
client = Hindsight(base_url="http://localhost:8888")
store = HindsightStore(client=client)
ns = ("user", "test-store-123")
# Put some values
print("--- Storing via put ---")
await store.aput(ns, "pref-theme", {"preference": "dark mode", "category": "ui"})
await store.aput(ns, "pref-lang", {"preference": "Python", "category": "coding"})
print("Stored 2 items")
await asyncio.sleep(2)
# Search
print("\n--- Searching ---")
results = await store.asearch(ns, query="programming language preference")
for item in results:
print(f" key={item.key} value={item.value} score={item.score:.2f}")
# Get specific
print("\n--- Get by key ---")
item = await store.aget(ns, "pref-theme")
if item:
print(f" key={item.key} value={item.value}")
# List namespaces
print("\n--- List namespaces ---")
namespaces = await store.alist_namespaces()
for ns_item in namespaces:
print(f" {ns_item}")
# Cleanup (bank_id uses "." separator: "user.test-store-123")
await client.adelete_bank("user.test-store-123")
print("\n--- Done, bank cleaned up ---")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,269 @@
"""Unit tests for Hindsight LangGraph nodes."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from hindsight_langgraph import create_recall_node, create_retain_node
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
def _mock_client():
client = MagicMock()
client.aretain = AsyncMock()
client.arecall = AsyncMock()
return client
def _mock_recall_response(texts: list[str]):
response = MagicMock()
results = []
for t in texts:
r = MagicMock()
r.text = t
results.append(r)
response.results = results
return response
class TestRecallNode:
@pytest.mark.asyncio
async def test_injects_memories_as_system_message(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response(
["User likes Python", "User is in NYC"]
)
node = create_recall_node(bank_id="test-bank", client=client)
state = {"messages": [HumanMessage(content="What do you remember about me?")]}
result = await node(state)
assert len(result["messages"]) == 1
msg = result["messages"][0]
assert isinstance(msg, SystemMessage)
assert "User likes Python" in msg.content
assert "User is in NYC" in msg.content
@pytest.mark.asyncio
async def test_returns_empty_when_no_human_message(self):
client = _mock_client()
node = create_recall_node(bank_id="test-bank", client=client)
state = {"messages": [SystemMessage(content="You are a helpful assistant")]}
result = await node(state)
assert result["messages"] == []
client.arecall.assert_not_called()
@pytest.mark.asyncio
async def test_returns_empty_when_no_results(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response([])
node = create_recall_node(bank_id="test-bank", client=client)
state = {"messages": [HumanMessage(content="hello")]}
result = await node(state)
assert result["messages"] == []
@pytest.mark.asyncio
async def test_respects_max_results(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response(
["fact1", "fact2", "fact3", "fact4", "fact5"]
)
node = create_recall_node(bank_id="test-bank", client=client, max_results=2)
state = {"messages": [HumanMessage(content="query")]}
result = await node(state)
msg = result["messages"][0]
assert "1. fact1" in msg.content
assert "2. fact2" in msg.content
assert "3." not in msg.content
@pytest.mark.asyncio
async def test_resolves_bank_id_from_config(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response(["fact"])
node = create_recall_node(client=client, bank_id_from_config="user_id")
state = {"messages": [HumanMessage(content="hello")]}
config = {"configurable": {"user_id": "user-456"}}
await node(state, config=config)
call_kwargs = client.arecall.call_args[1]
assert call_kwargs["bank_id"] == "user-456"
@pytest.mark.asyncio
async def test_skips_when_no_bank_id(self):
client = _mock_client()
node = create_recall_node(client=client)
state = {"messages": [HumanMessage(content="hello")]}
result = await node(state)
assert result["messages"] == []
client.arecall.assert_not_called()
@pytest.mark.asyncio
async def test_handles_recall_error_gracefully(self):
client = _mock_client()
client.arecall.side_effect = RuntimeError("connection refused")
node = create_recall_node(bank_id="test-bank", client=client)
state = {"messages": [HumanMessage(content="hello")]}
result = await node(state)
assert result["messages"] == []
@pytest.mark.asyncio
async def test_passes_tags(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response(["fact"])
node = create_recall_node(
bank_id="test-bank",
client=client,
tags=["scope:user"],
tags_match="all",
)
state = {"messages": [HumanMessage(content="hello")]}
await node(state)
call_kwargs = client.arecall.call_args[1]
assert call_kwargs["tags"] == ["scope:user"]
assert call_kwargs["tags_match"] == "all"
class TestRecallNodeOutputKey:
@pytest.mark.asyncio
async def test_output_key_returns_memory_text(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response(
["User likes Python", "User is in NYC"]
)
node = create_recall_node(
bank_id="test-bank", client=client, output_key="memory_context"
)
state = {"messages": [HumanMessage(content="What do you remember?")]}
result = await node(state)
assert "messages" not in result
assert "memory_context" in result
assert "User likes Python" in result["memory_context"]
assert "User is in NYC" in result["memory_context"]
@pytest.mark.asyncio
async def test_output_key_returns_none_when_no_results(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response([])
node = create_recall_node(
bank_id="test-bank", client=client, output_key="memory_context"
)
state = {"messages": [HumanMessage(content="hello")]}
result = await node(state)
assert result == {"memory_context": None}
@pytest.mark.asyncio
async def test_output_key_returns_none_on_error(self):
client = _mock_client()
client.arecall.side_effect = RuntimeError("connection refused")
node = create_recall_node(
bank_id="test-bank", client=client, output_key="memory_context"
)
state = {"messages": [HumanMessage(content="hello")]}
result = await node(state)
assert result == {"memory_context": None}
class TestRetainNode:
@pytest.mark.asyncio
async def test_retains_human_messages(self):
client = _mock_client()
node = create_retain_node(bank_id="test-bank", client=client)
state = {
"messages": [
HumanMessage(content="I like pizza"),
AIMessage(content="Got it!"),
]
}
await node(state)
client.aretain.assert_called_once()
call_kwargs = client.aretain.call_args[1]
assert call_kwargs["bank_id"] == "test-bank"
assert "I like pizza" in call_kwargs["content"]
assert "Got it!" not in call_kwargs["content"]
@pytest.mark.asyncio
async def test_retains_both_when_configured(self):
client = _mock_client()
node = create_retain_node(
bank_id="test-bank", client=client, retain_human=True, retain_ai=True
)
state = {
"messages": [
HumanMessage(content="I like pizza"),
AIMessage(content="Got it!"),
]
}
await node(state)
call_kwargs = client.aretain.call_args[1]
assert "I like pizza" in call_kwargs["content"]
assert "Got it!" in call_kwargs["content"]
@pytest.mark.asyncio
async def test_skips_when_no_messages_match(self):
client = _mock_client()
node = create_retain_node(
bank_id="test-bank", client=client, retain_human=False, retain_ai=False
)
state = {"messages": [HumanMessage(content="hello")]}
await node(state)
client.aretain.assert_not_called()
@pytest.mark.asyncio
async def test_passes_tags(self):
client = _mock_client()
node = create_retain_node(
bank_id="test-bank", client=client, tags=["source:chat"]
)
state = {"messages": [HumanMessage(content="hello")]}
await node(state)
call_kwargs = client.aretain.call_args[1]
assert call_kwargs["tags"] == ["source:chat"]
@pytest.mark.asyncio
async def test_resolves_bank_id_from_config(self):
client = _mock_client()
node = create_retain_node(client=client, bank_id_from_config="user_id")
state = {"messages": [HumanMessage(content="hello")]}
config = {"configurable": {"user_id": "user-789"}}
await node(state, config=config)
call_kwargs = client.aretain.call_args[1]
assert call_kwargs["bank_id"] == "user-789"
@pytest.mark.asyncio
async def test_handles_retain_error_gracefully(self):
client = _mock_client()
client.aretain.side_effect = RuntimeError("connection refused")
node = create_retain_node(bank_id="test-bank", client=client)
state = {"messages": [HumanMessage(content="hello")]}
# Should not raise
result = await node(state)
assert result["messages"] == []
@@ -0,0 +1,297 @@
"""Unit tests for Hindsight LangGraph BaseStore adapter."""
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from hindsight_langgraph.errors import HindsightError
from hindsight_langgraph.store import (
HindsightStore,
_namespace_to_bank_id,
_parse_value,
)
def _mock_client():
client = MagicMock()
client.aretain = AsyncMock()
client.arecall = AsyncMock()
client.acreate_bank = AsyncMock()
return client
def _mock_recall_response(texts: list[str], document_ids: list[str] | None = None):
response = MagicMock()
results = []
for i, t in enumerate(texts):
r = MagicMock()
r.text = t
r.document_id = document_ids[i] if document_ids else None
r.occurred_start = None
results.append(r)
response.results = results
return response
class TestNamespaceMapping:
def test_simple_namespace(self):
assert _namespace_to_bank_id(("user", "123")) == "user.123"
def test_single_element(self):
assert _namespace_to_bank_id(("memories",)) == "memories"
def test_empty_namespace(self):
assert _namespace_to_bank_id(()) == "default"
def test_deep_namespace(self):
assert (
_namespace_to_bank_id(("org", "team", "user", "123")) == "org.team.user.123"
)
class TestParseValue:
def test_parses_json_dict(self):
assert _parse_value('{"name": "Alice"}') == {"name": "Alice"}
def test_wraps_plain_text(self):
assert _parse_value("hello world") == {"text": "hello world"}
def test_wraps_json_non_dict(self):
assert _parse_value("[1, 2, 3]") == {"text": "[1, 2, 3]"}
class TestHindsightStorePut:
@pytest.mark.asyncio
async def test_put_calls_retain(self):
client = _mock_client()
store = HindsightStore(client=client)
await store.aput(("user", "123"), "pref-1", {"color": "blue"})
client.aretain.assert_called_once()
call_kwargs = client.aretain.call_args[1]
assert call_kwargs["bank_id"] == "user.123"
assert call_kwargs["document_id"] == "pref-1"
assert json.loads(call_kwargs["content"]) == {"color": "blue"}
@pytest.mark.asyncio
async def test_put_passes_tags(self):
client = _mock_client()
store = HindsightStore(client=client, tags=["source:langgraph"])
await store.aput(("user", "123"), "key", {"value": 1})
call_kwargs = client.aretain.call_args[1]
assert call_kwargs["tags"] == ["source:langgraph"]
@pytest.mark.asyncio
async def test_put_tracks_namespace(self):
client = _mock_client()
store = HindsightStore(client=client)
await store.aput(("user", "123"), "key", {"value": 1})
namespaces = await store.alist_namespaces()
assert ("user", "123") in namespaces
@pytest.mark.asyncio
async def test_put_none_value_is_delete_noop(self):
client = _mock_client()
store = HindsightStore(client=client)
await store.adelete(("user", "123"), "key")
client.aretain.assert_not_called()
@pytest.mark.asyncio
async def test_put_raises_on_error(self):
client = _mock_client()
client.aretain.side_effect = RuntimeError("connection refused")
store = HindsightStore(client=client)
with pytest.raises(HindsightError, match="Store put failed"):
await store.aput(("user", "123"), "key", {"value": 1})
class TestHindsightStoreGet:
@pytest.mark.asyncio
async def test_get_returns_item_by_document_id(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response(
['{"color": "blue"}'], document_ids=["pref-1"]
)
store = HindsightStore(client=client)
item = await store.aget(("user", "123"), "pref-1")
assert item is not None
assert item.namespace == ("user", "123")
assert item.key == "pref-1"
assert item.value == {"color": "blue"}
@pytest.mark.asyncio
async def test_get_returns_none_when_empty(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response([])
store = HindsightStore(client=client)
item = await store.aget(("user", "123"), "nonexistent")
assert item is None
@pytest.mark.asyncio
async def test_get_handles_error_gracefully(self):
client = _mock_client()
client.arecall.side_effect = RuntimeError("timeout")
store = HindsightStore(client=client)
item = await store.aget(("user", "123"), "key")
assert item is None
class TestHindsightStoreSearch:
@pytest.mark.asyncio
async def test_search_returns_results(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response(
["User likes Python", "User is in NYC"]
)
store = HindsightStore(client=client)
results = await store.asearch(("user", "123"), query="preferences")
assert len(results) == 2
assert results[0].value == {"text": "User likes Python"}
assert results[1].value == {"text": "User is in NYC"}
@pytest.mark.asyncio
async def test_search_respects_limit(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response(
["fact1", "fact2", "fact3", "fact4", "fact5"]
)
store = HindsightStore(client=client)
results = await store.asearch(("user", "123"), query="facts", limit=2)
assert len(results) == 2
@pytest.mark.asyncio
async def test_search_empty_results(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response([])
store = HindsightStore(client=client)
results = await store.asearch(("user", "123"), query="anything")
assert results == []
@pytest.mark.asyncio
async def test_search_with_filter(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response(
[
'{"type": "preference", "text": "likes Python"}',
'{"type": "fact", "text": "lives in NYC"}',
]
)
store = HindsightStore(client=client)
results = await store.asearch(
("user", "123"), query="info", filter={"type": "preference"}
)
assert len(results) == 1
assert results[0].value["type"] == "preference"
class TestHindsightStoreListNamespaces:
@pytest.mark.asyncio
async def test_lists_known_namespaces(self):
client = _mock_client()
store = HindsightStore(client=client)
await store.aput(("user", "123"), "k1", {"v": 1})
await store.aput(("user", "456"), "k2", {"v": 2})
namespaces = await store.alist_namespaces()
assert len(namespaces) == 2
assert ("user", "123") in namespaces
assert ("user", "456") in namespaces
@pytest.mark.asyncio
async def test_list_respects_max_depth(self):
"""max_depth truncates deep namespaces and deduplicates per BaseStore contract."""
client = _mock_client()
store = HindsightStore(client=client)
await store.aput(("a",), "k", {"v": 1})
await store.aput(("a", "b", "c"), "k", {"v": 2})
await store.aput(("x", "y"), "k", {"v": 3})
namespaces = await store.alist_namespaces(max_depth=1)
# ("a",) stays as-is, ("a", "b", "c") truncated to ("a",) and deduped,
# ("x", "y") truncated to ("x",)
assert ("a",) in namespaces
assert ("x",) in namespaces
assert ("a", "b", "c") not in namespaces
assert ("x", "y") not in namespaces
assert len(namespaces) == 2
@pytest.mark.asyncio
async def test_list_filters_by_prefix(self):
client = _mock_client()
store = HindsightStore(client=client)
await store.aput(("user", "123"), "k1", {"v": 1})
await store.aput(("user", "456"), "k2", {"v": 2})
await store.aput(("org", "abc"), "k3", {"v": 3})
namespaces = await store.alist_namespaces(prefix=("user",))
assert ("user", "123") in namespaces
assert ("user", "456") in namespaces
assert ("org", "abc") not in namespaces
@pytest.mark.asyncio
async def test_list_filters_by_suffix(self):
client = _mock_client()
store = HindsightStore(client=client)
await store.aput(("user", "prefs"), "k1", {"v": 1})
await store.aput(("org", "prefs"), "k2", {"v": 2})
await store.aput(("user", "history"), "k3", {"v": 3})
namespaces = await store.alist_namespaces(suffix=("prefs",))
assert ("user", "prefs") in namespaces
assert ("org", "prefs") in namespaces
assert ("user", "history") not in namespaces
@pytest.mark.asyncio
async def test_list_filters_by_prefix_and_suffix(self):
client = _mock_client()
store = HindsightStore(client=client)
await store.aput(("user", "prefs"), "k1", {"v": 1})
await store.aput(("org", "prefs"), "k2", {"v": 2})
await store.aput(("user", "history"), "k3", {"v": 3})
namespaces = await store.alist_namespaces(prefix=("user",), suffix=("prefs",))
assert namespaces == [("user", "prefs")]
@pytest.mark.asyncio
async def test_list_respects_limit(self):
client = _mock_client()
store = HindsightStore(client=client)
for i in range(5):
await store.aput((f"ns-{i}",), "k", {"v": i})
namespaces = await store.alist_namespaces(limit=2)
assert len(namespaces) == 2
@@ -0,0 +1,400 @@
"""Unit tests for Hindsight LangGraph tools."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from hindsight_langgraph import (
configure,
create_hindsight_tools,
reset_config,
)
from hindsight_langgraph.errors import HindsightError
def _mock_client():
"""Create a mock Hindsight client with async methods."""
client = MagicMock()
client.aretain = AsyncMock()
client.arecall = AsyncMock()
client.areflect = AsyncMock()
return client
def _mock_recall_response(texts: list[str]):
response = MagicMock()
results = []
for t in texts:
r = MagicMock()
r.text = t
results.append(r)
response.results = results
return response
def _mock_reflect_response(text: str):
response = MagicMock()
response.text = text
return response
def _mock_retain_response():
response = MagicMock()
response.success = True
return response
class TestCreateHindsightTools:
def setup_method(self):
reset_config()
def teardown_method(self):
reset_config()
def test_returns_three_tools_by_default(self):
client = _mock_client()
tools = create_hindsight_tools(bank_id="test", client=client)
assert len(tools) == 3
def test_include_retain_only(self):
client = _mock_client()
tools = create_hindsight_tools(
bank_id="test",
client=client,
include_retain=True,
include_recall=False,
include_reflect=False,
)
assert len(tools) == 1
assert tools[0].name == "hindsight_retain"
def test_include_recall_only(self):
client = _mock_client()
tools = create_hindsight_tools(
bank_id="test",
client=client,
include_retain=False,
include_recall=True,
include_reflect=False,
)
assert len(tools) == 1
assert tools[0].name == "hindsight_recall"
def test_include_reflect_only(self):
client = _mock_client()
tools = create_hindsight_tools(
bank_id="test",
client=client,
include_retain=False,
include_recall=False,
include_reflect=True,
)
assert len(tools) == 1
assert tools[0].name == "hindsight_reflect"
def test_no_tools_when_all_excluded(self):
client = _mock_client()
tools = create_hindsight_tools(
bank_id="test",
client=client,
include_retain=False,
include_recall=False,
include_reflect=False,
)
assert len(tools) == 0
def test_raises_without_client_or_config(self):
with pytest.raises(HindsightError, match="No Hindsight API URL"):
create_hindsight_tools(bank_id="test")
def test_falls_back_to_global_config(self):
configure(hindsight_api_url="http://localhost:8888")
with patch("hindsight_langgraph._client.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
tools = create_hindsight_tools(bank_id="test")
assert len(tools) == 3
mock_cls.assert_called_once_with(
base_url="http://localhost:8888", timeout=30.0
)
def test_explicit_url_overrides_config(self):
configure(hindsight_api_url="http://config:8888")
with patch("hindsight_langgraph._client.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
create_hindsight_tools(
bank_id="test", hindsight_api_url="http://explicit:9999"
)
mock_cls.assert_called_once_with(
base_url="http://explicit:9999", timeout=30.0
)
class TestRetainTool:
@pytest.mark.asyncio
async def test_retain_stores_memory(self):
client = _mock_client()
client.aretain.return_value = _mock_retain_response()
tools = create_hindsight_tools(
bank_id="test-bank",
client=client,
include_recall=False,
include_reflect=False,
)
result = await tools[0].ainvoke("The user likes Python")
assert result == "Memory stored successfully."
client.aretain.assert_called_once_with(
bank_id="test-bank", content="The user likes Python"
)
@pytest.mark.asyncio
async def test_retain_passes_tags(self):
client = _mock_client()
client.aretain.return_value = _mock_retain_response()
tools = create_hindsight_tools(
bank_id="test-bank",
client=client,
tags=["source:chat"],
include_recall=False,
include_reflect=False,
)
await tools[0].ainvoke("some content")
call_kwargs = client.aretain.call_args[1]
assert call_kwargs["tags"] == ["source:chat"]
@pytest.mark.asyncio
async def test_retain_raises_hindsight_error(self):
client = _mock_client()
client.aretain.side_effect = RuntimeError("connection refused")
tools = create_hindsight_tools(
bank_id="test",
client=client,
include_recall=False,
include_reflect=False,
)
with pytest.raises(HindsightError, match="Retain failed"):
await tools[0].ainvoke("content")
class TestRecallTool:
@pytest.mark.asyncio
async def test_recall_returns_numbered_results(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response(
["User likes Python", "User is in NYC"]
)
tools = create_hindsight_tools(
bank_id="test-bank",
client=client,
include_retain=False,
include_reflect=False,
)
result = await tools[0].ainvoke("user preferences")
assert "1. User likes Python" in result
assert "2. User is in NYC" in result
@pytest.mark.asyncio
async def test_recall_empty_results(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response([])
tools = create_hindsight_tools(
bank_id="test",
client=client,
include_retain=False,
include_reflect=False,
)
result = await tools[0].ainvoke("anything")
assert result == "No relevant memories found."
@pytest.mark.asyncio
async def test_recall_passes_budget_and_max_tokens(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response(["fact"])
tools = create_hindsight_tools(
bank_id="test",
client=client,
budget="high",
max_tokens=2048,
include_retain=False,
include_reflect=False,
)
await tools[0].ainvoke("query")
call_kwargs = client.arecall.call_args[1]
assert call_kwargs["budget"] == "high"
assert call_kwargs["max_tokens"] == 2048
@pytest.mark.asyncio
async def test_recall_passes_tags(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response(["fact"])
tools = create_hindsight_tools(
bank_id="test",
client=client,
recall_tags=["scope:user"],
recall_tags_match="all",
include_retain=False,
include_reflect=False,
)
await tools[0].ainvoke("query")
call_kwargs = client.arecall.call_args[1]
assert call_kwargs["tags"] == ["scope:user"]
assert call_kwargs["tags_match"] == "all"
class TestReflectTool:
@pytest.mark.asyncio
async def test_reflect_returns_text(self):
client = _mock_client()
client.areflect.return_value = _mock_reflect_response(
"The user is a Python developer who prefers functional patterns."
)
tools = create_hindsight_tools(
bank_id="test-bank",
client=client,
include_retain=False,
include_recall=False,
)
result = await tools[0].ainvoke("What do you know about the user?")
assert (
result == "The user is a Python developer who prefers functional patterns."
)
@pytest.mark.asyncio
async def test_reflect_empty_returns_fallback(self):
client = _mock_client()
client.areflect.return_value = _mock_reflect_response("")
tools = create_hindsight_tools(
bank_id="test",
client=client,
include_retain=False,
include_recall=False,
)
result = await tools[0].ainvoke("anything")
assert result == "No relevant memories found."
@pytest.mark.asyncio
async def test_reflect_passes_budget(self):
client = _mock_client()
client.areflect.return_value = _mock_reflect_response("answer")
tools = create_hindsight_tools(
bank_id="test",
client=client,
budget="high",
include_retain=False,
include_recall=False,
)
await tools[0].ainvoke("query")
call_kwargs = client.areflect.call_args[1]
assert call_kwargs["budget"] == "high"
@pytest.mark.asyncio
async def test_reflect_passes_context(self):
client = _mock_client()
client.areflect.return_value = _mock_reflect_response("answer")
tools = create_hindsight_tools(
bank_id="test",
client=client,
reflect_context="The user is asking about project setup",
include_retain=False,
include_recall=False,
)
await tools[0].ainvoke("query")
call_kwargs = client.areflect.call_args[1]
assert call_kwargs["context"] == "The user is asking about project setup"
@pytest.mark.asyncio
async def test_reflect_passes_max_tokens_and_response_schema(self):
client = _mock_client()
client.areflect.return_value = _mock_reflect_response("answer")
schema = {"type": "object", "properties": {"summary": {"type": "string"}}}
tools = create_hindsight_tools(
bank_id="test",
client=client,
reflect_max_tokens=2048,
reflect_response_schema=schema,
include_retain=False,
include_recall=False,
)
await tools[0].ainvoke("query")
call_kwargs = client.areflect.call_args[1]
assert call_kwargs["max_tokens"] == 2048
assert call_kwargs["response_schema"] == schema
@pytest.mark.asyncio
async def test_reflect_passes_tags(self):
client = _mock_client()
client.areflect.return_value = _mock_reflect_response("answer")
tools = create_hindsight_tools(
bank_id="test",
client=client,
reflect_tags=["scope:global"],
reflect_tags_match="all",
include_retain=False,
include_recall=False,
)
await tools[0].ainvoke("query")
call_kwargs = client.areflect.call_args[1]
assert call_kwargs["tags"] == ["scope:global"]
assert call_kwargs["tags_match"] == "all"
class TestRetainExtendedParams:
@pytest.mark.asyncio
async def test_retain_passes_metadata(self):
client = _mock_client()
client.aretain.return_value = _mock_retain_response()
tools = create_hindsight_tools(
bank_id="test",
client=client,
retain_metadata={"source": "chat", "session": "abc"},
include_recall=False,
include_reflect=False,
)
await tools[0].ainvoke("content")
call_kwargs = client.aretain.call_args[1]
assert call_kwargs["metadata"] == {"source": "chat", "session": "abc"}
@pytest.mark.asyncio
async def test_retain_passes_document_id(self):
client = _mock_client()
client.aretain.return_value = _mock_retain_response()
tools = create_hindsight_tools(
bank_id="test",
client=client,
retain_document_id="session-123",
include_recall=False,
include_reflect=False,
)
await tools[0].ainvoke("content")
call_kwargs = client.aretain.call_args[1]
assert call_kwargs["document_id"] == "session-123"
class TestRecallExtendedParams:
@pytest.mark.asyncio
async def test_recall_passes_types(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response(["fact"])
tools = create_hindsight_tools(
bank_id="test",
client=client,
recall_types=["world", "experience"],
include_retain=False,
include_reflect=False,
)
await tools[0].ainvoke("query")
call_kwargs = client.arecall.call_args[1]
assert call_kwargs["types"] == ["world", "experience"]
@pytest.mark.asyncio
async def test_recall_passes_include_entities(self):
client = _mock_client()
client.arecall.return_value = _mock_recall_response(["fact"])
tools = create_hindsight_tools(
bank_id="test",
client=client,
recall_include_entities=True,
include_retain=False,
include_reflect=False,
)
await tools[0].ainvoke("query")
call_kwargs = client.arecall.call_args[1]
assert call_kwargs["include_entities"] is True
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -13,7 +13,7 @@ print_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
print_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
VALID_INTEGRATIONS=("litellm" "pydantic-ai" "crewai" "ai-sdk" "chat" "openclaw")
VALID_INTEGRATIONS=("litellm" "pydantic-ai" "crewai" "ai-sdk" "chat" "openclaw" "langgraph")
usage() {
print_error "Usage: $0 <integration> <version>"