Compare commits

...
Author SHA1 Message Date
Ben 45395e2b51 Merge branch 'main' into feat/haystack-integration 2026-06-08 14:39:17 -04:00
Ben 1a200a9cd6 fix(haystack): use official Haystack logo for gallery icon
Replace the placeholder glyph with the real deepset Haystack mark (teal
#0EAF9C rounded square + white symbol), extracted as vector from deepset's
own website source (deepset-ai/haystack-home site-logo partial).
2026-06-08 14:35:04 -04:00
Ben 6df79e329d fix(haystack): register in changelog/gallery + docs page + tidy tools
Review follow-ups for the Haystack integration:

1. Add haystack to the INTEGRATIONS map in generate_changelog.py so the
   release script's changelog step resolves the slug (was missing, which
   would fail the release).
2. Add the integrations.json gallery entry, a doc page at
   docs-integrations/haystack.md, and an icon — required by
   check-integrations.mjs (forward: entry needs a doc page; reverse: a
   released integration must appear in the gallery).
3. Drop the inaccurate 'Raises: HindsightError' clause from
   create_hindsight_tools — resolution always succeeds (URL defaults to
   Cloud) so it never raises; the error type stays exported as the
   conventional public catch type.
4. Replace the _TOOL_DEFS dict-of-3-tuples with a frozen _ToolDef dataclass
   and drop the redundant method-name field (it equalled the dict key).
2026-06-08 14:27:52 -04:00
DK09876 3ab39a8d3c Merge remote-tracking branch 'origin/main' into feat/haystack-integration
# Conflicts:
#	scripts/release-integration.sh
2026-06-08 08:29:07 -07:00
DK09876 1a39843e58 Merge remote-tracking branch 'origin/main' into haystack-fix2
# Conflicts:
#	scripts/release-integration.sh
2026-06-06 21:52:26 -07:00
DK09876 0fb376f5e7 ci: trailing newline to force CI retrigger 2026-06-06 21:50:47 -07:00
DK09876 ef37dd6347 ci: empty commit to attach pull_request CI check to the PR head
(Previous pushes did not auto-trigger pull_request workflow events for
reasons internal to GitHub Actions; manual workflow_dispatch runs passed
green but their checks don't roll up onto the PR. Re-poking the head
to surface the green state on the PR.)
2026-06-05 16:02:45 -07:00
DK09876 7ac2c6516c ci: re-trigger CI
A previous push to this branch silently did not trigger a pull_request
event in GitHub Actions, leaving the PR without a CI run for the latest
HEAD. Push an empty commit to force a new event.
2026-06-05 15:35:42 -07:00
DK09876andClaude Opus 4.7 febf3528a6 fix(haystack): strip api_key from to_dict() so it doesn't leak to YAML
_build_backend_kwargs was emitting the api_key in the serializable dict
that to_dict() returns. Haystack pipelines get dumped to YAML for
inspection, checkpointing, and sharing — a serialized key leaks into
every dump. Reviewer (benfrank241) flagged this on #1256.

Drop the api_key from the serialized backend_kwargs. resolve_client()
already reads HINDSIGHT_API_KEY from the env var as a final fallback,
so a redeployed pipeline picks the key back up from the host's
environment rather than from the YAML.

The test_tools_round_trip_serialization_with_client test previously
asserted the leak — flipped it to assert the key is NOT present
and added a json.dumps probe asserting the literal key value also
doesn't appear under any other field name. Pre-fix, the test fails:
  AssertionError: api_key must not appear in serialized backend_kwargs
   — would leak to YAML pipeline dumps
  assert 'api_key' not in {'api_key': 'client-key', ...}

