fleet-memory/hindsight-integrations/crewai/tests/test_storage.py
Ben 41db2960c5
feat: add CrewAI integration for persistent crew memory (#319)
* feat: add CrewAI integration for persistent crew memory

Implements a CrewAI ExternalMemory storage backend that maps CrewAI's
Storage interface (save/search/reset) to Hindsight's retain/recall/delete
APIs, giving crews long-term memory with fact extraction, entity tracking,
and temporal awareness across runs.

Key features:
- HindsightStorage: drop-in Storage backend for CrewAI ExternalMemory
- HindsightReflectTool: BaseTool exposing Hindsight's reflect API
- Per-agent memory banks with customizable bank resolver
- Async compatibility layer for CrewAI's threading model
- 35 unit tests, docs site page, example script

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: move CrewAI example to hindsight-cookbook

Move research_crew.py example from hindsight-integrations/crewai/examples/
to the cookbook repo and update the integration README to link there instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: add GitHub Actions test job for CrewAI integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: add uv.lock for frozen installs in CI

The test-crewai-integration CI job uses `uv sync --frozen` which
requires a committed lock file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:05:52 +01:00

259 lines
9.9 KiB
Python

"""Unit tests for HindsightStorage."""
from unittest.mock import MagicMock, patch
from hindsight_crewai import HindsightStorage, configure, reset_config
from hindsight_crewai.errors import HindsightError
import pytest
def _passthrough(fn, *args, **kwargs):
"""Replace call_sync with direct call for testing."""
return fn(*args, **kwargs)
class TestHindsightStorage:
def setup_method(self):
reset_config()
configure(hindsight_api_url="http://localhost:8888")
def teardown_method(self):
reset_config()
def _make_storage(self, **kwargs):
"""Create a storage instance with a mocked client."""
storage = HindsightStorage(bank_id="test-bank", **kwargs)
mock_client = MagicMock()
storage._local.client = mock_client
storage._created_banks.add("test-bank")
return storage, mock_client
def _make_recall_result(self, text="Memory text", type_="world", **kwargs):
"""Create a mock RecallResult."""
r = MagicMock()
r.text = text
r.type = type_
r.context = kwargs.get("context")
r.occurred_start = kwargs.get("occurred_start")
r.document_id = kwargs.get("document_id")
r.metadata = kwargs.get("metadata")
r.tags = kwargs.get("tags")
return r
# --- save() tests ---
@patch("hindsight_crewai.storage.call_sync", side_effect=_passthrough)
def test_save_calls_retain(self, _mock_cs):
storage, mock_client = self._make_storage()
storage.save("Task output text", metadata={"task": "research"}, agent="Researcher")
mock_client.retain.assert_called_once()
call_kwargs = mock_client.retain.call_args[1]
assert call_kwargs["bank_id"] == "test-bank"
assert call_kwargs["content"] == "Task output text"
assert call_kwargs["metadata"]["source"] == "crewai"
assert call_kwargs["metadata"]["agent"] == "Researcher"
assert call_kwargs["metadata"]["task"] == "research"
@patch("hindsight_crewai.storage.call_sync", side_effect=_passthrough)
def test_save_stringifies_metadata_values(self, _mock_cs):
storage, mock_client = self._make_storage()
storage.save("text", metadata={"count": 42, "active": True})
call_kwargs = mock_client.retain.call_args[1]
assert call_kwargs["metadata"]["count"] == "42"
assert call_kwargs["metadata"]["active"] == "True"
@patch("hindsight_crewai.storage.call_sync", side_effect=_passthrough)
def test_save_without_metadata(self, _mock_cs):
storage, mock_client = self._make_storage()
storage.save("text")
call_kwargs = mock_client.retain.call_args[1]
assert call_kwargs["metadata"] == {"source": "crewai"}
assert call_kwargs["context"] == "crewai:task_output:unknown"
@patch("hindsight_crewai.storage.call_sync", side_effect=_passthrough)
def test_save_raises_hindsight_error_on_failure(self, _mock_cs):
storage, mock_client = self._make_storage()
mock_client.retain.side_effect = RuntimeError("connection refused")
with pytest.raises(HindsightError, match="Failed to store memory"):
storage.save("text")
@patch("hindsight_crewai.storage.call_sync", side_effect=_passthrough)
def test_save_passes_tags(self, _mock_cs):
storage, mock_client = self._make_storage(tags=["env:prod"])
storage.save("text")
call_kwargs = mock_client.retain.call_args[1]
assert call_kwargs["tags"] == ["env:prod"]
# --- search() tests ---
@patch("hindsight_crewai.storage.call_sync", side_effect=_passthrough)
def test_search_calls_recall(self, _mock_cs):
storage, mock_client = self._make_storage()
mock_response = MagicMock()
mock_response.results = [self._make_recall_result()]
mock_client.recall.return_value = mock_response
results = storage.search("programming preferences", limit=5)
mock_client.recall.assert_called_once()
assert len(results) == 1
assert results[0]["context"] == "Memory text"
assert "score" in results[0]
assert results[0]["metadata"]["type"] == "world"
@patch("hindsight_crewai.storage.call_sync", side_effect=_passthrough)
def test_search_respects_limit(self, _mock_cs):
storage, mock_client = self._make_storage()
mock_response = MagicMock()
mock_response.results = [self._make_recall_result(text=f"Memory {i}") for i in range(10)]
mock_client.recall.return_value = mock_response
results = storage.search("test", limit=3)
assert len(results) == 3
@patch("hindsight_crewai.storage.call_sync", side_effect=_passthrough)
def test_search_returns_empty_on_no_results(self, _mock_cs):
storage, mock_client = self._make_storage()
mock_response = MagicMock()
mock_response.results = []
mock_client.recall.return_value = mock_response
results = storage.search("nonexistent topic")
assert results == []
@patch("hindsight_crewai.storage.call_sync", side_effect=_passthrough)
def test_search_includes_rich_metadata(self, _mock_cs):
storage, mock_client = self._make_storage()
r = self._make_recall_result(
context="conversation",
occurred_start="2024-01-01",
document_id="doc-1",
metadata={"key": "value"},
tags=["tag1"],
)
mock_response = MagicMock()
mock_response.results = [r]
mock_client.recall.return_value = mock_response
results = storage.search("test")
meta = results[0]["metadata"]
assert meta["source_context"] == "conversation"
assert meta["occurred_start"] == "2024-01-01"
assert meta["document_id"] == "doc-1"
assert meta["key"] == "value"
assert meta["tags"] == ["tag1"]
@patch("hindsight_crewai.storage.call_sync", side_effect=_passthrough)
def test_search_passes_recall_tags(self, _mock_cs):
storage, mock_client = self._make_storage(recall_tags=["scope:global"], recall_tags_match="all")
mock_response = MagicMock()
mock_response.results = []
mock_client.recall.return_value = mock_response
storage.search("test")
call_kwargs = mock_client.recall.call_args[1]
assert call_kwargs["tags"] == ["scope:global"]
assert call_kwargs["tags_match"] == "all"
@patch("hindsight_crewai.storage.call_sync", side_effect=_passthrough)
def test_search_raises_hindsight_error_on_failure(self, _mock_cs):
storage, mock_client = self._make_storage()
mock_client.recall.side_effect = RuntimeError("timeout")
with pytest.raises(HindsightError, match="Failed to search memories"):
storage.search("test")
@patch("hindsight_crewai.storage.call_sync", side_effect=_passthrough)
def test_search_synthetic_scores_descend(self, _mock_cs):
storage, mock_client = self._make_storage()
mock_response = MagicMock()
mock_response.results = [self._make_recall_result(text=f"Memory {i}") for i in range(5)]
mock_client.recall.return_value = mock_response
results = storage.search("test", limit=5, score_threshold=0.0)
scores = [r["score"] for r in results]
assert scores == sorted(scores, reverse=True)
assert scores[0] == 1.0
# --- reset() tests ---
@patch("hindsight_crewai.storage.call_sync", side_effect=_passthrough)
def test_reset_deletes_bank(self, _mock_cs):
storage, mock_client = self._make_storage()
storage.reset()
mock_client.delete_bank.assert_called_once_with("test-bank")
@patch("hindsight_crewai.storage.call_sync", side_effect=_passthrough)
def test_reset_is_best_effort(self, _mock_cs):
storage, mock_client = self._make_storage()
mock_client.delete_bank.side_effect = RuntimeError("not found")
# Should not raise
storage.reset()
# --- per-agent banks ---
def test_per_agent_banks_resolves_bank_id(self):
storage = HindsightStorage(bank_id="crew", per_agent_banks=True)
assert storage._resolve_bank_id("Researcher") == "crew-researcher"
assert storage._resolve_bank_id("Data Analyst") == "crew-data-analyst"
assert storage._resolve_bank_id(None) == "crew"
def test_custom_bank_resolver(self):
resolver = lambda base, agent: f"custom-{agent}" if agent else base
storage = HindsightStorage(bank_id="crew", bank_resolver=resolver)
assert storage._resolve_bank_id("Alice") == "custom-Alice"
assert storage._resolve_bank_id(None) == "crew"
@patch("hindsight_crewai.storage.call_sync", side_effect=_passthrough)
def test_save_uses_per_agent_bank(self, _mock_cs):
storage = HindsightStorage(bank_id="crew", per_agent_banks=True)
mock_client = MagicMock()
storage._local.client = mock_client
storage._created_banks.add("crew-researcher")
storage.save("output", agent="Researcher")
call_kwargs = mock_client.retain.call_args[1]
assert call_kwargs["bank_id"] == "crew-researcher"
# --- config resolution ---
def test_constructor_overrides_config(self):
configure(budget="low", max_tokens=1024)
storage = HindsightStorage(bank_id="test", budget="high", max_tokens=8192)
assert storage._budget == "high"
assert storage._max_tokens == 8192
def test_falls_back_to_config(self):
configure(budget="high", max_tokens=2048, verbose=True)
storage = HindsightStorage(bank_id="test")
assert storage._budget == "high"
assert storage._max_tokens == 2048
assert storage._verbose is True
def test_falls_back_to_defaults_without_config(self):
reset_config()
storage = HindsightStorage(bank_id="test")
assert storage._api_url == "http://localhost:8888"
assert storage._budget == "mid"
assert storage._max_tokens == 4096