437 lines
16 KiB
Python
437 lines
16 KiB
Python
"""U5: ReflexionEngine reflection persistence to EpisodicMemory (R11, R15).
|
|
|
|
Covers:
|
|
- store_prompt_reflection() stores via existing store() with task_type discriminator
|
|
- search_prompt_reflections() filters by task_type="prompt_reflection"
|
|
- cleanup_expired() deletes old records (TTL)
|
|
- _reflect() persists reflection when episodic_memory configured (non-blocking)
|
|
- _reflect() skips persistence when episodic_memory=None (backward compat)
|
|
- Persistence failure does not block _reflect (returns reflection text)
|
|
- Multi-version coexistence (same task_hash, different versions)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from agentkit.core.react import ReActResult
|
|
from agentkit.core.reflexion import ReflexionEngine
|
|
from agentkit.memory.episodic import EpisodicMemory
|
|
|
|
|
|
# ── Helpers ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_episodic_memory_mock() -> MagicMock:
|
|
"""Create a mock EpisodicMemory that tracks store/search calls."""
|
|
mem = MagicMock(spec=EpisodicMemory)
|
|
mem.store = AsyncMock(return_value=None)
|
|
mem.search = AsyncMock(return_value=[])
|
|
mem.store_prompt_reflection = AsyncMock(return_value="prompt_reflection:abc:1")
|
|
mem.search_prompt_reflections = AsyncMock(return_value=[])
|
|
mem.cleanup_expired = AsyncMock(return_value=0)
|
|
return mem
|
|
|
|
|
|
def _make_react_result(output: str = "test output", status: str = "completed") -> ReActResult:
|
|
return ReActResult(
|
|
output=output,
|
|
trajectory=[],
|
|
total_steps=1,
|
|
total_tokens=10,
|
|
status=status,
|
|
)
|
|
|
|
|
|
def _make_llm_gateway_mock(reflection_text: str = "reflection text") -> MagicMock:
|
|
"""Gateway that returns reflection text from chat()."""
|
|
gw = MagicMock()
|
|
response = MagicMock()
|
|
response.content = reflection_text
|
|
gw.chat = AsyncMock(return_value=response)
|
|
return gw
|
|
|
|
|
|
# ── EpisodicMemory.store_prompt_reflection ─────────────────────────────
|
|
|
|
|
|
class TestStorePromptReflection:
|
|
"""U5/R11: store_prompt_reflection persists via direct ORM write with
|
|
task_type='prompt_reflection' discriminator and metadata_ JSONB."""
|
|
|
|
def _make_persistable_mock(self) -> MagicMock:
|
|
"""Build an EpisodicMemory mock with _embedder + _session_factory
|
|
configured so store_prompt_reflection can run its real code path."""
|
|
mem = MagicMock(spec=EpisodicMemory)
|
|
mem._embedder = None # skip embedding generation in test
|
|
mock_db = AsyncMock()
|
|
mock_db.add = MagicMock()
|
|
mock_db.commit = AsyncMock()
|
|
mock_db.rollback = AsyncMock()
|
|
mem._session_factory = MagicMock()
|
|
mem._session_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db)
|
|
mem._session_factory.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
mem._episodic_model = MagicMock()
|
|
# Track the ORM entry passed to db.add for assertions
|
|
mem._mock_db = mock_db
|
|
return mem
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_writes_orm_entry_with_correct_fields(self):
|
|
mem = self._make_persistable_mock()
|
|
await EpisodicMemory.store_prompt_reflection(
|
|
mem,
|
|
task_input="test task",
|
|
reflection="reflection text",
|
|
improved_prompt="improved prompt",
|
|
version=1,
|
|
score=0.5,
|
|
agent_name="test_agent",
|
|
)
|
|
|
|
# Verify ORM entry created via model constructor + db.add + commit
|
|
mem._episodic_model.assert_called_once()
|
|
call_kwargs = mem._episodic_model.call_args.kwargs
|
|
assert call_kwargs["task_type"] == "prompt_reflection"
|
|
assert call_kwargs["agent_name"] == "test_agent"
|
|
assert call_kwargs["input_summary"] == "test task"
|
|
assert call_kwargs["output_summary"] == "improved prompt"
|
|
assert call_kwargs["reflection"] == "reflection text"
|
|
assert call_kwargs["quality_score"] == 0.5
|
|
assert call_kwargs["outcome"] == "reflection"
|
|
# metadata_ JSONB carries task_hash + version + score
|
|
assert "task_hash" in call_kwargs["metadata_"]
|
|
assert call_kwargs["metadata_"]["version"] == 1
|
|
assert call_kwargs["metadata_"]["score"] == 0.5
|
|
mem._mock_db.add.assert_called_once()
|
|
mem._mock_db.commit.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_returns_key_on_success(self):
|
|
mem = self._make_persistable_mock()
|
|
key = await EpisodicMemory.store_prompt_reflection(
|
|
mem,
|
|
task_input="test",
|
|
reflection="r",
|
|
improved_prompt="p",
|
|
version=2,
|
|
)
|
|
assert key is not None
|
|
assert ":2" in key
|
|
assert key.startswith("prompt_reflection:")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_returns_none_on_failure(self):
|
|
from sqlalchemy.exc import DBAPIError
|
|
|
|
mem = self._make_persistable_mock()
|
|
mem._mock_db.commit = AsyncMock(
|
|
side_effect=DBAPIError("stmt", params={}, orig=Exception("db down"))
|
|
)
|
|
key = await EpisodicMemory.store_prompt_reflection(
|
|
mem,
|
|
task_input="test",
|
|
reflection="r",
|
|
improved_prompt="p",
|
|
)
|
|
assert key is None
|
|
mem._mock_db.rollback.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_uses_provided_task_hash(self):
|
|
mem = self._make_persistable_mock()
|
|
key = await EpisodicMemory.store_prompt_reflection(
|
|
mem,
|
|
task_input="test",
|
|
reflection="r",
|
|
improved_prompt="p",
|
|
task_hash="custom_hash",
|
|
)
|
|
assert "custom_hash" in key
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_generates_task_hash_from_input(self):
|
|
mem = self._make_persistable_mock()
|
|
key1 = await EpisodicMemory.store_prompt_reflection(
|
|
mem, task_input="same task", reflection="r", improved_prompt="p"
|
|
)
|
|
key2 = await EpisodicMemory.store_prompt_reflection(
|
|
mem, task_input="same task", reflection="r", improved_prompt="p"
|
|
)
|
|
# Same task_input → same hash prefix
|
|
assert key1 == key2
|
|
|
|
|
|
# ── EpisodicMemory.search_prompt_reflections ───────────────────────────
|
|
|
|
|
|
class TestSearchPromptReflections:
|
|
"""U5/R11: search_prompt_reflections filters by task_type."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_calls_underlying_search_with_filter(self):
|
|
mem = MagicMock(spec=EpisodicMemory)
|
|
mem.search = AsyncMock(return_value=[])
|
|
await EpisodicMemory.search_prompt_reflections(mem, task_input="find similar", top_k=3)
|
|
|
|
mem.search.assert_awaited_once()
|
|
call_kwargs = mem.search.await_args.kwargs
|
|
assert call_kwargs["query"] == "find similar"
|
|
assert call_kwargs["top_k"] == 3
|
|
filters = call_kwargs["filters"]
|
|
assert filters["task_type"] == "prompt_reflection"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_includes_agent_name_filter_when_provided(self):
|
|
mem = MagicMock(spec=EpisodicMemory)
|
|
mem.search = AsyncMock(return_value=[])
|
|
await EpisodicMemory.search_prompt_reflections(mem, task_input="q", agent_name="agent_x")
|
|
|
|
filters = mem.search.await_args.kwargs["filters"]
|
|
assert filters["agent_name"] == "agent_x"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_returns_empty_on_failure(self):
|
|
from sqlalchemy.exc import DBAPIError
|
|
|
|
mem = MagicMock(spec=EpisodicMemory)
|
|
mem.search = AsyncMock(side_effect=DBAPIError("stmt", params={}, orig=Exception("err")))
|
|
result = await EpisodicMemory.search_prompt_reflections(mem, task_input="q")
|
|
assert result == []
|
|
|
|
|
|
# ── EpisodicMemory.cleanup_expired ─────────────────────────────────────
|
|
|
|
|
|
class TestCleanupExpired:
|
|
"""U5/R15: cleanup_expired deletes records older than max_age_days."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleanup_calls_delete_with_cutoff(self):
|
|
# Patch sqlalchemy.delete to bypass ORM model requirement — we only
|
|
# verify the session.execute/commit calls happen.
|
|
mock_db = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.rowcount = 5
|
|
mock_db.execute = AsyncMock(return_value=mock_result)
|
|
mock_db.commit = AsyncMock()
|
|
|
|
mock_session_factory = MagicMock()
|
|
mock_session_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db)
|
|
mock_session_factory.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
# created_at must support `<` comparison with datetime — configure
|
|
# __lt__ to return a truthy mock so `.where(...)` gets a valid arg.
|
|
mock_model = MagicMock()
|
|
mock_model.created_at = MagicMock()
|
|
mock_model.created_at.__lt__ = MagicMock(return_value=MagicMock())
|
|
|
|
mem = EpisodicMemory(
|
|
session_factory=mock_session_factory,
|
|
episodic_model=mock_model,
|
|
embedder=None,
|
|
pgvector_enabled=False,
|
|
)
|
|
|
|
# Patch sql_delete to return a chainable mock
|
|
fake_stmt = MagicMock()
|
|
fake_stmt.where = MagicMock(return_value=fake_stmt)
|
|
with patch("sqlalchemy.delete", return_value=fake_stmt):
|
|
deleted = await mem.cleanup_expired(max_age_days=30)
|
|
|
|
assert deleted == 5
|
|
mock_db.execute.assert_awaited_once()
|
|
mock_db.commit.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleanup_returns_zero_on_failure(self):
|
|
from sqlalchemy.exc import DBAPIError
|
|
|
|
mock_db = AsyncMock()
|
|
mock_db.execute = AsyncMock(
|
|
side_effect=DBAPIError("stmt", params={}, orig=Exception("err"))
|
|
)
|
|
mock_db.rollback = AsyncMock()
|
|
|
|
mock_session_factory = MagicMock()
|
|
mock_session_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db)
|
|
mock_session_factory.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
mock_model = MagicMock()
|
|
mock_model.created_at = MagicMock()
|
|
mock_model.created_at.__lt__ = MagicMock(return_value=MagicMock())
|
|
|
|
mem = EpisodicMemory(
|
|
session_factory=mock_session_factory,
|
|
episodic_model=mock_model,
|
|
embedder=None,
|
|
pgvector_enabled=False,
|
|
)
|
|
|
|
fake_stmt = MagicMock()
|
|
fake_stmt.where = MagicMock(return_value=fake_stmt)
|
|
with patch("sqlalchemy.delete", return_value=fake_stmt):
|
|
deleted = await mem.cleanup_expired(max_age_days=30)
|
|
|
|
assert deleted == 0
|
|
mock_db.rollback.assert_awaited_once()
|
|
|
|
|
|
# ── ReflexionEngine._reflect persistence ──────────────────────────────
|
|
|
|
|
|
class TestReflectPersistence:
|
|
"""U5/R11: _reflect persists reflection when episodic_memory configured."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reflect_persists_when_episodic_memory_configured(self):
|
|
episodic = _make_episodic_memory_mock()
|
|
gw = _make_llm_gateway_mock(reflection_text="my reflection")
|
|
engine = ReflexionEngine(
|
|
llm_gateway=gw,
|
|
episodic_memory=episodic,
|
|
)
|
|
|
|
result = await engine._reflect(
|
|
react_result=_make_react_result(),
|
|
score=0.3,
|
|
messages=[{"role": "user", "content": "test task"}],
|
|
reflect_model="default",
|
|
agent_name="test_agent",
|
|
task_type="test",
|
|
)
|
|
|
|
assert result == "my reflection"
|
|
episodic.store_prompt_reflection.assert_awaited_once()
|
|
call_kwargs = episodic.store_prompt_reflection.await_args.kwargs
|
|
assert call_kwargs["task_input"] == "test task"
|
|
assert call_kwargs["reflection"] == "my reflection"
|
|
assert call_kwargs["score"] == 0.3
|
|
assert call_kwargs["agent_name"] == "test_agent"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reflect_skips_persistence_when_no_episodic_memory(self):
|
|
gw = _make_llm_gateway_mock(reflection_text="my reflection")
|
|
engine = ReflexionEngine(
|
|
llm_gateway=gw,
|
|
episodic_memory=None, # No persistence
|
|
)
|
|
|
|
result = await engine._reflect(
|
|
react_result=_make_react_result(),
|
|
score=0.3,
|
|
messages=[{"role": "user", "content": "test task"}],
|
|
reflect_model="default",
|
|
agent_name="test_agent",
|
|
task_type="test",
|
|
)
|
|
|
|
assert result == "my reflection"
|
|
# No episodic_memory → no store call
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reflect_persistence_failure_does_not_block(self):
|
|
"""If store_prompt_reflection raises, _reflect still returns reflection."""
|
|
episodic = MagicMock(spec=EpisodicMemory)
|
|
episodic.store_prompt_reflection = AsyncMock(side_effect=RuntimeError("DB down"))
|
|
gw = _make_llm_gateway_mock(reflection_text="important reflection")
|
|
engine = ReflexionEngine(
|
|
llm_gateway=gw,
|
|
episodic_memory=episodic,
|
|
)
|
|
|
|
result = await engine._reflect(
|
|
react_result=_make_react_result(),
|
|
score=0.3,
|
|
messages=[{"role": "user", "content": "test task"}],
|
|
reflect_model="default",
|
|
agent_name="test_agent",
|
|
task_type="test",
|
|
)
|
|
|
|
# Reflection still returned despite persistence failure
|
|
assert result == "important reflection"
|
|
episodic.store_prompt_reflection.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reflect_skips_persistence_when_llm_returns_none(self):
|
|
"""If LLM returns empty/None, no persistence attempted."""
|
|
episodic = _make_episodic_memory_mock()
|
|
gw = MagicMock()
|
|
response = MagicMock()
|
|
response.content = None # Empty reflection
|
|
gw.chat = AsyncMock(return_value=response)
|
|
engine = ReflexionEngine(
|
|
llm_gateway=gw,
|
|
episodic_memory=episodic,
|
|
)
|
|
|
|
result = await engine._reflect(
|
|
react_result=_make_react_result(),
|
|
score=0.3,
|
|
messages=[{"role": "user", "content": "test task"}],
|
|
reflect_model="default",
|
|
agent_name="test_agent",
|
|
task_type="test",
|
|
)
|
|
|
|
assert result is None
|
|
episodic.store_prompt_reflection.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reflect_skips_persistence_on_llm_failure(self):
|
|
"""If LLM call raises, no persistence attempted."""
|
|
episodic = _make_episodic_memory_mock()
|
|
gw = MagicMock()
|
|
gw.chat = AsyncMock(side_effect=RuntimeError("LLM down"))
|
|
engine = ReflexionEngine(
|
|
llm_gateway=gw,
|
|
episodic_memory=episodic,
|
|
)
|
|
|
|
result = await engine._reflect(
|
|
react_result=_make_react_result(),
|
|
score=0.3,
|
|
messages=[{"role": "user", "content": "test task"}],
|
|
reflect_model="default",
|
|
agent_name="test_agent",
|
|
task_type="test",
|
|
)
|
|
|
|
assert result is None
|
|
episodic.store_prompt_reflection.assert_not_awaited()
|
|
|
|
|
|
# ── Multi-version coexistence ─────────────────────────────────────────
|
|
|
|
|
|
class TestMultiVersionCoexistence:
|
|
"""U5/R11: same task_hash with different versions all stored."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_versions_stored_with_incrementing_version(self):
|
|
# Reuse the persistable mock helper from TestStorePromptReflection.
|
|
helper = TestStorePromptReflection()
|
|
mem = helper._make_persistable_mock()
|
|
|
|
# Store v1, v2, v3 for same task
|
|
key1 = await EpisodicMemory.store_prompt_reflection(
|
|
mem, task_input="same task", reflection="r1", improved_prompt="p1", version=1
|
|
)
|
|
key2 = await EpisodicMemory.store_prompt_reflection(
|
|
mem, task_input="same task", reflection="r2", improved_prompt="p2", version=2
|
|
)
|
|
key3 = await EpisodicMemory.store_prompt_reflection(
|
|
mem, task_input="same task", reflection="r3", improved_prompt="p3", version=3
|
|
)
|
|
|
|
# All keys have same task_hash prefix but different version suffix
|
|
assert key1 != key2 != key3
|
|
assert ":1" in key1 and ":2" in key2 and ":3" in key3
|
|
|
|
# All three ORM entries created via db.add
|
|
assert mem._mock_db.add.call_count == 3
|
|
assert mem._mock_db.commit.await_count == 3
|