mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: add RedisContextProvider (#716)
* Setting up * Readme * Add redis tests path to all-tests * First pass integration * Keep provider convention * First pass integration * add redis integration tests * update README.md * Add basic sample for redis integration * Add partitioning, add partition-aware tests, improve sample script * Fix code quality check * Try to resolve pytest check * Try to identify if pytest is the cause of failed checks * Re-enable tests * Rename redis test file * Removing some tests to narrow down issue * Revert, no difference * Delete temp files * Starting refactor of RedisProvider * Build dynamic schema builder, still need to do dynamic embedding model config * Add scope control * Complete first pass functionality with OpenAI + HF vectors -> Tests, Samples, Demo to follow * Fix code quality * attempt to identify rootcause of failed test * attempt to identify rootcause of failed test * Attempt to resolve code quality fail * Update pyproject.toml for foundry to pin azure-ai-projects == 1.1.0b3,azure-ai-agents == 1.2.0b3 * Add tests for redisprovider * Remove invalid tests * Add API key handling for openai vectorizer * Update uv.locl * Use master uv.lock * Begin sample file, add lazy index creation, fix faulty override * Index drop and reinit depends on drop_redis_index not overwrite * Add samples, threading included, escaped queries, verify threading works, sample README.md * Refactor filters * Opinionated vars * Allow filter expression combination * Try inline stubs for mypy * Address mypy errors * Better docstrings, tweaks for feedback * Tweak example 3 in redis_threads.py sample * adjust confusing name * Enrich docstrings * Add descriptions and comments to samples, externalize vectorizer choice, remove nltk and sentencetransformers dependnecy * Add descriptions and comments to samples, externalize vectorizer choice, remove nltk and sentencetransformers dependnecy * Incorporate initial feedback from dmytrostruk * Fix uv.lock * Attempt to resolve conflict * Use remote .tomls * Sanity check * fix tests * Remove hardcoded API key from samples * Fix incorrect env var * Make add and redis_search private * Fix tests relying on private funcs * Expand tests * Explainer comments to each test * Add a 'get_conversation_history' function to RedisProvider - This just returns messages in sequential order. Added 'created_at_*' timestamps to facilitate sequential recovery. function has to be manually invoked by user * Add agent-framework-redis to python/pyproject.toml * Remove get_conversation_history * improve redis context provider with pydantic techniques and safe index handling patterns * add RedisChatMessageStore * remove integration test :( * fix mypy error * Remove unused params * Redo schema validation to be order-invariant, handle attrs (previously throwing errors due to strict ==) * Expand explanation * Add ChatMessageStore example * Fix comments in redis_conversation.py * Resolving uv.lock conflict, update to match main * Fix test in redis provider * Apply suggestion from @ekzhu * Update python/packages/main/pyproject.toml --------- Co-authored-by: Tyler Hutcherson <tyler.hutcherson@redis.com> Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
import importlib.metadata
|
||||
|
||||
from ._chat_message_store import RedisChatMessageStore
|
||||
from ._provider import RedisProvider
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
__all__ = [
|
||||
"RedisChatMessageStore",
|
||||
"RedisProvider",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,507 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import redis.asyncio as redis
|
||||
from agent_framework import ChatMessage
|
||||
from agent_framework._pydantic import AFBaseModel
|
||||
|
||||
|
||||
class RedisStoreState(AFBaseModel):
|
||||
"""State model for serializing and deserializing Redis chat message store data."""
|
||||
|
||||
thread_id: str
|
||||
redis_url: str | None = None
|
||||
key_prefix: str = "chat_messages"
|
||||
max_messages: int | None = None
|
||||
|
||||
|
||||
class RedisChatMessageStore:
|
||||
"""Redis-backed implementation of ChatMessageStore using Redis Lists.
|
||||
|
||||
This implementation provides persistent, thread-safe chat message storage using Redis Lists.
|
||||
Messages are stored as JSON-serialized strings in chronological order, with each conversation
|
||||
thread isolated by a unique Redis key.
|
||||
|
||||
Key Features:
|
||||
============
|
||||
- **Persistent Storage**: Messages survive application restarts and crashes
|
||||
- **Thread Isolation**: Each conversation thread has its own Redis key namespace
|
||||
- **Auto Message Limits**: Configurable automatic trimming of old messages using LTRIM
|
||||
- **Performance Optimized**: Uses native Redis operations for efficiency
|
||||
- **State Serialization**: Full compatibility with Agent Framework thread serialization
|
||||
- **Initial Message Support**: Pre-load conversations with existing message history
|
||||
- **Production Ready**: Atomic operations, error handling, connection pooling
|
||||
|
||||
Redis Operations:
|
||||
- RPUSH: Add messages to the end of the list (chronological order)
|
||||
- LRANGE: Retrieve messages in chronological order
|
||||
- LTRIM: Maintain message limits by trimming old messages
|
||||
- DELETE: Clear all messages for a thread
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis_url: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
key_prefix: str = "chat_messages",
|
||||
max_messages: int | None = None,
|
||||
messages: Sequence[ChatMessage] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the Redis chat message store.
|
||||
|
||||
Creates a Redis-backed chat message store for a specific conversation thread.
|
||||
The store will automatically create a Redis connection and manage message
|
||||
persistence using Redis List operations.
|
||||
|
||||
Args:
|
||||
redis_url: Redis connection URL (e.g., "redis://localhost:6379").
|
||||
Required for establishing Redis connection.
|
||||
thread_id: Unique identifier for this conversation thread.
|
||||
If not provided, a UUID will be auto-generated.
|
||||
This becomes part of the Redis key: {key_prefix}:{thread_id}
|
||||
key_prefix: Prefix for Redis keys to namespace different applications.
|
||||
Defaults to 'chat_messages'. Useful for multi-tenant scenarios.
|
||||
max_messages: Maximum number of messages to retain in Redis.
|
||||
When exceeded, oldest messages are automatically trimmed using LTRIM.
|
||||
None means unlimited storage.
|
||||
messages: Initial messages to pre-populate the conversation.
|
||||
These are added to Redis on first access if the Redis key is empty.
|
||||
Useful for resuming conversations or seeding with context.
|
||||
|
||||
Raises:
|
||||
ValueError: If redis_url is None (Redis connection is required).
|
||||
redis.ConnectionError: If unable to connect to Redis server.
|
||||
|
||||
|
||||
"""
|
||||
# Validate required parameters
|
||||
if redis_url is None:
|
||||
raise ValueError("redis_url is required for Redis connection")
|
||||
|
||||
# Store configuration
|
||||
self.redis_url = redis_url
|
||||
self.thread_id = thread_id or f"thread_{uuid4()}"
|
||||
self.key_prefix = key_prefix
|
||||
self.max_messages = max_messages
|
||||
|
||||
# Initialize Redis client with connection pooling and async support
|
||||
self._redis_client = redis.from_url(redis_url, decode_responses=True) # type: ignore[no-untyped-call]
|
||||
|
||||
# Handle initial messages (will be moved to Redis on first access)
|
||||
self._initial_messages = list(messages) if messages else []
|
||||
self._initial_messages_added = False
|
||||
|
||||
@property
|
||||
def redis_key(self) -> str:
|
||||
"""Get the Redis key for this thread's messages.
|
||||
|
||||
The key format is: {key_prefix}:{thread_id}
|
||||
|
||||
Returns:
|
||||
Redis key string used for storing this thread's messages.
|
||||
|
||||
Example:
|
||||
For key_prefix="chat_messages" and thread_id="user_123_session_456":
|
||||
Returns "chat_messages:user_123_session_456"
|
||||
"""
|
||||
return f"{self.key_prefix}:{self.thread_id}"
|
||||
|
||||
async def _ensure_initial_messages_added(self) -> None:
|
||||
"""Ensure initial messages are added to Redis if not already present.
|
||||
|
||||
This method is called before any Redis operations to guarantee that
|
||||
initial messages provided during construction are persisted to Redis.
|
||||
"""
|
||||
if not self._initial_messages or self._initial_messages_added:
|
||||
return
|
||||
|
||||
# Check if Redis key already has messages (prevents duplicate additions)
|
||||
existing_count = await self._redis_client.llen(self.redis_key) # type: ignore[misc] # type: ignore[misc]
|
||||
if existing_count == 0:
|
||||
# Add initial messages using atomic pipeline operation
|
||||
await self._add_redis_messages(self._initial_messages)
|
||||
|
||||
# Mark as completed and free memory
|
||||
self._initial_messages_added = True
|
||||
self._initial_messages.clear()
|
||||
|
||||
async def _add_redis_messages(self, messages: Sequence[ChatMessage]) -> None:
|
||||
"""Add multiple messages to Redis using atomic pipeline operation.
|
||||
|
||||
This internal method efficiently adds multiple messages to the Redis list
|
||||
using a single atomic transaction to ensure consistency.
|
||||
|
||||
Args:
|
||||
messages: Sequence of ChatMessage objects to add to Redis.
|
||||
"""
|
||||
if not messages:
|
||||
return
|
||||
|
||||
# Pre-serialize all messages for efficient pipeline operation
|
||||
serialized_messages = [self._serialize_message(message) for message in messages]
|
||||
|
||||
# Use Redis pipeline for atomic batch operation
|
||||
async with self._redis_client.pipeline(transaction=True) as pipe:
|
||||
for serialized_message in serialized_messages:
|
||||
await pipe.rpush(self.redis_key, serialized_message) # type: ignore[misc]
|
||||
await pipe.execute()
|
||||
|
||||
async def add_messages(self, messages: Sequence[ChatMessage]) -> None:
|
||||
"""Add messages to the Redis store (ChatMessageStore protocol method).
|
||||
|
||||
This method implements the required ChatMessageStore protocol for adding messages.
|
||||
Messages are appended to the Redis list in chronological order, with automatic
|
||||
trimming if message limits are configured.
|
||||
|
||||
Args:
|
||||
messages: Sequence of ChatMessage objects to add to the store.
|
||||
Can be empty (no-op) or contain multiple messages.
|
||||
|
||||
Thread Safety:
|
||||
- Atomic pipeline ensures all messages are added together
|
||||
- LTRIM operation is atomic for consistent message limits
|
||||
|
||||
Example:
|
||||
```python
|
||||
messages = [ChatMessage(role=Role.USER, text="Hello"), ChatMessage(role=Role.ASSISTANT, text="Hi there!")]
|
||||
await store.add_messages(messages)
|
||||
```
|
||||
"""
|
||||
if not messages:
|
||||
return
|
||||
|
||||
# Ensure any initial messages are persisted first
|
||||
await self._ensure_initial_messages_added()
|
||||
|
||||
# Add new messages using atomic pipeline operation
|
||||
await self._add_redis_messages(messages)
|
||||
|
||||
# Apply message limit if configured (automatic cleanup)
|
||||
if self.max_messages is not None:
|
||||
current_count = await self._redis_client.llen(self.redis_key) # type: ignore[misc]
|
||||
if current_count > self.max_messages:
|
||||
# Keep only the most recent max_messages using LTRIM
|
||||
await self._redis_client.ltrim(self.redis_key, -self.max_messages, -1) # type: ignore[misc]
|
||||
|
||||
async def list_messages(self) -> list[ChatMessage]:
|
||||
"""Get all messages from the store in chronological order (ChatMessageStore protocol method).
|
||||
|
||||
This method implements the required ChatMessageStore protocol for retrieving messages.
|
||||
Returns all messages stored in Redis, ordered from oldest (index 0) to newest (index -1).
|
||||
|
||||
Returns:
|
||||
List of ChatMessage objects in chronological order (oldest first).
|
||||
Returns empty list if no messages exist or if Redis connection fails.
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Get all conversation history
|
||||
messages = await store.list_messages()
|
||||
```
|
||||
"""
|
||||
# Ensure any initial messages are persisted to Redis first
|
||||
await self._ensure_initial_messages_added()
|
||||
|
||||
messages = []
|
||||
# Retrieve all messages from Redis list (oldest to newest)
|
||||
redis_messages = await self._redis_client.lrange(self.redis_key, 0, -1) # type: ignore[misc]
|
||||
|
||||
if redis_messages:
|
||||
for serialized_message in redis_messages:
|
||||
# Deserialize each JSON message back to ChatMessage
|
||||
message = self._deserialize_message(serialized_message)
|
||||
messages.append(message)
|
||||
|
||||
return messages
|
||||
|
||||
async def serialize_state(self, **kwargs: Any) -> Any:
|
||||
"""Serialize the current store state for persistence (ChatMessageStore protocol method).
|
||||
|
||||
This method implements the required ChatMessageStore protocol for state serialization.
|
||||
Captures the Redis connection configuration and thread information needed to
|
||||
reconstruct the store and reconnect to the same conversation data.
|
||||
|
||||
Args:
|
||||
**kwargs: Additional arguments passed to Pydantic model_dump() for serialization.
|
||||
Common options: exclude_none=True, by_alias=True
|
||||
|
||||
Returns:
|
||||
Dictionary containing serialized store configuration that can be persisted
|
||||
to databases, files, or other storage mechanisms.
|
||||
"""
|
||||
state = RedisStoreState(
|
||||
thread_id=self.thread_id,
|
||||
redis_url=self.redis_url,
|
||||
key_prefix=self.key_prefix,
|
||||
max_messages=self.max_messages,
|
||||
)
|
||||
return state.model_dump(**kwargs)
|
||||
|
||||
async def deserialize_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
|
||||
"""Deserialize state data into this store instance (ChatMessageStore protocol method).
|
||||
|
||||
This method implements the required ChatMessageStore protocol for state deserialization.
|
||||
Restores the store configuration from previously serialized data, allowing the store
|
||||
to reconnect to the same conversation data in Redis.
|
||||
|
||||
Args:
|
||||
serialized_store_state: Previously serialized state data from serialize_state().
|
||||
Should be a dictionary with thread_id, redis_url, etc.
|
||||
**kwargs: Additional arguments passed to Pydantic model validation.
|
||||
"""
|
||||
if not serialized_store_state:
|
||||
return
|
||||
|
||||
# Validate and parse the serialized state using Pydantic
|
||||
state = RedisStoreState.model_validate(serialized_store_state, **kwargs)
|
||||
|
||||
# Update store configuration from deserialized state
|
||||
self.thread_id = state.thread_id
|
||||
if state.redis_url is not None:
|
||||
self.redis_url = state.redis_url
|
||||
self.key_prefix = state.key_prefix
|
||||
self.max_messages = state.max_messages
|
||||
|
||||
# Recreate Redis client if the URL changed
|
||||
if state.redis_url and state.redis_url != getattr(self, "_last_redis_url", None):
|
||||
self._redis_client = redis.from_url(state.redis_url, decode_responses=True) # type: ignore[no-untyped-call]
|
||||
self._last_redis_url = state.redis_url
|
||||
|
||||
# Reset initial message state since we're connecting to existing data
|
||||
self._initial_messages_added = False
|
||||
|
||||
async def clear(self) -> None:
|
||||
"""Remove all messages from the store.
|
||||
|
||||
Permanently deletes all messages for this conversation thread by removing
|
||||
the Redis key. This operation cannot be undone.
|
||||
|
||||
Warning:
|
||||
- This permanently deletes all conversation history
|
||||
- Consider exporting messages before clearing if backup is needed
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Clear conversation history
|
||||
await store.clear()
|
||||
|
||||
# Verify messages are gone
|
||||
messages = await store.list_messages()
|
||||
assert len(messages) == 0
|
||||
```
|
||||
"""
|
||||
await self._redis_client.delete(self.redis_key)
|
||||
|
||||
def _serialize_message(self, message: ChatMessage) -> str:
|
||||
"""Serialize a ChatMessage to JSON string.
|
||||
|
||||
Args:
|
||||
message: ChatMessage to serialize.
|
||||
|
||||
Returns:
|
||||
JSON string representation of the message.
|
||||
"""
|
||||
# Convert ChatMessage to dictionary using Pydantic serialization
|
||||
message_dict = message.model_dump()
|
||||
# Serialize to compact JSON (no extra whitespace for Redis efficiency)
|
||||
return json.dumps(message_dict, separators=(",", ":"))
|
||||
|
||||
def _deserialize_message(self, serialized_message: str) -> ChatMessage:
|
||||
"""Deserialize a JSON string to ChatMessage.
|
||||
|
||||
Args:
|
||||
serialized_message: JSON string representation of a message.
|
||||
|
||||
Returns:
|
||||
ChatMessage object.
|
||||
"""
|
||||
# Parse JSON string back to dictionary
|
||||
message_dict = json.loads(serialized_message)
|
||||
# Reconstruct ChatMessage using Pydantic validation
|
||||
return ChatMessage.model_validate(message_dict)
|
||||
|
||||
# ============================================================================
|
||||
# List-like Convenience Methods (Redis-optimized async versions)
|
||||
# ============================================================================
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""Return True since the store always exists once created.
|
||||
|
||||
This method is called by Python's truthiness checks (if store:).
|
||||
Since a RedisChatMessageStore instance always represents a valid store,
|
||||
this always returns True.
|
||||
|
||||
Returns:
|
||||
Always True - the store exists and is ready for operations.
|
||||
|
||||
Note:
|
||||
This is used by the Agent Framework to check if a message store
|
||||
is configured: `if thread.message_store:`
|
||||
"""
|
||||
return True
|
||||
|
||||
async def __len__(self) -> int:
|
||||
"""Return the number of messages in the Redis store.
|
||||
|
||||
Provides efficient message counting using Redis LLEN command.
|
||||
This is the async equivalent of Python's built-in len() function.
|
||||
|
||||
Returns:
|
||||
The count of messages currently stored in Redis.
|
||||
"""
|
||||
await self._ensure_initial_messages_added()
|
||||
return await self._redis_client.llen(self.redis_key) # type: ignore[misc,no-any-return]
|
||||
|
||||
async def getitem(self, index: int) -> ChatMessage:
|
||||
"""Get a message by index using Redis LINDEX.
|
||||
|
||||
Args:
|
||||
index: The index of the message to retrieve.
|
||||
|
||||
Returns:
|
||||
The ChatMessage at the specified index.
|
||||
|
||||
Raises:
|
||||
IndexError: If the index is out of range.
|
||||
"""
|
||||
await self._ensure_initial_messages_added()
|
||||
|
||||
# Use Redis LINDEX for efficient single-item access
|
||||
serialized_message = await self._redis_client.lindex(self.redis_key, index) # type: ignore[misc]
|
||||
if serialized_message is None:
|
||||
raise IndexError("list index out of range")
|
||||
|
||||
return self._deserialize_message(serialized_message)
|
||||
|
||||
async def setitem(self, index: int, item: ChatMessage) -> None:
|
||||
"""Set a message at the specified index using Redis LSET.
|
||||
|
||||
Args:
|
||||
index: The index at which to set the message.
|
||||
item: The ChatMessage to set at the specified index.
|
||||
|
||||
Raises:
|
||||
IndexError: If the index is out of range.
|
||||
"""
|
||||
await self._ensure_initial_messages_added()
|
||||
|
||||
# Validate index exists using LLEN
|
||||
current_count = await self._redis_client.llen(self.redis_key) # type: ignore[misc]
|
||||
if index < 0:
|
||||
index = current_count + index
|
||||
if index < 0 or index >= current_count:
|
||||
raise IndexError("list index out of range")
|
||||
|
||||
# Use Redis LSET for efficient single-item update
|
||||
serialized_message = self._serialize_message(item)
|
||||
await self._redis_client.lset(self.redis_key, index, serialized_message) # type: ignore[misc]
|
||||
|
||||
async def append(self, item: ChatMessage) -> None:
|
||||
"""Append a message to the end of the store.
|
||||
|
||||
Args:
|
||||
item: The ChatMessage to append.
|
||||
"""
|
||||
await self.add_messages([item])
|
||||
|
||||
async def count(self) -> int:
|
||||
"""Return the number of messages in the Redis store.
|
||||
|
||||
Returns:
|
||||
The count of messages currently stored in Redis.
|
||||
"""
|
||||
await self._ensure_initial_messages_added()
|
||||
return await self._redis_client.llen(self.redis_key) # type: ignore[misc,no-any-return]
|
||||
|
||||
async def index(self, item: ChatMessage) -> int:
|
||||
"""Return the index of the first occurrence of the specified message.
|
||||
|
||||
Uses Redis LINDEX to iterate through the list without loading all messages.
|
||||
Still O(N) but more memory efficient for large lists.
|
||||
|
||||
Args:
|
||||
item: The ChatMessage to find.
|
||||
|
||||
Returns:
|
||||
The index of the first occurrence of the message.
|
||||
|
||||
Raises:
|
||||
ValueError: If the message is not found in the store.
|
||||
"""
|
||||
await self._ensure_initial_messages_added()
|
||||
|
||||
target_serialized = self._serialize_message(item)
|
||||
list_length = await self._redis_client.llen(self.redis_key) # type: ignore[misc]
|
||||
|
||||
# Iterate through Redis list using LINDEX
|
||||
for i in range(list_length):
|
||||
redis_message = await self._redis_client.lindex(self.redis_key, i) # type: ignore[misc]
|
||||
if redis_message == target_serialized:
|
||||
return i
|
||||
|
||||
raise ValueError("ChatMessage not found in store")
|
||||
|
||||
async def remove(self, item: ChatMessage) -> None:
|
||||
"""Remove the first occurrence of the specified message from the store.
|
||||
|
||||
Uses Redis LREM command for efficient removal by value.
|
||||
O(N) but performed natively in Redis without data transfer.
|
||||
|
||||
Args:
|
||||
item: The ChatMessage to remove.
|
||||
|
||||
Raises:
|
||||
ValueError: If the message is not found in the store.
|
||||
"""
|
||||
await self._ensure_initial_messages_added()
|
||||
|
||||
# Serialize the message to match Redis storage format
|
||||
target_serialized = self._serialize_message(item)
|
||||
|
||||
# Use LREM to remove first occurrence (count=1)
|
||||
removed_count = await self._redis_client.lrem(self.redis_key, 1, target_serialized) # type: ignore[misc]
|
||||
|
||||
if removed_count == 0:
|
||||
raise ValueError("ChatMessage not found in store")
|
||||
|
||||
async def extend(self, items: Sequence[ChatMessage]) -> None:
|
||||
"""Extend the store by appending all messages from the iterable.
|
||||
|
||||
Args:
|
||||
items: Sequence of ChatMessage objects to append.
|
||||
"""
|
||||
await self.add_messages(items)
|
||||
|
||||
async def ping(self) -> bool:
|
||||
"""Test the Redis connection.
|
||||
|
||||
Returns:
|
||||
True if the connection is successful, False otherwise.
|
||||
"""
|
||||
try:
|
||||
await self._redis_client.ping() # type: ignore[misc]
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close the Redis connection.
|
||||
|
||||
This method provides a clean way to close the underlying Redis connection
|
||||
when the store is no longer needed. This is particularly useful in samples
|
||||
and applications where explicit resource cleanup is desired.
|
||||
"""
|
||||
await self._redis_client.aclose() # type: ignore[misc]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the store."""
|
||||
return (
|
||||
f"RedisChatMessageStore(thread_id='{self.thread_id}', "
|
||||
f"redis_key='{self.redis_key}', max_messages={self.max_messages})"
|
||||
)
|
||||
@@ -0,0 +1,547 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import MutableSequence, Sequence
|
||||
from functools import reduce
|
||||
from operator import and_
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from agent_framework import ChatMessage, Context, ContextProvider, Role, TextContent
|
||||
from agent_framework.exceptions import (
|
||||
ServiceInitializationError,
|
||||
ServiceInvalidRequestError,
|
||||
)
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import Self # pragma: no cover
|
||||
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
from redisvl.index import AsyncSearchIndex
|
||||
from redisvl.query import FilterQuery, HybridQuery, TextQuery
|
||||
from redisvl.query.filter import FilterExpression, Tag
|
||||
from redisvl.utils.token_escaper import TokenEscaper
|
||||
from redisvl.utils.vectorize import BaseVectorizer
|
||||
|
||||
|
||||
class RedisProvider(ContextProvider):
|
||||
"""Redis context provider with dynamic, filterable schema.
|
||||
|
||||
Stores context in Redis and retrieves scoped context.
|
||||
Uses full-text or optional hybrid vector search to ground model responses.
|
||||
"""
|
||||
|
||||
# Connection and indexing
|
||||
redis_url: str = "redis://localhost:6379"
|
||||
index_name: str = "context"
|
||||
prefix: str = "context"
|
||||
|
||||
# Redis vectorizer configuration (optional, injected by client)
|
||||
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
|
||||
|
||||
# Partition fields (indexed for filtering)
|
||||
application_id: str | None = None
|
||||
agent_id: str | None = None
|
||||
user_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
scope_to_per_operation_thread_id: bool = False
|
||||
|
||||
# Prompt and runtime
|
||||
context_prompt: str = ContextProvider.DEFAULT_CONTEXT_PROMPT
|
||||
redis_index: Any = None
|
||||
overwrite_index: bool = False
|
||||
_per_operation_thread_id: str | None = None
|
||||
_token_escaper: TokenEscaper = TokenEscaper()
|
||||
_conversation_id: str | None = None
|
||||
_index_initialized: bool = False
|
||||
_schema_dict: dict[str, Any] | None = None
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Post-initialization hook to set up computed fields after Pydantic initialization.
|
||||
|
||||
This is called automatically by Pydantic after the model is initialized.
|
||||
"""
|
||||
# Create Redis index using the cached schema_dict property
|
||||
self.redis_index = AsyncSearchIndex.from_dict(self.schema_dict, redis_url=self.redis_url, validate_on_load=True)
|
||||
|
||||
@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:
|
||||
# Get vector configuration from vectorizer if available
|
||||
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.
|
||||
|
||||
This ANDs non-empty tag filters and is used to scope all operations to app/agent/user/thread partitions.
|
||||
|
||||
Args:
|
||||
filters: Mapping of field name to value; falsy values are ignored.
|
||||
|
||||
Returns:
|
||||
A combined filter expression or None if no filters are provided.
|
||||
"""
|
||||
parts = [Tag(k) == v for k, v in filters.items() if v]
|
||||
return reduce(and_, parts) if parts else None
|
||||
|
||||
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.
|
||||
|
||||
Defines text and tag fields for messages plus an optional vector field enabling KNN/hybrid search.
|
||||
|
||||
Args:
|
||||
index_name: Index name.
|
||||
prefix: Key prefix.
|
||||
vector_field_name: Vector field name or None.
|
||||
vector_dims: Vector dimensionality or None.
|
||||
vector_datatype: Vector datatype or None.
|
||||
vector_algorithm: Vector index algorithm or None.
|
||||
vector_distance_metric: Vector distance metric or None.
|
||||
|
||||
Returns:
|
||||
Dict representing the index and fields configuration.
|
||||
"""
|
||||
fields: list[dict[str, Any]] = [
|
||||
{"name": "role", "type": "tag"},
|
||||
{"name": "mime_type", "type": "tag"},
|
||||
{"name": "content", "type": "text"},
|
||||
# Conversation tracking
|
||||
{"name": "conversation_id", "type": "tag"},
|
||||
{"name": "message_id", "type": "tag"},
|
||||
{"name": "author_name", "type": "tag"},
|
||||
# Partition fields (TAG for fast filtering)
|
||||
{"name": "application_id", "type": "tag"},
|
||||
{"name": "agent_id", "type": "tag"},
|
||||
{"name": "user_id", "type": "tag"},
|
||||
{"name": "thread_id", "type": "tag"},
|
||||
]
|
||||
|
||||
# Add vector field only if configured (keeps provider runnable with no params)
|
||||
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.
|
||||
|
||||
- Connect to existing index if it exists and schema matches
|
||||
- Create new index if it doesn't exist
|
||||
- Overwrite if requested via overwrite_index=True
|
||||
- Validate schema compatibility to prevent accidental data loss
|
||||
"""
|
||||
if self._index_initialized:
|
||||
return
|
||||
|
||||
# Check if index already exists
|
||||
index_exists = await self.redis_index.exists()
|
||||
|
||||
if not self.overwrite_index and index_exists:
|
||||
# Validate schema compatibility before connecting
|
||||
await self._validate_schema_compatibility()
|
||||
|
||||
# Create the index (will connect to existing or create new)
|
||||
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.
|
||||
|
||||
Raises ServiceInitializationError if schemas don't match, with helpful guidance.
|
||||
|
||||
self._build_schema_dict returns a minimal schema while Redis returns an expanded
|
||||
schema with all defaults filled in. To compare for incompatibilities, compare
|
||||
significant parts of the schema by creating signatures with normalized default values.
|
||||
"""
|
||||
# Defaults for attr normalization
|
||||
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 {})}
|
||||
# Require these to exist if vector field is present
|
||||
return {k: a.get(k) for k in ("algorithm", "dims", "distance_metric", "datatype")}
|
||||
|
||||
def _schema_signature(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
# Order-independent, minimal signature
|
||||
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:
|
||||
# Unknown field types: compare by type only
|
||||
sig["fields"][name] = {"type": ftype}
|
||||
return sig
|
||||
|
||||
existing_index = await AsyncSearchIndex.from_existing(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:
|
||||
# Add sigs to error message
|
||||
raise ServiceInitializationError(
|
||||
"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]],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Inserts one or many documents with partition fields populated.
|
||||
|
||||
Fills default partition fields, optionally embeds content when configured, and loads documents in a batch.
|
||||
|
||||
Args:
|
||||
data: Single document or list of documents to insert.
|
||||
metadata: Optional metadata dictionary (unused placeholder).
|
||||
|
||||
Raises:
|
||||
ServiceInvalidRequestError: If required fields are missing or invalid.
|
||||
"""
|
||||
# Ensure provider has at least one scope set (symmetry with Mem0Provider)
|
||||
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) # shallow copy
|
||||
|
||||
# Partition defaults
|
||||
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", self._effective_thread_id)
|
||||
# Conversation defaults
|
||||
d.setdefault("conversation_id", self._conversation_id)
|
||||
|
||||
# Logical requirement
|
||||
if "content" not in d:
|
||||
raise ServiceInvalidRequestError("add() requires a 'content' field in data")
|
||||
|
||||
# Vector field requirement (only if schema has one)
|
||||
if self.vector_field_name:
|
||||
d.setdefault(self.vector_field_name, None)
|
||||
|
||||
prepared.append(d)
|
||||
|
||||
# Batch embed contents for every message
|
||||
if self.redis_vectorizer and self.vector_field_name:
|
||||
text_list = [d["content"] for d in prepared]
|
||||
embeddings = await self.redis_vectorizer.aembed_many(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
|
||||
|
||||
# Load all at once if supported
|
||||
await self.redis_index.load(prepared)
|
||||
return
|
||||
|
||||
async def _redis_search(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
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.
|
||||
|
||||
Builds a TextQuery or HybridQuery and automatically ANDs partition filters to keep results scoped and safe.
|
||||
|
||||
Args:
|
||||
text: Query text.
|
||||
text_scorer: Scorer to use for text ranking.
|
||||
filter_expression: Additional filter expression to AND with partition filters.
|
||||
return_fields: Fields to return in results.
|
||||
num_results: Maximum number of results.
|
||||
alpha: Hybrid balancing parameter when vectors are enabled.
|
||||
|
||||
Returns:
|
||||
List of result dictionaries.
|
||||
|
||||
Raises:
|
||||
ServiceInvalidRequestError: If input is invalid or the query fails.
|
||||
"""
|
||||
# Enforce presence of at least one provider-level filter (symmetry with Mem0Provider)
|
||||
await self._ensure_index()
|
||||
self._validate_filters()
|
||||
|
||||
q = (text or "").strip()
|
||||
if not q:
|
||||
raise ServiceInvalidRequestError("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": self._effective_thread_id,
|
||||
"conversation_id": self._conversation_id,
|
||||
})
|
||||
|
||||
if filter_expression is not None:
|
||||
combined_filter = (combined_filter & filter_expression) if combined_filter else filter_expression
|
||||
|
||||
# Choose return fields
|
||||
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:
|
||||
# Build hybrid query: combine full-text and vector similarity
|
||||
vector = await self.redis_vectorizer.aembed(q)
|
||||
query = HybridQuery(
|
||||
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,
|
||||
)
|
||||
hybrid_results = await self.redis_index.query(query)
|
||||
return cast(list[dict[str, Any]], hybrid_results)
|
||||
# Text-only search
|
||||
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,
|
||||
)
|
||||
text_results = await self.redis_index.query(query)
|
||||
return cast(list[dict[str, Any]], text_results)
|
||||
except Exception as exc: # pragma: no cover - surface as framework error
|
||||
raise ServiceInvalidRequestError(f"Redis text search failed: {exc}") from exc
|
||||
|
||||
async def search_all(self, page_size: int = 200) -> list[dict[str, Any]]:
|
||||
"""Returns all documents in the index.
|
||||
|
||||
Streams results via pagination to avoid excessive memory and response sizes.
|
||||
|
||||
Args:
|
||||
page_size: Page size used for pagination under the hood.
|
||||
|
||||
Returns:
|
||||
List of all documents.
|
||||
"""
|
||||
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
|
||||
|
||||
@property
|
||||
def _effective_thread_id(self) -> str | None:
|
||||
"""Resolves the active thread id.
|
||||
|
||||
Returns per-operation thread id when scoping is enabled; otherwise the provider's thread id.
|
||||
"""
|
||||
return self._per_operation_thread_id if self.scope_to_per_operation_thread_id else self.thread_id
|
||||
|
||||
async def thread_created(self, thread_id: str | None) -> None:
|
||||
"""Called when a new thread is created.
|
||||
|
||||
Captures the per-operation thread id when scoping is enabled to enforce single-thread usage.
|
||||
|
||||
Args:
|
||||
thread_id: The ID of the thread or None.
|
||||
"""
|
||||
self._validate_per_operation_thread_id(thread_id)
|
||||
self._per_operation_thread_id = self._per_operation_thread_id or thread_id
|
||||
# Track current conversation id (Agent passes conversation_id here)
|
||||
self._conversation_id = thread_id or self._conversation_id
|
||||
|
||||
async def messages_adding(self, thread_id: str | None, new_messages: ChatMessage | Sequence[ChatMessage]) -> None:
|
||||
"""Called when a new message is being added to the thread.
|
||||
|
||||
Validates scope, normalizes allowed roles, and persists messages to Redis via add().
|
||||
|
||||
Args:
|
||||
thread_id: The ID of the thread or None.
|
||||
new_messages: New messages to add.
|
||||
"""
|
||||
self._validate_filters()
|
||||
self._validate_per_operation_thread_id(thread_id)
|
||||
self._per_operation_thread_id = self._per_operation_thread_id or thread_id
|
||||
|
||||
messages_list = [new_messages] if isinstance(new_messages, ChatMessage) else list(new_messages)
|
||||
|
||||
messages: list[dict[str, Any]] = []
|
||||
for message in messages_list:
|
||||
if (
|
||||
message.role.value in {Role.USER.value, Role.ASSISTANT.value, Role.SYSTEM.value}
|
||||
and message.text
|
||||
and message.text.strip()
|
||||
):
|
||||
shaped: dict[str, Any] = {
|
||||
"role": message.role.value,
|
||||
"content": message.text,
|
||||
"conversation_id": self._conversation_id,
|
||||
"message_id": message.message_id,
|
||||
"author_name": message.author_name,
|
||||
}
|
||||
messages.append(shaped)
|
||||
if messages:
|
||||
await self._add(data=messages)
|
||||
|
||||
async def model_invoking(self, messages: ChatMessage | MutableSequence[ChatMessage]) -> Context:
|
||||
"""Called before invoking the model to provide scoped context.
|
||||
|
||||
Concatenates recent messages into a query, fetches matching memories from Redis.
|
||||
Prepends them as instructions.
|
||||
|
||||
Args:
|
||||
messages: List of new messages in the thread.
|
||||
|
||||
Returns:
|
||||
Context: Context object containing instructions with memories.
|
||||
"""
|
||||
self._validate_filters()
|
||||
messages_list = [messages] if isinstance(messages, ChatMessage) else list(messages)
|
||||
input_text = "\n".join(msg.text for msg in messages_list if msg and msg.text and msg.text.strip())
|
||||
|
||||
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")
|
||||
)
|
||||
content = TextContent(f"{self.context_prompt}\n{line_separated_memories}") if line_separated_memories else None
|
||||
return Context(contents=[content] if content else None)
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Async context manager entry.
|
||||
|
||||
No special setup is required; provided for symmetry with the Mem0 provider.
|
||||
"""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
|
||||
"""Async context manager exit.
|
||||
|
||||
No cleanup is required; indexes/keys remain unless explicitly cleared.
|
||||
"""
|
||||
return
|
||||
|
||||
def _validate_filters(self) -> None:
|
||||
"""Validates that at least one filter is provided.
|
||||
|
||||
Prevents unbounded operations by requiring a partition filter before reads or writes.
|
||||
|
||||
Raises:
|
||||
ServiceInitializationError: If no filters are provided.
|
||||
"""
|
||||
if not self.agent_id and not self.user_id and not self.application_id and not self.thread_id:
|
||||
raise ServiceInitializationError(
|
||||
"At least one of the filters: agent_id, user_id, application_id, or thread_id is required."
|
||||
)
|
||||
|
||||
def _validate_per_operation_thread_id(self, thread_id: str | None) -> None:
|
||||
"""Validates that a new thread ID doesn't conflict when scoped.
|
||||
|
||||
Prevents cross-thread data leakage by enforcing single-thread usage when per-operation scoping is enabled.
|
||||
|
||||
Args:
|
||||
thread_id: The new thread ID or None.
|
||||
|
||||
Raises:
|
||||
ValueError: If a new thread ID conflicts with the existing one.
|
||||
"""
|
||||
if (
|
||||
self.scope_to_per_operation_thread_id
|
||||
and thread_id
|
||||
and self._per_operation_thread_id
|
||||
and thread_id != self._per_operation_thread_id
|
||||
):
|
||||
raise ValueError(
|
||||
"RedisProvider can only be used with one thread, when scope_to_per_operation_thread_id is True."
|
||||
)
|
||||
Reference in New Issue
Block a user