Skip to content

Commit c983448

Browse files
Add STORES_BY_DEFAULT ClassVar to skip redundant InMemoryHistoryProvider injection
Chat clients that store history server-side by default (OpenAI Responses API, Azure AI Agent) now declare STORES_BY_DEFAULT = True. The agent checks this during auto-injection and skips InMemoryHistoryProvider unless the user explicitly sets store=False.
1 parent 73eb64e commit c983448

14 files changed

Lines changed: 252 additions & 114 deletions

File tree

python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py

Lines changed: 35 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,12 @@
1010

1111
import sys
1212
from collections.abc import Awaitable, Callable
13-
from typing import TYPE_CHECKING, Any, ClassVar, Literal
13+
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict
1414

1515
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message
1616
from agent_framework._logging import get_logger
17-
from agent_framework._pydantic import AFBaseSettings
1817
from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext
19-
from agent_framework._settings import load_settings
18+
from agent_framework._settings import SecretString, load_settings
2019
from agent_framework.exceptions import ServiceInitializationError
2120
from azure.core.credentials import AzureKeyCredential
2221
from azure.core.credentials_async import AsyncTokenCredential
@@ -42,54 +41,6 @@
4241
VectorizableTextQuery,
4342
VectorizedQuery,
4443
)
45-
from pydantic import SecretStr
46-
47-
48-
class AzureAISearchSettings(AFBaseSettings):
49-
"""Settings for Azure AI Search Context Provider with auto-loading from environment.
50-
51-
The settings are first loaded from environment variables with the prefix 'AZURE_SEARCH_'.
52-
If the environment variables are not found, the settings can be loaded from a .env file.
53-
54-
Keyword Args:
55-
endpoint: Azure AI Search endpoint URL.
56-
Can be set via environment variable AZURE_SEARCH_ENDPOINT.
57-
index_name: Name of the search index.
58-
Can be set via environment variable AZURE_SEARCH_INDEX_NAME.
59-
knowledge_base_name: Name of an existing Knowledge Base (for agentic mode).
60-
Can be set via environment variable AZURE_SEARCH_KNOWLEDGE_BASE_NAME.
61-
api_key: API key for authentication (optional, use managed identity if not provided).
62-
Can be set via environment variable AZURE_SEARCH_API_KEY.
63-
env_file_path: If provided, the .env settings are read from this file path location.
64-
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
65-
66-
Examples:
67-
.. code-block:: python
68-
69-
from agent_framework_aisearch import AzureAISearchSettings
70-
71-
# Using environment variables
72-
# Set AZURE_SEARCH_ENDPOINT=https://mysearch.search.windows.net
73-
# Set AZURE_SEARCH_INDEX_NAME=my-index
74-
settings = AzureAISearchSettings()
75-
76-
# Or passing parameters directly
77-
settings = AzureAISearchSettings(
78-
endpoint="https://mysearch.search.windows.net",
79-
index_name="my-index",
80-
)
81-
82-
# Or loading from a .env file
83-
settings = AzureAISearchSettings(env_file_path="path/to/.env")
84-
"""
85-
86-
env_prefix: ClassVar[str] = "AZURE_SEARCH_"
87-
88-
endpoint: str | None = None
89-
index_name: str | None = None
90-
knowledge_base_name: str | None = None
91-
api_key: SecretStr | None = None
92-
9344

9445
if TYPE_CHECKING:
9546
from agent_framework._agents import SupportsAgentRun
@@ -157,6 +108,29 @@ class AzureAISearchSettings(AFBaseSettings):
157108
_DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT = 10
158109

159110

111+
class AzureAISearchSettings(TypedDict, total=False):
112+
"""Settings for Azure AI Search Context Provider with auto-loading from environment.
113+
114+
The settings are first loaded from environment variables with the prefix 'AZURE_SEARCH_'.
115+
If the environment variables are not found, the settings can be loaded from a .env file.
116+
117+
Keys:
118+
endpoint: Azure AI Search endpoint URL.
119+
Can be set via environment variable AZURE_SEARCH_ENDPOINT.
120+
index_name: Name of the search index.
121+
Can be set via environment variable AZURE_SEARCH_INDEX_NAME.
122+
knowledge_base_name: Name of an existing Knowledge Base (for agentic mode).
123+
Can be set via environment variable AZURE_SEARCH_KNOWLEDGE_BASE_NAME.
124+
api_key: API key for authentication (optional, use managed identity if not provided).
125+
Can be set via environment variable AZURE_SEARCH_API_KEY.
126+
"""
127+
128+
endpoint: str | None
129+
index_name: str | None
130+
knowledge_base_name: str | None
131+
api_key: SecretString | None
132+
133+
160134
class AzureAISearchContextProvider(BaseContextProvider):
161135
"""Azure AI Search context provider using the new BaseContextProvider hooks pattern.
162136
@@ -220,10 +194,18 @@ def __init__(
220194
"""
221195
super().__init__(source_id)
222196

