diff --git a/python/packages/redis/AGENTS.md b/python/packages/redis/AGENTS.md index 93794790c64..52b7070ee66 100644 --- a/python/packages/redis/AGENTS.md +++ b/python/packages/redis/AGENTS.md @@ -4,7 +4,9 @@ Redis-based storage for agent threads and context. ## Main Classes -- **`RedisHistoryProvider`** - Persistent chat history provider using Redis +- **`RedisHistoryProvider`** - Persistent chat history provider using scoped Redis keys. Scoped mode requires an + application ID and non-empty session ID, and can additionally isolate tenants and agents. Use explicit legacy mode + only while deliberately migrating historical keys; scoped mode never accesses them automatically. - **`RedisContextProvider`** - Context provider with Redis-backed retrieval - **`RedisSettings`** - TypedDict connection settings for vector stores, resolved with core `load_settings` from explicit URL overrides, an optional .env file, or `REDIS_URL`. URLs use `SecretString` to mask credentials. @@ -40,7 +42,9 @@ their own indexes/keys, never `FLUSHALL`. Coverage includes both formats and from agent_framework.redis import RedisContextProvider, RedisHistoryProvider context_provider = RedisContextProvider(redis_url="redis://localhost:6379") -history_provider = RedisHistoryProvider(redis_url="redis://localhost:6379") +history_provider = RedisHistoryProvider( + redis_url="redis://localhost:6379", application_id="my-app", agent_id="my-agent" +) ``` ## Import Path diff --git a/python/packages/redis/README.md b/python/packages/redis/README.md index 627f39ed13a..a551741cba1 100644 --- a/python/packages/redis/README.md +++ b/python/packages/redis/README.md @@ -79,6 +79,47 @@ arrays. Indexed strings cannot contain surrounding whitespace, NUL, or U+001F, and must fit Redis's 4096-byte TAG limit; these restrictions do not apply to unindexed payloads. Unsupported operations raise an error. +## Isolate conversation history + +`RedisHistoryProvider` uses scoped keys by default. Supply a stable +`application_id`; also supply `tenant_id` and `agent_id` whenever those +boundaries exist in your application. The provider's `source_id` and each +non-empty session ID are included automatically: + +```python +from agent_framework.redis import RedisHistoryProvider + +history_provider = RedisHistoryProvider( + redis_url="redis://localhost:6379", + application_id="support-app", + tenant_id="contoso", + agent_id="triage-agent", +) +``` + +Scoped mode rejects missing application or session identifiers rather than +placing unrelated conversations under a shared fallback key. Identifiers are +encoded independently, so they do not need to be globally unique across +tenants, applications, agents, and provider sources. + +Releases that predate scoped keys used +`{key_prefix}:{session_id or "default"}`. Existing deployments can temporarily +retain that exact format by opting in explicitly: + +```python +legacy_history_provider = RedisHistoryProvider( + redis_url="redis://localhost:6379", + key_format="legacy", +) +``` + +Legacy mode does not accept scoped identifiers. Scoped mode never reads, +rewrites, or deletes legacy keys. To migrate existing history, copy only the +records belonging to a verified application, tenant, agent, provider source, +and session into the corresponding scoped key using an application-owned +migration process. After verifying the copied history, remove legacy keys +separately according to the application's retention policy. + ## Store and search documents This example uses precomputed vectors, so no embedding service is needed. diff --git a/python/packages/redis/agent_framework_redis/_history_provider.py b/python/packages/redis/agent_framework_redis/_history_provider.py index 8ec968a66e7..b29dc672f7d 100644 --- a/python/packages/redis/agent_framework_redis/_history_provider.py +++ b/python/packages/redis/agent_framework_redis/_history_provider.py @@ -9,12 +9,14 @@ from __future__ import annotations import json +import warnings from collections.abc import Awaitable, Sequence from inspect import isawaitable -from typing import Any, ClassVar, TypeVar, cast +from typing import Any, ClassVar, Literal, TypeVar, cast import redis.asyncio as redis from agent_framework import Message +from agent_framework._filesystem import _storage_key_segment # pyright: ignore[reportPrivateUsage] from agent_framework._sessions import HistoryProvider, filter_new_messages from agent_framework._telemetry import mark_feature_used from redis.credentials import CredentialProvider @@ -43,10 +45,17 @@ class RedisHistoryProvider(HistoryProvider): """Redis-backed history provider using the new HistoryProvider hooks pattern. Stores conversation history in Redis Lists, with each session isolated by a - unique Redis key. + key scoped to the application, optional tenant and agent, and provider source. """ DEFAULT_SOURCE_ID: ClassVar[str] = "redis_memory" + _ENCODED_KEY_PREFIX: ClassVar[str] = "~redis-key-prefix-" + _ENCODED_TENANT_PREFIX: ClassVar[str] = "~redis-tenant-" + _ENCODED_APPLICATION_PREFIX: ClassVar[str] = "~redis-application-" + _ENCODED_AGENT_PREFIX: ClassVar[str] = "~redis-agent-" + _ENCODED_SOURCE_PREFIX: ClassVar[str] = "~redis-source-" + _ENCODED_SESSION_PREFIX: ClassVar[str] = "~redis-session-" + _ABSENT_SCOPE_SEGMENT: ClassVar[str] = "~none" def __init__( self, @@ -59,6 +68,10 @@ def __init__( username: str | None = None, *, key_prefix: str = "chat_messages", + tenant_id: str | None = None, + application_id: str | None = None, + agent_id: str | None = None, + key_format: Literal["scoped", "legacy"] = "scoped", max_messages: int | None = None, load_messages: bool = True, store_outputs: bool = True, @@ -78,7 +91,17 @@ def __init__( port: Redis port number. Defaults to 6380 (Azure Redis SSL port). ssl: Enable SSL/TLS connection. Defaults to True. username: Redis username. - key_prefix: Prefix for Redis keys. Defaults to 'chat_messages'. + key_prefix: Base prefix for Redis keys. Scoped mode appends independently encoded + tenant, application, agent, provider source, and session segments. + Defaults to 'chat_messages'. + tenant_id: Optional tenant identifier used as an independent key boundary. + application_id: Application identifier used as a required key boundary in scoped mode. + agent_id: Optional agent identifier used as an independent key boundary. + key_format: Redis key format. ``"scoped"`` isolates history by tenant, + application, agent, provider source, and session. ``"legacy"`` + preserves the historical ``{key_prefix}:{session_id}`` format for + explicit migration compatibility. Scoped identifiers cannot be + supplied in legacy mode. Defaults to ``"scoped"``. max_messages: Maximum number of messages to retain per session. When exceeded, oldest messages are automatically trimmed. None means unlimited storage; 0 retains nothing, and no message @@ -95,6 +118,7 @@ def __init__( 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. + ValueError: If key_format or its scoped identifiers are invalid. """ super().__init__( source_id, @@ -113,8 +137,30 @@ def __init__( 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") + if key_format not in ("scoped", "legacy"): + raise ValueError("key_format must be 'scoped' or 'legacy'") + if key_format == "scoped": + if not application_id: + raise ValueError("application_id must be a non-empty string when key_format='scoped'") + if tenant_id == "": + raise ValueError("tenant_id must be non-empty when supplied") + if agent_id == "": + raise ValueError("agent_id must be non-empty when supplied") + elif any(scope is not None for scope in (tenant_id, application_id, agent_id)): + raise ValueError("tenant_id, application_id, and agent_id cannot be used with key_format='legacy'") + if key_format == "legacy": + warnings.warn( + "key_format='legacy' is deprecated and will be removed in a future version. " + "Migrate persisted history to scoped keys and use key_format='scoped'.", + DeprecationWarning, + stacklevel=2, + ) self.key_prefix = key_prefix + self.tenant_id = tenant_id + self.application_id = application_id + self.agent_id = agent_id + self.key_format = key_format self.max_messages = max_messages self.redis_url = redis_url @@ -130,9 +176,47 @@ def __init__( else: self._redis_client = redis.from_url(redis_url, decode_responses=True) # type: ignore[no-untyped-call] + @classmethod + def _optional_scope_segment(cls, value: str | None, *, encoded_prefix: str) -> str: + """Encode an optional isolation boundary without conflating absence with a caller value.""" + if value is None: + return cls._ABSENT_SCOPE_SEGMENT + return _storage_key_segment(value, encoded_prefix=encoded_prefix) + def _redis_key(self, session_id: str | None) -> str: - """Get the Redis key for a given session's messages.""" - return f"{self.key_prefix}:{session_id or 'default'}" + """Get a pipe-delimited scoped key or the historical colon-delimited legacy key.""" + if self.key_format == "legacy": + return f"{self.key_prefix}:{session_id or 'default'}" + if not session_id: + raise ValueError("session_id must be a non-empty string when key_format='scoped'") + + key_prefix_segment = _storage_key_segment( + self.key_prefix, + encoded_prefix=self._ENCODED_KEY_PREFIX, + ) + tenant_segment = self._optional_scope_segment( + self.tenant_id, + encoded_prefix=self._ENCODED_TENANT_PREFIX, + ) + agent_segment = self._optional_scope_segment( + self.agent_id, + encoded_prefix=self._ENCODED_AGENT_PREFIX, + ) + application_segment = _storage_key_segment( + cast("str", self.application_id), + encoded_prefix=self._ENCODED_APPLICATION_PREFIX, + ) + source_segment = _storage_key_segment(self.source_id, encoded_prefix=self._ENCODED_SOURCE_PREFIX) + session_segment = _storage_key_segment(session_id, encoded_prefix=self._ENCODED_SESSION_PREFIX) + return "|".join(( + key_prefix_segment, + "v2", + tenant_segment, + application_segment, + agent_segment, + source_segment, + session_segment, + )) async def get_messages( self, @@ -181,19 +265,17 @@ async def save_messages( **kwargs: Additional arguments (unused). """ mark_feature_used(FeatureIndex.REDIS) + key = self._redis_key(session_id) 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. + # AOF or a replica. Stored history is deliberately left alone; removing stored + # history is what clear() is for. return - key = self._redis_key(session_id) - existing_messages = await self.get_messages(session_id, state=state, **kwargs) new_messages = filter_new_messages(existing_messages, messages) diff --git a/python/packages/redis/tests/test_providers.py b/python/packages/redis/tests/test_providers.py index c6ba48a0afa..9cf10c07bca 100644 --- a/python/packages/redis/tests/test_providers.py +++ b/python/packages/redis/tests/test_providers.py @@ -21,11 +21,12 @@ # --------------------------------------------------------------------------- -async def test_empty_history_save_marks_redis_used() -> None: - provider = object.__new__(RedisHistoryProvider) +async def test_empty_history_save_marks_redis_used(mock_redis_client: MagicMock) -> None: + with patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client): + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") with patch("agent_framework_redis._history_provider.mark_feature_used") as mark_feature_used: - await provider.save_messages(None, []) + await provider.save_messages("session", []) mark_feature_used.assert_called_once_with(FeatureIndex.REDIS) @@ -350,7 +351,7 @@ class TestRedisHistoryProviderInit: def test_basic_construction(self, mock_redis_client: MagicMock): with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("memory", redis_url="redis://localhost:6379") + provider = RedisHistoryProvider("memory", redis_url="redis://localhost:6379", application_id="test-app") assert provider.source_id == "memory" assert provider.key_prefix == "chat_messages" @@ -365,6 +366,7 @@ def test_custom_params(self, mock_redis_client: MagicMock): provider = RedisHistoryProvider( "mem", redis_url="redis://localhost:6379", + application_id="test-app", key_prefix="custom", max_messages=50, load_messages=False, @@ -373,6 +375,8 @@ def test_custom_params(self, mock_redis_client: MagicMock): ) assert provider.key_prefix == "custom" + assert provider.application_id == "test-app" + assert provider.key_format == "scoped" assert provider.max_messages == 50 assert provider.load_messages is False assert provider.store_outputs is False @@ -399,13 +403,15 @@ def test_credential_provider_without_host_raises(self): def test_negative_max_messages_raises(self): with pytest.raises(ValueError, match="max_messages"): - RedisHistoryProvider("mem", redis_url="redis://localhost:6379", max_messages=-5) + RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app", 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: mock_redis_cls.return_value = MagicMock() - provider = RedisHistoryProvider("mem", credential_provider=mock_cred, host="myhost") + provider = RedisHistoryProvider( + "mem", credential_provider=mock_cred, host="myhost", application_id="test-app" + ) mock_redis_cls.assert_called_once_with( host="myhost", @@ -419,14 +425,162 @@ def test_credential_provider_with_host(self): class TestRedisHistoryProviderRedisKey: - def test_key_format(self, mock_redis_client: MagicMock): + def test_scoped_key_is_deterministic_and_encodes_opaque_components(self, mock_redis_client: MagicMock): 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", key_prefix="msgs") + provider = RedisHistoryProvider( + "source:one", + redis_url="redis://localhost:6379", + key_prefix="messages:one", + tenant_id="tenant/one", + application_id="Application One", + agent_id="agent:one", + ) + + key = provider._redis_key("session/one") + + assert key == provider._redis_key("session/one") + assert key.split("|")[1] == "v2" + assert ":" not in key + raw_components = ("messages:one", "tenant/one", "Application One", "agent:one", "source:one", "session/one") + assert all(raw_component not in key for raw_component in raw_components) + + def test_scoped_key_isolates_each_boundary(self, mock_redis_client: MagicMock): + with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: + mock_from_url.return_value = mock_redis_client + providers = [ + RedisHistoryProvider( + source_id, + redis_url="redis://localhost:6379", + tenant_id=tenant_id, + application_id=application_id, + agent_id=agent_id, + ) + for source_id, tenant_id, application_id, agent_id in ( + ("source-a", "tenant-a", "app-a", "agent-a"), + ("source-b", "tenant-a", "app-a", "agent-a"), + ("source-a", "tenant-b", "app-a", "agent-a"), + ("source-a", "tenant-a", "app-b", "agent-a"), + ("source-a", "tenant-a", "app-a", "agent-b"), + ) + ] + + keys = {provider._redis_key("shared-session") for provider in providers} + + assert len(keys) == len(providers) + + @pytest.mark.parametrize("application_id", [None, ""]) + def test_scoped_format_requires_application_id( + self, mock_redis_client: MagicMock, application_id: str | None + ) -> None: + with ( + patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client), + pytest.raises(ValueError, match="application_id"), + ): + RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id=application_id) + + @pytest.mark.parametrize(("tenant_id", "agent_id", "field"), [("", None, "tenant_id"), (None, "", "agent_id")]) + def test_scoped_format_rejects_empty_optional_scope( + self, mock_redis_client: MagicMock, tenant_id: str | None, agent_id: str | None, field: str + ) -> None: + with ( + patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client), + pytest.raises(ValueError, match=field), + ): + RedisHistoryProvider( + "mem", + redis_url="redis://localhost:6379", + application_id="app", + tenant_id=tenant_id, + agent_id=agent_id, + ) + + @pytest.mark.parametrize("session_id", [None, ""]) + def test_scoped_format_requires_session_id(self, mock_redis_client: MagicMock, session_id: str | None) -> None: + with patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client): + provider = RedisHistoryProvider( + "mem", + redis_url="redis://localhost:6379", + application_id="app", + ) + + with pytest.raises(ValueError, match="session_id"): + provider._redis_key(session_id) + + @pytest.mark.parametrize("max_messages", [None, 0]) + async def test_scoped_save_validates_session_before_noop( + self, mock_redis_client: MagicMock, max_messages: int | None + ) -> None: + with patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client): + provider = RedisHistoryProvider( + "mem", + redis_url="redis://localhost:6379", + application_id="app", + max_messages=max_messages, + ) + + messages = [] if max_messages is None else [Message(role="user", contents=["hello"])] + with pytest.raises(ValueError, match="session_id"): + await provider.save_messages(None, messages) + + async def test_scoped_operations_use_only_the_scoped_key(self, mock_redis_client: MagicMock): + with patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client): + provider = RedisHistoryProvider( + "mem", + redis_url="redis://localhost:6379", + tenant_id="tenant", + application_id="app", + agent_id="agent", + ) + + expected_key = provider._redis_key("session") + message = Message(role="user", contents=["hello"]) + + await provider.get_messages("session") + await provider.save_messages("session", [message]) + await provider.clear("session") + + expected_range_call = ((expected_key, 0, -1),) + assert mock_redis_client.lrange.call_args_list == [expected_range_call, expected_range_call] + pipeline = mock_redis_client.pipeline.return_value.__aenter__.return_value + pipeline.rpush.assert_awaited_once_with(expected_key, provider._serialize_json(message)) + mock_redis_client.delete.assert_awaited_once_with(expected_key) + assert expected_key != "chat_messages:session" + + def test_legacy_format_warns_and_preserves_historical_keys(self, mock_redis_client: MagicMock): + with ( + patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client), + pytest.warns(DeprecationWarning, match="key_format='legacy'.*deprecated"), + ): + provider = RedisHistoryProvider( + "mem", + redis_url="redis://localhost:6379", + key_prefix="msgs", + key_format="legacy", + ) assert provider._redis_key("session-123") == "msgs:session-123" assert provider._redis_key(None) == "msgs:default" + def test_legacy_format_rejects_scoped_configuration(self, mock_redis_client: MagicMock): + with ( + patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client), + pytest.raises(ValueError, match="legacy"), + ): + RedisHistoryProvider( + "mem", + redis_url="redis://localhost:6379", + application_id="app", + key_format="legacy", + ) + + def test_rejects_unsupported_key_format(self, mock_redis_client: MagicMock): + with ( + patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client), + pytest.raises(ValueError, match="key_format"), + ): + RedisHistoryProvider("mem", redis_url="redis://localhost:6379", key_format=cast(Any, "unsupported")) + class TestRedisHistoryProviderGetMessages: async def test_returns_deserialized_messages(self, mock_redis_client: MagicMock): @@ -436,7 +590,7 @@ async def test_returns_deserialized_messages(self, mock_redis_client: MagicMock) 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") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") messages = await provider.get_messages("s1") assert len(messages) == 2 @@ -450,7 +604,7 @@ async def test_empty_returns_empty(self, mock_redis_client: MagicMock): 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") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") messages = await provider.get_messages("s1") assert messages == [] @@ -462,7 +616,7 @@ async def test_returns_messages_when_lrange_is_synchronous(self, mock_redis_clie 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") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") messages = await provider.get_messages("s1") assert len(messages) == 1 @@ -484,7 +638,7 @@ class TestRedisHistoryProviderSaveMessages: async def test_saves_serialized_messages(self, mock_redis_client: MagicMock): 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") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") msgs = [Message(role="user", contents=["Hello"]), Message(role="assistant", contents=["Hi"])] await provider.save_messages("s1", msgs) @@ -496,7 +650,7 @@ async def test_saves_serialized_messages(self, mock_redis_client: MagicMock): async def test_empty_messages_noop(self, mock_redis_client: MagicMock): 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") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") await provider.save_messages("s1", []) mock_redis_client.pipeline.assert_not_called() @@ -506,18 +660,22 @@ async def test_max_messages_trimming(self, mock_redis_client: MagicMock): 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=10) + provider = RedisHistoryProvider( + "mem", redis_url="redis://localhost:6379", application_id="test-app", max_messages=10 + ) await provider.save_messages("s1", [Message(role="user", contents=["msg"])]) - mock_redis_client.ltrim.assert_called_once_with("chat_messages:s1", -10, -1) + mock_redis_client.ltrim.assert_called_once_with(provider._redis_key("s1"), -10, -1) async def test_no_trim_when_under_limit(self, mock_redis_client: MagicMock): mock_redis_client.llen = AsyncMock(return_value=3) 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=10) + provider = RedisHistoryProvider( + "mem", redis_url="redis://localhost:6379", application_id="test-app", max_messages=10 + ) await provider.save_messages("s1", [Message(role="user", contents=["msg"])]) @@ -533,7 +691,9 @@ async def test_max_messages_zero_retains_nothing(self, mock_redis_client: MagicM 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) + provider = RedisHistoryProvider( + "mem", redis_url="redis://localhost:6379", application_id="test-app", max_messages=0 + ) await provider.save_messages("s1", [Message(role="user", contents=["msg"])]) @@ -542,17 +702,12 @@ async def test_max_messages_zero_retains_nothing(self, mock_redis_client: MagicM 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. - """ + """Disabling retention must leave previously stored history unchanged.""" 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) + provider = RedisHistoryProvider( + "mem", redis_url="redis://localhost:6379", application_id="test-app", max_messages=0 + ) await provider.save_messages("s1", [Message(role="user", contents=["msg"])]) @@ -563,10 +718,10 @@ class TestRedisHistoryProviderClear: async def test_clear_calls_delete(self, mock_redis_client: MagicMock): 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") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") await provider.clear("session-1") - mock_redis_client.delete.assert_called_once_with("chat_messages:session-1") + mock_redis_client.delete.assert_called_once_with(provider._redis_key("session-1")) class TestRedisHistoryProviderBeforeAfterRun: @@ -578,7 +733,7 @@ async def test_before_run_loads_history(self, mock_redis_client: MagicMock): 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") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") session = AgentSession(session_id="test") ctx = SessionContext(input_messages=[Message(role="user", contents=["new msg"])], session_id="s1") @@ -597,7 +752,7 @@ async def test_before_run_loads_history(self, mock_redis_client: MagicMock): async def test_after_run_stores_input_and_response(self, mock_redis_client: MagicMock): 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") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") session = AgentSession(session_id="test") ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1") @@ -618,7 +773,11 @@ async def test_after_run_skips_when_no_messages(self, mock_redis_client: MagicMo 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", store_inputs=False, store_outputs=False + "mem", + redis_url="redis://localhost:6379", + application_id="test-app", + store_inputs=False, + store_outputs=False, ) session = AgentSession(session_id="test") @@ -645,7 +804,7 @@ async def test_deduplicates_identical_messages(self, mock_redis_client: MagicMoc 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") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") await provider.save_messages("s1", [msg1, msg2]) @@ -662,7 +821,7 @@ async def test_only_appends_new_messages(self, mock_redis_client: MagicMock): 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") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") await provider.save_messages("s1", [msg1, msg2, msg3]) @@ -680,7 +839,7 @@ async def test_different_roles_same_text_not_deduplicated(self, mock_redis_clien 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") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") msg2 = Message(role="assistant", contents=["ping"]) await provider.save_messages("s1", [msg1, msg2]) @@ -699,7 +858,7 @@ async def test_trimmed_messages_not_reappended(self, mock_redis_client: MagicMoc 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") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") await provider.save_messages("s1", [msg_old, msg_new]) @@ -716,7 +875,7 @@ async def test_preserves_duplicate_content(self, mock_redis_client: MagicMock): 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") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") await provider.save_messages("s1", [yes_1, yes_2]) diff --git a/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py b/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py index a5baf84b1c1..d94ece6c7e2 100644 --- a/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py +++ b/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py @@ -77,6 +77,8 @@ async def main() -> None: port=10000, ssl=True, key_prefix="chat_messages", + application_id="azure-redis-conversation", + agent_id="azure-redis-assistant", max_messages=100, ) @@ -96,8 +98,8 @@ async def main() -> None: ) # 5. Create a session to provide conversation identity. - # The session ID is used as the Redis key — all runs sharing the same session - # will read/write the same conversation history in Redis. + # The session ID is part of the scoped Redis key, so runs sharing this provider + # configuration and session read/write the same conversation history. session = agent.create_session() # 6. Conversation — each run passes the same session for continuity diff --git a/python/samples/02-agents/conversations/redis_history_provider.py b/python/samples/02-agents/conversations/redis_history_provider.py index 6829382dc85..69dafd36b18 100644 --- a/python/samples/02-agents/conversations/redis_history_provider.py +++ b/python/samples/02-agents/conversations/redis_history_provider.py @@ -23,6 +23,7 @@ # Default Redis URL for local Redis Stack. # Override via the REDIS_URL environment variable for remote or authenticated instances. REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379") +APPLICATION_ID = "redis-history-sample" async def example_manual_memory_store() -> None: @@ -33,6 +34,8 @@ async def example_manual_memory_store() -> None: redis_provider = RedisHistoryProvider( source_id="redis_basic_chat", redis_url=REDIS_URL, + application_id=APPLICATION_ID, + agent_id="redis-bot", ) # Create agent with Redis history provider @@ -72,6 +75,9 @@ async def example_user_session_management() -> None: redis_provider = RedisHistoryProvider( source_id=f"redis_{user_id}", redis_url=REDIS_URL, + tenant_id="sample-tenant", + application_id=APPLICATION_ID, + agent_id="session-bot", max_messages=10, # Keep only last 10 messages ) @@ -114,6 +120,8 @@ async def example_conversation_persistence() -> None: redis_provider = RedisHistoryProvider( source_id="redis_persistent_chat", redis_url=REDIS_URL, + application_id=APPLICATION_ID, + agent_id="persistent-bot", ) agent = Agent( @@ -164,6 +172,8 @@ async def example_session_serialization() -> None: redis_provider = RedisHistoryProvider( source_id="redis_serialization_chat", redis_url=REDIS_URL, + application_id=APPLICATION_ID, + agent_id="serialization-bot", ) agent = Agent( @@ -207,6 +217,8 @@ async def example_message_limits() -> None: redis_provider = RedisHistoryProvider( source_id="redis_limited_chat", redis_url=REDIS_URL, + application_id=APPLICATION_ID, + agent_id="limit-bot", max_messages=3, # Keep only 3 most recent messages )