86/86 tests pass post-fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-05 14:21:41 -07:00
DK09876 30d8993272 Merge remote-tracking branch 'origin/main' into feat/haystack-integration 2026-06-05 12:49:30 -07:00
DK09876andClaude Opus 4.7 6766e23f92 chore(haystack): sync openapi.json with main
Branch carried an older snapshot of hindsight-docs/static/openapi.json
that pre-dated main's addition of OperationProgress. Re-checkout main's
openapi.json so check-openapi-compatibility passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-05 12:41:35 -07:00
DK09876andClaude Opus 4.7 a3e1e691ae fix(haystack): close owned clients at exit; run E2E client I/O on the bridge loop
The tools run async client calls on a persistent background event loop. aiohttp
sessions bound to that loop were never closed, surfacing as "Unclosed client
session/connector" warnings. Track module-owned Hindsight clients (those created
when the caller didn't pass client=) and close them on the loop via an atexit
hook, then stop the loop. The live E2E now performs all client I/O through that
same loop (acreate_bank/adelete_bank/aclose via _run_sync) and logs cleanup
failures instead of swallowing them — zero unclosed-connector warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-05 08:38:58 -07:00
DK09876andClaude Opus 4.7 ea3ecdaf02 fix(haystack): default to Cloud without configure(); add gated E2E + bucketing
- resolve_client() now falls back to DEFAULT_HINDSIGHT_API_URL and the
  HINDSIGHT_API_KEY env var when configure() was never called (it previously
  raised "No Hindsight API URL configured"). Updated the unit test to assert
  the cloud-default + env-key behavior. Satisfies the "default to Cloud" goal.
- Add a gated tests/test_e2e.py (retain/recall/reflect tools against a live
  Hindsight server), marked requires_real_llm; register the marker in
  pyproject; the test-haystack-integration CI job now runs the deterministic
  bucket (-m "not requires_real_llm").

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-05 08:38:58 -07:00
DK09876andClaude Opus 4.6 687db27cc9 fix(haystack): address review round 2 — max_recall_results, role metadata, _run_sync cleanup
- Add max_recall_results param to HindsightToolset (default 10) to cap
  auto-recall prompt injection size, matching Pydantic AI pattern
- Auto-retain now includes role + source metadata on messages, matching
  LlamaIndex's metadata pattern for distinguishable conversation turns
- _recall_for_prompt now calls the API directly with result cap instead
  of going through the formatted string from recall_memory
- Serialize/deserialize max_recall_results in to_dict/from_dict
- Add tests for max_recall_results and role metadata (82 total)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-06-05 08:38:58 -07:00
DK09876andClaude Opus 4.6 1fb1bf080d feat(haystack): add HindsightToolset with auto-recall/retain, fix review issues
- Add HindsightToolset(Toolset) with auto_recall and auto_retain flags
  that automatically inject recalled memories into the system prompt
  before each turn and retain user/assistant messages after each turn
- Fix _ensure_bank to retry on transient errors instead of permanently
  disabling bank creation
- Fix reflect_on_memory to return structured_output JSON when
  response_schema is set
- Truncate error messages to avoid dumping raw HTTP responses to agents
- Extract _build_backend_kwargs() and _build_tools() as shared helpers
- Add 20 new tests (60 -> 80 total) covering toolset, auto-recall,
  auto-retain, structured output, and bank creation retry

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-06-05 08:38:58 -07:00
DK09876andClaude Opus 4.6 f9c06113a3 fix(haystack): use persistent event loop for async client calls
aiohttp binds its session to the creating event loop, so asyncio.run()
(which creates/destroys a loop per call) breaks on sequential calls.
Switch to a persistent daemon-thread event loop with
run_coroutine_threadsafe. Also removes unused per-operation timeout
constants and adds _run_sync tests.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-06-05 08:38:58 -07:00
DK09876andClaude Opus 4.6 2a7c496e28 feat(integrations): add Haystack integration for persistent agent memory
Add hindsight-haystack package providing Haystack Tool instances backed
by Hindsight's retain/recall/reflect APIs. Uses async client methods with
event-loop-safe sync wrapper to work correctly inside Haystack's agent
runtime.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-06-05 08:38:58 -07:00
19 changed files with 4938 additions and 1 deletions
+43
View File
@@ -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
```
+10
View File
@@ -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

+108
View File
@@ -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
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -13,7 +13,7 @@ print_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
print_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
VALID_INTEGRATIONS=("litellm" "pydantic-ai" "crewai" "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>"