chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Redis Package (agent-framework-redis)
Redis-based storage for agent threads and context.
## Main Classes
- **`RedisHistoryProvider`** - Persistent chat history provider using Redis
- **`RedisContextProvider`** - Context provider with Redis-backed retrieval
## Usage
```python
from agent_framework.redis import RedisContextProvider, RedisHistoryProvider
context_provider = RedisContextProvider(redis_url="redis://localhost:6379")
history_provider = RedisHistoryProvider(redis_url="redis://localhost:6379")
```
## Import Path
```python
from agent_framework.redis import RedisContextProvider, RedisHistoryProvider
# or directly:
from agent_framework_redis import RedisContextProvider, RedisHistoryProvider
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+53
View File
@@ -0,0 +1,53 @@
# Get Started with Microsoft Agent Framework Redis
Please install this package via pip:
```bash
pip install agent-framework-redis --pre
```
## Components
### Memory Context Provider
The `RedisContextProvider` enables persistent context and memory capabilities for your agents, allowing them to remember user preferences and conversation context across sessions and threads.
#### Basic Usage Examples
Review the set of [getting started examples](../../samples/02-agents/context_providers/redis/README.md) for using the Redis context provider.
### Redis History Provider
The `RedisHistoryProvider` provides persistent conversation storage using Redis Lists, enabling chat history to survive application restarts and support distributed applications.
#### Key Features
- **Persistent Storage**: Messages survive application restarts
- **Thread Isolation**: Each conversation thread has its own Redis key
- **Message Limits**: Configurable automatic trimming of old messages
- **Serialization Support**: Full compatibility with Agent Framework thread serialization
- **Production Ready**: Connection pooling, error handling, and performance optimized
#### Basic Usage Examples
See the complete [Redis history provider examples](../../samples/02-agents/conversations/redis_history_provider.py) including:
- User session management
- Conversation persistence across restarts
- Session serialization and deserialization
- Automatic message trimming
- Error handling patterns
### Installing and running Redis
You have 3 options to set-up Redis:
#### Option A: Local Redis with Docker
```bash
docker run --name redis -p 6379:6379 -d redis:8.0.3
```
#### Option B: Redis Cloud
Get a free db at https://redis.io/cloud/
#### Option C: Azure Managed Redis
Here's a quickstart guide to create **Azure Managed Redis** for as low as $12 monthly: https://learn.microsoft.com/en-us/azure/redis/quickstart-create-managed-redis
@@ -0,0 +1,16 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
from ._context_provider import RedisContextProvider
from ._history_provider import RedisHistoryProvider
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"RedisContextProvider",
"RedisHistoryProvider",
"__version__",
]
@@ -0,0 +1,429 @@
# Copyright (c) Microsoft. All rights reserved.
"""New-pattern Redis context provider using ContextProvider.
This module provides ``RedisContextProvider``, built on the new
:class:`ContextProvider` hooks pattern.
"""
from __future__ import annotations
import json
import sys
from typing import TYPE_CHECKING, Any, ClassVar, Literal
import numpy as np
from agent_framework import Message
from agent_framework._sessions import AgentSession, ContextProvider, SessionContext
from agent_framework.exceptions import (
AgentException,
IntegrationInvalidRequestException,
)
from redisvl.index import AsyncSearchIndex
from redisvl.query import AggregateHybridQuery, TextQuery
from redisvl.query.filter import FilterExpression, Tag
from redisvl.utils.token_escaper import TokenEscaper
from redisvl.utils.vectorize import BaseVectorizer
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
if TYPE_CHECKING:
from agent_framework._agents import SupportsAgentRun
class RedisContextProvider(ContextProvider):
"""Redis context provider using the new ContextProvider hooks pattern.
Stores context in Redis and retrieves scoped context via full-text or
optional hybrid vector search.
"""
DEFAULT_CONTEXT_PROMPT = "## Memories\nConsider the following memories when answering user questions:"
DEFAULT_SOURCE_ID: ClassVar[str] = "redis"
def __init__(
self,
source_id: str = DEFAULT_SOURCE_ID,
redis_url: str = "redis://localhost:6379",
index_name: str = "context",
prefix: str = "context",
*,
redis_vectorizer: BaseVectorizer | None = None,
vector_field_name: str | None = None,
vector_algorithm: Literal["flat", "hnsw"] | None = None,
vector_distance_metric: Literal["cosine", "ip", "l2"] | None = None,
application_id: str | None = None,
agent_id: str | None = None,
user_id: str | None = None,
context_prompt: str | None = None,
redis_index: Any = None,
overwrite_index: bool = False,
):
"""Create a Redis Context Provider.
Args:
source_id: Unique identifier for this provider instance.
redis_url: The Redis server URL.
index_name: The name of the Redis index.
prefix: The prefix for all keys in the Redis database.
redis_vectorizer: The vectorizer to use for Redis.
vector_field_name: The name of the vector field in Redis.
vector_algorithm: The algorithm to use for vector search.
vector_distance_metric: The distance metric to use for vector search.
application_id: The application ID to scope the context.
agent_id: The agent ID to scope the context.
user_id: The user ID to scope the context.
context_prompt: The context prompt to use for the provider.
redis_index: The Redis index to use for the provider.
overwrite_index: Whether to overwrite the existing Redis index.
"""
super().__init__(source_id)
self.redis_url = redis_url
self.index_name = index_name
self.prefix = prefix
if redis_vectorizer is not None and not isinstance(redis_vectorizer, BaseVectorizer):
raise AgentException(
f"The redis vectorizer is not a valid type, got: {type(redis_vectorizer)}, expected: BaseVectorizer."
)
self.redis_vectorizer = redis_vectorizer
self.vector_field_name = vector_field_name
self.vector_algorithm: Literal["flat", "hnsw"] | None = vector_algorithm
self.vector_distance_metric: Literal["cosine", "ip", "l2"] | None = vector_distance_metric
self.application_id = application_id
self.agent_id = agent_id
self.user_id = user_id
self.context_prompt = context_prompt or self.DEFAULT_CONTEXT_PROMPT
self.overwrite_index = overwrite_index
self._token_escaper: TokenEscaper = TokenEscaper()
self._index_initialized: bool = False
self._schema_dict: dict[str, Any] | None = None
index = redis_index or AsyncSearchIndex.from_dict( # pyright: ignore[reportUnknownMemberType]
self.schema_dict, redis_url=self.redis_url, validate_on_load=True
)
self.redis_index: Any = index
# -- Hooks pattern ---------------------------------------------------------
@override
async def before_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Retrieve scoped context from Redis and add to the session context."""
self._validate_filters()
input_text = "\n".join(msg.text for msg in context.input_messages if msg and msg.text and msg.text.strip())
if not input_text.strip():
return
memories = await self._redis_search(text=input_text)
line_separated_memories = "\n".join(
str(memory.get("content", "")) for memory in memories if memory.get("content")
)
if line_separated_memories:
context.extend_messages(
self.source_id,
[Message(role="user", contents=[f"{self.context_prompt}\n{line_separated_memories}"])],
)
@override
async def after_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Store request/response messages to Redis for future retrieval."""
self._validate_filters()
messages_to_store: list[Message] = list(context.input_messages)
if context.response and context.response.messages:
messages_to_store.extend(context.response.messages)
messages: list[dict[str, Any]] = []
for message in messages_to_store:
if message.role in {"user", "assistant", "system"} and message.text and message.text.strip():
shaped: dict[str, Any] = {
"role": message.role,
"content": message.text,
"conversation_id": context.session_id,
"message_id": message.message_id,
"author_name": message.author_name,
}
messages.append(shaped)
if messages:
await self._add(data=messages, session_id=context.session_id)
# -- Internal methods (ported from RedisProvider) --------------------------
@property
def schema_dict(self) -> dict[str, Any]:
"""Get the Redis schema dictionary, computing and caching it on first access."""
if self._schema_dict is None:
vector_dims = self.redis_vectorizer.dims if self.redis_vectorizer is not None else None
vector_datatype = self.redis_vectorizer.dtype if self.redis_vectorizer is not None else None
self._schema_dict = self._build_schema_dict(
index_name=self.index_name,
prefix=self.prefix,
vector_field_name=self.vector_field_name,
vector_dims=vector_dims,
vector_datatype=vector_datatype,
vector_algorithm=self.vector_algorithm,
vector_distance_metric=self.vector_distance_metric,
)
return self._schema_dict
def _build_filter_from_dict(self, filters: dict[str, str | None]) -> Any | None:
"""Builds a combined filter expression from simple equality tags."""
parts: list[FilterExpression] = [Tag(k) == v for k, v in filters.items() if v]
if not parts:
return None
combined = parts[0]
for part in parts[1:]:
combined = combined & part
return combined
def _build_schema_dict(
self,
*,
index_name: str,
prefix: str,
vector_field_name: str | None,
vector_dims: int | None,
vector_datatype: str | None,
vector_algorithm: Literal["flat", "hnsw"] | None,
vector_distance_metric: Literal["cosine", "ip", "l2"] | None,
) -> dict[str, Any]:
"""Builds the RediSearch schema configuration dictionary."""
fields: list[dict[str, Any]] = [
{"name": "role", "type": "tag"},
{"name": "mime_type", "type": "tag"},
{"name": "content", "type": "text"},
{"name": "conversation_id", "type": "tag"},
{"name": "message_id", "type": "tag"},
{"name": "author_name", "type": "tag"},
{"name": "application_id", "type": "tag"},
{"name": "agent_id", "type": "tag"},
{"name": "user_id", "type": "tag"},
{"name": "thread_id", "type": "tag"},
]
if vector_field_name is not None and vector_dims is not None:
fields.append({
"name": vector_field_name,
"type": "vector",
"attrs": {
"algorithm": (vector_algorithm or "hnsw"),
"dims": int(vector_dims),
"distance_metric": (vector_distance_metric or "cosine"),
"datatype": (vector_datatype or "float32"),
},
})
return {
"index": {"name": index_name, "prefix": prefix, "key_separator": ":", "storage_type": "hash"},
"fields": fields,
}
async def _ensure_index(self) -> None:
"""Initialize the search index."""
if self._index_initialized:
return
index_exists = await self.redis_index.exists()
if not self.overwrite_index and index_exists:
await self._validate_schema_compatibility()
await self.redis_index.create(overwrite=self.overwrite_index, drop=False)
self._index_initialized = True
async def _validate_schema_compatibility(self) -> None:
"""Validate that existing index schema matches current configuration."""
TAG_DEFAULTS = {"separator": ",", "case_sensitive": False, "withsuffixtrie": False}
TEXT_DEFAULTS = {"weight": 1.0, "no_stem": False}
def _significant_index(i: dict[str, Any]) -> dict[str, Any]:
return {k: i.get(k) for k in ("name", "prefix", "key_separator", "storage_type")}
def _sig_tag(attrs: dict[str, Any] | None) -> dict[str, Any]:
a = {**TAG_DEFAULTS, **(attrs or {})}
return {k: a[k] for k in ("separator", "case_sensitive", "withsuffixtrie")}
def _sig_text(attrs: dict[str, Any] | None) -> dict[str, Any]:
a = {**TEXT_DEFAULTS, **(attrs or {})}
return {k: a[k] for k in ("weight", "no_stem")}
def _sig_vector(attrs: dict[str, Any] | None) -> dict[str, Any]:
a = {**(attrs or {})}
return {k: a.get(k) for k in ("algorithm", "dims", "distance_metric", "datatype")}
def _schema_signature(schema: dict[str, Any]) -> dict[str, Any]:
sig: dict[str, Any] = {"index": _significant_index(schema.get("index", {})), "fields": {}}
for f in schema.get("fields", []):
name, ftype = f.get("name"), f.get("type")
if not name:
continue
if ftype == "tag":
sig["fields"][name] = {"type": "tag", "attrs": _sig_tag(f.get("attrs"))}
elif ftype == "text":
sig["fields"][name] = {"type": "text", "attrs": _sig_text(f.get("attrs"))}
elif ftype == "vector":
sig["fields"][name] = {"type": "vector", "attrs": _sig_vector(f.get("attrs"))}
else:
sig["fields"][name] = {"type": ftype}
return sig
existing_index: Any = await AsyncSearchIndex.from_existing( # pyright: ignore[reportUnknownMemberType]
self.index_name, redis_url=self.redis_url
)
existing_schema = existing_index.schema.to_dict()
current_schema = self.schema_dict
existing_sig = _schema_signature(existing_schema)
current_sig = _schema_signature(current_schema)
if existing_sig != current_sig:
raise ValueError(
"Existing Redis index schema is incompatible with the current configuration.\n"
f"Existing (significant): {json.dumps(existing_sig, indent=2, sort_keys=True)}\n"
f"Current (significant): {json.dumps(current_sig, indent=2, sort_keys=True)}\n"
"Set overwrite_index=True to rebuild if this change is intentional."
)
async def _add(
self,
*,
data: dict[str, Any] | list[dict[str, Any]],
session_id: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
"""Inserts one or many documents with partition fields populated."""
self._validate_filters()
await self._ensure_index()
docs = data if isinstance(data, list) else [data]
prepared: list[dict[str, Any]] = []
for doc in docs:
d = dict(doc)
d.setdefault("application_id", self.application_id)
d.setdefault("agent_id", self.agent_id)
d.setdefault("user_id", self.user_id)
d.setdefault("thread_id", session_id)
d.setdefault("conversation_id", session_id)
if "content" not in d:
raise IntegrationInvalidRequestException("add() requires a 'content' field in data")
if self.vector_field_name:
d.setdefault(self.vector_field_name, None)
prepared.append(d)
if self.redis_vectorizer and self.vector_field_name:
text_list = [d["content"] for d in prepared]
embeddings = await self.redis_vectorizer.aembed_many( # pyright: ignore[reportUnknownMemberType]
text_list, batch_size=len(text_list)
)
for i, d in enumerate(prepared):
vec = np.asarray(embeddings[i], dtype=np.float32).tobytes()
field_name: str = self.vector_field_name
d[field_name] = vec
await self.redis_index.load(prepared)
async def _redis_search(
self,
text: str,
*,
session_id: str | None = None,
text_scorer: str = "BM25STD",
filter_expression: Any | None = None,
return_fields: list[str] | None = None,
num_results: int = 10,
alpha: float = 0.7,
) -> list[dict[str, Any]]:
"""Runs a text or hybrid vector-text search with optional filters."""
await self._ensure_index()
self._validate_filters()
q = (text or "").strip()
if not q:
raise IntegrationInvalidRequestException("text_search() requires non-empty text")
num_results = max(int(num_results or 10), 1)
combined_filter = self._build_filter_from_dict({
"application_id": self.application_id,
"agent_id": self.agent_id,
"user_id": self.user_id,
"thread_id": session_id,
"conversation_id": session_id,
})
if filter_expression is not None:
combined_filter = (combined_filter & filter_expression) if combined_filter else filter_expression
return_fields = (
return_fields
if return_fields is not None
else ["content", "role", "application_id", "agent_id", "user_id", "thread_id"]
)
try:
if self.redis_vectorizer and self.vector_field_name:
vector = await self.redis_vectorizer.aembed(q) # pyright: ignore[reportUnknownMemberType]
query = AggregateHybridQuery(
text=q,
text_field_name="content",
vector=vector,
vector_field_name=self.vector_field_name,
text_scorer=text_scorer,
filter_expression=combined_filter,
alpha=alpha,
dtype=self.redis_vectorizer.dtype,
num_results=num_results,
return_fields=return_fields,
stopwords=None,
)
return await self.redis_index.query(query)
query = TextQuery(
text=q,
text_field_name="content",
text_scorer=text_scorer,
filter_expression=combined_filter,
num_results=num_results,
return_fields=return_fields,
stopwords=None,
)
return await self.redis_index.query(query)
except Exception as exc: # pragma: no cover
raise IntegrationInvalidRequestException(f"Redis text search failed: {exc}") from exc
def _validate_filters(self) -> None:
"""Validates that at least one filter is provided."""
if not self.agent_id and not self.user_id and not self.application_id:
raise ValueError("At least one of the filters: agent_id, user_id, or application_id is required.")
async def search_all(self, page_size: int = 200) -> list[dict[str, Any]]:
"""Returns all documents in the index."""
from redisvl.query import FilterQuery
out: list[dict[str, Any]] = []
async for batch in self.redis_index.paginate(
FilterQuery(FilterExpression("*"), return_fields=[], num_results=page_size),
page_size=page_size,
):
out.extend(batch)
return out
async def __aenter__(self) -> Self:
"""Async context manager entry."""
return self
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
"""Async context manager exit."""
__all__ = ["RedisContextProvider"]
@@ -0,0 +1,194 @@
# Copyright (c) Microsoft. All rights reserved.
"""New-pattern Redis history provider using HistoryProvider.
This module provides ``RedisHistoryProvider``, built on the new
:class:`HistoryProvider` hooks pattern.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any, ClassVar
import redis.asyncio as redis
from agent_framework import Message
from agent_framework._sessions import HistoryProvider
from redis.credentials import CredentialProvider
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.
"""
DEFAULT_SOURCE_ID: ClassVar[str] = "redis_memory"
def __init__(
self,
source_id: str = DEFAULT_SOURCE_ID,
redis_url: str | None = None,
credential_provider: CredentialProvider | None = None,
host: str | None = None,
port: int = 6380,
ssl: bool = True,
username: str | None = None,
*,
key_prefix: str = "chat_messages",
max_messages: int | None = None,
load_messages: bool = True,
store_outputs: bool = True,
store_inputs: bool = True,
store_context_messages: bool = False,
store_context_from: set[str] | None = None,
) -> None:
"""Initialize the Redis history provider.
Args:
source_id: Unique identifier for this provider instance.
redis_url: Redis connection URL (e.g., "redis://localhost:6379").
Mutually exclusive with credential_provider.
credential_provider: Redis credential provider for Azure AD authentication.
Requires host parameter. Mutually exclusive with redis_url.
host: Redis host name. Required when using credential_provider.
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'.
max_messages: Maximum number of messages to retain per session.
When exceeded, oldest messages are automatically trimmed.
None means unlimited storage.
load_messages: Whether to load messages before invocation.
store_outputs: Whether to store response messages.
store_inputs: Whether to store input messages.
store_context_messages: Whether to store context from other providers.
store_context_from: If set, only store context from these source_ids.
Raises:
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.
"""
super().__init__(
source_id,
load_messages=load_messages,
store_outputs=store_outputs,
store_inputs=store_inputs,
store_context_messages=store_context_messages,
store_context_from=store_context_from,
)
if redis_url is None and credential_provider is None:
raise ValueError("Either redis_url or credential_provider must be provided")
if redis_url is not None and credential_provider is not None:
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")
self.key_prefix = key_prefix
self.max_messages = max_messages
self.redis_url = redis_url
if credential_provider is not None and host is not None:
self._redis_client = redis.Redis(
host=host,
port=port,
ssl=ssl,
username=username,
credential_provider=credential_provider,
decode_responses=True,
)
else:
self._redis_client = redis.from_url(redis_url, decode_responses=True) # type: ignore[no-untyped-call]
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'}"
async def get_messages(
self,
session_id: str | None,
*,
state: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[Message]:
"""Retrieve stored messages for this session from Redis.
Args:
session_id: The session ID to retrieve messages for.
state: Optional session state. Unused for Redis-backed history.
**kwargs: Additional arguments (unused).
Returns:
List of stored Message objects in chronological order.
"""
key = self._redis_key(session_id)
redis_messages: list[str] = await self._redis_client.lrange(key, 0, -1) # type: ignore[misc]
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]
return messages
async def save_messages(
self,
session_id: str | None,
messages: Sequence[Message],
*,
state: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Persist messages for this session to Redis.
Args:
session_id: The session ID to store messages for.
messages: The messages to persist.
state: Optional session state. Unused for Redis-backed history.
**kwargs: Additional arguments (unused).
"""
if not messages:
return
key = self._redis_key(session_id)
serialized_messages = [self._serialize_json(msg) for msg in 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 pipe.execute()
if self.max_messages is not None:
current_count = await self._redis_client.llen(key) # type: ignore[misc]
if current_count > self.max_messages:
await self._redis_client.ltrim(key, -self.max_messages, -1) # type: ignore[misc]
@staticmethod
def _serialize_json(message: Message) -> str:
"""Serialize a Message to a JSON string for Redis storage."""
import json
return json.dumps(message.to_dict())
@staticmethod
def _deserialize_json(data: str) -> dict[str, Any]:
"""Deserialize a JSON string from Redis to a dict."""
import json
return json.loads(data)
async def clear(self, session_id: str | None) -> None:
"""Clear all messages for a session.
Args:
session_id: The session ID to clear messages for.
"""
await self._redis_client.delete(self._redis_key(session_id))
async def aclose(self) -> None:
"""Close the Redis connection."""
await self._redis_client.aclose()
__all__ = ["RedisHistoryProvider"]
+100
View File
@@ -0,0 +1,100 @@
[project]
name = "agent-framework-redis"
description = "Redis integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.6.0,<2",
"redis>=6.4.0,<7.2.1",
"redisvl>=0.11.0,<0.16",
"numpy>=2.2.6,<3"
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
]
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_redis"]
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_redis"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_redis"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_redis --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,561 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for RedisContextProvider and RedisHistoryProvider."""
from __future__ import annotations
import json
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import AgentResponse, Message
from agent_framework._sessions import AgentSession, SessionContext
from agent_framework_redis._context_provider import RedisContextProvider
from agent_framework_redis._history_provider import RedisHistoryProvider
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_index() -> AsyncMock:
idx = AsyncMock()
idx.create = AsyncMock()
idx.load = AsyncMock()
idx.query = AsyncMock(return_value=[])
idx.exists = AsyncMock(return_value=False)
return idx
@pytest.fixture
def patch_index_from_dict(mock_index: AsyncMock):
with patch("agent_framework_redis._context_provider.AsyncSearchIndex") as mock_cls:
mock_cls.from_dict = MagicMock(return_value=mock_index)
async def mock_from_existing(index_name: str, redis_url: str): # noqa: ARG001
mock_existing = AsyncMock()
mock_existing.schema.to_dict = MagicMock(
side_effect=lambda: mock_cls.from_dict.call_args[0][0] if mock_cls.from_dict.call_args else {}
)
return mock_existing
mock_cls.from_existing = AsyncMock(side_effect=mock_from_existing)
yield mock_cls
@pytest.fixture
def mock_redis_client():
client = MagicMock()
client.lrange = AsyncMock(return_value=[])
client.llen = AsyncMock(return_value=0)
client.ltrim = AsyncMock()
client.delete = AsyncMock()
mock_pipeline = AsyncMock()
mock_pipeline.rpush = AsyncMock()
mock_pipeline.execute = AsyncMock()
client.pipeline.return_value.__aenter__.return_value = mock_pipeline
return client
# ===========================================================================
# RedisContextProvider tests
# ===========================================================================
class TestRedisContextProviderInit:
def test_basic_construction(self, patch_index_from_dict: MagicMock): # noqa: ARG002
provider = RedisContextProvider(source_id="ctx", user_id="u1")
assert provider.source_id == "ctx"
assert provider.user_id == "u1"
assert provider.redis_url == "redis://localhost:6379"
assert provider.index_name == "context"
assert provider.prefix == "context"
def test_custom_params(self, patch_index_from_dict: MagicMock): # noqa: ARG002
provider = RedisContextProvider(
source_id="ctx",
redis_url="redis://custom:6380",
index_name="my_idx",
prefix="my_prefix",
application_id="app1",
agent_id="agent1",
user_id="user1",
context_prompt="Custom prompt",
)
assert provider.redis_url == "redis://custom:6380"
assert provider.index_name == "my_idx"
assert provider.prefix == "my_prefix"
assert provider.application_id == "app1"
assert provider.agent_id == "agent1"
assert provider.context_prompt == "Custom prompt"
def test_default_context_prompt(self, patch_index_from_dict: MagicMock): # noqa: ARG002
provider = RedisContextProvider(source_id="ctx", user_id="u1")
assert "Memories" in provider.context_prompt
def test_invalid_vectorizer_raises(self, patch_index_from_dict: MagicMock): # noqa: ARG002
from agent_framework.exceptions import AgentException
with pytest.raises(AgentException, match="not a valid type"):
RedisContextProvider(source_id="ctx", user_id="u1", redis_vectorizer="bad") # type: ignore[arg-type] # ty: ignore[invalid-argument-type]
class TestRedisContextProviderValidateFilters:
def test_no_filters_raises(self, patch_index_from_dict: MagicMock): # noqa: ARG002
provider = RedisContextProvider(source_id="ctx")
with pytest.raises(ValueError, match="(?i)at least one"):
provider._validate_filters()
def test_any_single_filter_ok(self, patch_index_from_dict: MagicMock): # noqa: ARG002
for kwargs in [{"user_id": "u"}, {"agent_id": "a"}, {"application_id": "app"}]:
provider = RedisContextProvider(source_id="ctx", **cast(Any, kwargs))
provider._validate_filters() # should not raise
class TestRedisContextProviderSchema:
def test_schema_has_expected_fields(self, patch_index_from_dict: MagicMock): # noqa: ARG002
provider = RedisContextProvider(source_id="ctx", user_id="u1")
schema = provider.schema_dict
field_names = [f["name"] for f in schema["fields"]]
for expected in ("role", "content", "conversation_id", "message_id", "application_id", "agent_id", "user_id"):
assert expected in field_names
assert schema["index"]["name"] == "context"
assert schema["index"]["prefix"] == "context"
def test_schema_no_vector_without_vectorizer(self, patch_index_from_dict: MagicMock): # noqa: ARG002
provider = RedisContextProvider(source_id="ctx", user_id="u1")
field_types = [f["type"] for f in provider.schema_dict["fields"]]
assert "vector" not in field_types
class TestRedisContextProviderBeforeRun:
async def test_search_results_added_to_context(
self,
mock_index: AsyncMock,
patch_index_from_dict: MagicMock, # noqa: ARG002
):
mock_index.query = AsyncMock(return_value=[{"content": "Memory A"}, {"content": "Memory B"}])
provider = RedisContextProvider(source_id="ctx", user_id="u1")
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[Message(role="user", contents=["test query"])], session_id="s1")
await provider.before_run(
agent=cast(Any, None),
session=session,
context=ctx,
state=session.state.setdefault(provider.source_id, {}),
) # type: ignore[arg-type]
assert "ctx" in ctx.context_messages
msgs = ctx.context_messages["ctx"]
assert len(msgs) == 1
assert "Memory A" in msgs[0].text
assert "Memory B" in msgs[0].text
async def test_empty_input_no_search(
self,
mock_index: AsyncMock,
patch_index_from_dict: MagicMock, # noqa: ARG002
):
provider = RedisContextProvider(source_id="ctx", user_id="u1")
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[Message(role="user", contents=[" "])], session_id="s1")
await provider.before_run(
agent=cast(Any, None),
session=session,
context=ctx,
state=session.state.setdefault(provider.source_id, {}),
) # type: ignore[arg-type]
mock_index.query.assert_not_called()
assert "ctx" not in ctx.context_messages
async def test_before_run_searches_without_session_id(
self,
mock_index: AsyncMock,
patch_index_from_dict: MagicMock, # noqa: ARG002
):
"""Verify that before_run performs cross-session retrieval (no session_id filter)."""
mock_index.query = AsyncMock(return_value=[{"content": "Memory"}])
provider = RedisContextProvider(source_id="ctx", user_id="u1")
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[Message(role="user", contents=["test query"])], session_id="s1")
with patch.object(provider, "_redis_search", wraps=provider._redis_search) as spy:
await provider.before_run(
agent=cast(Any, None),
session=session,
context=ctx,
state=session.state.setdefault(provider.source_id, {}),
) # type: ignore[arg-type]
spy.assert_called_once()
# session_id should not be passed to _redis_search (cross-session retrieval)
assert "session_id" not in spy.call_args.kwargs
async def test_empty_results_no_messages(
self,
mock_index: AsyncMock,
patch_index_from_dict: MagicMock, # noqa: ARG002
):
mock_index.query = AsyncMock(return_value=[])
provider = RedisContextProvider(source_id="ctx", user_id="u1")
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[Message(role="user", contents=["hello"])], session_id="s1")
await provider.before_run(
agent=cast(Any, None),
session=session,
context=ctx,
state=session.state.setdefault(provider.source_id, {}),
) # type: ignore[arg-type]
assert "ctx" not in ctx.context_messages
class TestRedisContextProviderAfterRun:
async def test_stores_messages(
self,
mock_index: AsyncMock,
patch_index_from_dict: MagicMock, # noqa: ARG002
):
provider = RedisContextProvider(source_id="ctx", user_id="u1")
session = AgentSession(session_id="test-session")
response = AgentResponse(messages=[Message(role="assistant", contents=["response text"])])
ctx = SessionContext(input_messages=[Message(role="user", contents=["user input"])], session_id="s1")
ctx._response = response
await provider.after_run(
agent=cast(Any, None),
session=session,
context=ctx,
state=session.state.setdefault(provider.source_id, {}),
) # type: ignore[arg-type]
mock_index.load.assert_called_once()
loaded = mock_index.load.call_args[0][0]
assert len(loaded) == 2
roles = {d["role"] for d in loaded}
assert roles == {"user", "assistant"}
async def test_skips_empty_conversations(
self,
mock_index: AsyncMock,
patch_index_from_dict: MagicMock, # noqa: ARG002
):
provider = RedisContextProvider(source_id="ctx", user_id="u1")
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[Message(role="user", contents=[" "])], session_id="s1")
await provider.after_run(
agent=cast(Any, None),
session=session,
context=ctx,
state=session.state.setdefault(provider.source_id, {}),
) # type: ignore[arg-type]
mock_index.load.assert_not_called()
async def test_stores_partition_fields(
self,
mock_index: AsyncMock,
patch_index_from_dict: MagicMock, # noqa: ARG002
):
provider = RedisContextProvider(source_id="ctx", application_id="app", agent_id="ag", user_id="u1")
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[Message(role="user", contents=["hello"])], session_id="s1")
await provider.after_run(
agent=cast(Any, None),
session=session,
context=ctx,
state=session.state.setdefault(provider.source_id, {}),
) # type: ignore[arg-type]
loaded = mock_index.load.call_args[0][0]
doc = loaded[0]
assert doc["application_id"] == "app"
assert doc["agent_id"] == "ag"
assert doc["user_id"] == "u1"
assert doc["conversation_id"] == "s1"
class TestRedisContextProviderContextManager:
async def test_aenter_returns_self(self, patch_index_from_dict: MagicMock): # noqa: ARG002
provider = RedisContextProvider(source_id="ctx", user_id="u1")
async with provider as p:
assert p is provider
class TestRedisContextProviderHybridQuery:
"""Test for AggregateHybridQuery parameter compatibility with redisvl 0.14.0."""
async def test_aggregate_hybrid_query_uses_alpha(
self,
mock_index: AsyncMock,
patch_index_from_dict: MagicMock, # noqa: ARG002 - fixture modifies behavior via side effects
):
"""Ensure AggregateHybridQuery is called with alpha parameter."""
from redisvl.utils.vectorize import BaseVectorizer
# Create a mock vectorizer that inherits from BaseVectorizer
mock_vectorizer = MagicMock(spec=BaseVectorizer)
mock_vectorizer.dims = 128
mock_vectorizer.dtype = "float32"
mock_vectorizer.aembed = AsyncMock(return_value=[0.1] * 128)
mock_index.query = AsyncMock(return_value=[{"content": "test result"}])
provider = RedisContextProvider(
source_id="ctx",
user_id="u1",
redis_vectorizer=mock_vectorizer,
vector_field_name="embedding",
)
# Call _redis_search with custom alpha
with patch("agent_framework_redis._context_provider.AggregateHybridQuery") as mock_hybrid_query:
mock_hybrid_query.return_value = MagicMock()
await provider._redis_search(text="test query", alpha=0.5)
# Verify AggregateHybridQuery was called with alpha parameter
mock_hybrid_query.assert_called_once()
call_kwargs = mock_hybrid_query.call_args.kwargs
assert "alpha" in call_kwargs
assert call_kwargs["alpha"] == 0.5
# ===========================================================================
# RedisHistoryProvider tests
# ===========================================================================
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")
assert provider.source_id == "memory"
assert provider.key_prefix == "chat_messages"
assert provider.max_messages is None
assert provider.load_messages is True
assert provider.store_outputs is True
assert provider.store_inputs is True
def test_custom_params(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="custom",
max_messages=50,
load_messages=False,
store_outputs=False,
store_inputs=False,
)
assert provider.key_prefix == "custom"
assert provider.max_messages == 50
assert provider.load_messages is False
assert provider.store_outputs is False
assert provider.store_inputs is False
def test_no_redis_url_or_credential_raises(self):
with pytest.raises(ValueError, match="Either redis_url or credential_provider must be provided"):
RedisHistoryProvider("mem")
def test_both_url_and_credential_raises(self):
mock_cred = MagicMock()
with pytest.raises(ValueError, match="mutually exclusive"):
RedisHistoryProvider(
"mem",
redis_url="redis://localhost:6379",
credential_provider=mock_cred,
host="myhost",
)
def test_credential_provider_without_host_raises(self):
mock_cred = MagicMock()
with pytest.raises(ValueError, match="host is required"):
RedisHistoryProvider("mem", credential_provider=mock_cred)
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")
mock_redis_cls.assert_called_once_with(
host="myhost",
port=6380,
ssl=True,
username=None,
credential_provider=mock_cred,
decode_responses=True,
)
assert provider.redis_url is None
class TestRedisHistoryProviderRedisKey:
def test_key_format(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")
assert provider._redis_key("session-123") == "msgs:session-123"
assert provider._redis_key(None) == "msgs:default"
class TestRedisHistoryProviderGetMessages:
async def test_returns_deserialized_messages(self, mock_redis_client: MagicMock):
msg1 = Message(role="user", contents=["Hello"])
msg2 = Message(role="assistant", contents=["Hi!"])
mock_redis_client.lrange = AsyncMock(return_value=[json.dumps(msg1.to_dict()), json.dumps(msg2.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) == 2
assert messages[0].role == "user"
assert messages[0].text == "Hello"
assert messages[1].role == "assistant"
assert messages[1].text == "Hi!"
async def test_empty_returns_empty(self, mock_redis_client: MagicMock):
mock_redis_client.lrange = AsyncMock(return_value=[])
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 messages == []
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")
msgs = [Message(role="user", contents=["Hello"]), Message(role="assistant", contents=["Hi"])]
await provider.save_messages("s1", msgs)
pipeline = mock_redis_client.pipeline.return_value.__aenter__.return_value
assert pipeline.rpush.call_count == 2
pipeline.execute.assert_called_once()
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")
await provider.save_messages("s1", [])
mock_redis_client.pipeline.assert_not_called()
async def test_max_messages_trimming(self, mock_redis_client: MagicMock):
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=10)
await provider.save_messages("s1", [Message(role="user", contents=["msg"])])
mock_redis_client.ltrim.assert_called_once_with("chat_messages: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)
await provider.save_messages("s1", [Message(role="user", contents=["msg"])])
mock_redis_client.ltrim.assert_not_called()
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")
await provider.clear("session-1")
mock_redis_client.delete.assert_called_once_with("chat_messages:session-1")
class TestRedisHistoryProviderBeforeAfterRun:
"""Test before_run/after_run integration via HistoryProvider defaults."""
async def test_before_run_loads_history(self, mock_redis_client: MagicMock):
msg = Message(role="user", contents=["old msg"])
mock_redis_client.lrange = AsyncMock(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")
session = AgentSession(session_id="test")
ctx = SessionContext(input_messages=[Message(role="user", contents=["new msg"])], session_id="s1")
await provider.before_run(
agent=cast(Any, None),
session=session,
context=ctx,
state=session.state.setdefault(provider.source_id, {}),
) # type: ignore[arg-type]
assert "mem" in ctx.context_messages
assert len(ctx.context_messages["mem"]) == 1
assert ctx.context_messages["mem"][0].text == "old msg"
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")
session = AgentSession(session_id="test")
ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1")
ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hello"])])
await provider.after_run(
agent=cast(Any, None),
session=session,
context=ctx,
state=session.state.setdefault(provider.source_id, {}),
) # type: ignore[arg-type]
pipeline = mock_redis_client.pipeline.return_value.__aenter__.return_value
assert pipeline.rpush.call_count == 2
pipeline.execute.assert_called_once()
async def test_after_run_skips_when_no_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", store_inputs=False, store_outputs=False
)
session = AgentSession(session_id="test")
ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1")
await provider.after_run(
agent=cast(Any, None),
session=session,
context=ctx,
state=session.state.setdefault(provider.source_id, {}),
) # type: ignore[arg-type]
mock_redis_client.pipeline.assert_not_called()