Python: Feature/azure ai search agentic rag (search as separate package) (#2328)

* Python: Fix pyright errors and move search provider to core (#1546)

* address pablo coments

* update azure ai search pypi version to latest prev

* init update

* Fix MyPy type annotation errors in search provider

- Add type annotation to DEFAULT_CONTEXT_PROMPT
- Add type annotation to vectorizable_fields
- Add union type annotation to vector_queries

* Fix DEFAULT_CONTEXT_PROMPT MyPy error and update test

- Rename DEFAULT_CONTEXT_PROMPT to _DEFAULT_SEARCH_CONTEXT_PROMPT to avoid conflict with base class Final variable
- Update test to use new constant name
- All core package tests passing (1123 passed)

* Python: Move Azure AI Search to separate package per PR feedback

Addresses reviewer feedback from PR #1546 by isolating the beta dependency
(azure-search-documents==11.7.0b2) into a new agent-framework-aisearch package.

Changes:
- Created new agent-framework-aisearch package with complete structure
- Moved AzureAISearchContextProvider from core to aisearch package
- Added AzureAISearchSettings class for environment variable auto-loading
- Added support for direct API key string (auto-converts to AzureKeyCredential)
- Added azure_openai_api_key parameter for Knowledge Base authentication
- Updated embedding_function type to Callable[[str], Awaitable[list[float]]]
- Moved Role import to top-level imports
- Maintained lazy loading through agent_framework.azure module
- Removed beta dependency from core package
- Updated all tests to use new package location
- All quality checks pass: ruff format/lint, pyright, mypy (0 errors)
- All 21 unit tests pass with 59% coverage

Semantic search mode verified working with both API key and managed identity authentication.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Python: Clarify top_k parameter only applies to semantic mode

Updated documentation to clarify that the top_k parameter only affects
semantic search mode. In agentic mode, the server-side Knowledge Base
determines retrieval based on query complexity and reasoning effort.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Python: Add Knowledge Base output mode and retrieval reasoning effort parameters

Added support for configurable Knowledge Base behavior in agentic mode:

- knowledge_base_output_mode: "extractive_data" (default) or "answer_synthesis"
  Some knowledge sources require answer_synthesis mode for proper functionality.

- retrieval_reasoning_effort: "minimal" (default), "medium", or "low"
  Controls query planning complexity and multi-hop reasoning depth.

These parameters give users fine-grained control over Knowledge Base behavior
and enable support for knowledge sources that require answer synthesis.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* effort and outputmode query params

* Address PR review feedback for Azure AI Search context provider

* comments eduward

* ed latest comments

---------

Co-authored-by: Farzad Sunavala <farzad.sunavala.enovate.ai>
Co-authored-by: farzad528 <farzad528@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Farzad Sunavala
2025-11-20 22:34:46 +00:00
committed by GitHub
Unverified
parent ab3d898979
commit 04e711cd55
14 changed files with 2330 additions and 0 deletions
+8
View File
@@ -3,6 +3,14 @@ AZURE_AI_PROJECT_ENDPOINT=""
AZURE_AI_MODEL_DEPLOYMENT_NAME=""
# Bing connection for web search (optional, used by samples with web search)
BING_CONNECTION_ID=""
# Azure AI Search (optional, used by AzureAISearchContextProvider samples)
AZURE_SEARCH_ENDPOINT=""
AZURE_SEARCH_API_KEY=""
AZURE_SEARCH_INDEX_NAME=""
AZURE_SEARCH_SEMANTIC_CONFIG=""
AZURE_SEARCH_KNOWLEDGE_BASE_NAME=""
# Note: For agentic mode Knowledge Bases, also set AZURE_OPENAI_ENDPOINT below
# (different from AZURE_AI_PROJECT_ENDPOINT - Knowledge Base needs OpenAI endpoint for model calls)
# OpenAI
OPENAI_API_KEY=""
OPENAI_CHAT_MODEL_ID=""
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+23
View File
@@ -0,0 +1,23 @@
# Get Started with Microsoft Agent Framework Azure AI Search
Please install this package via pip:
```bash
pip install agent-framework-aisearch --pre
```
## Azure AI Search Integration
The Azure AI Search integration provides context providers for RAG (Retrieval Augmented Generation) capabilities with two modes:
- **Semantic Mode**: Fast hybrid search (vector + keyword) with semantic ranking
- **Agentic Mode**: Multi-hop reasoning using Knowledge Bases for complex queries
### Basic Usage Example
See the [Azure AI Search context provider examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/azure_ai/) which demonstrate:
- Semantic search with hybrid (vector + keyword) queries
- Agentic mode with Knowledge Bases for complex multi-hop reasoning
- Environment variable configuration with Settings class
- API key and managed identity authentication
@@ -0,0 +1,16 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
from ._search_provider import AzureAISearchContextProvider, AzureAISearchSettings
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"AzureAISearchContextProvider",
"AzureAISearchSettings",
"__version__",
]
@@ -0,0 +1,914 @@
# Copyright (c) Microsoft. All rights reserved.
"""Azure AI Search Context Provider for Agent Framework.
This module provides context providers for Azure AI Search integration with two modes:
- Agentic: Recommended for most scenarios. Uses Knowledge Bases for query planning and
multi-hop reasoning. Slightly slower with more token consumption, but more accurate.
- Semantic: Fast hybrid search (vector + keyword) with semantic ranker. Best for simple
queries where speed is critical.
See: https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/foundry-iq-boost-response-relevance-by-36-with-agentic-retrieval/4470720
"""
import sys
from collections.abc import Awaitable, Callable, MutableSequence
from typing import TYPE_CHECKING, Any, ClassVar, Literal
from agent_framework import ChatMessage, Context, ContextProvider, Role
from agent_framework._logging import get_logger
from agent_framework._pydantic import AFBaseSettings
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 SecretStr, ValidationError
# Type checking imports for optional agentic mode dependencies
if TYPE_CHECKING:
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,
)
# 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
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
# Module-level constants
logger = get_logger("agent_framework.azure")
_DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT = 10
class AzureAISearchSettings(AFBaseSettings):
"""Settings for Azure AI Search Context Provider with auto-loading from environment.
The settings are first loaded from environment variables with the prefix 'AZURE_SEARCH_'.
If the environment variables are not found, the settings can be loaded from a .env file.
Keyword Args:
endpoint: Azure AI Search endpoint URL.
Can be set via environment variable AZURE_SEARCH_ENDPOINT.
index_name: Name of the search index.
Can be set via environment variable AZURE_SEARCH_INDEX_NAME.
api_key: API key for authentication (optional, use managed identity if not provided).
Can be set via environment variable AZURE_SEARCH_API_KEY.
env_file_path: If provided, the .env settings are read from this file path location.
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
Examples:
.. code-block:: python
from agent_framework_aisearch import AzureAISearchSettings
# Using environment variables
# Set AZURE_SEARCH_ENDPOINT=https://mysearch.search.windows.net
# Set AZURE_SEARCH_INDEX_NAME=my-index
settings = AzureAISearchSettings()
# Or passing parameters directly
settings = AzureAISearchSettings(
endpoint="https://mysearch.search.windows.net",
index_name="my-index",
)
# Or loading from a .env file
settings = AzureAISearchSettings(env_file_path="path/to/.env")
"""
env_prefix: ClassVar[str] = "AZURE_SEARCH_"
endpoint: str | None = None
index_name: str | None = None
api_key: SecretStr | None = None
class AzureAISearchContextProvider(ContextProvider):
"""Azure AI Search Context Provider with hybrid search and semantic ranking.
This provider retrieves relevant documents from Azure AI Search to provide context
to the AI agent. It supports two modes:
- **agentic**: Recommended for most scenarios. Uses Knowledge Bases for query planning
and multi-hop reasoning. Slightly slower with more token consumption, but provides
more accurate results (up to 36% improvement in response relevance).
- **semantic** (default): Fast hybrid search combining vector and keyword search
with semantic reranking. Best for simple queries where speed is critical.
Examples:
Using environment variables (recommended):
.. code-block:: python
from agent_framework_aisearch import AzureAISearchContextProvider
from azure.identity.aio import DefaultAzureCredential
# Set AZURE_SEARCH_ENDPOINT and AZURE_SEARCH_INDEX_NAME in environment
search_provider = AzureAISearchContextProvider(credential=DefaultAzureCredential())
Semantic hybrid search with API key:
.. code-block:: python
# Direct API key string
search_provider = AzureAISearchContextProvider(
endpoint="https://mysearch.search.windows.net",
index_name="my-index",
api_key="my-api-key",
mode="semantic",
)
Loading from .env file:
.. code-block:: python
# Load settings from a .env file
search_provider = AzureAISearchContextProvider(
credential=DefaultAzureCredential(), env_file_path="path/to/.env"
)
Agentic retrieval for complex queries:
.. code-block:: python
# Use agentic mode for multi-hop reasoning
# Note: azure_openai_resource_url is the OpenAI endpoint for Knowledge Base model calls,
# which is different from azure_ai_project_endpoint (the AI Foundry project endpoint)
search_provider = AzureAISearchContextProvider(
endpoint="https://mysearch.search.windows.net",
index_name="my-index",
credential=DefaultAzureCredential(),
mode="agentic",
azure_openai_resource_url="https://myresource.openai.azure.com",
model_deployment_name="gpt-4o",
knowledge_base_name="my-knowledge-base",
)
"""
_DEFAULT_SEARCH_CONTEXT_PROMPT = "Use the following context to answer the question:"
def __init__(
self,
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,
# Agentic mode parameters (Knowledge Base)
azure_ai_project_endpoint: 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:
endpoint: Azure AI Search endpoint URL.
Can also be set via environment variable AZURE_SEARCH_ENDPOINT.
index_name: Name of the search index to query.
Can also be set via environment variable AZURE_SEARCH_INDEX_NAME.
api_key: API key for authentication (string or AzureKeyCredential).
Can also be set via environment variable AZURE_SEARCH_API_KEY.
credential: AsyncTokenCredential for managed identity authentication.
Use this for Entra ID authentication instead of api_key.
mode: Search mode - "semantic" for hybrid search with semantic ranking (fast)
or "agentic" for multi-hop reasoning (slower). Default: "semantic".
top_k: Maximum number of documents to retrieve. Only applies to semantic mode.
In agentic mode, the server-side Knowledge Base determines retrieval based on
query complexity and reasoning effort. Default: 5.
semantic_configuration_name: Name of semantic configuration in the index.
Required for semantic ranking. If None, uses index default.
vector_field_name: Name of the vector field in the index for hybrid search.
Required if using vector search. Default: None (keyword search only).
embedding_function: Async function to generate embeddings for vector search.
Signature: async def embed(text: str) -> list[float]
Required if vector_field_name is specified and no server-side vectorization.
context_prompt: Custom prompt to prepend to retrieved context.
Default: "Use the following context to answer the question:"
azure_ai_project_endpoint: Azure AI Foundry project endpoint URL.
This is NOT the same as azure_openai_resource_url - the project endpoint is used
for Azure AI Foundry services, while the OpenAI endpoint is used by the Knowledge
Base to call the model for query planning. Required for agentic mode.
Example: "https://myproject.services.ai.azure.com/api/projects/myproject"
azure_openai_resource_url: Azure OpenAI resource URL for Knowledge Base model calls.
This is the OpenAI endpoint used by the Knowledge Base to call the LLM for
query planning and reasoning. This is separate from the project endpoint because
the Knowledge Base directly calls Azure OpenAI for its internal operations.
Required for agentic mode. Example: "https://myresource.openai.azure.com"
model_deployment_name: Model deployment name in Azure OpenAI for Knowledge Base.
This is the deployment name the Knowledge Base uses to call the LLM.
Required for agentic mode.
model_name: The underlying model name (e.g., "gpt-4o", "gpt-4o-mini").
If not provided, defaults to model_deployment_name. Used for Knowledge Base configuration.
knowledge_base_name: Name for the Knowledge Base. Required for agentic mode.
retrieval_instructions: Custom instructions for the Knowledge Base's
retrieval planning. Only used in agentic mode.
azure_openai_api_key: Azure OpenAI API key for Knowledge Base to call the model.
Only needed when using API key authentication instead of managed identity.
knowledge_base_output_mode: Output mode for Knowledge Base retrieval. Only used in agentic mode.
"extractive_data": Returns raw chunks without synthesis (default, recommended for agent integration).
"answer_synthesis": Returns synthesized answer from the LLM.
Some knowledge sources require answer_synthesis mode. Default: "extractive_data".
retrieval_reasoning_effort: Reasoning effort for Knowledge Base query planning. Only used in agentic mode.
"minimal": Fastest, basic query planning.
"medium": Moderate reasoning with some query decomposition.
"low": Lower reasoning effort than medium.
Default: "minimal".
agentic_message_history_count: Number of recent messages from conversation history to send to
the Knowledge Base. This context helps with query planning in agentic mode, allowing the
Knowledge Base to understand the conversation flow and generate better retrieval queries.
There is no technical limit - adjust based on your use case. Default: 10.
env_file_path: Path to environment file for loading settings.
env_file_encoding: Encoding of the environment file.
Examples:
.. code-block:: python
from agent_framework_aisearch import AzureAISearchContextProvider
from azure.identity.aio import DefaultAzureCredential
# Using environment variables
# Set AZURE_SEARCH_ENDPOINT=https://mysearch.search.windows.net
# Set AZURE_SEARCH_INDEX_NAME=my-index
credential = DefaultAzureCredential()
provider = AzureAISearchContextProvider(credential=credential)
# Or passing parameters directly
provider = AzureAISearchContextProvider(
endpoint="https://mysearch.search.windows.net",
index_name="my-index",
credential=credential,
)
# Or loading from a .env file
provider = AzureAISearchContextProvider(credential=credential, env_file_path="path/to/.env")
"""
# Load settings from environment/file
try:
settings = AzureAISearchSettings(
endpoint=endpoint,
index_name=index_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
# Validate required parameters
if not settings.endpoint:
raise ServiceInitializationError(
"Azure AI Search endpoint is required. Set via 'endpoint' parameter "
"or 'AZURE_SEARCH_ENDPOINT' environment variable."
)
if not settings.index_name:
raise ServiceInitializationError(
"Azure AI Search index name is required. Set via 'index_name' parameter "
"or 'AZURE_SEARCH_INDEX_NAME' environment variable."
)
# Determine the credential to use
resolved_credential: AzureKeyCredential | AsyncTokenCredential
if credential:
# AsyncTokenCredential takes precedence
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
# Agentic mode parameters (Knowledge Base)
self.azure_openai_resource_url = azure_openai_resource_url
self.azure_openai_deployment_name = model_deployment_name
# If model_name not provided, default to deployment name
self.model_name = model_name or model_deployment_name
self.knowledge_base_name = knowledge_base_name
self.retrieval_instructions = retrieval_instructions
self.azure_openai_api_key = azure_openai_api_key
self.azure_ai_project_endpoint = azure_ai_project_endpoint
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
# Auto-discover vector field if not specified
self._auto_discovered_vector_field = False
self._use_vectorizable_query = False # Will be set to True if server-side vectorization detected
if not vector_field_name and mode == "semantic":
# Attempt to auto-discover vector field from index schema
# This will be done lazily on first search to avoid blocking initialization
pass
# Validation
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. "
"Please upgrade: pip install azure-search-documents>=11.7.0b1"
)
if not self.azure_openai_resource_url:
raise ValueError(
"azure_openai_resource_url is required for agentic mode. "
"This should be your Azure OpenAI endpoint (e.g., 'https://myresource.openai.azure.com')"
)
if not self.azure_openai_deployment_name:
raise ValueError("model_deployment_name is required for agentic mode")
if not knowledge_base_name:
raise ValueError("knowledge_base_name is required for agentic mode")
# Create search client for semantic mode
self._search_client = SearchClient(
endpoint=self.endpoint,
index_name=self.index_name,
credential=self.credential,
)
# Create index client and retrieval client for agentic mode (Knowledge Base)
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,
)
# Retrieval client will be created after Knowledge Base initialization
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.
Args:
exc_type: Exception type if an error occurred.
exc_val: Exception value if an error occurred.
exc_tb: Exception traceback if an error occurred.
"""
# Close retrieval client if it was created
if self._retrieval_client is not None:
await self._retrieval_client.close()
self._retrieval_client = None
@override
async def invoking(
self,
messages: ChatMessage | MutableSequence[ChatMessage],
**kwargs: Any,
) -> Context:
"""Retrieve relevant context from Azure AI Search before model invocation.
Args:
messages: User messages to use for context retrieval.
**kwargs: Additional arguments (unused).
Returns:
Context object with retrieved documents as messages.
"""
# Convert to list and filter to USER/ASSISTANT messages with text only
messages_list = [messages] if isinstance(messages, ChatMessage) else list(messages)
filtered_messages = [
msg
for msg in messages_list
if msg and msg.text and msg.text.strip() and msg.role in [Role.USER, Role.ASSISTANT]
]
if not filtered_messages:
return Context()
# Perform search based on mode
if self.mode == "semantic":
# Semantic mode: flatten messages to single query
query = "\n".join(msg.text for msg in filtered_messages)
search_result_parts = await self._semantic_search(query)
else: # agentic
# Agentic mode: pass recent messages as conversation history
recent_messages = filtered_messages[-self.agentic_message_history_count :]
search_result_parts = await self._agentic_search(recent_messages)
# Format results as context - return multiple messages for each result part
if not search_result_parts:
return Context()
# Create context messages: first message with prompt, then one message per result part
context_messages = [ChatMessage(role=Role.USER, text=self.context_prompt)]
context_messages.extend([ChatMessage(role=Role.USER, text=part) for part in search_result_parts])
return Context(messages=context_messages)
def _find_vector_fields(self, index: Any) -> list[str]:
"""Find all fields that can store vectors (have dimensions defined).
Args:
index: SearchIndex object from Azure Search.
Returns:
List of vector field names.
"""
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.
These are fields that have a vectorizer in their profile, meaning the index
can automatically vectorize text queries without needing a client-side embedding function.
Args:
index: SearchIndex object from Azure Search.
vector_fields: List of vector field names.
Returns:
List of vectorizable field names (subset of vector_fields).
"""
vectorizable_fields: list[str] = []
# Check if index has vector search configuration
if not index.vector_search or not index.vector_search.profiles:
return vectorizable_fields
# For each vector field, check if it has a vectorizer configured
for field in index.fields:
if field.name in vector_fields and field.vector_search_profile_name:
# Find the profile for this field
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:
# This field has server-side vectorization configured
vectorizable_fields.append(field.name)
return vectorizable_fields
async def _auto_discover_vector_field(self) -> None:
"""Auto-discover vector field from index schema.
Attempts to find vector fields in the index and detect which have server-side
vectorization configured. Prioritizes vectorizable fields (which can auto-embed text)
over regular vector fields (which require client-side embedding).
"""
if self._auto_discovered_vector_field or self.vector_field_name:
return # Already discovered or manually specified
try:
# Use existing index client or create temporary one
if not self._index_client:
self._index_client = SearchIndexClient(endpoint=self.endpoint, credential=self.credential)
index_client = self._index_client
# Get index schema
index = await index_client.get_index(self.index_name)
# Step 1: Find all vector fields
vector_fields = self._find_vector_fields(index)
if not vector_fields:
# No vector fields found - keyword search only
logger.info(f"No vector fields found in index '{self.index_name}'. Using keyword-only search.")
self._auto_discovered_vector_field = True
return
# Step 2: Find which vector fields have server-side vectorization
vectorizable_fields = self._find_vectorizable_fields(index, vector_fields)
# Step 3: Decide which field to use
if vectorizable_fields:
# Prefer vectorizable fields (server-side embedding)
if len(vectorizable_fields) == 1:
self.vector_field_name = vectorizable_fields[0]
self._auto_discovered_vector_field = True
self._use_vectorizable_query = True # Use VectorizableTextQuery
logger.info(
f"Auto-discovered vectorizable field '{self.vector_field_name}' "
f"with server-side vectorization. No embedding_function needed."
)
else:
# Multiple vectorizable fields
logger.warning(
f"Multiple vectorizable fields found: {vectorizable_fields}. "
f"Please specify vector_field_name explicitly. Using keyword-only search."
)
elif len(vector_fields) == 1:
# Single vector field without vectorizer - needs client-side embedding
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, or it will fall back to keyword-only search."
)
self.vector_field_name = None
else:
# Multiple vector fields without vectorizers
logger.warning(
f"Multiple vector fields found: {vector_fields}. "
f"Please specify vector_field_name explicitly. Using keyword-only search."
)
except Exception as e:
# Log warning but continue with keyword search
logger.warning(f"Failed to auto-discover vector field: {e}. Using keyword-only search.")
self._auto_discovered_vector_field = True # Mark as attempted
async def _semantic_search(self, query: str) -> list[str]:
"""Perform semantic hybrid search with semantic ranking.
This is the recommended mode for most use cases. It combines:
- Vector search (if embedding_function provided)
- Keyword search (BM25)
- Semantic reranking (if semantic_configuration_name provided)
Args:
query: Search query text.
Returns:
List of formatted search result strings, one per document.
"""
# Auto-discover vector field if not already done
await self._auto_discover_vector_field()
vector_queries: list[VectorizableTextQuery | VectorizedQuery] = []
# Build vector query based on server-side vectorization or client-side embedding
if self.vector_field_name:
# Use larger k for vector query when semantic reranker is enabled for better ranking quality
vector_k = max(self.top_k, 50) if self.semantic_configuration_name else self.top_k
if self._use_vectorizable_query:
# Server-side vectorization: Index will auto-embed the text query
vector_queries = [
VectorizableTextQuery(
text=query,
k_nearest_neighbors=vector_k,
fields=self.vector_field_name,
)
]
elif self.embedding_function:
# Client-side embedding: We provide the vector
query_vector = await self.embedding_function(query)
vector_queries = [
VectorizedQuery(
vector=query_vector,
k_nearest_neighbors=vector_k,
fields=self.vector_field_name,
)
]
# else: vector_field_name is set but no vectorization available - skip vector search
# Build search parameters
search_params: dict[str, Any] = {
"search_text": query,
"top": self.top_k,
}
if vector_queries:
search_params["vector_queries"] = vector_queries
# Add semantic ranking if configured
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
# Execute search
results = await self._search_client.search(**search_params) # type: ignore[reportUnknownVariableType]
# Format results with citations
formatted_results: list[str] = []
async for doc in results: # type: ignore[reportUnknownVariableType]
# Extract document ID for citation
doc_id = doc.get("id") or doc.get("@search.id") # type: ignore[reportUnknownVariableType]
# Use full document chunks with citation
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.
This method is idempotent - it will only create resources if they don't exist.
Note: Azure SDK uses KnowledgeAgent classes internally, but the feature
is marketed as "Knowledge Bases" in Azure AI Search.
"""
if self._knowledge_base_initialized or not self._index_client:
return
# Runtime validation for agentic mode parameters
if not self.knowledge_base_name:
raise ValueError("knowledge_base_name is required for agentic mode")
if not self.azure_openai_resource_url:
raise ValueError("azure_openai_resource_url is required for agentic mode")
if not self.azure_openai_deployment_name:
raise ValueError("model_deployment_name is required for agentic mode")
knowledge_base_name = self.knowledge_base_name
# Step 1: Create or get knowledge source
knowledge_source_name = f"{self.index_name}-source"
try:
# Try to get existing knowledge source
await self._index_client.get_knowledge_source(knowledge_source_name)
except ResourceNotFoundError:
# Create new knowledge source if it doesn't exist
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)
# Step 2: Create or update Knowledge Base
# Always create/update to ensure configuration is current
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,
)
# Map output mode string to SDK enum
output_mode = (
KnowledgeRetrievalOutputMode.EXTRACTIVE_DATA
if self.knowledge_base_output_mode == "extractive_data"
else KnowledgeRetrievalOutputMode.ANSWER_SYNTHESIS
)
# Map reasoning effort string to SDK class
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
# Create retrieval client now that Knowledge Base is initialized
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,
)
async def _agentic_search(self, messages: list[ChatMessage]) -> list[str]:
"""Perform agentic retrieval with multi-hop reasoning using Knowledge Bases.
This mode uses query planning and is slightly slower than semantic search,
but provides more accurate results through intelligent retrieval.
This method uses Azure AI Search Knowledge Bases which:
1. Analyze the query and plan sub-queries
2. Retrieve relevant documents across multiple sources
3. Perform multi-hop reasoning with an LLM
4. Synthesize a comprehensive answer with references
Args:
messages: Conversation history to use for retrieval context.
Returns:
List of answer parts from the Knowledge Base, one per content item.
"""
# Ensure Knowledge Base is initialized
await self._ensure_knowledge_base()
# Map reasoning effort string to SDK class (for retrieval requests)
reasoning_effort_map: dict[str, KBRetrievalReasoningEffort] = {
"minimal": KBRetrievalMinimalReasoningEffort(),
"medium": KBRetrievalMediumReasoningEffort(),
"low": KBRetrievalLowReasoningEffort(),
}
reasoning_effort = reasoning_effort_map[self.retrieval_reasoning_effort]
# Map output mode string to SDK enum (for retrieval requests)
output_mode = (
KBRetrievalOutputMode.EXTRACTIVE_DATA
if self.knowledge_base_output_mode == "extractive_data"
else KBRetrievalOutputMode.ANSWER_SYNTHESIS
)
# For minimal reasoning, use intents API; for medium/low, use messages API
if self.retrieval_reasoning_effort == "minimal":
# Minimal reasoning uses intents with a single search query
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:
# Medium/low reasoning uses messages with conversation history
kb_messages = [
KnowledgeBaseMessage(
role=msg.role.value 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,
)
# Use reusable retrieval client
if not self._retrieval_client:
raise RuntimeError("Retrieval client not initialized. Ensure Knowledge Base is set up correctly.")
# Perform retrieval via Knowledge Base
retrieval_result = await self._retrieval_client.retrieve(retrieval_request=retrieval_request)
# Extract answer parts from response
if retrieval_result.response and len(retrieval_result.response) > 0:
# Get the assistant's response (last message)
assistant_message = retrieval_result.response[-1]
if assistant_message.content:
# Extract all text content items as separate parts
answer_parts: list[str] = []
for content_item in assistant_message.content:
# Check if this is a text content item
if isinstance(content_item, KnowledgeBaseMessageTextContent) and content_item.text:
answer_parts.append(content_item.text)
if answer_parts:
return answer_parts
# Fallback if no answer generated
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.
Args:
doc: Search result document.
doc_id: Optional document ID for citation.
Returns:
Formatted document text with citation if doc_id provided.
"""
# Try common text field names
text = ""
for field in ["content", "text", "description", "body", "chunk"]:
if doc.get(field):
text = str(doc[field])
break
# Fallback: concatenate all string fields
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 ""
# Add citation if document ID provided
if doc_id and text:
return f"[Source: {doc_id}] {text}"
return text
+91
View File
@@ -0,0 +1,91 @@
[project]
name = "agent-framework-aisearch"
description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251118"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core",
"azure-search-documents==11.7.0b2",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
]
timeout = 120
[tool.ruff]
extend = "../../pyproject.toml"
exclude = ["examples"]
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_aisearch"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_aisearch"
test = "pytest --cov=agent_framework_aisearch --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,992 @@
# Copyright (c) Microsoft. All rights reserved.
# pyright: reportPrivateUsage=false
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import ChatMessage, Context, Role
from agent_framework.azure import AzureAISearchContextProvider
from agent_framework.exceptions import ServiceInitializationError
from azure.core.credentials import AzureKeyCredential
from azure.core.exceptions import ResourceNotFoundError
from agent_framework_aisearch import AzureAISearchSettings
@pytest.fixture
def mock_search_client() -> AsyncMock:
"""Create a mock SearchClient."""
mock_client = AsyncMock()
mock_client.search = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
return mock_client
@pytest.fixture
def mock_index_client() -> AsyncMock:
"""Create a mock SearchIndexClient."""
mock_client = AsyncMock()
mock_client.get_knowledge_source = AsyncMock()
mock_client.create_knowledge_source = AsyncMock()
mock_client.get_agent = AsyncMock()
mock_client.create_agent = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
return mock_client
@pytest.fixture
def sample_messages() -> list[ChatMessage]:
"""Create sample chat messages for testing."""
return [
ChatMessage(role=Role.USER, text="What is in the documents?"),
]
class TestAzureAISearchSettings:
"""Test AzureAISearchSettings configuration."""
def test_settings_with_direct_values(self) -> None:
"""Test settings with direct values."""
settings = AzureAISearchSettings(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
)
assert settings.endpoint == "https://test.search.windows.net"
assert settings.index_name == "test-index"
# api_key is now SecretStr
assert settings.api_key.get_secret_value() == "test-key"
def test_settings_with_env_file_path(self) -> None:
"""Test settings with env_file_path parameter."""
settings = AzureAISearchSettings(
endpoint="https://test.search.windows.net",
index_name="test-index",
env_file_path="test.env",
)
assert settings.endpoint == "https://test.search.windows.net"
assert settings.index_name == "test-index"
def test_provider_uses_settings_from_env(self) -> None:
"""Test that provider creates settings internally from env."""
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
)
assert provider.endpoint == "https://test.search.windows.net"
assert provider.index_name == "test-index"
def test_provider_missing_endpoint_raises_error(self) -> None:
"""Test that provider raises ServiceInitializationError without endpoint."""
# Use patch.dict to clear environment and pass env_file_path="" to prevent .env file loading
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")}
with (
patch.dict(os.environ, clean_env, clear=True),
pytest.raises(ServiceInitializationError, match="endpoint is required"),
):
AzureAISearchContextProvider(
index_name="test-index",
api_key="test-key",
env_file_path="", # Disable .env file loading
)
def test_provider_missing_index_name_raises_error(self) -> None:
"""Test that provider raises ServiceInitializationError without index_name."""
# Use patch.dict to clear environment and pass env_file_path="" to prevent .env file loading
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")}
with (
patch.dict(os.environ, clean_env, clear=True),
pytest.raises(ServiceInitializationError, match="index name is required"),
):
AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
api_key="test-key",
env_file_path="", # Disable .env file loading
)
def test_provider_missing_credential_raises_error(self) -> None:
"""Test that provider raises ServiceInitializationError without credential."""
# Use patch.dict to clear environment and pass env_file_path="" to prevent .env file loading
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")}
with (
patch.dict(os.environ, clean_env, clear=True),
pytest.raises(ServiceInitializationError, match="credential is required"),
):
AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
env_file_path="", # Disable .env file loading
)
class TestSearchProviderInitialization:
"""Test initialization and configuration of AzureAISearchContextProvider."""
def test_init_semantic_mode_minimal(self) -> None:
"""Test initialization with minimal semantic mode parameters."""
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
assert provider.endpoint == "https://test.search.windows.net"
assert provider.index_name == "test-index"
assert provider.mode == "semantic"
assert provider.top_k == 5
def test_init_semantic_mode_with_vector_field_requires_embedding_function(self) -> None:
"""Test that vector_field_name requires embedding_function."""
with pytest.raises(ValueError, match="embedding_function is required"):
AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
vector_field_name="embedding",
)
def test_init_agentic_mode_requires_azure_openai_resource_url(self) -> None:
"""Test that agentic mode requires azure_openai_resource_url."""
with pytest.raises(ValueError, match="azure_openai_resource_url"):
AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
)
def test_init_agentic_mode_requires_model_deployment_name(self) -> None:
"""Test that agentic mode requires model_deployment_name."""
with pytest.raises(ValueError, match="model_deployment_name"):
AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
azure_openai_resource_url="https://test.openai.azure.com",
)
def test_init_agentic_mode_requires_knowledge_base_name(self) -> None:
"""Test that agentic mode requires knowledge_base_name."""
with pytest.raises(ValueError, match="knowledge_base_name"):
AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="gpt-4o",
azure_openai_resource_url="https://test.openai.azure.com",
)
def test_init_agentic_mode_with_all_params(self) -> None:
"""Test initialization with all agentic mode parameters."""
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="my-gpt-4o-deployment",
model_name="gpt-4o",
knowledge_base_name="test-kb",
azure_openai_resource_url="https://test.openai.azure.com",
)
assert provider.mode == "agentic"
assert provider.azure_ai_project_endpoint == "https://test.services.ai.azure.com"
assert provider.azure_openai_resource_url == "https://test.openai.azure.com"
assert provider.azure_openai_deployment_name == "my-gpt-4o-deployment"
assert provider.model_name == "gpt-4o"
assert provider.knowledge_base_name == "test-kb"
def test_init_model_name_defaults_to_deployment_name(self) -> None:
"""Test that model_name defaults to deployment_name if not provided."""
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="gpt-4o",
knowledge_base_name="test-kb",
azure_openai_resource_url="https://test.openai.azure.com",
)
assert provider.model_name == "gpt-4o"
def test_init_with_custom_context_prompt(self) -> None:
"""Test initialization with custom context prompt."""
custom_prompt = "Use the following information:"
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
context_prompt=custom_prompt,
)
assert provider.context_prompt == custom_prompt
def test_init_uses_default_context_prompt(self) -> None:
"""Test that default context prompt is used when not provided."""
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
assert provider.context_prompt == provider._DEFAULT_SEARCH_CONTEXT_PROMPT
class TestSemanticSearch:
"""Test semantic search functionality."""
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_semantic_search_basic(
self, mock_search_class: MagicMock, sample_messages: list[ChatMessage]
) -> None:
"""Test basic semantic search without vector search."""
# Setup mock
mock_search_client = AsyncMock()
mock_results = AsyncMock()
mock_results.__aiter__.return_value = iter([{"content": "Test document content"}])
mock_search_client.search.return_value = mock_results
mock_search_class.return_value = mock_search_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
context = await provider.invoking(sample_messages)
assert isinstance(context, Context)
assert len(context.messages) > 1 # First message is prompt, rest are results
# First message should be the context prompt
assert "Use the following context" in context.messages[0].text
# Second message should contain the search result
assert "Test document content" in context.messages[1].text
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_semantic_search_empty_query(self, mock_search_class: MagicMock) -> None:
"""Test that empty queries return empty context."""
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
# Empty message
context = await provider.invoking([ChatMessage(role=Role.USER, text="")])
assert isinstance(context, Context)
assert len(context.messages) == 0
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_semantic_search_with_vector_query(
self, mock_search_class: MagicMock, sample_messages: list[ChatMessage]
) -> None:
"""Test semantic search with vector query."""
# Setup mock
mock_search_client = AsyncMock()
mock_results = AsyncMock()
mock_results.__aiter__.return_value = iter([{"content": "Vector search result"}])
mock_search_client.search.return_value = mock_results
mock_search_class.return_value = mock_search_client
# Mock embedding function
async def mock_embed(text: str) -> list[float]:
return [0.1, 0.2, 0.3]
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
vector_field_name="embedding",
embedding_function=mock_embed,
)
context = await provider.invoking(sample_messages)
assert isinstance(context, Context)
assert len(context.messages) > 0
# Verify that search was called
mock_search_client.search.assert_called_once()
class TestKnowledgeBaseSetup:
"""Test Knowledge Base setup for agentic mode."""
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_ensure_knowledge_base_creates_when_not_exists(
self, mock_search_class: MagicMock, mock_index_class: MagicMock
) -> None:
"""Test that Knowledge Base is created when it doesn't exist."""
# Setup mocks
mock_index_client = AsyncMock()
mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found")
mock_index_client.create_knowledge_source = AsyncMock()
mock_index_client.get_knowledge_base.side_effect = ResourceNotFoundError("Not found")
mock_index_client.create_or_update_knowledge_base = AsyncMock()
mock_index_class.return_value = mock_index_client
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="gpt-4o",
model_name="gpt-4o",
knowledge_base_name="test-kb",
azure_openai_resource_url="https://test.openai.azure.com",
)
await provider._ensure_knowledge_base()
# Verify knowledge source was created
mock_index_client.create_knowledge_source.assert_called_once()
# Verify Knowledge Base was created
mock_index_client.create_or_update_knowledge_base.assert_called_once()
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_ensure_knowledge_base_skips_when_exists(
self, mock_search_class: MagicMock, mock_index_class: MagicMock
) -> None:
"""Test that Knowledge Base setup is skipped when already exists."""
# Setup mocks
mock_index_client = AsyncMock()
mock_index_client.get_knowledge_source.return_value = MagicMock() # Exists
mock_index_client.get_knowledge_base.return_value = MagicMock() # Exists
mock_index_class.return_value = mock_index_client
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="gpt-4o",
knowledge_base_name="test-kb",
azure_openai_resource_url="https://test.openai.azure.com",
)
await provider._ensure_knowledge_base()
# Verify nothing was created
mock_index_client.create_knowledge_source.assert_not_called()
mock_index_client.create_agent.assert_not_called()
class TestContextProviderLifecycle:
"""Test context provider lifecycle methods."""
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_context_manager(self, mock_search_class: MagicMock) -> None:
"""Test that provider can be used as async context manager."""
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
async with AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
) as provider:
assert provider is not None
assert isinstance(provider, AzureAISearchContextProvider)
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.KnowledgeBaseRetrievalClient")
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_context_manager_agentic_cleanup(
self, mock_search_class: MagicMock, mock_index_class: MagicMock, mock_retrieval_class: MagicMock
) -> None:
"""Test that agentic mode provider cleans up retrieval client."""
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
mock_index_client = AsyncMock()
mock_index_class.return_value = mock_index_client
mock_retrieval_client = AsyncMock()
mock_retrieval_client.close = AsyncMock()
mock_retrieval_class.return_value = mock_retrieval_client
async with AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="gpt-4o",
knowledge_base_name="test-kb",
azure_openai_resource_url="https://test.openai.azure.com",
) as provider:
# Simulate retrieval client being created
provider._retrieval_client = mock_retrieval_client
# Verify cleanup was called
mock_retrieval_client.close.assert_called_once()
def test_string_api_key_conversion(self) -> None:
"""Test that string api_key is converted to AzureKeyCredential."""
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="my-api-key", # String api_key
mode="semantic",
)
assert isinstance(provider.credential, AzureKeyCredential)
class TestMessageFiltering:
"""Test message filtering functionality."""
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_filters_non_user_assistant_messages(self, mock_search_class: MagicMock) -> None:
"""Test that only USER and ASSISTANT messages are processed."""
# Setup mock
mock_search_client = AsyncMock()
mock_results = AsyncMock()
mock_results.__aiter__.return_value = iter([{"content": "Test result"}])
mock_search_client.search.return_value = mock_results
mock_search_class.return_value = mock_search_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
# Mix of message types
messages = [
ChatMessage(role=Role.SYSTEM, text="System message"),
ChatMessage(role=Role.USER, text="User message"),
ChatMessage(role=Role.ASSISTANT, text="Assistant message"),
ChatMessage(role=Role.TOOL, text="Tool message"),
]
context = await provider.invoking(messages)
# Should have processed only USER and ASSISTANT messages
assert isinstance(context, Context)
mock_search_client.search.assert_called_once()
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_filters_empty_messages(self, mock_search_class: MagicMock) -> None:
"""Test that empty/whitespace messages are filtered out."""
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
# Messages with empty/whitespace text
messages = [
ChatMessage(role=Role.USER, text=""),
ChatMessage(role=Role.USER, text=" "),
ChatMessage(role=Role.USER, text=None),
]
context = await provider.invoking(messages)
# Should return empty context
assert len(context.messages) == 0
class TestCitations:
"""Test citation functionality."""
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_citations_included_in_semantic_search(self, mock_search_class: MagicMock) -> None:
"""Test that citations are included in semantic search results."""
# Setup mock with document ID
mock_search_client = AsyncMock()
mock_results = AsyncMock()
mock_doc = {"id": "doc123", "content": "Test document content"}
mock_results.__aiter__.return_value = iter([mock_doc])
mock_search_client.search.return_value = mock_results
mock_search_class.return_value = mock_search_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
context = await provider.invoking([ChatMessage(role=Role.USER, text="test query")])
# Check that citation is included
assert isinstance(context, Context)
assert len(context.messages) > 1 # First message is prompt, rest are results
# Citation should be in the result message (second message)
assert "[Source: doc123]" in context.messages[1].text
assert "Test document content" in context.messages[1].text
class TestAgenticSearch:
"""Test agentic search functionality."""
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.KnowledgeBaseRetrievalClient")
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_agentic_search_basic(
self,
mock_search_class: MagicMock,
mock_index_class: MagicMock,
mock_retrieval_class: MagicMock,
sample_messages: list[ChatMessage],
) -> None:
"""Test basic agentic search with Knowledge Base retrieval."""
# Setup search client mock
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
# Setup index client mock
mock_index_client = AsyncMock()
mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found")
mock_index_client.create_knowledge_source = AsyncMock()
mock_index_client.create_or_update_knowledge_base = AsyncMock()
mock_index_class.return_value = mock_index_client
# Setup retrieval client mock with response
mock_retrieval_client = AsyncMock()
mock_response = MagicMock()
mock_message = MagicMock()
mock_content = MagicMock()
mock_content.text = "Agentic search result"
# Make it pass isinstance check
from agent_framework_aisearch._search_provider import _agentic_retrieval_available
if _agentic_retrieval_available:
from azure.search.documents.knowledgebases.models import KnowledgeBaseMessageTextContent
mock_content.__class__ = KnowledgeBaseMessageTextContent
mock_message.content = [mock_content]
mock_response.response = [mock_message]
mock_retrieval_client.retrieve.return_value = mock_response
mock_retrieval_client.close = AsyncMock()
mock_retrieval_class.return_value = mock_retrieval_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="gpt-4o",
knowledge_base_name="test-kb",
azure_openai_resource_url="https://test.openai.azure.com",
)
context = await provider.invoking(sample_messages)
assert isinstance(context, Context)
# Should have at least the prompt message
assert len(context.messages) >= 1
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.KnowledgeBaseRetrievalClient")
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_agentic_search_no_results(
self,
mock_search_class: MagicMock,
mock_index_class: MagicMock,
mock_retrieval_class: MagicMock,
sample_messages: list[ChatMessage],
) -> None:
"""Test agentic search when no results are returned."""
# Setup mocks
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
mock_index_client = AsyncMock()
mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found")
mock_index_client.create_knowledge_source = AsyncMock()
mock_index_client.create_or_update_knowledge_base = AsyncMock()
mock_index_class.return_value = mock_index_client
# Empty response
mock_retrieval_client = AsyncMock()
mock_response = MagicMock()
mock_response.response = []
mock_retrieval_client.retrieve.return_value = mock_response
mock_retrieval_client.close = AsyncMock()
mock_retrieval_class.return_value = mock_retrieval_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="gpt-4o",
knowledge_base_name="test-kb",
azure_openai_resource_url="https://test.openai.azure.com",
)
context = await provider.invoking(sample_messages)
assert isinstance(context, Context)
# Should have fallback message
assert len(context.messages) >= 1
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.KnowledgeBaseRetrievalClient")
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_agentic_search_with_medium_reasoning(
self,
mock_search_class: MagicMock,
mock_index_class: MagicMock,
mock_retrieval_class: MagicMock,
sample_messages: list[ChatMessage],
) -> None:
"""Test agentic search with medium reasoning effort."""
# Setup mocks
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
mock_index_client = AsyncMock()
mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found")
mock_index_client.create_knowledge_source = AsyncMock()
mock_index_client.create_or_update_knowledge_base = AsyncMock()
mock_index_class.return_value = mock_index_client
mock_retrieval_client = AsyncMock()
mock_response = MagicMock()
mock_message = MagicMock()
mock_content = MagicMock()
mock_content.text = "Medium reasoning result"
from agent_framework_aisearch._search_provider import _agentic_retrieval_available
if _agentic_retrieval_available:
from azure.search.documents.knowledgebases.models import KnowledgeBaseMessageTextContent
mock_content.__class__ = KnowledgeBaseMessageTextContent
mock_message.content = [mock_content]
mock_response.response = [mock_message]
mock_retrieval_client.retrieve.return_value = mock_response
mock_retrieval_client.close = AsyncMock()
mock_retrieval_class.return_value = mock_retrieval_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="gpt-4o",
knowledge_base_name="test-kb",
azure_openai_resource_url="https://test.openai.azure.com",
retrieval_reasoning_effort="medium", # Test medium reasoning
)
context = await provider.invoking(sample_messages)
assert isinstance(context, Context)
assert len(context.messages) >= 1
class TestVectorFieldAutoDiscovery:
"""Test vector field auto-discovery functionality."""
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_auto_discovers_single_vector_field(
self, mock_search_class: MagicMock, mock_index_class: MagicMock
) -> None:
"""Test that single vector field is auto-discovered."""
# Setup search client mock
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
# Setup index client mock
mock_index_client = AsyncMock()
mock_index = MagicMock()
# Create mock field with vector_search_dimensions attribute
mock_vector_field = MagicMock()
mock_vector_field.name = "embedding_vector"
mock_vector_field.vector_search_dimensions = 1536
mock_index.fields = [mock_vector_field]
mock_index_client.get_index.return_value = mock_index
mock_index_client.close = AsyncMock()
mock_index_class.return_value = mock_index_client
# Create provider without specifying vector_field_name
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
# Trigger auto-discovery
await provider._auto_discover_vector_field()
# Vector field should be auto-discovered but not used without embedding function
assert provider._auto_discovered_vector_field is True
# Should be cleared since no embedding function
assert provider.vector_field_name is None
@pytest.mark.asyncio
async def test_vector_detection_accuracy(self) -> None:
"""Test that vector field detection logic correctly identifies vector fields."""
from azure.search.documents.indexes.models import SearchField
# Create real SearchField objects to test the detection logic
vector_field = SearchField(
name="embedding_vector", type="Collection(Edm.Single)", vector_search_dimensions=1536, searchable=True
)
string_field = SearchField(name="content", type="Edm.String", searchable=True)
number_field = SearchField(name="price", type="Edm.Double", filterable=True)
# Test detection logic directly
is_vector_1 = vector_field.vector_search_dimensions is not None and vector_field.vector_search_dimensions > 0
is_vector_2 = string_field.vector_search_dimensions is not None and string_field.vector_search_dimensions > 0
is_vector_3 = number_field.vector_search_dimensions is not None and number_field.vector_search_dimensions > 0
# Only the vector field should be detected
assert is_vector_1 is True
assert is_vector_2 is False
assert is_vector_3 is False
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_no_false_positives_on_string_fields(
self, mock_search_class: MagicMock, mock_index_class: MagicMock
) -> None:
"""Test that regular string fields are not detected as vector fields."""
# Setup search client mock
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
# Setup index with only string fields (no vectors)
mock_index_client = AsyncMock()
mock_index = MagicMock()
# All fields have vector_search_dimensions = None
mock_fields = []
for name in ["id", "title", "content", "category"]:
field = MagicMock()
field.name = name
field.vector_search_dimensions = None
field.vector_search_profile_name = None
mock_fields.append(field)
mock_index.fields = mock_fields
mock_index_client.get_index.return_value = mock_index
mock_index_client.close = AsyncMock()
mock_index_class.return_value = mock_index_client
# Create provider
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
# Trigger auto-discovery
await provider._auto_discover_vector_field()
# Should NOT detect any vector fields
assert provider.vector_field_name is None
assert provider._auto_discovered_vector_field is True
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_multiple_vector_fields_without_vectorizer(
self, mock_search_class: MagicMock, mock_index_class: MagicMock
) -> None:
"""Test that multiple vector fields without vectorizer logs warning and uses keyword search."""
# Setup search client mock
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
# Setup index with multiple vector fields (no vectorizers)
mock_index_client = AsyncMock()
mock_index = MagicMock()
# Multiple vector fields
mock_fields = []
for name in ["embedding1", "embedding2"]:
field = MagicMock()
field.name = name
field.vector_search_dimensions = 1536
field.vector_search_profile_name = None # No vectorizer
mock_fields.append(field)
mock_index.fields = mock_fields
mock_index.vector_search = None # No vector search config
mock_index_client.get_index.return_value = mock_index
mock_index_client.close = AsyncMock()
mock_index_class.return_value = mock_index_client
# Create provider
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
# Trigger auto-discovery
await provider._auto_discover_vector_field()
# Should NOT use any vector field (multiple fields, can't choose)
assert provider.vector_field_name is None
assert provider._auto_discovered_vector_field is True
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_multiple_vectorizable_fields(
self, mock_search_class: MagicMock, mock_index_class: MagicMock
) -> None:
"""Test that multiple vectorizable fields logs warning and uses keyword search."""
# Setup search client mock
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
# Setup index with multiple vectorizable fields
mock_index_client = AsyncMock()
mock_index = MagicMock()
# Multiple vector fields with vectorizers
mock_fields = []
for name in ["embedding1", "embedding2"]:
field = MagicMock()
field.name = name
field.vector_search_dimensions = 1536
field.vector_search_profile_name = f"{name}-profile"
mock_fields.append(field)
mock_index.fields = mock_fields
# Setup vector search config with profiles that have vectorizers
mock_profile1 = MagicMock()
mock_profile1.name = "embedding1-profile"
mock_profile1.vectorizer_name = "vectorizer1"
mock_profile2 = MagicMock()
mock_profile2.name = "embedding2-profile"
mock_profile2.vectorizer_name = "vectorizer2"
mock_index.vector_search = MagicMock()
mock_index.vector_search.profiles = [mock_profile1, mock_profile2]
mock_index_client.get_index.return_value = mock_index
mock_index_client.close = AsyncMock()
mock_index_class.return_value = mock_index_client
# Create provider
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
# Trigger auto-discovery
await provider._auto_discover_vector_field()
# Should NOT use any vector field (multiple vectorizable fields, can't choose)
assert provider.vector_field_name is None
assert provider._auto_discovered_vector_field is True
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_single_vectorizable_field_detected(
self, mock_search_class: MagicMock, mock_index_class: MagicMock
) -> None:
"""Test that single vectorizable field is auto-detected for server-side vectorization."""
# Setup search client mock
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
# Setup index with single vectorizable field
mock_index_client = AsyncMock()
mock_index = MagicMock()
# Single vector field with vectorizer
mock_field = MagicMock()
mock_field.name = "embedding"
mock_field.vector_search_dimensions = 1536
mock_field.vector_search_profile_name = "embedding-profile"
mock_index.fields = [mock_field]
# Setup vector search config with profile that has vectorizer
mock_profile = MagicMock()
mock_profile.name = "embedding-profile"
mock_profile.vectorizer_name = "openai-vectorizer"
mock_index.vector_search = MagicMock()
mock_index.vector_search.profiles = [mock_profile]
mock_index_client.get_index.return_value = mock_index
mock_index_client.close = AsyncMock()
mock_index_class.return_value = mock_index_client
# Create provider
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
# Trigger auto-discovery
await provider._auto_discover_vector_field()
# Should detect the vectorizable field
assert provider.vector_field_name == "embedding"
assert provider._auto_discovered_vector_field is True
assert provider._use_vectorizable_query is True # Server-side vectorization
@@ -10,6 +10,8 @@ _IMPORTS: dict[str, tuple[str, str]] = {
"AgentResponseCallbackProtocol": ("agent_framework_azurefunctions", "azurefunctions"),
"AzureAIAgentClient": ("agent_framework_azure_ai", "azure-ai"),
"AzureAIClient": ("agent_framework_azure_ai", "azure-ai"),
"AzureAISearchContextProvider": ("agent_framework_aisearch", "aisearch"),
"AzureAISearchSettings": ("agent_framework_aisearch", "aisearch"),
"AzureOpenAIAssistantsClient": ("agent_framework.azure._assistants_client", "core"),
"AzureOpenAIChatClient": ("agent_framework.azure._chat_client", "core"),
"AzureAISettings": ("agent_framework_azure_ai", "azure-ai"),
+1
View File
@@ -43,6 +43,7 @@ dependencies = [
all = [
"agent-framework-a2a",
"agent-framework-ag-ui",
"agent-framework-aisearch",
"agent-framework-anthropic",
"agent-framework-azure-ai",
"agent-framework-azurefunctions",
+1
View File
@@ -81,6 +81,7 @@ agent-framework = { workspace = true }
agent-framework-core = { workspace = true }
agent-framework-a2a = { workspace = true }
agent-framework-ag-ui = { workspace = true }
agent-framework-aisearch = { workspace = true }
agent-framework-anthropic = { workspace = true }
agent-framework-azure-ai = { workspace = true }
agent-framework-azurefunctions = { workspace = true }
@@ -20,6 +20,8 @@ This folder contains examples demonstrating different ways to create and use age
| [`azure_ai_with_file_search.py`](azure_ai_with_file_search.py) | Shows how to use the `HostedFileSearchTool` with Azure AI agents to upload files, create vector stores, and enable agents to search through uploaded documents to answer user questions. |
| [`azure_ai_with_hosted_mcp.py`](azure_ai_with_hosted_mcp.py) | Shows how to integrate hosted Model Context Protocol (MCP) tools with Azure AI Agent. |
| [`azure_ai_with_response_format.py`](azure_ai_with_response_format.py) | Shows how to use structured outputs (response format) with Azure AI agents using Pydantic models to enforce specific response schemas. |
| [`azure_ai_with_search_context_agentic.py`](azure_ai_with_search_context_agentic.py) | Shows how to use AzureAISearchContextProvider with agentic mode. Uses Knowledge Bases for multi-hop reasoning across documents with query planning. Recommended for most scenarios - slightly slower with more token consumption for query planning, but more accurate results. |
| [`azure_ai_with_search_context_semantic.py`](azure_ai_with_search_context_semantic.py) | Shows how to use AzureAISearchContextProvider with semantic mode. Fast hybrid search with vector + keyword search and semantic ranking for RAG. Best for simple queries where speed is critical. |
| [`azure_ai_with_sharepoint.py`](azure_ai_with_sharepoint.py) | Shows how to use SharePoint grounding with Azure AI agents to search through SharePoint content and answer user questions with proper citations. Requires a SharePoint connection configured in your Azure AI project. |
| [`azure_ai_with_thread.py`](azure_ai_with_thread.py) | Demonstrates thread management with Azure AI agents, including automatic thread creation for stateless conversations and explicit thread management for maintaining conversation context across multiple interactions. |
| [`azure_ai_with_image_generation.py`](azure_ai_with_image_generation.py) | Shows how to use the `ImageGenTool` with Azure AI agents to generate images based on text prompts. |
@@ -0,0 +1,118 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from dotenv import load_dotenv
from agent_framework import ChatAgent
from agent_framework_aisearch import AzureAISearchContextProvider
from agent_framework_azure_ai import AzureAIAgentClient
from azure.identity.aio import DefaultAzureCredential
# Load environment variables from .env file
load_dotenv()
"""
This sample demonstrates how to use Azure AI Search with agentic mode for RAG
(Retrieval Augmented Generation) with Azure AI agents.
**Agentic mode** is recommended for most scenarios:
- Uses Knowledge Bases in Azure AI Search for query planning
- Performs multi-hop reasoning across documents
- Provides more accurate results through intelligent retrieval
- Slightly slower with more token consumption for query planning
- See: https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/foundry-iq-boost-response-relevance-by-36-with-agentic-retrieval/4470720
For simple queries where speed is critical, use semantic mode instead (see azure_ai_with_search_context_semantic.py).
Prerequisites:
1. An Azure AI Search service with a search index
2. An Azure AI Foundry project with a model deployment
3. An Azure OpenAI resource (for Knowledge Base model calls)
4. Set the following environment variables:
- AZURE_SEARCH_ENDPOINT: Your Azure AI Search endpoint
- AZURE_SEARCH_API_KEY: (Optional) Your search API key - if not provided, uses DefaultAzureCredential for Entra ID
- AZURE_SEARCH_INDEX_NAME: Your search index name
- AZURE_AI_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint
- AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name (e.g., "gpt-4o")
- AZURE_SEARCH_KNOWLEDGE_BASE_NAME: Your Knowledge Base name
- AZURE_OPENAI_RESOURCE_URL: Your Azure OpenAI resource URL (e.g., "https://myresource.openai.azure.com")
Note: This is different from AZURE_AI_PROJECT_ENDPOINT - Knowledge Base needs the OpenAI endpoint for model calls
"""
# Sample queries to demonstrate agentic RAG
USER_INPUTS = [
"What information is available in the knowledge base?",
"Analyze and compare the main topics from different documents",
"What connections can you find across different sections?",
]
async def main() -> None:
"""Main function demonstrating Azure AI Search agentic mode."""
# Get configuration from environment
search_endpoint = os.environ["AZURE_SEARCH_ENDPOINT"]
search_key = os.environ.get("AZURE_SEARCH_API_KEY")
index_name = os.environ["AZURE_SEARCH_INDEX_NAME"]
project_endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
model_deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o")
knowledge_base_name = os.environ["AZURE_SEARCH_KNOWLEDGE_BASE_NAME"]
azure_openai_resource_url = os.environ["AZURE_OPENAI_RESOURCE_URL"]
# Create Azure AI Search context provider with agentic mode (recommended for accuracy)
print("Using AGENTIC mode (Knowledge Bases with query planning, recommended)\n")
print("️ This mode is slightly slower but provides more accurate results.\n")
search_provider = AzureAISearchContextProvider(
endpoint=search_endpoint,
index_name=index_name,
api_key=search_key, # Use api_key for API key auth, or credential for managed identity
credential=DefaultAzureCredential() if not search_key else None,
mode="agentic", # Advanced mode for multi-hop reasoning
# Agentic mode configuration
azure_ai_project_endpoint=project_endpoint,
azure_openai_resource_url=azure_openai_resource_url,
model_deployment_name=model_deployment,
knowledge_base_name=knowledge_base_name,
# Optional: Configure retrieval behavior
knowledge_base_output_mode="extractive_data", # or "answer_synthesis"
retrieval_reasoning_effort="minimal", # or "medium", "low"
top_k=3, # Note: In agentic mode, the server-side Knowledge Base determines final retrieval
)
# Create agent with search context provider
async with (
search_provider,
AzureAIAgentClient(
project_endpoint=project_endpoint,
model_deployment_name=model_deployment,
async_credential=DefaultAzureCredential(),
) as client,
ChatAgent(
chat_client=client,
name="SearchAgent",
instructions=(
"You are a helpful assistant with advanced reasoning capabilities. "
"Use the provided context from the knowledge base to answer complex "
"questions that may require synthesizing information from multiple sources."
),
context_providers=[search_provider],
) as agent,
):
print("=== Azure AI Agent with Search Context (Agentic Mode) ===\n")
for user_input in USER_INPUTS:
print(f"User: {user_input}")
print("Agent: ", end="", flush=True)
# Stream response
async for chunk in agent.run_stream(user_input):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,99 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from dotenv import load_dotenv
from agent_framework import ChatAgent
from agent_framework_aisearch import AzureAISearchContextProvider
from agent_framework_azure_ai import AzureAIAgentClient
from azure.identity.aio import DefaultAzureCredential
# Load environment variables from .env file
load_dotenv()
"""
This sample demonstrates how to use Azure AI Search with semantic mode for RAG
(Retrieval Augmented Generation) with Azure AI agents.
**Semantic mode** is the recommended default mode:
- Fast hybrid search combining vector and keyword search
- Uses semantic ranking for improved relevance
- Returns raw search results as context
- Best for most RAG use cases
Prerequisites:
1. An Azure AI Search service with a search index
2. An Azure AI Foundry project with a model deployment
3. Set the following environment variables:
- AZURE_SEARCH_ENDPOINT: Your Azure AI Search endpoint
- AZURE_SEARCH_API_KEY: (Optional) Your search API key - if not provided, uses DefaultAzureCredential for Entra ID
- AZURE_SEARCH_INDEX_NAME: Your search index name
- AZURE_AI_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint
- AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name (e.g., "gpt-4o")
"""
# Sample queries to demonstrate RAG
USER_INPUTS = [
"What information is available in the knowledge base?",
"Summarize the main topics from the documents",
"Find specific details about the content",
]
async def main() -> None:
"""Main function demonstrating Azure AI Search semantic mode."""
# Get configuration from environment
search_endpoint = os.environ["AZURE_SEARCH_ENDPOINT"]
search_key = os.environ.get("AZURE_SEARCH_API_KEY")
index_name = os.environ["AZURE_SEARCH_INDEX_NAME"]
project_endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
model_deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o")
# Create Azure AI Search context provider with semantic mode (recommended, fast)
print("Using SEMANTIC mode (hybrid search + semantic ranking, fast)\n")
search_provider = AzureAISearchContextProvider(
endpoint=search_endpoint,
index_name=index_name,
api_key=search_key, # Use api_key for API key auth, or credential for managed identity
credential=DefaultAzureCredential() if not search_key else None,
mode="semantic", # Default mode
top_k=3, # Retrieve top 3 most relevant documents
)
# Create agent with search context provider
async with (
search_provider,
AzureAIAgentClient(
project_endpoint=project_endpoint,
model_deployment_name=model_deployment,
async_credential=DefaultAzureCredential(),
) as client,
ChatAgent(
chat_client=client,
name="SearchAgent",
instructions=(
"You are a helpful assistant. Use the provided context from the "
"knowledge base to answer questions accurately."
),
context_providers=[search_provider],
) as agent,
):
print("=== Azure AI Agent with Search Context (Semantic Mode) ===\n")
for user_input in USER_INPUTS:
print(f"User: {user_input}")
print("Agent: ", end="", flush=True)
# Stream response
async for chunk in agent.run_stream(user_input):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
if __name__ == "__main__":
asyncio.run(main())
+42
View File
@@ -26,6 +26,7 @@ members = [
"agent-framework",
"agent-framework-a2a",
"agent-framework-ag-ui",
"agent-framework-aisearch",
"agent-framework-anthropic",
"agent-framework-azure-ai",
"agent-framework-azurefunctions",
@@ -193,6 +194,21 @@ requires-dist = [
]
provides-extras = ["dev"]
[[package]]
name = "agent-framework-aisearch"
version = "1.0.0b251118"
source = { editable = "packages/aisearch" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "azure-search-documents", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
[package.metadata]
requires-dist = [
{ name = "agent-framework-core", editable = "packages/core" },
{ name = "azure-search-documents", specifier = "==11.7.0b2" },
]
[[package]]
name = "agent-framework-anthropic"
version = "1.0.0b251114"
@@ -304,6 +320,7 @@ dependencies = [
all = [
{ name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-ag-ui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-aisearch", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-anthropic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-azure-ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-azurefunctions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -321,6 +338,7 @@ all = [
requires-dist = [
{ name = "agent-framework-a2a", marker = "extra == 'all'", editable = "packages/a2a" },
{ name = "agent-framework-ag-ui", marker = "extra == 'all'", editable = "packages/ag-ui" },
{ name = "agent-framework-aisearch", marker = "extra == 'all'", editable = "packages/aisearch" },
{ name = "agent-framework-anthropic", marker = "extra == 'all'", editable = "packages/anthropic" },
{ name = "agent-framework-azure-ai", marker = "extra == 'all'", editable = "packages/azure-ai" },
{ name = "agent-framework-azurefunctions", marker = "extra == 'all'", editable = "packages/azurefunctions" },
@@ -928,6 +946,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/41/d9a2b3eb33b4ffd9acfaa115cfd456e32d0c754227d6d78ec5d039ff75c2/azure_ai_projects-2.0.0b2-py3-none-any.whl", hash = "sha256:642496fdf9846c91f3557d39899d3893f0ce8f910334320686fc8f617492351d", size = 234023 },
]
[[package]]
name = "azure-common"
version = "1.1.28"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3e/71/f6f71a276e2e69264a97ad39ef850dca0a04fce67b12570730cb38d0ccac/azure-common-1.1.28.zip", hash = "sha256:4ac0cd3214e36b6a1b6a442686722a5d8cc449603aa833f3f0f40bda836704a3", size = 20914, upload-time = "2022-02-03T19:39:44.373Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/55/7f118b9c1b23ec15ca05d15a578d8207aa1706bc6f7c87218efffbbf875d/azure_common-1.1.28-py2.py3-none-any.whl", hash = "sha256:5c12d3dcf4ec20599ca6b0d3e09e86e146353d443e7fcc050c9a19c1f9df20ad", size = 14462, upload-time = "2022-02-03T19:39:42.417Z" },
]
[[package]]
name = "azure-core"
version = "1.36.0"
@@ -987,6 +1014,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/7b/5652771e24fff12da9dde4c20ecf4682e606b104f26419d139758cc935a6/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651", size = 191317 },
]
[[package]]
name = "azure-search-documents"
version = "11.7.0b2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "azure-common", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f9/ba/bde0f03e0a742ba3bbcc929f91ed2f3b1420c2bb84c9a7f878f3b87ebfce/azure_search_documents-11.7.0b2.tar.gz", hash = "sha256:b6e039f8038ff2210d2057e704e867c6e29bb46bfcd400da4383e45e4b8bb189", size = 423956, upload-time = "2025-11-14T20:09:32.876Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/26/ed4498374f9088818278ac225f2bea688b4ec979d81bf83a5355c8c366af/azure_search_documents-11.7.0b2-py3-none-any.whl", hash = "sha256:f82117b321344a84474269ed26df194c24cca619adc024d981b1b86aee3c6f05", size = 432037, upload-time = "2025-11-14T20:09:34.347Z" },
]
[[package]]
name = "azure-storage-blob"
version = "12.27.1"