Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion python/packages/redis/agent_framework_redis/_history_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ 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. Stored history is left as it
is - use ``clear`` to remove it.
load_messages: Whether to load messages before invocation.
store_outputs: Whether to store response messages.
store_inputs: Whether to store input messages.
Expand All @@ -73,6 +75,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,
Expand All @@ -89,6 +92,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")
Comment on lines +95 to +96

self.key_prefix = key_prefix
self.max_messages = max_messages
Expand Down Expand Up @@ -156,6 +161,14 @@ async def save_messages(
if not messages:
return

if self.max_messages == 0:
# Retention is disabled. Trimming cannot express this - LTRIM key 0 -1 keeps
# the whole list - so return before serializing: no payload reaches Redis, an
# AOF or a replica. Stored history is deliberately left alone. _redis_key omits
# source_id, so the list can belong to a co-located provider, and removing
# stored history is what clear() is for.
return

key = self._redis_key(session_id)
serialized_messages = [self._serialize_json(msg) for msg in messages]

Expand Down
39 changes: 39 additions & 0 deletions python/packages/redis/tests/test_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -495,6 +499,41 @@ 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"])])

# No payload reaches Redis at all, so nothing is exposed to readers, AOF or replicas.
mock_redis_client.pipeline.assert_not_called()
mock_redis_client.ltrim.assert_not_called()

async def test_max_messages_zero_leaves_stored_history_alone(self, mock_redis_client: MagicMock):
"""Disabling retention must not delete history this provider does not own.

``_redis_key`` omits ``source_id``, so two providers with the default prefix
share ``{key_prefix}:{session_id}``. Persisting runs in reverse provider order,
so a zero-retention provider that deleted the key would drop a co-located
provider's just-written history on every turn. Removing stored history is
``clear()``'s job, not a retention setting's.
"""
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_not_called()


class TestRedisHistoryProviderClear:
async def test_clear_calls_delete(self, mock_redis_client: MagicMock):
Expand Down
Loading