* 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>
1836 lines
66 KiB
Python
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]
|