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
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:
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user