fleet-memory/hindsight-integrations/agno/tests/test_tools.py
Ben 8c378b981a
feat: add Agno integration with Hindsight memory toolkit (#596)
* feat: add Agno integration with Hindsight memory toolkit

Add hindsight-agno package providing Hindsight memory tools (retain,
recall, reflect) as an Agno Toolkit, following the same pattern as
Agno's Mem0Tools. Includes per-user bank isolation, global config,
bank auto-creation, and memory_instructions() for system prompt
injection.

Also adds cookbook documentation page with architecture diagrams,
quick start examples, and configuration reference.

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

* chore: remove n8n blog post, add Agno icon, bind to release process

- Remove n8n blog post from the agno integration branch
- Add Agno logo icon and map hindsight-agno SDK tag in CookbookGrid
- Add hindsight-agno to release.sh PYTHON_PACKAGES array
- Add build, publish, artifact upload, and release asset steps in release.yml

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

* chore: remove cookbook page (moved to hindsight-cookbook repo)

The Agno cookbook application now lives in
vectorize-io/hindsight-cookbook/applications/agno-memory.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 11:17:23 +01:00

1836 lines
66 KiB
Python

"""Unit tests for Hindsight Agno tools."""
import logging
from unittest.mock import MagicMock, patch
import pytest
from hindsight_agno import (
HindsightTools,
configure,
memory_instructions,
reset_config,
)
from hindsight_agno.errors import HindsightError
from hindsight_agno.tools import _TOOL_INSTRUCTIONS, _resolve_client
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mock_client():
"""Create a mock Hindsight client."""
client = MagicMock()
client.retain = MagicMock()
client.recall = MagicMock()
client.reflect = MagicMock()
client.create_bank = MagicMock()
return client
def _mock_run_context(user_id=None, session_id=None):
"""Create a mock Agno RunContext."""
ctx = MagicMock()
ctx.user_id = user_id
ctx.session_id = session_id
return ctx
def _mock_recall_response(texts: list[str]):
"""Create a mock RecallResponse with results."""
response = MagicMock()
results = []
for t in texts:
r = MagicMock()
r.text = t
results.append(r)
response.results = results
return response
def _mock_reflect_response(text: str):
"""Create a mock ReflectResponse."""
response = MagicMock()
response.text = text
return response
# ---------------------------------------------------------------------------
# _resolve_client
# ---------------------------------------------------------------------------
class TestResolveClient:
def setup_method(self):
reset_config()
def teardown_method(self):
reset_config()
def test_returns_explicit_client(self):
client = _mock_client()
assert _resolve_client(client, None, None) is client
def test_explicit_client_ignores_url_and_key(self):
client = _mock_client()
result = _resolve_client(client, "http://ignored", "ignored-key")
assert result is client
def test_creates_client_from_url(self):
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
_resolve_client(None, "http://localhost:8888", None)
mock_cls.assert_called_once_with(
base_url="http://localhost:8888", timeout=30.0
)
def test_creates_client_with_api_key(self):
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
_resolve_client(None, "http://localhost:8888", "my-key")
mock_cls.assert_called_once_with(
base_url="http://localhost:8888", timeout=30.0, api_key="my-key"
)
def test_falls_back_to_global_config_url(self):
configure(hindsight_api_url="http://config:8888")
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
_resolve_client(None, None, None)
mock_cls.assert_called_once_with(
base_url="http://config:8888", timeout=30.0
)
def test_falls_back_to_global_config_api_key(self):
configure(hindsight_api_url="http://config:8888", api_key="config-key")
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
_resolve_client(None, None, None)
mock_cls.assert_called_once_with(
base_url="http://config:8888", timeout=30.0, api_key="config-key"
)
def test_explicit_url_overrides_config(self):
configure(hindsight_api_url="http://config:8888")
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
_resolve_client(None, "http://explicit:9999", None)
mock_cls.assert_called_once_with(
base_url="http://explicit:9999", timeout=30.0
)
def test_explicit_api_key_overrides_config(self):
configure(hindsight_api_url="http://config:8888", api_key="config-key")
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
_resolve_client(None, None, "explicit-key")
mock_cls.assert_called_once_with(
base_url="http://config:8888", timeout=30.0, api_key="explicit-key"
)
def test_raises_without_url_or_config(self):
with pytest.raises(HindsightError, match="No Hindsight API URL"):
_resolve_client(None, None, None)
def test_raises_with_empty_config_no_url(self):
# Config exists but has no url override and default is set,
# so this should NOT raise since default URL exists in config
configure()
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
_resolve_client(None, None, None)
mock_cls.assert_called_once()
# ---------------------------------------------------------------------------
# HindsightTools initialization
# ---------------------------------------------------------------------------
class TestHindsightToolsInit:
def setup_method(self):
reset_config()
def teardown_method(self):
reset_config()
def test_creates_three_tools_by_default(self):
client = _mock_client()
toolkit = HindsightTools(bank_id="test", client=client)
assert "retain_memory" in toolkit.functions
assert "recall_memory" in toolkit.functions
assert "reflect_on_memory" in toolkit.functions
assert len(toolkit.functions) == 3
def test_enable_retain_only(self):
client = _mock_client()
toolkit = HindsightTools(
bank_id="test",
client=client,
enable_retain=True,
enable_recall=False,
enable_reflect=False,
)
assert "retain_memory" in toolkit.functions
assert "recall_memory" not in toolkit.functions
assert "reflect_on_memory" not in toolkit.functions
def test_enable_recall_only(self):
client = _mock_client()
toolkit = HindsightTools(
bank_id="test",
client=client,
enable_retain=False,
enable_recall=True,
enable_reflect=False,
)
assert "retain_memory" not in toolkit.functions
assert "recall_memory" in toolkit.functions
assert "reflect_on_memory" not in toolkit.functions
def test_enable_reflect_only(self):
client = _mock_client()
toolkit = HindsightTools(
bank_id="test",
client=client,
enable_retain=False,
enable_recall=False,
enable_reflect=True,
)
assert "retain_memory" not in toolkit.functions
assert "recall_memory" not in toolkit.functions
assert "reflect_on_memory" in toolkit.functions
def test_enable_two_tools(self):
client = _mock_client()
toolkit = HindsightTools(
bank_id="test",
client=client,
enable_retain=True,
enable_recall=True,
enable_reflect=False,
)
assert len(toolkit.functions) == 2
assert "retain_memory" in toolkit.functions
assert "recall_memory" in toolkit.functions
def test_no_tools_when_all_disabled(self):
client = _mock_client()
toolkit = HindsightTools(
bank_id="test",
client=client,
enable_retain=False,
enable_recall=False,
enable_reflect=False,
)
assert len(toolkit.functions) == 0
def test_raises_without_client_or_config(self):
with pytest.raises(HindsightError, match="No Hindsight API URL"):
HindsightTools(bank_id="test")
def test_falls_back_to_global_config(self):
configure(hindsight_api_url="http://localhost:8888")
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
toolkit = HindsightTools(bank_id="test")
assert "retain_memory" in toolkit.functions
mock_cls.assert_called_once_with(
base_url="http://localhost:8888", timeout=30.0
)
def test_explicit_url_overrides_config(self):
configure(hindsight_api_url="http://config:8888")
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
HindsightTools(bank_id="test", hindsight_api_url="http://explicit:9999")
mock_cls.assert_called_once_with(
base_url="http://explicit:9999", timeout=30.0
)
def test_toolkit_name(self):
client = _mock_client()
toolkit = HindsightTools(bank_id="test", client=client)
assert toolkit.name == "hindsight_tools"
def test_toolkit_has_instructions(self):
client = _mock_client()
toolkit = HindsightTools(bank_id="test", client=client)
assert toolkit.instructions == _TOOL_INSTRUCTIONS
assert "retain_memory" in toolkit.instructions
assert "recall_memory" in toolkit.instructions
assert "reflect_on_memory" in toolkit.instructions
def test_constructor_defaults_override_config_for_budget(self):
"""Constructor default budget='mid' is truthy, so config budget is not used.
This matches pydantic-ai integration behavior — pass budget explicitly to override."""
configure(hindsight_api_url="http://localhost:8888", budget="low")
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
toolkit = HindsightTools(bank_id="test")
client = mock_cls.return_value
client.recall.return_value = _mock_recall_response(["fact"])
ctx = _mock_run_context()
toolkit.recall_memory(ctx, "q")
# Constructor default "mid" wins over config "low" due to `or` logic
assert client.recall.call_args[1]["budget"] == "mid"
def test_explicit_budget_overrides_default(self):
configure(hindsight_api_url="http://localhost:8888")
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
toolkit = HindsightTools(bank_id="test", budget="low")
client = mock_cls.return_value
client.recall.return_value = _mock_recall_response(["fact"])
ctx = _mock_run_context()
toolkit.recall_memory(ctx, "q")
assert client.recall.call_args[1]["budget"] == "low"
def test_config_defaults_for_tags(self):
"""Tags use 'is not None' check, so config tags ARE picked up when not explicitly set."""
configure(
hindsight_api_url="http://localhost:8888",
tags=["config-tag"],
recall_tags=["config-recall"],
)
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
toolkit = HindsightTools(bank_id="test")
client = mock_cls.return_value
client.retain.return_value = None
client.recall.return_value = _mock_recall_response(["fact"])
ctx = _mock_run_context()
toolkit.retain_memory(ctx, "content")
assert client.retain.call_args[1]["tags"] == ["config-tag"]
toolkit.recall_memory(ctx, "q")
assert client.recall.call_args[1]["tags"] == ["config-recall"]
def test_explicit_tags_override_config(self):
configure(
hindsight_api_url="http://localhost:8888",
tags=["config-tag"],
recall_tags=["config-recall"],
)
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
toolkit = HindsightTools(
bank_id="test",
tags=["explicit-tag"],
recall_tags=["explicit-recall"],
)
client = mock_cls.return_value
client.retain.return_value = None
client.recall.return_value = _mock_recall_response(["fact"])
ctx = _mock_run_context()
toolkit.retain_memory(ctx, "content")
assert client.retain.call_args[1]["tags"] == ["explicit-tag"]
toolkit.recall_memory(ctx, "q")
assert client.recall.call_args[1]["tags"] == ["explicit-recall"]
def test_api_key_passed_to_client(self):
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
HindsightTools(
bank_id="test",
hindsight_api_url="http://localhost:8888",
api_key="secret",
)
mock_cls.assert_called_once_with(
base_url="http://localhost:8888",
timeout=30.0,
api_key="secret",
)
# ---------------------------------------------------------------------------
# Bank ID resolution
# ---------------------------------------------------------------------------
class TestBankIdResolution:
def test_static_bank_id(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
toolkit = HindsightTools(bank_id="my-bank", client=client)
ctx = _mock_run_context()
toolkit.recall_memory(ctx, "query")
assert client.recall.call_args[1]["bank_id"] == "my-bank"
def test_bank_id_from_run_context_user_id(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
toolkit = HindsightTools(client=client)
ctx = _mock_run_context(user_id="user-456")
toolkit.recall_memory(ctx, "query")
assert client.recall.call_args[1]["bank_id"] == "user-456"
def test_custom_bank_resolver(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
resolver = MagicMock(return_value="resolved-bank")
toolkit = HindsightTools(bank_resolver=resolver, client=client)
ctx = _mock_run_context(user_id="user-789")
toolkit.recall_memory(ctx, "query")
resolver.assert_called_once_with(ctx)
assert client.recall.call_args[1]["bank_id"] == "resolved-bank"
def test_bank_resolver_takes_priority_over_bank_id(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
resolver = MagicMock(return_value="resolver-wins")
toolkit = HindsightTools(
bank_id="static-bank",
bank_resolver=resolver,
client=client,
)
ctx = _mock_run_context()
toolkit.recall_memory(ctx, "query")
assert client.recall.call_args[1]["bank_id"] == "resolver-wins"
def test_static_bank_id_takes_priority_over_user_id(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
toolkit = HindsightTools(bank_id="static-bank", client=client)
ctx = _mock_run_context(user_id="user-id-ignored")
toolkit.recall_memory(ctx, "query")
assert client.recall.call_args[1]["bank_id"] == "static-bank"
def test_missing_bank_id_raises_error(self):
client = _mock_client()
toolkit = HindsightTools(client=client)
ctx = _mock_run_context()
with pytest.raises(HindsightError, match="No bank_id available"):
toolkit.recall_memory(ctx, "query")
def test_missing_bank_id_no_user_id_attr(self):
client = _mock_client()
toolkit = HindsightTools(client=client)
ctx = MagicMock(spec=[]) # No attributes at all
with pytest.raises(HindsightError, match="No bank_id available"):
toolkit.recall_memory(ctx, "query")
def test_bank_resolver_error_propagates(self):
client = _mock_client()
def bad_resolver(ctx):
raise ValueError("resolver broke")
toolkit = HindsightTools(bank_resolver=bad_resolver, client=client)
ctx = _mock_run_context()
with pytest.raises(HindsightError, match="resolver broke"):
toolkit.retain_memory(ctx, "content")
def test_bank_id_consistent_across_tools(self):
"""All tools resolve the same bank_id from the same context."""
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
client.reflect.return_value = _mock_reflect_response("answer")
toolkit = HindsightTools(client=client)
ctx = _mock_run_context(user_id="shared-user")
toolkit.retain_memory(ctx, "content")
toolkit.recall_memory(ctx, "query")
toolkit.reflect_on_memory(ctx, "question")
assert client.retain.call_args[1]["bank_id"] == "shared-user"
assert client.recall.call_args[1]["bank_id"] == "shared-user"
assert client.reflect.call_args[1]["bank_id"] == "shared-user"
# ---------------------------------------------------------------------------
# Bank auto-creation (_ensure_bank)
# ---------------------------------------------------------------------------
class TestEnsureBank:
def test_creates_bank_on_first_tool_use(self):
client = _mock_client()
toolkit = HindsightTools(bank_id="new-bank", client=client)
ctx = _mock_run_context()
toolkit.retain_memory(ctx, "content")
client.create_bank.assert_called_once_with(bank_id="new-bank", name="new-bank")
def test_does_not_recreate_bank(self):
client = _mock_client()
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
toolkit.retain_memory(ctx, "first")
toolkit.retain_memory(ctx, "second")
assert client.create_bank.call_count == 1
def test_bank_creation_failure_is_swallowed(self):
client = _mock_client()
client.create_bank.side_effect = RuntimeError("bank exists")
toolkit = HindsightTools(bank_id="existing-bank", client=client)
ctx = _mock_run_context()
# Should not raise — bank creation failure is tolerated
result = toolkit.retain_memory(ctx, "content")
assert result == "Memory stored successfully."
def test_bank_creation_failure_marks_as_created(self):
"""After a bank creation failure, it shouldn't retry."""
client = _mock_client()
client.create_bank.side_effect = RuntimeError("conflict")
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
toolkit.retain_memory(ctx, "first")
toolkit.retain_memory(ctx, "second")
assert client.create_bank.call_count == 1
def test_different_bank_ids_created_separately(self):
"""When bank_resolver returns different IDs, each is created once."""
client = _mock_client()
def resolver(ctx):
return f"bank-{ctx.user_id}"
toolkit = HindsightTools(bank_resolver=resolver, client=client)
toolkit.retain_memory(_mock_run_context(user_id="alice"), "content")
toolkit.retain_memory(_mock_run_context(user_id="bob"), "content")
toolkit.retain_memory(_mock_run_context(user_id="alice"), "more")
assert client.create_bank.call_count == 2
bank_ids = [c[1]["bank_id"] for c in client.create_bank.call_args_list]
assert "bank-alice" in bank_ids
assert "bank-bob" in bank_ids
def test_recall_does_not_create_bank(self):
"""Recall doesn't call _ensure_bank (only retain does)."""
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
toolkit.recall_memory(ctx, "query")
client.create_bank.assert_not_called()
def test_reflect_does_not_create_bank(self):
"""Reflect doesn't call _ensure_bank (only retain does)."""
client = _mock_client()
client.reflect.return_value = _mock_reflect_response("answer")
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
toolkit.reflect_on_memory(ctx, "query")
client.create_bank.assert_not_called()
# ---------------------------------------------------------------------------
# Retain tool
# ---------------------------------------------------------------------------
class TestRetainTool:
def test_retain_stores_memory(self):
client = _mock_client()
toolkit = HindsightTools(bank_id="test-bank", client=client)
ctx = _mock_run_context()
result = toolkit.retain_memory(ctx, "The user likes Python")
assert result == "Memory stored successfully."
client.retain.assert_called_once_with(
bank_id="test-bank", content="The user likes Python"
)
def test_retain_passes_tags(self):
client = _mock_client()
toolkit = HindsightTools(
bank_id="test-bank", client=client, tags=["source:chat"]
)
ctx = _mock_run_context()
toolkit.retain_memory(ctx, "some content")
call_kwargs = client.retain.call_args[1]
assert call_kwargs["tags"] == ["source:chat"]
def test_retain_no_tags_key_when_none(self):
client = _mock_client()
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
toolkit.retain_memory(ctx, "content")
call_kwargs = client.retain.call_args[1]
assert "tags" not in call_kwargs
def test_retain_with_empty_content(self):
client = _mock_client()
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
result = toolkit.retain_memory(ctx, "")
assert result == "Memory stored successfully."
client.retain.assert_called_once_with(bank_id="test", content="")
def test_retain_raises_hindsight_error(self):
client = _mock_client()
client.retain.side_effect = RuntimeError("connection refused")
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
with pytest.raises(HindsightError, match="Retain failed"):
toolkit.retain_memory(ctx, "content")
def test_retain_preserves_hindsight_error(self):
"""HindsightError from bank resolution is not double-wrapped."""
client = _mock_client()
toolkit = HindsightTools(client=client)
ctx = _mock_run_context() # No user_id, no bank_id
with pytest.raises(HindsightError, match="No bank_id available"):
toolkit.retain_memory(ctx, "content")
def test_retain_error_chains_original_exception(self):
client = _mock_client()
original = RuntimeError("original error")
client.retain.side_effect = original
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
with pytest.raises(HindsightError) as exc_info:
toolkit.retain_memory(ctx, "content")
assert exc_info.value.__cause__ is original
def test_retain_with_bank_resolver(self):
client = _mock_client()
resolver = MagicMock(return_value="resolved")
toolkit = HindsightTools(bank_resolver=resolver, client=client)
ctx = _mock_run_context()
toolkit.retain_memory(ctx, "content")
assert client.retain.call_args[1]["bank_id"] == "resolved"
# ---------------------------------------------------------------------------
# Recall tool
# ---------------------------------------------------------------------------
class TestRecallTool:
def test_recall_returns_numbered_results(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(
["User likes Python", "User is in NYC"]
)
toolkit = HindsightTools(bank_id="test-bank", client=client)
ctx = _mock_run_context()
result = toolkit.recall_memory(ctx, "user preferences")
assert "1. User likes Python" in result
assert "2. User is in NYC" in result
def test_recall_single_result(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["only fact"])
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
result = toolkit.recall_memory(ctx, "query")
assert result == "1. only fact"
def test_recall_many_results(self):
client = _mock_client()
facts = [f"fact {i}" for i in range(1, 11)]
client.recall.return_value = _mock_recall_response(facts)
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
result = toolkit.recall_memory(ctx, "query")
for i in range(1, 11):
assert f"{i}. fact {i}" in result
def test_recall_empty_results(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response([])
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
result = toolkit.recall_memory(ctx, "anything")
assert result == "No relevant memories found."
def test_recall_none_results(self):
client = _mock_client()
response = MagicMock()
response.results = None
client.recall.return_value = response
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
result = toolkit.recall_memory(ctx, "anything")
assert result == "No relevant memories found."
def test_recall_passes_budget_and_max_tokens(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
toolkit = HindsightTools(
bank_id="test", client=client, budget="high", max_tokens=2048
)
ctx = _mock_run_context()
toolkit.recall_memory(ctx, "query")
call_kwargs = client.recall.call_args[1]
assert call_kwargs["budget"] == "high"
assert call_kwargs["max_tokens"] == 2048
def test_recall_default_budget_and_max_tokens(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
toolkit.recall_memory(ctx, "query")
call_kwargs = client.recall.call_args[1]
assert call_kwargs["budget"] == "mid"
assert call_kwargs["max_tokens"] == 4096
def test_recall_passes_tags(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
toolkit = HindsightTools(
bank_id="test",
client=client,
recall_tags=["scope:user"],
recall_tags_match="all",
)
ctx = _mock_run_context()
toolkit.recall_memory(ctx, "query")
call_kwargs = client.recall.call_args[1]
assert call_kwargs["tags"] == ["scope:user"]
assert call_kwargs["tags_match"] == "all"
def test_recall_no_tags_key_when_none(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
toolkit.recall_memory(ctx, "query")
call_kwargs = client.recall.call_args[1]
assert "tags" not in call_kwargs
assert "tags_match" not in call_kwargs
def test_recall_raises_hindsight_error(self):
client = _mock_client()
client.recall.side_effect = RuntimeError("timeout")
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
with pytest.raises(HindsightError, match="Recall failed"):
toolkit.recall_memory(ctx, "query")
def test_recall_preserves_hindsight_error(self):
client = _mock_client()
toolkit = HindsightTools(client=client)
ctx = _mock_run_context()
with pytest.raises(HindsightError, match="No bank_id available"):
toolkit.recall_memory(ctx, "query")
def test_recall_error_chains_original_exception(self):
client = _mock_client()
original = ConnectionError("network down")
client.recall.side_effect = original
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
with pytest.raises(HindsightError) as exc_info:
toolkit.recall_memory(ctx, "query")
assert exc_info.value.__cause__ is original
def test_recall_passes_query(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
toolkit.recall_memory(ctx, "specific query text")
assert client.recall.call_args[1]["query"] == "specific query text"
# ---------------------------------------------------------------------------
# Reflect tool
# ---------------------------------------------------------------------------
class TestReflectTool:
def test_reflect_returns_text(self):
client = _mock_client()
client.reflect.return_value = _mock_reflect_response(
"The user is a Python developer who prefers functional patterns."
)
toolkit = HindsightTools(bank_id="test-bank", client=client)
ctx = _mock_run_context()
result = toolkit.reflect_on_memory(ctx, "What do you know about the user?")
assert (
result == "The user is a Python developer who prefers functional patterns."
)
def test_reflect_empty_returns_fallback(self):
client = _mock_client()
client.reflect.return_value = _mock_reflect_response("")
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
result = toolkit.reflect_on_memory(ctx, "anything")
assert result == "No relevant memories found."
def test_reflect_none_text_returns_fallback(self):
client = _mock_client()
response = MagicMock()
response.text = None
client.reflect.return_value = response
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
result = toolkit.reflect_on_memory(ctx, "anything")
assert result == "No relevant memories found."
def test_reflect_passes_budget(self):
client = _mock_client()
client.reflect.return_value = _mock_reflect_response("answer")
toolkit = HindsightTools(bank_id="test", client=client, budget="high")
ctx = _mock_run_context()
toolkit.reflect_on_memory(ctx, "query")
call_kwargs = client.reflect.call_args[1]
assert call_kwargs["budget"] == "high"
def test_reflect_default_budget(self):
client = _mock_client()
client.reflect.return_value = _mock_reflect_response("answer")
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
toolkit.reflect_on_memory(ctx, "query")
assert client.reflect.call_args[1]["budget"] == "mid"
def test_reflect_passes_query(self):
client = _mock_client()
client.reflect.return_value = _mock_reflect_response("answer")
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
toolkit.reflect_on_memory(ctx, "What is the user's favorite color?")
assert (
client.reflect.call_args[1]["query"] == "What is the user's favorite color?"
)
def test_reflect_passes_bank_id(self):
client = _mock_client()
client.reflect.return_value = _mock_reflect_response("answer")
toolkit = HindsightTools(bank_id="my-bank", client=client)
ctx = _mock_run_context()
toolkit.reflect_on_memory(ctx, "query")
assert client.reflect.call_args[1]["bank_id"] == "my-bank"
def test_reflect_raises_hindsight_error(self):
client = _mock_client()
client.reflect.side_effect = RuntimeError("timeout")
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
with pytest.raises(HindsightError, match="Reflect failed"):
toolkit.reflect_on_memory(ctx, "query")
def test_reflect_preserves_hindsight_error(self):
client = _mock_client()
toolkit = HindsightTools(client=client)
ctx = _mock_run_context()
with pytest.raises(HindsightError, match="No bank_id available"):
toolkit.reflect_on_memory(ctx, "query")
def test_reflect_error_chains_original_exception(self):
client = _mock_client()
original = TimeoutError("timed out")
client.reflect.side_effect = original
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
with pytest.raises(HindsightError) as exc_info:
toolkit.reflect_on_memory(ctx, "query")
assert exc_info.value.__cause__ is original
# ---------------------------------------------------------------------------
# memory_instructions
# ---------------------------------------------------------------------------
class TestMemoryInstructions:
def setup_method(self):
reset_config()
def teardown_method(self):
reset_config()
def test_returns_formatted_memories(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(
["Likes Python", "Lives in NYC", "Prefers dark mode"]
)
result = memory_instructions(bank_id="test-bank", client=client)
assert "Relevant memories:" in result
assert "1. Likes Python" in result
assert "2. Lives in NYC" in result
assert "3. Prefers dark mode" in result
def test_returns_string_type(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
result = memory_instructions(bank_id="test", client=client)
assert isinstance(result, str)
def test_respects_max_results(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(
["fact1", "fact2", "fact3", "fact4", "fact5"]
)
result = memory_instructions(bank_id="test", client=client, max_results=2)
assert "1. fact1" in result
assert "2. fact2" in result
assert "3." not in result
def test_max_results_larger_than_available(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact1", "fact2"])
result = memory_instructions(bank_id="test", client=client, max_results=10)
assert "1. fact1" in result
assert "2. fact2" in result
def test_custom_prefix(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
result = memory_instructions(
bank_id="test", client=client, prefix="Memory context:\n"
)
assert result.startswith("Memory context:")
def test_empty_results_returns_empty_string(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response([])
result = memory_instructions(bank_id="test", client=client)
assert result == ""
def test_none_results_returns_empty_string(self):
client = _mock_client()
response = MagicMock()
response.results = None
client.recall.return_value = response
result = memory_instructions(bank_id="test", client=client)
assert result == ""
def test_error_returns_empty_string(self):
client = _mock_client()
client.recall.side_effect = RuntimeError("connection error")
result = memory_instructions(bank_id="test", client=client)
assert result == ""
def test_error_does_not_raise(self):
"""Errors should be silently swallowed, never propagated."""
client = _mock_client()
client.recall.side_effect = ConnectionError("unreachable")
# Should not raise
result = memory_instructions(bank_id="test", client=client)
assert result == ""
def test_passes_query_and_budget(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
memory_instructions(
bank_id="test",
client=client,
query="user preferences and context",
budget="high",
)
call_kwargs = client.recall.call_args[1]
assert call_kwargs["query"] == "user preferences and context"
assert call_kwargs["budget"] == "high"
def test_passes_max_tokens(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
memory_instructions(bank_id="test", client=client, max_tokens=2048)
call_kwargs = client.recall.call_args[1]
assert call_kwargs["max_tokens"] == 2048
def test_default_parameters(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
memory_instructions(bank_id="test", client=client)
call_kwargs = client.recall.call_args[1]
assert call_kwargs["query"] == "relevant context about the user"
assert call_kwargs["budget"] == "low"
assert call_kwargs["max_tokens"] == 4096
def test_passes_tags(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
memory_instructions(
bank_id="test",
client=client,
tags=["scope:user"],
tags_match="all",
)
call_kwargs = client.recall.call_args[1]
assert call_kwargs["tags"] == ["scope:user"]
assert call_kwargs["tags_match"] == "all"
def test_no_tags_key_when_none(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
memory_instructions(bank_id="test", client=client)
call_kwargs = client.recall.call_args[1]
assert "tags" not in call_kwargs
assert "tags_match" not in call_kwargs
def test_raises_without_client_or_config(self):
with pytest.raises(HindsightError, match="No Hindsight API URL"):
memory_instructions(bank_id="test")
def test_uses_global_config(self):
configure(hindsight_api_url="http://localhost:8888")
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_client = _mock_client()
mock_client.recall.return_value = _mock_recall_response(["fact"])
mock_cls.return_value = mock_client
result = memory_instructions(bank_id="test")
assert "1. fact" in result
mock_cls.assert_called_once_with(
base_url="http://localhost:8888", timeout=30.0
)
def test_uses_sync_recall(self):
"""memory_instructions should use sync client.recall, not arecall."""
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
memory_instructions(bank_id="test", client=client)
client.recall.assert_called_once()
# Should NOT call async variant
assert not hasattr(client, "arecall") or not client.arecall.called
# ---------------------------------------------------------------------------
# Package-level exports
# ---------------------------------------------------------------------------
class TestExports:
def test_all_exports_importable(self):
import hindsight_agno
for name in hindsight_agno.__all__:
assert hasattr(hindsight_agno, name), (
f"{name} in __all__ but not importable"
)
def test_version(self):
import hindsight_agno
assert hindsight_agno.__version__ == "0.1.0"
def test_hindsight_error_importable_from_top_level(self):
from hindsight_agno import HindsightError
assert issubclass(HindsightError, Exception)
def test_config_class_importable(self):
from hindsight_agno import HindsightAgnoConfig
config = HindsightAgnoConfig()
assert config.budget == "mid"
# ---------------------------------------------------------------------------
# Error class
# ---------------------------------------------------------------------------
class TestHindsightError:
def test_is_exception(self):
assert issubclass(HindsightError, Exception)
def test_can_be_raised_and_caught(self):
with pytest.raises(HindsightError, match="test error"):
raise HindsightError("test error")
def test_str_representation(self):
err = HindsightError("something went wrong")
assert str(err) == "something went wrong"
# ---------------------------------------------------------------------------
# Multiple toolkit instances
# ---------------------------------------------------------------------------
class TestMultipleToolkits:
def test_separate_created_banks_sets(self):
"""Each toolkit instance tracks its own created banks."""
client = _mock_client()
toolkit1 = HindsightTools(bank_id="bank-1", client=client)
toolkit2 = HindsightTools(bank_id="bank-2", client=client)
ctx = _mock_run_context()
toolkit1.retain_memory(ctx, "content")
toolkit2.retain_memory(ctx, "content")
assert client.create_bank.call_count == 2
def test_separate_config_per_toolkit(self):
"""Each toolkit can have different settings."""
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
toolkit_low = HindsightTools(bank_id="test", client=client, budget="low")
toolkit_high = HindsightTools(bank_id="test", client=client, budget="high")
ctx = _mock_run_context()
toolkit_low.recall_memory(ctx, "query")
low_budget = client.recall.call_args[1]["budget"]
toolkit_high.recall_memory(ctx, "query")
high_budget = client.recall.call_args[1]["budget"]
assert low_budget == "low"
assert high_budget == "high"
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
class TestLogging:
def test_retain_logs_error(self, caplog):
client = _mock_client()
client.retain.side_effect = RuntimeError("boom")
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
with caplog.at_level(logging.ERROR, logger="hindsight_agno.tools"):
with pytest.raises(HindsightError):
toolkit.retain_memory(ctx, "content")
assert "Retain failed: boom" in caplog.text
def test_recall_logs_error(self, caplog):
client = _mock_client()
client.recall.side_effect = RuntimeError("timeout")
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
with caplog.at_level(logging.ERROR, logger="hindsight_agno.tools"):
with pytest.raises(HindsightError):
toolkit.recall_memory(ctx, "query")
assert "Recall failed: timeout" in caplog.text
def test_reflect_logs_error(self, caplog):
client = _mock_client()
client.reflect.side_effect = RuntimeError("service down")
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
with caplog.at_level(logging.ERROR, logger="hindsight_agno.tools"):
with pytest.raises(HindsightError):
toolkit.reflect_on_memory(ctx, "query")
assert "Reflect failed: service down" in caplog.text
def test_hindsight_error_not_logged(self, caplog):
"""HindsightError (e.g. missing bank_id) should not log — it's re-raised directly."""
client = _mock_client()
toolkit = HindsightTools(client=client)
ctx = _mock_run_context() # No user_id
with caplog.at_level(logging.ERROR, logger="hindsight_agno.tools"):
with pytest.raises(HindsightError, match="No bank_id"):
toolkit.retain_memory(ctx, "content")
assert "Retain failed" not in caplog.text
# ---------------------------------------------------------------------------
# Edge cases: falsy and special values
# ---------------------------------------------------------------------------
class TestEdgeCases:
def test_empty_string_bank_id_is_falsy(self):
"""bank_id='' is falsy, so resolution falls through to user_id."""
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
toolkit = HindsightTools(bank_id="", client=client)
ctx = _mock_run_context(user_id="user-from-ctx")
# "" is not None, so _bank_id check passes, but "" is also the bank_id
# Actually: `if self._bank_id is not None:` — "" is not None, so "" is used
toolkit.recall_memory(ctx, "query")
assert client.recall.call_args[1]["bank_id"] == ""
def test_empty_string_user_id_is_falsy(self):
"""user_id='' is falsy, so resolution raises error."""
client = _mock_client()
toolkit = HindsightTools(client=client)
ctx = _mock_run_context(user_id="")
with pytest.raises(HindsightError, match="No bank_id available"):
toolkit.recall_memory(ctx, "query")
def test_unicode_content_in_retain(self):
client = _mock_client()
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
result = toolkit.retain_memory(ctx, "The user likes coffee and books")
assert result == "Memory stored successfully."
assert (
client.retain.call_args[1]["content"] == "The user likes coffee and books"
)
def test_unicode_in_recall_results(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["Cafe du Monde"])
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
result = toolkit.recall_memory(ctx, "query")
assert "Cafe du Monde" in result
def test_multiline_content_in_retain(self):
client = _mock_client()
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
content = "Line 1\nLine 2\nLine 3"
result = toolkit.retain_memory(ctx, content)
assert result == "Memory stored successfully."
assert client.retain.call_args[1]["content"] == content
def test_newlines_in_recall_results(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(
["fact with\nnewline", "normal fact"]
)
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
result = toolkit.recall_memory(ctx, "query")
assert "1. fact with\nnewline" in result
assert "2. normal fact" in result
def test_special_chars_in_bank_id(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
toolkit = HindsightTools(bank_id="org/user-123_v2", client=client)
ctx = _mock_run_context()
toolkit.recall_memory(ctx, "query")
assert client.recall.call_args[1]["bank_id"] == "org/user-123_v2"
def test_empty_tags_list_not_sent(self):
"""tags=[] is falsy, so tags should not be sent to retain."""
client = _mock_client()
toolkit = HindsightTools(bank_id="test", client=client, tags=[])
ctx = _mock_run_context()
toolkit.retain_memory(ctx, "content")
assert "tags" not in client.retain.call_args[1]
def test_empty_recall_tags_list_not_sent(self):
"""recall_tags=[] is falsy, so tags should not be sent to recall."""
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
toolkit = HindsightTools(bank_id="test", client=client, recall_tags=[])
ctx = _mock_run_context()
toolkit.recall_memory(ctx, "query")
assert "tags" not in client.recall.call_args[1]
def test_very_long_content(self):
client = _mock_client()
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
content = "x" * 100_000
result = toolkit.retain_memory(ctx, content)
assert result == "Memory stored successfully."
assert len(client.retain.call_args[1]["content"]) == 100_000
def test_recall_with_empty_query(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
toolkit.recall_memory(ctx, "")
assert client.recall.call_args[1]["query"] == ""
def test_reflect_with_empty_query(self):
client = _mock_client()
client.reflect.return_value = _mock_reflect_response("answer")
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
toolkit.reflect_on_memory(ctx, "")
assert client.reflect.call_args[1]["query"] == ""
# ---------------------------------------------------------------------------
# Error type variations
# ---------------------------------------------------------------------------
class TestErrorTypes:
"""Verify various exception types are properly wrapped."""
def test_retain_wraps_type_error(self):
client = _mock_client()
client.retain.side_effect = TypeError("bad type")
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
with pytest.raises(HindsightError, match="Retain failed.*bad type"):
toolkit.retain_memory(ctx, "content")
def test_recall_wraps_key_error(self):
client = _mock_client()
client.recall.side_effect = KeyError("missing key")
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
with pytest.raises(HindsightError, match="Recall failed"):
toolkit.recall_memory(ctx, "query")
def test_reflect_wraps_os_error(self):
client = _mock_client()
client.reflect.side_effect = OSError("disk full")
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
with pytest.raises(HindsightError, match="Reflect failed.*disk full"):
toolkit.reflect_on_memory(ctx, "query")
def test_retain_wraps_value_error(self):
client = _mock_client()
client.retain.side_effect = ValueError("invalid input")
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
with pytest.raises(HindsightError, match="Retain failed.*invalid input"):
toolkit.retain_memory(ctx, "content")
# ---------------------------------------------------------------------------
# Tool docstrings (these become LLM tool descriptions)
# ---------------------------------------------------------------------------
class TestToolDocstrings:
"""Tool docstrings are critical — Agno exposes them to the LLM."""
def test_retain_has_docstring(self):
client = _mock_client()
toolkit = HindsightTools(bank_id="test", client=client)
assert toolkit.retain_memory.__doc__ is not None
assert "Store" in toolkit.retain_memory.__doc__
assert "memory" in toolkit.retain_memory.__doc__.lower()
def test_recall_has_docstring(self):
client = _mock_client()
toolkit = HindsightTools(bank_id="test", client=client)
assert toolkit.recall_memory.__doc__ is not None
assert "Search" in toolkit.recall_memory.__doc__
assert "memory" in toolkit.recall_memory.__doc__.lower()
def test_reflect_has_docstring(self):
client = _mock_client()
toolkit = HindsightTools(bank_id="test", client=client)
assert toolkit.reflect_on_memory.__doc__ is not None
assert "Synthesize" in toolkit.reflect_on_memory.__doc__
# ---------------------------------------------------------------------------
# Toolkit integration with Agno's registration
# ---------------------------------------------------------------------------
class TestToolkitIntegration:
def test_get_functions_returns_registered_tools(self):
"""Verify tools are accessible via Toolkit.get_functions()."""
client = _mock_client()
toolkit = HindsightTools(bank_id="test", client=client)
functions = toolkit.get_functions()
assert "retain_memory" in functions
assert "recall_memory" in functions
assert "reflect_on_memory" in functions
def test_get_functions_respects_enable_flags(self):
client = _mock_client()
toolkit = HindsightTools(
bank_id="test",
client=client,
enable_retain=False,
enable_reflect=False,
)
functions = toolkit.get_functions()
assert "retain_memory" not in functions
assert "recall_memory" in functions
assert "reflect_on_memory" not in functions
def test_instructions_are_set(self):
client = _mock_client()
toolkit = HindsightTools(bank_id="test", client=client)
assert toolkit.instructions is not None
assert len(toolkit.instructions) > 0
def test_instructions_mention_all_tools(self):
client = _mock_client()
toolkit = HindsightTools(bank_id="test", client=client)
assert "retain_memory" in toolkit.instructions
assert "recall_memory" in toolkit.instructions
assert "reflect_on_memory" in toolkit.instructions
# ---------------------------------------------------------------------------
# Bank creation across tool types
# ---------------------------------------------------------------------------
class TestBankCreationCrossTool:
def test_retain_then_recall_creates_bank_once(self):
"""Bank created by retain is reused by recall (no re-creation)."""
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
toolkit = HindsightTools(bank_id="shared-bank", client=client)
ctx = _mock_run_context()
toolkit.retain_memory(ctx, "content")
toolkit.recall_memory(ctx, "query")
# create_bank only called by retain (once)
client.create_bank.assert_called_once()
def test_retain_then_reflect_creates_bank_once(self):
client = _mock_client()
client.reflect.return_value = _mock_reflect_response("answer")
toolkit = HindsightTools(bank_id="shared-bank", client=client)
ctx = _mock_run_context()
toolkit.retain_memory(ctx, "content")
toolkit.reflect_on_memory(ctx, "query")
client.create_bank.assert_called_once()
# ---------------------------------------------------------------------------
# memory_instructions edge cases
# ---------------------------------------------------------------------------
class TestMemoryInstructionsEdgeCases:
def setup_method(self):
reset_config()
def teardown_method(self):
reset_config()
def test_max_results_zero(self):
"""max_results=0 → results[:0] is empty → returns ''."""
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact1", "fact2"])
result = memory_instructions(bank_id="test", client=client, max_results=0)
assert result == ""
def test_max_results_one(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["only-fact", "extra"])
result = memory_instructions(bank_id="test", client=client, max_results=1)
assert "1. only-fact" in result
assert "extra" not in result
def test_empty_prefix(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
result = memory_instructions(bank_id="test", client=client, prefix="")
# Empty prefix still appears as first line (empty string before \n join)
assert result.startswith("\n") or result == "\n1. fact"
def test_prefix_without_newline(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
result = memory_instructions(bank_id="test", client=client, prefix="Context: ")
assert result == "Context: \n1. fact"
def test_bank_id_passed_to_recall(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
memory_instructions(bank_id="specific-bank", client=client)
assert client.recall.call_args[1]["bank_id"] == "specific-bank"
def test_empty_tags_list_not_sent(self):
"""tags=[] is falsy, so tags should not be included in recall kwargs."""
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
memory_instructions(bank_id="test", client=client, tags=[])
assert "tags" not in client.recall.call_args[1]
def test_multiple_calls_with_same_client(self):
"""memory_instructions can be called multiple times with the same client."""
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
r1 = memory_instructions(bank_id="bank-1", client=client)
r2 = memory_instructions(bank_id="bank-2", client=client)
assert client.recall.call_count == 2
assert r1 == r2 # Same response mock
def test_client_resolution_error_propagates(self):
"""_resolve_client error during memory_instructions IS raised (not swallowed)."""
with pytest.raises(HindsightError, match="No Hindsight API URL"):
memory_instructions(bank_id="test")
def test_recall_error_swallowed_not_raised(self):
"""Once client is resolved, recall errors are swallowed (returns '')."""
client = _mock_client()
client.recall.side_effect = Exception("any error")
result = memory_instructions(bank_id="test", client=client)
assert result == ""
# ---------------------------------------------------------------------------
# _resolve_client edge cases
# ---------------------------------------------------------------------------
class TestResolveClientEdgeCases:
def setup_method(self):
reset_config()
def teardown_method(self):
reset_config()
def test_empty_string_api_key_not_passed(self):
"""api_key='' is falsy, so it should not be passed to Hindsight."""
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
_resolve_client(None, "http://localhost:8888", "")
call_kwargs = mock_cls.call_args[1]
assert "api_key" not in call_kwargs
def test_none_api_key_not_passed(self):
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
_resolve_client(None, "http://localhost:8888", None)
call_kwargs = mock_cls.call_args[1]
assert "api_key" not in call_kwargs
def test_whitespace_api_key_is_passed(self):
"""Non-empty whitespace string IS truthy, so it gets passed."""
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
_resolve_client(None, "http://localhost:8888", " ")
call_kwargs = mock_cls.call_args[1]
assert call_kwargs["api_key"] == " "
def test_config_api_key_used_when_no_explicit(self):
configure(hindsight_api_url="http://config:8888", api_key="config-key")
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
_resolve_client(None, None, None)
assert mock_cls.call_args[1]["api_key"] == "config-key"
def test_config_none_api_key_not_passed(self):
configure(hindsight_api_url="http://config:8888", api_key=None)
with patch("hindsight_agno.tools.Hindsight") as mock_cls:
mock_cls.return_value = _mock_client()
_resolve_client(None, None, None)
assert "api_key" not in mock_cls.call_args[1]
# ---------------------------------------------------------------------------
# Config interaction with toolkit
# ---------------------------------------------------------------------------
class TestConfigToolkitInteraction:
def setup_method(self):
reset_config()
def teardown_method(self):
reset_config()
def test_config_set_after_toolkit_creation_not_used(self):
"""Config is captured at construction time, not at tool call time."""
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
toolkit = HindsightTools(bank_id="test", client=client, tags=["original"])
ctx = _mock_run_context()
# Change config after construction
configure(hindsight_api_url="http://new:8888", tags=["updated"])
toolkit.retain_memory(ctx, "content")
# Should use the original tags, not updated
assert client.retain.call_args[1]["tags"] == ["original"]
def test_toolkit_without_config_then_configure(self):
"""Creating toolkit with explicit client doesn't require config."""
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
# No config set
toolkit = HindsightTools(bank_id="test", client=client)
# Config set after — doesn't affect existing toolkit
configure(hindsight_api_url="http://new:8888", budget="high")
toolkit.recall_memory(_mock_run_context(), "query")
assert client.recall.call_args[1]["budget"] == "mid" # Not "high"
def test_multiple_tags(self):
"""Multiple tags are passed as a list."""
client = _mock_client()
toolkit = HindsightTools(
bank_id="test",
client=client,
tags=["source:chat", "env:prod", "version:2"],
)
ctx = _mock_run_context()
toolkit.retain_memory(ctx, "content")
tags = client.retain.call_args[1]["tags"]
assert tags == ["source:chat", "env:prod", "version:2"]
def test_multiple_recall_tags(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact"])
toolkit = HindsightTools(
bank_id="test",
client=client,
recall_tags=["scope:user", "type:preference"],
recall_tags_match="all_strict",
)
ctx = _mock_run_context()
toolkit.recall_memory(ctx, "query")
kwargs = client.recall.call_args[1]
assert kwargs["tags"] == ["scope:user", "type:preference"]
assert kwargs["tags_match"] == "all_strict"
# ---------------------------------------------------------------------------
# Recall output formatting
# ---------------------------------------------------------------------------
class TestRecallFormatting:
def test_result_numbering_starts_at_one(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["alpha"])
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
result = toolkit.recall_memory(ctx, "q")
assert result.startswith("1.")
assert "0." not in result
def test_results_separated_by_newlines(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["a", "b", "c"])
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
result = toolkit.recall_memory(ctx, "q")
lines = result.split("\n")
assert len(lines) == 3
assert lines[0] == "1. a"
assert lines[1] == "2. b"
assert lines[2] == "3. c"
def test_double_digit_numbering(self):
client = _mock_client()
facts = [f"fact-{i}" for i in range(1, 13)]
client.recall.return_value = _mock_recall_response(facts)
toolkit = HindsightTools(bank_id="test", client=client)
ctx = _mock_run_context()
result = toolkit.recall_memory(ctx, "q")
assert "10. fact-10" in result
assert "11. fact-11" in result
assert "12. fact-12" in result
# ---------------------------------------------------------------------------
# memory_instructions output formatting
# ---------------------------------------------------------------------------
class TestMemoryInstructionsFormatting:
def test_default_prefix_includes_trailing_newline(self):
"""Default prefix is 'Relevant memories:\\n', so join adds a blank line."""
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact1", "fact2"])
result = memory_instructions(bank_id="test", client=client)
# prefix="Relevant memories:\n" joined with "\n" produces:
# "Relevant memories:\n\n1. fact1\n2. fact2"
assert result == "Relevant memories:\n\n1. fact1\n2. fact2"
def test_single_result_with_default_prefix(self):
client = _mock_client()
client.recall.return_value = _mock_recall_response(["single"])
result = memory_instructions(bank_id="test", client=client)
assert result == "Relevant memories:\n\n1. single"
def test_clean_prefix_no_double_newline(self):
"""Using a prefix without trailing newline avoids the blank line."""
client = _mock_client()
client.recall.return_value = _mock_recall_response(["fact1", "fact2"])
result = memory_instructions(
bank_id="test", client=client, prefix="Relevant memories:"
)
assert result == "Relevant memories:\n1. fact1\n2. fact2"
# ---------------------------------------------------------------------------
# Keyword-only constructor enforcement
# ---------------------------------------------------------------------------
class TestConstructorAPI:
def test_hindsight_tools_requires_keyword_args(self):
"""All HindsightTools args are keyword-only (due to *)."""
client = _mock_client()
with pytest.raises(TypeError):
HindsightTools("test", client) # type: ignore[misc]
def test_memory_instructions_requires_keyword_args(self):
"""All memory_instructions args are keyword-only (due to *)."""
client = _mock_client()
with pytest.raises(TypeError):
memory_instructions("test", client) # type: ignore[misc]