Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45395e2b51 | ||
|
|
1a200a9cd6 | ||
|
|
6df79e329d | ||
|
|
3ab39a8d3c | ||
|
|
1a39843e58 | ||
|
|
0fb376f5e7 | ||
|
|
ef37dd6347 | ||
|
|
7ac2c6516c | ||
|
|
febf3528a6 | ||
|
|
30d8993272 | ||
|
|
6766e23f92 | ||
|
|
a3e1e691ae | ||
|
|
ea3ecdaf02 | ||
|
|
687db27cc9 | ||
|
|
1fb1bf080d | ||
|
|
f9c06113a3 | ||
|
|
2a7c496e28 |
@@ -59,6 +59,7 @@ jobs:
|
||||
integrations-vapi: ${{ steps.filter.outputs.integrations-vapi }}
|
||||
integrations-flowise: ${{ steps.filter.outputs.integrations-flowise }}
|
||||
integrations-google-adk: ${{ steps.filter.outputs.integrations-google-adk }}
|
||||
integrations-haystack: ${{ steps.filter.outputs.integrations-haystack }}
|
||||
tools-agent-sdk: ${{ steps.filter.outputs.tools-agent-sdk }}
|
||||
integrations-roo-code: ${{ steps.filter.outputs.integrations-roo-code }}
|
||||
dev: ${{ steps.filter.outputs.dev }}
|
||||
@@ -141,6 +142,8 @@ jobs:
|
||||
- 'hindsight-integrations/langgraph/**'
|
||||
integrations-llamaindex:
|
||||
- 'hindsight-integrations/llamaindex/**'
|
||||
integrations-haystack:
|
||||
- 'hindsight-integrations/haystack/**'
|
||||
integrations-paperclip:
|
||||
- 'hindsight-integrations/paperclip/**'
|
||||
integrations-opencode:
|
||||
@@ -3273,6 +3276,45 @@ jobs:
|
||||
# (requires_real_llm) needs a live Hindsight server and runs separately.
|
||||
run: uv run pytest tests -v -m "not requires_real_llm"
|
||||
|
||||
test-haystack-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name != 'pull_request_review' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-haystack == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Build haystack integration
|
||||
working-directory: ./hindsight-integrations/haystack
|
||||
run: uv build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/haystack
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/haystack
|
||||
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
|
||||
# (requires_real_llm) needs a live Hindsight server and runs separately.
|
||||
run: uv run pytest tests -v -m "not requires_real_llm"
|
||||
|
||||
test-openai-agents-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
@@ -4257,6 +4299,7 @@ jobs:
|
||||
- test-llamaindex-integration
|
||||
- test-openai-agents-integration
|
||||
- test-agentcore-integration
|
||||
- test-haystack-integration
|
||||
- test-pip-slim
|
||||
- test-embed
|
||||
- test-embed-windows
|
||||
|
||||
@@ -68,6 +68,7 @@ INTEGRATIONS: dict[str, IntegrationMeta] = {
|
||||
"flowise": IntegrationMeta("@vectorize-io/flowise-nodes-hindsight", "Flowise"),
|
||||
"google-adk": IntegrationMeta("hindsight-google-adk", "Google ADK"),
|
||||
"superagent": IntegrationMeta("hindsight-superagent", "Superagent"),
|
||||
"haystack": IntegrationMeta("hindsight-haystack", "Haystack"),
|
||||
"roo-code": IntegrationMeta("hindsight-roo-code", "Roo Code"),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
sidebar_position: 34
|
||||
title: "Haystack Persistent Memory with Hindsight | Integration"
|
||||
description: "Add persistent long-term memory to Haystack agents with Hindsight. Provides retain/recall/reflect Tools plus a HindsightToolset with optional auto-recall and auto-retain."
|
||||
---
|
||||
|
||||
# Haystack
|
||||
|
||||
Persistent long-term memory for [Haystack](https://haystack.deepset.ai/) agents via Hindsight. The `hindsight-haystack` package gives you two complementary patterns:
|
||||
|
||||
- **`create_hindsight_tools(...)`** — Returns a list of Haystack `Tool`s (`retain_memory`, `recall_memory`, `reflect_on_memory`) the model can call directly inside a turn.
|
||||
- **`HindsightToolset`** — A Haystack `Toolset` that bundles the same tools and adds optional **auto-recall** (inject relevant memories into the system prompt before each turn) and **auto-retain** (store user + assistant messages after each turn).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install hindsight-haystack
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from hindsight_client import Hindsight
|
||||
from hindsight_haystack import create_hindsight_tools
|
||||
from haystack.components.agents import Agent
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.dataclasses import ChatMessage
|
||||
|
||||
client = Hindsight(base_url="http://localhost:8888")
|
||||
|
||||
tools = create_hindsight_tools(
|
||||
client=client,
|
||||
bank_id="user-123",
|
||||
mission="Track user preferences",
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"),
|
||||
tools=tools,
|
||||
system_prompt=(
|
||||
"You are a helpful assistant with long-term memory. "
|
||||
"Use retain_memory to store important facts. "
|
||||
"Use recall_memory to search memory before answering."
|
||||
),
|
||||
)
|
||||
|
||||
result = agent.run(messages=[ChatMessage.from_user("Remember that I prefer dark mode")])
|
||||
print(result["messages"][-1].text)
|
||||
```
|
||||
|
||||
## Automatic Memory with HindsightToolset
|
||||
|
||||
For automatic recall and retain without relying on the agent to call tools:
|
||||
|
||||
```python
|
||||
from hindsight_haystack import HindsightToolset
|
||||
|
||||
toolset = HindsightToolset(
|
||||
client=client,
|
||||
bank_id="user-123",
|
||||
mission="Track user preferences",
|
||||
auto_recall=True, # Inject memories into the system prompt before each turn
|
||||
auto_retain=True, # Store user + assistant messages after each turn
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"),
|
||||
tools=toolset,
|
||||
system_prompt="You are a helpful assistant with long-term memory.",
|
||||
)
|
||||
|
||||
# Use toolset.run() for automatic memory behavior
|
||||
result = toolset.run(agent, messages=[ChatMessage.from_user("I prefer dark mode")])
|
||||
```
|
||||
|
||||
## Selective Tools
|
||||
|
||||
```python
|
||||
# Only retain + recall (no reflect)
|
||||
tools = create_hindsight_tools(
|
||||
client=client,
|
||||
bank_id="user-123",
|
||||
include_reflect=False,
|
||||
)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Call `configure()` once to set connection defaults so you can omit `client=`/`hindsight_api_url=` on every call:
|
||||
|
||||
```python
|
||||
from hindsight_haystack import configure
|
||||
|
||||
configure(
|
||||
hindsight_api_url="http://localhost:8888",
|
||||
api_key="your-api-key",
|
||||
budget="mid",
|
||||
tags=["source:haystack"],
|
||||
context="my-app",
|
||||
mission="Track user preferences",
|
||||
)
|
||||
|
||||
tools = create_hindsight_tools(bank_id="user-123")
|
||||
```
|
||||
|
||||
The API URL defaults to Hindsight Cloud (`https://api.hindsight.vectorize.io`), and the API key falls back to the `HINDSIGHT_API_KEY` environment variable.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- `haystack-ai >= 2.12.0`
|
||||
- `hindsight-client >= 0.4.0`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
A running Hindsight instance:
|
||||
|
||||
**Hindsight Cloud (recommended):** [Sign up](https://ui.hindsight.vectorize.io/signup) — no self-hosting required.
|
||||
|
||||
**Self-hosted:**
|
||||
|
||||
```bash
|
||||
pip install hindsight-all
|
||||
export HINDSIGHT_API_LLM_API_KEY=your-api-key
|
||||
hindsight-api # starts on http://localhost:8888
|
||||
```
|
||||
@@ -200,6 +200,16 @@
|
||||
"link": "/sdks/integrations/google-adk",
|
||||
"icon": "/img/icons/google-adk.png"
|
||||
},
|
||||
{
|
||||
"id": "haystack",
|
||||
"name": "Haystack",
|
||||
"description": "Persistent memory for Haystack agents. Provides retain/recall/reflect Tools plus a HindsightToolset with optional auto-recall and auto-retain.",
|
||||
"type": "official",
|
||||
"by": "hindsight",
|
||||
"category": "framework",
|
||||
"link": "/sdks/integrations/haystack",
|
||||
"icon": "/img/icons/haystack.svg"
|
||||
},
|
||||
{
|
||||
"id": "nemoclaw",
|
||||
"name": "NemoClaw",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 55.3388 55.3388" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Haystack">
|
||||
<rect width="55.3388" height="55.3388" rx="4.26442" fill="#0EAF9C" />
|
||||
<path
|
||||
d="M41.7585 36.8447C41.7585 37.1692 41.494 37.4309 41.1685 37.4309C38.2332 37.4309 35.854 35.0669 35.854 32.1504V22.6587C35.854 18.4462 32.4169 15.0322 28.1784 15.0322C23.94 15.0322 20.6916 18.4473 20.6916 22.6587V26.1748C20.6848 26.4927 20.9391 26.7554 21.2578 26.7611C21.2624 26.7611 21.2658 26.7611 21.2703 26.7611H24.6238C24.9493 26.7678 25.2194 26.5106 25.2262 26.1861C25.2262 26.1816 25.2262 26.1782 25.2262 26.1737V22.516C25.2262 20.8955 26.5475 19.5827 28.1784 19.5827C29.8094 19.5827 31.1307 20.8955 31.1307 22.516V44.5643C31.1239 44.8945 30.8492 45.1573 30.5169 45.1505C27.5952 45.1505 25.2262 42.7967 25.2262 39.8937C25.2262 39.8858 25.2262 39.8779 25.2262 39.8701V32.8299C25.2194 32.5042 24.9515 32.2436 24.6238 32.2436H21.2703C20.9504 32.2436 20.6916 32.5008 20.6916 32.8186C20.6916 32.8231 20.6916 32.8265 20.6916 32.831V35.1781C20.6916 38.0946 18.1236 40.4585 15.1883 40.4585C14.8617 40.4585 14.5984 40.1958 14.5984 39.8723V22.6598C14.5984 15.2074 20.678 9.16669 28.1784 9.16669C35.6788 9.16669 41.7585 15.2074 41.7585 22.6598V36.8447Z"
|
||||
fill="white" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,108 @@
|
||||
# hindsight-haystack
|
||||
|
||||
Haystack integration for [Hindsight](https://github.com/vectorize-io/hindsight) — persistent long-term memory for AI agents.
|
||||
|
||||
Provides Haystack `Tool` instances that give any Haystack `Agent` persistent memory via Hindsight's retain/recall/reflect APIs.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install hindsight-haystack
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from hindsight_client import Hindsight
|
||||
from hindsight_haystack import create_hindsight_tools
|
||||
from haystack.components.agents import Agent
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.dataclasses import ChatMessage
|
||||
|
||||
client = Hindsight(base_url="http://localhost:8888")
|
||||
|
||||
tools = create_hindsight_tools(
|
||||
client=client,
|
||||
bank_id="user-123",
|
||||
mission="Track user preferences",
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"),
|
||||
tools=tools,
|
||||
system_prompt=(
|
||||
"You are a helpful assistant with long-term memory. "
|
||||
"Use retain_memory to store important facts. "
|
||||
"Use recall_memory to search memory before answering."
|
||||
),
|
||||
)
|
||||
|
||||
result = agent.run(messages=[ChatMessage.from_user("Remember that I prefer dark mode")])
|
||||
print(result["messages"][-1].text)
|
||||
```
|
||||
|
||||
## Automatic Memory with HindsightToolset
|
||||
|
||||
For automatic recall and retain without relying on the agent to call tools:
|
||||
|
||||
```python
|
||||
from hindsight_haystack import HindsightToolset
|
||||
|
||||
toolset = HindsightToolset(
|
||||
client=client,
|
||||
bank_id="user-123",
|
||||
mission="Track user preferences",
|
||||
auto_recall=True, # Inject memories into system prompt before each turn
|
||||
auto_retain=True, # Store user + assistant messages after each turn
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"),
|
||||
tools=toolset,
|
||||
system_prompt="You are a helpful assistant with long-term memory.",
|
||||
)
|
||||
|
||||
# Use toolset.run() for automatic memory behavior
|
||||
result = toolset.run(agent, messages=[ChatMessage.from_user("I prefer dark mode")])
|
||||
```
|
||||
|
||||
## Selective Tools
|
||||
|
||||
```python
|
||||
# Only retain + recall (no reflect)
|
||||
tools = create_hindsight_tools(
|
||||
client=client,
|
||||
bank_id="user-123",
|
||||
include_reflect=False,
|
||||
)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```python
|
||||
from hindsight_haystack import configure
|
||||
|
||||
configure(
|
||||
hindsight_api_url="http://localhost:8888",
|
||||
api_key="your-api-key",
|
||||
budget="mid",
|
||||
tags=["source:haystack"],
|
||||
context="my-app",
|
||||
mission="Track user preferences",
|
||||
)
|
||||
|
||||
# Now you can skip client= and url= arguments
|
||||
tools = create_hindsight_tools(bank_id="user-123")
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- `haystack-ai >= 2.12.0`
|
||||
- `hindsight-client >= 0.4.0`
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Integration docs](https://docs.hindsight.vectorize.io/docs/sdks/integrations/haystack)
|
||||
- [Hindsight API docs](https://docs.hindsight.vectorize.io)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Hindsight memory integration for Haystack agents.
|
||||
|
||||
Provides Haystack-compatible ``Tool`` instances backed by Hindsight's
|
||||
retain/recall/reflect APIs. Use ``create_hindsight_tools()`` to create
|
||||
tools for any Haystack ``Agent``.
|
||||
|
||||
Usage::
|
||||
|
||||
from hindsight_haystack import create_hindsight_tools
|
||||
from haystack.components.agents import Agent
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
|
||||
tools = create_hindsight_tools(bank_id="user-123", client=client)
|
||||
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=tools)
|
||||
"""
|
||||
|
||||
from .config import (
|
||||
HindsightHaystackConfig,
|
||||
configure,
|
||||
get_config,
|
||||
reset_config,
|
||||
)
|
||||
from .errors import HindsightError
|
||||
from .tools import HindsightToolset, create_hindsight_tools
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
"configure",
|
||||
"get_config",
|
||||
"reset_config",
|
||||
"HindsightHaystackConfig",
|
||||
"HindsightError",
|
||||
"create_hindsight_tools",
|
||||
"HindsightToolset",
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Shared Hindsight client resolution logic."""
|
||||
|
||||
import os
|
||||
from importlib import metadata
|
||||
from typing import Any, Optional
|
||||
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
from .config import DEFAULT_HINDSIGHT_API_URL, HINDSIGHT_API_KEY_ENV, get_config
|
||||
|
||||
try:
|
||||
_VERSION = metadata.version("hindsight-haystack")
|
||||
except metadata.PackageNotFoundError:
|
||||
_VERSION = "0.0.0"
|
||||
_USER_AGENT = f"hindsight-haystack/{_VERSION}"
|
||||
|
||||
TIMEOUT_DEFAULT = 30.0
|
||||
|
||||
|
||||
def resolve_client(
|
||||
client: Optional[Hindsight],
|
||||
hindsight_api_url: Optional[str],
|
||||
api_key: Optional[str],
|
||||
) -> Hindsight:
|
||||
"""Resolve a Hindsight client from explicit args or global config.
|
||||
|
||||
Falls back to the default API URL and the ``HINDSIGHT_API_KEY`` env var when
|
||||
neither an explicit argument nor a prior ``configure()`` call supplied them,
|
||||
so the tools work with nothing but the env var set. Self-hosted users
|
||||
override the URL. The API key is optional at construction time — a missing
|
||||
key only fails when a call is actually made.
|
||||
"""
|
||||
if client is not None:
|
||||
return client
|
||||
|
||||
config = get_config()
|
||||
url = hindsight_api_url or (config.hindsight_api_url if config else DEFAULT_HINDSIGHT_API_URL)
|
||||
# Read HINDSIGHT_API_KEY directly so the no-configure() path still honours
|
||||
# the env var — the base Hindsight client doesn't fall back to it on its own.
|
||||
key = api_key or (config.api_key if config else None) or os.environ.get(HINDSIGHT_API_KEY_ENV)
|
||||
|
||||
kwargs: dict[str, Any] = {"base_url": url, "timeout": TIMEOUT_DEFAULT, "user_agent": _USER_AGENT}
|
||||
if key:
|
||||
kwargs["api_key"] = key
|
||||
return Hindsight(**kwargs)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Global configuration for Hindsight-Haystack integration."""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
DEFAULT_HINDSIGHT_API_URL = "https://api.hindsight.vectorize.io"
|
||||
HINDSIGHT_API_KEY_ENV = "HINDSIGHT_API_KEY"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HindsightHaystackConfig:
|
||||
"""Connection and default settings for the Haystack integration.
|
||||
|
||||
Attributes:
|
||||
hindsight_api_url: URL of the Hindsight API server.
|
||||
api_key: API key for Hindsight authentication.
|
||||
budget: Default recall budget level (low/mid/high).
|
||||
max_tokens: Default maximum tokens for recall results.
|
||||
tags: Default tags applied when storing memories.
|
||||
recall_tags: Default tags to filter when searching memories.
|
||||
recall_tags_match: Tag matching mode (any/all/any_strict/all_strict).
|
||||
context: Source label for retain operations (default: "haystack").
|
||||
mission: Bank mission for fact extraction context.
|
||||
verbose: Enable verbose logging.
|
||||
"""
|
||||
|
||||
hindsight_api_url: str = DEFAULT_HINDSIGHT_API_URL
|
||||
api_key: Optional[str] = None
|
||||
budget: str = "mid"
|
||||
max_tokens: int = 4096
|
||||
tags: Optional[list[str]] = None
|
||||
recall_tags: Optional[list[str]] = None
|
||||
recall_tags_match: str = "any"
|
||||
context: str = "haystack"
|
||||
mission: Optional[str] = None
|
||||
verbose: bool = False
|
||||
|
||||
|
||||
_global_config: Optional[HindsightHaystackConfig] = None
|
||||
|
||||
|
||||
def configure(
|
||||
hindsight_api_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
budget: str = "mid",
|
||||
max_tokens: int = 4096,
|
||||
tags: Optional[list[str]] = None,
|
||||
recall_tags: Optional[list[str]] = None,
|
||||
recall_tags_match: str = "any",
|
||||
context: str = "haystack",
|
||||
mission: Optional[str] = None,
|
||||
verbose: bool = False,
|
||||
) -> HindsightHaystackConfig:
|
||||
"""Configure Hindsight connection and default settings.
|
||||
|
||||
Args:
|
||||
hindsight_api_url: Hindsight API URL (default: production).
|
||||
api_key: API key. Falls back to HINDSIGHT_API_KEY env var.
|
||||
budget: Default recall budget (low/mid/high).
|
||||
max_tokens: Default max tokens for recall.
|
||||
tags: Default tags for retain operations.
|
||||
recall_tags: Default tags to filter recall/search.
|
||||
recall_tags_match: Tag matching mode.
|
||||
context: Source label for retain operations.
|
||||
mission: Bank mission for fact extraction context.
|
||||
verbose: Enable verbose logging.
|
||||
|
||||
Returns:
|
||||
The configured HindsightHaystackConfig.
|
||||
"""
|
||||
global _global_config
|
||||
|
||||
resolved_url = hindsight_api_url or DEFAULT_HINDSIGHT_API_URL
|
||||
resolved_key = api_key or os.environ.get(HINDSIGHT_API_KEY_ENV)
|
||||
|
||||
_global_config = HindsightHaystackConfig(
|
||||
hindsight_api_url=resolved_url,
|
||||
api_key=resolved_key,
|
||||
budget=budget,
|
||||
max_tokens=max_tokens,
|
||||
tags=tags,
|
||||
recall_tags=recall_tags,
|
||||
recall_tags_match=recall_tags_match,
|
||||
context=context,
|
||||
mission=mission,
|
||||
verbose=verbose,
|
||||
)
|
||||
|
||||
return _global_config
|
||||
|
||||
|
||||
def get_config() -> Optional[HindsightHaystackConfig]:
|
||||
"""Get the current global configuration."""
|
||||
return _global_config
|
||||
|
||||
|
||||
def reset_config() -> None:
|
||||
"""Reset global configuration to None."""
|
||||
global _global_config
|
||||
_global_config = None
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Hindsight-Haystack error types."""
|
||||
|
||||
|
||||
class HindsightError(Exception):
|
||||
"""Exception raised when a Hindsight memory operation fails."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,941 @@
|
||||
"""Haystack tool factory for Hindsight memory operations.
|
||||
|
||||
Provides a convenience factory that creates Haystack-compatible ``Tool``
|
||||
instances backed by Hindsight's retain/recall/reflect APIs, and a
|
||||
``HindsightToolset`` with optional auto-recall and auto-retain.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import atexit
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from haystack.dataclasses import ChatMessage
|
||||
from haystack.tools import Tool, Toolset
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
from ._client import resolve_client
|
||||
from .config import get_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Persistent event loop in a daemon thread for async Hindsight client calls.
|
||||
# aiohttp binds its session to the event loop that created it, so every call
|
||||
# must use the *same* loop. ``asyncio.run()`` creates and closes a fresh loop
|
||||
# each time, which breaks aiohttp on subsequent calls. We keep one loop alive
|
||||
# in a background thread and submit coroutines to it via run_coroutine_threadsafe.
|
||||
_loop = asyncio.new_event_loop()
|
||||
|
||||
|
||||
def _start_loop() -> None:
|
||||
asyncio.set_event_loop(_loop)
|
||||
_loop.run_forever()
|
||||
|
||||
|
||||
threading.Thread(target=_start_loop, daemon=True).start()
|
||||
|
||||
|
||||
def _run_sync(coro): # type: ignore[no-untyped-def]
|
||||
"""Run an async coroutine synchronously from any context.
|
||||
|
||||
Haystack's ``Tool`` requires sync callables, but the hindsight_client's
|
||||
async methods (aretain, arecall, areflect) must be awaited. This helper
|
||||
submits the coroutine to a persistent background event loop and blocks
|
||||
until the result is ready. Works regardless of whether the caller is
|
||||
inside a running event loop (e.g., Haystack's agent runtime) or not.
|
||||
"""
|
||||
future = asyncio.run_coroutine_threadsafe(coro, _loop)
|
||||
return future.result()
|
||||
|
||||
|
||||
# Hindsight clients this module creates (i.e. when the caller did not pass their
|
||||
# own ``client=``). Their aiohttp sessions live on the background ``_loop`` and
|
||||
# would otherwise leak "Unclosed client session/connector" warnings at exit, so
|
||||
# we close them on that loop via an atexit hook. Caller-owned clients are left
|
||||
# to the caller to close.
|
||||
_owned_clients: list[Hindsight] = []
|
||||
_owned_clients_lock = threading.Lock()
|
||||
|
||||
|
||||
def _register_owned_client(client: Hindsight) -> None:
|
||||
with _owned_clients_lock:
|
||||
_owned_clients.append(client)
|
||||
|
||||
|
||||
@atexit.register
|
||||
def _shutdown() -> None:
|
||||
"""Close module-owned clients on the background loop, then stop it."""
|
||||
if _loop.is_closed() or not _loop.is_running():
|
||||
return
|
||||
with _owned_clients_lock:
|
||||
clients = list(_owned_clients)
|
||||
_owned_clients.clear()
|
||||
for client in clients:
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(client.aclose(), _loop).result(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_loop.call_soon_threadsafe(_loop.stop)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
DEFAULT_MEMORY_PROMPT = (
|
||||
"Below are relevant memories from previous conversations:\n{memories}\n"
|
||||
"Use these memories to provide more personalized and contextual responses."
|
||||
)
|
||||
|
||||
|
||||
class _HindsightToolBackend:
|
||||
"""Internal backend that implements Hindsight memory operations.
|
||||
|
||||
Encapsulates client resolution, config fallback, bank management,
|
||||
and the retain/recall/reflect logic. Methods are wrapped as Haystack
|
||||
``Tool`` objects by ``create_hindsight_tools()``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bank_id: str,
|
||||
client: Optional[Hindsight] = None,
|
||||
hindsight_api_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
budget: Optional[str] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
recall_tags: Optional[list[str]] = None,
|
||||
recall_tags_match: Optional[str] = None,
|
||||
# Retain options
|
||||
retain_metadata: Optional[dict[str, str]] = None,
|
||||
retain_document_id: Optional[str] = None,
|
||||
retain_context: Optional[str] = None,
|
||||
# Recall options
|
||||
recall_types: Optional[list[str]] = None,
|
||||
recall_include_entities: bool = False,
|
||||
# Reflect options
|
||||
reflect_context: Optional[str] = None,
|
||||
reflect_max_tokens: Optional[int] = None,
|
||||
reflect_response_schema: Optional[dict[str, Any]] = None,
|
||||
reflect_tags: Optional[list[str]] = None,
|
||||
reflect_tags_match: Optional[str] = None,
|
||||
# Bank management
|
||||
mission: Optional[str] = None,
|
||||
):
|
||||
# When the caller didn't pass a client, resolve_client() created one we
|
||||
# own — register it for cleanup at exit (its session lives on _loop).
|
||||
_owns_client = client is None
|
||||
self._client = resolve_client(client, hindsight_api_url, api_key)
|
||||
if _owns_client:
|
||||
_register_owned_client(self._client)
|
||||
self._bank_id = bank_id
|
||||
self._session_id = str(uuid.uuid4())[:8]
|
||||
self._bank_initialized = False
|
||||
|
||||
# Resolve effective values using None-sentinel config fallback
|
||||
config = get_config()
|
||||
self._tags = tags if tags is not None else (config.tags if config else None)
|
||||
self._recall_tags = recall_tags if recall_tags is not None else (config.recall_tags if config else None)
|
||||
self._recall_tags_match = (
|
||||
recall_tags_match if recall_tags_match is not None else (config.recall_tags_match if config else "any")
|
||||
)
|
||||
self._budget = budget if budget is not None else (config.budget if config else "mid")
|
||||
self._max_tokens = max_tokens if max_tokens is not None else (config.max_tokens if config else 4096)
|
||||
|
||||
# Retain-specific
|
||||
self._retain_metadata = retain_metadata
|
||||
self._retain_document_id = retain_document_id
|
||||
self._retain_context = (
|
||||
retain_context if retain_context is not None else (config.context if config else "haystack")
|
||||
)
|
||||
|
||||
# Recall-specific
|
||||
self._recall_types = recall_types
|
||||
self._recall_include_entities = recall_include_entities
|
||||
|
||||
# Reflect-specific
|
||||
self._reflect_context = reflect_context
|
||||
self._reflect_max_tokens = reflect_max_tokens
|
||||
self._reflect_response_schema = reflect_response_schema
|
||||
self._reflect_tags = reflect_tags
|
||||
self._reflect_tags_match = reflect_tags_match
|
||||
|
||||
# Bank management
|
||||
self._mission = mission if mission is not None else (config.mission if config else None)
|
||||
|
||||
def _ensure_bank(self) -> None:
|
||||
"""Create/update the bank with mission if not already done."""
|
||||
if self._bank_initialized or not self._mission:
|
||||
return
|
||||
try:
|
||||
_run_sync(
|
||||
self._client.acreate_bank(
|
||||
bank_id=self._bank_id,
|
||||
name=self._bank_id,
|
||||
mission=self._mission,
|
||||
)
|
||||
)
|
||||
self._bank_initialized = True
|
||||
logger.debug(f"Created/updated bank: {self._bank_id}")
|
||||
except Exception as e:
|
||||
err_str = str(e).lower()
|
||||
if "already exists" in err_str or "409" in err_str or "conflict" in err_str:
|
||||
self._bank_initialized = True
|
||||
logger.debug(f"Bank already exists: {self._bank_id}")
|
||||
else:
|
||||
# Transient error — don't mark as initialized so we retry next time
|
||||
logger.warning(f"Bank creation failed for {self._bank_id}: {e}")
|
||||
|
||||
def _generate_document_id(self) -> str:
|
||||
"""Generate a unique document_id for retain operations."""
|
||||
return f"{self._session_id}-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
def _retain_kwargs(self, content: str) -> dict[str, Any]:
|
||||
kwargs: dict[str, Any] = {
|
||||
"bank_id": self._bank_id,
|
||||
"content": content,
|
||||
"context": self._retain_context,
|
||||
}
|
||||
if self._tags:
|
||||
kwargs["tags"] = self._tags
|
||||
if self._retain_metadata:
|
||||
kwargs["metadata"] = self._retain_metadata
|
||||
# Use explicit document_id if set, otherwise auto-generate
|
||||
kwargs["document_id"] = self._retain_document_id or self._generate_document_id()
|
||||
return kwargs
|
||||
|
||||
def _recall_kwargs(self, query: str) -> dict[str, Any]:
|
||||
kwargs: dict[str, Any] = {
|
||||
"bank_id": self._bank_id,
|
||||
"query": query,
|
||||
"budget": self._budget,
|
||||
"max_tokens": self._max_tokens,
|
||||
}
|
||||
if self._recall_tags:
|
||||
kwargs["tags"] = self._recall_tags
|
||||
kwargs["tags_match"] = self._recall_tags_match
|
||||
if self._recall_types:
|
||||
kwargs["types"] = self._recall_types
|
||||
if self._recall_include_entities:
|
||||
kwargs["include_entities"] = True
|
||||
return kwargs
|
||||
|
||||
def _reflect_kwargs(self, query: str) -> dict[str, Any]:
|
||||
kwargs: dict[str, Any] = {
|
||||
"bank_id": self._bank_id,
|
||||
"query": query,
|
||||
"budget": self._budget,
|
||||
}
|
||||
if self._reflect_context:
|
||||
kwargs["context"] = self._reflect_context
|
||||
effective_reflect_max = self._reflect_max_tokens or self._max_tokens
|
||||
if effective_reflect_max:
|
||||
kwargs["max_tokens"] = effective_reflect_max
|
||||
if self._reflect_response_schema:
|
||||
kwargs["response_schema"] = self._reflect_response_schema
|
||||
effective_reflect_tags = self._reflect_tags if self._reflect_tags is not None else self._recall_tags
|
||||
effective_reflect_tags_match = self._reflect_tags_match or self._recall_tags_match
|
||||
if effective_reflect_tags:
|
||||
kwargs["tags"] = effective_reflect_tags
|
||||
kwargs["tags_match"] = effective_reflect_tags_match
|
||||
return kwargs
|
||||
|
||||
@staticmethod
|
||||
def _format_recall(response: Any) -> str:
|
||||
if not response.results:
|
||||
return "No relevant memories found."
|
||||
lines = []
|
||||
for i, result in enumerate(response.results, 1):
|
||||
lines.append(f"{i}. {result.text}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def retain_memory(self, content: str) -> str:
|
||||
"""Store information to long-term memory for later retrieval.
|
||||
|
||||
Use this to save important facts, user preferences, decisions,
|
||||
or any information that should be remembered across conversations.
|
||||
|
||||
Args:
|
||||
content: The information to store in memory.
|
||||
"""
|
||||
try:
|
||||
self._ensure_bank()
|
||||
_run_sync(self._client.aretain(**self._retain_kwargs(content)))
|
||||
return "Memory stored successfully."
|
||||
except Exception as e:
|
||||
logger.error(f"Retain failed: {e}")
|
||||
return f"Failed to store memory: {e!s:.200}"
|
||||
|
||||
def recall_memory(self, query: str) -> str:
|
||||
"""Search long-term memory for relevant information.
|
||||
|
||||
Use this to find previously stored facts, preferences, or context.
|
||||
Returns a numbered list of matching memories.
|
||||
|
||||
Args:
|
||||
query: What to search for in memory.
|
||||
"""
|
||||
try:
|
||||
self._ensure_bank()
|
||||
response = _run_sync(self._client.arecall(**self._recall_kwargs(query)))
|
||||
return self._format_recall(response)
|
||||
except Exception as e:
|
||||
logger.error(f"Recall failed: {e}")
|
||||
return f"Failed to search memory: {e!s:.200}"
|
||||
|
||||
def reflect_on_memory(self, query: str) -> str:
|
||||
"""Synthesize a thoughtful answer from long-term memories.
|
||||
|
||||
Use this when you need a coherent summary or reasoned response
|
||||
about what you know, rather than raw memory facts.
|
||||
|
||||
Args:
|
||||
query: The question to reflect on using stored memories.
|
||||
"""
|
||||
try:
|
||||
self._ensure_bank()
|
||||
response = _run_sync(self._client.areflect(**self._reflect_kwargs(query)))
|
||||
# When response_schema is set, return the structured JSON output
|
||||
if self._reflect_response_schema and response.structured_output is not None:
|
||||
return json.dumps(response.structured_output)
|
||||
return response.text or "No relevant memories found."
|
||||
except Exception as e:
|
||||
logger.error(f"Reflect failed: {e}")
|
||||
return f"Failed to reflect on memory: {e!s:.200}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ToolDef:
|
||||
"""Static definition of a Hindsight tool.
|
||||
|
||||
The dict key in ``_TOOL_DEFS`` doubles as both the tool name and the
|
||||
backend method name (they are identical), so only the description and
|
||||
parameter schema need to be stored here.
|
||||
"""
|
||||
|
||||
description: str
|
||||
parameters: dict[str, Any]
|
||||
|
||||
|
||||
_TOOL_DEFS: dict[str, _ToolDef] = {
|
||||
"retain_memory": _ToolDef(
|
||||
description=(
|
||||
"Store information to long-term memory for later retrieval. "
|
||||
"Use this to save important facts, user preferences, decisions, "
|
||||
"or any information that should be remembered across conversations."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The information to store in memory.",
|
||||
},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
),
|
||||
"recall_memory": _ToolDef(
|
||||
description=(
|
||||
"Search long-term memory for relevant information. "
|
||||
"Use this to find previously stored facts, preferences, or context. "
|
||||
"Returns a numbered list of matching memories."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "What to search for in memory.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
),
|
||||
"reflect_on_memory": _ToolDef(
|
||||
description=(
|
||||
"Synthesize a thoughtful answer from long-term memories. "
|
||||
"Use this when you need a coherent summary or reasoned response "
|
||||
"about what you know, rather than raw memory facts."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The question to reflect on using stored memories.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class _HindsightTool(Tool):
|
||||
"""A Haystack Tool backed by a Hindsight memory operation.
|
||||
|
||||
Overrides ``to_dict()``/``from_dict()`` so that the tool's configuration
|
||||
(bank_id, API URL, etc.) is serialized instead of the bound method,
|
||||
which Haystack cannot serialize.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
backend: "_HindsightToolBackend",
|
||||
tool_name: str,
|
||||
backend_kwargs: dict[str, Any],
|
||||
):
|
||||
# The tool name and the backend method name are identical.
|
||||
tool_def = _TOOL_DEFS[tool_name]
|
||||
super().__init__(
|
||||
name=tool_name,
|
||||
description=tool_def.description,
|
||||
function=getattr(backend, tool_name),
|
||||
parameters=tool_def.parameters,
|
||||
)
|
||||
self._backend = backend
|
||||
self._backend_kwargs = backend_kwargs
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize the tool to a dictionary.
|
||||
|
||||
Stores the backend configuration so the tool can be reconstructed
|
||||
via ``from_dict()`` without needing to serialize the bound method.
|
||||
"""
|
||||
data = {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": self.parameters,
|
||||
"backend_kwargs": self._backend_kwargs,
|
||||
}
|
||||
cls = type(self)
|
||||
qualified_name = f"{cls.__module__}.{cls.__qualname__}"
|
||||
return {"type": qualified_name, "data": data}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "_HindsightTool":
|
||||
"""Deserialize the tool from a dictionary."""
|
||||
inner = data["data"]
|
||||
backend_kwargs = inner["backend_kwargs"]
|
||||
backend = _HindsightToolBackend(**backend_kwargs)
|
||||
return cls(
|
||||
backend=backend,
|
||||
tool_name=inner["name"],
|
||||
backend_kwargs=backend_kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _build_backend_kwargs(
|
||||
*,
|
||||
bank_id: str,
|
||||
client: Optional[Hindsight],
|
||||
hindsight_api_url: Optional[str],
|
||||
api_key: Optional[str],
|
||||
budget: Optional[str],
|
||||
max_tokens: Optional[int],
|
||||
tags: Optional[list[str]],
|
||||
recall_tags: Optional[list[str]],
|
||||
recall_tags_match: Optional[str],
|
||||
retain_metadata: Optional[dict[str, str]],
|
||||
retain_document_id: Optional[str],
|
||||
retain_context: Optional[str],
|
||||
recall_types: Optional[list[str]],
|
||||
recall_include_entities: bool,
|
||||
reflect_context: Optional[str],
|
||||
reflect_max_tokens: Optional[int],
|
||||
reflect_response_schema: Optional[dict[str, Any]],
|
||||
reflect_tags: Optional[list[str]],
|
||||
reflect_tags_match: Optional[str],
|
||||
mission: Optional[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Build serializable backend kwargs, extracting client connection info.
|
||||
|
||||
The api_key is intentionally NOT serialized. Haystack pipelines get dumped
|
||||
to YAML for inspection/checkpointing/sharing, and a serialized key would
|
||||
leak into every dump. On deserialization, resolve_client() reads the key
|
||||
from the HINDSIGHT_API_KEY env var (see _client.py:resolve_client), so a
|
||||
redeployed pipeline picks the key back up from the host's environment
|
||||
rather than from the YAML.
|
||||
"""
|
||||
serializable_url = hindsight_api_url
|
||||
if client is not None and serializable_url is None:
|
||||
serializable_url = getattr(client, "_base_url", None) or getattr(client, "base_url", None)
|
||||
if serializable_url is not None:
|
||||
serializable_url = str(serializable_url)
|
||||
# api_key is deliberately omitted from the returned dict; resolve_client's
|
||||
# env-var fallback supplies it on rebuild.
|
||||
del api_key
|
||||
|
||||
return {
|
||||
"bank_id": bank_id,
|
||||
"hindsight_api_url": serializable_url,
|
||||
"budget": budget,
|
||||
"max_tokens": max_tokens,
|
||||
"tags": tags,
|
||||
"recall_tags": recall_tags,
|
||||
"recall_tags_match": recall_tags_match,
|
||||
"retain_metadata": retain_metadata,
|
||||
"retain_document_id": retain_document_id,
|
||||
"retain_context": retain_context,
|
||||
"recall_types": recall_types,
|
||||
"recall_include_entities": recall_include_entities,
|
||||
"reflect_context": reflect_context,
|
||||
"reflect_max_tokens": reflect_max_tokens,
|
||||
"reflect_response_schema": reflect_response_schema,
|
||||
"reflect_tags": reflect_tags,
|
||||
"reflect_tags_match": reflect_tags_match,
|
||||
"mission": mission,
|
||||
}
|
||||
|
||||
|
||||
def _build_tools(
|
||||
backend: _HindsightToolBackend,
|
||||
backend_kwargs: dict[str, Any],
|
||||
*,
|
||||
include_retain: bool = True,
|
||||
include_recall: bool = True,
|
||||
include_reflect: bool = True,
|
||||
) -> list[Tool]:
|
||||
"""Create _HindsightTool instances from a backend."""
|
||||
tools: list[Tool] = []
|
||||
if include_retain:
|
||||
tools.append(_HindsightTool(backend=backend, tool_name="retain_memory", backend_kwargs=backend_kwargs))
|
||||
if include_recall:
|
||||
tools.append(_HindsightTool(backend=backend, tool_name="recall_memory", backend_kwargs=backend_kwargs))
|
||||
if include_reflect:
|
||||
tools.append(_HindsightTool(backend=backend, tool_name="reflect_on_memory", backend_kwargs=backend_kwargs))
|
||||
return tools
|
||||
|
||||
|
||||
def create_hindsight_tools(
|
||||
*,
|
||||
bank_id: str,
|
||||
client: Optional[Hindsight] = None,
|
||||
hindsight_api_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
budget: Optional[str] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
recall_tags: Optional[list[str]] = None,
|
||||
recall_tags_match: Optional[str] = None,
|
||||
# Retain options
|
||||
retain_metadata: Optional[dict[str, str]] = None,
|
||||
retain_document_id: Optional[str] = None,
|
||||
retain_context: Optional[str] = None,
|
||||
# Recall options
|
||||
recall_types: Optional[list[str]] = None,
|
||||
recall_include_entities: bool = False,
|
||||
# Reflect options
|
||||
reflect_context: Optional[str] = None,
|
||||
reflect_max_tokens: Optional[int] = None,
|
||||
reflect_response_schema: Optional[dict[str, Any]] = None,
|
||||
reflect_tags: Optional[list[str]] = None,
|
||||
reflect_tags_match: Optional[str] = None,
|
||||
# Bank management
|
||||
mission: Optional[str] = None,
|
||||
include_retain: bool = True,
|
||||
include_recall: bool = True,
|
||||
include_reflect: bool = True,
|
||||
) -> list[Tool]:
|
||||
"""Create Hindsight memory tools for a Haystack agent.
|
||||
|
||||
Convenience factory that creates a backend and returns Haystack ``Tool``
|
||||
instances ready for use with any Haystack agent. For automatic recall
|
||||
and retain behavior, use :class:`HindsightToolset` instead.
|
||||
|
||||
Args:
|
||||
bank_id: The Hindsight memory bank to operate on.
|
||||
client: Pre-configured Hindsight client (preferred).
|
||||
hindsight_api_url: API URL (used if no client provided).
|
||||
api_key: API key (used if no client provided).
|
||||
budget: Recall/reflect budget level (low/mid/high).
|
||||
max_tokens: Maximum tokens for recall results.
|
||||
tags: Tags applied when storing memories via retain.
|
||||
recall_tags: Tags to filter when searching memories.
|
||||
recall_tags_match: Tag matching mode (any/all/any_strict/all_strict).
|
||||
retain_metadata: Default metadata dict for retain operations.
|
||||
retain_document_id: Default document_id for retain. If None,
|
||||
auto-generates per call.
|
||||
retain_context: Source label for retain operations.
|
||||
recall_types: Fact types to filter (world, experience, opinion, observation).
|
||||
recall_include_entities: Include entity information in recall results.
|
||||
reflect_context: Additional context for reflect operations.
|
||||
reflect_max_tokens: Max tokens for reflect results (defaults to max_tokens).
|
||||
reflect_response_schema: JSON schema to constrain reflect output format.
|
||||
reflect_tags: Tags to filter memories used in reflect (defaults to recall_tags).
|
||||
reflect_tags_match: Tag matching for reflect (defaults to recall_tags_match).
|
||||
mission: Bank mission for fact extraction context.
|
||||
include_retain: Include the retain (store) tool.
|
||||
include_recall: Include the recall (search) tool.
|
||||
include_reflect: Include the reflect (synthesize) tool.
|
||||
|
||||
Returns:
|
||||
List of Haystack Tool instances.
|
||||
|
||||
Note:
|
||||
Tool *invocations* never raise — they catch errors and return an
|
||||
error string so the agent can react. Connection resolution always
|
||||
succeeds because the API URL defaults to Hindsight Cloud; a missing
|
||||
API key only surfaces when a call is actually made.
|
||||
"""
|
||||
backend_kwargs = _build_backend_kwargs(
|
||||
bank_id=bank_id,
|
||||
client=client,
|
||||
hindsight_api_url=hindsight_api_url,
|
||||
api_key=api_key,
|
||||
budget=budget,
|
||||
max_tokens=max_tokens,
|
||||
tags=tags,
|
||||
recall_tags=recall_tags,
|
||||
recall_tags_match=recall_tags_match,
|
||||
retain_metadata=retain_metadata,
|
||||
retain_document_id=retain_document_id,
|
||||
retain_context=retain_context,
|
||||
recall_types=recall_types,
|
||||
recall_include_entities=recall_include_entities,
|
||||
reflect_context=reflect_context,
|
||||
reflect_max_tokens=reflect_max_tokens,
|
||||
reflect_response_schema=reflect_response_schema,
|
||||
reflect_tags=reflect_tags,
|
||||
reflect_tags_match=reflect_tags_match,
|
||||
mission=mission,
|
||||
)
|
||||
|
||||
backend = _HindsightToolBackend(client=client, **backend_kwargs)
|
||||
|
||||
return _build_tools(
|
||||
backend,
|
||||
backend_kwargs,
|
||||
include_retain=include_retain,
|
||||
include_recall=include_recall,
|
||||
include_reflect=include_reflect,
|
||||
)
|
||||
|
||||
|
||||
class HindsightToolset(Toolset):
|
||||
"""Haystack ``Toolset`` with optional auto-recall and auto-retain.
|
||||
|
||||
Groups Hindsight memory tools into a single toolset and optionally adds
|
||||
automatic memory behavior:
|
||||
|
||||
- **auto_recall**: Before each agent turn, recalls relevant memories and
|
||||
prepends them to the system prompt so the agent has context without
|
||||
needing to call a tool.
|
||||
- **auto_retain**: After each agent turn, retains user and assistant
|
||||
messages to Hindsight for long-term storage.
|
||||
|
||||
Use :meth:`run` / :meth:`run_async` for automatic behavior, or pass the
|
||||
toolset directly to ``Agent(tools=...)`` for tool-only (explicit) mode.
|
||||
|
||||
Example::
|
||||
|
||||
from hindsight_haystack import HindsightToolset
|
||||
from haystack.components.agents import Agent
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
|
||||
toolset = HindsightToolset(
|
||||
bank_id="user-123",
|
||||
client=client,
|
||||
mission="Track user preferences",
|
||||
auto_recall=True,
|
||||
auto_retain=True,
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
chat_generator=OpenAIChatGenerator(),
|
||||
tools=toolset,
|
||||
system_prompt="You are a helpful assistant with long-term memory.",
|
||||
)
|
||||
|
||||
# Use toolset.run() for auto-recall/retain behavior
|
||||
result = toolset.run(agent, messages=[ChatMessage.from_user("Hi!")])
|
||||
|
||||
Args:
|
||||
bank_id: The Hindsight memory bank to operate on.
|
||||
client: Pre-configured Hindsight client (preferred).
|
||||
hindsight_api_url: API URL (used if no client provided).
|
||||
api_key: API key (used if no client provided).
|
||||
budget: Recall/reflect budget level (low/mid/high).
|
||||
max_tokens: Maximum tokens for recall results.
|
||||
tags: Tags applied when storing memories via retain.
|
||||
recall_tags: Tags to filter when searching memories.
|
||||
recall_tags_match: Tag matching mode.
|
||||
retain_metadata: Default metadata for retain operations.
|
||||
retain_document_id: Default document_id for retain.
|
||||
retain_context: Source label for retain operations.
|
||||
recall_types: Fact types to filter on recall.
|
||||
recall_include_entities: Include entities in recall results.
|
||||
reflect_context: Additional context for reflect.
|
||||
reflect_max_tokens: Max tokens for reflect.
|
||||
reflect_response_schema: JSON schema for structured reflect output.
|
||||
reflect_tags: Tags to filter reflect memories.
|
||||
reflect_tags_match: Tag matching for reflect.
|
||||
mission: Bank mission for fact extraction context.
|
||||
include_retain: Include the retain tool (default True).
|
||||
include_recall: Include the recall tool (default True).
|
||||
include_reflect: Include the reflect tool (default True).
|
||||
auto_recall: Auto-recall memories into system prompt before each turn.
|
||||
auto_retain: Auto-retain user/assistant messages after each turn.
|
||||
max_recall_results: Maximum number of memories to inject into the
|
||||
system prompt during auto-recall (default 10).
|
||||
memory_prompt_template: Template for injecting memories into system
|
||||
prompt. Must contain ``{memories}`` placeholder.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bank_id: str,
|
||||
client: Optional[Hindsight] = None,
|
||||
hindsight_api_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
budget: Optional[str] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
recall_tags: Optional[list[str]] = None,
|
||||
recall_tags_match: Optional[str] = None,
|
||||
retain_metadata: Optional[dict[str, str]] = None,
|
||||
retain_document_id: Optional[str] = None,
|
||||
retain_context: Optional[str] = None,
|
||||
recall_types: Optional[list[str]] = None,
|
||||
recall_include_entities: bool = False,
|
||||
reflect_context: Optional[str] = None,
|
||||
reflect_max_tokens: Optional[int] = None,
|
||||
reflect_response_schema: Optional[dict[str, Any]] = None,
|
||||
reflect_tags: Optional[list[str]] = None,
|
||||
reflect_tags_match: Optional[str] = None,
|
||||
mission: Optional[str] = None,
|
||||
include_retain: bool = True,
|
||||
include_recall: bool = True,
|
||||
include_reflect: bool = True,
|
||||
auto_recall: bool = False,
|
||||
auto_retain: bool = False,
|
||||
max_recall_results: int = 10,
|
||||
memory_prompt_template: str = DEFAULT_MEMORY_PROMPT,
|
||||
):
|
||||
backend_kwargs = _build_backend_kwargs(
|
||||
bank_id=bank_id,
|
||||
client=client,
|
||||
hindsight_api_url=hindsight_api_url,
|
||||
api_key=api_key,
|
||||
budget=budget,
|
||||
max_tokens=max_tokens,
|
||||
tags=tags,
|
||||
recall_tags=recall_tags,
|
||||
recall_tags_match=recall_tags_match,
|
||||
retain_metadata=retain_metadata,
|
||||
retain_document_id=retain_document_id,
|
||||
retain_context=retain_context,
|
||||
recall_types=recall_types,
|
||||
recall_include_entities=recall_include_entities,
|
||||
reflect_context=reflect_context,
|
||||
reflect_max_tokens=reflect_max_tokens,
|
||||
reflect_response_schema=reflect_response_schema,
|
||||
reflect_tags=reflect_tags,
|
||||
reflect_tags_match=reflect_tags_match,
|
||||
mission=mission,
|
||||
)
|
||||
|
||||
self._backend = _HindsightToolBackend(client=client, **backend_kwargs)
|
||||
self._backend_kwargs = backend_kwargs
|
||||
self._auto_recall = auto_recall
|
||||
self._auto_retain = auto_retain
|
||||
self._max_recall_results = max_recall_results
|
||||
self._memory_prompt_template = memory_prompt_template
|
||||
self._include_retain = include_retain
|
||||
self._include_recall = include_recall
|
||||
self._include_reflect = include_reflect
|
||||
|
||||
tools = _build_tools(
|
||||
self._backend,
|
||||
backend_kwargs,
|
||||
include_retain=include_retain,
|
||||
include_recall=include_recall,
|
||||
include_reflect=include_reflect,
|
||||
)
|
||||
super().__init__(tools=tools)
|
||||
|
||||
def _recall_for_prompt(self, query: str) -> str:
|
||||
"""Recall memories and format for system prompt injection.
|
||||
|
||||
Caps results at ``max_recall_results`` to prevent unbounded prompt growth.
|
||||
"""
|
||||
try:
|
||||
self._backend._ensure_bank()
|
||||
response = _run_sync(self._backend._client.arecall(**self._backend._recall_kwargs(query)))
|
||||
if not response.results:
|
||||
return ""
|
||||
lines = []
|
||||
for i, r in enumerate(response.results[: self._max_recall_results], 1):
|
||||
lines.append(f"{i}. {r.text}")
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
logger.error(f"Auto-recall failed: {e}")
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_last_user_text(messages: list[ChatMessage]) -> str:
|
||||
"""Extract the text of the last user message."""
|
||||
for msg in reversed(messages):
|
||||
if msg.role.value == "user" and msg.text:
|
||||
return msg.text
|
||||
return ""
|
||||
|
||||
def _enrich_system_prompt(self, base_prompt: Optional[str], query: str) -> Optional[str]:
|
||||
"""Recall memories and append to the system prompt."""
|
||||
memories = self._recall_for_prompt(query)
|
||||
if not memories:
|
||||
return base_prompt
|
||||
memory_block = self._memory_prompt_template.format(memories=memories)
|
||||
if base_prompt:
|
||||
return f"{base_prompt}\n\n{memory_block}"
|
||||
return memory_block
|
||||
|
||||
def _retain_messages(self, messages: list[ChatMessage]) -> None:
|
||||
"""Retain user and assistant messages to Hindsight.
|
||||
|
||||
Includes message role in metadata so recalled turns are distinguishable.
|
||||
"""
|
||||
for msg in messages:
|
||||
role = msg.role.value
|
||||
if role in ("user", "assistant") and msg.text:
|
||||
try:
|
||||
self._backend._ensure_bank()
|
||||
kwargs = self._backend._retain_kwargs(msg.text)
|
||||
# Merge role metadata with any configured retain_metadata
|
||||
existing_meta = kwargs.get("metadata") or {}
|
||||
kwargs["metadata"] = {**existing_meta, "role": role, "source": "haystack"}
|
||||
_run_sync(self._backend._client.aretain(**kwargs))
|
||||
except Exception as e:
|
||||
logger.error(f"Auto-retain failed for {role} message: {e}")
|
||||
|
||||
def run(
|
||||
self,
|
||||
agent: Any,
|
||||
*,
|
||||
messages: list[ChatMessage],
|
||||
system_prompt: Optional[str] = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Run an agent with auto-recall and auto-retain.
|
||||
|
||||
Wraps ``agent.run()`` with automatic memory behavior:
|
||||
|
||||
1. **Auto-recall** (if enabled): Recalls memories relevant to the
|
||||
user's message and prepends them to the system prompt.
|
||||
2. **Agent execution**: Calls ``agent.run()`` with the enriched
|
||||
system prompt.
|
||||
3. **Auto-retain** (if enabled): Retains the user messages and the
|
||||
agent's final response to Hindsight.
|
||||
|
||||
Args:
|
||||
agent: A Haystack ``Agent`` instance.
|
||||
messages: User messages to process.
|
||||
system_prompt: Base system prompt. If None, uses the agent's
|
||||
configured ``system_prompt``.
|
||||
**kwargs: Additional kwargs passed to ``agent.run()``.
|
||||
|
||||
Returns:
|
||||
The result dict from ``agent.run()``.
|
||||
"""
|
||||
base_prompt = system_prompt if system_prompt is not None else getattr(agent, "system_prompt", None)
|
||||
|
||||
# Auto-recall: enrich system prompt with relevant memories
|
||||
effective_prompt = base_prompt
|
||||
if self._auto_recall:
|
||||
user_text = self._extract_last_user_text(messages)
|
||||
if user_text:
|
||||
effective_prompt = self._enrich_system_prompt(base_prompt, user_text)
|
||||
|
||||
result = agent.run(messages=messages, system_prompt=effective_prompt, **kwargs)
|
||||
|
||||
# Auto-retain: store user messages and agent response
|
||||
if self._auto_retain:
|
||||
self._retain_messages(messages)
|
||||
last_msg = result.get("last_message")
|
||||
if last_msg and last_msg.role.value == "assistant" and last_msg.text:
|
||||
self._retain_messages([last_msg])
|
||||
|
||||
return result
|
||||
|
||||
async def run_async(
|
||||
self,
|
||||
agent: Any,
|
||||
*,
|
||||
messages: list[ChatMessage],
|
||||
system_prompt: Optional[str] = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Async version of :meth:`run`.
|
||||
|
||||
Uses the same persistent event loop bridge internally, so auto-recall
|
||||
and auto-retain work identically to the sync version.
|
||||
|
||||
Args:
|
||||
agent: A Haystack ``Agent`` instance.
|
||||
messages: User messages to process.
|
||||
system_prompt: Base system prompt override.
|
||||
**kwargs: Additional kwargs passed to ``agent.run_async()``.
|
||||
|
||||
Returns:
|
||||
The result dict from ``agent.run_async()``.
|
||||
"""
|
||||
base_prompt = system_prompt if system_prompt is not None else getattr(agent, "system_prompt", None)
|
||||
|
||||
effective_prompt = base_prompt
|
||||
if self._auto_recall:
|
||||
user_text = self._extract_last_user_text(messages)
|
||||
if user_text:
|
||||
effective_prompt = self._enrich_system_prompt(base_prompt, user_text)
|
||||
|
||||
result = await agent.run_async(messages=messages, system_prompt=effective_prompt, **kwargs)
|
||||
|
||||
if self._auto_retain:
|
||||
self._retain_messages(messages)
|
||||
last_msg = result.get("last_message")
|
||||
if last_msg and last_msg.role.value == "assistant" and last_msg.text:
|
||||
self._retain_messages([last_msg])
|
||||
|
||||
return result
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize the toolset to a dictionary."""
|
||||
cls = type(self)
|
||||
qualified_name = f"{cls.__module__}.{cls.__qualname__}"
|
||||
return {
|
||||
"type": qualified_name,
|
||||
"data": {
|
||||
"backend_kwargs": self._backend_kwargs,
|
||||
"include_retain": self._include_retain,
|
||||
"include_recall": self._include_recall,
|
||||
"include_reflect": self._include_reflect,
|
||||
"auto_recall": self._auto_recall,
|
||||
"auto_retain": self._auto_retain,
|
||||
"max_recall_results": self._max_recall_results,
|
||||
"memory_prompt_template": self._memory_prompt_template,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "HindsightToolset":
|
||||
"""Deserialize the toolset from a dictionary."""
|
||||
inner = data["data"]
|
||||
backend_kwargs = inner["backend_kwargs"]
|
||||
return cls(
|
||||
**backend_kwargs,
|
||||
include_retain=inner.get("include_retain", True),
|
||||
include_recall=inner.get("include_recall", True),
|
||||
include_reflect=inner.get("include_reflect", True),
|
||||
auto_recall=inner.get("auto_recall", False),
|
||||
auto_retain=inner.get("auto_retain", False),
|
||||
max_recall_results=inner.get("max_recall_results", 10),
|
||||
memory_prompt_template=inner.get("memory_prompt_template", DEFAULT_MEMORY_PROMPT),
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
[project]
|
||||
name = "hindsight-haystack"
|
||||
version = "0.1.0"
|
||||
description = "Haystack integration for Hindsight - persistent memory for AI agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
authors = [
|
||||
{ name = "Vectorize", email = "[email protected]" }
|
||||
]
|
||||
keywords = [
|
||||
"ai",
|
||||
"memory",
|
||||
"haystack",
|
||||
"deepset",
|
||||
"agents",
|
||||
"hindsight",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"haystack-ai>=2.12.0",
|
||||
"hindsight-client>=0.4.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/vectorize-io/hindsight"
|
||||
Documentation = "https://github.com/vectorize-io/hindsight/tree/main/hindsight-integrations/haystack"
|
||||
Repository = "https://github.com/vectorize-io/hindsight"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["hindsight_haystack"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
markers = [
|
||||
"requires_real_llm: end-to-end test that needs live external services (a running Hindsight server and/or real LLM provider keys). Excluded from the deterministic PR-CI bucket via -m 'not requires_real_llm'; run on its own via -m requires_real_llm.",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=9.0.2",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"ruff>=0.8.0",
|
||||
]
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Unit tests for Hindsight Haystack configuration."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from hindsight_haystack import (
|
||||
HindsightHaystackConfig,
|
||||
configure,
|
||||
get_config,
|
||||
reset_config,
|
||||
)
|
||||
|
||||
|
||||
class TestConfigure:
|
||||
def setup_method(self):
|
||||
reset_config()
|
||||
|
||||
def teardown_method(self):
|
||||
reset_config()
|
||||
|
||||
def test_configure_returns_config(self):
|
||||
config = configure(hindsight_api_url="http://localhost:8888")
|
||||
assert isinstance(config, HindsightHaystackConfig)
|
||||
assert config.hindsight_api_url == "http://localhost:8888"
|
||||
|
||||
def test_configure_sets_global_config(self):
|
||||
assert get_config() is None
|
||||
configure(hindsight_api_url="http://localhost:8888")
|
||||
assert get_config() is not None
|
||||
assert get_config().hindsight_api_url == "http://localhost:8888"
|
||||
|
||||
def test_configure_defaults(self):
|
||||
config = configure()
|
||||
assert config.hindsight_api_url == "https://api.hindsight.vectorize.io"
|
||||
assert config.api_key is None
|
||||
assert config.budget == "mid"
|
||||
assert config.max_tokens == 4096
|
||||
assert config.tags is None
|
||||
assert config.recall_tags is None
|
||||
assert config.recall_tags_match == "any"
|
||||
assert config.context == "haystack"
|
||||
assert config.mission is None
|
||||
assert config.verbose is False
|
||||
|
||||
def test_configure_with_all_params(self):
|
||||
config = configure(
|
||||
hindsight_api_url="http://test:9999",
|
||||
api_key="test-key",
|
||||
budget="high",
|
||||
max_tokens=2048,
|
||||
tags=["tag1"],
|
||||
recall_tags=["rtag1"],
|
||||
recall_tags_match="all",
|
||||
context="my-app",
|
||||
mission="test mission",
|
||||
verbose=True,
|
||||
)
|
||||
assert config.hindsight_api_url == "http://test:9999"
|
||||
assert config.api_key == "test-key"
|
||||
assert config.budget == "high"
|
||||
assert config.max_tokens == 2048
|
||||
assert config.tags == ["tag1"]
|
||||
assert config.recall_tags == ["rtag1"]
|
||||
assert config.recall_tags_match == "all"
|
||||
assert config.context == "my-app"
|
||||
assert config.mission == "test mission"
|
||||
assert config.verbose is True
|
||||
|
||||
def test_reset_config(self):
|
||||
configure(hindsight_api_url="http://localhost:8888")
|
||||
assert get_config() is not None
|
||||
reset_config()
|
||||
assert get_config() is None
|
||||
|
||||
def test_api_key_from_env(self):
|
||||
with patch.dict(os.environ, {"HINDSIGHT_API_KEY": "env-key"}):
|
||||
config = configure()
|
||||
assert config.api_key == "env-key"
|
||||
|
||||
def test_explicit_api_key_overrides_env(self):
|
||||
with patch.dict(os.environ, {"HINDSIGHT_API_KEY": "env-key"}):
|
||||
config = configure(api_key="explicit-key")
|
||||
assert config.api_key == "explicit-key"
|
||||
|
||||
def test_context_defaults_to_haystack(self):
|
||||
config = configure()
|
||||
assert config.context == "haystack"
|
||||
@@ -0,0 +1,131 @@
|
||||
"""End-to-end tests for the Hindsight-Haystack integration.
|
||||
|
||||
Exercises the retain/recall/reflect tools against a live Hindsight server. The
|
||||
tools talk to Hindsight directly (the server's LLM does fact extraction), so
|
||||
only a running Hindsight instance is required — no provider key. By default
|
||||
these tests are skipped; point ``HINDSIGHT_API_URL`` at a reachable server to
|
||||
enable them.
|
||||
|
||||
Run with::
|
||||
|
||||
uv run pytest tests/test_e2e.py -v
|
||||
|
||||
The whole module is the real-LLM bucket (``requires_real_llm``): it depends on
|
||||
the Hindsight server's LLM-backed fact extraction, so it is excluded from the
|
||||
deterministic PR-CI bucket and run on its own / nightly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
from hindsight_haystack import create_hindsight_tools
|
||||
from hindsight_haystack.tools import _run_sync
|
||||
|
||||
HINDSIGHT_API_URL = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888")
|
||||
_NO_MEMORIES = "No relevant memories found."
|
||||
|
||||
|
||||
def _hindsight_available() -> bool:
|
||||
try:
|
||||
return requests.get(f"{HINDSIGHT_API_URL}/health", timeout=3).status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
requires_hindsight = pytest.mark.skipif(
|
||||
not _hindsight_available(),
|
||||
reason=f"Hindsight not reachable at {HINDSIGHT_API_URL}",
|
||||
)
|
||||
|
||||
# Real-LLM / real-service bucket: depends on a live Hindsight server. Excluded
|
||||
# from PR CI via `-m "not requires_real_llm"`; the skipif still gates runtime.
|
||||
pytestmark = [requires_hindsight, pytest.mark.requires_real_llm]
|
||||
|
||||
|
||||
def _tool(tools, name):
|
||||
return next(t for t in tools if t.name == name)
|
||||
|
||||
|
||||
def _recall_until_nonempty(recall_tool, query, attempts=12, delay=1.0):
|
||||
"""Poll the recall tool until it surfaces a memory (retain takes a moment
|
||||
to flow through fact extraction + indexing)."""
|
||||
for _ in range(attempts):
|
||||
result = recall_tool.invoke(query=query)
|
||||
if result and result != _NO_MEMORIES:
|
||||
return result
|
||||
time.sleep(delay)
|
||||
pytest.fail(
|
||||
f"recall({query!r}) returned no memories after {attempts * delay:.0f}s — "
|
||||
"either retain failed to surface or the query no longer matches."
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live():
|
||||
client = Hindsight(base_url=HINDSIGHT_API_URL)
|
||||
bank_id = f"haystack-e2e-{uuid.uuid4().hex[:8]}"
|
||||
# Run all client I/O on the tools' background loop so the aiohttp session is
|
||||
# created and closed on the same loop (avoids unclosed-connector warnings).
|
||||
_run_sync(client.acreate_bank(bank_id, name=f"Haystack E2E {bank_id}"))
|
||||
try:
|
||||
yield client, bank_id
|
||||
finally:
|
||||
try:
|
||||
_run_sync(client.adelete_bank(bank_id))
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning("E2E bank cleanup failed: %s", e)
|
||||
try:
|
||||
_run_sync(client.aclose())
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning("E2E client close failed: %s", e)
|
||||
|
||||
|
||||
class TestE2ETools:
|
||||
def test_retain_and_recall_roundtrip(self, live):
|
||||
client, bank_id = live
|
||||
tools = create_hindsight_tools(bank_id=bank_id, client=client)
|
||||
retain, recall = _tool(tools, "retain_memory"), _tool(tools, "recall_memory")
|
||||
|
||||
assert retain.invoke(content="The team uses PostgreSQL 16 and deploys to us-east-1.") == (
|
||||
"Memory stored successfully."
|
||||
)
|
||||
result = _recall_until_nonempty(recall, "What technologies does the team use?")
|
||||
lowered = result.lower()
|
||||
assert "postgresql" in lowered or "us-east-1" in lowered, (
|
||||
f"recall surfaced results but none referenced the stored content: {result}"
|
||||
)
|
||||
|
||||
def test_reflect_synthesizes_from_memory(self, live):
|
||||
client, bank_id = live
|
||||
tools = create_hindsight_tools(bank_id=bank_id, client=client)
|
||||
retain, recall, reflect = (
|
||||
_tool(tools, "retain_memory"),
|
||||
_tool(tools, "recall_memory"),
|
||||
_tool(tools, "reflect_on_memory"),
|
||||
)
|
||||
|
||||
retain.invoke(content="The team uses PostgreSQL 16 and deploys to us-east-1.")
|
||||
_recall_until_nonempty(recall, "What technologies does the team use?")
|
||||
|
||||
result = reflect.invoke(query="What do I know about the team's tech stack?")
|
||||
assert result and result != _NO_MEMORIES, "reflect should synthesise non-empty text"
|
||||
lowered = result.lower()
|
||||
assert "postgresql" in lowered or "us-east" in lowered, (
|
||||
f"reflect text didn't reference the stored memory: {result[:300]}"
|
||||
)
|
||||
|
||||
def test_recall_empty_bank(self, live):
|
||||
client, bank_id = live
|
||||
tools = create_hindsight_tools(
|
||||
bank_id=bank_id, client=client, include_retain=False, include_reflect=False
|
||||
)
|
||||
result = _tool(tools, "recall_memory").invoke(query="anything at all")
|
||||
assert result == _NO_MEMORIES
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+2056
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ print_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
print_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
VALID_INTEGRATIONS=("litellm" "pydantic-ai" "crewai" "ag2" "ai-sdk" "chat" "openclaw" "langgraph" "llamaindex" "nemoclaw" "strands" "claude-code" "codex" "cursor-cli" "hermes" "autogen" "paperclip" "opencode" "cloudflare-oauth-proxy" "openai-agents" "pipecat" "agentcore" "smolagents" "n8n" "dify" "gemini-spark" "vapi" "roo-code" "flowise" "google-adk" "claude-agent-sdk" "superagent")
|
||||
VALID_INTEGRATIONS=("litellm" "pydantic-ai" "crewai" "ag2" "ai-sdk" "chat" "openclaw" "langgraph" "llamaindex" "nemoclaw" "strands" "claude-code" "codex" "cursor-cli" "hermes" "autogen" "paperclip" "opencode" "cloudflare-oauth-proxy" "openai-agents" "pipecat" "agentcore" "smolagents" "n8n" "dify" "gemini-spark" "vapi" "roo-code" "flowise" "google-adk" "haystack" "claude-agent-sdk" "superagent")
|
||||
|
||||
usage() {
|
||||
print_error "Usage: $0 <integration> <version>"
|
||||
|
||||
Reference in New Issue
Block a user