Python: [BREAKING] Python: Provider-leading client design & OpenAI package extraction (#4818)

* Python: Provider-leading client design & OpenAI package extraction

Major refactoring of the Python Agent Framework client architecture:

- Extract OpenAI clients into new `agent-framework-openai` package
- Core package no longer depends on openai, azure-identity, azure-ai-projects
- Rename clients for discoverability: OpenAIResponsesClient → OpenAIChatClient,
  OpenAIChatClient → OpenAIChatCompletionClient
- Unify `model_id`/`deployment_name`/`model_deployment_name` → `model` param
- New FoundryChatClient for Azure AI Foundry Responses API
- New FoundryAgent/FoundryAgentClient for connecting to pre-configured Foundry agents
- Remove OpenAIBase/OpenAIConfigMixin from non-deprecated client MRO
- Deprecate AzureOpenAI* clients, AzureAIClient, OpenAIAssistantsClient
- Reorganize samples: azure_openai+azure_ai+azure_ai_agent → azure/
- ADR-0020: Provider-Leading Client Design

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: missing Agent imports in samples, .model_id → .model in foundry_local sample

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: CI failures — mypy errors, coverage targets, sample imports

- azure-ai mypy: add type ignores for TypedDict total=, model arg, forward ref
- Coverage: replace core.azure/openai targets with openai package target
- project_provider: add type annotation for opts dict

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: populate openai .pyi stub, fix broken README links, coverage targets

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fixes

* updated observabilitty

* reset azure init.pyi

* fix errors

* updated adr number

* fix foundry local

* fixed not renamed docstrings and comments, and added deprecated markers to old classes

* fix tests and pyprojects

* fix test vars

* updated function tests

* update durable

* updated test setup for functions

* Fix Foundry auth in workflow samples

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Stabilize Python integration workflows

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update hosting samples for Foundry

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Trigger full CI rerun

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Trigger CI rerun again

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* trigger rerun

* trigger rerun

* fix for litellm

* undo durabletask changes

* Move Foundry APIs into foundry namespace

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix Foundry pyproject formatting

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Split provider samples by Foundry surface

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Restore hosting sample requirements

Also fix the Foundry Local sample link after the provider sample move.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* updated tests

* udpated foundry integration tests

* removed dist from azurefunctions tests

* Use separate Foundry clients for concurrent agents

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix client setup in azfunc and durable

* disabled two tests

* updated setup for some function and durable tests

* improved azure openai setup with new clients

* ignore deprecated

* fixes

* skip 11

* remove openai assistants int tests

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-03-25 10:56:29 +01:00
committed by GitHub
Unverified
parent 4b533608b6
commit 5e056b672e
485 changed files with 9784 additions and 12084 deletions
@@ -0,0 +1,24 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
from ._foundry_agent import FoundryAgent, RawFoundryAgent
from ._foundry_agent_client import RawFoundryAgentChatClient
from ._foundry_chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient
from ._foundry_memory_provider import FoundryMemoryProvider
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"FoundryAgent",
"FoundryChatClient",
"FoundryChatOptions",
"FoundryMemoryProvider",
"RawFoundryAgent",
"RawFoundryAgentChatClient",
"RawFoundryChatClient",
"__version__",
]
@@ -0,0 +1,67 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from typing import Union
from agent_framework.exceptions import ChatClientInvalidAuthException
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
logger: logging.Logger = logging.getLogger(__name__)
AzureTokenProvider = Callable[[], Union[str, Awaitable[str]]]
"""A callable that returns a bearer token string, either synchronously or asynchronously."""
AzureCredentialTypes = Union[TokenCredential, AsyncTokenCredential]
"""Union of Azure credential types.
Accepts:
- ``TokenCredential`` — synchronous Azure credential (e.g. ``DefaultAzureCredential()``)
- ``AsyncTokenCredential`` — asynchronous Azure credential (e.g. ``azure.identity.aio.DefaultAzureCredential()``)
"""
def resolve_credential_to_token_provider(
credential: AzureCredentialTypes | AzureTokenProvider,
token_endpoint: str | None,
) -> AzureTokenProvider:
"""Convert an Azure credential or token provider into an ``ad_token_provider`` callable.
If the credential is already a callable token provider, it is returned as-is
(``token_endpoint`` is not required in this case).
If it is a ``TokenCredential`` or ``AsyncTokenCredential``, it is wrapped using
``azure.identity.get_bearer_token_provider`` (sync or async variant) which
handles token caching and automatic refresh.
Args:
credential: An Azure credential or token provider callable.
token_endpoint: The token scope/endpoint
(e.g. ``"https://cognitiveservices.azure.com/.default"``).
Required when ``credential`` is a ``TokenCredential`` or ``AsyncTokenCredential``.
Returns:
A callable that returns a bearer token string (sync or async).
Raises:
ServiceInvalidAuthError: If the token endpoint is empty when needed for credential wrapping.
"""
# Already a token provider callable (not a credential object) — use directly
if callable(credential) and not isinstance(credential, (TokenCredential, AsyncTokenCredential)):
return credential
if not token_endpoint:
raise ChatClientInvalidAuthException(
"A token endpoint must be provided either in settings, as an environment variable, or as an argument."
)
if isinstance(credential, AsyncTokenCredential):
from azure.identity.aio import get_bearer_token_provider as get_async_bearer_token_provider
return get_async_bearer_token_provider(credential, token_endpoint)
from azure.identity import get_bearer_token_provider
return get_bearer_token_provider(credential, token_endpoint) # type: ignore[arg-type]
@@ -0,0 +1,287 @@
# Copyright (c) Microsoft. All rights reserved.
"""Microsoft Foundry Agent for connecting to pre-configured agents in Foundry.
This module provides ``RawFoundryAgent`` and ``FoundryAgent`` — Agent subclasses
that connect to existing PromptAgents or HostedAgents in Foundry. Use
``FoundryAgent`` for the recommended experience with full middleware and telemetry.
"""
from __future__ import annotations
import logging
import sys
from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING, Any
from agent_framework import (
AgentMiddlewareLayer,
BaseContextProvider,
RawAgent,
)
from agent_framework.observability import AgentTelemetryLayer
from azure.ai.projects.aio import AIProjectClient
from ._entra_id_authentication import AzureCredentialTypes
from ._foundry_agent_client import (
RawFoundryAgentChatClient,
_FoundryAgentChatClient, # pyright: ignore[reportPrivateUsage]
)
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
else:
from typing_extensions import TypeVar # type: ignore # pragma: no cover
if sys.version_info >= (3, 11):
from typing import TypedDict # type: ignore # pragma: no cover
else:
from typing_extensions import TypedDict # type: ignore # pragma: no cover
if TYPE_CHECKING:
from agent_framework._middleware import MiddlewareTypes
from agent_framework._tools import FunctionTool
from agent_framework_openai._chat_client import OpenAIChatOptions
logger: logging.Logger = logging.getLogger("agent_framework.foundry")
FoundryAgentOptionsT = TypeVar(
"FoundryAgentOptionsT",
bound=TypedDict, # type: ignore[valid-type]
default="OpenAIChatOptions",
covariant=True,
)
class RawFoundryAgent( # type: ignore[misc]
RawAgent[FoundryAgentOptionsT],
):
"""Raw Microsoft Foundry Agent without agent-level middleware or telemetry.
Connects to an existing PromptAgent or HostedAgent in Foundry.
For full middleware and telemetry support, use :class:`FoundryAgent`.
Examples:
.. code-block:: python
from agent_framework.foundry import RawFoundryAgent
from azure.identity import AzureCliCredential
agent = RawFoundryAgent(
project_endpoint="https://your-project.services.ai.azure.com",
agent_name="my-prompt-agent",
agent_version="1.0",
credential=AzureCliCredential(),
)
result = await agent.run("Hello!")
"""
def __init__(
self,
*,
project_endpoint: str | None = None,
agent_name: str | None = None,
agent_version: str | None = None,
credential: AzureCredentialTypes | None = None,
project_client: AIProjectClient | None = None,
allow_preview: bool | None = None,
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
client_type: type[RawFoundryAgentChatClient] | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize a Foundry Agent.
Keyword Args:
project_endpoint: The Foundry project endpoint URL.
Can also be set via environment variable FOUNDRY_PROJECT_ENDPOINT.
agent_name: The name of the Foundry agent to connect to.
Can also be set via environment variable FOUNDRY_AGENT_NAME.
agent_version: The version of the agent (required for PromptAgents, optional for HostedAgents).
Can also be set via environment variable FOUNDRY_AGENT_VERSION.
credential: Azure credential for authentication.
project_client: An existing AIProjectClient to use.
allow_preview: Enables preview opt-in on internally-created AIProjectClient.
tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted.
context_providers: Optional context providers for injecting dynamic context.
client_type: Custom client class to use (must be a subclass of ``RawFoundryAgentChatClient``).
Defaults to ``_FoundryAgentChatClient`` (full client middleware).
env_file_path: Path to .env file for settings.
env_file_encoding: Encoding for .env file.
kwargs: Additional keyword arguments passed to the Agent base class.
"""
# Create the client
actual_client_type = client_type or _FoundryAgentChatClient
if not issubclass(actual_client_type, RawFoundryAgentChatClient):
raise TypeError(
f"client_type must be a subclass of RawFoundryAgentChatClient, got {actual_client_type.__name__}"
)
client = actual_client_type(
project_endpoint=project_endpoint,
agent_name=agent_name,
agent_version=agent_version,
credential=credential,
project_client=project_client,
allow_preview=allow_preview,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
super().__init__(
client=client, # type: ignore[arg-type]
tools=tools, # type: ignore[arg-type]
context_providers=context_providers,
**kwargs,
)
async def configure_azure_monitor(
self,
enable_sensitive_data: bool = False,
**kwargs: Any,
) -> None:
"""Setup observability with Azure Monitor (Microsoft Foundry integration).
This method configures Azure Monitor for telemetry collection using the
connection string from the Foundry project client (accessed via the internal client).
Args:
enable_sensitive_data: Enable sensitive data logging (prompts, responses).
Should only be enabled in development/test environments. Default is False.
**kwargs: Additional arguments passed to configure_azure_monitor().
Raises:
ImportError: If azure-monitor-opentelemetry-exporter is not installed.
"""
from azure.core.exceptions import ResourceNotFoundError
from ._foundry_agent_client import RawFoundryAgentChatClient
client = self.client
if not isinstance(client, RawFoundryAgentChatClient):
raise TypeError("configure_azure_monitor requires a RawFoundryAgentChatClient-based client.")
try:
conn_string = await client.project_client.telemetry.get_application_insights_connection_string()
except ResourceNotFoundError:
logger.warning(
"No Application Insights connection string found for the Foundry project. "
"Please ensure Application Insights is configured in your project, "
"or call configure_otel_providers() manually with custom exporters."
)
return
try:
from azure.monitor.opentelemetry import configure_azure_monitor # type: ignore[import]
except ImportError as exc:
raise ImportError(
"azure-monitor-opentelemetry is required for Azure Monitor integration. "
"Install it with: pip install azure-monitor-opentelemetry"
) from exc
from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation
if "resource" not in kwargs:
kwargs["resource"] = create_resource()
configure_azure_monitor(
connection_string=conn_string,
views=create_metric_views(),
**kwargs,
)
enable_instrumentation(enable_sensitive_data=enable_sensitive_data)
class FoundryAgent( # type: ignore[misc]
AgentTelemetryLayer,
AgentMiddlewareLayer,
RawFoundryAgent[FoundryAgentOptionsT],
):
"""Microsoft Foundry Agent with full middleware and telemetry support.
Connects to an existing PromptAgent or HostedAgent in Foundry.
This is the recommended class for production use.
Examples:
.. code-block:: python
from agent_framework.foundry import FoundryAgent
from azure.identity import AzureCliCredential
# Connect to a PromptAgent
agent = FoundryAgent(
project_endpoint="https://your-project.services.ai.azure.com",
agent_name="my-prompt-agent",
agent_version="1.0",
credential=AzureCliCredential(),
tools=[my_function_tool],
)
result = await agent.run("Hello!")
# Connect to a HostedAgent (no version needed)
agent = FoundryAgent(
project_endpoint="https://your-project.services.ai.azure.com",
agent_name="my-hosted-agent",
credential=AzureCliCredential(),
)
# Custom client (e.g., raw client without client middleware)
agent = FoundryAgent(
project_endpoint="https://your-project.services.ai.azure.com",
agent_name="my-agent",
credential=AzureCliCredential(),
client_type=RawFoundryAgentChatClient,
)
"""
def __init__(
self,
*,
project_endpoint: str | None = None,
agent_name: str | None = None,
agent_version: str | None = None,
credential: AzureCredentialTypes | None = None,
project_client: AIProjectClient | None = None,
allow_preview: bool | None = None,
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
client_type: type[RawFoundryAgentChatClient] | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize a Foundry Agent with full middleware and telemetry.
Keyword Args:
project_endpoint: The Foundry project endpoint URL.
agent_name: The name of the Foundry agent to connect to.
agent_version: The version of the agent (for PromptAgents).
credential: Azure credential for authentication.
project_client: An existing AIProjectClient to use.
allow_preview: Enables preview opt-in on internally-created AIProjectClient.
tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted.
context_providers: Optional context providers.
middleware: Optional agent-level middleware.
client_type: Custom client class (must subclass ``RawFoundryAgentChatClient``).
env_file_path: Path to .env file for settings.
env_file_encoding: Encoding for .env file.
kwargs: Additional keyword arguments.
"""
super().__init__(
project_endpoint=project_endpoint,
agent_name=agent_name,
agent_version=agent_version,
credential=credential,
project_client=project_client,
allow_preview=allow_preview,
tools=tools,
context_providers=context_providers,
middleware=middleware,
client_type=client_type,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
**kwargs,
)
@@ -0,0 +1,395 @@
# Copyright (c) Microsoft. All rights reserved.
"""Microsoft Foundry Agent client for connecting to pre-configured agents in Foundry.
This module provides ``RawFoundryAgentClient`` and ``FoundryAgentClient`` for
communicating with PromptAgents and HostedAgents via the Responses API.
"""
from __future__ import annotations
import logging
import sys
from collections.abc import Callable, Mapping, MutableMapping, Sequence
from typing import TYPE_CHECKING, Any, ClassVar, Generic, cast
from agent_framework._middleware import ChatMiddlewareLayer
from agent_framework._settings import load_settings
from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT
from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool
from agent_framework._types import Message
from agent_framework.observability import ChatTelemetryLayer
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
from azure.ai.projects.aio import AIProjectClient
from ._entra_id_authentication import AzureCredentialTypes
logger: logging.Logger = logging.getLogger(__name__)
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
else:
from typing_extensions import TypeVar # type: ignore # 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 # pragma: no cover
if sys.version_info >= (3, 11):
from typing import TypedDict # type: ignore # pragma: no cover
else:
from typing_extensions import TypedDict # type: ignore # pragma: no cover
if TYPE_CHECKING:
from agent_framework import Agent, BaseContextProvider
from agent_framework._middleware import (
ChatMiddleware,
ChatMiddlewareCallable,
FunctionMiddleware,
FunctionMiddlewareCallable,
MiddlewareTypes,
)
from agent_framework._tools import ToolTypes
class FoundryAgentSettings(TypedDict, total=False):
"""Settings for Microsoft FoundryAgentClient resolved from args and environment.
Keyword Args:
project_endpoint: The Foundry project endpoint URL.
Can be set via environment variable FOUNDRY_PROJECT_ENDPOINT.
agent_name: The name of the Foundry agent to connect to.
Can be set via environment variable FOUNDRY_AGENT_NAME.
agent_version: The version of the Foundry agent (for PromptAgents).
Can be set via environment variable FOUNDRY_AGENT_VERSION.
"""
project_endpoint: str | None
agent_name: str | None
agent_version: str | None
FoundryAgentOptionsT = TypeVar(
"FoundryAgentOptionsT",
bound=TypedDict, # type: ignore[valid-type]
default="OpenAIChatOptions",
covariant=True,
)
class RawFoundryAgentChatClient( # type: ignore[misc]
RawOpenAIChatClient[FoundryAgentOptionsT],
Generic[FoundryAgentOptionsT],
):
"""Raw Microsoft Foundry Agent chat client for connecting to pre-configured agents in Foundry.
Connects to existing PromptAgents or HostedAgents via the Responses API.
Does not create or delete agents — the agent must already exist in Foundry.
This is a raw client without function invocation, chat middleware, or telemetry layers.
Tools passed in options are validated (only ``FunctionTool`` allowed) but **not invoked** —
the function invocation loop is handled by ``_FoundryAgentChatClient`` or a custom subclass
that includes ``FunctionInvocationLayer``.
Use this class as an extension point when building a custom client with specific middleware
layers via subclassing::
from agent_framework._tools import FunctionInvocationLayer
from agent_framework.foundry import RawFoundryAgentChatClient
class MyClient(FunctionInvocationLayer, RawFoundryAgentChatClient):
pass
agent = FoundryAgent(..., client_type=MyClient)
"""
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.foundry"
def __init__(
self,
*,
project_endpoint: str | None = None,
agent_name: str | None = None,
agent_version: str | None = None,
credential: AzureCredentialTypes | None = None,
project_client: AIProjectClient | None = None,
allow_preview: bool | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize a raw Foundry Agent client.
Keyword Args:
project_endpoint: The Foundry project endpoint URL.
Can also be set via environment variable FOUNDRY_PROJECT_ENDPOINT.
agent_name: The name of the Foundry agent to connect to.
Can also be set via environment variable FOUNDRY_AGENT_NAME.
agent_version: The version of the agent (required for PromptAgents, optional for HostedAgents).
Can also be set via environment variable FOUNDRY_AGENT_VERSION.
credential: Azure credential for authentication.
project_client: An existing AIProjectClient to use.
allow_preview: Enables preview opt-in on internally-created AIProjectClient.
env_file_path: Path to .env file for settings.
env_file_encoding: Encoding for .env file.
kwargs: Additional keyword arguments.
"""
settings = load_settings(
FoundryAgentSettings,
env_prefix="FOUNDRY_",
project_endpoint=project_endpoint,
agent_name=agent_name,
agent_version=agent_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
resolved_endpoint = settings.get("project_endpoint")
self.agent_name = settings.get("agent_name")
self.agent_version = settings.get("agent_version")
if not self.agent_name:
raise ValueError(
"Agent name is required. Set via 'agent_name' parameter or 'FOUNDRY_AGENT_NAME' environment variable."
)
# Create or use provided project client
self._should_close_client = False
if project_client is not None:
self.project_client = project_client
else:
if not resolved_endpoint:
raise ValueError(
"Either 'project_endpoint' or 'project_client' is required. "
"Set project_endpoint via parameter or 'FOUNDRY_PROJECT_ENDPOINT' environment variable."
)
if not credential:
raise ValueError("Azure credential is required when using project_endpoint without a project_client.")
project_client_kwargs: dict[str, Any] = {
"endpoint": resolved_endpoint,
"credential": credential,
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
}
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
self.project_client = AIProjectClient(**project_client_kwargs)
self._should_close_client = True
# Get OpenAI client from project
async_client = self.project_client.get_openai_client()
super().__init__(async_client=async_client, **kwargs)
def _get_agent_reference(self) -> dict[str, str]:
"""Build the agent reference dict for the Responses API."""
ref: dict[str, str] = {"name": self.agent_name, "type": "agent_reference"} # type: ignore[dict-item]
if self.agent_version:
ref["version"] = self.agent_version
return ref
@override
def as_agent(
self,
*,
id: str | None = None,
name: str | None = None,
description: str | None = None,
instructions: str | None = None,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
default_options: FoundryAgentOptionsT | Mapping[str, Any] | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
**kwargs: Any,
) -> Agent[FoundryAgentOptionsT]:
"""Create a FoundryAgent that reuses this client's Foundry configuration."""
from ._foundry_agent import FoundryAgent
function_tools = cast(
FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None,
tools,
)
return cast(
"Agent[FoundryAgentOptionsT]",
FoundryAgent(
project_client=self.project_client,
agent_name=self.agent_name,
agent_version=self.agent_version,
tools=function_tools,
context_providers=context_providers,
middleware=middleware,
client_type=cast(type[RawFoundryAgentChatClient], self.__class__),
id=id,
name=self.agent_name if name is None else name,
description=description,
instructions=instructions,
default_options=default_options,
**kwargs,
),
)
@override
async def _prepare_options(
self,
messages: Sequence[Message],
options: Mapping[str, Any],
**kwargs: Any,
) -> dict[str, Any]:
"""Prepare options for the Responses API, injecting agent reference and validating tools."""
# Validate tools — only FunctionTool allowed
tools = options.get("tools", [])
if tools:
for tool_item in tools:
if not isinstance(tool_item, FunctionTool):
raise TypeError(
f"Only FunctionTool objects are accepted for Foundry agents, "
f"got {type(tool_item).__name__}. Other tool types (MCPTool, dict schemas, "
f"hosted tools) must be defined on the Foundry agent definition in the service."
)
# Prepare messages: extract system/developer messages as instructions
prepared_messages, _instructions = self._prepare_messages_for_azure_ai(messages)
# Call parent prepare_options (OpenAI Responses API format)
run_options = await super()._prepare_options(prepared_messages, options, **kwargs)
# Apply Azure AI schema transforms
if "input" in run_options and isinstance(run_options["input"], list):
run_options["input"] = self._transform_input_for_azure_ai(cast(list[dict[str, Any]], run_options["input"]))
# Inject agent reference
run_options["extra_body"] = {"agent_reference": self._get_agent_reference()}
return run_options
@override
def _check_model_presence(self, options: dict[str, Any]) -> None:
"""Skip model check — model is configured on the Foundry agent."""
pass
def _prepare_messages_for_azure_ai(self, messages: Sequence[Message]) -> tuple[list[Message], str | None]:
"""Extract system/developer messages as instructions for Azure AI.
Foundry agents may not support system/developer messages directly.
Instead, extract them as instructions to prepend.
"""
prepared: list[Message] = []
instructions_parts: list[str] = []
for msg in messages:
if msg.role in ("system", "developer"):
if msg.text:
instructions_parts.append(msg.text)
else:
prepared.append(msg)
instructions = "\n".join(instructions_parts) if instructions_parts else None
return prepared, instructions
def _transform_input_for_azure_ai(self, input_items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Transform input items to match Azure AI Projects expected schema.
Azure AI Projects 'create responses' API expects 'type' at item level
and 'annotations' for output_text content items.
"""
transformed: list[dict[str, Any]] = []
for item in input_items:
new_item: dict[str, Any] = dict(item)
if "role" in new_item and "type" not in new_item:
new_item["type"] = "message"
if (content := new_item.get("content")) and isinstance(content, list):
new_content: list[Any] = []
for content_item in content: # type: ignore[union-attr]
if isinstance(content_item, MutableMapping):
if content_item.get("type") == "output_text" and "annotations" not in content_item: # type: ignore[operator]
content_item["annotations"] = []
new_content.append(content_item)
else:
new_content.append(content_item)
new_item["content"] = new_content
transformed.append(new_item)
return transformed
async def close(self) -> None:
"""Close the project client if we created it."""
if self._should_close_client:
await self.project_client.close()
class _FoundryAgentChatClient( # type: ignore[misc]
FunctionInvocationLayer[FoundryAgentOptionsT],
ChatMiddlewareLayer[FoundryAgentOptionsT],
ChatTelemetryLayer[FoundryAgentOptionsT],
RawFoundryAgentChatClient[FoundryAgentOptionsT],
Generic[FoundryAgentOptionsT],
):
"""Microsoft Foundry Agent client with middleware, telemetry, and function invocation support.
Connects to existing PromptAgents or HostedAgents in Foundry.
Examples:
.. code-block:: python
from agent_framework import Agent
from agent_framework.foundry import FoundryAgentClient
from azure.identity import AzureCliCredential
client = FoundryAgentClient(
project_endpoint="https://your-project.services.ai.azure.com",
agent_name="my-prompt-agent",
agent_version="1.0",
credential=AzureCliCredential(),
)
agent = Agent(client=client, tools=[my_function_tool])
result = await agent.run("Hello!")
"""
def __init__(
self,
*,
project_endpoint: str | None = None,
agent_name: str | None = None,
agent_version: str | None = None,
credential: AzureCredentialTypes | None = None,
project_client: AIProjectClient | None = None,
allow_preview: bool | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
middleware: (
Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None
) = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
**kwargs: Any,
) -> None:
"""Initialize a Foundry Agent client with full middleware support.
Keyword Args:
project_endpoint: The Foundry project endpoint URL.
agent_name: The name of the Foundry agent to connect to.
agent_version: The version of the agent (for PromptAgents).
credential: Azure credential for authentication.
project_client: An existing AIProjectClient to use.
allow_preview: Enables preview opt-in on internally-created AIProjectClient.
env_file_path: Path to .env file for settings.
env_file_encoding: Encoding for .env file.
middleware: Optional sequence of middleware.
function_invocation_configuration: Optional function invocation configuration.
kwargs: Additional keyword arguments.
"""
super().__init__(
project_endpoint=project_endpoint,
agent_name=agent_name,
agent_version=agent_version,
credential=credential,
project_client=project_client,
allow_preview=allow_preview,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
**kwargs,
)
@@ -0,0 +1,530 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import logging
import sys
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal
from agent_framework._middleware import ChatMiddlewareLayer
from agent_framework._settings import load_settings
from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT
from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer
from agent_framework._types import Content
from agent_framework.observability import ChatTelemetryLayer
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
from azure.ai.projects.aio import AIProjectClient
from azure.ai.projects.models import (
AutoCodeInterpreterToolParam,
CodeInterpreterTool,
ImageGenTool,
WebSearchApproximateLocation,
WebSearchTool,
WebSearchToolFilters,
)
from azure.ai.projects.models import FileSearchTool as ProjectsFileSearchTool
from azure.ai.projects.models import MCPTool as FoundryMCPTool
from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider
from ._shared import resolve_file_ids
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
else:
from typing_extensions import TypeVar # type: ignore # 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 # pragma: no cover
if sys.version_info >= (3, 11):
from typing import TypedDict # type: ignore # pragma: no cover
else:
from typing_extensions import TypedDict # type: ignore # pragma: no cover
if TYPE_CHECKING:
from agent_framework._middleware import (
ChatMiddleware,
ChatMiddlewareCallable,
FunctionMiddleware,
FunctionMiddlewareCallable,
)
logger: logging.Logger = logging.getLogger("agent_framework.foundry")
class FoundrySettings(TypedDict, total=False):
"""Settings for Microsoft FoundryChatClient resolved from args and environment.
Keyword Args:
model: The model deployment name.
Can be set via environment variable FOUNDRY_MODEL.
project_endpoint: The Microsoft Foundry project endpoint URL.
Can be set via environment variable FOUNDRY_PROJECT_ENDPOINT.
"""
model: str | None
project_endpoint: str | None
FoundryChatOptionsT = TypeVar(
"FoundryChatOptionsT",
bound=TypedDict, # type: ignore[valid-type]
default="OpenAIChatOptions",
covariant=True,
)
FoundryChatOptions = OpenAIChatOptions
class RawFoundryChatClient( # type: ignore[misc]
RawOpenAIChatClient[FoundryChatOptionsT],
Generic[FoundryChatOptionsT],
):
"""Raw Microsoft Foundry chat client using the OpenAI Responses API via a Foundry project.
This client creates an OpenAI-compatible client from a Foundry project
and delegates to ``RawOpenAIChatClient`` for request handling.
Environment variables:
- ``FOUNDRY_PROJECT_ENDPOINT`` to provide the Foundry project endpoint.
- ``FOUNDRY_MODEL`` to provide the Foundry model deployment name.
Warning:
**This class should not normally be used directly.** Use ``FoundryChatClient``
for a fully-featured client with middleware, telemetry, and function invocation.
"""
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.foundry" # type: ignore[reportIncompatibleVariableOverride, misc]
def __init__(
self,
*,
project_endpoint: str | None = None,
project_client: AIProjectClient | None = None,
model: str | None = None,
credential: AzureCredentialTypes | AzureTokenProvider | None = None,
allow_preview: bool | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
instruction_role: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize a raw Microsoft Foundry chat client.
Keyword Args:
project_endpoint: The Foundry project endpoint URL.
Can also be set via environment variable FOUNDRY_PROJECT_ENDPOINT.
project_client: An existing AIProjectClient to use. If provided,
the OpenAI client will be obtained via ``project_client.get_openai_client()``.
model: The model deployment name.
Can also be set via environment variable FOUNDRY_MODEL.
credential: Azure credential or token provider for authentication.
Required when using ``project_endpoint`` without a ``project_client``.
allow_preview: Enables preview opt-in on internally-created AIProjectClient.
env_file_path: Path to .env file for settings.
env_file_encoding: Encoding for .env file.
instruction_role: The role to use for 'instruction' messages.
kwargs: Additional keyword arguments.
"""
foundry_settings = load_settings(
FoundrySettings,
env_prefix="FOUNDRY_",
model=model,
project_endpoint=project_endpoint,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
resolved_model = foundry_settings.get("model")
if not resolved_model:
raise ValueError("Model is required. Set via 'model' parameter or 'FOUNDRY_MODEL' environment variable.")
project_endpoint = foundry_settings.get("project_endpoint")
if project_endpoint is None and project_client is None:
raise ValueError(
"Either 'project_endpoint' or 'project_client' is required. "
"Set project_endpoint via parameter or 'FOUNDRY_PROJECT_ENDPOINT' environment variable."
)
if not project_client:
if not project_endpoint:
raise ValueError(
"Azure AI project endpoint is required. Set via 'project_endpoint' parameter "
"or 'FOUNDRY_PROJECT_ENDPOINT' environment variable,"
"or pass in a AIProjectClient."
)
if not credential:
raise ValueError("Azure credential is required when using project_endpoint without a project_client.")
project_client_kwargs: dict[str, Any] = {
"endpoint": project_endpoint,
"credential": credential, # type: ignore[arg-type]
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
}
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
project_client = AIProjectClient(**project_client_kwargs)
super().__init__(
model=resolved_model,
async_client=project_client.get_openai_client(),
instruction_role=instruction_role,
**kwargs,
)
self.project_client = project_client
@override
def _check_model_presence(self, options: dict[str, Any]) -> None:
if not options.get("model"):
if not self.model:
raise ValueError("model must be a non-empty string")
options["model"] = self.model
async def configure_azure_monitor(
self,
enable_sensitive_data: bool = False,
**kwargs: Any,
) -> None:
"""Setup observability with Azure Monitor (Microsoft Foundry integration).
This method configures Azure Monitor for telemetry collection using the
connection string from the Foundry project client.
Args:
enable_sensitive_data: Enable sensitive data logging (prompts, responses).
Should only be enabled in development/test environments. Default is False.
**kwargs: Additional arguments passed to configure_azure_monitor().
Common options include:
- enable_live_metrics (bool): Enable Azure Monitor Live Metrics
- credential (TokenCredential): Azure credential for Entra ID auth
- resource (Resource): Custom OpenTelemetry resource
Raises:
ImportError: If azure-monitor-opentelemetry-exporter is not installed.
"""
from azure.core.exceptions import ResourceNotFoundError
try:
conn_string = await self.project_client.telemetry.get_application_insights_connection_string()
except ResourceNotFoundError:
logger.warning(
"No Application Insights connection string found for the Foundry project. "
"Please ensure Application Insights is configured in your project, "
"or call configure_otel_providers() manually with custom exporters."
)
return
try:
from azure.monitor.opentelemetry import configure_azure_monitor # type: ignore[import]
except ImportError as exc:
raise ImportError(
"azure-monitor-opentelemetry is required for Azure Monitor integration. "
"Install it with: pip install azure-monitor-opentelemetry"
) from exc
from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation
if "resource" not in kwargs:
kwargs["resource"] = create_resource()
configure_azure_monitor(
connection_string=conn_string,
views=create_metric_views(),
**kwargs,
)
enable_instrumentation(enable_sensitive_data=enable_sensitive_data)
# region Tool factory methods (override OpenAI defaults with Foundry versions)
@staticmethod
def get_code_interpreter_tool( # type: ignore[override]
*,
file_ids: list[str | Content] | None = None,
container: Literal["auto"] | dict[str, Any] = "auto",
**kwargs: Any,
) -> CodeInterpreterTool:
"""Create a code interpreter tool configuration for Foundry.
Keyword Args:
file_ids: Optional list of file IDs or Content objects to make available.
container: Container configuration. Use "auto" for automatic management.
**kwargs: Additional arguments passed to the SDK CodeInterpreterTool constructor.
Returns:
A CodeInterpreterTool ready to pass to an Agent.
"""
if file_ids is None and isinstance(container, dict):
file_ids = container.get("file_ids")
resolved = resolve_file_ids(file_ids)
tool_container = AutoCodeInterpreterToolParam(file_ids=resolved)
return CodeInterpreterTool(container=tool_container, **kwargs)
@staticmethod
def get_file_search_tool(
*,
vector_store_ids: list[str],
max_num_results: int | None = None,
ranking_options: dict[str, Any] | None = None,
filters: dict[str, Any] | None = None,
**kwargs: Any,
) -> ProjectsFileSearchTool:
"""Create a file search tool configuration for Foundry.
Keyword Args:
vector_store_ids: List of vector store IDs to search.
max_num_results: Maximum number of results to return (1-50).
ranking_options: Ranking options for search results.
filters: A filter to apply (ComparisonFilter or CompoundFilter).
**kwargs: Additional arguments passed to the SDK FileSearchTool constructor.
Returns:
A FileSearchTool ready to pass to an Agent.
"""
if not vector_store_ids:
raise ValueError("File search tool requires 'vector_store_ids' to be specified.")
return ProjectsFileSearchTool(
vector_store_ids=vector_store_ids,
max_num_results=max_num_results,
ranking_options=ranking_options, # type: ignore[arg-type]
filters=filters, # type: ignore[arg-type]
**kwargs,
)
@staticmethod
def get_web_search_tool( # type: ignore[override]
*,
user_location: dict[str, str] | None = None,
search_context_size: Literal["low", "medium", "high"] | None = None,
allowed_domains: list[str] | None = None,
custom_search_configuration: dict[str, Any] | None = None,
**kwargs: Any,
) -> WebSearchTool:
"""Create a web search tool configuration for Microsoft Foundry.
Keyword Args:
user_location: Location context with keys like "city", "country", "region", "timezone".
search_context_size: Amount of context from search results ("low", "medium", "high").
allowed_domains: List of domains to restrict search results to.
custom_search_configuration: Custom Bing search configuration.
**kwargs: Additional arguments passed to the SDK WebSearchTool constructor.
Returns:
A WebSearchTool ready to pass to an Agent.
"""
ws_kwargs: dict[str, Any] = {**kwargs}
if search_context_size:
ws_kwargs["search_context_size"] = search_context_size
if allowed_domains:
ws_kwargs["filters"] = WebSearchToolFilters(allowed_domains=allowed_domains)
if custom_search_configuration:
ws_kwargs["custom_search_configuration"] = custom_search_configuration
ws_tool = WebSearchTool(**ws_kwargs)
if user_location:
ws_tool.user_location = WebSearchApproximateLocation(
city=user_location.get("city"),
country=user_location.get("country"),
region=user_location.get("region"),
timezone=user_location.get("timezone"),
)
return ws_tool
@staticmethod
def get_image_generation_tool( # type: ignore[override]
*,
model: Literal["gpt-image-1"] | str | None = None,
size: Literal["1024x1024", "1024x1536", "1536x1024", "auto"] | None = None,
output_format: Literal["png", "webp", "jpeg"] | None = None,
quality: Literal["low", "medium", "high", "auto"] | None = None,
background: Literal["transparent", "opaque", "auto"] | None = None,
partial_images: int | None = None,
moderation: Literal["auto", "low"] | None = None,
output_compression: int | None = None,
**kwargs: Any,
) -> ImageGenTool:
"""Create an image generation tool configuration for Foundry.
Keyword Args:
model: The model to use for image generation.
size: Output image size.
output_format: Output image format.
quality: Output image quality.
background: Background transparency setting.
partial_images: Number of partial images to return during generation.
moderation: Moderation level.
output_compression: Compression level.
**kwargs: Additional arguments passed to the SDK ImageGenTool constructor.
Returns:
An ImageGenTool ready to pass to an Agent.
"""
return ImageGenTool( # type: ignore[misc]
model=model, # type: ignore[arg-type]
size=size,
output_format=output_format,
quality=quality,
background=background,
partial_images=partial_images,
moderation=moderation,
output_compression=output_compression,
**kwargs,
)
@staticmethod
def get_mcp_tool(
*,
name: str,
url: str | None = None,
description: str | None = None,
approval_mode: Literal["always_require", "never_require"] | dict[str, list[str]] | None = None,
allowed_tools: list[str] | None = None,
headers: dict[str, str] | None = None,
project_connection_id: str | None = None,
**kwargs: Any,
) -> FoundryMCPTool:
"""Create a hosted MCP tool configuration for Foundry.
This configures an MCP server that runs remotely on Azure AI, not locally.
Keyword Args:
name: A label/name for the MCP server.
url: The URL of the MCP server. Required if project_connection_id is not provided.
description: A description of what the MCP server provides.
approval_mode: Tool approval mode ("always_require", "never_require", or dict).
allowed_tools: List of allowed tool names from this MCP server.
headers: HTTP headers to include in requests to the MCP server.
project_connection_id: Foundry connection ID for managed MCP connections.
**kwargs: Additional arguments passed to the SDK MCPTool constructor.
Returns:
An MCPTool configuration ready to pass to an Agent.
"""
mcp = FoundryMCPTool(server_label=name.replace(" ", "_"), server_url=url or "", **kwargs)
if description:
mcp["server_description"] = description
if project_connection_id:
mcp["project_connection_id"] = project_connection_id
elif headers:
mcp["headers"] = headers
if allowed_tools:
mcp["allowed_tools"] = allowed_tools
if approval_mode:
if isinstance(approval_mode, str):
mcp["require_approval"] = "always" if approval_mode == "always_require" else "never"
else:
if always_require := approval_mode.get("always_require_approval"):
mcp["require_approval"] = {"always": {"tool_names": always_require}}
if never_require := approval_mode.get("never_require_approval"):
mcp["require_approval"] = {"never": {"tool_names": never_require}}
return mcp
# endregion
class FoundryChatClient( # type: ignore[misc]
FunctionInvocationLayer[FoundryChatOptionsT],
ChatMiddlewareLayer[FoundryChatOptionsT],
ChatTelemetryLayer[FoundryChatOptionsT],
RawFoundryChatClient[FoundryChatOptionsT],
Generic[FoundryChatOptionsT],
):
"""Microsoft Foundry chat client using the OpenAI Responses API.
Creates an OpenAI-compatible client from a Foundry project
with middleware, telemetry, and function invocation support.
Environment variables:
- ``FOUNDRY_PROJECT_ENDPOINT`` to provide the Foundry project endpoint.
- ``FOUNDRY_MODEL`` to provide the Foundry model deployment name.
Keyword Args:
project_endpoint: The Foundry project endpoint URL.
Can also be set via environment variable ``FOUNDRY_PROJECT_ENDPOINT``.
project_client: An existing AIProjectClient to use.
model: The model deployment name.
Can also be set via environment variable ``FOUNDRY_MODEL``.
model_id: Deprecated alias for ``model``.
credential: Azure credential or token provider for authentication.
allow_preview: Enables preview opt-in on internally-created AIProjectClient.
env_file_path: Path to .env file for settings.
env_file_encoding: Encoding for .env file.
instruction_role: The role to use for 'instruction' messages.
middleware: Optional sequence of middleware.
function_invocation_configuration: Optional function invocation configuration.
Examples:
.. code-block:: python
from azure.identity import AzureCliCredential
from agent_framework_foundry import FoundryChatClient
client = FoundryChatClient(
project_endpoint="https://your-project.services.ai.azure.com",
model="gpt-4o",
credential=AzureCliCredential(),
)
# Or using an existing AIProjectClient
from azure.ai.projects.aio import AIProjectClient
project_client = AIProjectClient(
endpoint="https://your-project.services.ai.azure.com",
credential=AzureCliCredential(),
)
client = FoundryChatClient(
project_client=project_client,
model="gpt-4o",
)
"""
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.foundry" # type: ignore[reportIncompatibleVariableOverride, misc]
def __init__(
self,
*,
project_endpoint: str | None = None,
project_client: AIProjectClient | None = None,
model: str | None = None,
credential: AzureCredentialTypes | AzureTokenProvider | None = None,
allow_preview: bool | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
instruction_role: str | None = None,
middleware: (
Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None
) = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
**kwargs: Any,
) -> None:
"""Initialize a Foundry chat client.
Keyword Args:
project_endpoint: The Foundry project endpoint URL.
Can also be set via environment variable ``FOUNDRY_PROJECT_ENDPOINT``.
project_client: An existing AIProjectClient to use.
model: The model deployment name.
Can also be set via environment variable ``FOUNDRY_MODEL``.
credential: Azure credential or token provider for authentication.
allow_preview: Enables preview opt-in on internally-created AIProjectClient.
env_file_path: Path to .env file for settings.
env_file_encoding: Encoding for .env file.
instruction_role: The role to use for 'instruction' messages.
middleware: Optional sequence of middleware.
function_invocation_configuration: Optional function invocation configuration.
kwargs: Additional keyword arguments.
"""
super().__init__(
project_endpoint=project_endpoint,
project_client=project_client,
model=model,
credential=credential,
allow_preview=allow_preview,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
instruction_role=instruction_role,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
**kwargs,
)
@@ -0,0 +1,261 @@
# Copyright (c) Microsoft. All rights reserved.
"""Foundry Memory Context Provider using BaseContextProvider.
This module provides ``FoundryMemoryProvider``, built on
:class:`BaseContextProvider`.
"""
from __future__ import annotations
import logging
import sys
from contextlib import AbstractAsyncContextManager
from typing import TYPE_CHECKING, Any, ClassVar
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message
from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext
from agent_framework._settings import load_settings
from azure.ai.projects.aio import AIProjectClient
from openai.types.responses import ResponseInputItemParam
from ._entra_id_authentication import AzureCredentialTypes
from ._shared import FoundryProjectSettings
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
if TYPE_CHECKING:
from agent_framework._agents import SupportsAgentRun
logger = logging.getLogger(__name__)
class FoundryMemoryProvider(BaseContextProvider):
"""Foundry Memory context provider using the new BaseContextProvider hooks pattern.
Integrates Azure AI Foundry Memory Store for persistent semantic memory,
searching and storing memories via the Azure AI Projects SDK.
Args:
source_id: Unique identifier for this provider instance.
project_client: Azure AI Project client for memory operations.
memory_store_name: The name of the memory store to use.
scope: The namespace that logically groups and isolates memories (e.g., user ID).
context_prompt: The prompt to prepend to retrieved memories.
update_delay: Timeout period before processing memory update in seconds.
Defaults to 300 (5 minutes). Set to 0 to immediately trigger updates.
"""
DEFAULT_SOURCE_ID: ClassVar[str] = "foundry_memory"
DEFAULT_CONTEXT_PROMPT = "## Memories\nConsider the following memories when answering user questions:"
def __init__(
self,
source_id: str = DEFAULT_SOURCE_ID,
*,
project_client: AIProjectClient | None = None,
project_endpoint: str | None = None,
credential: AzureCredentialTypes | None = None,
allow_preview: bool | None = None,
memory_store_name: str,
scope: str | None = None,
context_prompt: str | None = None,
update_delay: int = 300,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize the Foundry Memory context provider.
Args:
source_id: Unique identifier for this provider instance.
project_client: Azure AI Project client for memory operations.
project_endpoint: Foundry project endpoint URL. Used when project_client is not provided.
credential: Azure credential for authentication. Accepts a TokenCredential,
AsyncTokenCredential, or a callable token provider.
Required when project_client is not provided.
allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``.
memory_store_name: The name of the memory store to use.
scope: The namespace that logically groups and isolates memories (e.g., user ID).
If None, `session_id` will be used.
context_prompt: The prompt to prepend to retrieved memories.
update_delay: Timeout period before processing memory update in seconds.
env_file_path: Path to environment file for loading settings.
env_file_encoding: Encoding of the environment file.
"""
super().__init__(source_id)
foundry_settings = load_settings(
FoundryProjectSettings,
env_prefix="FOUNDRY_",
project_endpoint=project_endpoint,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
if project_client is None:
resolved_endpoint = foundry_settings.get("project_endpoint")
if not resolved_endpoint:
raise ValueError(
"Foundry project endpoint is required. Set via 'project_endpoint' parameter "
"or 'FOUNDRY_PROJECT_ENDPOINT' environment variable."
)
if not credential:
raise ValueError("Azure credential is required when project_client is not provided.")
project_client_kwargs: dict[str, Any] = {
"endpoint": resolved_endpoint,
"credential": credential, # type: ignore[arg-type]
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
}
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
project_client = AIProjectClient(**project_client_kwargs)
if not memory_store_name:
raise ValueError("memory_store_name is required")
if not scope:
raise ValueError("scope is required")
self.project_client = project_client
self.memory_store_name = memory_store_name
self.scope = scope
self.context_prompt = context_prompt or self.DEFAULT_CONTEXT_PROMPT
self.update_delay = update_delay
async def __aenter__(self) -> Self:
"""Async context manager entry."""
if self.project_client and isinstance(self.project_client, AbstractAsyncContextManager):
await self.project_client.__aenter__()
return self
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
"""Async context manager exit."""
if self.project_client and isinstance(self.project_client, AbstractAsyncContextManager):
await self.project_client.__aexit__(exc_type, exc_val, exc_tb)
# -- Hooks pattern ---------------------------------------------------------
async def before_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Search Foundry Memory for relevant memories and add to the session context.
This method:
1. Retrieves static memories (user profile) on first call per session
2. Searches for contextual memories based on input messages
3. Combines and injects memories into the context
"""
# On first run, retrieve static memories (user profile memories)
if not state.get("initialized"):
try:
static_search_result = await self.project_client.beta.memory_stores.search_memories(
name=self.memory_store_name,
scope=self.scope or context.session_id, # type: ignore[arg-type]
)
static_memories = [{"content": memory.memory_item.content} for memory in static_search_result.memories]
state["static_memories"] = static_memories
except Exception as e:
# Log but don't fail - memory retrieval is non-critical
logger.warning(f"Failed to retrieve static memories: {e}")
state["static_memories"] = []
finally:
# Mark as initialized regardless of success to avoid repeated attempts
state["initialized"] = True
# Search for contextual memories based on input messages
# Check if there are any non-empty input messages
has_input = any(msg and msg.text and msg.text.strip() for msg in context.input_messages)
if not has_input:
return
# Convert input messages to memory search item format
items: list[ResponseInputItemParam] = [
{"type": "message", "role": "user", "content": msg.text}
for msg in context.input_messages
if msg and msg.text and msg.text.strip()
]
try:
search_result = await self.project_client.beta.memory_stores.search_memories(
name=self.memory_store_name,
scope=self.scope or context.session_id, # type: ignore[arg-type]
items=items,
previous_search_id=state.get("previous_search_id"),
)
# Extract search_id for next incremental search
if search_result.memories:
state["previous_search_id"] = search_result.search_id
# Combine static and contextual memories
contextual_memories = [{"content": memory.memory_item.content} for memory in search_result.memories]
all_memories = state.get("static_memories", []) + contextual_memories
# Inject memories into context
if all_memories:
line_separated_memories = "\n".join(
str(memory.get("content", "")) for memory in all_memories if memory.get("content")
)
if line_separated_memories:
context.extend_messages(
self.source_id,
[Message(role="user", text=f"{self.context_prompt}\n{line_separated_memories}")],
)
except Exception as e:
# Log but don't fail - memory retrieval is non-critical
logger.warning(f"Failed to search contextual memories: {e}")
async def after_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Store request/response messages to Foundry Memory for future retrieval.
This method updates the memory store with conversation messages.
The update is debounced by the configured update_delay.
"""
messages_to_store: list[Message] = list(context.input_messages)
if context.response and context.response.messages:
messages_to_store.extend(context.response.messages)
# Filter and convert messages to memory update item format
items: list[ResponseInputItemParam] = []
for message in messages_to_store:
if message.role in {"user", "assistant", "system"} and message.text and message.text.strip():
if message.role == "user":
items.append({"role": "user", "type": "message", "content": message.text})
elif message.role == "assistant":
items.append({"role": "assistant", "type": "message", "content": message.text})
if not items:
return
try:
# Fire and forget - don't wait for the update to complete
update_poller = await self.project_client.beta.memory_stores.begin_update_memories(
name=self.memory_store_name,
scope=self.scope or context.session_id, # type: ignore[arg-type]
items=items,
previous_update_id=state.get("previous_update_id"),
update_delay=self.update_delay,
)
# Store the update_id for next incremental update
state["previous_update_id"] = update_poller.update_id
except Exception as e:
# Log but don't fail - memory storage is non-critical
logger.warning(f"Failed to update memories: {e}")
__all__ = ["FoundryMemoryProvider"]
@@ -0,0 +1,49 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import logging
import sys
from collections.abc import Sequence
from agent_framework import Content
if sys.version_info >= (3, 11):
from typing import TypedDict # pragma: no cover
else:
from typing_extensions import TypedDict # type: ignore # pragma: no cover
logger = logging.getLogger("agent_framework.foundry")
class FoundryProjectSettings(TypedDict, total=False):
"""Foundry project settings loaded from FOUNDRY_ environment variables."""
project_endpoint: str | None
def resolve_file_ids(file_ids: Sequence[str | Content] | None) -> list[str] | None:
"""Resolve file IDs from strings or hosted-file Content objects."""
if not file_ids:
return None
resolved: list[str] = []
for item in file_ids:
if isinstance(item, str):
if not item:
raise ValueError("file_ids must not contain empty strings.")
resolved.append(item)
elif isinstance(item, Content):
if item.type != "hosted_file":
raise ValueError(
f"Unsupported Content type {item.type!r} for code interpreter file_ids. "
"Only Content.from_hosted_file() is supported."
)
if item.file_id is None:
raise ValueError(
"Content.from_hosted_file() item is missing a file_id. "
"Ensure the Content object has a valid file_id before using it in file_ids."
)
resolved.append(item.file_id)
return resolved if resolved else None