"""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]