Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa74841f3a | ||
|
|
f8304a1631 | ||
|
|
9814240835 | ||
|
|
9ef886c4b3 | ||
|
|
11a8242b5e | ||
|
|
73184dd0fa | ||
|
|
c5a3e4ab93 | ||
|
|
54c187d792 | ||
|
|
7e0aebddf8 | ||
|
|
d22d745ecd | ||
|
|
b6c6f5d90b |
@@ -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]`)*
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Generated
+1854
File diff suppressed because it is too large
Load Diff
@@ -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>"
|
||||
|
||||
Reference in New Issue
Block a user