197+
# Determine which fields are required based on mode
198+
required: list[str | tuple[str, ...]] = ["endpoint"]
199+
if mode == "semantic":
200+
required.append("index_name")
201+
elif mode == "agentic":
202+
required.append(("index_name", "knowledge_base_name"))
203+
223204
# Load settings from environment/file
224205
settings = load_settings(
225206
AzureAISearchSettings,
226207
env_prefix="AZURE_SEARCH_",
208+
required_fields=required,
227209
endpoint=endpoint,
228210
index_name=index_name,
229211
knowledge_base_name=knowledge_base_name,
@@ -232,32 +214,11 @@ def __init__(
232214
env_file_encoding=env_file_encoding,
233215
)
234216

235-
if not settings.get("endpoint"):
217+
if mode == "agentic" and settings.get("index_name") and not model_deployment_name:
236218
raise ServiceInitializationError(
237-
"Azure AI Search endpoint is required. Set via 'endpoint' parameter "
238-
"or 'AZURE_SEARCH_ENDPOINT' environment variable."
219+
"model_deployment_name is required for agentic mode when creating Knowledge Base from index."
239220
)
240221

241-
if mode == "semantic":
242-
if not settings.get("index_name"):
243-
raise ServiceInitializationError(
244-
"Azure AI Search index name is required for semantic mode. "
245-
"Set via 'index_name' parameter or 'AZURE_SEARCH_INDEX_NAME' environment variable."
246-
)
247-
elif mode == "agentic":
248-
if settings.get("index_name") and settings.get("knowledge_base_name"):
249-
raise ServiceInitializationError(
250-
"For agentic mode, provide either 'index_name' OR 'knowledge_base_name', not both."
251-
)
252-
if not settings.get("index_name") and not settings.get("knowledge_base_name"):
253-
raise ServiceInitializationError(
254-
"For agentic mode, provide either 'index_name' or 'knowledge_base_name'."
255-
)
256-
if settings.get("index_name") and not model_deployment_name:
257-
raise ServiceInitializationError(
258-
"model_deployment_name is required for agentic mode when creating Knowledge Base from index."
259-
)
260-
261222
resolved_credential: AzureKeyCredential | AsyncTokenCredential
262223
if credential:
263224
resolved_credential = credential

python/packages/azure-ai-search/tests/test_aisearch_context_provider.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import pytest
88
from agent_framework import Message
99
from agent_framework._sessions import AgentSession, SessionContext
10-
from agent_framework.exceptions import ServiceInitializationError
10+
from agent_framework.exceptions import ServiceInitializationError, SettingNotFoundError
1111

1212
from agent_framework_azure_ai_search._context_provider import AzureAISearchContextProvider
1313

@@ -88,7 +88,7 @@ def test_source_id_set(self) -> None:
8888
assert provider.source_id == "my-source"
8989

9090
def test_missing_endpoint_raises(self) -> None:
91-
with patch.dict(os.environ, {}, clear=True), pytest.raises(ServiceInitializationError, match="endpoint"):
91+
with patch.dict(os.environ, {}, clear=True), pytest.raises(SettingNotFoundError, match="endpoint"):
9292
AzureAISearchContextProvider(
9393
source_id="s",
9494
endpoint=None,
@@ -97,7 +97,7 @@ def test_missing_endpoint_raises(self) -> None:
9797
)
9898

9999
def test_missing_index_name_semantic_raises(self) -> None:
100-
with pytest.raises(ServiceInitializationError, match="index name"):
100+
with pytest.raises(SettingNotFoundError, match="index_name"):
101101
AzureAISearchContextProvider(
102102
source_id="s",
103103
endpoint="https://test.search.windows.net",
@@ -124,7 +124,7 @@ class TestInitAgenticValidation:
124124
"""Initialization validation tests for agentic mode."""
125125

126126
def test_both_index_and_kb_raises(self) -> None:
127-
with pytest.raises(ServiceInitializationError, match="not both"):
127+
with pytest.raises(SettingNotFoundError, match="multiple were set"):
128128
AzureAISearchContextProvider(
129129
source_id="s",
130130
endpoint="https://test.search.windows.net",
@@ -137,7 +137,7 @@ def test_both_index_and_kb_raises(self) -> None:
137137
)
138138

139139
def test_neither_index_nor_kb_raises(self) -> None:
140-
with pytest.raises(ServiceInitializationError, match="provide either"):
140+
with pytest.raises(SettingNotFoundError, match="none was set"):
141141
AzureAISearchContextProvider(
142142
source_id="s",
143143
endpoint="https://test.search.windows.net",

python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ class AzureAIAgentClient(
210210
"""Azure AI Agent Chat client with middleware, telemetry, and function invocation support."""
211211

212212
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc]
213+
STORES_BY_DEFAULT: ClassVar[bool] = True # type: ignore[reportIncompatibleVariableOverride, misc]
213214

214215
# region Hosted Tool Factory Methods
215216

python/packages/core/agent_framework/_agents.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,9 @@
6767
if TYPE_CHECKING:
6868
from ._types import ChatOptions
6969

70-
71-
ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None, covariant=True)
72-
ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel)
73-
74-
7570
logger = get_logger("agent_framework")
7671

77-
ThreadTypeT = TypeVar("ThreadTypeT", bound="AgentSession")
72+
ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel)
7873
OptionsCoT = TypeVar(
7974
"OptionsCoT",
8075
bound=TypedDict, # type: ignore[valid-type]
@@ -978,6 +973,10 @@ async def _prepare_run_context(
978973
and not session.service_session_id
979974
and not opts.get("conversation_id")
980975
and not opts.get("store")
976+
and not (
977+
getattr(self.client, "STORES_BY_DEFAULT", False)
978+
and opts.get("store") is not False
979+
)
981980
):
982981
self.context_providers.append(InMemoryHistoryProvider("memory"))
983982

python/packages/core/agent_framework/_clients.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,15 @@ async def _stream():
262262

263263
OTEL_PROVIDER_NAME: ClassVar[str] = "unknown"
264264
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"additional_properties"}
265-
# This is used for OTel setup, should be overridden in subclasses
265+
STORES_BY_DEFAULT: ClassVar[bool] = False
266+
"""Whether this client stores conversation history server-side by default.
267+
268+
Clients that use server-side storage (e.g., OpenAI Responses API with ``store=True``
269+
as default, Azure AI Agent threads) should override this to ``True``.
270+
When ``True``, the agent skips auto-injecting ``InMemoryHistoryProvider`` unless the
271+
user explicitly sets ``store=False``.
272+
"""
273+
# OTEL_PROVIDER_NAME is used for OTel setup, should be overridden in subclasses
266274

