diff --git a/python/packages/redis/agent_framework_redis/_history_provider.py b/python/packages/redis/agent_framework_redis/_history_provider.py index a7703db8a2..518b394dfb 100644 --- a/python/packages/redis/agent_framework_redis/_history_provider.py +++ b/python/packages/redis/agent_framework_redis/_history_provider.py @@ -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 @@ -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. @@ -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( @@ -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: diff --git a/python/packages/redis/tests/test_providers.py b/python/packages/redis/tests/test_providers.py index 55aee29662..52de3878b8 100644 --- a/python/packages/redis/tests/test_providers.py +++ b/python/packages/redis/tests/test_providers.py @@ -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 @@ -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):