hindsight-langgraph 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hindsight_langgraph/__init__.py +93 -0
- hindsight_langgraph/_client.py +32 -0
- hindsight_langgraph/config.py +91 -0
- hindsight_langgraph/errors.py +7 -0
- hindsight_langgraph/nodes.py +259 -0
- hindsight_langgraph/store.py +430 -0
- hindsight_langgraph/tools.py +217 -0
- hindsight_langgraph-0.1.1.dist-info/METADATA +25 -0
- hindsight_langgraph-0.1.1.dist-info/RECORD +10 -0
- hindsight_langgraph-0.1.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Hindsight-LangGraph: Persistent memory for LangGraph and LangChain agents.
|
|
2
|
+
|
|
3
|
+
Provides Hindsight-backed tools, nodes, and a BaseStore adapter,
|
|
4
|
+
giving agents long-term memory across conversations.
|
|
5
|
+
|
|
6
|
+
The **tools** pattern works with both LangChain and LangGraph — only
|
|
7
|
+
``langchain-core`` is required. The **nodes** and **store** patterns
|
|
8
|
+
require ``langgraph`` (install with ``pip install hindsight-langgraph[langgraph]``).
|
|
9
|
+
|
|
10
|
+
Basic usage with tools (LangChain or LangGraph)::
|
|
11
|
+
|
|
12
|
+
from hindsight_client import Hindsight
|
|
13
|
+
from hindsight_langgraph import create_hindsight_tools
|
|
14
|
+
|
|
15
|
+
client = Hindsight(base_url="http://localhost:8888")
|
|
16
|
+
tools = create_hindsight_tools(client=client, bank_id="user-123")
|
|
17
|
+
|
|
18
|
+
# Bind tools to your model
|
|
19
|
+
model = ChatOpenAI(model="gpt-4o").bind_tools(tools)
|
|
20
|
+
|
|
21
|
+
Usage with memory nodes (requires langgraph)::
|
|
22
|
+
|
|
23
|
+
from hindsight_langgraph import create_recall_node, create_retain_node
|
|
24
|
+
|
|
25
|
+
recall = create_recall_node(client=client, bank_id="user-123")
|
|
26
|
+
retain = create_retain_node(client=client, bank_id="user-123")
|
|
27
|
+
|
|
28
|
+
builder.add_node("recall", recall)
|
|
29
|
+
builder.add_node("agent", agent_node)
|
|
30
|
+
builder.add_node("retain", retain)
|
|
31
|
+
builder.add_edge("recall", "agent")
|
|
32
|
+
builder.add_edge("agent", "retain")
|
|
33
|
+
|
|
34
|
+
Usage with BaseStore (requires langgraph)::
|
|
35
|
+
|
|
36
|
+
from hindsight_langgraph import HindsightStore
|
|
37
|
+
|
|
38
|
+
store = HindsightStore(client=client)
|
|
39
|
+
graph = builder.compile(checkpointer=checkpointer, store=store)
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from .config import (
|
|
43
|
+
HindsightLangGraphConfig,
|
|
44
|
+
configure,
|
|
45
|
+
get_config,
|
|
46
|
+
reset_config,
|
|
47
|
+
)
|
|
48
|
+
from .errors import HindsightError
|
|
49
|
+
from .tools import create_hindsight_tools
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def __getattr__(name: str):
|
|
53
|
+
"""Lazy-import LangGraph-specific modules so langgraph is optional."""
|
|
54
|
+
if name == "create_recall_node" or name == "create_retain_node":
|
|
55
|
+
try:
|
|
56
|
+
from .nodes import create_recall_node, create_retain_node
|
|
57
|
+
except ImportError:
|
|
58
|
+
raise ImportError(
|
|
59
|
+
f"'{name}' requires langgraph. Install with: pip install hindsight-langgraph[langgraph]"
|
|
60
|
+
) from None
|
|
61
|
+
return (
|
|
62
|
+
create_recall_node if name == "create_recall_node" else create_retain_node
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if name == "HindsightStore":
|
|
66
|
+
try:
|
|
67
|
+
from .store import HindsightStore
|
|
68
|
+
except ImportError:
|
|
69
|
+
raise ImportError(
|
|
70
|
+
"HindsightStore requires langgraph. Install with: pip install hindsight-langgraph[langgraph]"
|
|
71
|
+
) from None
|
|
72
|
+
return HindsightStore
|
|
73
|
+
|
|
74
|
+
raise AttributeError(f"module 'hindsight_langgraph' has no attribute {name!r}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__version__ = "0.1.0"
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"configure",
|
|
81
|
+
"get_config",
|
|
82
|
+
"reset_config",
|
|
83
|
+
"HindsightLangGraphConfig",
|
|
84
|
+
"HindsightError",
|
|
85
|
+
"create_hindsight_tools",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
import langgraph # noqa: F401
|
|
90
|
+
|
|
91
|
+
__all__ += ["create_recall_node", "create_retain_node", "HindsightStore"]
|
|
92
|
+
except ImportError:
|
|
93
|
+
pass
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Shared Hindsight client resolution logic."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from hindsight_client import Hindsight
|
|
6
|
+
|
|
7
|
+
from .config import get_config
|
|
8
|
+
from .errors import HindsightError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_client(
|
|
12
|
+
client: Optional[Hindsight],
|
|
13
|
+
hindsight_api_url: Optional[str],
|
|
14
|
+
api_key: Optional[str],
|
|
15
|
+
) -> Hindsight:
|
|
16
|
+
"""Resolve a Hindsight client from explicit args or global config."""
|
|
17
|
+
if client is not None:
|
|
18
|
+
return client
|
|
19
|
+
|
|
20
|
+
config = get_config()
|
|
21
|
+
url = hindsight_api_url or (config.hindsight_api_url if config else None)
|
|
22
|
+
key = api_key or (config.api_key if config else None)
|
|
23
|
+
|
|
24
|
+
if url is None:
|
|
25
|
+
raise HindsightError(
|
|
26
|
+
"No Hindsight API URL configured. Pass client= or hindsight_api_url=, or call configure() first."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
kwargs: dict[str, Any] = {"base_url": url, "timeout": 30.0}
|
|
30
|
+
if key:
|
|
31
|
+
kwargs["api_key"] = key
|
|
32
|
+
return Hindsight(**kwargs)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Global configuration for Hindsight-LangGraph integration."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
DEFAULT_HINDSIGHT_API_URL = "https://api.hindsight.vectorize.io"
|
|
8
|
+
HINDSIGHT_API_KEY_ENV = "HINDSIGHT_API_KEY"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class HindsightLangGraphConfig:
|
|
13
|
+
"""Connection and default settings for the LangGraph integration.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
hindsight_api_url: URL of the Hindsight API server.
|
|
17
|
+
api_key: API key for Hindsight authentication.
|
|
18
|
+
budget: Default recall budget level (low/mid/high).
|
|
19
|
+
max_tokens: Default maximum tokens for recall results.
|
|
20
|
+
tags: Default tags applied when storing memories.
|
|
21
|
+
recall_tags: Default tags to filter when searching memories.
|
|
22
|
+
recall_tags_match: Tag matching mode (any/all/any_strict/all_strict).
|
|
23
|
+
verbose: Enable verbose logging.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
hindsight_api_url: str = DEFAULT_HINDSIGHT_API_URL
|
|
27
|
+
api_key: Optional[str] = None
|
|
28
|
+
budget: str = "mid"
|
|
29
|
+
max_tokens: int = 4096
|
|
30
|
+
tags: Optional[list[str]] = None
|
|
31
|
+
recall_tags: Optional[list[str]] = None
|
|
32
|
+
recall_tags_match: str = "any"
|
|
33
|
+
verbose: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_global_config: Optional[HindsightLangGraphConfig] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def configure(
|
|
40
|
+
hindsight_api_url: Optional[str] = None,
|
|
41
|
+
api_key: Optional[str] = None,
|
|
42
|
+
budget: str = "mid",
|
|
43
|
+
max_tokens: int = 4096,
|
|
44
|
+
tags: Optional[list[str]] = None,
|
|
45
|
+
recall_tags: Optional[list[str]] = None,
|
|
46
|
+
recall_tags_match: str = "any",
|
|
47
|
+
verbose: bool = False,
|
|
48
|
+
) -> HindsightLangGraphConfig:
|
|
49
|
+
"""Configure Hindsight connection and default settings.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
hindsight_api_url: Hindsight API URL (default: production).
|
|
53
|
+
api_key: API key. Falls back to HINDSIGHT_API_KEY env var.
|
|
54
|
+
budget: Default recall budget (low/mid/high).
|
|
55
|
+
max_tokens: Default max tokens for recall.
|
|
56
|
+
tags: Default tags for retain operations.
|
|
57
|
+
recall_tags: Default tags to filter recall/search.
|
|
58
|
+
recall_tags_match: Tag matching mode.
|
|
59
|
+
verbose: Enable verbose logging.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The configured HindsightLangGraphConfig.
|
|
63
|
+
"""
|
|
64
|
+
global _global_config
|
|
65
|
+
|
|
66
|
+
resolved_url = hindsight_api_url or DEFAULT_HINDSIGHT_API_URL
|
|
67
|
+
resolved_key = api_key or os.environ.get(HINDSIGHT_API_KEY_ENV)
|
|
68
|
+
|
|
69
|
+
_global_config = HindsightLangGraphConfig(
|
|
70
|
+
hindsight_api_url=resolved_url,
|
|
71
|
+
api_key=resolved_key,
|
|
72
|
+
budget=budget,
|
|
73
|
+
max_tokens=max_tokens,
|
|
74
|
+
tags=tags,
|
|
75
|
+
recall_tags=recall_tags,
|
|
76
|
+
recall_tags_match=recall_tags_match,
|
|
77
|
+
verbose=verbose,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return _global_config
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_config() -> Optional[HindsightLangGraphConfig]:
|
|
84
|
+
"""Get the current global configuration."""
|
|
85
|
+
return _global_config
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def reset_config() -> None:
|
|
89
|
+
"""Reset global configuration to None."""
|
|
90
|
+
global _global_config
|
|
91
|
+
_global_config = None
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Pre-built LangGraph nodes for Hindsight memory operations.
|
|
2
|
+
|
|
3
|
+
Provides node functions that can be added directly to a StateGraph to
|
|
4
|
+
inject memories at conversation start and store new memories after responses.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from hindsight_client import Hindsight
|
|
11
|
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
|
12
|
+
from langchain_core.runnables import RunnableConfig
|
|
13
|
+
from langgraph.graph import MessagesState
|
|
14
|
+
|
|
15
|
+
from ._client import resolve_client
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _extract_text_content(content: Any) -> str:
|
|
21
|
+
"""Extract text from a message content field.
|
|
22
|
+
|
|
23
|
+
Handles both plain string content and multimodal content lists
|
|
24
|
+
(where each item may be a dict with "type" and "text" keys).
|
|
25
|
+
Returns the concatenated text parts, or an empty string if no
|
|
26
|
+
text content is found.
|
|
27
|
+
"""
|
|
28
|
+
if isinstance(content, str):
|
|
29
|
+
return content
|
|
30
|
+
if isinstance(content, list):
|
|
31
|
+
parts = []
|
|
32
|
+
for part in content:
|
|
33
|
+
if isinstance(part, str):
|
|
34
|
+
parts.append(part)
|
|
35
|
+
elif isinstance(part, dict) and part.get("type") == "text":
|
|
36
|
+
parts.append(part.get("text", ""))
|
|
37
|
+
return " ".join(parts)
|
|
38
|
+
return str(content) if content else ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_recall_node(
|
|
42
|
+
*,
|
|
43
|
+
bank_id: Optional[str] = None,
|
|
44
|
+
client: Optional[Hindsight] = None,
|
|
45
|
+
hindsight_api_url: Optional[str] = None,
|
|
46
|
+
api_key: Optional[str] = None,
|
|
47
|
+
budget: str = "mid",
|
|
48
|
+
max_tokens: int = 4096,
|
|
49
|
+
max_results: int = 10,
|
|
50
|
+
tags: Optional[list[str]] = None,
|
|
51
|
+
tags_match: str = "any",
|
|
52
|
+
bank_id_from_config: str = "user_id",
|
|
53
|
+
output_key: Optional[str] = None,
|
|
54
|
+
):
|
|
55
|
+
"""Create a node that injects relevant memories into the conversation.
|
|
56
|
+
|
|
57
|
+
This node extracts the latest user message, recalls relevant memories
|
|
58
|
+
from Hindsight, and returns them either as a SystemMessage in the
|
|
59
|
+
``messages`` list (default) or as a plain string under a custom state
|
|
60
|
+
key via ``output_key``.
|
|
61
|
+
|
|
62
|
+
**Message ordering:** When using the default ``messages`` output,
|
|
63
|
+
``MessagesState`` uses ``add_messages`` as its reducer, which appends.
|
|
64
|
+
The memory SystemMessage will appear after existing messages, not at
|
|
65
|
+
position 0. If your LLM provider requires system messages first, use
|
|
66
|
+
``output_key`` to write the memory text to a separate state field and
|
|
67
|
+
inject it into your prompt in the agent node.
|
|
68
|
+
|
|
69
|
+
Example with ``output_key`` (recommended for correct ordering)::
|
|
70
|
+
|
|
71
|
+
from typing import Optional
|
|
72
|
+
from langgraph.graph import MessagesState
|
|
73
|
+
|
|
74
|
+
class AgentState(MessagesState):
|
|
75
|
+
memory_context: Optional[str] = None
|
|
76
|
+
|
|
77
|
+
recall = create_recall_node(
|
|
78
|
+
client=client, bank_id="user-123", output_key="memory_context"
|
|
79
|
+
)
|
|
80
|
+
# In your agent node, read state["memory_context"] and prepend
|
|
81
|
+
# it to the system prompt.
|
|
82
|
+
|
|
83
|
+
The bank_id can be provided directly or resolved dynamically from
|
|
84
|
+
the graph's RunnableConfig via the ``bank_id_from_config`` key.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
bank_id: Static Hindsight memory bank ID.
|
|
88
|
+
client: Pre-configured Hindsight client.
|
|
89
|
+
hindsight_api_url: API URL (used if no client provided).
|
|
90
|
+
api_key: API key (used if no client provided).
|
|
91
|
+
budget: Recall budget level (low/mid/high).
|
|
92
|
+
max_tokens: Maximum tokens for recall results.
|
|
93
|
+
max_results: Maximum number of memories to inject.
|
|
94
|
+
tags: Tags to filter recall results.
|
|
95
|
+
tags_match: Tag matching mode.
|
|
96
|
+
bank_id_from_config: Config key to read bank_id from at runtime.
|
|
97
|
+
Looked up in ``config["configurable"][bank_id_from_config]``.
|
|
98
|
+
Only used when ``bank_id`` is not provided.
|
|
99
|
+
output_key: If set, write the memory text to this state key as a
|
|
100
|
+
plain string instead of appending a SystemMessage to ``messages``.
|
|
101
|
+
Use this with a custom state type to control where memory context
|
|
102
|
+
appears in your prompt.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
An async node function compatible with LangGraph StateGraph.
|
|
106
|
+
"""
|
|
107
|
+
resolved_client = resolve_client(client, hindsight_api_url, api_key)
|
|
108
|
+
|
|
109
|
+
async def recall_node(
|
|
110
|
+
state: MessagesState, config: Optional[RunnableConfig] = None
|
|
111
|
+
) -> dict[str, Any]:
|
|
112
|
+
resolved_bank_id = bank_id
|
|
113
|
+
if resolved_bank_id is None and config:
|
|
114
|
+
configurable = config.get("configurable", {})
|
|
115
|
+
resolved_bank_id = configurable.get(bank_id_from_config)
|
|
116
|
+
|
|
117
|
+
if not resolved_bank_id:
|
|
118
|
+
logger.warning(
|
|
119
|
+
"No bank_id available for recall node, skipping memory injection."
|
|
120
|
+
)
|
|
121
|
+
if output_key:
|
|
122
|
+
return {output_key: None}
|
|
123
|
+
return {"messages": []}
|
|
124
|
+
|
|
125
|
+
# Extract query from the latest human message
|
|
126
|
+
query = None
|
|
127
|
+
for msg in reversed(state["messages"]):
|
|
128
|
+
if isinstance(msg, HumanMessage):
|
|
129
|
+
query = _extract_text_content(msg.content)
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
if not query:
|
|
133
|
+
if output_key:
|
|
134
|
+
return {output_key: None}
|
|
135
|
+
return {"messages": []}
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
recall_kwargs: dict[str, Any] = {
|
|
139
|
+
"bank_id": resolved_bank_id,
|
|
140
|
+
"query": query,
|
|
141
|
+
"budget": budget,
|
|
142
|
+
"max_tokens": max_tokens,
|
|
143
|
+
}
|
|
144
|
+
if tags:
|
|
145
|
+
recall_kwargs["tags"] = tags
|
|
146
|
+
recall_kwargs["tags_match"] = tags_match
|
|
147
|
+
|
|
148
|
+
response = await resolved_client.arecall(**recall_kwargs)
|
|
149
|
+
results = response.results[:max_results] if response.results else []
|
|
150
|
+
|
|
151
|
+
if not results:
|
|
152
|
+
if output_key:
|
|
153
|
+
return {output_key: None}
|
|
154
|
+
return {"messages": []}
|
|
155
|
+
|
|
156
|
+
lines = ["Relevant memories about this user:"]
|
|
157
|
+
for i, result in enumerate(results, 1):
|
|
158
|
+
lines.append(f"{i}. {result.text}")
|
|
159
|
+
memory_text = "\n".join(lines)
|
|
160
|
+
|
|
161
|
+
if output_key:
|
|
162
|
+
return {output_key: memory_text}
|
|
163
|
+
return {
|
|
164
|
+
"messages": [
|
|
165
|
+
SystemMessage(content=memory_text, id="hindsight_memory_context")
|
|
166
|
+
]
|
|
167
|
+
}
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.error(f"Recall node failed: {e}")
|
|
170
|
+
if output_key:
|
|
171
|
+
return {output_key: None}
|
|
172
|
+
return {"messages": []}
|
|
173
|
+
|
|
174
|
+
return recall_node
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def create_retain_node(
|
|
178
|
+
*,
|
|
179
|
+
bank_id: Optional[str] = None,
|
|
180
|
+
client: Optional[Hindsight] = None,
|
|
181
|
+
hindsight_api_url: Optional[str] = None,
|
|
182
|
+
api_key: Optional[str] = None,
|
|
183
|
+
tags: Optional[list[str]] = None,
|
|
184
|
+
bank_id_from_config: str = "user_id",
|
|
185
|
+
retain_human: bool = True,
|
|
186
|
+
retain_ai: bool = False,
|
|
187
|
+
):
|
|
188
|
+
"""Create a node that stores conversation messages as memories.
|
|
189
|
+
|
|
190
|
+
This node extracts messages from the conversation and stores them
|
|
191
|
+
via Hindsight retain. It should be placed after the LLM response
|
|
192
|
+
node in your graph.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
bank_id: Static Hindsight memory bank ID.
|
|
196
|
+
client: Pre-configured Hindsight client.
|
|
197
|
+
hindsight_api_url: API URL (used if no client provided).
|
|
198
|
+
api_key: API key (used if no client provided).
|
|
199
|
+
tags: Tags to apply to stored memories.
|
|
200
|
+
bank_id_from_config: Config key to read bank_id from at runtime.
|
|
201
|
+
retain_human: Store human messages as memories.
|
|
202
|
+
retain_ai: Store AI responses as memories.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
An async node function compatible with LangGraph StateGraph.
|
|
206
|
+
"""
|
|
207
|
+
resolved_client = resolve_client(client, hindsight_api_url, api_key)
|
|
208
|
+
|
|
209
|
+
async def retain_node(
|
|
210
|
+
state: MessagesState, config: Optional[RunnableConfig] = None
|
|
211
|
+
) -> dict[str, Any]:
|
|
212
|
+
resolved_bank_id = bank_id
|
|
213
|
+
if resolved_bank_id is None and config:
|
|
214
|
+
configurable = config.get("configurable", {})
|
|
215
|
+
resolved_bank_id = configurable.get(bank_id_from_config)
|
|
216
|
+
|
|
217
|
+
if not resolved_bank_id:
|
|
218
|
+
logger.warning(
|
|
219
|
+
"No bank_id available for retain node, skipping memory storage."
|
|
220
|
+
)
|
|
221
|
+
return {"messages": []}
|
|
222
|
+
|
|
223
|
+
# Only retain the latest human and/or AI message to avoid
|
|
224
|
+
# duplicating memories that were already stored in prior calls.
|
|
225
|
+
messages_to_retain = []
|
|
226
|
+
if retain_human:
|
|
227
|
+
for msg in reversed(state["messages"]):
|
|
228
|
+
if isinstance(msg, HumanMessage):
|
|
229
|
+
text = _extract_text_content(msg.content)
|
|
230
|
+
if text:
|
|
231
|
+
messages_to_retain.append(text)
|
|
232
|
+
break
|
|
233
|
+
if retain_ai:
|
|
234
|
+
for msg in reversed(state["messages"]):
|
|
235
|
+
if isinstance(msg, AIMessage):
|
|
236
|
+
text = _extract_text_content(msg.content)
|
|
237
|
+
if text:
|
|
238
|
+
messages_to_retain.append(text)
|
|
239
|
+
break
|
|
240
|
+
|
|
241
|
+
if not messages_to_retain:
|
|
242
|
+
return {"messages": []}
|
|
243
|
+
|
|
244
|
+
content = "\n\n".join(messages_to_retain)
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
retain_kwargs: dict[str, Any] = {
|
|
248
|
+
"bank_id": resolved_bank_id,
|
|
249
|
+
"content": content,
|
|
250
|
+
}
|
|
251
|
+
if tags:
|
|
252
|
+
retain_kwargs["tags"] = tags
|
|
253
|
+
await resolved_client.aretain(**retain_kwargs)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
logger.error(f"Retain node failed: {e}")
|
|
256
|
+
|
|
257
|
+
return {"messages": []}
|
|
258
|
+
|
|
259
|
+
return retain_node
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""LangGraph BaseStore adapter backed by Hindsight.
|
|
2
|
+
|
|
3
|
+
Maps LangGraph's key-value store interface to Hindsight's memory operations.
|
|
4
|
+
Namespace tuples are joined to form bank IDs, and values are stored/retrieved
|
|
5
|
+
via retain/recall.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any, Iterable, Optional
|
|
14
|
+
|
|
15
|
+
from hindsight_client import Hindsight
|
|
16
|
+
from langgraph.store.base import (
|
|
17
|
+
BaseStore,
|
|
18
|
+
GetOp,
|
|
19
|
+
Item,
|
|
20
|
+
ListNamespacesOp,
|
|
21
|
+
PutOp,
|
|
22
|
+
Result,
|
|
23
|
+
SearchItem,
|
|
24
|
+
SearchOp,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from ._client import resolve_client
|
|
28
|
+
from .errors import HindsightError
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _namespace_to_bank_id(namespace: tuple[str, ...]) -> str:
|
|
34
|
+
"""Convert a namespace tuple to a Hindsight bank ID.
|
|
35
|
+
|
|
36
|
+
Uses "." as separator since "/" is not valid in Hindsight bank IDs
|
|
37
|
+
(interpreted as URL path segments).
|
|
38
|
+
"""
|
|
39
|
+
return ".".join(namespace) if namespace else "default"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _make_item(
|
|
43
|
+
namespace: tuple[str, ...],
|
|
44
|
+
key: str,
|
|
45
|
+
value: dict,
|
|
46
|
+
created_at: Optional[datetime] = None,
|
|
47
|
+
) -> Item:
|
|
48
|
+
"""Create a LangGraph Item from Hindsight data."""
|
|
49
|
+
now = datetime.now(timezone.utc)
|
|
50
|
+
return Item(
|
|
51
|
+
namespace=namespace,
|
|
52
|
+
key=key,
|
|
53
|
+
value=value,
|
|
54
|
+
created_at=created_at or now,
|
|
55
|
+
updated_at=now,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _make_search_item(
|
|
60
|
+
namespace: tuple[str, ...],
|
|
61
|
+
key: str,
|
|
62
|
+
value: dict,
|
|
63
|
+
score: float,
|
|
64
|
+
created_at: Optional[datetime] = None,
|
|
65
|
+
) -> SearchItem:
|
|
66
|
+
"""Create a LangGraph SearchItem from Hindsight recall results."""
|
|
67
|
+
now = datetime.now(timezone.utc)
|
|
68
|
+
return SearchItem(
|
|
69
|
+
namespace=namespace,
|
|
70
|
+
key=key,
|
|
71
|
+
value=value,
|
|
72
|
+
score=score,
|
|
73
|
+
created_at=created_at or now,
|
|
74
|
+
updated_at=now,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class HindsightStore(BaseStore):
|
|
79
|
+
"""LangGraph BaseStore implementation backed by Hindsight.
|
|
80
|
+
|
|
81
|
+
Maps LangGraph's namespace/key-value model to Hindsight memory banks:
|
|
82
|
+
- Namespace tuples are joined with "." to form bank IDs
|
|
83
|
+
- ``put()`` stores values via Hindsight retain with the key as document_id
|
|
84
|
+
- ``search()`` uses Hindsight recall for semantic search
|
|
85
|
+
- ``get()`` uses recall with the key as a targeted query, returning only
|
|
86
|
+
exact ``document_id`` matches. If the stored document does not surface in
|
|
87
|
+
the recall window, ``get()`` returns ``None`` even though the item exists.
|
|
88
|
+
Hindsight does not currently expose a direct document-lookup endpoint.
|
|
89
|
+
|
|
90
|
+
**Known limitations:**
|
|
91
|
+
|
|
92
|
+
- **Async-only.** All sync methods (``batch``, ``get``, ``put``, ``delete``,
|
|
93
|
+
``search``, ``list_namespaces``) raise ``NotImplementedError``. Use the
|
|
94
|
+
async variants (``abatch``, ``aget``, ``aput``, ``adelete``, ``asearch``,
|
|
95
|
+
``alist_namespaces``) instead.
|
|
96
|
+
- **``list_namespaces`` is session-scoped.** It only tracks namespaces that
|
|
97
|
+
have been written to via ``aput()`` during the current process. After a
|
|
98
|
+
restart, ``list_namespaces`` returns empty even though data still exists
|
|
99
|
+
in Hindsight. Hindsight does not currently provide a bank-listing API.
|
|
100
|
+
- **``delete`` is a no-op.** Calling ``adelete()`` logs a debug message but
|
|
101
|
+
does not remove data. Hindsight's memory model is append-oriented; fact
|
|
102
|
+
superseding is handled automatically during retain.
|
|
103
|
+
- **``get()`` relies on recall.** There is no direct key lookup — the key
|
|
104
|
+
is used as a recall query and only exact ``document_id`` matches are
|
|
105
|
+
returned. Items that do not rank in the top recall results will appear
|
|
106
|
+
missing.
|
|
107
|
+
|
|
108
|
+
Example::
|
|
109
|
+
|
|
110
|
+
from hindsight_client import Hindsight
|
|
111
|
+
from hindsight_langgraph import HindsightStore
|
|
112
|
+
|
|
113
|
+
store = HindsightStore(client=Hindsight(base_url="http://localhost:8888"))
|
|
114
|
+
graph = builder.compile(checkpointer=checkpointer, store=store)
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
*,
|
|
120
|
+
client: Optional[Hindsight] = None,
|
|
121
|
+
hindsight_api_url: Optional[str] = None,
|
|
122
|
+
api_key: Optional[str] = None,
|
|
123
|
+
tags: Optional[list[str]] = None,
|
|
124
|
+
):
|
|
125
|
+
self._client = resolve_client(client, hindsight_api_url, api_key)
|
|
126
|
+
self._tags = tags
|
|
127
|
+
# Track known namespaces for list_namespaces (session-scoped only)
|
|
128
|
+
self._known_namespaces: set[tuple[str, ...]] = set()
|
|
129
|
+
# Track banks that have been created to avoid repeated create calls
|
|
130
|
+
self._created_banks: set[str] = set()
|
|
131
|
+
# Per-bank locks for concurrency-safe bank creation
|
|
132
|
+
self._bank_locks: dict[str, asyncio.Lock] = {}
|
|
133
|
+
|
|
134
|
+
def batch(
|
|
135
|
+
self, ops: Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp]
|
|
136
|
+
) -> list[Result]:
|
|
137
|
+
raise NotImplementedError("Use abatch() for async operation.")
|
|
138
|
+
|
|
139
|
+
async def abatch(
|
|
140
|
+
self, ops: Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp]
|
|
141
|
+
) -> list[Result]:
|
|
142
|
+
results: list[Result] = []
|
|
143
|
+
for op in ops:
|
|
144
|
+
if isinstance(op, GetOp):
|
|
145
|
+
results.append(await self._handle_get(op))
|
|
146
|
+
elif isinstance(op, PutOp):
|
|
147
|
+
await self._handle_put(op)
|
|
148
|
+
results.append(None)
|
|
149
|
+
elif isinstance(op, SearchOp):
|
|
150
|
+
results.append(await self._handle_search(op))
|
|
151
|
+
elif isinstance(op, ListNamespacesOp):
|
|
152
|
+
results.append(await self._handle_list_namespaces(op))
|
|
153
|
+
else:
|
|
154
|
+
results.append(None)
|
|
155
|
+
return results
|
|
156
|
+
|
|
157
|
+
async def _handle_get(self, op: GetOp) -> Optional[Item]:
|
|
158
|
+
"""Handle a get operation by recalling with the key as query."""
|
|
159
|
+
bank_id = _namespace_to_bank_id(op.namespace)
|
|
160
|
+
try:
|
|
161
|
+
await self._ensure_bank(bank_id)
|
|
162
|
+
response = await self._client.arecall(
|
|
163
|
+
bank_id=bank_id,
|
|
164
|
+
query=op.key,
|
|
165
|
+
budget="low",
|
|
166
|
+
max_tokens=1024,
|
|
167
|
+
)
|
|
168
|
+
if not response.results:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
# Only return a result if the document_id matches the requested key exactly.
|
|
172
|
+
# Do NOT fall back to semantic search — that would violate key-value store semantics.
|
|
173
|
+
for result in response.results:
|
|
174
|
+
doc_id = getattr(result, "document_id", None)
|
|
175
|
+
if doc_id == op.key:
|
|
176
|
+
value = _parse_value(result.text)
|
|
177
|
+
ts = getattr(result, "occurred_start", None)
|
|
178
|
+
return _make_item(op.namespace, op.key, value, created_at=ts)
|
|
179
|
+
|
|
180
|
+
return None
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.error(f"Store get failed for {op.namespace}/{op.key}: {e}")
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
async def _ensure_bank(self, bank_id: str) -> None:
|
|
186
|
+
"""Create a bank if it hasn't been created yet in this session.
|
|
187
|
+
|
|
188
|
+
Uses per-bank locking to prevent concurrent creation races.
|
|
189
|
+
"""
|
|
190
|
+
if bank_id in self._created_banks:
|
|
191
|
+
return
|
|
192
|
+
lock = self._bank_locks.setdefault(bank_id, asyncio.Lock())
|
|
193
|
+
async with lock:
|
|
194
|
+
# Double-check after acquiring the lock
|
|
195
|
+
if bank_id in self._created_banks:
|
|
196
|
+
return
|
|
197
|
+
try:
|
|
198
|
+
await self._client.acreate_bank(bank_id, name=bank_id)
|
|
199
|
+
self._created_banks.add(bank_id)
|
|
200
|
+
except Exception as e:
|
|
201
|
+
error_str = str(e).lower()
|
|
202
|
+
if (
|
|
203
|
+
"already exists" in error_str
|
|
204
|
+
or "conflict" in error_str
|
|
205
|
+
or "409" in error_str
|
|
206
|
+
):
|
|
207
|
+
# Bank already exists — safe to cache
|
|
208
|
+
self._created_banks.add(bank_id)
|
|
209
|
+
else:
|
|
210
|
+
logger.error(f"Failed to create bank '{bank_id}': {e}")
|
|
211
|
+
raise
|
|
212
|
+
|
|
213
|
+
async def _handle_put(self, op: PutOp) -> None:
|
|
214
|
+
"""Handle a put operation by retaining the value."""
|
|
215
|
+
bank_id = _namespace_to_bank_id(op.namespace)
|
|
216
|
+
self._known_namespaces.add(op.namespace)
|
|
217
|
+
|
|
218
|
+
if op.value is None:
|
|
219
|
+
# LangGraph uses value=None as delete
|
|
220
|
+
logger.debug(f"Delete not supported for {op.namespace}/{op.key}, skipping.")
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
await self._ensure_bank(bank_id)
|
|
225
|
+
content = (
|
|
226
|
+
json.dumps(op.value) if isinstance(op.value, dict) else str(op.value)
|
|
227
|
+
)
|
|
228
|
+
retain_kwargs: dict[str, Any] = {
|
|
229
|
+
"bank_id": bank_id,
|
|
230
|
+
"content": content,
|
|
231
|
+
"document_id": op.key,
|
|
232
|
+
}
|
|
233
|
+
if self._tags:
|
|
234
|
+
retain_kwargs["tags"] = self._tags
|
|
235
|
+
await self._client.aretain(**retain_kwargs)
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.error(f"Store put failed for {op.namespace}/{op.key}: {e}")
|
|
238
|
+
raise HindsightError(f"Store put failed: {e}") from e
|
|
239
|
+
|
|
240
|
+
async def _handle_search(self, op: SearchOp) -> list[SearchItem]:
|
|
241
|
+
"""Handle a search operation via Hindsight recall."""
|
|
242
|
+
bank_id = _namespace_to_bank_id(op.namespace_prefix)
|
|
243
|
+
query = op.query or "*"
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
await self._ensure_bank(bank_id)
|
|
247
|
+
recall_kwargs: dict[str, Any] = {
|
|
248
|
+
"bank_id": bank_id,
|
|
249
|
+
"query": query,
|
|
250
|
+
"budget": "mid",
|
|
251
|
+
"max_tokens": 4096,
|
|
252
|
+
}
|
|
253
|
+
response = await self._client.arecall(**recall_kwargs)
|
|
254
|
+
if not response.results:
|
|
255
|
+
return []
|
|
256
|
+
|
|
257
|
+
# Build all candidate items first
|
|
258
|
+
all_items = []
|
|
259
|
+
for i, result in enumerate(response.results):
|
|
260
|
+
value = _parse_value(result.text)
|
|
261
|
+
doc_id = getattr(result, "document_id", None) or _content_key(
|
|
262
|
+
result.text
|
|
263
|
+
)
|
|
264
|
+
score = max(
|
|
265
|
+
0.0, 1.0 - (i * 0.01)
|
|
266
|
+
) # Approximate score from rank position
|
|
267
|
+
ts = getattr(result, "occurred_start", None)
|
|
268
|
+
all_items.append(
|
|
269
|
+
_make_search_item(
|
|
270
|
+
op.namespace_prefix, doc_id, value, score=score, created_at=ts
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# Apply filters BEFORE pagination so offset/limit operate on
|
|
275
|
+
# the filtered set rather than discarding matching items.
|
|
276
|
+
if op.filter:
|
|
277
|
+
all_items = [
|
|
278
|
+
item for item in all_items if _matches_filter(item.value, op.filter)
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
limit = op.limit or 10
|
|
282
|
+
offset = op.offset or 0
|
|
283
|
+
return all_items[offset : offset + limit]
|
|
284
|
+
except Exception as e:
|
|
285
|
+
logger.error(f"Store search failed for {op.namespace_prefix}: {e}")
|
|
286
|
+
return []
|
|
287
|
+
|
|
288
|
+
async def _handle_list_namespaces(
|
|
289
|
+
self, op: ListNamespacesOp
|
|
290
|
+
) -> list[tuple[str, ...]]:
|
|
291
|
+
"""List known namespaces. Limited to namespaces seen via put() in this session."""
|
|
292
|
+
namespaces = list(self._known_namespaces)
|
|
293
|
+
|
|
294
|
+
if op.match_conditions:
|
|
295
|
+
filtered = []
|
|
296
|
+
for ns in namespaces:
|
|
297
|
+
match = True
|
|
298
|
+
for cond in op.match_conditions:
|
|
299
|
+
match_type = getattr(cond, "match_type", "prefix")
|
|
300
|
+
if match_type == "prefix":
|
|
301
|
+
if not _namespace_starts_with(ns, cond.path):
|
|
302
|
+
match = False
|
|
303
|
+
break
|
|
304
|
+
elif match_type == "suffix":
|
|
305
|
+
if not _namespace_ends_with(ns, cond.path):
|
|
306
|
+
match = False
|
|
307
|
+
break
|
|
308
|
+
if match:
|
|
309
|
+
filtered.append(ns)
|
|
310
|
+
namespaces = filtered
|
|
311
|
+
|
|
312
|
+
if op.max_depth is not None:
|
|
313
|
+
# Truncate namespaces to max_depth and deduplicate, per BaseStore contract.
|
|
314
|
+
namespaces = list(dict.fromkeys(ns[: op.max_depth] for ns in namespaces))
|
|
315
|
+
|
|
316
|
+
limit = op.limit or 100
|
|
317
|
+
offset = op.offset or 0
|
|
318
|
+
return namespaces[offset : offset + limit]
|
|
319
|
+
|
|
320
|
+
# Sync convenience methods that delegate to async
|
|
321
|
+
|
|
322
|
+
def get(self, namespace: tuple[str, ...], key: str) -> Optional[Item]:
|
|
323
|
+
raise NotImplementedError("Use aget() for async operation.")
|
|
324
|
+
|
|
325
|
+
async def aget(self, namespace: tuple[str, ...], key: str) -> Optional[Item]:
|
|
326
|
+
result = await self.abatch([GetOp(namespace=namespace, key=key)])
|
|
327
|
+
return result[0]
|
|
328
|
+
|
|
329
|
+
def put(
|
|
330
|
+
self,
|
|
331
|
+
namespace: tuple[str, ...],
|
|
332
|
+
key: str,
|
|
333
|
+
value: dict,
|
|
334
|
+
index: Optional[Any] = None,
|
|
335
|
+
) -> None:
|
|
336
|
+
raise NotImplementedError("Use aput() for async operation.")
|
|
337
|
+
|
|
338
|
+
async def aput(
|
|
339
|
+
self,
|
|
340
|
+
namespace: tuple[str, ...],
|
|
341
|
+
key: str,
|
|
342
|
+
value: dict,
|
|
343
|
+
index: Optional[Any] = None,
|
|
344
|
+
ttl: Optional[float] = None,
|
|
345
|
+
) -> None:
|
|
346
|
+
# ttl is accepted for BaseStore compatibility but not used;
|
|
347
|
+
# Hindsight does not support TTL-based expiration natively.
|
|
348
|
+
await self.abatch([PutOp(namespace=namespace, key=key, value=value)])
|
|
349
|
+
|
|
350
|
+
def delete(self, namespace: tuple[str, ...], key: str) -> None:
|
|
351
|
+
raise NotImplementedError("Use adelete() for async operation.")
|
|
352
|
+
|
|
353
|
+
async def adelete(self, namespace: tuple[str, ...], key: str) -> None:
|
|
354
|
+
await self.abatch([PutOp(namespace=namespace, key=key, value=None)])
|
|
355
|
+
|
|
356
|
+
def search(
|
|
357
|
+
self,
|
|
358
|
+
namespace_prefix: tuple[str, ...],
|
|
359
|
+
*,
|
|
360
|
+
query: Optional[str] = None,
|
|
361
|
+
filter: Optional[dict] = None,
|
|
362
|
+
limit: int = 10,
|
|
363
|
+
offset: int = 0,
|
|
364
|
+
) -> list[SearchItem]:
|
|
365
|
+
raise NotImplementedError("Use asearch() for async operation.")
|
|
366
|
+
|
|
367
|
+
async def asearch(
|
|
368
|
+
self,
|
|
369
|
+
namespace_prefix: tuple[str, ...],
|
|
370
|
+
*,
|
|
371
|
+
query: Optional[str] = None,
|
|
372
|
+
filter: Optional[dict] = None,
|
|
373
|
+
limit: int = 10,
|
|
374
|
+
offset: int = 0,
|
|
375
|
+
) -> list[SearchItem]:
|
|
376
|
+
result = await self.abatch(
|
|
377
|
+
[
|
|
378
|
+
SearchOp(
|
|
379
|
+
namespace_prefix=namespace_prefix,
|
|
380
|
+
query=query,
|
|
381
|
+
filter=filter,
|
|
382
|
+
limit=limit,
|
|
383
|
+
offset=offset,
|
|
384
|
+
)
|
|
385
|
+
]
|
|
386
|
+
)
|
|
387
|
+
return result[0]
|
|
388
|
+
|
|
389
|
+
# list_namespaces / alist_namespaces are NOT overridden here.
|
|
390
|
+
# The base class converts prefix=/suffix= kwargs into MatchCondition
|
|
391
|
+
# objects and calls abatch() -> _handle_list_namespaces(). Overriding
|
|
392
|
+
# with a different signature (match_conditions=) would break callers.
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _parse_value(text: str) -> dict:
|
|
396
|
+
"""Try to parse stored text as JSON, fallback to wrapping in a dict."""
|
|
397
|
+
try:
|
|
398
|
+
parsed = json.loads(text)
|
|
399
|
+
if isinstance(parsed, dict):
|
|
400
|
+
return parsed
|
|
401
|
+
except (json.JSONDecodeError, TypeError):
|
|
402
|
+
pass
|
|
403
|
+
return {"text": text}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _content_key(text: str) -> str:
|
|
407
|
+
"""Generate a stable key from content text."""
|
|
408
|
+
return hashlib.sha256(text.encode()).hexdigest()[:12]
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _matches_filter(value: dict, filter_dict: dict) -> bool:
|
|
412
|
+
"""Check if a value dict matches all filter conditions."""
|
|
413
|
+
for key, expected in filter_dict.items():
|
|
414
|
+
if value.get(key) != expected:
|
|
415
|
+
return False
|
|
416
|
+
return True
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _namespace_starts_with(namespace: tuple[str, ...], prefix: tuple[str, ...]) -> bool:
|
|
420
|
+
"""Check if namespace starts with the given prefix."""
|
|
421
|
+
if len(prefix) > len(namespace):
|
|
422
|
+
return False
|
|
423
|
+
return namespace[: len(prefix)] == prefix
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _namespace_ends_with(namespace: tuple[str, ...], suffix: tuple[str, ...]) -> bool:
|
|
427
|
+
"""Check if namespace ends with the given suffix."""
|
|
428
|
+
if len(suffix) > len(namespace):
|
|
429
|
+
return False
|
|
430
|
+
return namespace[len(namespace) - len(suffix) :] == suffix
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""LangGraph tool definitions for Hindsight memory operations.
|
|
2
|
+
|
|
3
|
+
Provides factory functions that create LangGraph-compatible tool functions
|
|
4
|
+
backed by Hindsight's retain/recall/reflect APIs. These tools can be bound
|
|
5
|
+
to a ChatModel via `model.bind_tools()` or used in a ToolNode.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from hindsight_client import Hindsight
|
|
12
|
+
from langchain_core.tools import tool
|
|
13
|
+
|
|
14
|
+
from ._client import resolve_client
|
|
15
|
+
from .config import get_config
|
|
16
|
+
from .errors import HindsightError
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_hindsight_tools(
|
|
22
|
+
*,
|
|
23
|
+
bank_id: str,
|
|
24
|
+
client: Optional[Hindsight] = None,
|
|
25
|
+
hindsight_api_url: Optional[str] = None,
|
|
26
|
+
api_key: Optional[str] = None,
|
|
27
|
+
budget: Optional[str] = None,
|
|
28
|
+
max_tokens: Optional[int] = None,
|
|
29
|
+
tags: Optional[list[str]] = None,
|
|
30
|
+
recall_tags: Optional[list[str]] = None,
|
|
31
|
+
recall_tags_match: Optional[str] = None,
|
|
32
|
+
# Retain options
|
|
33
|
+
retain_metadata: Optional[dict[str, str]] = None,
|
|
34
|
+
retain_document_id: Optional[str] = None,
|
|
35
|
+
# Recall options
|
|
36
|
+
recall_types: Optional[list[str]] = None,
|
|
37
|
+
recall_include_entities: bool = False,
|
|
38
|
+
# Reflect options
|
|
39
|
+
reflect_context: Optional[str] = None,
|
|
40
|
+
reflect_max_tokens: Optional[int] = None,
|
|
41
|
+
reflect_response_schema: Optional[dict[str, Any]] = None,
|
|
42
|
+
reflect_tags: Optional[list[str]] = None,
|
|
43
|
+
reflect_tags_match: Optional[str] = None,
|
|
44
|
+
include_retain: bool = True,
|
|
45
|
+
include_recall: bool = True,
|
|
46
|
+
include_reflect: bool = True,
|
|
47
|
+
) -> list:
|
|
48
|
+
"""Create Hindsight memory tools for a LangGraph agent.
|
|
49
|
+
|
|
50
|
+
Returns a list of LangChain tool instances compatible with LangGraph's
|
|
51
|
+
ToolNode and ChatModel.bind_tools().
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
bank_id: The Hindsight memory bank to operate on.
|
|
55
|
+
client: Pre-configured Hindsight client (preferred).
|
|
56
|
+
hindsight_api_url: API URL (used if no client provided).
|
|
57
|
+
api_key: API key (used if no client provided).
|
|
58
|
+
budget: Recall/reflect budget level (low/mid/high).
|
|
59
|
+
max_tokens: Maximum tokens for recall results.
|
|
60
|
+
tags: Tags applied when storing memories via retain.
|
|
61
|
+
recall_tags: Tags to filter when searching memories.
|
|
62
|
+
recall_tags_match: Tag matching mode (any/all/any_strict/all_strict).
|
|
63
|
+
retain_metadata: Default metadata dict for retain operations.
|
|
64
|
+
retain_document_id: Default document_id for retain (groups/upserts memories).
|
|
65
|
+
recall_types: Fact types to filter (world, experience, opinion, observation).
|
|
66
|
+
recall_include_entities: Include entity information in recall results.
|
|
67
|
+
reflect_context: Additional context for reflect operations.
|
|
68
|
+
reflect_max_tokens: Max tokens for reflect results (defaults to max_tokens).
|
|
69
|
+
reflect_response_schema: JSON schema to constrain reflect output format.
|
|
70
|
+
reflect_tags: Tags to filter memories used in reflect (defaults to recall_tags).
|
|
71
|
+
reflect_tags_match: Tag matching for reflect (defaults to recall_tags_match).
|
|
72
|
+
include_retain: Include the retain (store) tool.
|
|
73
|
+
include_recall: Include the recall (search) tool.
|
|
74
|
+
include_reflect: Include the reflect (synthesize) tool.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
List of LangChain tool instances.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
HindsightError: If no client or API URL can be resolved.
|
|
81
|
+
"""
|
|
82
|
+
resolved_client = resolve_client(client, hindsight_api_url, api_key)
|
|
83
|
+
|
|
84
|
+
config = get_config()
|
|
85
|
+
effective_tags = tags if tags is not None else (config.tags if config else None)
|
|
86
|
+
effective_recall_tags = (
|
|
87
|
+
recall_tags
|
|
88
|
+
if recall_tags is not None
|
|
89
|
+
else (config.recall_tags if config else None)
|
|
90
|
+
)
|
|
91
|
+
effective_recall_tags_match = (
|
|
92
|
+
recall_tags_match
|
|
93
|
+
if recall_tags_match is not None
|
|
94
|
+
else (config.recall_tags_match if config else "any")
|
|
95
|
+
)
|
|
96
|
+
effective_budget = (
|
|
97
|
+
budget if budget is not None else (config.budget if config else "mid")
|
|
98
|
+
)
|
|
99
|
+
effective_max_tokens = (
|
|
100
|
+
max_tokens
|
|
101
|
+
if max_tokens is not None
|
|
102
|
+
else (config.max_tokens if config else 4096)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
tools: list = []
|
|
106
|
+
|
|
107
|
+
if include_retain:
|
|
108
|
+
|
|
109
|
+
@tool
|
|
110
|
+
async def hindsight_retain(content: str) -> str:
|
|
111
|
+
"""Store information to long-term memory for later retrieval.
|
|
112
|
+
|
|
113
|
+
Use this to save important facts, user preferences, decisions,
|
|
114
|
+
or any information that should be remembered across conversations.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
content: The information to store in memory.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
retain_kwargs: dict[str, Any] = {"bank_id": bank_id, "content": content}
|
|
121
|
+
if effective_tags:
|
|
122
|
+
retain_kwargs["tags"] = effective_tags
|
|
123
|
+
if retain_metadata:
|
|
124
|
+
retain_kwargs["metadata"] = retain_metadata
|
|
125
|
+
if retain_document_id:
|
|
126
|
+
retain_kwargs["document_id"] = retain_document_id
|
|
127
|
+
await resolved_client.aretain(**retain_kwargs)
|
|
128
|
+
return "Memory stored successfully."
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.error(f"Retain failed: {e}")
|
|
131
|
+
raise HindsightError(f"Retain failed: {e}") from e
|
|
132
|
+
|
|
133
|
+
tools.append(hindsight_retain)
|
|
134
|
+
|
|
135
|
+
if include_recall:
|
|
136
|
+
|
|
137
|
+
@tool
|
|
138
|
+
async def hindsight_recall(query: str) -> str:
|
|
139
|
+
"""Search long-term memory for relevant information.
|
|
140
|
+
|
|
141
|
+
Use this to find previously stored facts, preferences, or context.
|
|
142
|
+
Returns a numbered list of matching memories.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
query: What to search for in memory.
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
recall_kwargs: dict[str, Any] = {
|
|
149
|
+
"bank_id": bank_id,
|
|
150
|
+
"query": query,
|
|
151
|
+
"budget": effective_budget,
|
|
152
|
+
"max_tokens": effective_max_tokens,
|
|
153
|
+
}
|
|
154
|
+
if effective_recall_tags:
|
|
155
|
+
recall_kwargs["tags"] = effective_recall_tags
|
|
156
|
+
recall_kwargs["tags_match"] = effective_recall_tags_match
|
|
157
|
+
if recall_types:
|
|
158
|
+
recall_kwargs["types"] = recall_types
|
|
159
|
+
if recall_include_entities:
|
|
160
|
+
recall_kwargs["include_entities"] = True
|
|
161
|
+
response = await resolved_client.arecall(**recall_kwargs)
|
|
162
|
+
if not response.results:
|
|
163
|
+
return "No relevant memories found."
|
|
164
|
+
lines = []
|
|
165
|
+
for i, result in enumerate(response.results, 1):
|
|
166
|
+
lines.append(f"{i}. {result.text}")
|
|
167
|
+
return "\n".join(lines)
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.error(f"Recall failed: {e}")
|
|
170
|
+
raise HindsightError(f"Recall failed: {e}") from e
|
|
171
|
+
|
|
172
|
+
tools.append(hindsight_recall)
|
|
173
|
+
|
|
174
|
+
if include_reflect:
|
|
175
|
+
|
|
176
|
+
@tool
|
|
177
|
+
async def hindsight_reflect(query: str) -> str:
|
|
178
|
+
"""Synthesize a thoughtful answer from long-term memories.
|
|
179
|
+
|
|
180
|
+
Use this when you need a coherent summary or reasoned response
|
|
181
|
+
about what you know, rather than raw memory facts.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
query: The question to reflect on using stored memories.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
reflect_kwargs: dict[str, Any] = {
|
|
188
|
+
"bank_id": bank_id,
|
|
189
|
+
"query": query,
|
|
190
|
+
"budget": effective_budget,
|
|
191
|
+
}
|
|
192
|
+
if reflect_context:
|
|
193
|
+
reflect_kwargs["context"] = reflect_context
|
|
194
|
+
effective_reflect_max = reflect_max_tokens or effective_max_tokens
|
|
195
|
+
if effective_reflect_max:
|
|
196
|
+
reflect_kwargs["max_tokens"] = effective_reflect_max
|
|
197
|
+
if reflect_response_schema:
|
|
198
|
+
reflect_kwargs["response_schema"] = reflect_response_schema
|
|
199
|
+
# Reflect tags: use reflect-specific or fall back to recall tags
|
|
200
|
+
effective_reflect_tags = (
|
|
201
|
+
reflect_tags if reflect_tags is not None else effective_recall_tags
|
|
202
|
+
)
|
|
203
|
+
effective_reflect_tags_match = (
|
|
204
|
+
reflect_tags_match or effective_recall_tags_match
|
|
205
|
+
)
|
|
206
|
+
if effective_reflect_tags:
|
|
207
|
+
reflect_kwargs["tags"] = effective_reflect_tags
|
|
208
|
+
reflect_kwargs["tags_match"] = effective_reflect_tags_match
|
|
209
|
+
response = await resolved_client.areflect(**reflect_kwargs)
|
|
210
|
+
return response.text or "No relevant memories found."
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.error(f"Reflect failed: {e}")
|
|
213
|
+
raise HindsightError(f"Reflect failed: {e}") from e
|
|
214
|
+
|
|
215
|
+
tools.append(hindsight_reflect)
|
|
216
|
+
|
|
217
|
+
return tools
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hindsight-langgraph
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: LangGraph integration for Hindsight - persistent memory tools, nodes, and store for AI agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/vectorize-io/hindsight
|
|
6
|
+
Project-URL: Documentation, https://github.com/vectorize-io/hindsight/tree/main/hindsight-integrations/langgraph
|
|
7
|
+
Project-URL: Repository, https://github.com/vectorize-io/hindsight
|
|
8
|
+
Author-email: Vectorize <support@vectorize.io>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: agents,ai,hindsight,langchain,langgraph,memory
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: hindsight-client>=0.4.0
|
|
21
|
+
Requires-Dist: langchain-core>=0.3.0
|
|
22
|
+
Provides-Extra: all
|
|
23
|
+
Requires-Dist: langgraph>=0.3.0; extra == 'all'
|
|
24
|
+
Provides-Extra: langgraph
|
|
25
|
+
Requires-Dist: langgraph>=0.3.0; extra == 'langgraph'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
hindsight_langgraph/__init__.py,sha256=4zYHuK9cpE0avkrCzPBL4Fqh-qH24wnOt-78RvcTj74,2934
|
|
2
|
+
hindsight_langgraph/_client.py,sha256=BVneZXQXfVewCxfElOJ2u1ejEKlIbaUjzxXCyC8XA4I,923
|
|
3
|
+
hindsight_langgraph/config.py,sha256=qVW7H006hbGQ9YFeIvN_7rufeGh_BnbGMWLNWiWBdN8,2867
|
|
4
|
+
hindsight_langgraph/errors.py,sha256=tn9hA-mH63fF9NipRZKbxvcTf-M2OP8WhaFccUYdthc,152
|
|
5
|
+
hindsight_langgraph/nodes.py,sha256=CwHBOLN63WDuQ998LA_3pSNv6mgE_-5r7ikIDyoTxEY,9485
|
|
6
|
+
hindsight_langgraph/store.py,sha256=clF3C2wRXs8SVI8rjw6I41WN-njdYIkudqiKN5lXJys,15717
|
|
7
|
+
hindsight_langgraph/tools.py,sha256=NDkZMETg-bCqCKgYWmPYBRg0mmiBlLKpwTSIz7uGxOY,8788
|
|
8
|
+
hindsight_langgraph-0.1.1.dist-info/METADATA,sha256=_DLuExAKWVxnY-YFYtNPdYN-KOJDklR3sq8e16PjR8A,1186
|
|
9
|
+
hindsight_langgraph-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
hindsight_langgraph-0.1.1.dist-info/RECORD,,
|