diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index d7b2c2a850e..713abee31bb 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **agent-framework-azure-ai-search**: Support the stable/GA (`azure-search-documents` `12.0.0`, api-version `2026-04-01`) and preview (`12.1.0b1`, api-version `2026-05-01-preview`) Azure AI Search SDKs across semantic and agentic modes. Bump the dependency to `>=12.0.0,<13` and auto-detect the installed build: preview-only agentic features (output mode, low/medium reasoning effort) are enabled when a preview build is installed and otherwise raise an actionable error. The installed SDK selects its own data-plane api-version (no `api_version` parameter to configure). Also fixes semantic search with a semantic configuration on the 12.x SDK by passing string values for `query_type`/`query_caption` (the 12.x non-`str` enums serialized to a form the service rejected). + ## [1.10.0] - 2026-06-25 ### Added diff --git a/python/packages/azure-ai-search/AGENTS.md b/python/packages/azure-ai-search/AGENTS.md index 114ee9d9abe..1cdbc2df43f 100644 --- a/python/packages/azure-ai-search/AGENTS.md +++ b/python/packages/azure-ai-search/AGENTS.md @@ -7,6 +7,26 @@ Integration with Azure AI Search for RAG (Retrieval-Augmented Generation). - **`AzureAISearchContextProvider`** - Context provider that retrieves relevant documents from Azure AI Search - **`AzureAISearchSettings`** - Pydantic settings for Azure AI Search configuration +## API versions: stable vs preview + +The package depends on `azure-search-documents>=12.0.0,<13`, which spans both channels, and +auto-detects which build is installed — there is no `api_version` parameter: + +| Channel | Install | SDK | Data-plane `api-version` (chosen by the SDK) | +| --- | --- | --- | --- | +| **Stable / GA** | `pip install azure-search-documents` | `12.0.0` | `2026-04-01` | +| **Preview** | `pip install --pre azure-search-documents` | `12.1.0b1` | `2026-05-01-preview` | + +The provider never pins an `api-version`; the installed build picks its own default, so newer +releases work without code changes (single source of truth = the install). + +Capability gating keys off `_preview_agentic_features_available` — whether the preview build's +agentic symbols (`KnowledgeRetrieval{Low,Medium}ReasoningEffort`, `KnowledgeRetrievalOutputMode`) +can be imported. Agentic **output mode** (`answer_synthesis`) and **extended reasoning effort** +(`low`/`medium`) ship only in the preview build; on a stable build the provider omits them +(extractive + minimal) and raises an actionable `ValueError` (citing the installed version) if +they are explicitly requested. Semantic mode is unaffected. + ## Usage ```python diff --git a/python/packages/azure-ai-search/README.md b/python/packages/azure-ai-search/README.md index fcd3161f949..234ccdf7f7f 100644 --- a/python/packages/azure-ai-search/README.md +++ b/python/packages/azure-ai-search/README.md @@ -13,6 +13,24 @@ The Azure AI Search integration provides context providers for RAG (Retrieval Au - **Semantic Mode**: Fast hybrid search (vector + keyword) with semantic ranking - **Agentic Mode**: Multi-hop reasoning using Knowledge Bases for complex queries +### API versions: stable vs preview + +The integration auto-detects which build of `azure-search-documents` is installed — there is +nothing to configure in code: + +| Channel | Install | Data-plane `api-version` (chosen by the SDK) | +| --- | --- | --- | +| **Stable** | `pip install azure-search-documents` (`>=12.0.0`) | `2026-04-01` | +| **Preview** | `pip install --pre azure-search-documents` (e.g. `12.1.0b1`) | `2026-05-01-preview` | + +The provider never pins an `api-version`; the installed build selects its own, so newer +releases work without code changes. + +Agentic **output modes** (`answer_synthesis`) and **extended reasoning effort** (`low`/`medium`) +ship only in the preview build. When a stable build is installed, the provider uses extractive +output with minimal reasoning effort and raises an actionable error if a preview-only option is +explicitly requested. Switching channels is a single change — the install — with no code edits. + ### Basic Usage Example See the [Azure AI Search context provider examples](../../samples/02-agents/context_providers/azure_ai_search/) which demonstrate: diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py index 9a7ced525a9..347e03c8186 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py @@ -8,6 +8,7 @@ from __future__ import annotations +import importlib.metadata import logging import sys from collections.abc import Awaitable, Callable @@ -35,11 +36,6 @@ AzureOpenAIVectorizerParameters, KnowledgeBase, KnowledgeBaseAzureOpenAIModel, - KnowledgeRetrievalLowReasoningEffort, - KnowledgeRetrievalMediumReasoningEffort, - KnowledgeRetrievalMinimalReasoningEffort, - KnowledgeRetrievalOutputMode, - KnowledgeRetrievalReasoningEffort, KnowledgeSourceReference, SearchIndexKnowledgeSource, SearchIndexKnowledgeSourceParameters, @@ -55,9 +51,9 @@ from agent_framework._agents import SupportsAgentRun from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient from azure.search.documents.knowledgebases.models import ( + KnowledgeBaseImageContent, KnowledgeBaseMessage, KnowledgeBaseMessageImageContent, - KnowledgeBaseMessageImageContentImage, KnowledgeBaseMessageTextContent, KnowledgeBaseReference, KnowledgeBaseRetrievalRequest, @@ -65,34 +61,24 @@ KnowledgeRetrievalIntent, KnowledgeRetrievalSemanticIntent, ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalLowReasoningEffort as KBRetrievalLowReasoningEffort, - ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalMediumReasoningEffort as KBRetrievalMediumReasoningEffort, - ) from azure.search.documents.knowledgebases.models import ( KnowledgeRetrievalMinimalReasoningEffort as KBRetrievalMinimalReasoningEffort, ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalOutputMode as KBRetrievalOutputMode, - ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalReasoningEffort as KBRetrievalReasoningEffort, - ) if sys.version_info >= (3, 11): from typing import Self # pragma: no cover else: from typing_extensions import Self # pragma: no cover -# Runtime imports for agentic mode (optional dependency) +# Runtime imports for agentic mode. Core knowledge base retrieval works on both the +# stable/GA SDK (api-version 2026-04-01) and the preview SDK (api-version +# 2026-05-01-preview). try: from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient from azure.search.documents.knowledgebases.models import ( + KnowledgeBaseImageContent, KnowledgeBaseMessage, KnowledgeBaseMessageImageContent, - KnowledgeBaseMessageImageContentImage, KnowledgeBaseMessageTextContent, KnowledgeBaseReference, KnowledgeBaseRetrievalRequest, @@ -100,26 +86,42 @@ 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 +# Preview-only agentic capabilities (api-version 2026-05-01-preview). These symbols are +# absent from the stable/GA SDK (api-version 2026-04-01): there, the knowledge base +# definition and retrieval request do not expose an output mode or extended (low/medium) +# reasoning effort, and retrieval is intent-based only. They are resolved dynamically (so +# the stable SDK type stubs don't flag missing symbols) and accessed exclusively behind +# ``_preview_agentic_features_available`` checks; ``Any`` keeps them usable under strict +# type checking. +KBRetrievalLowReasoningEffort: Any = None +KBRetrievalMediumReasoningEffort: Any = None +KBRetrievalOutputMode: Any = None +_preview_agentic_features_available = False +if _agentic_retrieval_available: + import azure.search.documents.knowledgebases.models as _kb_models + + _preview_symbols = { + name: getattr(_kb_models, name, None) + for name in ( + "KnowledgeRetrievalLowReasoningEffort", + "KnowledgeRetrievalMediumReasoningEffort", + "KnowledgeRetrievalOutputMode", + ) + } + if all(symbol is not None for symbol in _preview_symbols.values()): + KBRetrievalLowReasoningEffort = _preview_symbols["KnowledgeRetrievalLowReasoningEffort"] + KBRetrievalMediumReasoningEffort = _preview_symbols["KnowledgeRetrievalMediumReasoningEffort"] + KBRetrievalOutputMode = _preview_symbols["KnowledgeRetrievalOutputMode"] + _preview_agentic_features_available = True + AzureCredentialTypes = TokenCredential | AsyncTokenCredential EmbeddingFunction = Callable[[str], Awaitable[list[float]]] | SupportsGetEmbeddings[str, list[float], Any] KnowledgeBaseOutputModeLiteral = Literal["extractive_data", "answer_synthesis"] @@ -130,6 +132,14 @@ _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT = 10 +def _installed_search_documents_version() -> str: + """Return the installed ``azure-search-documents`` version (for diagnostics).""" + try: + return importlib.metadata.version("azure-search-documents") + except importlib.metadata.PackageNotFoundError: # pragma: no cover - defensive + return "unknown" + + class AzureAISearchSettings(TypedDict, total=False): """Settings for Azure AI Search Context Provider with auto-loading from environment. @@ -522,12 +532,30 @@ def __init__( if mode == "agentic": if not _agentic_retrieval_available: raise ImportError( - "Agentic retrieval requires azure-search-documents >= 11.7.0b1 with Knowledge Base support." + "Agentic retrieval requires azure-search-documents >= 12.0.0 with Knowledge Base support." ) if not self._use_existing_knowledge_base and not self.azure_openai_resource_url: raise ValueError( "azure_openai_resource_url is required for agentic mode when creating Knowledge Base from index." ) + if not _preview_agentic_features_available: + # Preview-only agentic options ship only in the preview (prerelease) build of + # azure-search-documents. On the stable/GA build the knowledge base definition + # and retrieval request do not accept an output mode or extended reasoning + # effort, so reject them up front instead of failing server-side. + installed = _installed_search_documents_version() + if knowledge_base_output_mode != "extractive_data": + raise ValueError( + f"knowledge_base_output_mode={knowledge_base_output_mode!r} requires a preview build " + f"of azure-search-documents (installed: {installed}). Install it with " + "`pip install --pre azure-search-documents`, or use 'extractive_data'." + ) + if retrieval_reasoning_effort != "minimal": + raise ValueError( + f"retrieval_reasoning_effort={retrieval_reasoning_effort!r} requires a preview build " + f"of azure-search-documents (installed: {installed}). Install it with " + "`pip install --pre azure-search-documents`, or use 'minimal'." + ) self._search_client: SearchClient | None = None if self.index_name: @@ -535,7 +563,7 @@ def __init__( endpoint=self.endpoint, index_name=self.index_name, credential=self.credential, - user_agent=get_user_agent(), + **self._common_client_kwargs(), ) self._index_client: SearchIndexClient | None = None @@ -544,11 +572,20 @@ def __init__( self._index_client = SearchIndexClient( endpoint=self.endpoint, credential=self.credential, - user_agent=get_user_agent(), + **self._common_client_kwargs(), ) self._knowledge_base_initialized = False + def _common_client_kwargs(self) -> dict[str, Any]: + """Build the keyword arguments shared by every Azure AI Search client. + + No ``api_version`` is forwarded: the installed ``azure-search-documents`` build selects + its own default (stable -> 2026-04-01, preview -> 2026-05-01-preview), so the data-plane + api-version always matches the installed SDK's capabilities. + """ + return {"user_agent": get_user_agent()} + async def __aenter__(self) -> Self: """Async context manager entry.""" return self @@ -640,7 +677,7 @@ async def _auto_discover_vector_field(self) -> None: self._index_client = SearchIndexClient( endpoint=self.endpoint, credential=self.credential, - user_agent=get_user_agent(), + **self._common_client_kwargs(), ) if not self.index_name: logger.warning("Cannot auto-discover vector field: index_name is not set.") @@ -695,22 +732,29 @@ async def _semantic_search(self, query: str) -> list[Message]: if self.vector_field_name: vector_k = max(self.top_k, 50) if self.semantic_configuration_name else self.top_k if self._use_vectorizable_query: - vector_queries = [VectorizableTextQuery(text=query, k=vector_k, fields=self.vector_field_name)] + vector_queries = [ + VectorizableTextQuery(text=query, k_nearest_neighbors=vector_k, fields=self.vector_field_name) + ] elif self.embedding_function: if isinstance(self.embedding_function, SupportsGetEmbeddings): embeddings = await self.embedding_function.get_embeddings([query]) # type: ignore[reportUnknownVariableType] query_vector = embeddings[0].vector # type: ignore[reportUnknownVariableType] else: query_vector = await self.embedding_function(query) - vector_queries = [VectorizedQuery(vector=query_vector, k=vector_k, fields=self.vector_field_name)] # type: ignore[reportUnknownArgumentType] + vector_queries = [ + VectorizedQuery(vector=query_vector, k_nearest_neighbors=vector_k, fields=self.vector_field_name) # type: ignore[reportUnknownArgumentType] + ] search_params: dict[str, Any] = {"search_text": query, "top": self.top_k} if vector_queries: search_params["vector_queries"] = vector_queries if self.semantic_configuration_name: - search_params["query_type"] = QueryType.SEMANTIC + # In azure-search-documents 12.x these are plain (non-str) enums, so the query + # serializer would emit ``str(enum)`` (e.g. "querycaptiontype.extractive"), which the + # service rejects. Pass the enum ``.value`` strings, accepted by every SDK version. + search_params["query_type"] = QueryType.SEMANTIC.value search_params["semantic_configuration_name"] = self.semantic_configuration_name - search_params["query_caption"] = QueryCaptionType.EXTRACTIVE + search_params["query_caption"] = QueryCaptionType.EXTRACTIVE.value if not self._search_client: raise RuntimeError("Search client is not initialized.") @@ -740,7 +784,7 @@ async def _ensure_knowledge_base(self) -> None: endpoint=self.endpoint, knowledge_base_name=knowledge_base_name, credential=self.credential, - user_agent=get_user_agent(), + **self._common_client_kwargs(), ) self._knowledge_base_initialized = True return @@ -774,26 +818,29 @@ async def _ensure_knowledge_base(self) -> None: api_key=self.azure_openai_api_key, ) - output_mode = ( - KnowledgeRetrievalOutputMode.EXTRACTIVE_DATA - if self.knowledge_base_output_mode == "extractive_data" - else KnowledgeRetrievalOutputMode.ANSWER_SYNTHESIS - ) - reasoning_effort_map: dict[str, KnowledgeRetrievalReasoningEffort] = { - "minimal": KnowledgeRetrievalMinimalReasoningEffort(), - "medium": KnowledgeRetrievalMediumReasoningEffort(), - "low": KnowledgeRetrievalLowReasoningEffort(), + kb_kwargs: dict[str, Any] = { + "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)], } - 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, - ) + if _preview_agentic_features_available: + # Output mode and reasoning effort on the knowledge base definition ship only in the + # preview build of azure-search-documents; the stable/GA build omits them (validated + # as defaults in __init__). + kb_kwargs["output_mode"] = ( + KBRetrievalOutputMode.EXTRACTIVE_DATA + if self.knowledge_base_output_mode == "extractive_data" + else KBRetrievalOutputMode.ANSWER_SYNTHESIS + ) + kb_reasoning_effort_map = { + "minimal": KBRetrievalMinimalReasoningEffort(), + "medium": KBRetrievalMediumReasoningEffort(), + "low": KBRetrievalLowReasoningEffort(), + } + kb_kwargs["retrieval_reasoning_effort"] = kb_reasoning_effort_map[self.retrieval_reasoning_effort] + + knowledge_base = KnowledgeBase(**kb_kwargs) await self._index_client.create_or_update_knowledge_base(knowledge_base) self._knowledge_base_initialized = True @@ -802,43 +849,40 @@ async def _ensure_knowledge_base(self) -> None: endpoint=self.endpoint, knowledge_base_name=knowledge_base_name, credential=self.credential, - user_agent=get_user_agent(), + **self._common_client_kwargs(), ) async def _agentic_search(self, messages: list[Message]) -> list[Message]: """Perform agentic retrieval with multi-hop reasoning.""" await self._ensure_knowledge_base() - reasoning_effort_map: dict[str, KBRetrievalReasoningEffort] = { - "minimal": KBRetrievalMinimalReasoningEffort(), - "medium": KBRetrievalMediumReasoningEffort(), - "low": KBRetrievalLowReasoningEffort(), - } - reasoning_effort = reasoning_effort_map[self.retrieval_reasoning_effort] - - output_mode = ( - KBRetrievalOutputMode.EXTRACTIVE_DATA - if self.knowledge_base_output_mode == "extractive_data" - else KBRetrievalOutputMode.ANSWER_SYNTHESIS - ) + request_kwargs: dict[str, Any] = {"include_activity": True} + if _preview_agentic_features_available: + # Reasoning effort and output mode on the retrieval request ship only in the preview + # build of azure-search-documents; the stable/GA build rejects them. + request_reasoning_effort_map = { + "minimal": KBRetrievalMinimalReasoningEffort(), + "medium": KBRetrievalMediumReasoningEffort(), + "low": KBRetrievalLowReasoningEffort(), + } + request_kwargs["retrieval_reasoning_effort"] = request_reasoning_effort_map[self.retrieval_reasoning_effort] + request_kwargs["output_mode"] = ( + KBRetrievalOutputMode.EXTRACTIVE_DATA + if self.knowledge_base_output_mode == "extractive_data" + else KBRetrievalOutputMode.ANSWER_SYNTHESIS + ) if self.retrieval_reasoning_effort == "minimal": query = "\n".join(msg.text for msg in messages if msg.text) intents: list[KnowledgeRetrievalIntent] = [KnowledgeRetrievalSemanticIntent(search=query)] - retrieval_request = KnowledgeBaseRetrievalRequest( - intents=intents, - retrieval_reasoning_effort=reasoning_effort, - output_mode=output_mode, - include_activity=True, - ) + request_kwargs["intents"] = intents else: - kb_messages = self._prepare_messages_for_kb_search(messages) - retrieval_request = KnowledgeBaseRetrievalRequest( - messages=kb_messages, - retrieval_reasoning_effort=reasoning_effort, - output_mode=output_mode, - include_activity=True, - ) + # Messages-based retrieval (multi-hop query planning) is preview-only; reaching + # this branch requires low/medium reasoning effort, which __init__ already + # rejects on the stable/GA SDK. + request_kwargs["messages"] = self._prepare_messages_for_kb_search(messages) + + retrieval_request = KnowledgeBaseRetrievalRequest(**request_kwargs) if not self._retrieval_client: raise RuntimeError("Retrieval client not initialized.") @@ -872,7 +916,7 @@ def _prepare_messages_for_kb_search(messages: list[Message]) -> list[KnowledgeBa ): kb_content.append( KnowledgeBaseMessageImageContent( - image=KnowledgeBaseMessageImageContentImage(url=content.uri), + image=KnowledgeBaseImageContent(url=content.uri), ) ) case _: @@ -924,8 +968,9 @@ def _parse_references_to_annotations(references: list[KnowledgeBaseReference] | doc_key = getattr(ref, "doc_key", None) if doc_key: extra["doc_key"] = doc_key - if ref.additional_properties: - extra["sdk_additional_properties"] = ref.additional_properties + sdk_additional_properties = getattr(ref, "additional_properties", None) + if sdk_additional_properties: + extra["sdk_additional_properties"] = sdk_additional_properties sensitivity_info = getattr(ref, "search_sensitivity_label_info", None) if sensitivity_info: extra["sensitivity_label"] = { diff --git a/python/packages/azure-ai-search/pyproject.toml b/python/packages/azure-ai-search/pyproject.toml index dbfe618f8bc..4f63f27539c 100644 --- a/python/packages/azure-ai-search/pyproject.toml +++ b/python/packages/azure-ai-search/pyproject.toml @@ -4,7 +4,7 @@ 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.0b260521" +version = "1.0.0b260618" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -24,7 +24,9 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.6.0,<2", - "azure-search-documents>=11.7.0b2,<11.7.0b3", + # Stable/GA (12.0.0) targets Azure AI Search api-version 2026-04-01; the preview + # line (e.g. 12.1.0b1, installed with --pre) targets api-version 2026-05-01-preview. + "azure-search-documents>=12.0.0,<13", ] [tool.uv] diff --git a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py index bf3c48d1674..17ae6eb06af 100644 --- a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py +++ b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py @@ -2,6 +2,8 @@ # pyright: reportPrivateUsage=false import os +from collections.abc import Iterator +from contextlib import contextmanager from types import SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock, Mock, patch @@ -12,7 +14,12 @@ from agent_framework.exceptions import SettingNotFoundError from azure.core.credentials import AzureKeyCredential -from agent_framework_azure_ai_search._context_provider import AzureAISearchContextProvider +from agent_framework_azure_ai_search import _context_provider +from agent_framework_azure_ai_search._context_provider import ( + AzureAISearchContextProvider, + KnowledgeBaseOutputModeLiteral, + RetrievalReasoningEffortLiteral, +) # -- Helpers ------------------------------------------------------------------- @@ -97,6 +104,44 @@ def _make_provider(**overrides: Any) -> AzureAISearchContextProvider: return provider +# -- Preview-feature stubs ---------------------------------------------------- +# The stable/GA azure-search-documents SDK (api-version 2026-04-01) does not ship the +# preview-only agentic symbols (output mode, low/medium reasoning effort, image content, +# messages-based retrieval request). These stubs let the preview code paths be exercised +# deterministically regardless of which SDK is installed. + + +class _StubReasoningEffort: + def __init__(self, *args: object, **kwargs: object) -> None: ... + + +class _StubOutputMode: + EXTRACTIVE_DATA = "extractiveData" + ANSWER_SYNTHESIS = "answerSynthesis" + + +class _StubRetrievalRequest: + """Lenient stand-in for the preview KnowledgeBaseRetrievalRequest that accepts any kwargs.""" + + def __init__(self, **kwargs: object) -> None: + self.__dict__.update(kwargs) + + +@contextmanager +def force_preview_features() -> Iterator[None]: + """Force preview-only agentic features on (with lightweight stubs).""" + with patch.multiple( + _context_provider, + _preview_agentic_features_available=True, + KBRetrievalMinimalReasoningEffort=_StubReasoningEffort, + KBRetrievalMediumReasoningEffort=_StubReasoningEffort, + KBRetrievalLowReasoningEffort=_StubReasoningEffort, + KBRetrievalOutputMode=_StubOutputMode, + KnowledgeBaseRetrievalRequest=_StubRetrievalRequest, + ): + yield + + # -- Initialization: semantic mode --------------------------------------------- @@ -316,6 +361,121 @@ def test_agentic_explicit_index_ignores_env_kb_name(self) -> None: assert provider._use_existing_knowledge_base is False +# -- client construction + stable/preview feature gating ---------------------- + + +class TestClientConstruction: + """The provider must not pin an api-version; the installed SDK picks its own default.""" + + def test_common_client_kwargs_has_no_api_version(self) -> None: + provider = _make_provider() + kwargs = provider._common_client_kwargs() + assert "user_agent" in kwargs + assert "api_version" not in kwargs + + def test_search_client_built_without_api_version(self) -> None: + with patch("agent_framework_azure_ai_search._context_provider.SearchClient") as mock_sc: + _make_provider() + _, kwargs = mock_sc.call_args + assert "api_version" not in kwargs + + def test_index_client_built_without_api_version_agentic(self) -> None: + with patch("agent_framework_azure_ai_search._context_provider.SearchIndexClient") as mock_ic: + AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + knowledge_base_name="kb", + api_key="key", + mode="agentic", + ) + _, kwargs = mock_ic.call_args + assert "api_version" not in kwargs + + +class TestPreviewFeatureGating: + """Auto-detect gating: preview-only agentic options require the preview (prerelease) build.""" + + def _agentic( + self, + *, + knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", + retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", + ) -> AzureAISearchContextProvider: + return AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + knowledge_base_name="kb", + api_key="key", + mode="agentic", + knowledge_base_output_mode=knowledge_base_output_mode, + retrieval_reasoning_effort=retrieval_reasoning_effort, + ) + + def test_answer_synthesis_rejected_without_preview_sdk(self) -> None: + with ( + patch.object(_context_provider, "_preview_agentic_features_available", False), + pytest.raises(ValueError, match="answer_synthesis"), + ): + self._agentic(knowledge_base_output_mode="answer_synthesis") + + def test_medium_effort_rejected_without_preview_sdk(self) -> None: + with ( + patch.object(_context_provider, "_preview_agentic_features_available", False), + pytest.raises(ValueError, match="reasoning_effort"), + ): + self._agentic(retrieval_reasoning_effort="medium") + + def test_low_effort_rejected_without_preview_sdk(self) -> None: + with ( + patch.object(_context_provider, "_preview_agentic_features_available", False), + pytest.raises(ValueError, match="reasoning_effort"), + ): + self._agentic(retrieval_reasoning_effort="low") + + def test_defaults_allowed_without_preview_sdk(self) -> None: + with patch.object(_context_provider, "_preview_agentic_features_available", False): + provider = self._agentic() + assert provider.knowledge_base_output_mode == "extractive_data" + assert provider.retrieval_reasoning_effort == "minimal" + + def test_preview_options_allowed_with_preview_sdk(self) -> None: + with patch.object(_context_provider, "_preview_agentic_features_available", True): + provider = self._agentic( + knowledge_base_output_mode="answer_synthesis", + retrieval_reasoning_effort="medium", + ) + assert provider.knowledge_base_output_mode == "answer_synthesis" + assert provider.retrieval_reasoning_effort == "medium" + + async def test_kb_creation_omits_preview_fields_on_stable_sdk(self) -> None: + provider = _make_provider() + provider._knowledge_base_initialized = False + provider._use_existing_knowledge_base = False + provider.knowledge_base_name = "test-kb" + provider.azure_openai_resource_url = "https://aoai.openai.azure.com" + provider.azure_openai_model = "gpt-4" + provider.index_name = "test-index" + + captured: dict[str, object] = {} + + async def _capture(kb: object) -> None: + captured["kb"] = kb + + mock_index_client = AsyncMock() + mock_index_client.get_knowledge_source.return_value = Mock() + mock_index_client.create_or_update_knowledge_base = AsyncMock(side_effect=_capture) + provider._index_client = mock_index_client + + with ( + patch.object(_context_provider, "_preview_agentic_features_available", False), + patch("agent_framework_azure_ai_search._context_provider.KnowledgeBaseRetrievalClient") as mock_cls, + ): + mock_cls.return_value = AsyncMock() + await provider._ensure_knowledge_base() + + kb = captured["kb"] + assert getattr(kb, "output_mode", None) is None + assert getattr(kb, "retrieval_reasoning_effort", None) is None + + # -- __aenter__ / __aexit__ --------------------------------------------------- @@ -844,9 +1004,13 @@ async def _search(**kwargs): await provider._semantic_search("sem query") call_kwargs = mock_client.search.call_args[1] + # Must be plain strings: in azure-search-documents 12.x these are non-str enums whose + # str() form ("querytype.semantic") is rejected by the service. assert call_kwargs["query_type"] == "semantic" + assert isinstance(call_kwargs["query_type"], str) assert call_kwargs["semantic_configuration_name"] == "my-semantic-config" - assert "query_caption" in call_kwargs + assert call_kwargs["query_caption"] == "extractive" + assert isinstance(call_kwargs["query_caption"], str) async def test_vector_k_with_semantic_config(self) -> None: provider = _make_provider(semantic_configuration_name="sc", top_k=3) @@ -1207,9 +1371,12 @@ async def test_non_minimal_reasoning_uses_messages(self) -> None: mock_retrieval.retrieve = AsyncMock(return_value=mock_result) provider._retrieval_client = mock_retrieval - with patch( - "agent_framework_azure_ai_search._context_provider.KnowledgeBaseMessageTextContent", - type(mock_content), + with ( + force_preview_features(), + patch( + "agent_framework_azure_ai_search._context_provider.KnowledgeBaseMessageTextContent", + type(mock_content), + ), ): results = await provider._agentic_search([ Message(role="user", contents=["question"]), @@ -1278,9 +1445,12 @@ async def test_answer_synthesis_output_mode(self) -> None: mock_retrieval.retrieve = AsyncMock(return_value=mock_result) provider._retrieval_client = mock_retrieval - with patch( - "agent_framework_azure_ai_search._context_provider.KnowledgeBaseMessageTextContent", - type(mock_content), + with ( + force_preview_features(), + patch( + "agent_framework_azure_ai_search._context_provider.KnowledgeBaseMessageTextContent", + type(mock_content), + ), ): results = await provider._agentic_search([Message(role="user", contents=["query"])]) @@ -1361,7 +1531,6 @@ def test_text_only_messages(self) -> None: assert result[0].content[0].text == "hello" def test_image_uri_content(self) -> None: - img = Content.from_uri(uri="https://example.com/photo.png", media_type="image/png") messages = [Message(role="user", contents=[img])] result = AzureAISearchContextProvider._prepare_messages_for_kb_search(messages) @@ -1372,7 +1541,6 @@ def test_image_uri_content(self) -> None: assert result[0].content[0].image.url == "https://example.com/photo.png" def test_mixed_text_and_image_content(self) -> None: - text = Content.from_text("describe this image") img = Content.from_uri(uri="https://example.com/img.jpg", media_type="image/jpeg") messages = [Message(role="user", contents=[text, img])] @@ -1404,7 +1572,6 @@ def test_fallback_to_msg_text_when_no_contents(self) -> None: assert result[0].content[0].text == "fallback text" def test_data_uri_image(self) -> None: - img = Content.from_data(data=b"\x89PNG", media_type="image/png") messages = [Message(role="user", contents=[img])] result = AzureAISearchContextProvider._prepare_messages_for_kb_search(messages) @@ -1487,18 +1654,19 @@ def test_raw_representation_stores_original_ref(self) -> None: assert result[0]["raw_representation"] is ref def test_remote_sharepoint_captures_sensitivity_label(self) -> None: - from azure.search.documents.knowledgebases.models import ( - KnowledgeBaseRemoteSharePointReference, - SharePointSensitivityLabelInfo, + # KnowledgeBaseRemoteSharePointReference is preview-only; a SimpleNamespace fake keeps this + # test runnable on the stable/GA SDK while exercising the same parsing branches. + ref = SimpleNamespace( + id="ref-6", + activity_source=0, + reranker_score=None, + source_data=None, + web_url="https://sp.example.com/doc", + search_sensitivity_label_info=SimpleNamespace( + display_name="Confidential", sensitivity_label_id="lbl-1", is_encrypted=True + ), ) - - label = SharePointSensitivityLabelInfo( - display_name="Confidential", sensitivity_label_id="lbl-1", is_encrypted=True - ) - ref = KnowledgeBaseRemoteSharePointReference( - id="ref-6", activity_source=0, web_url="https://sp.example.com/doc", search_sensitivity_label_info=label - ) - result = AzureAISearchContextProvider._parse_references_to_annotations([ref]) + result = AzureAISearchContextProvider._parse_references_to_annotations(cast(Any, [ref])) assert result[0]["url"] == "https://sp.example.com/doc" sl = result[0]["additional_properties"]["sensitivity_label"] assert sl["display_name"] == "Confidential" @@ -1563,19 +1731,21 @@ def test_empty_response_returns_default(self) -> None: def test_image_content(self) -> None: from azure.search.documents.knowledgebases.models import ( + KnowledgeBaseImageContent, KnowledgeBaseMessage, KnowledgeBaseMessageImageContent, - KnowledgeBaseMessageImageContentImage, KnowledgeBaseRetrievalResponse, ) + # KnowledgeBaseImageContent is available on both the stable and preview SDKs and + # exposes ``url``, so the parsing assertion stays SDK-agnostic. response = KnowledgeBaseRetrievalResponse( response=[ KnowledgeBaseMessage( role="assistant", content=[ KnowledgeBaseMessageImageContent( - image=KnowledgeBaseMessageImageContentImage(url="https://img.example.com/a.png") + image=KnowledgeBaseImageContent(url="https://img.example.com/a.png") ) ], ), @@ -1589,9 +1759,9 @@ def test_image_content(self) -> None: def test_mixed_text_and_image_content(self) -> None: from azure.search.documents.knowledgebases.models import ( + KnowledgeBaseImageContent, KnowledgeBaseMessage, KnowledgeBaseMessageImageContent, - KnowledgeBaseMessageImageContentImage, KnowledgeBaseMessageTextContent, KnowledgeBaseRetrievalResponse, ) @@ -1603,7 +1773,7 @@ def test_mixed_text_and_image_content(self) -> None: content=[ KnowledgeBaseMessageTextContent(text="description"), KnowledgeBaseMessageImageContent( - image=KnowledgeBaseMessageImageContentImage(url="https://img.example.com/b.png") + image=KnowledgeBaseImageContent(url="https://img.example.com/b.png") ), ], ), diff --git a/python/samples/02-agents/context_providers/azure_ai_search/README.md b/python/samples/02-agents/context_providers/azure_ai_search/README.md index 2e32819003d..ccb8c63fc78 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/README.md +++ b/python/samples/02-agents/context_providers/azure_ai_search/README.md @@ -14,7 +14,7 @@ This folder contains examples demonstrating how to use the Azure AI Search conte ## Installation ```bash -pip install agent-framework-foundry-search agent-framework-foundry +pip install agent-framework-azure-ai-search agent-framework-foundry ``` ## Prerequisites @@ -42,6 +42,22 @@ Both examples support two authentication methods: Run `az login` if using Entra ID authentication. +### API versions (stable vs preview) + +The provider auto-detects which build of `azure-search-documents` is installed — nothing to +configure in code: + +- **Stable / GA** — `pip install azure-search-documents` (`>=12.0.0`) → api-version `2026-04-01`. +- **Preview** — `pip install --pre azure-search-documents` (e.g. `12.1.0b1`) → api-version `2026-05-01-preview`. + +The installed build picks its own api-version, so newer releases work without code changes. + +Agentic `knowledge_base_output_mode="answer_synthesis"` and `retrieval_reasoning_effort` of +`"low"`/`"medium"` ship **only** in the preview build. On a stable build the provider uses +extractive output with minimal reasoning effort and raises an actionable error if a preview-only +option is requested. To enable them, just install the preview build (`pip install --pre +azure-search-documents`) — no code change. + ## Configuration ### Environment Variables diff --git a/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py b/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py index 07c69ecc3f2..0860fb20447 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py +++ b/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py @@ -82,9 +82,11 @@ async def main() -> None: credential=AzureCliCredential() if not search_key else None, mode="agentic", 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" + # Optional: Configure retrieval behavior. "answer_synthesis" output mode and + # "medium"/"low" reasoning effort require the preview build of azure-search-documents + # (`pip install --pre azure-search-documents`); the provider auto-detects the build. + knowledge_base_output_mode="extractive_data", # or "answer_synthesis" (preview build only) + retrieval_reasoning_effort="minimal", # or "medium", "low" (preview build only) ) else: # Auto-create Knowledge Base from index @@ -101,9 +103,11 @@ async def main() -> None: mode="agentic", azure_openai_resource_url=azure_openai_resource_url, model=model_deployment, - # Optional: Configure retrieval behavior - knowledge_base_output_mode="extractive_data", # or "answer_synthesis" - retrieval_reasoning_effort="minimal", # or "medium", "low" + # Optional: Configure retrieval behavior. "answer_synthesis" output mode and + # "medium"/"low" reasoning effort require the preview build of azure-search-documents + # (`pip install --pre azure-search-documents`); the provider auto-detects the build. + knowledge_base_output_mode="extractive_data", # or "answer_synthesis" (preview build only) + retrieval_reasoning_effort="minimal", # or "medium", "low" (preview build only) top_k=3, ) diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/provision_index.py b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/provision_index.py index 596c6ecd90a..641fd3fd54c 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/provision_index.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/provision_index.py @@ -76,10 +76,10 @@ def build_index(name: str) -> SearchIndex: return SearchIndex( name=name, fields=[ - SimpleField(name="id", type=SearchFieldDataType.String, key=True, filterable=True), - SearchableField(name="content", type=SearchFieldDataType.String, analyzer_name="standard.lucene"), - SimpleField(name="sourceName", type=SearchFieldDataType.String, filterable=True, retrievable=True), - SimpleField(name="sourceLink", type=SearchFieldDataType.String, retrievable=True), + SimpleField(name="id", type=SearchFieldDataType.STRING, key=True, filterable=True), + SearchableField(name="content", type=SearchFieldDataType.STRING, analyzer_name="standard.lucene"), + SimpleField(name="sourceName", type=SearchFieldDataType.STRING, filterable=True, retrievable=True), + SimpleField(name="sourceLink", type=SearchFieldDataType.STRING, retrievable=True), ], ) diff --git a/python/uv.lock b/python/uv.lock index 9e16f664933..49e8ce22605 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -235,7 +235,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-ai-search" -version = "1.0.0b260521" +version = "1.0.0b260618" source = { editable = "packages/azure-ai-search" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -245,7 +245,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "azure-search-documents", specifier = ">=11.7.0b2,<11.7.0b3" }, + { name = "azure-search-documents", specifier = ">=12.0.0,<13" }, ] [[package]] @@ -1359,15 +1359,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/cf/90f27a2b48c9b748f84194b07e565f900e7f0ce0500da9b9f067dca599d3/azure_ai_projects-2.2.0-py3-none-any.whl", hash = "sha256:8f89bdaca4df1bd479d3bd2bd0f19a0905d60be6d17b84a69e8fabd82eac5906", size = 344307, upload-time = "2026-05-30T00:21:00.672Z" }, ] -[[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.41.0" @@ -1496,17 +1487,16 @@ wheels = [ [[package]] name = "azure-search-documents" -version = "11.7.0b2" +version = "12.0.0" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/59/dc/bb4db263381aa5b29414e280a8535a343d877a3831a501ef39332174c85c/azure_search_documents-12.0.0.tar.gz", hash = "sha256:8e6d73ec0ed1623083435b757e34324db65d72d4e09cca061a59fc7e90c8ddbc", size = 386222 } 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" }, + { url = "https://files.pythonhosted.org/packages/a4/b1/4869a064dbb79fb4ecac684de51a8f8f7a93a315f3f9cc4bf8a65cc413cd/azure_search_documents-12.0.0-py3-none-any.whl", hash = "sha256:d88114e4179cd753845711042380a4571e7faa8619addf5e017928ebe37fc0d1", size = 352117 }, ] [[package]]