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
41 changes: 32 additions & 9 deletions python/packages/redis/agent_framework_redis/_history_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@

from __future__ import annotations

from collections.abc import Sequence
from typing import Any, ClassVar
from collections.abc import Awaitable, Sequence
from inspect import isawaitable
from typing import Any, ClassVar, TypeVar, cast

import redis.asyncio as redis
from agent_framework import Message
Expand All @@ -19,6 +20,23 @@

from ._feature_usage import FeatureIndex

_T = TypeVar("_T")


async def _redis_result(value: Awaitable[_T] | _T) -> _T:
"""Await a redis-py command result that is annotated as the sync/async union.

Several redis-py commands are annotated as returning ``Awaitable[T] | T`` even on the asyncio
client, so awaiting them directly does not type-check. Newer redis-py releases narrow those
annotations to the awaitable alone, which makes a bare ``# type: ignore`` *required* on the
older annotations and *unnecessary* on the newer ones: no single ignore comment satisfies the
whole supported range. Normalising through this helper type-checks on every supported version
without an ignore comment.
"""
if isawaitable(value):
return cast("_T", await value)
return cast("_T", value)


class RedisHistoryProvider(HistoryProvider):
"""Redis-backed history provider using the new HistoryProvider hooks pattern.
Expand Down Expand Up @@ -129,11 +147,16 @@ async def get_messages(
"""
mark_feature_used(FeatureIndex.REDIS)
key = self._redis_key(session_id)
redis_messages: list[str] = await self._redis_client.lrange(key, 0, -1) # type: ignore[misc]
# Older redis-py annotates ``lrange`` with a partially unknown type while newer releases
# type it precisely, so neither keeping nor dropping an ignore comment here is correct for
# the whole supported range. Going through an explicitly ``Any``-typed client makes the
# call site version-independent, and the cast pins the element type that
# ``decode_responses=True`` guarantees.
client: Any = self._redis_client
redis_messages = cast("list[str]", await _redis_result(client.lrange(key, 0, -1)))
messages: list[Message] = []
if redis_messages:
for serialized in redis_messages: # type: ignore[union-attr]
messages.append(Message.from_dict(self._deserialize_json(serialized))) # type: ignore[union-attr]
for serialized in redis_messages:
messages.append(Message.from_dict(self._deserialize_json(serialized)))
return messages

async def save_messages(
Expand Down Expand Up @@ -161,13 +184,13 @@ async def save_messages(

async with self._redis_client.pipeline(transaction=True) as pipe:
for serialized in serialized_messages:
await pipe.rpush(key, serialized) # type: ignore[misc]
await _redis_result(pipe.rpush(key, serialized))
await pipe.execute()

if self.max_messages is not None:
current_count = await self._redis_client.llen(key) # type: ignore[misc]
current_count: int = await _redis_result(self._redis_client.llen(key))
if current_count > self.max_messages:
await self._redis_client.ltrim(key, -self.max_messages, -1) # type: ignore[misc]
await _redis_result(self._redis_client.ltrim(key, -self.max_messages, -1))

@staticmethod
def _serialize_json(message: Message) -> str:
Expand Down
26 changes: 25 additions & 1 deletion python/packages/redis/tests/test_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from agent_framework_redis._context_provider import RedisContextProvider
from agent_framework_redis._feature_usage import FeatureIndex
from agent_framework_redis._history_provider import RedisHistoryProvider
from agent_framework_redis._history_provider import RedisHistoryProvider, _redis_result

# ---------------------------------------------------------------------------
# Shared fixtures
Expand Down Expand Up @@ -451,6 +451,30 @@ async def test_empty_returns_empty(self, mock_redis_client: MagicMock):
messages = await provider.get_messages("s1")
assert messages == []

async def test_returns_messages_when_lrange_is_synchronous(self, mock_redis_client: MagicMock):
"""redis-py types several commands as returning a value or an awaitable; handle both."""
msg = Message(role="user", contents=["Hello"])
mock_redis_client.lrange = MagicMock(return_value=[json.dumps(msg.to_dict())])

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

messages = await provider.get_messages("s1")
assert len(messages) == 1
assert messages[0].text == "Hello"


class TestRedisResultHelper:
async def test_awaits_an_awaitable_result(self):
async def _coro() -> int:
return 7

assert await _redis_result(_coro()) == 7

async def test_passes_through_a_plain_result(self):
assert await _redis_result(7) == 7


class TestRedisHistoryProviderSaveMessages:
async def test_saves_serialized_messages(self, mock_redis_client: MagicMock):
Expand Down
Loading