From 3592be564d39c65d4c8f2ab3156e12ef39ae67dd Mon Sep 17 00:00:00 2001 From: DK09876 Date: Tue, 19 May 2026 10:53:58 -0700 Subject: [PATCH 1/3] docs: add langgraph.py example snippets for integration docs Adds embeddable code snippets covering all three LangGraph integration patterns: tools (ReAct agent), memory nodes, BaseStore, and constructor options. Follows the same [docs:section] pattern as ai-sdk.ts. Co-Authored-By: Claude Opus 4.6 --- .../examples/integrations/langgraph.py | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 hindsight-docs/examples/integrations/langgraph.py diff --git a/hindsight-docs/examples/integrations/langgraph.py b/hindsight-docs/examples/integrations/langgraph.py new file mode 100644 index 000000000..e2fc58f25 --- /dev/null +++ b/hindsight-docs/examples/integrations/langgraph.py @@ -0,0 +1,115 @@ +""" +Hindsight LangGraph integration examples. +These snippets are embedded in the documentation via CodeSnippet. +""" + +# [docs:setup] +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") +# [/docs:setup] + +# [docs:react-agent] +from langchain_openai import ChatOpenAI +from langgraph.prebuilt import create_react_agent + +agent = create_react_agent( + ChatOpenAI(model="gpt-4o"), + tools=tools, + prompt=( + "You are a helpful assistant with long-term memory. " + "Use hindsight_retain to store important facts about the user. " + "Use hindsight_recall to search your memory before answering. " + "Use hindsight_reflect for thoughtful summaries of what you know." + ), +) + +result = await agent.ainvoke( + {"messages": [{"role": "user", "content": "Remember that I prefer dark mode"}]} +) +# [/docs:react-agent] + +# [docs:memory-nodes] +from hindsight_langgraph import create_recall_node, create_retain_node +from langchain_core.messages import HumanMessage +from langchain_openai import ChatOpenAI +from langgraph.graph import StateGraph, MessagesState, START, END + +recall = create_recall_node( + client=client, + bank_id_from_config="user_id", + budget="mid", + max_results=5, +) +retain = create_retain_node( + client=client, + bank_id_from_config="user_id", + tags=["source:auto"], +) + + +async def llm_node(state: MessagesState): + model = ChatOpenAI(model="gpt-4o") + response = await model.ainvoke(state["messages"]) + return {"messages": [response]} + + +builder = StateGraph(MessagesState) +builder.add_node("recall", recall) +builder.add_node("llm", llm_node) +builder.add_node("retain", retain) + +builder.add_edge(START, "recall") +builder.add_edge("recall", "llm") +builder.add_edge("llm", "retain") +builder.add_edge("retain", END) + +graph = builder.compile() + +result = await graph.ainvoke( + {"messages": [HumanMessage(content="What exercise should I do today?")]}, + config={"configurable": {"user_id": "user-456"}}, +) +# [/docs:memory-nodes] + +# [docs:base-store] +from hindsight_langgraph import HindsightStore +from langgraph.checkpoint.memory import MemorySaver + +store = HindsightStore(client=client) +checkpointer = MemorySaver() + +graph = builder.compile(checkpointer=checkpointer, store=store) + +# Store memories via the BaseStore API +await store.aput(("user", "123", "prefs"), "theme", {"value": "dark mode"}) +await store.aput(("user", "123", "prefs"), "language", {"value": "Python"}) + +# Semantic search across stored memories +results = await store.asearch(("user", "123", "prefs"), query="display preferences") +for item in results: + print(f"{item.key}: {item.value}") +# [/docs:base-store] + +# [docs:constructor-options] +tools = create_hindsight_tools( + client=client, + bank_id="user-123", + budget="high", + max_tokens=2048, + tags=["env:prod", "app:support"], + recall_tags=["env:prod"], + recall_tags_match="any", + retain_metadata={"version": "2.0"}, + retain_document_id="session-abc", + recall_types=["experience", "world"], + recall_include_entities=True, + reflect_context="The user is a senior engineer.", + include_retain=True, + include_recall=True, + include_reflect=True, +) +# [/docs:constructor-options] From 96ce11b64a1986b9b311a14088a060c9831f2eb5 Mon Sep 17 00:00:00 2001 From: DK09876 Date: Wed, 20 May 2026 08:45:45 -0700 Subject: [PATCH 2/3] LangGraph integration: add memory_instructions, fix nodes, remove BaseStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add memory_instructions() for standalone LangChain use without a graph - Add recall_types, recall_include_entities to create_recall_node() - Add metadata, document_id to create_retain_node() - Nodes now raise HindsightError instead of silently swallowing errors - Remove HindsightStore (BaseStore adapter) — leaky KV abstraction over semantic memory (get unreliable, delete no-op, list session-scoped) - Update README: cloud-first examples, add memory_instructions section - Update docs example: replace base-store with memory-instructions snippet - Fix pre-existing test failures (user_agent mock mismatch) - 52 unit tests pass, 13 E2E tests pass Co-Authored-By: Claude Opus 4.6 --- .../examples/integrations/langgraph.py | 40 +- hindsight-integrations/langgraph/README.md | 64 +-- .../langgraph/hindsight_langgraph/__init__.py | 41 +- .../langgraph/hindsight_langgraph/nodes.py | 22 +- .../langgraph/hindsight_langgraph/store.py | 408 ------------------ .../langgraph/hindsight_langgraph/tools.py | 86 ++++ .../langgraph/tests/test_manual_store.py | 47 -- .../langgraph/tests/test_nodes.py | 86 +++- .../langgraph/tests/test_store.py | 297 ------------- .../langgraph/tests/test_tools.py | 108 ++++- 10 files changed, 357 insertions(+), 842 deletions(-) delete mode 100644 hindsight-integrations/langgraph/hindsight_langgraph/store.py delete mode 100644 hindsight-integrations/langgraph/tests/test_manual_store.py delete mode 100644 hindsight-integrations/langgraph/tests/test_store.py diff --git a/hindsight-docs/examples/integrations/langgraph.py b/hindsight-docs/examples/integrations/langgraph.py index e2fc58f25..99c28b9ae 100644 --- a/hindsight-docs/examples/integrations/langgraph.py +++ b/hindsight-docs/examples/integrations/langgraph.py @@ -4,12 +4,13 @@ """ # [docs:setup] -from hindsight_client import Hindsight from hindsight_langgraph import create_hindsight_tools -client = Hindsight(base_url="http://localhost:8888") +# Uses the default API URL. Set HINDSIGHT_API_KEY env var to authenticate. +tools = create_hindsight_tools(bank_id="user-123") -tools = create_hindsight_tools(client=client, bank_id="user-123") +# To connect to a self-hosted instance, pass the URL explicitly: +# tools = create_hindsight_tools(bank_id="user-123", hindsight_api_url="http://localhost:8888") # [/docs:setup] # [docs:react-agent] @@ -39,13 +40,11 @@ from langgraph.graph import StateGraph, MessagesState, START, END recall = create_recall_node( - client=client, bank_id_from_config="user_id", budget="mid", max_results=5, ) retain = create_retain_node( - client=client, bank_id_from_config="user_id", tags=["source:auto"], ) @@ -75,28 +74,25 @@ async def llm_node(state: MessagesState): ) # [/docs:memory-nodes] -# [docs:base-store] -from hindsight_langgraph import HindsightStore -from langgraph.checkpoint.memory import MemorySaver +# [docs:memory-instructions] +from hindsight_langgraph import memory_instructions -store = HindsightStore(client=client) -checkpointer = MemorySaver() - -graph = builder.compile(checkpointer=checkpointer, store=store) - -# Store memories via the BaseStore API -await store.aput(("user", "123", "prefs"), "theme", {"value": "dark mode"}) -await store.aput(("user", "123", "prefs"), "language", {"value": "Python"}) +get_instructions = memory_instructions( + bank_id="user-123", + base_instructions="You are a helpful assistant with long-term memory.", + budget="mid", + max_results=5, +) -# Semantic search across stored memories -results = await store.asearch(("user", "123", "prefs"), query="display preferences") -for item in results: - print(f"{item.key}: {item.value}") -# [/docs:base-store] +# Use in a LangChain chain (no graph needed) +instructions = await get_instructions() +response = await ChatOpenAI(model="gpt-4o").ainvoke( + [{"role": "system", "content": instructions}, {"role": "user", "content": "What do you know about me?"}] +) +# [/docs:memory-instructions] # [docs:constructor-options] tools = create_hindsight_tools( - client=client, bank_id="user-123", budget="high", max_tokens=2048, diff --git a/hindsight-integrations/langgraph/README.md b/hindsight-integrations/langgraph/README.md index 1620c77bd..87d12ca87 100644 --- a/hindsight-integrations/langgraph/README.md +++ b/hindsight-integrations/langgraph/README.md @@ -5,11 +5,11 @@ LangGraph and LangChain integration for [Hindsight](https://github.com/vectorize 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 +- **Memory Instructions** — pre-fetch memories into a system prompt string. Works with **any LangChain model**, no graph needed. ## 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)) +- A [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup) account or a [self-hosted](https://github.com/vectorize-io/hindsight#quick-start) Hindsight instance - Python 3.10+ ## Installation @@ -23,13 +23,12 @@ pip install hindsight-langgraph 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") +# Set HINDSIGHT_API_KEY env var to authenticate +tools = create_hindsight_tools(bank_id="user-123") agent = create_react_agent( ChatOpenAI(model="gpt-4o"), @@ -46,14 +45,11 @@ result = await agent.ainvoke( 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") +recall = create_recall_node(bank_id="user-123") +retain = create_retain_node(bank_id="user-123") builder = StateGraph(MessagesState) builder.add_node("recall", recall) @@ -73,8 +69,8 @@ graph = builder.compile() 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") +recall = create_recall_node(bank_id_from_config="user_id") +retain = create_retain_node(bank_id_from_config="user_id") # Bank ID resolved at runtime result = await graph.ainvoke( @@ -83,22 +79,25 @@ result = await graph.ainvoke( ) ``` -## Quick Start: BaseStore +## Quick Start: Memory Instructions -Use Hindsight as a LangGraph `BaseStore` for cross-thread persistent memory with semantic search. +Pre-fetch memories and inject them into a system prompt. Works with any LangChain model — no graph needed. ```python -from hindsight_client import Hindsight -from hindsight_langgraph import HindsightStore - -client = Hindsight(base_url="http://localhost:8888") -store = HindsightStore(client=client) +from hindsight_langgraph import memory_instructions +from langchain_openai import ChatOpenAI -graph = builder.compile(checkpointer=checkpointer, store=store) +get_instructions = memory_instructions( + bank_id="user-123", + base_instructions="You are a helpful assistant.", +) -# 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") +# Each call re-fetches memories, so it stays up to date +instructions = await get_instructions() +response = await ChatOpenAI(model="gpt-4o").ainvoke([ + {"role": "system", "content": instructions}, + {"role": "user", "content": "What do you know about me?"}, +]) ``` ## Configuration @@ -109,13 +108,28 @@ results = await store.asearch(("user", "123", "prefs"), query="theme preference" 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"], ) ``` +### Self-hosted instance + +To connect to a self-hosted Hindsight instance instead of Hindsight Cloud: + +```python +configure( + hindsight_api_url="http://localhost:8888", +) +``` + +Or pass `hindsight_api_url` directly to any factory function: + +```python +tools = create_hindsight_tools(bank_id="user-123", hindsight_api_url="http://localhost:8888") +``` + ### Per-call overrides All factory functions accept `client`, `hindsight_api_url`, and `api_key` to override the global config. @@ -135,7 +149,7 @@ All factory functions accept `client`, `hindsight_api_url`, and `api_key` to ove - 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]`)* +- `langgraph >= 0.3.0` *(only for nodes pattern — install with `pip install hindsight-langgraph[langgraph]`)* ## Documentation diff --git a/hindsight-integrations/langgraph/hindsight_langgraph/__init__.py b/hindsight-integrations/langgraph/hindsight_langgraph/__init__.py index dda9d1421..3d3c3160c 100644 --- a/hindsight-integrations/langgraph/hindsight_langgraph/__init__.py +++ b/hindsight-integrations/langgraph/hindsight_langgraph/__init__.py @@ -1,19 +1,21 @@ """Hindsight-LangGraph: Persistent memory for LangGraph and LangChain agents. -Provides Hindsight-backed tools, nodes, and a BaseStore adapter, +Provides Hindsight-backed tools, nodes, and a memory instructions helper, 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]``). +The **tools** and **memory_instructions** patterns work with both LangChain +and LangGraph — only ``langchain-core`` is required. The **nodes** pattern +requires ``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") + # Uses Hindsight Cloud by default (set HINDSIGHT_API_KEY env var) + tools = create_hindsight_tools(bank_id="user-123") + + # Or connect to a self-hosted instance: + # tools = create_hindsight_tools(bank_id="user-123", hindsight_api_url="http://localhost:8888") # Bind tools to your model model = ChatOpenAI(model="gpt-4o").bind_tools(tools) @@ -31,12 +33,15 @@ builder.add_edge("recall", "agent") builder.add_edge("agent", "retain") -Usage with BaseStore (requires langgraph):: +Usage with memory_instructions (LangChain, no graph needed):: - from hindsight_langgraph import HindsightStore + from hindsight_langgraph import memory_instructions - store = HindsightStore(client=client) - graph = builder.compile(checkpointer=checkpointer, store=store) + get_instructions = memory_instructions( + client=client, bank_id="user-123", + base_instructions="You are a helpful assistant.", + ) + instructions = await get_instructions() """ from .config import ( @@ -46,7 +51,7 @@ reset_config, ) from .errors import HindsightError -from .tools import create_hindsight_tools +from .tools import create_hindsight_tools, memory_instructions def __getattr__(name: str): @@ -60,15 +65,6 @@ def __getattr__(name: str): ) 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}") @@ -81,11 +77,12 @@ def __getattr__(name: str): "HindsightLangGraphConfig", "HindsightError", "create_hindsight_tools", + "memory_instructions", ] try: import langgraph # noqa: F401 - __all__ += ["create_recall_node", "create_retain_node", "HindsightStore"] + __all__ += ["create_recall_node", "create_retain_node"] except ImportError: pass diff --git a/hindsight-integrations/langgraph/hindsight_langgraph/nodes.py b/hindsight-integrations/langgraph/hindsight_langgraph/nodes.py index e14d01836..e24953982 100644 --- a/hindsight-integrations/langgraph/hindsight_langgraph/nodes.py +++ b/hindsight-integrations/langgraph/hindsight_langgraph/nodes.py @@ -13,6 +13,7 @@ from langgraph.graph import MessagesState from ._client import resolve_client +from .errors import HindsightError logger = logging.getLogger(__name__) @@ -49,6 +50,8 @@ def create_recall_node( max_results: int = 10, tags: Optional[list[str]] = None, tags_match: str = "any", + recall_types: Optional[list[str]] = None, + recall_include_entities: bool = False, bank_id_from_config: str = "user_id", output_key: Optional[str] = None, ): @@ -93,6 +96,8 @@ class AgentState(MessagesState): max_results: Maximum number of memories to inject. tags: Tags to filter recall results. tags_match: Tag matching mode. + recall_types: Fact types to filter (world, experience, opinion, observation). + recall_include_entities: Include entity information in recall results. 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. @@ -140,6 +145,10 @@ async def recall_node(state: MessagesState, config: Optional[RunnableConfig] = N if tags: recall_kwargs["tags"] = tags recall_kwargs["tags_match"] = 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) results = response.results[:max_results] if response.results else [] @@ -159,9 +168,7 @@ async def recall_node(state: MessagesState, config: Optional[RunnableConfig] = N 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": []} + raise HindsightError(f"Recall node failed: {e}") from e return recall_node @@ -173,6 +180,8 @@ def create_retain_node( hindsight_api_url: Optional[str] = None, api_key: Optional[str] = None, tags: Optional[list[str]] = None, + metadata: Optional[dict[str, str]] = None, + document_id: Optional[str] = None, bank_id_from_config: str = "user_id", retain_human: bool = True, retain_ai: bool = False, @@ -189,6 +198,8 @@ def create_retain_node( 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. + metadata: Metadata dict to attach to retained memories. + document_id: Document ID for grouping/upserting 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. @@ -238,9 +249,14 @@ async def retain_node(state: MessagesState, config: Optional[RunnableConfig] = N } if tags: retain_kwargs["tags"] = tags + if metadata: + retain_kwargs["metadata"] = metadata + if document_id: + retain_kwargs["document_id"] = document_id await resolved_client.aretain(**retain_kwargs) except Exception as e: logger.error(f"Retain node failed: {e}") + raise HindsightError(f"Retain node failed: {e}") from e return {"messages": []} diff --git a/hindsight-integrations/langgraph/hindsight_langgraph/store.py b/hindsight-integrations/langgraph/hindsight_langgraph/store.py deleted file mode 100644 index fd5e9e8f4..000000000 --- a/hindsight-integrations/langgraph/hindsight_langgraph/store.py +++ /dev/null @@ -1,408 +0,0 @@ -"""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 diff --git a/hindsight-integrations/langgraph/hindsight_langgraph/tools.py b/hindsight-integrations/langgraph/hindsight_langgraph/tools.py index 2cad5bd8a..efeda5b21 100644 --- a/hindsight-integrations/langgraph/hindsight_langgraph/tools.py +++ b/hindsight-integrations/langgraph/hindsight_langgraph/tools.py @@ -6,6 +6,7 @@ """ import logging +from collections.abc import Awaitable, Callable from typing import Any, Optional from hindsight_client import Hindsight @@ -199,3 +200,88 @@ async def hindsight_reflect(query: str) -> str: tools.append(hindsight_reflect) return tools + + +def memory_instructions( + *, + bank_id: str, + base_instructions: str = "", + client: Optional[Hindsight] = None, + hindsight_api_url: Optional[str] = None, + api_key: Optional[str] = None, + query: str = "relevant context about the user", + budget: Optional[str] = None, + max_results: int = 5, + max_tokens: Optional[int] = None, + prefix: str = "\n\nRelevant memories:\n", + tags: Optional[list[str]] = None, + tags_match: Optional[str] = None, +) -> Callable[..., Awaitable[str]]: + """Create an async callable that auto-injects relevant memories into instructions. + + Returns an async function that recalls memories from Hindsight and appends + them to base instructions. Useful for LangChain chains or any context where + you want memory injection without a full LangGraph graph. + + Args: + bank_id: The Hindsight memory bank to recall from. + base_instructions: Static instructions prepended before memories. + 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). + query: The recall query to find relevant memories. + budget: Recall budget level (low/mid/high). + max_results: Maximum number of memories to include. + max_tokens: Maximum tokens for recall results. + prefix: Text prepended before the memory list. + tags: Tags to filter when searching memories. + tags_match: Tag matching mode (any/all/any_strict/all_strict). + + Returns: + An async callable that returns instructions with memories appended. + + Example:: + + from hindsight_langgraph import memory_instructions + + get_instructions = memory_instructions( + client=client, + bank_id="user-123", + base_instructions="You are a helpful assistant.", + ) + instructions = await get_instructions() + """ + resolved_client = resolve_client(client, hindsight_api_url, api_key) + + config = get_config() + 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) + effective_tags = tags if tags is not None else (config.recall_tags if config else None) + effective_tags_match = ( + tags_match if tags_match is not None else (config.recall_tags_match if config else "any") + ) + + async def _instructions(*args: Any, **kwargs: Any) -> str: + """Recall memories and format as instructions text.""" + try: + recall_kwargs: dict[str, Any] = { + "bank_id": bank_id, + "query": query, + "budget": effective_budget, + "max_tokens": effective_max_tokens, + } + if effective_tags: + recall_kwargs["tags"] = effective_tags + recall_kwargs["tags_match"] = effective_tags_match + response = await resolved_client.arecall(**recall_kwargs) + if not response.results: + return base_instructions + lines = [] + for i, result in enumerate(response.results[:max_results], 1): + lines.append(f"{i}. {result.text}") + return base_instructions + prefix + "\n".join(lines) + except Exception as e: + logger.error("memory_instructions recall failed: %s", e) + return base_instructions + + return _instructions diff --git a/hindsight-integrations/langgraph/tests/test_manual_store.py b/hindsight-integrations/langgraph/tests/test_manual_store.py deleted file mode 100644 index da0e6a8c3..000000000 --- a/hindsight-integrations/langgraph/tests/test_manual_store.py +++ /dev/null @@ -1,47 +0,0 @@ -"""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()) diff --git a/hindsight-integrations/langgraph/tests/test_nodes.py b/hindsight-integrations/langgraph/tests/test_nodes.py index de06b282f..e98e39cbf 100644 --- a/hindsight-integrations/langgraph/tests/test_nodes.py +++ b/hindsight-integrations/langgraph/tests/test_nodes.py @@ -4,6 +4,7 @@ import pytest from hindsight_langgraph import create_recall_node, create_retain_node +from hindsight_langgraph.errors import HindsightError from langchain_core.messages import AIMessage, HumanMessage, SystemMessage @@ -106,15 +107,14 @@ async def test_skips_when_no_bank_id(self): client.arecall.assert_not_called() @pytest.mark.asyncio - async def test_handles_recall_error_gracefully(self): + async def test_raises_on_recall_error(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"] == [] + with pytest.raises(HindsightError, match="Recall node failed"): + await node(state) @pytest.mark.asyncio async def test_passes_tags(self): @@ -168,7 +168,7 @@ async def test_output_key_returns_none_when_no_results(self): assert result == {"memory_context": None} @pytest.mark.asyncio - async def test_output_key_returns_none_on_error(self): + async def test_output_key_raises_on_error(self): client = _mock_client() client.arecall.side_effect = RuntimeError("connection refused") node = create_recall_node( @@ -176,9 +176,8 @@ async def test_output_key_returns_none_on_error(self): ) state = {"messages": [HumanMessage(content="hello")]} - result = await node(state) - - assert result == {"memory_context": None} + with pytest.raises(HindsightError, match="Recall node failed"): + await node(state) class TestRetainNode: @@ -258,12 +257,75 @@ async def test_resolves_bank_id_from_config(self): assert call_kwargs["bank_id"] == "user-789" @pytest.mark.asyncio - async def test_handles_retain_error_gracefully(self): + async def test_raises_on_retain_error(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"] == [] + with pytest.raises(HindsightError, match="Retain node failed"): + await node(state) + + @pytest.mark.asyncio + async def test_passes_metadata_and_document_id(self): + client = _mock_client() + node = create_retain_node( + bank_id="test-bank", + client=client, + metadata={"source": "chat"}, + document_id="session-1", + ) + + state = {"messages": [HumanMessage(content="hello")]} + await node(state) + + call_kwargs = client.aretain.call_args[1] + assert call_kwargs["metadata"] == {"source": "chat"} + assert call_kwargs["document_id"] == "session-1" + + +class TestRecallNodeNewParams: + @pytest.mark.asyncio + async def test_passes_recall_types(self): + client = _mock_client() + client.arecall.return_value = _mock_recall_response(["fact"]) + node = create_recall_node( + bank_id="test-bank", + client=client, + recall_types=["world", "experience"], + ) + + state = {"messages": [HumanMessage(content="hello")]} + await node(state) + + call_kwargs = client.arecall.call_args[1] + assert call_kwargs["types"] == ["world", "experience"] + + @pytest.mark.asyncio + async def test_passes_recall_include_entities(self): + client = _mock_client() + client.arecall.return_value = _mock_recall_response(["fact"]) + node = create_recall_node( + bank_id="test-bank", + client=client, + recall_include_entities=True, + ) + + state = {"messages": [HumanMessage(content="hello")]} + await node(state) + + call_kwargs = client.arecall.call_args[1] + assert call_kwargs["include_entities"] is True + + @pytest.mark.asyncio + async def test_recall_types_not_passed_when_none(self): + client = _mock_client() + client.arecall.return_value = _mock_recall_response(["fact"]) + node = create_recall_node(bank_id="test-bank", client=client) + + state = {"messages": [HumanMessage(content="hello")]} + await node(state) + + call_kwargs = client.arecall.call_args[1] + assert "types" not in call_kwargs + assert "include_entities" not in call_kwargs diff --git a/hindsight-integrations/langgraph/tests/test_store.py b/hindsight-integrations/langgraph/tests/test_store.py deleted file mode 100644 index 9cadd3cb2..000000000 --- a/hindsight-integrations/langgraph/tests/test_store.py +++ /dev/null @@ -1,297 +0,0 @@ -"""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 diff --git a/hindsight-integrations/langgraph/tests/test_tools.py b/hindsight-integrations/langgraph/tests/test_tools.py index 115bfbb6f..c6afe97be 100644 --- a/hindsight-integrations/langgraph/tests/test_tools.py +++ b/hindsight-integrations/langgraph/tests/test_tools.py @@ -6,6 +6,7 @@ from hindsight_langgraph import ( configure, create_hindsight_tools, + memory_instructions, reset_config, ) from hindsight_langgraph.errors import HindsightError @@ -112,9 +113,10 @@ def test_falls_back_to_global_config(self): 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 - ) + call_kwargs = mock_cls.call_args[1] + assert call_kwargs["base_url"] == "http://localhost:8888" + assert call_kwargs["timeout"] == 30.0 + assert "user_agent" in call_kwargs def test_explicit_url_overrides_config(self): configure(hindsight_api_url="http://config:8888") @@ -123,9 +125,9 @@ def test_explicit_url_overrides_config(self): 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 - ) + call_kwargs = mock_cls.call_args[1] + assert call_kwargs["base_url"] == "http://explicit:9999" + assert call_kwargs["timeout"] == 30.0 class TestRetainTool: @@ -398,3 +400,97 @@ async def test_recall_passes_include_entities(self): await tools[0].ainvoke("query") call_kwargs = client.arecall.call_args[1] assert call_kwargs["include_entities"] is True + + +class TestMemoryInstructions: + @pytest.mark.asyncio + async def test_returns_base_instructions_when_no_memories(self): + client = _mock_client() + response = MagicMock() + response.results = [] + client.arecall.return_value = response + + fn = memory_instructions( + bank_id="test", + client=client, + base_instructions="You are helpful.", + ) + result = await fn() + assert result == "You are helpful." + + @pytest.mark.asyncio + async def test_appends_memories_to_base_instructions(self): + client = _mock_client() + client.arecall.return_value = _mock_recall_response(["likes Python", "uses VS Code"]) + + fn = memory_instructions( + bank_id="test", + client=client, + base_instructions="You are helpful.", + ) + result = await fn() + assert result.startswith("You are helpful.") + assert "likes Python" in result + assert "uses VS Code" in result + + @pytest.mark.asyncio + async def test_passes_recall_params(self): + client = _mock_client() + client.arecall.return_value = _mock_recall_response(["fact"]) + + fn = memory_instructions( + bank_id="test", + client=client, + budget="high", + max_tokens=2048, + tags=["scope:user"], + tags_match="all", + ) + await fn() + + call_kwargs = client.arecall.call_args[1] + assert call_kwargs["bank_id"] == "test" + assert call_kwargs["budget"] == "high" + assert call_kwargs["max_tokens"] == 2048 + assert call_kwargs["tags"] == ["scope:user"] + assert call_kwargs["tags_match"] == "all" + + @pytest.mark.asyncio + async def test_respects_max_results(self): + client = _mock_client() + client.arecall.return_value = _mock_recall_response(["a", "b", "c", "d"]) + + fn = memory_instructions( + bank_id="test", + client=client, + max_results=2, + ) + result = await fn() + # Should only include 2 memories + assert "1." in result + assert "2." in result + assert "3." not in result + + @pytest.mark.asyncio + async def test_custom_prefix(self): + client = _mock_client() + client.arecall.return_value = _mock_recall_response(["fact"]) + + fn = memory_instructions( + bank_id="test", + client=client, + prefix="\n\nContext:\n", + ) + result = await fn() + assert "\n\nContext:\n" in result + + @pytest.mark.asyncio + async def test_falls_back_on_error(self): + client = _mock_client() + client.arecall.side_effect = RuntimeError("connection refused") + + fn = memory_instructions( + bank_id="test", client=client, base_instructions="You are helpful." + ) + result = await fn() + assert result == "You are helpful." From 001f04c92366ef9be22a320a64d8aa5cf4d177b8 Mon Sep 17 00:00:00 2001 From: DK09876 Date: Fri, 22 May 2026 09:14:55 -0700 Subject: [PATCH 3/3] style(langgraph): run ruff format on tools.py Co-Authored-By: Claude Sonnet 4.6 --- hindsight-integrations/langgraph/hindsight_langgraph/tools.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/hindsight-integrations/langgraph/hindsight_langgraph/tools.py b/hindsight-integrations/langgraph/hindsight_langgraph/tools.py index efeda5b21..d98711958 100644 --- a/hindsight-integrations/langgraph/hindsight_langgraph/tools.py +++ b/hindsight-integrations/langgraph/hindsight_langgraph/tools.py @@ -257,9 +257,7 @@ def memory_instructions( 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) effective_tags = tags if tags is not None else (config.recall_tags if config else None) - effective_tags_match = ( - tags_match if tags_match is not None else (config.recall_tags_match if config else "any") - ) + effective_tags_match = tags_match if tags_match is not None else (config.recall_tags_match if config else "any") async def _instructions(*args: Any, **kwargs: Any) -> str: """Recall memories and format as instructions text."""