diff --git a/python/packages/redis/agent_framework_redis/_history_provider.py b/python/packages/redis/agent_framework_redis/_history_provider.py index a7703db8a2..c6b52a2f52 100644 --- a/python/packages/redis/agent_framework_redis/_history_provider.py +++ b/python/packages/redis/agent_framework_redis/_history_provider.py @@ -62,7 +62,8 @@ def __init__( key_prefix: Prefix for Redis keys. Defaults to 'chat_messages'. max_messages: Maximum number of messages to retain per session. When exceeded, oldest messages are automatically trimmed. - None means unlimited storage. + None means unlimited storage; 0 retains nothing, and no message + payload is written to Redis at all. load_messages: Whether to load messages before invocation. store_outputs: Whether to store response messages. store_inputs: Whether to store input messages. @@ -73,6 +74,7 @@ def __init__( ValueError: If neither redis_url nor credential_provider is provided. ValueError: If both redis_url and credential_provider are provided. ValueError: If credential_provider is used without host parameter. + ValueError: If max_messages is negative. """ super().__init__( source_id, @@ -89,6 +91,8 @@ def __init__( raise ValueError("redis_url and credential_provider are mutually exclusive") if credential_provider is not None and host is None: raise ValueError("host is required when using credential_provider") + if max_messages is not None and max_messages < 0: + raise ValueError("max_messages must be None (unlimited) or a non-negative integer") self.key_prefix = key_prefix self.max_messages = max_messages @@ -157,6 +161,15 @@ async def save_messages( return key = self._redis_key(session_id) + + if self.max_messages == 0: + # Retention is disabled. Trimming cannot express this - LTRIM key 0 -1 keeps + # the whole list - and writing first would put the payload in Redis, and in any + # AOF or replica stream, before deleting it. Drop any existing history and + # never write the messages at all. + await self._redis_client.delete(key) # type: ignore[misc] + return + serialized_messages = [self._serialize_json(msg) for msg in messages] async with self._redis_client.pipeline(transaction=True) as pipe: diff --git a/python/packages/redis/tests/test_providers.py b/python/packages/redis/tests/test_providers.py index 55aee29662..c04760ad46 100644 --- a/python/packages/redis/tests/test_providers.py +++ b/python/packages/redis/tests/test_providers.py @@ -397,6 +397,10 @@ def test_credential_provider_without_host_raises(self): with pytest.raises(ValueError, match="host is required"): RedisHistoryProvider("mem", credential_provider=mock_cred) + def test_negative_max_messages_raises(self): + with pytest.raises(ValueError, match="max_messages"): + RedisHistoryProvider("mem", redis_url="redis://localhost:6379", max_messages=-5) + def test_credential_provider_with_host(self): mock_cred = MagicMock() with patch("agent_framework_redis._history_provider.redis.Redis") as mock_redis_cls: @@ -495,6 +499,25 @@ async def test_no_trim_when_under_limit(self, mock_redis_client: MagicMock): mock_redis_client.ltrim.assert_not_called() + async def test_max_messages_zero_retains_nothing(self, mock_redis_client: MagicMock): + """Only None means unlimited, so a retention count of 0 must retain nothing. + + ``LTRIM key 0 -1`` is Redis's "keep the whole list", so trimming to + ``-max_messages`` cannot express a limit of zero. + """ + mock_redis_client.llen = AsyncMock(return_value=15) + + with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: + mock_from_url.return_value = mock_redis_client + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", max_messages=0) + + await provider.save_messages("s1", [Message(role="user", contents=["msg"])]) + + mock_redis_client.delete.assert_called_once_with("chat_messages:s1") + mock_redis_client.ltrim.assert_not_called() + # No payload reaches Redis at all, so nothing is exposed to readers, AOF or replicas. + mock_redis_client.pipeline.assert_not_called() + class TestRedisHistoryProviderClear: async def test_clear_calls_delete(self, mock_redis_client: MagicMock):