Python: PR1 — New session and context provider types (side-by-side) (#3763)

* PR1: Add core context provider types and tests

New types in _sessions.py (no changes to existing code):
- SessionContext: per-invocation state with extend_messages/get_messages/
  extend_instructions/extend_tools and read-only response property
- _ContextProviderBase: base class with before_run/after_run hooks
- _HistoryProviderBase: storage base with load/store flags, abstract
  get_messages/save_messages, default before_run/after_run
- AgentSession: lightweight session with state dict, to_dict/from_dict
- InMemoryHistoryProvider: built-in provider storing in session.state

35 unit tests covering all classes and configuration flags.

* feat: keyword-only params, stateless InMemoryHistoryProvider, deep serialization

- Make before_run/after_run parameters keyword-only
- InMemoryHistoryProvider stores ChatMessage objects directly (no per-cycle serialization)
- Deep serialization via to_dict/from_dict only at session boundary
- State type registry for automatic deserialization of registered types
- Updated tests for new serialization approach

* feat: add new-pattern provider implementations for external packages

- _RedisContextProvider(BaseContextProvider) - Redis search/vector context
- _RedisHistoryProvider(BaseHistoryProvider) - Redis-backed message storage
- _Mem0ContextProvider(BaseContextProvider) - Mem0 semantic memory
- _AzureAISearchContextProvider(BaseContextProvider) - Azure AI Search (semantic + agentic)

All use temporary _ prefix names for side-by-side coexistence with existing providers.
Will be renamed in PR2 when old ContextProvider/ChatMessageStore are removed.

* test: add tests for new-pattern provider implementations

- 32 tests for _RedisContextProvider and _RedisHistoryProvider
- 29 tests for _Mem0ContextProvider
- 17 tests for _AzureAISearchContextProvider

* fix: address PR review comments and CI failures

- Move module docstring before imports in _sessions.py (review comment)
- Import TYPE_CHECKING unconditionally in Redis _context_provider.py (NameError on Python <3.12)
- Fix Mem0 test_init_auto_creates_client_when_none to patch at class level

* feat: add source attribution to extend_messages

Set attribution marker in additional_properties for each message
added via extend_messages(), matching the tool attribution pattern.
Uses setdefault to preserve any existing attribution.

* refactor: make attribution value a dict with source_id key

* add attribution and use sets for filters

* Add source_type to message attribution and copy messages in extend_messages

- SessionContext.extend_messages now accepts source as str or object with
  source_id attribute; when an object is passed, its class name is recorded
  as source_type in the attribution dict
- Messages are shallow-copied before attribution is added so callers'
  original objects are never mutated
- Filter framework-internal keys (attribution) from A2A wire metadata
  to prevent leaking internal state over the wire

* fix: correct mypy type: ignore comment from union-attr to attr-defined

* set attribution to _attribution

* adjusted naming of bools
This commit is contained in:
Eduard van Valkenburg
2026-02-10 22:19:15 +01:00
committed by GitHub
Unverified
parent ccff3d3452
commit ac0e6b0ee1
13 changed files with 3494 additions and 2 deletions
@@ -7,7 +7,7 @@ import json
import re
import uuid
from collections.abc import AsyncIterable, Awaitable, Sequence
from typing import Any, Final, Literal, cast, overload
from typing import Any, Final, Literal, overload
import httpx
from a2a.client import Client, ClientConfig, ClientFactory, minimal_agent_card
@@ -451,11 +451,15 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
case _:
raise ValueError(f"Unknown content type: {content.type}")
# Exclude framework-internal keys (e.g. attribution) from wire metadata
internal_keys = {"_attribution"}
metadata = {k: v for k, v in message.additional_properties.items() if k not in internal_keys} or None
return A2AMessage(
role=A2ARole("user"),
parts=parts,
message_id=message.message_id or uuid.uuid4().hex,
metadata=cast(dict[str, Any], message.additional_properties),
metadata=metadata,
)
def _parse_contents_from_a2a(self, parts: Sequence[A2APart]) -> list[Content]:
@@ -2,6 +2,7 @@
import importlib.metadata
from ._context_provider import _AzureAISearchContextProvider
from ._search_provider import AzureAISearchContextProvider, AzureAISearchSettings
try:
@@ -12,5 +13,6 @@ except importlib.metadata.PackageNotFoundError:
__all__ = [
"AzureAISearchContextProvider",
"AzureAISearchSettings",
"_AzureAISearchContextProvider",
"__version__",
]
@@ -0,0 +1,625 @@
# Copyright (c) Microsoft. All rights reserved.
"""New-pattern Azure AI Search context provider using BaseContextProvider.
This module provides ``_AzureAISearchContextProvider``, a side-by-side implementation of
:class:`AzureAISearchContextProvider` built on the new :class:`BaseContextProvider` hooks
pattern. It will replace the existing class in PR2.
"""
from __future__ import annotations
import sys
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any, ClassVar, Literal
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, ChatMessage
from agent_framework._logging import get_logger
from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext
from agent_framework.exceptions import ServiceInitializationError
from azure.core.credentials import AzureKeyCredential
from azure.core.credentials_async import AsyncTokenCredential
from azure.core.exceptions import ResourceNotFoundError
from azure.search.documents.aio import SearchClient
from azure.search.documents.indexes.aio import SearchIndexClient
from azure.search.documents.indexes.models import (
AzureOpenAIVectorizerParameters,
KnowledgeBase,
KnowledgeBaseAzureOpenAIModel,
KnowledgeRetrievalLowReasoningEffort,
KnowledgeRetrievalMediumReasoningEffort,
KnowledgeRetrievalMinimalReasoningEffort,
KnowledgeRetrievalOutputMode,
KnowledgeRetrievalReasoningEffort,
KnowledgeSourceReference,
SearchIndexKnowledgeSource,
SearchIndexKnowledgeSourceParameters,
)
from azure.search.documents.models import (
QueryCaptionType,
QueryType,
VectorizableTextQuery,
VectorizedQuery,
)
from pydantic import ValidationError
from ._search_provider import AzureAISearchSettings
if TYPE_CHECKING:
from agent_framework._agents import SupportsAgentRun
from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient
from azure.search.documents.knowledgebases.models import (
KnowledgeBaseMessage,
KnowledgeBaseMessageTextContent,
KnowledgeBaseRetrievalRequest,
KnowledgeRetrievalIntent,
KnowledgeRetrievalSemanticIntent,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalLowReasoningEffort as KBRetrievalLowReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalMediumReasoningEffort as KBRetrievalMediumReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalMinimalReasoningEffort as KBRetrievalMinimalReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalOutputMode as KBRetrievalOutputMode,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalReasoningEffort as KBRetrievalReasoningEffort,
)
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
# Runtime imports for agentic mode (optional dependency)
try:
from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient
from azure.search.documents.knowledgebases.models import (
KnowledgeBaseMessage,
KnowledgeBaseMessageTextContent,
KnowledgeBaseRetrievalRequest,
KnowledgeRetrievalIntent,
KnowledgeRetrievalSemanticIntent,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalLowReasoningEffort as KBRetrievalLowReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalMediumReasoningEffort as KBRetrievalMediumReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalMinimalReasoningEffort as KBRetrievalMinimalReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalOutputMode as KBRetrievalOutputMode,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalReasoningEffort as KBRetrievalReasoningEffort,
)
_agentic_retrieval_available = True
except ImportError:
_agentic_retrieval_available = False
logger = get_logger(__name__)
_DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT = 10
class _AzureAISearchContextProvider(BaseContextProvider):
"""Azure AI Search context provider using the new BaseContextProvider hooks pattern.
Retrieves relevant context from Azure AI Search using semantic or agentic search
modes. This is the new-pattern equivalent of :class:`AzureAISearchContextProvider`.
Note:
This class uses a temporary ``_`` prefix to coexist with the existing
:class:`AzureAISearchContextProvider`. It will replace the existing class
in PR2.
"""
_DEFAULT_SEARCH_CONTEXT_PROMPT: ClassVar[str] = "Use the following context to answer the question:"
def __init__(
self,
source_id: str,
endpoint: str | None = None,
index_name: str | None = None,
api_key: str | AzureKeyCredential | None = None,
credential: AsyncTokenCredential | None = None,
*,
mode: Literal["semantic", "agentic"] = "semantic",
top_k: int = 5,
semantic_configuration_name: str | None = None,
vector_field_name: str | None = None,
embedding_function: Callable[[str], Awaitable[list[float]]] | None = None,
context_prompt: str | None = None,
azure_openai_resource_url: str | None = None,
model_deployment_name: str | None = None,
model_name: str | None = None,
knowledge_base_name: str | None = None,
retrieval_instructions: str | None = None,
azure_openai_api_key: str | None = None,
knowledge_base_output_mode: Literal["extractive_data", "answer_synthesis"] = "extractive_data",
retrieval_reasoning_effort: Literal["minimal", "medium", "low"] = "minimal",
agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize Azure AI Search Context Provider.
Args:
source_id: Unique identifier for this provider instance.
endpoint: Azure AI Search endpoint URL.
index_name: Name of the search index to query.
api_key: API key for authentication.
credential: AsyncTokenCredential for managed identity authentication.
mode: Search mode - "semantic" or "agentic". Default: "semantic".
top_k: Maximum number of documents to retrieve. Default: 5.
semantic_configuration_name: Name of semantic configuration in the index.
vector_field_name: Name of the vector field in the index.
embedding_function: Async function to generate embeddings.
context_prompt: Custom prompt to prepend to retrieved context.
azure_openai_resource_url: Azure OpenAI resource URL for Knowledge Base.
model_deployment_name: Model deployment name in Azure OpenAI.
model_name: The underlying model name.
knowledge_base_name: Name of an existing Knowledge Base to use.
retrieval_instructions: Custom instructions for Knowledge Base retrieval.
azure_openai_api_key: Azure OpenAI API key.
knowledge_base_output_mode: Output mode for Knowledge Base retrieval.
retrieval_reasoning_effort: Reasoning effort for Knowledge Base query planning.
agentic_message_history_count: Number of recent messages for agentic mode.
env_file_path: Path to environment file for loading settings.
env_file_encoding: Encoding of the environment file.
"""
super().__init__(source_id)
# Load settings from environment/file
try:
settings = AzureAISearchSettings(
endpoint=endpoint,
index_name=index_name,
knowledge_base_name=knowledge_base_name,
api_key=api_key if isinstance(api_key, str) else None,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
except ValidationError as ex:
raise ServiceInitializationError("Failed to create Azure AI Search settings.", ex) from ex
if not settings.endpoint:
raise ServiceInitializationError(
"Azure AI Search endpoint is required. Set via 'endpoint' parameter "
"or 'AZURE_SEARCH_ENDPOINT' environment variable."
)
if mode == "semantic":
if not settings.index_name:
raise ServiceInitializationError(
"Azure AI Search index name is required for semantic mode. "
"Set via 'index_name' parameter or 'AZURE_SEARCH_INDEX_NAME' environment variable."
)
elif mode == "agentic":
if settings.index_name and settings.knowledge_base_name:
raise ServiceInitializationError(
"For agentic mode, provide either 'index_name' OR 'knowledge_base_name', not both."
)
if not settings.index_name and not settings.knowledge_base_name:
raise ServiceInitializationError(
"For agentic mode, provide either 'index_name' or 'knowledge_base_name'."
)
if settings.index_name and not model_deployment_name:
raise ServiceInitializationError(
"model_deployment_name is required for agentic mode when creating Knowledge Base from index."
)
resolved_credential: AzureKeyCredential | AsyncTokenCredential
if credential:
resolved_credential = credential
elif isinstance(api_key, AzureKeyCredential):
resolved_credential = api_key
elif settings.api_key:
resolved_credential = AzureKeyCredential(settings.api_key.get_secret_value())
else:
raise ServiceInitializationError(
"Azure credential is required. Provide 'api_key' or 'credential' parameter "
"or set 'AZURE_SEARCH_API_KEY' environment variable."
)
self.endpoint = settings.endpoint
self.index_name = settings.index_name
self.credential = resolved_credential
self.mode = mode
self.top_k = top_k
self.semantic_configuration_name = semantic_configuration_name
self.vector_field_name = vector_field_name
self.embedding_function = embedding_function
self.context_prompt = context_prompt or self._DEFAULT_SEARCH_CONTEXT_PROMPT
self.azure_openai_resource_url = azure_openai_resource_url
self.azure_openai_deployment_name = model_deployment_name
self.model_name = model_name or model_deployment_name
self.knowledge_base_name = settings.knowledge_base_name
self.retrieval_instructions = retrieval_instructions
self.azure_openai_api_key = azure_openai_api_key
self.knowledge_base_output_mode = knowledge_base_output_mode
self.retrieval_reasoning_effort = retrieval_reasoning_effort
self.agentic_message_history_count = agentic_message_history_count
self._use_existing_knowledge_base = False
if mode == "agentic":
if settings.knowledge_base_name:
self._use_existing_knowledge_base = True
else:
self.knowledge_base_name = f"{settings.index_name}-kb"
self._auto_discovered_vector_field = False
self._use_vectorizable_query = False
if vector_field_name and not embedding_function:
raise ValueError("embedding_function is required when vector_field_name is specified")
if mode == "agentic":
if not _agentic_retrieval_available:
raise ImportError(
"Agentic retrieval requires azure-search-documents >= 11.7.0b1 with Knowledge Base support."
)
if not self._use_existing_knowledge_base and not self.azure_openai_resource_url:
raise ValueError(
"azure_openai_resource_url is required for agentic mode when creating Knowledge Base from index."
)
self._search_client: SearchClient | None = None
if self.index_name:
self._search_client = SearchClient(
endpoint=self.endpoint,
index_name=self.index_name,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
self._index_client: SearchIndexClient | None = None
self._retrieval_client: KnowledgeBaseRetrievalClient | None = None
if mode == "agentic":
self._index_client = SearchIndexClient(
endpoint=self.endpoint,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
self._knowledge_base_initialized = False
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 - cleanup clients."""
if self._retrieval_client is not None:
await self._retrieval_client.close()
self._retrieval_client = None
# -- Hooks pattern ---------------------------------------------------------
async def before_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Retrieve relevant context from Azure AI Search and add to session context."""
messages_list = list(context.input_messages)
def get_role_value(role: str | Any) -> str:
return role.value if hasattr(role, "value") else str(role)
filtered_messages = [
msg
for msg in messages_list
if msg and msg.text and msg.text.strip() and get_role_value(msg.role) in ["user", "assistant"]
]
if not filtered_messages:
return
if self.mode == "semantic":
query = "\n".join(msg.text for msg in filtered_messages)
search_result_parts = await self._semantic_search(query)
else:
recent_messages = filtered_messages[-self.agentic_message_history_count :]
search_result_parts = await self._agentic_search(recent_messages)
if not search_result_parts:
return
context_messages = [ChatMessage(role="user", text=self.context_prompt)]
context_messages.extend([ChatMessage(role="user", text=part) for part in search_result_parts])
context.extend_messages(self.source_id, context_messages)
# -- Internal methods (ported from AzureAISearchContextProvider) -----------
def _find_vector_fields(self, index: Any) -> list[str]:
"""Find all fields that can store vectors."""
return [
field.name
for field in index.fields
if field.vector_search_dimensions is not None and field.vector_search_dimensions > 0
]
def _find_vectorizable_fields(self, index: Any, vector_fields: list[str]) -> list[str]:
"""Find vector fields that have auto-vectorization configured."""
vectorizable_fields: list[str] = []
if not index.vector_search or not index.vector_search.profiles:
return vectorizable_fields
for field in index.fields:
if field.name in vector_fields and field.vector_search_profile_name:
profile = next(
(p for p in index.vector_search.profiles if p.name == field.vector_search_profile_name), None
)
if profile and hasattr(profile, "vectorizer_name") and profile.vectorizer_name:
vectorizable_fields.append(field.name)
return vectorizable_fields
async def _auto_discover_vector_field(self) -> None:
"""Auto-discover vector field from index schema."""
if self._auto_discovered_vector_field or self.vector_field_name:
return
try:
if not self._index_client:
self._index_client = SearchIndexClient(
endpoint=self.endpoint,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
if not self.index_name:
logger.warning("Cannot auto-discover vector field: index_name is not set.")
self._auto_discovered_vector_field = True
return
index = await self._index_client.get_index(self.index_name)
vector_fields = self._find_vector_fields(index)
if not vector_fields:
logger.info(f"No vector fields found in index '{self.index_name}'. Using keyword-only search.")
self._auto_discovered_vector_field = True
return
vectorizable_fields = self._find_vectorizable_fields(index, vector_fields)
if vectorizable_fields:
if len(vectorizable_fields) == 1:
self.vector_field_name = vectorizable_fields[0]
self._auto_discovered_vector_field = True
self._use_vectorizable_query = True
logger.info(
f"Auto-discovered vectorizable field '{self.vector_field_name}' with server-side vectorization."
)
else:
logger.warning(
f"Multiple vectorizable fields found: {vectorizable_fields}. "
f"Please specify vector_field_name explicitly."
)
elif len(vector_fields) == 1:
self.vector_field_name = vector_fields[0]
self._auto_discovered_vector_field = True
self._use_vectorizable_query = False
if not self.embedding_function:
logger.warning(
f"Auto-discovered vector field '{self.vector_field_name}' without server-side vectorization. "
f"Provide embedding_function for vector search."
)
self.vector_field_name = None
else:
logger.warning(
f"Multiple vector fields found: {vector_fields}. Please specify vector_field_name explicitly."
)
except Exception as e:
logger.warning(f"Failed to auto-discover vector field: {e}. Using keyword-only search.")
self._auto_discovered_vector_field = True
async def _semantic_search(self, query: str) -> list[str]:
"""Perform semantic hybrid search."""
await self._auto_discover_vector_field()
vector_queries: list[VectorizableTextQuery | VectorizedQuery] = []
if self.vector_field_name:
vector_k = max(self.top_k, 50) if self.semantic_configuration_name else self.top_k
if self._use_vectorizable_query:
vector_queries = [
VectorizableTextQuery(text=query, k_nearest_neighbors=vector_k, fields=self.vector_field_name)
]
elif self.embedding_function:
query_vector = await self.embedding_function(query)
vector_queries = [
VectorizedQuery(vector=query_vector, k_nearest_neighbors=vector_k, fields=self.vector_field_name)
]
search_params: dict[str, Any] = {"search_text": query, "top": self.top_k}
if vector_queries:
search_params["vector_queries"] = vector_queries
if self.semantic_configuration_name:
search_params["query_type"] = QueryType.SEMANTIC
search_params["semantic_configuration_name"] = self.semantic_configuration_name
search_params["query_caption"] = QueryCaptionType.EXTRACTIVE
if not self._search_client:
raise RuntimeError("Search client is not initialized.")
results = await self._search_client.search(**search_params) # type: ignore[reportUnknownVariableType]
formatted_results: list[str] = []
async for doc in results: # type: ignore[reportUnknownVariableType]
doc_id = doc.get("id") or doc.get("@search.id") # type: ignore[reportUnknownVariableType]
doc_text: str = self._extract_document_text(doc, doc_id=doc_id) # type: ignore[reportUnknownArgumentType]
if doc_text:
formatted_results.append(doc_text) # type: ignore[reportUnknownArgumentType]
return formatted_results
async def _ensure_knowledge_base(self) -> None:
"""Ensure Knowledge Base and knowledge source are created or use existing KB."""
if self._knowledge_base_initialized:
return
if not self.knowledge_base_name:
raise ValueError("knowledge_base_name is required for agentic mode")
knowledge_base_name = self.knowledge_base_name
if self._use_existing_knowledge_base:
if _agentic_retrieval_available and self._retrieval_client is None:
self._retrieval_client = KnowledgeBaseRetrievalClient(
endpoint=self.endpoint,
knowledge_base_name=knowledge_base_name,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
self._knowledge_base_initialized = True
return
if not self._index_client:
raise ValueError("Index client is required when creating Knowledge Base from index")
if not self.azure_openai_resource_url:
raise ValueError("azure_openai_resource_url is required when creating Knowledge Base from index")
if not self.azure_openai_deployment_name:
raise ValueError("model_deployment_name is required when creating Knowledge Base from index")
if not self.index_name:
raise ValueError("index_name is required when creating Knowledge Base from index")
knowledge_source_name = f"{self.index_name}-source"
try:
await self._index_client.get_knowledge_source(knowledge_source_name)
except ResourceNotFoundError:
knowledge_source = SearchIndexKnowledgeSource(
name=knowledge_source_name,
description=f"Knowledge source for {self.index_name} search index",
search_index_parameters=SearchIndexKnowledgeSourceParameters(
search_index_name=self.index_name,
),
)
await self._index_client.create_knowledge_source(knowledge_source)
aoai_params = AzureOpenAIVectorizerParameters(
resource_url=self.azure_openai_resource_url,
deployment_name=self.azure_openai_deployment_name,
model_name=self.model_name,
api_key=self.azure_openai_api_key,
)
output_mode = (
KnowledgeRetrievalOutputMode.EXTRACTIVE_DATA
if self.knowledge_base_output_mode == "extractive_data"
else KnowledgeRetrievalOutputMode.ANSWER_SYNTHESIS
)
reasoning_effort_map: dict[str, KnowledgeRetrievalReasoningEffort] = {
"minimal": KnowledgeRetrievalMinimalReasoningEffort(),
"medium": KnowledgeRetrievalMediumReasoningEffort(),
"low": KnowledgeRetrievalLowReasoningEffort(),
}
reasoning_effort = reasoning_effort_map[self.retrieval_reasoning_effort]
knowledge_base = KnowledgeBase(
name=knowledge_base_name,
description=f"Knowledge Base for multi-hop retrieval across {self.index_name}",
knowledge_sources=[KnowledgeSourceReference(name=knowledge_source_name)],
models=[KnowledgeBaseAzureOpenAIModel(azure_open_ai_parameters=aoai_params)],
output_mode=output_mode,
retrieval_reasoning_effort=reasoning_effort,
)
await self._index_client.create_or_update_knowledge_base(knowledge_base)
self._knowledge_base_initialized = True
if _agentic_retrieval_available and self._retrieval_client is None:
self._retrieval_client = KnowledgeBaseRetrievalClient(
endpoint=self.endpoint,
knowledge_base_name=knowledge_base_name,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
async def _agentic_search(self, messages: list[ChatMessage]) -> list[str]:
"""Perform agentic retrieval with multi-hop reasoning."""
await self._ensure_knowledge_base()
reasoning_effort_map: dict[str, KBRetrievalReasoningEffort] = {
"minimal": KBRetrievalMinimalReasoningEffort(),
"medium": KBRetrievalMediumReasoningEffort(),
"low": KBRetrievalLowReasoningEffort(),
}
reasoning_effort = reasoning_effort_map[self.retrieval_reasoning_effort]
output_mode = (
KBRetrievalOutputMode.EXTRACTIVE_DATA
if self.knowledge_base_output_mode == "extractive_data"
else KBRetrievalOutputMode.ANSWER_SYNTHESIS
)
if self.retrieval_reasoning_effort == "minimal":
query = "\n".join(msg.text for msg in messages if msg.text)
intents: list[KnowledgeRetrievalIntent] = [KnowledgeRetrievalSemanticIntent(search=query)]
retrieval_request = KnowledgeBaseRetrievalRequest(
intents=intents,
retrieval_reasoning_effort=reasoning_effort,
output_mode=output_mode,
include_activity=True,
)
else:
kb_messages = [
KnowledgeBaseMessage(
role=msg.role if hasattr(msg.role, "value") else str(msg.role),
content=[KnowledgeBaseMessageTextContent(text=msg.text)],
)
for msg in messages
if msg.text
]
retrieval_request = KnowledgeBaseRetrievalRequest(
messages=kb_messages,
retrieval_reasoning_effort=reasoning_effort,
output_mode=output_mode,
include_activity=True,
)
if not self._retrieval_client:
raise RuntimeError("Retrieval client not initialized.")
retrieval_result = await self._retrieval_client.retrieve(retrieval_request=retrieval_request)
if retrieval_result.response and len(retrieval_result.response) > 0:
assistant_message = retrieval_result.response[-1]
if assistant_message.content:
answer_parts: list[str] = []
for content_item in assistant_message.content:
if isinstance(content_item, KnowledgeBaseMessageTextContent) and content_item.text:
answer_parts.append(content_item.text)
if answer_parts:
return answer_parts
return ["No results found from Knowledge Base."]
def _extract_document_text(self, doc: dict[str, Any], doc_id: str | None = None) -> str:
"""Extract readable text from a search document with optional citation."""
text = ""
for field in ["content", "text", "description", "body", "chunk"]:
if doc.get(field):
text = str(doc[field])
break
if not text:
text_parts: list[str] = []
for key, value in doc.items():
if isinstance(value, str) and not key.startswith("@") and key != "id":
text_parts.append(f"{key}: {value}")
text = " | ".join(text_parts) if text_parts else ""
if doc_id and text:
return f"[Source: {doc_id}] {text}"
return text
__all__ = ["_AzureAISearchContextProvider"]
@@ -0,0 +1,293 @@
# Copyright (c) Microsoft. All rights reserved.
# pyright: reportPrivateUsage=false
import os
from unittest.mock import AsyncMock, patch
import pytest
from agent_framework import ChatMessage
from agent_framework._sessions import AgentSession, SessionContext
from agent_framework.exceptions import ServiceInitializationError
from agent_framework_azure_ai_search._context_provider import _AzureAISearchContextProvider
# -- Helpers -------------------------------------------------------------------
class MockSearchResults:
"""Async-iterable mock for Azure SearchClient.search() results."""
def __init__(self, docs: list[dict]):
self._docs = docs
self._index = 0
def __aiter__(self):
return self
async def __anext__(self):
if self._index >= len(self._docs):
raise StopAsyncIteration
doc = self._docs[self._index]
self._index += 1
return doc
@pytest.fixture
def mock_search_client() -> AsyncMock:
"""Create a mock SearchClient that returns one document."""
client = AsyncMock()
async def _search(**kwargs):
return MockSearchResults([{"id": "doc1", "content": "test document"}])
client.search = AsyncMock(side_effect=_search)
return client
@pytest.fixture
def mock_search_client_empty() -> AsyncMock:
"""Create a mock SearchClient that returns no results."""
client = AsyncMock()
async def _search(**kwargs):
return MockSearchResults([])
client.search = AsyncMock(side_effect=_search)
return client
def _make_provider(**overrides) -> _AzureAISearchContextProvider:
"""Create a semantic-mode provider with mocked internals (skips auto-discovery)."""
defaults = {
"source_id": "aisearch",
"endpoint": "https://test.search.windows.net",
"index_name": "test-index",
"api_key": "test-key",
}
defaults.update(overrides)
provider = _AzureAISearchContextProvider(**defaults)
provider._auto_discovered_vector_field = True # skip auto-discovery
return provider
# -- Initialization: semantic mode ---------------------------------------------
class TestInitSemantic:
"""Initialization tests for semantic mode."""
def test_valid_init(self) -> None:
provider = _make_provider()
assert provider.source_id == "aisearch"
assert provider.endpoint == "https://test.search.windows.net"
assert provider.index_name == "test-index"
assert provider.mode == "semantic"
def test_source_id_set(self) -> None:
provider = _make_provider(source_id="my-source")
assert provider.source_id == "my-source"
def test_missing_endpoint_raises(self) -> None:
with patch.dict(os.environ, {}, clear=True), pytest.raises(ServiceInitializationError, match="endpoint"):
_AzureAISearchContextProvider(
source_id="s",
endpoint=None,
index_name="idx",
api_key="key",
)
def test_missing_index_name_semantic_raises(self) -> None:
with pytest.raises(ServiceInitializationError, match="index name"):
_AzureAISearchContextProvider(
source_id="s",
endpoint="https://test.search.windows.net",
index_name=None,
api_key="key",
)
def test_env_variable_fallback(self) -> None:
env = {
"AZURE_SEARCH_ENDPOINT": "https://env.search.windows.net",
"AZURE_SEARCH_INDEX_NAME": "env-index",
"AZURE_SEARCH_API_KEY": "env-key",
}
with patch.dict(os.environ, env, clear=False):
provider = _AzureAISearchContextProvider(source_id="env-test")
assert provider.endpoint == "https://env.search.windows.net"
assert provider.index_name == "env-index"
# -- Initialization: agentic mode validation -----------------------------------
class TestInitAgenticValidation:
"""Initialization validation tests for agentic mode."""
def test_both_index_and_kb_raises(self) -> None:
with pytest.raises(ServiceInitializationError, match="not both"):
_AzureAISearchContextProvider(
source_id="s",
endpoint="https://test.search.windows.net",
index_name="idx",
knowledge_base_name="kb",
api_key="key",
mode="agentic",
model_deployment_name="deploy",
azure_openai_resource_url="https://aoai.openai.azure.com",
)
def test_neither_index_nor_kb_raises(self) -> None:
with pytest.raises(ServiceInitializationError, match="provide either"):
_AzureAISearchContextProvider(
source_id="s",
endpoint="https://test.search.windows.net",
api_key="key",
mode="agentic",
)
def test_missing_model_deployment_name_raises(self) -> None:
with pytest.raises(ServiceInitializationError, match="model_deployment_name"):
_AzureAISearchContextProvider(
source_id="s",
endpoint="https://test.search.windows.net",
index_name="idx",
api_key="key",
mode="agentic",
azure_openai_resource_url="https://aoai.openai.azure.com",
)
def test_vector_field_without_embedding_raises(self) -> None:
with pytest.raises(ValueError, match="embedding_function"):
_AzureAISearchContextProvider(
source_id="s",
endpoint="https://test.search.windows.net",
index_name="idx",
api_key="key",
vector_field_name="embedding",
)
# -- before_run: semantic mode -------------------------------------------------
class TestBeforeRunSemantic:
"""Tests for before_run in semantic mode."""
async def test_results_added_to_context(self, mock_search_client: AsyncMock) -> None:
provider = _make_provider()
provider._search_client = mock_search_client
session = AgentSession(session_id="test-session")
ctx = SessionContext(
input_messages=[ChatMessage(role="user", contents=["test query"])],
session_id="s1",
)
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
mock_search_client.search.assert_awaited_once()
msgs = ctx.context_messages.get("aisearch", [])
assert len(msgs) >= 2 # context_prompt + at least one result
assert msgs[0].text == provider.context_prompt
async def test_empty_input_no_search(self, mock_search_client: AsyncMock) -> None:
provider = _make_provider()
provider._search_client = mock_search_client
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[], session_id="s1")
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
mock_search_client.search.assert_not_awaited()
assert ctx.context_messages.get("aisearch") is None
async def test_no_results_no_messages(self, mock_search_client_empty: AsyncMock) -> None:
provider = _make_provider()
provider._search_client = mock_search_client_empty
session = AgentSession(session_id="test-session")
ctx = SessionContext(
input_messages=[ChatMessage(role="user", contents=["test query"])],
session_id="s1",
)
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
mock_search_client_empty.search.assert_awaited_once()
assert ctx.context_messages.get("aisearch") is None
async def test_context_prompt_prepended(self, mock_search_client: AsyncMock) -> None:
custom_prompt = "Custom search context:"
provider = _make_provider(context_prompt=custom_prompt)
provider._search_client = mock_search_client
session = AgentSession(session_id="test-session")
ctx = SessionContext(
input_messages=[ChatMessage(role="user", contents=["test query"])],
session_id="s1",
)
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
msgs = ctx.context_messages["aisearch"]
assert msgs[0].text == custom_prompt
# -- before_run: message filtering ---------------------------------------------
class TestBeforeRunFiltering:
"""Tests that only user/assistant messages are used for search."""
async def test_filters_non_user_assistant(self, mock_search_client: AsyncMock) -> None:
provider = _make_provider()
provider._search_client = mock_search_client
session = AgentSession(session_id="test-session")
ctx = SessionContext(
input_messages=[
ChatMessage(role="system", contents=["system prompt"]),
ChatMessage(role="user", contents=["actual question"]),
],
session_id="s1",
)
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
mock_search_client.search.assert_awaited_once()
call_kwargs = mock_search_client.search.call_args[1]
# The search text should contain only the user message, not the system message
assert "actual question" in call_kwargs["search_text"]
assert "system prompt" not in call_kwargs["search_text"]
async def test_only_system_messages_no_search(self, mock_search_client: AsyncMock) -> None:
provider = _make_provider()
provider._search_client = mock_search_client
session = AgentSession(session_id="test-session")
ctx = SessionContext(
input_messages=[ChatMessage(role="system", contents=["system prompt"])],
session_id="s1",
)
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
mock_search_client.search.assert_not_awaited()
# -- __aexit__ -----------------------------------------------------------------
class TestAexit:
"""Tests for async context manager cleanup."""
async def test_closes_retrieval_client(self) -> None:
provider = _make_provider()
mock_retrieval = AsyncMock()
provider._retrieval_client = mock_retrieval
await provider.__aexit__(None, None, None)
mock_retrieval.close.assert_awaited_once()
assert provider._retrieval_client is None
async def test_no_retrieval_client_no_error(self) -> None:
provider = _make_provider()
assert provider._retrieval_client is None
await provider.__aexit__(None, None, None) # should not raise
@@ -0,0 +1,522 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unified context management types for the agent framework.
This module provides the core types for the context provider pipeline:
- SessionContext: Per-invocation state passed through providers
- BaseContextProvider: Base class for context providers (renamed to ContextProvider in PR2)
- BaseHistoryProvider: Base class for history storage providers (renamed to HistoryProvider in PR2)
- AgentSession: Lightweight session state container
- InMemoryHistoryProvider: Built-in in-memory history provider
"""
from __future__ import annotations
import copy
import uuid
from abc import abstractmethod
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any
from ._tools import ToolProtocol
from ._types import AgentResponse, ChatMessage
if TYPE_CHECKING:
from ._agents import SupportsAgentRun
__all__ = [
"AgentSession",
"BaseContextProvider",
"BaseHistoryProvider",
"InMemoryHistoryProvider",
"SessionContext",
]
# Registry of known types for state deserialization
_STATE_TYPE_REGISTRY: dict[str, type] = {}
def _register_state_type(cls: type) -> None:
"""Register a type for automatic deserialization in session state."""
type_id: str = getattr(cls, "_get_type_identifier", lambda: cls.__name__.lower())()
_STATE_TYPE_REGISTRY[type_id] = cls
def _serialize_value(value: Any) -> Any:
"""Serialize a single value, handling objects with to_dict()."""
if hasattr(value, "to_dict") and callable(value.to_dict):
return value.to_dict() # pyright: ignore[reportUnknownMemberType]
if isinstance(value, list):
return [_serialize_value(item) for item in value] # pyright: ignore[reportUnknownVariableType]
if isinstance(value, dict):
return {str(k): _serialize_value(v) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
return value
def _deserialize_value(value: Any) -> Any:
"""Deserialize a single value, restoring registered types."""
if isinstance(value, dict) and "type" in value:
type_id = str(value["type"]) # pyright: ignore[reportUnknownArgumentType]
cls = _STATE_TYPE_REGISTRY.get(type_id)
if cls is not None and hasattr(cls, "from_dict"):
return cls.from_dict(value) # type: ignore[union-attr]
if isinstance(value, list):
return [_deserialize_value(item) for item in value] # pyright: ignore[reportUnknownVariableType]
if isinstance(value, dict):
return {str(k): _deserialize_value(v) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
return value
def _serialize_state(state: dict[str, Any]) -> dict[str, Any]:
"""Deep-serialize a state dict, converting SerializationProtocol objects to dicts."""
return {k: _serialize_value(v) for k, v in state.items()}
def _deserialize_state(state: dict[str, Any]) -> dict[str, Any]:
"""Deep-deserialize a state dict, restoring SerializationProtocol objects."""
return {k: _deserialize_value(v) for k, v in state.items()}
# Register known types
_register_state_type(ChatMessage)
class SessionContext:
"""Per-invocation state passed through the context provider pipeline.
Created fresh for each agent.run() call. Providers read from and write to
the mutable fields to add context before invocation and process responses after.
Attributes:
session_id: The ID of the current session.
service_session_id: Service-managed session ID (if present, service handles storage).
input_messages: The new messages being sent to the agent (set by caller).
context_messages: Dict mapping source_id -> messages added by that provider.
Maintains insertion order (provider execution order).
instructions: Additional instructions added by providers.
tools: Additional tools added by providers.
response: After invocation, contains the full AgentResponse, should not be changed.
options: Options passed to agent.run() - read-only, for reflection only.
metadata: Shared metadata dictionary for cross-provider communication.
"""
def __init__(
self,
*,
session_id: str | None = None,
service_session_id: str | None = None,
input_messages: list[ChatMessage],
context_messages: dict[str, list[ChatMessage]] | None = None,
instructions: list[str] | None = None,
tools: list[ToolProtocol] | None = None,
options: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
):
"""Initialize the session context.
Args:
session_id: The ID of the current session.
service_session_id: Service-managed session ID.
input_messages: The new messages being sent to the agent.
context_messages: Pre-populated context messages by source.
instructions: Pre-populated instructions.
tools: Pre-populated tools.
options: Options from agent.run() - read-only for providers.
metadata: Shared metadata for cross-provider communication.
"""
self.session_id = session_id
self.service_session_id = service_session_id
self.input_messages = input_messages
self.context_messages: dict[str, list[ChatMessage]] = context_messages or {}
self.instructions: list[str] = instructions or []
self.tools: list[ToolProtocol] = tools or []
self._response: AgentResponse | None = None
self.options: dict[str, Any] = options or {}
self.metadata: dict[str, Any] = metadata or {}
@property
def response(self) -> AgentResponse | None:
"""The agent's response. Set by the framework after invocation, read-only for providers."""
return self._response
def extend_messages(self, source: str | object, messages: Sequence[ChatMessage]) -> None:
"""Add context messages from a specific source.
Messages are copied before attribution is added, so the caller's
original message objects are never mutated. The copies are stored
keyed by source_id, maintaining insertion order based on provider
execution order. Each message gets an ``attribution`` marker in
``additional_properties`` for downstream filtering.
Args:
source: Either a plain ``source_id`` string, or an object with a
``source_id`` attribute (e.g. a context provider). When an
object is passed, its class name is recorded as
``source_type`` in the attribution.
messages: The messages to add.
"""
if isinstance(source, str):
source_id = source
attribution: dict[str, str] = {"source_id": source_id}
else:
source_id = source.source_id # type: ignore[attr-defined]
attribution = {"source_id": source_id, "source_type": type(source).__name__}
copied: list[ChatMessage] = []
for message in messages:
msg_copy = copy.copy(message)
msg_copy.additional_properties = dict(message.additional_properties)
msg_copy.additional_properties.setdefault("_attribution", attribution)
copied.append(msg_copy)
if source_id not in self.context_messages:
self.context_messages[source_id] = []
self.context_messages[source_id].extend(copied)
def extend_instructions(self, source_id: str, instructions: str | Sequence[str]) -> None:
"""Add instructions to be prepended to the conversation.
Args:
source_id: The provider source_id adding these instructions.
instructions: A single instruction string or sequence of strings.
"""
if isinstance(instructions, str):
instructions = [instructions]
self.instructions.extend(instructions)
def extend_tools(self, source_id: str, tools: Sequence[ToolProtocol]) -> None:
"""Add tools to be available for this invocation.
Tools are added with source attribution in their metadata.
Args:
source_id: The provider source_id adding these tools.
tools: The tools to add.
"""
for tool in tools:
if hasattr(tool, "additional_properties") and isinstance(tool.additional_properties, dict):
tool.additional_properties["context_source"] = source_id
self.tools.extend(tools)
def get_messages(
self,
*,
sources: set[str] | None = None,
exclude_sources: set[str] | None = None,
include_input: bool = False,
include_response: bool = False,
) -> list[ChatMessage]:
"""Get context messages, optionally filtered and including input/response.
Returns messages in provider execution order (dict insertion order),
with input and response appended if requested.
Args:
sources: If provided, only include context messages from these sources.
exclude_sources: If provided, exclude context messages from these sources.
include_input: If True, append input_messages after context.
include_response: If True, append response.messages at the end.
Returns:
Flattened list of messages in conversation order.
"""
result: list[ChatMessage] = []
for source_id, messages in self.context_messages.items():
if sources is not None and source_id not in sources:
continue
if exclude_sources is not None and source_id in exclude_sources:
continue
result.extend(messages)
if include_input and self.input_messages:
result.extend(self.input_messages)
if include_response and self.response and self.response.messages:
result.extend(self.response.messages)
return result
class BaseContextProvider:
"""Base class for context providers (hooks pattern).
Context providers participate in the context engineering pipeline,
adding context before model invocation and processing responses after.
Note:
This class uses a temporary name prefixed with ``_`` to avoid collision
with the existing ``ContextProvider`` in ``_memory.py``. It will be
renamed to ``ContextProvider`` in PR2 when the old class is removed.
Attributes:
source_id: Unique identifier for this provider instance (required).
Used for message/tool attribution so other providers can filter.
"""
def __init__(self, source_id: str):
"""Initialize the provider.
Args:
source_id: Unique identifier for this provider instance.
"""
self.source_id = source_id
async def before_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Called before model invocation.
Override to add context (messages, instructions, tools) to the
SessionContext before the model is invoked.
Args:
agent: The agent running this invocation.
session: The current session.
context: The invocation context - add messages/instructions/tools here.
state: The session's mutable state dict.
"""
async def after_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Called after model invocation.
Override to process the response (store messages, extract info, etc.).
The context.response will be populated at this point.
Args:
agent: The agent that ran this invocation.
session: The current session.
context: The invocation context with response populated.
state: The session's mutable state dict.
"""
class BaseHistoryProvider(BaseContextProvider):
"""Base class for conversation history storage providers.
A single class configurable for different use cases:
- Primary memory storage (loads + stores messages)
- Audit/logging storage (stores only, doesn't load)
- Evaluation storage (stores only for later analysis)
Note:
This class uses a temporary name prefixed with ``_`` to avoid collision
with existing types. It will be renamed to ``HistoryProvider`` in PR2.
Subclasses only need to implement ``get_messages()`` and ``save_messages()``.
The default ``before_run``/``after_run`` handle loading and storing based on
configuration flags. Override them for custom behavior.
Attributes:
load_messages: Whether to load messages before invocation (default True).
When False, the agent skips calling ``before_run`` entirely.
store_inputs: Whether to store input messages (default True).
store_context_messages: Whether to store context from other providers (default False).
store_context_from: If set, only store context from these source_ids.
store_outputs: Whether to store response messages (default True).
"""
def __init__(
self,
source_id: str,
*,
load_messages: bool = True,
store_inputs: bool = True,
store_context_messages: bool = False,
store_context_from: set[str] | None = None,
store_outputs: bool = True,
):
"""Initialize the history provider.
Args:
source_id: Unique identifier for this provider instance.
load_messages: Whether to load messages before invocation.
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.
store_outputs: Whether to store response messages.
"""
super().__init__(source_id)
self.load_messages = load_messages
self.store_inputs = store_inputs
self.store_context_messages = store_context_messages
self.store_context_from = store_context_from
self.store_outputs = store_outputs
@abstractmethod
async def get_messages(self, session_id: str | None, **kwargs: Any) -> list[ChatMessage]:
"""Retrieve stored messages for this session.
Args:
session_id: The session ID to retrieve messages for.
**kwargs: Additional arguments (e.g., ``state`` for in-memory providers).
Returns:
List of stored messages.
"""
...
@abstractmethod
async def save_messages(self, session_id: str | None, messages: Sequence[ChatMessage], **kwargs: Any) -> None:
"""Persist messages for this session.
Args:
session_id: The session ID to store messages for.
messages: The messages to persist.
**kwargs: Additional arguments (e.g., ``state`` for in-memory providers).
"""
...
def _get_context_messages_to_store(self, context: SessionContext) -> list[ChatMessage]:
"""Get context messages that should be stored based on configuration."""
if not self.store_context_messages:
return []
if self.store_context_from is not None:
return context.get_messages(sources=self.store_context_from)
return context.get_messages(exclude_sources={self.source_id})
async def before_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Load history into context. Skipped by the agent when load_messages=False."""
history = await self.get_messages(context.session_id, state=state)
context.extend_messages(self, history)
async def after_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Store messages based on configuration."""
messages_to_store: list[ChatMessage] = []
messages_to_store.extend(self._get_context_messages_to_store(context))
if self.store_inputs:
messages_to_store.extend(context.input_messages)
if self.store_outputs and context.response and context.response.messages:
messages_to_store.extend(context.response.messages)
if messages_to_store:
await self.save_messages(context.session_id, messages_to_store, state=state)
class AgentSession:
"""A conversation session with an agent.
Lightweight state container. Provider instances are owned by the agent,
not the session. The session only holds session IDs and a mutable state dict.
Attributes:
session_id: Unique identifier for this session.
service_session_id: Service-managed session ID (if using service-side storage).
state: Mutable state dict shared with all providers.
"""
def __init__(
self,
*,
session_id: str | None = None,
service_session_id: str | None = None,
):
"""Initialize the session.
Args:
session_id: Optional session ID (generated if not provided).
service_session_id: Optional service-managed session ID.
"""
self._session_id = session_id or str(uuid.uuid4())
self.service_session_id = service_session_id
self.state: dict[str, Any] = {}
@property
def session_id(self) -> str:
"""The unique identifier for this session."""
return self._session_id
def to_dict(self) -> dict[str, Any]:
"""Serialize session to a plain dict for storage/transfer.
Values in ``state`` that implement ``SerializationProtocol`` (i.e. have
``to_dict``/``from_dict``) are serialized automatically. Built-in types
(str, int, float, bool, None, list, dict) are kept as-is.
"""
return {
"type": "session",
"session_id": self._session_id,
"service_session_id": self.service_session_id,
"state": _serialize_state(self.state),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> AgentSession:
"""Restore session from a previously serialized dict.
Values in ``state`` that were serialized via ``SerializationProtocol``
(containing a ``type`` key) are restored to their original types.
Args:
data: Dict from a previous ``to_dict()`` call.
Returns:
Restored AgentSession instance.
"""
session = cls(
session_id=data["session_id"],
service_session_id=data.get("service_session_id"),
)
session.state = _deserialize_state(data.get("state", {}))
return session
class InMemoryHistoryProvider(BaseHistoryProvider):
"""Built-in history provider that stores messages in session.state.
Messages are stored in ``state[source_id]["messages"]`` as a list of
``ChatMessage`` objects. Serialization to/from dicts is handled by
``AgentSession.to_dict()``/``from_dict()`` using ``SerializationProtocol``.
This provider holds no instance state — all data lives in the session's
state dict, passed as a named ``state`` parameter to ``get_messages``/``save_messages``.
This is the default provider auto-added by the agent when no providers
are configured and ``conversation_id`` or ``store=True`` is set.
"""
async def get_messages(
self, session_id: str | None, *, state: dict[str, Any] | None = None, **kwargs: Any
) -> list[ChatMessage]:
"""Retrieve messages from session state."""
if state is None:
return []
my_state = state.get(self.source_id, {})
return list(my_state.get("messages", []))
async def save_messages(
self,
session_id: str | None,
messages: Sequence[ChatMessage],
*,
state: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Persist messages to session state."""
if state is None:
return
my_state = state.setdefault(self.source_id, {})
existing = my_state.get("messages", [])
my_state["messages"] = [*existing, *messages]
@@ -0,0 +1,421 @@
# Copyright (c) Microsoft. All rights reserved.
import json
from collections.abc import Sequence
from agent_framework import ChatMessage
from agent_framework._sessions import (
AgentSession,
BaseContextProvider,
BaseHistoryProvider,
InMemoryHistoryProvider,
SessionContext,
)
# ---------------------------------------------------------------------------
# SessionContext tests
# ---------------------------------------------------------------------------
class TestSessionContext:
def test_init_defaults(self) -> None:
ctx = SessionContext(input_messages=[])
assert ctx.session_id is None
assert ctx.service_session_id is None
assert ctx.input_messages == []
assert ctx.context_messages == {}
assert ctx.instructions == []
assert ctx.tools == []
assert ctx.response is None
assert ctx.options == {}
assert ctx.metadata == {}
def test_extend_messages_creates_key(self) -> None:
ctx = SessionContext(input_messages=[])
msg = ChatMessage(role="user", contents=["hello"])
ctx.extend_messages("rag", [msg])
assert "rag" in ctx.context_messages
assert len(ctx.context_messages["rag"]) == 1
assert ctx.context_messages["rag"][0].text == "hello"
def test_extend_messages_appends_to_existing(self) -> None:
ctx = SessionContext(input_messages=[])
msg1 = ChatMessage(role="user", contents=["first"])
msg2 = ChatMessage(role="user", contents=["second"])
ctx.extend_messages("src", [msg1])
ctx.extend_messages("src", [msg2])
assert len(ctx.context_messages["src"]) == 2
def test_extend_messages_preserves_source_order(self) -> None:
ctx = SessionContext(input_messages=[])
ctx.extend_messages("a", [ChatMessage(role="user", contents=["a"])])
ctx.extend_messages("b", [ChatMessage(role="user", contents=["b"])])
ctx.extend_messages("c", [ChatMessage(role="user", contents=["c"])])
assert list(ctx.context_messages.keys()) == ["a", "b", "c"]
def test_extend_messages_sets_attribution(self) -> None:
ctx = SessionContext(input_messages=[])
msg = ChatMessage(role="system", contents=["context"])
ctx.extend_messages("rag", [msg])
stored = ctx.context_messages["rag"][0]
assert stored.additional_properties["_attribution"] == {"source_id": "rag"}
# Original message is not mutated
assert "_attribution" not in msg.additional_properties
def test_extend_messages_does_not_overwrite_existing_attribution(self) -> None:
ctx = SessionContext(input_messages=[])
msg = ChatMessage(
role="system", contents=["context"], additional_properties={"_attribution": {"source_id": "custom"}}
)
ctx.extend_messages("rag", [msg])
stored = ctx.context_messages["rag"][0]
assert stored.additional_properties["_attribution"] == {"source_id": "custom"}
def test_extend_messages_copies_messages(self) -> None:
ctx = SessionContext(input_messages=[])
msg = ChatMessage(role="user", contents=["hello"])
ctx.extend_messages("src", [msg])
stored = ctx.context_messages["src"][0]
assert stored is not msg
assert stored.text == "hello"
# Mutating stored copy does not affect original
stored.additional_properties["extra"] = True
assert "extra" not in msg.additional_properties
def test_extend_messages_sender_sets_source_type(self) -> None:
class MyProvider:
source_id = "rag"
ctx = SessionContext(input_messages=[])
msg = ChatMessage(role="system", contents=["ctx"])
ctx.extend_messages(MyProvider(), [msg])
stored = ctx.context_messages["rag"][0]
assert stored.additional_properties["_attribution"] == {"source_id": "rag", "source_type": "MyProvider"}
def test_extend_instructions_string(self) -> None:
ctx = SessionContext(input_messages=[])
ctx.extend_instructions("sys", "Be helpful")
assert ctx.instructions == ["Be helpful"]
def test_extend_instructions_sequence(self) -> None:
ctx = SessionContext(input_messages=[])
ctx.extend_instructions("sys", ["Be helpful", "Be concise"])
assert ctx.instructions == ["Be helpful", "Be concise"]
def test_get_messages_all(self) -> None:
ctx = SessionContext(input_messages=[])
ctx.extend_messages("a", [ChatMessage(role="user", contents=["a"])])
ctx.extend_messages("b", [ChatMessage(role="user", contents=["b"])])
result = ctx.get_messages()
assert len(result) == 2
assert result[0].text == "a"
assert result[1].text == "b"
def test_get_messages_filter_sources(self) -> None:
ctx = SessionContext(input_messages=[])
ctx.extend_messages("a", [ChatMessage(role="user", contents=["a"])])
ctx.extend_messages("b", [ChatMessage(role="user", contents=["b"])])
result = ctx.get_messages(sources=["a"])
assert len(result) == 1
assert result[0].text == "a"
def test_get_messages_exclude_sources(self) -> None:
ctx = SessionContext(input_messages=[])
ctx.extend_messages("a", [ChatMessage(role="user", contents=["a"])])
ctx.extend_messages("b", [ChatMessage(role="user", contents=["b"])])
result = ctx.get_messages(exclude_sources=["a"])
assert len(result) == 1
assert result[0].text == "b"
def test_get_messages_include_input(self) -> None:
input_msg = ChatMessage(role="user", contents=["input"])
ctx = SessionContext(input_messages=[input_msg])
ctx.extend_messages("a", [ChatMessage(role="user", contents=["context"])])
result = ctx.get_messages(include_input=True)
assert len(result) == 2
assert result[1].text == "input"
def test_get_messages_include_response(self) -> None:
from agent_framework import AgentResponse
ctx = SessionContext(input_messages=[])
ctx._response = AgentResponse(messages=[ChatMessage(role="assistant", contents=["reply"])])
result = ctx.get_messages(include_response=True)
assert len(result) == 1
assert result[0].text == "reply"
def test_response_readonly(self) -> None:
ctx = SessionContext(input_messages=[])
assert ctx.response is None
# Can set via _response internally
from agent_framework import AgentResponse
resp = AgentResponse(messages=[])
ctx._response = resp
assert ctx.response is resp
# ---------------------------------------------------------------------------
# BaseContextProvider tests
# ---------------------------------------------------------------------------
class TestContextProviderBase:
def test_source_id_required(self) -> None:
provider = BaseContextProvider(source_id="test")
assert provider.source_id == "test"
async def test_before_run_is_noop(self) -> None:
provider = BaseContextProvider(source_id="test")
session = AgentSession()
ctx = SessionContext(input_messages=[])
# Should not raise
await provider.before_run(agent=None, session=session, context=ctx, state={}) # type: ignore[arg-type]
async def test_after_run_is_noop(self) -> None:
provider = BaseContextProvider(source_id="test")
session = AgentSession()
ctx = SessionContext(input_messages=[])
await provider.after_run(agent=None, session=session, context=ctx, state={}) # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# BaseHistoryProvider tests
# ---------------------------------------------------------------------------
class ConcreteHistoryProvider(BaseHistoryProvider):
"""Concrete test implementation."""
def __init__(self, source_id: str, stored_messages: list[ChatMessage] | None = None, **kwargs) -> None:
super().__init__(source_id, **kwargs)
self.stored: list[ChatMessage] = []
self._stored_messages = stored_messages or []
async def get_messages(self, session_id: str | None, **kwargs) -> list[ChatMessage]:
return list(self._stored_messages)
async def save_messages(self, session_id: str | None, messages: Sequence[ChatMessage], **kwargs) -> None:
self.stored.extend(messages)
class TestHistoryProviderBase:
def test_default_flags(self) -> None:
provider = ConcreteHistoryProvider("mem")
assert provider.load_messages is True
assert provider.store_outputs is True
assert provider.store_inputs is True
assert provider.store_context_messages is False
assert provider.store_context_from is None
def test_custom_flags(self) -> None:
provider = ConcreteHistoryProvider(
"audit",
load_messages=False,
store_inputs=False,
store_context_messages=True,
store_context_from={"rag"},
)
assert provider.load_messages is False
assert provider.store_inputs is False
assert provider.store_context_messages is True
assert provider.store_context_from == {"rag"}
async def test_before_run_loads_messages(self) -> None:
msgs = [ChatMessage(role="user", contents=["history"])]
provider = ConcreteHistoryProvider("mem", stored_messages=msgs)
session = AgentSession()
ctx = SessionContext(session_id="s1", input_messages=[])
await provider.before_run(agent=None, session=session, context=ctx, state={}) # type: ignore[arg-type]
assert len(ctx.context_messages["mem"]) == 1
assert ctx.context_messages["mem"][0].text == "history"
async def test_after_run_stores_inputs_and_responses(self) -> None:
from agent_framework import AgentResponse
provider = ConcreteHistoryProvider("mem")
session = AgentSession()
input_msg = ChatMessage(role="user", contents=["hello"])
resp_msg = ChatMessage(role="assistant", contents=["hi"])
ctx = SessionContext(session_id="s1", input_messages=[input_msg])
ctx._response = AgentResponse(messages=[resp_msg])
await provider.after_run(agent=None, session=session, context=ctx, state={}) # type: ignore[arg-type]
assert len(provider.stored) == 2
assert provider.stored[0].text == "hello"
assert provider.stored[1].text == "hi"
async def test_after_run_skips_inputs_when_disabled(self) -> None:
from agent_framework import AgentResponse
provider = ConcreteHistoryProvider("mem", store_inputs=False)
ctx = SessionContext(session_id="s1", input_messages=[ChatMessage(role="user", contents=["hello"])])
ctx._response = AgentResponse(messages=[ChatMessage(role="assistant", contents=["hi"])])
await provider.after_run(agent=None, session=AgentSession(), context=ctx, state={}) # type: ignore[arg-type]
assert len(provider.stored) == 1
assert provider.stored[0].text == "hi"
async def test_after_run_skips_responses_when_disabled(self) -> None:
from agent_framework import AgentResponse
provider = ConcreteHistoryProvider("mem", store_outputs=False)
ctx = SessionContext(session_id="s1", input_messages=[ChatMessage(role="user", contents=["hello"])])
ctx._response = AgentResponse(messages=[ChatMessage(role="assistant", contents=["hi"])])
await provider.after_run(agent=None, session=AgentSession(), context=ctx, state={}) # type: ignore[arg-type]
assert len(provider.stored) == 1
assert provider.stored[0].text == "hello"
async def test_after_run_stores_context_messages(self) -> None:
from agent_framework import AgentResponse
provider = ConcreteHistoryProvider("audit", load_messages=False, store_context_messages=True)
ctx = SessionContext(session_id="s1", input_messages=[ChatMessage(role="user", contents=["hello"])])
ctx.extend_messages("rag", [ChatMessage(role="system", contents=["context"])])
ctx._response = AgentResponse(messages=[ChatMessage(role="assistant", contents=["hi"])])
await provider.after_run(agent=None, session=AgentSession(), context=ctx, state={}) # type: ignore[arg-type]
# Should store: context from rag + input + response
texts = [m.text for m in provider.stored]
assert "context" in texts
assert "hello" in texts
assert "hi" in texts
async def test_after_run_stores_context_from_specific_sources(self) -> None:
from agent_framework import AgentResponse
provider = ConcreteHistoryProvider(
"audit", load_messages=False, store_context_messages=True, store_context_from={"rag"}
)
ctx = SessionContext(session_id="s1", input_messages=[])
ctx.extend_messages("rag", [ChatMessage(role="system", contents=["rag-context"])])
ctx.extend_messages("other", [ChatMessage(role="system", contents=["other-context"])])
ctx._response = AgentResponse(messages=[])
await provider.after_run(agent=None, session=AgentSession(), context=ctx, state={}) # type: ignore[arg-type]
texts = [m.text for m in provider.stored]
assert "rag-context" in texts
assert "other-context" not in texts
# ---------------------------------------------------------------------------
# AgentSession tests
# ---------------------------------------------------------------------------
class TestAgentSession:
def test_auto_generates_session_id(self) -> None:
session = AgentSession()
assert session.session_id is not None
assert len(session.session_id) > 0
def test_custom_session_id(self) -> None:
session = AgentSession(session_id="custom-123")
assert session.session_id == "custom-123"
def test_state_starts_empty(self) -> None:
session = AgentSession()
assert session.state == {}
def test_service_session_id(self) -> None:
session = AgentSession(service_session_id="svc-456")
assert session.service_session_id == "svc-456"
def test_to_dict(self) -> None:
session = AgentSession(session_id="s1", service_session_id="svc1")
session.state = {"key": "value"}
d = session.to_dict()
assert d["type"] == "session"
assert d["session_id"] == "s1"
assert d["service_session_id"] == "svc1"
assert d["state"] == {"key": "value"}
def test_from_dict(self) -> None:
data = {
"type": "session",
"session_id": "s1",
"service_session_id": "svc1",
"state": {"key": "value"},
}
session = AgentSession.from_dict(data)
assert session.session_id == "s1"
assert session.service_session_id == "svc1"
assert session.state == {"key": "value"}
def test_roundtrip(self) -> None:
session = AgentSession(session_id="rt-1")
session.state = {"messages": ["a", "b"], "count": 42}
json_str = json.dumps(session.to_dict())
restored = AgentSession.from_dict(json.loads(json_str))
assert restored.session_id == "rt-1"
assert restored.state == {"messages": ["a", "b"], "count": 42}
def test_from_dict_missing_state(self) -> None:
data = {"session_id": "s1"}
session = AgentSession.from_dict(data)
assert session.state == {}
# ---------------------------------------------------------------------------
# InMemoryHistoryProvider tests
# ---------------------------------------------------------------------------
class TestInMemoryHistoryProvider:
async def test_empty_state_returns_no_messages(self) -> None:
provider = InMemoryHistoryProvider("memory")
session = AgentSession()
ctx = SessionContext(session_id="s1", input_messages=[])
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
assert ctx.context_messages.get("memory", []) == []
async def test_stores_and_loads_messages(self) -> None:
from agent_framework import AgentResponse
provider = InMemoryHistoryProvider("memory")
session = AgentSession()
# First run: send input, get response
input_msg = ChatMessage(role="user", contents=["hello"])
resp_msg = ChatMessage(role="assistant", contents=["hi there"])
ctx1 = SessionContext(session_id="s1", input_messages=[input_msg])
await provider.before_run(agent=None, session=session, context=ctx1, state=session.state) # type: ignore[arg-type]
ctx1._response = AgentResponse(messages=[resp_msg])
await provider.after_run(agent=None, session=session, context=ctx1, state=session.state) # type: ignore[arg-type]
# Second run: should load previous messages
ctx2 = SessionContext(session_id="s1", input_messages=[ChatMessage(role="user", contents=["again"])])
await provider.before_run(agent=None, session=session, context=ctx2, state=session.state) # type: ignore[arg-type]
loaded = ctx2.context_messages.get("memory", [])
assert len(loaded) == 2
assert loaded[0].text == "hello"
assert loaded[1].text == "hi there"
async def test_state_is_serializable(self) -> None:
from agent_framework import AgentResponse
provider = InMemoryHistoryProvider("memory")
session = AgentSession()
input_msg = ChatMessage(role="user", contents=["test"])
ctx = SessionContext(session_id="s1", input_messages=[input_msg])
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
ctx._response = AgentResponse(messages=[ChatMessage(role="assistant", contents=["reply"])])
await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
# State contains ChatMessage objects (not dicts)
assert isinstance(session.state["memory"]["messages"][0], ChatMessage)
# to_dict() serializes them via SerializationProtocol
session_dict = session.to_dict()
json_str = json.dumps(session_dict)
assert json_str # no error
# Round-trip through session serialization restores ChatMessage objects
restored = AgentSession.from_dict(json.loads(json_str))
assert isinstance(restored.state["memory"]["messages"][0], ChatMessage)
assert restored.state["memory"]["messages"][0].text == "test"
assert restored.state["memory"]["messages"][1].text == "reply"
async def test_source_id_attribution(self) -> None:
provider = InMemoryHistoryProvider("custom-source")
assert provider.source_id == "custom-source"
ctx = SessionContext(session_id="s1", input_messages=[])
ctx.extend_messages("custom-source", [ChatMessage(role="user", contents=["test"])])
assert "custom-source" in ctx.context_messages
@@ -8,6 +8,7 @@ import os
if os.environ.get("MEM0_TELEMETRY") is None:
os.environ["MEM0_TELEMETRY"] = "false"
from ._context_provider import _Mem0ContextProvider
from ._provider import Mem0Provider
try:
@@ -17,5 +18,6 @@ except importlib.metadata.PackageNotFoundError:
__all__ = [
"Mem0Provider",
"_Mem0ContextProvider",
"__version__",
]
@@ -0,0 +1,193 @@
# Copyright (c) Microsoft. All rights reserved.
"""New-pattern Mem0 context provider using BaseContextProvider.
This module provides ``_Mem0ContextProvider``, a side-by-side implementation of
:class:`Mem0Provider` built on the new :class:`BaseContextProvider` hooks pattern.
It will be renamed to ``Mem0ContextProvider`` in PR2 when the old class is removed.
"""
from __future__ import annotations
import sys
from contextlib import AbstractAsyncContextManager
from typing import TYPE_CHECKING, Any
from agent_framework import ChatMessage
from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext
from agent_framework.exceptions import ServiceInitializationError
from mem0 import AsyncMemory, AsyncMemoryClient
if sys.version_info >= (3, 11):
from typing import NotRequired, Self, TypedDict # pragma: no cover
else:
from typing_extensions import NotRequired, Self, TypedDict # pragma: no cover
if TYPE_CHECKING:
from agent_framework._agents import SupportsAgentRun
class _MemorySearchResponse_v1_1(TypedDict):
results: list[dict[str, Any]]
relations: NotRequired[list[dict[str, Any]]]
_MemorySearchResponse_v2 = list[dict[str, Any]]
class _Mem0ContextProvider(BaseContextProvider):
"""Mem0 context provider using the new BaseContextProvider hooks pattern.
Integrates Mem0 for persistent semantic memory, searching and storing
memories via the Mem0 API. This is the new-pattern equivalent of
:class:`Mem0Provider`.
Note:
This class uses a temporary ``_`` prefix to coexist with the existing
:class:`Mem0Provider`. It will be renamed to ``Mem0ContextProvider``
in PR2.
"""
DEFAULT_CONTEXT_PROMPT = "## Memories\nConsider the following memories when answering user questions:"
def __init__(
self,
source_id: str,
mem0_client: AsyncMemory | AsyncMemoryClient | None = None,
api_key: str | None = None,
application_id: str | None = None,
agent_id: str | None = None,
user_id: str | None = None,
*,
context_prompt: str | None = None,
) -> None:
"""Initialize the Mem0 context provider.
Args:
source_id: Unique identifier for this provider instance.
mem0_client: A pre-created Mem0 MemoryClient or None to create a default client.
api_key: The API key for authenticating with the Mem0 API.
application_id: The application ID for scoping memories.
agent_id: The agent ID for scoping memories.
user_id: The user ID for scoping memories.
context_prompt: The prompt to prepend to retrieved memories.
"""
super().__init__(source_id)
should_close_client = False
if mem0_client is None:
mem0_client = AsyncMemoryClient(api_key=api_key)
should_close_client = True
self.api_key = api_key
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.mem0_client = mem0_client
self._should_close_client = should_close_client
async def __aenter__(self) -> Self:
"""Async context manager entry."""
if self.mem0_client and isinstance(self.mem0_client, AbstractAsyncContextManager):
await self.mem0_client.__aenter__()
return self
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
"""Async context manager exit."""
if self._should_close_client and self.mem0_client and isinstance(self.mem0_client, AbstractAsyncContextManager):
await self.mem0_client.__aexit__(exc_type, exc_val, exc_tb)
# -- Hooks pattern ---------------------------------------------------------
async def before_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Search Mem0 for relevant memories 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
filters = self._build_filters(session_id=context.session_id)
search_response: _MemorySearchResponse_v1_1 | _MemorySearchResponse_v2 = await self.mem0_client.search( # type: ignore[misc]
query=input_text,
filters=filters,
)
if isinstance(search_response, list):
memories = search_response
elif isinstance(search_response, dict) and "results" in search_response:
memories = search_response["results"]
else:
memories = [search_response]
line_separated_memories = "\n".join(memory.get("memory", "") for memory in memories)
if line_separated_memories:
context.extend_messages(
self.source_id,
[ChatMessage(role="user", text=f"{self.context_prompt}\n{line_separated_memories}")],
)
async def after_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Store request/response messages to Mem0 for future retrieval."""
self._validate_filters()
messages_to_store: list[ChatMessage] = list(context.input_messages)
if context.response and context.response.messages:
messages_to_store.extend(context.response.messages)
def get_role_value(role: Any) -> str:
return role.value if hasattr(role, "value") else str(role)
messages: list[dict[str, str]] = [
{"role": get_role_value(message.role), "content": message.text}
for message in messages_to_store
if get_role_value(message.role) in {"user", "assistant", "system"} and message.text and message.text.strip()
]
if messages:
await self.mem0_client.add( # type: ignore[misc]
messages=messages,
user_id=self.user_id,
agent_id=self.agent_id,
run_id=context.session_id,
metadata={"application_id": self.application_id},
)
# -- Internal methods ------------------------------------------------------
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 ServiceInitializationError(
"At least one of the filters: agent_id, user_id, or application_id is required."
)
def _build_filters(self, *, session_id: str | None = None) -> dict[str, Any]:
"""Build search filters from initialization parameters."""
filters: dict[str, Any] = {}
if self.user_id:
filters["user_id"] = self.user_id
if self.agent_id:
filters["agent_id"] = self.agent_id
if session_id:
filters["run_id"] = session_id
if self.application_id:
filters["app_id"] = self.application_id
return filters
__all__ = ["_Mem0ContextProvider"]
@@ -0,0 +1,352 @@
# Copyright (c) Microsoft. All rights reserved.
# pyright: reportPrivateUsage=false
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from agent_framework import AgentResponse, ChatMessage
from agent_framework._sessions import AgentSession, SessionContext
from agent_framework.exceptions import ServiceInitializationError
from agent_framework_mem0._context_provider import _Mem0ContextProvider
@pytest.fixture
def mock_mem0_client() -> AsyncMock:
"""Create a mock Mem0 AsyncMemoryClient."""
from mem0 import AsyncMemoryClient
mock_client = AsyncMock(spec=AsyncMemoryClient)
mock_client.add = AsyncMock()
mock_client.search = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
return mock_client
# -- Initialization tests ------------------------------------------------------
class TestInit:
"""Test _Mem0ContextProvider initialization."""
def test_init_with_all_params(self, mock_mem0_client: AsyncMock) -> None:
provider = _Mem0ContextProvider(
source_id="mem0",
mem0_client=mock_mem0_client,
api_key="key-123",
application_id="app1",
agent_id="agent1",
user_id="user1",
context_prompt="Custom prompt",
)
assert provider.source_id == "mem0"
assert provider.api_key == "key-123"
assert provider.application_id == "app1"
assert provider.agent_id == "agent1"
assert provider.user_id == "user1"
assert provider.context_prompt == "Custom prompt"
assert provider.mem0_client is mock_mem0_client
assert provider._should_close_client is False
def test_init_default_context_prompt(self, mock_mem0_client: AsyncMock) -> None:
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
assert provider.context_prompt == _Mem0ContextProvider.DEFAULT_CONTEXT_PROMPT
def test_init_auto_creates_client_when_none(self) -> None:
"""When no client is provided, a default AsyncMemoryClient is created and flagged for closing."""
with (
patch("mem0.client.main.AsyncMemoryClient.__init__", return_value=None) as mock_init,
patch("mem0.client.main.AsyncMemoryClient._validate_api_key", return_value=None),
):
provider = _Mem0ContextProvider(source_id="mem0", api_key="test-key", user_id="u1")
mock_init.assert_called_once_with(api_key="test-key")
assert provider._should_close_client is True
def test_provided_client_not_flagged_for_close(self, mock_mem0_client: AsyncMock) -> None:
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
assert provider._should_close_client is False
# -- before_run tests ----------------------------------------------------------
class TestBeforeRun:
"""Test before_run hook."""
async def test_memories_added_to_context(self, mock_mem0_client: AsyncMock) -> None:
"""Mocked mem0 search returns memories → messages added to context with prompt."""
mock_mem0_client.search.return_value = [
{"memory": "User likes Python"},
{"memory": "User prefers dark mode"},
]
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[ChatMessage(role="user", text="Hello")], session_id="s1")
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
mock_mem0_client.search.assert_awaited_once()
assert "mem0" in ctx.context_messages
added = ctx.context_messages["mem0"]
assert len(added) == 1
assert "User likes Python" in added[0].text # type: ignore[operator]
assert "User prefers dark mode" in added[0].text # type: ignore[operator]
assert provider.context_prompt in added[0].text # type: ignore[operator]
async def test_empty_input_skips_search(self, mock_mem0_client: AsyncMock) -> None:
"""Empty input messages → no search performed."""
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[ChatMessage(role="user", text="")], session_id="s1")
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
mock_mem0_client.search.assert_not_awaited()
assert "mem0" not in ctx.context_messages
async def test_empty_search_results_no_messages(self, mock_mem0_client: AsyncMock) -> None:
"""Empty search results → no messages added."""
mock_mem0_client.search.return_value = []
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[ChatMessage(role="user", text="test")], session_id="s1")
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
assert "mem0" not in ctx.context_messages
async def test_validates_filters_before_search(self, mock_mem0_client: AsyncMock) -> None:
"""Raises ServiceInitializationError when no filters."""
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client)
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[ChatMessage(role="user", text="test")], session_id="s1")
with pytest.raises(ServiceInitializationError, match="At least one of the filters"):
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
async def test_v1_1_response_format(self, mock_mem0_client: AsyncMock) -> None:
"""Search response in v1.1 dict format with 'results' key."""
mock_mem0_client.search.return_value = {"results": [{"memory": "remembered fact"}]}
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[ChatMessage(role="user", text="test")], session_id="s1")
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
added = ctx.context_messages["mem0"]
assert "remembered fact" in added[0].text # type: ignore[operator]
async def test_search_query_combines_input_messages(self, mock_mem0_client: AsyncMock) -> None:
"""Multiple input messages are joined for the search query."""
mock_mem0_client.search.return_value = []
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
session = AgentSession(session_id="test-session")
ctx = SessionContext(
input_messages=[
ChatMessage(role="user", text="Hello"),
ChatMessage(role="user", text="World"),
],
session_id="s1",
)
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
call_kwargs = mock_mem0_client.search.call_args.kwargs
assert call_kwargs["query"] == "Hello\nWorld"
# -- after_run tests -----------------------------------------------------------
class TestAfterRun:
"""Test after_run hook."""
async def test_stores_input_and_response(self, mock_mem0_client: AsyncMock) -> None:
"""Stores input+response messages to mem0 via client.add."""
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[ChatMessage(role="user", text="question")], session_id="s1")
ctx._response = AgentResponse(messages=[ChatMessage(role="assistant", text="answer")])
await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
mock_mem0_client.add.assert_awaited_once()
call_kwargs = mock_mem0_client.add.call_args.kwargs
assert call_kwargs["messages"] == [
{"role": "user", "content": "question"},
{"role": "assistant", "content": "answer"},
]
assert call_kwargs["user_id"] == "u1"
assert call_kwargs["run_id"] == "s1"
async def test_only_stores_user_assistant_system(self, mock_mem0_client: AsyncMock) -> None:
"""Only stores user/assistant/system messages with text."""
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
session = AgentSession(session_id="test-session")
ctx = SessionContext(
input_messages=[
ChatMessage(role="user", text="hello"),
ChatMessage(role="tool", text="tool output"),
],
session_id="s1",
)
ctx._response = AgentResponse(messages=[ChatMessage(role="assistant", text="reply")])
await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
call_kwargs = mock_mem0_client.add.call_args.kwargs
roles = [m["role"] for m in call_kwargs["messages"]]
assert "tool" not in roles
assert roles == ["user", "assistant"]
async def test_skips_empty_messages(self, mock_mem0_client: AsyncMock) -> None:
"""Skips messages with empty text."""
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
session = AgentSession(session_id="test-session")
ctx = SessionContext(
input_messages=[
ChatMessage(role="user", text=""),
ChatMessage(role="user", text=" "),
],
session_id="s1",
)
ctx._response = AgentResponse(messages=[])
await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
mock_mem0_client.add.assert_not_awaited()
async def test_uses_session_id_as_run_id(self, mock_mem0_client: AsyncMock) -> None:
"""Uses session_id as run_id."""
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[ChatMessage(role="user", text="hi")], session_id="my-session")
ctx._response = AgentResponse(messages=[ChatMessage(role="assistant", text="hey")])
await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
assert mock_mem0_client.add.call_args.kwargs["run_id"] == "my-session"
async def test_validates_filters(self, mock_mem0_client: AsyncMock) -> None:
"""Raises ServiceInitializationError when no filters."""
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client)
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[ChatMessage(role="user", text="hi")], session_id="s1")
ctx._response = AgentResponse(messages=[ChatMessage(role="assistant", text="hey")])
with pytest.raises(ServiceInitializationError, match="At least one of the filters"):
await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
async def test_stores_with_application_id_metadata(self, mock_mem0_client: AsyncMock) -> None:
"""application_id is passed in metadata."""
provider = _Mem0ContextProvider(
source_id="mem0", mem0_client=mock_mem0_client, user_id="u1", application_id="app1"
)
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[ChatMessage(role="user", text="hi")], session_id="s1")
ctx._response = AgentResponse(messages=[])
await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
assert mock_mem0_client.add.call_args.kwargs["metadata"] == {"application_id": "app1"}
# -- _validate_filters tests --------------------------------------------------
class TestValidateFilters:
"""Test _validate_filters method."""
def test_raises_when_no_filters(self, mock_mem0_client: AsyncMock) -> None:
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client)
with pytest.raises(ServiceInitializationError, match="At least one of the filters"):
provider._validate_filters()
def test_passes_with_user_id(self, mock_mem0_client: AsyncMock) -> None:
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
provider._validate_filters() # should not raise
def test_passes_with_agent_id(self, mock_mem0_client: AsyncMock) -> None:
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, agent_id="a1")
provider._validate_filters()
def test_passes_with_application_id(self, mock_mem0_client: AsyncMock) -> None:
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, application_id="app1")
provider._validate_filters()
# -- _build_filters tests -----------------------------------------------------
class TestBuildFilters:
"""Test _build_filters method."""
def test_user_id_only(self, mock_mem0_client: AsyncMock) -> None:
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
assert provider._build_filters() == {"user_id": "u1"}
def test_all_params(self, mock_mem0_client: AsyncMock) -> None:
provider = _Mem0ContextProvider(
source_id="mem0",
mem0_client=mock_mem0_client,
user_id="u1",
agent_id="a1",
application_id="app1",
)
assert provider._build_filters(session_id="sess1") == {
"user_id": "u1",
"agent_id": "a1",
"run_id": "sess1",
"app_id": "app1",
}
def test_excludes_none_values(self, mock_mem0_client: AsyncMock) -> None:
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
filters = provider._build_filters()
assert "agent_id" not in filters
assert "run_id" not in filters
assert "app_id" not in filters
def test_session_id_mapped_to_run_id(self, mock_mem0_client: AsyncMock) -> None:
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
filters = provider._build_filters(session_id="s99")
assert filters["run_id"] == "s99"
def test_empty_when_no_params(self, mock_mem0_client: AsyncMock) -> None:
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client)
assert provider._build_filters() == {}
# -- Context manager tests -----------------------------------------------------
class TestContextManager:
"""Test __aenter__/__aexit__ delegation."""
async def test_aenter_delegates_to_client(self, mock_mem0_client: AsyncMock) -> None:
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
result = await provider.__aenter__()
assert result is provider
mock_mem0_client.__aenter__.assert_awaited_once()
async def test_aexit_closes_auto_created_client(self, mock_mem0_client: AsyncMock) -> None:
"""Auto-created clients (_should_close_client=True) are closed on exit."""
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
provider._should_close_client = True
await provider.__aexit__(None, None, None)
mock_mem0_client.__aexit__.assert_awaited_once()
async def test_aexit_does_not_close_provided_client(self, mock_mem0_client: AsyncMock) -> None:
"""Provided clients (_should_close_client=False) are NOT closed on exit."""
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
assert provider._should_close_client is False
await provider.__aexit__(None, None, None)
mock_mem0_client.__aexit__.assert_not_awaited()
async def test_async_with_syntax(self, mock_mem0_client: AsyncMock) -> None:
provider = _Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
async with provider as p:
assert p is provider
@@ -2,6 +2,8 @@
import importlib.metadata
from ._chat_message_store import RedisChatMessageStore
from ._context_provider import _RedisContextProvider
from ._history_provider import _RedisHistoryProvider
from ._provider import RedisProvider
try:
@@ -12,5 +14,7 @@ except importlib.metadata.PackageNotFoundError:
__all__ = [
"RedisChatMessageStore",
"RedisProvider",
"_RedisContextProvider",
"_RedisHistoryProvider",
"__version__",
]
@@ -0,0 +1,432 @@
# Copyright (c) Microsoft. All rights reserved.
"""New-pattern Redis context provider using BaseContextProvider.
This module provides ``_RedisContextProvider``, a side-by-side implementation of
:class:`RedisProvider` built on the new :class:`BaseContextProvider` hooks pattern.
It will be renamed to ``RedisContextProvider`` in PR2 when the old class is removed.
"""
from __future__ import annotations
import json
import sys
from functools import reduce
from operator import and_
from typing import TYPE_CHECKING, Any, Literal, cast
import numpy as np
from agent_framework import ChatMessage
from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext
from agent_framework.exceptions import (
AgentException,
ServiceInitializationError,
ServiceInvalidRequestError,
)
from redisvl.index import AsyncSearchIndex
from redisvl.query import HybridQuery, 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 # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
if TYPE_CHECKING:
from agent_framework._agents import SupportsAgentRun
class _RedisContextProvider(BaseContextProvider):
"""Redis context provider using the new BaseContextProvider hooks pattern.
Stores context in Redis and retrieves scoped context via full-text or
optional hybrid vector search. This is the new-pattern equivalent of
:class:`RedisProvider`.
Note:
This class uses a temporary ``_`` prefix to coexist with the existing
:class:`RedisProvider`. It will be renamed to ``RedisContextProvider``
in PR2.
"""
DEFAULT_CONTEXT_PROMPT = "## Memories\nConsider the following memories when answering user questions:"
def __init__(
self,
source_id: str,
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
self.redis_index = redis_index or AsyncSearchIndex.from_dict(
self.schema_dict, redis_url=self.redis_url, validate_on_load=True
)
# -- 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, session_id=context.session_id)
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,
[ChatMessage(role="user", text=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[ChatMessage] = 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 = [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."""
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 = 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:
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]],
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 ServiceInvalidRequestError("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(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 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": 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)
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)
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
raise ServiceInvalidRequestError(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 ServiceInitializationError(
"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,187 @@
# Copyright (c) Microsoft. All rights reserved.
"""New-pattern Redis history provider using BaseHistoryProvider.
This module provides ``_RedisHistoryProvider``, a side-by-side implementation of
:class:`RedisChatMessageStore` built on the new :class:`BaseHistoryProvider` hooks pattern.
It will be renamed to ``RedisHistoryProvider`` in PR2 when the old class is removed.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any
import redis.asyncio as redis
from agent_framework import ChatMessage
from agent_framework._sessions import BaseHistoryProvider
from redis.credentials import CredentialProvider
if TYPE_CHECKING:
pass
class _RedisHistoryProvider(BaseHistoryProvider):
"""Redis-backed history provider using the new BaseHistoryProvider hooks pattern.
Stores conversation history in Redis Lists, with each session isolated by a
unique Redis key. This is the new-pattern equivalent of
:class:`RedisChatMessageStore`.
Note:
This class uses a temporary ``_`` prefix to coexist with the existing
:class:`RedisChatMessageStore`. It will be renamed to ``RedisHistoryProvider``
in PR2.
"""
def __init__(
self,
source_id: str,
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, **kwargs: Any) -> list[ChatMessage]:
"""Retrieve stored messages for this session from Redis.
Args:
session_id: The session ID to retrieve messages for.
**kwargs: Additional arguments (unused).
Returns:
List of stored ChatMessage objects in chronological order.
"""
key = self._redis_key(session_id)
redis_messages = await self._redis_client.lrange(key, 0, -1) # type: ignore[misc]
messages: list[ChatMessage] = []
if redis_messages:
for serialized in redis_messages:
messages.append(ChatMessage.from_dict(self._deserialize_json(serialized)))
return messages
async def save_messages(self, session_id: str | None, messages: Sequence[ChatMessage], **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.
**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: ChatMessage) -> str:
"""Serialize a ChatMessage 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) # type: ignore[no-any-return]
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() # type: ignore[misc]
__all__ = ["_RedisHistoryProvider"]
@@ -0,0 +1,455 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for _RedisContextProvider and _RedisHistoryProvider."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import AgentResponse, ChatMessage
from agent_framework._sessions import AgentSession, SessionContext
from agent_framework.exceptions import ServiceInitializationError
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]
class TestRedisContextProviderValidateFilters:
def test_no_filters_raises(self, patch_index_from_dict: MagicMock): # noqa: ARG002
provider = _RedisContextProvider(source_id="ctx")
with pytest.raises(ServiceInitializationError, 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", **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=[ChatMessage(role="user", contents=["test query"])], session_id="s1")
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # 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=[ChatMessage(role="user", contents=[" "])], session_id="s1")
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
mock_index.query.assert_not_called()
assert "ctx" not in ctx.context_messages
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=[ChatMessage(role="user", contents=["hello"])], session_id="s1")
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # 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=[ChatMessage(role="assistant", contents=["response text"])])
ctx = SessionContext(input_messages=[ChatMessage(role="user", contents=["user input"])], session_id="s1")
ctx._response = response
await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # 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=[ChatMessage(role="user", contents=[" "])], session_id="s1")
await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # 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=[ChatMessage(role="user", contents=["hello"])], session_id="s1")
await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # 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
# ===========================================================================
# _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 = ChatMessage(role="user", contents=["Hello"])
msg2 = ChatMessage(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 = [ChatMessage(role="user", contents=["Hello"]), ChatMessage(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", [ChatMessage(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", [ChatMessage(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 BaseHistoryProvider defaults."""
async def test_before_run_loads_history(self, mock_redis_client: MagicMock):
msg = ChatMessage(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=[ChatMessage(role="user", contents=["new msg"])], session_id="s1")
await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # 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=[ChatMessage(role="user", contents=["hi"])], session_id="s1")
ctx._response = AgentResponse(messages=[ChatMessage(role="assistant", contents=["hello"])])
await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # 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=[ChatMessage(role="user", contents=["hi"])], session_id="s1")
await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
mock_redis_client.pipeline.assert_not_called()