267275
def __init__(
268276
self,

python/packages/core/agent_framework/_settings.py

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,17 @@
1212
class MySettings(TypedDict, total=False):
1313
api_key: str | None # optional — resolves to None if not set
1414
model_id: str | None # optional by default
15+
source_a: str | None
16+
source_b: str | None
1517
1618
17-
# Make model_id required at call time:
19+
# Make model_id required; require exactly one of source_a / source_b:
1820
settings = load_settings(
1921
MySettings,
2022
env_prefix="MY_APP_",
21-
required_fields=["model_id"],
23+
required_fields=["model_id", ("source_a", "source_b")],
2224
model_id="gpt-4",
25+
source_a="value",
2326
)
2427
settings["api_key"] # type-checked dict access
2528
settings["model_id"] # str | None per type, but guaranteed not None at runtime
@@ -167,7 +170,7 @@ def load_settings(
167170
env_prefix: str = "",
168171
env_file_path: str | None = None,
169172
env_file_encoding: str | None = None,
170-
required_fields: Sequence[str] | None = None,
173+
required_fields: Sequence[str | tuple[str, ...]] | None = None,
171174
**overrides: Any,
172175
) -> SettingsT:
173176
"""Load settings from environment variables, a ``.env`` file, and explicit overrides.
@@ -181,26 +184,28 @@ def load_settings(
181184
4. Default values — fields with class-level defaults on the TypedDict, or
182185
``None`` for optional fields.
183186
184-
Fields listed in *required_fields* are validated after resolution. If any
185-
required field resolves to ``None``, a ``SettingNotFoundError`` is raised.
186-
This allows callers to decide which fields are required based on runtime
187-
context (e.g. ``endpoint`` is only required when no pre-built client is
188-
provided).
187+
Entries in *required_fields* are validated after resolution:
188+
189+
- A **string** entry means the field must resolve to a non-``None`` value.
190+
- A **tuple** entry means exactly one field in the group must be non-``None``
191+
(mutually exclusive).
189192
190193
Args:
191194
settings_type: A ``TypedDict`` class describing the settings schema.
192195
env_prefix: Prefix for environment variable lookup (e.g. ``"OPENAI_"``).
193196
env_file_path: Path to ``.env`` file. Defaults to ``".env"`` when omitted.
194197
env_file_encoding: Encoding for reading the ``.env`` file. Defaults to ``"utf-8"``.
195-
required_fields: Field names that must resolve to a non-``None`` value.
198+
required_fields: Field names (``str``) that must resolve to a non-``None``
199+
value, or tuples of field names where exactly one must be set.
196200
**overrides: Field values. ``None`` values are ignored so that callers can
197201
forward optional parameters without masking env-var / default resolution.
198202
199203
Returns:
200204
A populated dict matching *settings_type*.
201205
202206
Raises:
203-
SettingNotFoundError: If a required field could not be resolved from any source.
207+
SettingNotFoundError: If a required field could not be resolved from any
208+
source, or if a mutually exclusive constraint is violated.
204209
ServiceInitializationError: If an override value has an incompatible type.
205210
"""
206211
encoding = env_file_encoding or "utf-8"
@@ -215,7 +220,6 @@ def load_settings(
215220

216221
# Get field type hints from the TypedDict
217222
hints = get_type_hints(settings_type)
218-
required: set[str] = set(required_fields) if required_fields else set()
219223

220224
result: dict[str, Any] = {}
221225
for field_name, field_type in hints.items():
@@ -249,14 +253,30 @@ def load_settings(
249253
result[field_name] = None
250254

251255
# Validate required fields after all resolution
252-
if required:
253-
for field_name in required:
254-
if result.get(field_name) is None:
255-
env_var_name = f"{env_prefix}{field_name.upper()}"
256-
raise SettingNotFoundError(
257-
f"Required setting '{field_name}' was not provided. "
258-
f"Set it via the '{field_name}' parameter or the "
259-
f"'{env_var_name}' environment variable."
260-
)
256+
if required_fields:
257+
for entry in required_fields:
258+
if isinstance(entry, str):
259+
# Single required field
260+
if result.get(entry) is None:
261+
env_var_name = f"{env_prefix}{entry.upper()}"
262+
raise SettingNotFoundError(
263+
f"Required setting '{entry}' was not provided. "
264+
f"Set it via the '{entry}' parameter or the "
265+
f"'{env_var_name}' environment variable."
266+
)
267+
else:
268+
# Mutually exclusive group — exactly one must be set
269+
set_fields = [f for f in entry if result.get(f) is not None]
270+
if len(set_fields) == 0:
271+
names = ", ".join(f"'{f}'" for f in entry)
272+
raise SettingNotFoundError(
273+
f"Exactly one of {names} must be provided, but none was set."
274+
)
275+
if len(set_fields) > 1:
276+
all_names = ", ".join(f"'{f}'" for f in entry)
277+
set_names = ", ".join(f"'{f}'" for f in set_fields)
278+
raise SettingNotFoundError(
279+
f"Only one of {all_names} may be provided, but multiple were set: {set_names}."
280+
)
261281

262282
return result # type: ignore[return-value]

python/packages/core/agent_framework/openai/_responses_client.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
)
1414
from datetime import datetime, timezone
1515
from itertools import chain
16-
from typing import TYPE_CHECKING, Any, Generic, Literal, NoReturn, TypedDict, cast
16+
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, NoReturn, TypedDict, cast
1717

1818
from openai import AsyncOpenAI, BadRequestError
1919
from openai.types.responses.file_search_tool_param import FileSearchToolParam
@@ -238,6 +238,8 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
238238
Use ``OpenAIResponsesClient`` instead for a fully-featured client with all layers applied.
239239
"""
240240

241+
STORES_BY_DEFAULT: ClassVar[bool] = True # type: ignore[reportIncompatibleVariableOverride, misc]
242+
241243
FILE_SEARCH_MAX_RESULTS: int = 50
242244

243245
# region Inner Methods

0 commit comments

Comments
 (0)