chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+258
View File
@@ -0,0 +1,258 @@
# Agent Framework Foundry
This package contains the Microsoft Foundry integrations for Microsoft Agent Framework, including Foundry chat clients, preconfigured Foundry agents, Foundry embedding clients, and Foundry memory providers.
## Toolboxes
A *toolbox* is a named, versioned bundle of hosted tool configurations — code interpreter, file search, image generation, MCP, web search, and so on — stored inside a Microsoft Foundry project. Toolboxes let you manage tool configuration once and reuse it across agents.
### Authoring a toolbox
Toolboxes can be authored two ways:
- **Foundry portal** — create and version toolboxes through the UI without touching code.
- **Programmatically** — use the [`azure-ai-projects`](https://pypi.org/project/azure-ai-projects/) SDK to create, update, and version toolboxes from Python.
> Toolbox authoring APIs (`ToolboxVersionObject`, `ToolboxObject`, `project_client.beta.toolboxes.*`) require `azure-ai-projects>=2.1.0`. Earlier versions can only consume toolboxes that already exist.
### Using toolboxes with `FoundryAgent`
For hosted `FoundryAgent`, the toolbox must already be attached to the agent in the Microsoft Foundry project. Once attached, the agent invokes its toolbox tools transparently — no client-side wiring required — and you interact with the agent the same way you would with any other tool-equipped Foundry agent.
### Using toolboxes with `FoundryChatClient`
Each toolbox is reachable as an MCP server. Connect to the toolbox's MCP endpoint with `MCPStreamableHTTPTool` — the agent then discovers and calls its tools over MCP at runtime:
```python
from agent_framework import Agent, MCPStreamableHTTPTool
from agent_framework.foundry import FoundryChatClient
async with Agent(
client=FoundryChatClient(...),
instructions="You are a helpful assistant. Use the toolbox tools when useful.",
tools=MCPStreamableHTTPTool(
name="my_toolbox",
description="Tools served by my Foundry toolbox",
url="https://<your-toolbox-mcp-endpoint>",
),
) as agent:
result = await agent.run("What tools are available?")
print(result.text)
```
## Hosted tool factories
`FoundryChatClient` exposes static factory methods that return Foundry SDK tool
configurations ready to pass to an `Agent`'s `tools=[...]` argument. These
factories don't require a `FoundryChatClient` instance — you can call them
statically and reuse the same tool configuration across agents.
```python
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
agent = Agent(
client=FoundryChatClient(...),
instructions="...",
tools=[
FoundryChatClient.get_web_search_tool(),
FoundryChatClient.get_code_interpreter_tool(),
],
)
```
Generally available factories: `get_code_interpreter_tool`,
`get_file_search_tool`, `get_web_search_tool`,
`get_image_generation_tool`, `get_mcp_tool`.
> **Choosing a web grounding tool.** `get_web_search_tool` is the recommended
> default — it requires no separate Bing resource and works with Azure OpenAI
> models out of the box. Reach for `get_bing_grounding_tool` (experimental,
> see below) when you need finer Bing parameters (`count`, `freshness`,
> `market`, `set_lang`), are grounding non-OpenAI Foundry models, or are
> migrating from Grounding with Bing Search on the classic platform — it
> requires a Grounding with Bing Search Azure resource that you manage.
> `get_bing_custom_search_tool` (also experimental) is for grounding
> restricted to a curated list of domains via a Bing Custom Search instance.
> See the
> [web grounding overview](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/web-overview)
> for the full comparison.
> **Experimental — `ExperimentalFeature.FOUNDRY_TOOLS`.** The following
> factories wrap GA Foundry tool SDK classes but are new wrappers in
> `agent-framework-foundry` and may change before the wrappers themselves
> reach GA. Calls emit an `ExperimentalWarning` the first time the
> `FOUNDRY_TOOLS` feature is exercised in a process (then deduplicated).
| Factory | Foundry SDK tool |
|---------|-----------------|
| `get_azure_ai_search_tool(index_connection_id, index_name, ...)` | `AzureAISearchTool` |
| `get_bing_grounding_tool(connection_id, ...)` | `BingGroundingTool` |
> **Experimental — `ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS`.** The
> following factories wrap **preview** Foundry tool SDK types — the underlying
> Foundry capability itself is in preview and may change or be removed before
> reaching GA. Calls emit a separate `ExperimentalWarning` the first time the
> `FOUNDRY_PREVIEW_TOOLS` feature is exercised in a process (then
> deduplicated). Use `FOUNDRY_TOOLS` for "wrapper is new" and
> `FOUNDRY_PREVIEW_TOOLS` for "underlying Foundry feature is preview".
| Factory | Foundry SDK tool |
|---------|-----------------|
| `get_sharepoint_tool(connection_id)` | `SharepointPreviewTool` |
| `get_fabric_tool(connection_id)` | `MicrosoftFabricPreviewTool` |
| `get_memory_search_tool(memory_store_name, scope, ...)` | `MemorySearchPreviewTool` |
| `get_computer_use_tool(environment, display_width, display_height)` | `ComputerUsePreviewTool` |
| `get_browser_automation_tool(connection_id)` | `BrowserAutomationPreviewTool` |
| `get_bing_custom_search_tool(connection_id, instance_name, ...)` | `BingCustomSearchPreviewTool` |
| `get_a2a_tool(base_url=..., project_connection_id=..., ...)` | `A2APreviewTool` |
## Creating Foundry conversation sessions
`FoundryAgent.create_conversation()` creates a server-side Foundry
project conversation and returns an `AgentSession` that can be passed to
`agent.run(...)` without reaching into the raw OpenAI client.
```python
from agent_framework.foundry import FoundryAgent
agent = FoundryAgent(
project_endpoint=project_endpoint,
agent_name="travel-agent",
credential=credential,
)
session = await agent.create_conversation()
response = await agent.run("Help me plan a trip to Seattle.", session=session)
```
This is separate from hosted-agent `isolation_key` sessions: the created
conversation ID is stored on `AgentSession.service_session_id`, while the local
`session_id` remains available for application/session storage.
## Publishing an agent as a Foundry prompt agent
> **Experimental — `ExperimentalFeature.TO_PROMPT_AGENT`.** `to_prompt_agent`
> is a preview API and may change before reaching GA. The warning fires the
> first time the `TO_PROMPT_AGENT` feature is exercised in a process and is
> then deduplicated.
`to_prompt_agent(agent)` converts an `Agent` whose chat client is a
`FoundryChatClient` into a Foundry `PromptAgentDefinition` that can be
published with `AIProjectClient.agents.create_version(...)`. The model is read
from `default_options["model"]` first and falls back to the bound
`FoundryChatClient.model` (matching `Agent.__init__`'s resolution order), so
the same agent definition you run locally can be published as a hosted prompt
agent without restating the model deployment name.
Every generation parameter that has an Agent Framework equivalent is sourced
from `agent.default_options` and translated into the matching Foundry shape by
`_prepare_prompt_agent_options` (a module-private helper in
`agent_framework_foundry._to_prompt_agent` that reuses the chat client's own
request-path helpers):
| `default_options` key | `PromptAgentDefinition` field |
|---|---|
| `temperature` | `temperature` |
| `top_p` | `top_p` |
| `tool_choice` (dropped when no tools) | `tool_choice` (`str` / `ToolChoiceFunction` / `ToolChoiceAllowed`) |
| `reasoning` (dict or `Reasoning`) | `reasoning` |
| `response_format` (dict or `BaseModel`) | `text.format` |
| `verbosity` | `text.verbosity` |
| `text` | merged into `text` |
This keeps the `Agent` as the single source of truth for everything it can
already express. Only Foundry-specific fields with no Agent Framework
equivalent are accepted as keyword arguments on `to_prompt_agent`:
- `structured_inputs``dict[str, StructuredInputDefinition]`
- `rai_config``RaiConfig`
```python
import asyncio
import os
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient, to_prompt_agent
from azure.ai.projects.aio import AIProjectClient
from azure.identity.aio import AzureCliCredential
async def main() -> None:
credential = AzureCliCredential()
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
agent = Agent(
client=FoundryChatClient(
project_endpoint=project_endpoint,
model="gpt-4o",
credential=credential,
),
name="travel-agent",
description="Helps Contoso employees book travel.",
instructions="You are a helpful travel assistant.",
tools=[
FoundryChatClient.get_web_search_tool(),
FoundryChatClient.get_code_interpreter_tool(),
],
# Generation parameters set on the Agent flow through automatically.
default_options={
"temperature": 0.3,
"top_p": 0.95,
"reasoning": {"effort": "medium"},
},
)
definition = to_prompt_agent(agent)
project_client = AIProjectClient(endpoint=project_endpoint, credential=credential)
created = await project_client.agents.create_version(
agent_name=agent.name,
definition=definition,
description=agent.description,
)
print(f"Published {created.name} v{created.version}")
asyncio.run(main())
```
Behaviour:
- `agent.client` must be a `FoundryChatClient` (or subclass) — otherwise the
converter raises `TypeError`.
- The bound client must have a `model` set — otherwise the converter raises
`ValueError`.
- Foundry SDK tool instances returned by `FoundryChatClient.get_*_tool()` are
passed through unchanged.
- AF `FunctionTool` instances (and `@tool`-decorated callables) are emitted as
Foundry `FunctionTool` **declarations** — the prompt agent receives the
schema only, not the Python implementation. To execute the function when
invoking the deployed prompt agent, connect with `FoundryAgent` and pass the
same callable via `tools=`:
```python
from agent_framework.foundry import FoundryAgent
deployed = FoundryAgent(
project_endpoint=project_endpoint,
agent_name="travel-agent",
credential=credential,
tools=[book_hotel], # same @tool-decorated callable used at publish time
)
result = await deployed.run("Book me a hotel in Seattle for 3 nights.")
```
`FoundryAgent` runs the function locally when the prompt agent calls it, so
the declaration on the server and the implementation on the client stay in
sync via the shared `@tool` definition.
- Local Agent Framework MCP tools cannot be published as prompt-agent tools —
the converter raises `ValueError` and points at
`FoundryChatClient.get_mcp_tool(...)` for hosted MCP servers.
See the runnable example under `samples/02-agents/providers/foundry/`:
- [`foundry_prompt_agents.py`](../../samples/02-agents/providers/foundry/foundry_prompt_agents.py)
— publish with `to_prompt_agent`, then connect back with `FoundryAgent` and
execute the same local `@tool` callable that the deployed prompt agent
invokes by name.
@@ -0,0 +1,46 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
from ._agent import FoundryAgent, FoundryAgentOptions, RawFoundryAgent, RawFoundryAgentChatClient
from ._chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient
from ._embedding_client import (
FoundryEmbeddingClient,
FoundryEmbeddingOptions,
FoundryEmbeddingSettings,
RawFoundryEmbeddingClient,
)
from ._foundry_evals import (
FoundryEvals,
GeneratedEvaluatorRef,
evaluate_foundry_target,
evaluate_traces,
)
from ._memory_provider import FoundryMemoryProvider
from ._to_prompt_agent import to_prompt_agent
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"FoundryAgent",
"FoundryAgentOptions",
"FoundryChatClient",
"FoundryChatOptions",
"FoundryEmbeddingClient",
"FoundryEmbeddingOptions",
"FoundryEmbeddingSettings",
"FoundryEvals",
"FoundryMemoryProvider",
"GeneratedEvaluatorRef",
"RawFoundryAgent",
"RawFoundryAgentChatClient",
"RawFoundryChatClient",
"RawFoundryEmbeddingClient",
"__version__",
"evaluate_foundry_target",
"evaluate_traces",
"to_prompt_agent",
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,396 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import logging
import sys
from collections.abc import Sequence
from contextlib import suppress
from typing import Any, ClassVar, Generic, TypedDict
from agent_framework import (
BaseEmbeddingClient,
Content,
Embedding,
EmbeddingGenerationOptions,
GeneratedEmbeddings,
UsageDetails,
load_settings,
)
from agent_framework.observability import EmbeddingTelemetryLayer
from azure.ai.inference.aio import EmbeddingsClient, ImageEmbeddingsClient
from azure.ai.inference.models import ImageEmbeddingInput
from azure.core.credentials import AzureKeyCredential
if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
else:
from typing_extensions import TypeVar # pragma: no cover
logger = logging.getLogger("agent_framework.foundry")
_IMAGE_MEDIA_PREFIXES = ("image/",)
class FoundryEmbeddingOptions(EmbeddingGenerationOptions, total=False):
"""Foundry inference-specific embedding options.
Extends ``EmbeddingGenerationOptions`` with Foundry inference-specific fields.
Examples:
.. code-block:: python
from agent_framework_foundry import FoundryEmbeddingOptions
options: FoundryEmbeddingOptions = {
"model": "text-embedding-3-small",
"dimensions": 1536,
"input_type": "document",
"encoding_format": "float",
}
"""
input_type: str
"""Input type hint for the model. Common values: ``"text"``, ``"query"``, ``"document"``."""
image_model: str
"""Override model for image embeddings. Falls back to the client's ``image_model``."""
encoding_format: str
"""Output encoding format.
Common values: ``"float"``, ``"base64"``, ``"int8"``, ``"uint8"``,
``"binary"``, ``"ubinary"``.
"""
extra_parameters: dict[str, Any]
"""Additional model-specific parameters passed directly to the API."""
FoundryEmbeddingOptionsT = TypeVar(
"FoundryEmbeddingOptionsT",
bound=TypedDict, # type: ignore[valid-type]
default="FoundryEmbeddingOptions",
covariant=True,
)
class FoundryEmbeddingSettings(TypedDict, total=False):
"""Foundry inference embedding settings."""
models_endpoint: str | None
models_api_key: str | None
embedding_model: str | None
image_embedding_model: str | None
class RawFoundryEmbeddingClient(
BaseEmbeddingClient[Content | str, list[float], FoundryEmbeddingOptionsT],
Generic[FoundryEmbeddingOptionsT],
):
"""Raw Foundry embedding client without telemetry.
Accepts both text (``str``) and image (``Content``) inputs. Text and image
inputs within a single batch are separated and dispatched to
``EmbeddingsClient`` and ``ImageEmbeddingsClient`` respectively. Results
are reassembled in the original input order.
Keyword Args:
model: The text embedding model (e.g. "text-embedding-3-small").
Can also be set via environment variable FOUNDRY_EMBEDDING_MODEL.
image_model: The image embedding model (e.g. "Cohere-embed-v3-english").
Can also be set via environment variable FOUNDRY_IMAGE_EMBEDDING_MODEL.
Falls back to ``model`` if not provided.
endpoint: The Foundry inference endpoint URL.
Can also be set via environment variable FOUNDRY_MODELS_ENDPOINT.
api_key: API key for authentication.
Can also be set via environment variable FOUNDRY_MODELS_API_KEY.
text_client: Optional pre-configured ``EmbeddingsClient``.
image_client: Optional pre-configured ``ImageEmbeddingsClient``.
credential: Optional ``AzureKeyCredential`` or token credential. If not provided,
one is created from ``api_key``.
env_file_path: Path to .env file for settings.
env_file_encoding: Encoding for .env file.
"""
def __init__(
self,
*,
model: str | None = None,
image_model: str | None = None,
endpoint: str | None = None,
api_key: str | None = None,
text_client: EmbeddingsClient | None = None,
image_client: ImageEmbeddingsClient | None = None,
credential: AzureKeyCredential | None = None,
additional_properties: dict[str, Any] | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize a raw Foundry embedding client."""
settings = load_settings(
FoundryEmbeddingSettings,
env_prefix="FOUNDRY_",
required_fields=["models_endpoint", "embedding_model"],
models_endpoint=endpoint,
models_api_key=api_key,
embedding_model=model,
image_embedding_model=image_model,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
self.model = settings["embedding_model"] # type: ignore[reportTypedDictNotRequiredAccess]
self.image_model: str = settings.get("image_embedding_model") or self.model # type: ignore[assignment]
resolved_endpoint = settings["models_endpoint"] # type: ignore[reportTypedDictNotRequiredAccess]
if credential is None and settings.get("models_api_key"):
credential = AzureKeyCredential(settings["models_api_key"]) # type: ignore[arg-type]
if credential is None and text_client is None and image_client is None:
raise ValueError("Either 'api_key', 'credential', or pre-configured client(s) must be provided.")
self._text_client = text_client or EmbeddingsClient(
endpoint=resolved_endpoint, # type: ignore[arg-type]
credential=credential, # type: ignore[arg-type]
)
self._image_client = image_client or ImageEmbeddingsClient(
endpoint=resolved_endpoint, # type: ignore[arg-type]
credential=credential, # type: ignore[arg-type]
)
self._endpoint = resolved_endpoint
super().__init__(additional_properties=additional_properties)
async def close(self) -> None:
"""Close the underlying SDK clients and release resources."""
with suppress(Exception):
await self._text_client.close()
with suppress(Exception):
await self._image_client.close()
async def __aenter__(self) -> RawFoundryEmbeddingClient[FoundryEmbeddingOptionsT]:
"""Enter the async context manager."""
return self
async def __aexit__(self, *args: Any) -> None:
"""Exit the async context manager and close clients."""
await self.close()
def service_url(self) -> str:
"""Get the URL of the service."""
return self._endpoint or ""
async def get_embeddings(
self,
values: Sequence[Content | str],
*,
options: FoundryEmbeddingOptionsT | None = None,
) -> GeneratedEmbeddings[list[float], FoundryEmbeddingOptionsT]:
"""Generate embeddings for text and/or image inputs.
Text inputs (``str`` or ``Content`` with ``type="text"``) are sent to the
text embeddings endpoint. Image inputs (``Content`` with an image
``media_type``) are sent to the image embeddings endpoint. Results are
returned in the same order as the input.
Args:
values: A sequence of text strings or ``Content`` instances.
options: Optional embedding generation options.
Returns:
Generated embeddings with usage metadata.
Raises:
ValueError: If model is not provided or an unsupported content type is encountered.
"""
if not values:
return GeneratedEmbeddings([], options=options)
opts: dict[str, Any] = dict(options) if options else {}
# Separate text and image inputs, tracking original indices.
text_items: list[tuple[int, str]] = []
image_items: list[tuple[int, ImageEmbeddingInput]] = []
for idx, value in enumerate(values):
if isinstance(value, str):
text_items.append((idx, value))
elif isinstance(value, Content):
if value.type == "text" and value.text is not None:
text_items.append((idx, value.text))
elif (
value.type in ("data", "uri")
and value.media_type
and value.media_type.startswith(_IMAGE_MEDIA_PREFIXES[0])
):
if not value.uri:
raise ValueError(f"Image Content at index {idx} has no URI.")
image_input = ImageEmbeddingInput(image=value.uri, text=value.text)
image_items.append((idx, image_input))
else:
raise ValueError(
f"Unsupported Content type '{value.type}' with media_type "
f"'{value.media_type}' at index {idx}. Expected text content or "
f"image content (media_type starting with 'image/')."
)
else:
raise ValueError(f"Unsupported input type {type(value).__name__} at index {idx}.")
# Build shared API kwargs (without model, which differs per client).
common_kwargs: dict[str, Any] = {}
if dimensions := opts.get("dimensions"):
common_kwargs["dimensions"] = dimensions
if encoding_format := opts.get("encoding_format"):
common_kwargs["encoding_format"] = encoding_format
if input_type := opts.get("input_type"):
common_kwargs["input_type"] = input_type
if extra_parameters := opts.get("extra_parameters"):
common_kwargs["model_extras"] = extra_parameters
# Allocate results array.
embeddings: list[Embedding[list[float]] | None] = [None] * len(values)
usage_details: UsageDetails = {}
# Embed text inputs.
if text_items:
if not (text_model := opts.get("model") or self.model):
raise ValueError("A model is required, either in the client or options, for text inputs.")
text_inputs = [t for _, t in text_items]
response = await self._text_client.embed(
input=text_inputs,
model=text_model,
**common_kwargs,
)
for i, item in enumerate(response.data):
original_idx = text_items[i][0]
vector: list[float] = [float(v) for v in item.embedding]
embeddings[original_idx] = Embedding(
vector=vector,
dimensions=len(vector),
model=response.model or text_model,
)
if response.usage:
usage_details["input_token_count"] = (usage_details.get("input_token_count") or 0) + (
response.usage.prompt_tokens or 0
)
usage_details["output_token_count"] = (usage_details.get("output_token_count") or 0) + (
getattr(response.usage, "completion_tokens", 0) or 0
)
# Embed image inputs.
if image_items:
if not (image_model := opts.get("image_model") or self.image_model):
raise ValueError("An image_model is required, either in the client or options, for image inputs.")
image_inputs = [img for _, img in image_items]
response = await self._image_client.embed(
input=image_inputs,
model=image_model,
**common_kwargs,
)
for i, item in enumerate(response.data):
original_idx = image_items[i][0]
image_vector: list[float] = [float(v) for v in item.embedding]
embeddings[original_idx] = Embedding(
vector=image_vector,
dimensions=len(image_vector),
model=response.model or image_model,
)
if response.usage:
usage_details["input_token_count"] = (usage_details.get("input_token_count") or 0) + (
response.usage.prompt_tokens or 0
)
usage_details["output_token_count"] = (usage_details.get("output_token_count") or 0) + (
getattr(response.usage, "completion_tokens", 0) or 0
)
return GeneratedEmbeddings(
[embedding for embedding in embeddings if embedding is not None],
options=options,
usage=usage_details,
)
class FoundryEmbeddingClient(
EmbeddingTelemetryLayer[Content | str, list[float], FoundryEmbeddingOptionsT],
RawFoundryEmbeddingClient[FoundryEmbeddingOptionsT],
Generic[FoundryEmbeddingOptionsT],
):
"""Foundry embedding client with telemetry support.
Supports both text and image inputs in a single client. Pass plain strings
or ``Content`` instances created with ``Content.from_text()`` or
``Content.from_data()``.
Keyword Args:
model: The text embedding model (e.g. "text-embedding-3-small").
Can also be set via environment variable FOUNDRY_EMBEDDING_MODEL.
image_model: The image embedding model
(e.g. "Cohere-embed-v3-english"). Can also be set via environment variable
FOUNDRY_IMAGE_EMBEDDING_MODEL. Falls back to ``model``.
endpoint: The Foundry inference endpoint URL.
Can also be set via environment variable FOUNDRY_MODELS_ENDPOINT.
api_key: API key for authentication.
Can also be set via environment variable FOUNDRY_MODELS_API_KEY.
text_client: Optional pre-configured ``EmbeddingsClient``.
image_client: Optional pre-configured ``ImageEmbeddingsClient``.
credential: Optional ``AzureKeyCredential`` or token credential.
otel_provider_name: Override for the OpenTelemetry provider name.
env_file_path: Path to .env file for settings.
env_file_encoding: Encoding for .env file.
Examples:
.. code-block:: python
from agent_framework_foundry import FoundryEmbeddingClient
# Using environment variables
# Set FOUNDRY_MODELS_ENDPOINT=https://your-endpoint.inference.ai.azure.com
# Set FOUNDRY_MODELS_API_KEY=your-key
# Set FOUNDRY_EMBEDDING_MODEL=text-embedding-3-small
# Set FOUNDRY_IMAGE_EMBEDDING_MODEL=Cohere-embed-v3-english
client = FoundryEmbeddingClient()
# Text embeddings
result = await client.get_embeddings(["Hello, world!"])
# Image embeddings
from agent_framework import Content
image = Content.from_data(data=image_bytes, media_type="image/png")
result = await client.get_embeddings([image])
# Mixed text and image
result = await client.get_embeddings(["hello", image])
"""
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.inference"
def __init__(
self,
*,
model: str | None = None,
image_model: str | None = None,
endpoint: str | None = None,
api_key: str | None = None,
text_client: EmbeddingsClient | None = None,
image_client: ImageEmbeddingsClient | None = None,
credential: AzureKeyCredential | None = None,
otel_provider_name: str | None = None,
additional_properties: dict[str, Any] | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize a Foundry embedding client."""
super().__init__(
model=model,
image_model=image_model,
endpoint=endpoint,
api_key=api_key,
text_client=text_client,
image_client=image_client,
credential=credential,
additional_properties=additional_properties,
otel_provider_name=otel_provider_name,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,274 @@
# Copyright (c) Microsoft. All rights reserved.
"""Foundry Memory Context Provider using ContextProvider.
This module provides ``FoundryMemoryProvider``, built on
:class:`ContextProvider`.
"""
from __future__ import annotations
import logging
import sys
from contextlib import AbstractAsyncContextManager
from typing import TYPE_CHECKING, Any, ClassVar
from agent_framework import (
AgentSession,
ContextProvider,
Message,
SessionContext,
load_settings,
)
from agent_framework._telemetry import get_user_agent
from azure.ai.projects.aio import AIProjectClient
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from openai.types.responses import ResponseInputItemParam
if sys.version_info >= (3, 11):
from typing import Self, TypedDict # pragma: no cover
else:
from typing_extensions import Self, TypedDict # pragma: no cover
if TYPE_CHECKING:
from agent_framework import SupportsAgentRun
logger = logging.getLogger(__name__)
AzureCredentialTypes = TokenCredential | AsyncTokenCredential
class FoundryProjectSettings(TypedDict, total=False):
"""Foundry project settings loaded from FOUNDRY_ environment variables."""
project_endpoint: str | None
class FoundryMemoryProvider(ContextProvider):
"""Foundry Memory context provider using the new ContextProvider 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,
"user_agent": get_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", contents=[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,75 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import logging
from typing import Any
from urllib.parse import urlparse
from agent_framework import ChatResponseUpdate, Content
logger = logging.getLogger(__name__)
def _validate_consent_link(consent_link: str, item_id: str) -> str:
"""Validate a consent link is HTTPS with a valid netloc.
Returns the link unchanged if valid, or an empty string if not.
"""
parsed = urlparse(consent_link)
if parsed.scheme.lower() != "https" or not parsed.netloc:
logger.warning(
"Skipping oauth_consent_request with non-HTTPS consent_link (item id=%s)",
item_id,
)
return ""
return consent_link
def try_parse_oauth_consent_event(event: Any, model: str) -> ChatResponseUpdate | None:
"""Parse an oauth_consent_request from a streaming event, if present.
Returns a ``ChatResponseUpdate`` when *event* is a
``response.output_item.added`` carrying an ``oauth_consent_request`` item
or a top-level ``response.oauth_consent_requested`` event,
or ``None`` so the caller can fall through to the base implementation.
"""
consent_link: str = ""
raw_item: Any = None
event_type = getattr(event, "type", None)
if event_type == "response.output_item.added" and getattr(event.item, "type", None) == "oauth_consent_request":
raw_item = event.item
consent_link = getattr(raw_item, "consent_link", None) or ""
elif event_type == "response.oauth_consent_requested":
raw_item = event
consent_link = getattr(event, "consent_link", None) or ""
else:
return None
item_id = getattr(raw_item, "id", "<unknown>")
if consent_link:
consent_link = _validate_consent_link(consent_link, item_id)
contents: list[Content] = []
if consent_link:
contents.append(
Content.from_oauth_consent_request(
consent_link=consent_link,
raw_representation=raw_item,
)
)
else:
logger.warning(
"Received oauth_consent_request output without valid consent_link (item id=%s)",
item_id,
)
return ChatResponseUpdate(
contents=contents,
role="assistant",
model=model,
raw_representation=event,
)
@@ -0,0 +1,323 @@
# Copyright (c) Microsoft. All rights reserved.
"""Convert an Agent Framework agent into a Foundry ``PromptAgentDefinition``.
The converter accepts an :class:`agent_framework.Agent` whose chat client is a
:class:`agent_framework_foundry.FoundryChatClient` (or a subclass) and returns
a ``PromptAgentDefinition`` ready to publish via
``AIProjectClient.agents.create_version(...)``.
The model is lifted from the bound ``FoundryChatClient`` so the same ``Agent``
definition used for local execution can be published as a hosted prompt agent
without restating the model deployment name. Generation parameters
(``temperature``, ``top_p``, ``tool_choice``, ``reasoning``,
``response_format`` / ``text`` / ``verbosity``) are translated from
``agent.default_options`` by the local ``_prepare_prompt_agent_options``
helper, which reuses the chat client's own request-path helpers so they stay
consistent with the agent's local execution.
Parameters with no Agent Framework equivalent (``structured_inputs``,
``rai_config``) are accepted as keyword arguments only.
Function tools derived from local Python callables are translated to Foundry
``FunctionTool`` *declarations* only. Prompt agents are server-side, so the
deployed agent will receive the schema for these tools but cannot execute the
underlying Python; wiring server-side execution is the caller's responsibility.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from typing import TYPE_CHECKING, Any, cast
from agent_framework import FunctionTool
from agent_framework._feature_stage import ExperimentalFeature, experimental
from agent_framework._mcp import MCPTool
from ._chat_client import RawFoundryChatClient
if TYPE_CHECKING:
from agent_framework import Agent
from azure.ai.projects.models import (
PromptAgentDefinition,
RaiConfig,
StructuredInputDefinition,
Tool,
)
@experimental(feature_id=ExperimentalFeature.TO_PROMPT_AGENT)
def to_prompt_agent(
agent: Agent,
*,
structured_inputs: Mapping[str, StructuredInputDefinition] | None = None,
rai_config: RaiConfig | None = None,
) -> PromptAgentDefinition:
"""Convert an ``Agent`` into a Foundry ``PromptAgentDefinition``.
The agent's chat client must be a :class:`FoundryChatClient` (or any
subclass). The model deployment name is lifted from the bound client.
All generation parameters that have an Agent Framework equivalent
(``temperature``, ``top_p``, ``tool_choice``, ``reasoning``,
``response_format`` / ``text`` / ``verbosity``) are sourced from
``agent.default_options`` and translated by ``_prepare_prompt_agent_options``.
The agent is the single source of truth for these; configure them on the
``Agent`` (or pass ``default_options={...}`` to its constructor) rather
than here.
Args:
agent: An Agent Framework agent whose client is a ``FoundryChatClient``.
Keyword Args:
structured_inputs: Mapping of structured input names to
``StructuredInputDefinition`` entries. Foundry-only; no
``ChatOptions`` equivalent.
rai_config: Foundry ``RaiConfig`` to attach to the definition.
Foundry-only; no ``ChatOptions`` equivalent.
Returns:
A ``PromptAgentDefinition`` carrying the agent's model, instructions,
tools, and generation parameters. Pass it to
``AIProjectClient.agents.create_version(...)`` to publish.
"""
if not isinstance(agent.client, RawFoundryChatClient):
raise TypeError(
"Creating a Foundry Prompt Agent requires an Agent whose client is a FoundryChatClient; "
f"got {type(agent.client).__name__!r}."
)
# Match the resolution order Agent.__init__ uses when building default_options:
# an agent-level model override in default_options wins over the bound client's model.
model = agent.default_options.get("model") or agent.client.model
if not model:
raise ValueError(
"Agent has no model. Set 'model' on the FoundryChatClient (via the FOUNDRY_MODEL "
"environment variable or the model= argument), or pass default_options={'model': ...} "
"to the Agent before converting."
)
instructions = agent.default_options.get("instructions")
tools = _convert_tools(
agent.default_options.get("tools", []),
getattr(agent, "mcp_tools", []),
)
translated = _prepare_prompt_agent_options(
agent.client,
agent.default_options,
has_tools=bool(tools),
)
from azure.ai.projects.models import PromptAgentDefinition
kwargs: dict[str, Any] = {"model": model}
if instructions is not None:
kwargs["instructions"] = instructions
if tools:
kwargs["tools"] = tools
kwargs.update(translated)
if structured_inputs is not None:
kwargs["structured_inputs"] = dict(structured_inputs)
if rai_config is not None:
kwargs["rai_config"] = rai_config
return PromptAgentDefinition(**kwargs)
def _prepare_prompt_agent_options(
client: RawFoundryChatClient[Any],
default_options: Mapping[str, Any],
*,
has_tools: bool = False,
) -> dict[str, Any]:
"""Translate ``default_options`` into ``PromptAgentDefinition`` field kwargs.
Reuses the chat client's own request-path helpers
(``validate_tool_mode``, ``client._prepare_response_and_text_format``,
``type_to_text_format_param``) so a published prompt agent stays
consistent with the agent's local execution.
Only fields with a direct ``PromptAgentDefinition`` counterpart are
translated: ``temperature``, ``top_p``, ``reasoning``, ``tool_choice``,
``response_format`` / ``text`` / ``verbosity``. Other ``OpenAIChatOptions``
keys (``include``, ``prompt``, ``store``, etc.) have no prompt-agent
equivalent and are intentionally ignored. The input mapping is never
mutated.
Args:
client: The bound ``FoundryChatClient`` (used to reuse its
``_prepare_response_and_text_format`` for dict-shaped
``response_format`` values).
default_options: The agent's ``default_options`` mapping.
Keyword Args:
has_tools: When ``False``, ``tool_choice`` is dropped (no point
emitting a tool selection policy when the definition has no
tools), mirroring the regular request path in
``_prepare_options``.
Returns:
A dict ready to splat into ``PromptAgentDefinition(**...)``. Unset
fields are omitted.
"""
from agent_framework._types import validate_tool_mode
from azure.ai.projects.models import (
PromptAgentDefinitionTextOptions,
Reasoning,
ToolChoiceAllowed,
ToolChoiceFunction,
)
from openai.lib._parsing._responses import (
type_to_text_format_param,
)
from pydantic import BaseModel
result: dict[str, Any] = {}
if (temperature := default_options.get("temperature")) is not None:
result["temperature"] = temperature
if (top_p := default_options.get("top_p")) is not None:
result["top_p"] = top_p
if (reasoning := default_options.get("reasoning")) is not None:
if isinstance(reasoning, Reasoning):
result["reasoning"] = reasoning
elif isinstance(reasoning, Mapping):
result["reasoning"] = Reasoning(**dict(cast("Mapping[str, Any]", reasoning)))
else:
result["reasoning"] = reasoning
if has_tools and (tool_choice := default_options.get("tool_choice")) is not None:
tool_mode = validate_tool_mode(tool_choice)
if tool_mode is not None:
mode = tool_mode.get("mode")
func_name = tool_mode.get("required_function_name")
allowed = tool_mode.get("allowed_tools")
if mode == "required" and func_name is not None:
result["tool_choice"] = ToolChoiceFunction(name=func_name)
elif mode == "auto" and allowed is not None:
result["tool_choice"] = ToolChoiceAllowed(
mode="auto",
tools=[{"type": "function", "name": name} for name in allowed],
)
else:
result["tool_choice"] = mode
existing_text = default_options.get("text")
text_config: dict[str, Any] | None = (
dict(cast("Mapping[str, Any]", existing_text)) if isinstance(existing_text, Mapping) else None
)
response_format = default_options.get("response_format")
if response_format is not None or text_config is not None:
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
format_config = dict(type_to_text_format_param(response_format))
text_config = dict(text_config) if text_config else {}
if "format" in text_config and text_config["format"] != format_config:
raise ValueError("Conflicting response_format definitions detected.")
text_config["format"] = format_config
elif response_format is not None:
response_format_model, text_config = client._prepare_response_and_text_format( # pyright: ignore[reportPrivateUsage]
response_format=response_format, text_config=text_config
)
if response_format_model is not None:
raise ValueError(
"response_format must be a Pydantic BaseModel subclass or a mapping when "
"converting to a PromptAgentDefinition."
)
if (verbosity := default_options.get("verbosity")) is not None:
text_config = dict(text_config) if text_config else {}
text_config["verbosity"] = verbosity
if text_config:
result["text"] = PromptAgentDefinitionTextOptions(text_config)
return result
def _convert_tools(
tools: Iterable[Any] | None,
mcp_tools: Iterable[MCPTool] | None,
) -> list[Tool]:
"""Map AF agent tools to Foundry ``PromptAgentDefinition`` tool entries.
Tool sources walked, in order:
* ``agent.default_options["tools"]`` — function tools and hosted Foundry SDK
tool instances (returned by ``FoundryChatClient.get_*_tool()``).
* ``agent.mcp_tools`` — local Agent Framework MCP servers (split off from
the tools list by ``normalize_tools()``). These cannot be published as
prompt-agent tools; the caller must use the hosted MCP factory instead.
Hosted SDK tool instances are passed through unchanged. Mapping/dict tools
are passed through after light validation. Anything else raises
``ValueError`` with a message that names the offending type.
"""
from azure.ai.projects.models import Tool as ProjectsTool
converted: list[Tool] = []
for tool_item in tools or ():
if isinstance(tool_item, ProjectsTool):
converted.append(tool_item)
continue
if isinstance(tool_item, FunctionTool):
converted.append(_function_tool_to_foundry(tool_item))
continue
if isinstance(tool_item, Mapping):
converted.append(_validate_mapping_tool(cast("Mapping[str, Any]", tool_item)))
continue
raise ValueError(
f"Unsupported tool type for PromptAgentDefinition: {type(tool_item).__name__}. "
"Use FoundryChatClient.get_*_tool() helpers, a callable / FunctionTool, "
"or a dict matching the Foundry tool schema."
)
for mcp_tool in mcp_tools or ():
raise ValueError(
f"Local MCP tool {mcp_tool.name!r} cannot be published as a prompt-agent tool. "
"Use FoundryChatClient.get_mcp_tool(...) to register a hosted MCP server instead."
)
return converted
def _function_tool_to_foundry(tool_item: FunctionTool) -> Tool:
"""Build a Foundry ``FunctionTool`` declaration from an AF ``FunctionTool``.
The result carries only the schema (name, description, parameters). It is a
declaration of the tool the prompt agent may call; server-side execution
must be wired separately by the caller.
"""
try:
from azure.ai.projects.models import FunctionTool as ProjectsFunctionTool
except ImportError as exc: # pragma: no cover - sanity guard
raise ImportError(
"FunctionTool is not available in the installed azure-ai-projects. Upgrade azure-ai-projects."
) from exc
return ProjectsFunctionTool(
name=tool_item.name,
description=tool_item.description or "",
parameters=tool_item.parameters(),
strict=True,
)
def _validate_mapping_tool(tool_item: Mapping[str, Any]) -> Tool:
"""Validate a dict-shaped tool and instantiate a Foundry ``Tool``.
The Foundry SDK can rehydrate a tool model from its raw JSON mapping via
the discriminator on ``type``. We require the ``type`` field so the
failure mode is obvious; everything else is dispatched through the SDK's
``Tool._deserialize`` entry point so the concrete subclass
(e.g. ``FunctionTool``, ``WebSearchTool``) is materialized rather than a
generic ``Tool`` instance.
"""
from azure.ai.projects.models import Tool as ProjectsTool
if "type" not in tool_item:
raise ValueError("Dict-shaped tools must include a 'type' field matching a Foundry tool discriminator.")
# ``_deserialize`` is the SDK's discriminator-aware entry point. It is marked
# protected by convention but is the standard way to rehydrate polymorphic
# azure-sdk-for-python models from a raw mapping.
return cast("Tool", ProjectsTool._deserialize(dict(tool_item), [])) # type: ignore[no-untyped-call]
@@ -0,0 +1,72 @@
# Copyright (c) Microsoft. All rights reserved.
"""Shared tool helpers for Foundry chat clients.
Includes Responses-API payload sanitization for Foundry hosted tools.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, cast
from azure.ai.projects.models import MCPTool as FoundryMCPTool
def _validate_hosted_tool_payload(sanitized: Mapping[str, Any]) -> None:
"""Fail fast on hosted tool payloads that would always be rejected by the Responses API.
These mismatches are not injectable defaults — the caller must supply the
missing information — so surfacing a clear error here points at the tool
definition instead of letting the API return a generic 400.
"""
tool_type = sanitized.get("type")
if tool_type == "file_search" and not sanitized.get("vector_store_ids"):
raise ValueError(
"'file_search' tool is missing required 'vector_store_ids'. "
"Update the tool definition to include at least one vector store ID."
)
if tool_type == "mcp" and not sanitized.get("server_url") and not sanitized.get("project_connection_id"):
raise ValueError(
"'mcp' tool is missing both 'server_url' and 'project_connection_id'. "
"Update the tool definition to include one of these."
)
def _sanitize_foundry_response_tool(tool_item: Any) -> Any: # pyright: ignore[reportUnusedFunction]
"""Return a Responses-API-safe tool payload for Foundry hosted tools.
Reconciles known mismatches between hosted tool definitions and the Responses API:
1. Hosted tool objects may carry read-model fields such as top-level ``name``
and ``description``. The Responses API rejects at least ``name`` with
``Unknown parameter: 'tools[0].name'``. These fields are stripped from
non-function hosted tool payloads.
2. ``code_interpreter`` tools without a ``container`` field (the Azure SDK
treats it as optional) are rejected by the Responses API with
``Missing required parameter: 'tools[N].container'``. A default
``{"type": "auto"}`` container is injected when absent.
3. Hosted tools that are structurally incomplete in ways that cannot be
defaulted (``file_search`` without ``vector_store_ids``, ``mcp`` without
either ``server_url`` or ``project_connection_id``) raise ``ValueError``
with a message that points at the tool definition.
"""
if isinstance(tool_item, FoundryMCPTool):
sanitized: dict[str, Any] = dict(cast("Mapping[str, Any]", tool_item))
sanitized.pop("name", None)
sanitized.pop("description", None)
_validate_hosted_tool_payload(sanitized)
return sanitized
if isinstance(tool_item, Mapping):
mapping = cast("Mapping[str, Any]", tool_item)
if "type" in mapping and mapping.get("type") not in {"function", "custom"}:
sanitized = dict(mapping)
sanitized.pop("name", None)
sanitized.pop("description", None)
if sanitized.get("type") == "code_interpreter" and "container" not in sanitized:
sanitized["container"] = {"type": "auto"}
_validate_hosted_tool_payload(sanitized)
return sanitized
return cast(Any, tool_item)
+107
View File
@@ -0,0 +1,107 @@
[project]
name = "agent-framework-foundry"
description = "Microsoft Foundry integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.10.1"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.11.0,<2",
"agent-framework-openai>=1.10.0,<2",
"aiohttp>=3.9,<4",
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
"azure-ai-projects>=2.2.0,<2.3.0",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_foundry"]
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_foundry"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_foundry"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_foundry --cov-report=term-missing:skip-covered tests'
[tool.poe.tasks.integration-tests]
help = "Run the package integration test suite."
cmd = """
pytest --import-mode=importlib
-n logical --dist worksteal
tests
"""
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

@@ -0,0 +1,78 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from pytest import fixture
@fixture
def exclude_list(request: Any) -> list[str]:
"""Fixture that returns a list of environment variables to exclude."""
return request.param if hasattr(request, "param") else []
@fixture
def override_env_param_dict(request: Any) -> dict[str, str]:
"""Fixture that returns a dict of environment variables to override."""
return request.param if hasattr(request, "param") else {}
@fixture()
def foundry_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore
"""Fixture to set environment variables for Foundry settings."""
if exclude_list is None:
exclude_list = []
if override_env_param_dict is None:
override_env_param_dict = {}
env_vars = {
"FOUNDRY_PROJECT_ENDPOINT": "https://test-project.services.ai.azure.com/",
"FOUNDRY_MODEL": "test-gpt-4o",
}
env_vars.update(override_env_param_dict) # type: ignore
for key, value in env_vars.items():
if key in exclude_list:
monkeypatch.delenv(key, raising=False) # type: ignore
continue
monkeypatch.setenv(key, value) # type: ignore
return env_vars
@fixture
def mock_agents_client() -> MagicMock:
"""Fixture that provides a mock AgentsClient."""
mock_client = MagicMock()
# Mock agents property
mock_client.create_agent = AsyncMock()
mock_client.delete_agent = AsyncMock()
# Mock agent creation response
mock_agent = MagicMock()
mock_agent.id = "test-agent-id"
mock_client.create_agent.return_value = mock_agent
# Mock threads property
mock_client.threads = MagicMock()
mock_client.threads.create = AsyncMock()
mock_client.messages.create = AsyncMock()
# Mock runs property
mock_client.runs = MagicMock()
mock_client.runs.list = AsyncMock()
mock_client.runs.cancel = AsyncMock()
mock_client.runs.stream = AsyncMock()
mock_client.runs.submit_tool_outputs_stream = AsyncMock()
return mock_client
@fixture
def mock_azure_credential() -> MagicMock:
"""Fixture that provides a mock AsyncTokenCredential."""
return MagicMock()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,316 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import os
from collections.abc import Sequence
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import Content
from agent_framework_foundry import (
FoundryEmbeddingClient,
FoundryEmbeddingOptions,
RawFoundryEmbeddingClient,
)
def _make_embed_response(
embeddings: Sequence[list[float]],
model: str = "test-model",
prompt_tokens: int = 10,
) -> MagicMock:
"""Create a mock EmbeddingsResult."""
data = []
for emb in embeddings:
item = MagicMock()
item.embedding = emb
data.append(item)
usage = MagicMock()
usage.prompt_tokens = prompt_tokens
usage.completion_tokens = 0
result = MagicMock()
result.data = data
result.model = model
result.usage = usage
return result
@pytest.fixture
def mock_text_client() -> AsyncMock:
"""Create a mock text EmbeddingsClient."""
client = AsyncMock()
client.embed = AsyncMock(return_value=_make_embed_response([[0.1, 0.2, 0.3]]))
return client
@pytest.fixture
def mock_image_client() -> AsyncMock:
"""Create a mock image ImageEmbeddingsClient."""
client = AsyncMock()
client.embed = AsyncMock(return_value=_make_embed_response([[0.4, 0.5, 0.6]]))
return client
@pytest.fixture
def raw_client(mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> RawFoundryEmbeddingClient[Any]:
"""Create a RawFoundryEmbeddingClient with mocked SDK clients."""
return RawFoundryEmbeddingClient(
model="test-model",
endpoint="https://test.inference.ai.azure.com",
api_key="test-key",
text_client=mock_text_client,
image_client=mock_image_client,
)
@pytest.fixture
def client(mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> FoundryEmbeddingClient[Any]:
"""Create a FoundryEmbeddingClient with mocked SDK clients."""
return FoundryEmbeddingClient(
model="test-model",
endpoint="https://test.inference.ai.azure.com",
api_key="test-key",
text_client=mock_text_client,
image_client=mock_image_client,
)
class TestRawFoundryEmbeddingClient:
"""Tests for the raw Foundry embedding client."""
async def test_text_embeddings(
self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock
) -> None:
"""Text inputs are dispatched to the text client."""
result = await raw_client.get_embeddings(["hello", "world"])
assert result is not None
call_kwargs = mock_text_client.embed.call_args
assert call_kwargs.kwargs["input"] == ["hello", "world"]
assert call_kwargs.kwargs["model"] == "test-model"
async def test_text_content_embeddings(
self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock
) -> None:
"""Content.from_text() inputs are dispatched to the text client."""
text_content = Content.from_text("hello")
await raw_client.get_embeddings([text_content])
mock_text_client.embed.assert_called_once()
call_kwargs = mock_text_client.embed.call_args
assert call_kwargs.kwargs["input"] == ["hello"]
async def test_image_content_embeddings(
self, raw_client: RawFoundryEmbeddingClient[Any], mock_image_client: AsyncMock
) -> None:
"""Image Content inputs are dispatched to the image client."""
image_content = Content.from_data(data=b"\x89PNG", media_type="image/png")
await raw_client.get_embeddings([image_content])
mock_image_client.embed.assert_called_once()
call_kwargs = mock_image_client.embed.call_args
image_inputs = call_kwargs.kwargs["input"]
assert len(image_inputs) == 1
assert image_inputs[0].image == image_content.uri
async def test_mixed_text_and_image(
self,
raw_client: RawFoundryEmbeddingClient[Any],
mock_text_client: AsyncMock,
mock_image_client: AsyncMock,
) -> None:
"""Mixed text and image inputs are dispatched to the correct clients."""
mock_text_client.embed.return_value = _make_embed_response([[0.1, 0.2]])
mock_image_client.embed.return_value = _make_embed_response([[0.3, 0.4]])
image = Content.from_data(data=b"\x89PNG", media_type="image/png")
await raw_client.get_embeddings(["hello", image, "world"])
# Text client gets "hello" and "world"
text_call = mock_text_client.embed.call_args
assert text_call.kwargs["input"] == ["hello", "world"]
# Image client gets the image
image_call = mock_image_client.embed.call_args
assert len(image_call.kwargs["input"]) == 1
async def test_empty_input(self, raw_client: RawFoundryEmbeddingClient[Any]) -> None:
"""Empty input returns empty result."""
result = await raw_client.get_embeddings([])
assert len(result) == 0
async def test_options_passed_through(
self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock
) -> None:
"""Options are passed through to the SDK."""
options: FoundryEmbeddingOptions = {
"dimensions": 512,
"input_type": "document",
"encoding_format": "float",
}
await raw_client.get_embeddings(["hello"], options=options)
call_kwargs = mock_text_client.embed.call_args
assert call_kwargs.kwargs["dimensions"] == 512
assert call_kwargs.kwargs["input_type"] == "document"
assert call_kwargs.kwargs["encoding_format"] == "float"
async def test_model_override_in_options(
self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock
) -> None:
"""model in options overrides the default."""
options: FoundryEmbeddingOptions = {"model": "custom-model"}
await raw_client.get_embeddings(["hello"], options=options)
call_kwargs = mock_text_client.embed.call_args
assert call_kwargs.kwargs["model"] == "custom-model"
async def test_unsupported_content_type_raises(self, raw_client: RawFoundryEmbeddingClient[Any]) -> None:
"""Non-text, non-image Content raises ValueError."""
error_content = Content("error", message="fail")
with pytest.raises(ValueError, match="Unsupported Content type"):
await raw_client.get_embeddings([error_content])
async def test_usage_metadata(
self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock
) -> None:
"""Usage metadata is populated from the response."""
mock_text_client.embed.return_value = _make_embed_response([[0.1, 0.2]], prompt_tokens=42)
result = await raw_client.get_embeddings(["hello"])
assert result.usage is not None
assert result.usage["input_token_count"] == 42
def test_service_url(self, raw_client: RawFoundryEmbeddingClient[Any]) -> None:
"""service_url returns the configured endpoint."""
assert raw_client.service_url() == "https://test.inference.ai.azure.com"
def test_settings_from_env(self) -> None:
"""Settings are loaded from environment variables."""
with (
patch.dict(
os.environ,
{
"FOUNDRY_MODELS_ENDPOINT": "https://env.inference.ai.azure.com",
"FOUNDRY_MODELS_API_KEY": "env-key",
"FOUNDRY_EMBEDDING_MODEL": "env-model",
},
clear=True,
),
patch("agent_framework_foundry._embedding_client.EmbeddingsClient"),
patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient"),
):
client = RawFoundryEmbeddingClient()
assert client.model == "env-model"
assert client.image_model == "env-model" # falls back to model
def test_image_model_from_env(self) -> None:
"""image_model is loaded from its own environment variable."""
with (
patch.dict(
os.environ,
{
"FOUNDRY_MODELS_ENDPOINT": "https://env.inference.ai.azure.com",
"FOUNDRY_MODELS_API_KEY": "env-key",
"FOUNDRY_EMBEDDING_MODEL": "text-model",
"FOUNDRY_IMAGE_EMBEDDING_MODEL": "image-model",
},
),
patch("agent_framework_foundry._embedding_client.EmbeddingsClient"),
patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient"),
):
client = RawFoundryEmbeddingClient()
assert client.model == "text-model"
assert client.image_model == "image-model"
def test_image_model_explicit(self, mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> None:
"""image_model can be set explicitly."""
client = RawFoundryEmbeddingClient(
model="text-model",
image_model="image-model",
endpoint="https://test.inference.ai.azure.com",
api_key="test-key",
text_client=mock_text_client,
image_client=mock_image_client,
)
assert client.model == "text-model"
assert client.image_model == "image-model"
async def test_image_model_sent_to_image_client(
self, mock_text_client: AsyncMock, mock_image_client: AsyncMock
) -> None:
"""image_model is passed to the image client embed call."""
client = RawFoundryEmbeddingClient(
model="text-model",
image_model="image-model",
endpoint="https://test.inference.ai.azure.com",
api_key="test-key",
text_client=mock_text_client,
image_client=mock_image_client,
)
image_content = Content.from_data(data=b"\x89PNG", media_type="image/png")
await client.get_embeddings([image_content])
call_kwargs = mock_image_client.embed.call_args
assert call_kwargs.kwargs["model"] == "image-model"
class TestFoundryEmbeddingClient:
"""Tests for the telemetry-enabled Foundry embedding client."""
async def test_text_embeddings(self, client: FoundryEmbeddingClient[Any], mock_text_client: AsyncMock) -> None:
"""Text embeddings work through the telemetry layer."""
result = await client.get_embeddings(["hello"])
assert len(result) == 1
assert result[0].vector == [0.1, 0.2, 0.3]
async def test_otel_provider_name_default(self) -> None:
"""Default OTEL provider name is azure.ai.inference."""
assert FoundryEmbeddingClient.OTEL_PROVIDER_NAME == "azure.ai.inference"
async def test_otel_provider_name_override(self, mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> None:
"""OTEL provider name can be overridden."""
client = FoundryEmbeddingClient(
model="test-model",
endpoint="https://test.inference.ai.azure.com",
api_key="test-key",
text_client=mock_text_client,
image_client=mock_image_client,
otel_provider_name="custom-provider",
)
assert client.otel_provider_name == "custom-provider"
_SKIP_REASON = "Foundry inference integration tests disabled"
def _foundry_integration_tests_enabled() -> bool:
return bool(
os.environ.get("FOUNDRY_MODELS_ENDPOINT")
and os.environ.get("FOUNDRY_MODELS_API_KEY")
and os.environ.get("FOUNDRY_EMBEDDING_MODEL")
)
skip_if_foundry_inference_integration_tests_disabled = pytest.mark.skipif(
not _foundry_integration_tests_enabled(),
reason=_SKIP_REASON,
)
class TestFoundryEmbeddingIntegration:
"""Integration tests requiring a live Foundry inference endpoint."""
@pytest.mark.skip(reason="Flaky in merge queue, blocking unrelated PRs. Tracked in #5553.")
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_inference_integration_tests_disabled
async def test_text_embedding_live(self) -> None:
"""Generate text embeddings against a live endpoint."""
client = FoundryEmbeddingClient()
result = await client.get_embeddings(["Hello, world!"])
assert len(result) == 1
assert len(result[0].vector) > 0
assert result[0].model is not None
@@ -0,0 +1,503 @@
# Copyright (c) Microsoft. All rights reserved.
# pyright: reportPrivateUsage=false
from __future__ import annotations
import os
from typing import Any, cast
from unittest.mock import AsyncMock, Mock, patch
import pytest
from agent_framework import AgentResponse, Message
from agent_framework._sessions import AgentSession, SessionContext
from agent_framework._telemetry import get_user_agent
from agent_framework_foundry._memory_provider import FoundryMemoryProvider
@pytest.fixture
def mock_project_client() -> AsyncMock:
"""Create a mock AIProjectClient."""
mock_client = AsyncMock()
mock_client.beta = AsyncMock()
mock_client.beta.memory_stores = AsyncMock()
mock_client.beta.memory_stores.search_memories = AsyncMock()
mock_client.beta.memory_stores.begin_update_memories = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
return mock_client
@pytest.fixture
def mock_credential() -> Mock:
"""Create a mock Azure credential."""
return Mock()
# -- Initialization tests ------------------------------------------------------
def test_init_with_all_params(mock_project_client: AsyncMock) -> None:
provider = FoundryMemoryProvider(
source_id="custom_source",
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
context_prompt="Custom prompt",
update_delay=60,
)
assert provider.source_id == "custom_source"
assert provider.project_client is mock_project_client
assert provider.memory_store_name == "test_store"
assert provider.scope == "user_123"
assert provider.context_prompt == "Custom prompt"
assert provider.update_delay == 60
def test_init_default_source_id(mock_project_client: AsyncMock) -> None:
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
)
assert provider.source_id == FoundryMemoryProvider.DEFAULT_SOURCE_ID
def test_init_default_context_prompt(mock_project_client: AsyncMock) -> None:
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
)
assert provider.context_prompt == FoundryMemoryProvider.DEFAULT_CONTEXT_PROMPT
def test_init_default_update_delay(mock_project_client: AsyncMock) -> None:
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
)
assert provider.update_delay == 300
def test_init_with_project_endpoint_and_credential(mock_project_client: AsyncMock, mock_credential: Mock) -> None:
with patch("agent_framework_foundry._memory_provider.AIProjectClient") as mock_ai_project_client:
mock_ai_project_client.return_value = mock_project_client
provider = FoundryMemoryProvider(
project_endpoint="https://test.project.endpoint",
credential=mock_credential, # type: ignore[arg-type]
allow_preview=True,
memory_store_name="test_store",
scope="user_123",
)
assert provider.project_client is mock_project_client
mock_ai_project_client.assert_called_once_with(
endpoint="https://test.project.endpoint",
credential=mock_credential,
allow_preview=True,
user_agent=get_user_agent(),
)
def test_init_requires_project_endpoint_without_project_client() -> None:
with (
patch("agent_framework_foundry._memory_provider.load_settings") as mock_load_settings,
patch.dict(os.environ, {}, clear=True),
pytest.raises(ValueError, match="project endpoint is required"),
):
mock_load_settings.return_value = {"project_endpoint": None}
FoundryMemoryProvider(
memory_store_name="test_store",
scope="user_123",
)
def test_init_requires_credential_without_project_client() -> None:
with pytest.raises(ValueError, match="Azure credential is required"):
FoundryMemoryProvider(
project_endpoint="https://test.project.endpoint",
memory_store_name="test_store",
scope="user_123",
)
def test_init_requires_memory_store_name(mock_project_client: AsyncMock) -> None:
with pytest.raises(ValueError, match="memory_store_name is required"):
FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="",
scope="user_123",
)
def test_init_requires_scope(mock_project_client: AsyncMock) -> None:
with pytest.raises(ValueError, match="scope is required"):
FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="",
)
# -- before_run tests ----------------------------------------------------------
async def test_retrieves_static_memories_on_first_run(mock_project_client: AsyncMock) -> None:
mem1 = Mock()
mem1.memory_item.content = "User prefers Python"
mem2 = Mock()
mem2.memory_item.content = "User is based in Seattle"
mock_search_result = Mock()
mock_search_result.memories = [mem1, mem2]
mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
)
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
await provider.before_run( # type: ignore[arg-type]
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
)
# Should call search_memories twice: once for static, once for contextual
assert mock_project_client.beta.memory_stores.search_memories.call_count == 2
# Static memories should be cached
assert len(session.state[provider.source_id]["static_memories"]) == 2
assert session.state[provider.source_id]["initialized"] is True
async def test_contextual_memories_added_to_context(mock_project_client: AsyncMock) -> None:
# Mock static search (first call)
static_mem = Mock()
static_mem.memory_item.content = "User prefers Python"
static_result = Mock()
static_result.memories = [static_mem]
# Mock contextual search (second call)
contextual_mem = Mock()
contextual_mem.memory_item.content = "Last discussed async patterns"
contextual_result = Mock()
contextual_result.memories = [contextual_mem]
contextual_result.search_id = "search-123"
mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result]
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
)
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
await provider.before_run( # type: ignore[arg-type]
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
)
# Check that memories were added to context
assert provider.source_id in ctx.context_messages
added = ctx.context_messages[provider.source_id]
assert len(added) == 1
assert "User prefers Python" in added[0].text # type: ignore[operator]
assert "Last discussed async patterns" in added[0].text # type: ignore[operator]
assert provider.context_prompt in added[0].text # type: ignore[operator]
assert session.state[provider.source_id]["previous_search_id"] == "search-123"
async def test_empty_input_skips_contextual_search(mock_project_client: AsyncMock) -> None:
static_result = Mock()
static_result.memories = []
mock_project_client.beta.memory_stores.search_memories.return_value = static_result
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
)
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[Message(role="user", contents=[""])], session_id="s1")
await provider.before_run( # type: ignore[arg-type]
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
)
# Should only call search_memories once for static memories
assert mock_project_client.beta.memory_stores.search_memories.call_count == 1
assert provider.source_id not in ctx.context_messages
async def test_empty_search_results_no_messages(mock_project_client: AsyncMock) -> None:
mock_search_result = Mock()
mock_search_result.memories = []
mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
)
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[Message(role="user", contents=["test"])], session_id="s1")
await provider.before_run( # type: ignore[arg-type]
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
)
assert provider.source_id not in ctx.context_messages
async def test_static_memories_only_retrieved_once(mock_project_client: AsyncMock) -> None:
static_mem = Mock()
static_mem.memory_item.content = "Static memory"
static_result = Mock()
static_result.memories = [static_mem]
contextual_result = Mock()
contextual_result.memories = []
mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result]
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
)
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
# First call
await provider.before_run( # type: ignore[arg-type]
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
)
assert mock_project_client.beta.memory_stores.search_memories.call_count == 2
# Reset mock for second call
mock_project_client.beta.memory_stores.search_memories.reset_mock()
contextual_result2 = Mock()
contextual_result2.memories = []
mock_project_client.beta.memory_stores.search_memories.return_value = contextual_result2
# Second call - should only search contextual, not static
ctx2 = SessionContext(input_messages=[Message(role="user", contents=["World"])], session_id="s1")
await provider.before_run( # type: ignore[arg-type]
agent=cast(Any, None), session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {})
)
assert mock_project_client.beta.memory_stores.search_memories.call_count == 1
async def test_handles_search_exception_gracefully(mock_project_client: AsyncMock) -> None:
mock_project_client.beta.memory_stores.search_memories.side_effect = Exception("API error")
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
)
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
# Should not raise exception
await provider.before_run( # type: ignore[arg-type]
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
)
# No memories added
assert provider.source_id not in ctx.context_messages
# -- after_run tests -----------------------------------------------------------
async def test_stores_input_and_response(mock_project_client: AsyncMock) -> None:
mock_poller = Mock()
mock_poller.update_id = "update-456"
mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
)
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[Message(role="user", contents=["question"])], session_id="s1")
ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["answer"])])
await provider.after_run( # type: ignore[arg-type]
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
)
mock_project_client.beta.memory_stores.begin_update_memories.assert_awaited_once()
call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs
assert call_kwargs["name"] == "test_store"
assert call_kwargs["scope"] == "user_123"
assert len(call_kwargs["items"]) == 2
assert call_kwargs["items"][0]["content"] == "question"
assert call_kwargs["items"][1]["content"] == "answer"
assert session.state[provider.source_id]["previous_update_id"] == "update-456"
async def test_only_stores_user_assistant_system(mock_project_client: AsyncMock) -> None:
mock_poller = Mock()
mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
)
session = AgentSession(session_id="test-session")
ctx = SessionContext(
input_messages=[
Message(role="user", contents=["hello"]),
Message(role="tool", contents=["tool output"]),
],
session_id="s1",
)
ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["reply"])])
await provider.after_run( # type: ignore[arg-type]
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
)
call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs
items = call_kwargs["items"]
assert len(items) == 2
assert items[0]["content"] == "hello"
assert items[1]["content"] == "reply"
async def test_skips_empty_messages(mock_project_client: AsyncMock) -> None:
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
)
session = AgentSession(session_id="test-session")
ctx = SessionContext(
input_messages=[
Message(role="user", contents=[""]),
Message(role="user", contents=[" "]),
],
session_id="s1",
)
ctx._response = AgentResponse(messages=[])
await provider.after_run( # type: ignore[arg-type]
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
)
mock_project_client.beta.memory_stores.begin_update_memories.assert_not_awaited()
async def test_uses_configured_update_delay(mock_project_client: AsyncMock) -> None:
mock_poller = Mock()
mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
update_delay=60,
)
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1")
ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hey"])])
await provider.after_run( # type: ignore[arg-type]
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
)
call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs
assert call_kwargs["update_delay"] == 60
async def test_uses_previous_update_id_for_incremental_updates(mock_project_client: AsyncMock) -> None:
mock_poller1 = Mock()
mock_poller1.update_id = "update-1"
mock_poller2 = Mock()
mock_poller2.update_id = "update-2"
mock_project_client.beta.memory_stores.begin_update_memories.side_effect = [mock_poller1, mock_poller2]
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
)
session = AgentSession(session_id="test-session")
ctx1 = SessionContext(input_messages=[Message(role="user", contents=["first"])], session_id="s1")
ctx1._response = AgentResponse(messages=[Message(role="assistant", contents=["response1"])])
# First update
await provider.after_run( # type: ignore[arg-type]
agent=cast(Any, None), session=session, context=ctx1, state=session.state.setdefault(provider.source_id, {})
)
assert session.state[provider.source_id]["previous_update_id"] == "update-1"
# Second update should use previous_update_id
ctx2 = SessionContext(input_messages=[Message(role="user", contents=["second"])], session_id="s1")
ctx2._response = AgentResponse(messages=[Message(role="assistant", contents=["response2"])])
await provider.after_run( # type: ignore[arg-type]
agent=cast(Any, None), session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {})
)
call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs
assert call_kwargs["previous_update_id"] == "update-1"
assert session.state[provider.source_id]["previous_update_id"] == "update-2"
async def test_handles_update_exception_gracefully(mock_project_client: AsyncMock) -> None:
mock_project_client.beta.memory_stores.begin_update_memories.side_effect = Exception("API error")
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
)
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1")
ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hey"])])
# Should not raise exception
await provider.after_run( # type: ignore[arg-type]
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
)
# -- Context manager tests -----------------------------------------------------
async def test_aenter_delegates_to_client(mock_project_client: AsyncMock) -> None:
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
)
result = await provider.__aenter__()
assert result is provider
mock_project_client.__aenter__.assert_awaited_once()
async def test_aexit_delegates_to_client(mock_project_client: AsyncMock) -> None:
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
)
await provider.__aexit__(None, None, None)
mock_project_client.__aexit__.assert_awaited_once()
async def test_async_with_syntax(mock_project_client: AsyncMock) -> None:
provider = FoundryMemoryProvider(
project_client=mock_project_client,
memory_store_name="test_store",
scope="user_123",
)
async with provider as p:
assert p is provider
@@ -0,0 +1,164 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import logging
from typing import Any
from unittest.mock import MagicMock
import pytest
from agent_framework_foundry._oauth_helpers import _validate_consent_link, try_parse_oauth_consent_event
# region _validate_consent_link tests
def test_validate_consent_link_accepts_valid_https() -> None:
"""A valid HTTPS URL with a netloc passes validation."""
link = "https://consent.example.com/auth?code=123"
assert _validate_consent_link(link, "item-1") == link
def test_validate_consent_link_rejects_http(caplog: pytest.LogCaptureFixture) -> None:
"""An HTTP link is rejected and a warning is logged."""
with caplog.at_level(logging.WARNING):
result = _validate_consent_link("http://insecure.example.com/login", "item-2")
assert result == ""
assert "non-HTTPS" in caplog.text
assert "item-2" in caplog.text
def test_validate_consent_link_rejects_empty_netloc(caplog: pytest.LogCaptureFixture) -> None:
"""An HTTPS URL with an empty netloc (e.g. https:///path) is rejected."""
with caplog.at_level(logging.WARNING):
result = _validate_consent_link("https:///path", "item-3")
assert result == ""
assert "non-HTTPS" in caplog.text
assert "item-3" in caplog.text
def test_validate_consent_link_rejects_non_url(caplog: pytest.LogCaptureFixture) -> None:
"""A non-URL string is rejected."""
with caplog.at_level(logging.WARNING):
result = _validate_consent_link("not-a-url", "item-4")
assert result == ""
# endregion
# region try_parse_oauth_consent_event tests
def _make_output_item_event(
*,
item_type: str = "oauth_consent_request",
consent_link: Any = "https://consent.example.com/auth",
item_id: str = "oauth-item-1",
) -> MagicMock:
"""Create a mock ``response.output_item.added`` event."""
event = MagicMock()
event.type = "response.output_item.added"
item = MagicMock()
item.type = item_type
item.consent_link = consent_link
item.id = item_id
event.item = item
return event
def _make_top_level_event(
*,
consent_link: Any = "https://consent.example.com/authorize",
event_id: str = "consent-event-1",
) -> MagicMock:
"""Create a mock ``response.oauth_consent_requested`` event."""
event = MagicMock()
event.type = "response.oauth_consent_requested"
event.consent_link = consent_link
event.id = event_id
return event
def test_returns_none_for_unrelated_event() -> None:
"""An event with a non-oauth type returns None."""
event = MagicMock()
event.type = "response.output_text.delta"
assert try_parse_oauth_consent_event(event, "model-x") is None
def test_returns_none_for_event_without_type() -> None:
"""An event object missing a 'type' attribute returns None."""
event = object() # no type attribute
assert try_parse_oauth_consent_event(event, "model-x") is None
def test_parses_output_item_added_with_valid_link() -> None:
"""A response.output_item.added event with a valid HTTPS link produces Content."""
event = _make_output_item_event()
update = try_parse_oauth_consent_event(event, "test-model")
assert update is not None
assert update.role == "assistant"
assert update.model == "test-model"
assert update.raw_representation is event
consent = [c for c in update.contents if c.type == "oauth_consent_request"]
assert len(consent) == 1
assert consent[0].consent_link == "https://consent.example.com/auth"
def test_parses_top_level_consent_requested_event() -> None:
"""A response.oauth_consent_requested event produces Content."""
event = _make_top_level_event()
update = try_parse_oauth_consent_event(event, "test-model")
assert update is not None
consent = [c for c in update.contents if c.type == "oauth_consent_request"]
assert len(consent) == 1
assert consent[0].consent_link == "https://consent.example.com/authorize"
def test_empty_contents_for_non_https_link(caplog: pytest.LogCaptureFixture) -> None:
"""A non-HTTPS consent_link produces an update with empty contents and logs a warning."""
event = _make_output_item_event(consent_link="http://bad.example.com/login", item_id="item-http")
with caplog.at_level(logging.WARNING):
update = try_parse_oauth_consent_event(event, "test-model")
assert update is not None
assert len(update.contents) == 0
assert "non-HTTPS" in caplog.text
def test_empty_contents_for_missing_consent_link(caplog: pytest.LogCaptureFixture) -> None:
"""A None consent_link produces an update with empty contents and logs a warning."""
event = _make_output_item_event(consent_link=None, item_id="item-none")
with caplog.at_level(logging.WARNING):
update = try_parse_oauth_consent_event(event, "test-model")
assert update is not None
assert len(update.contents) == 0
assert "without valid consent_link" in caplog.text
def test_empty_contents_for_empty_string_consent_link(caplog: pytest.LogCaptureFixture) -> None:
"""An empty-string consent_link produces an update with empty contents and logs a warning."""
event = _make_output_item_event(consent_link="", item_id="item-empty")
with caplog.at_level(logging.WARNING):
update = try_parse_oauth_consent_event(event, "test-model")
assert update is not None
assert len(update.contents) == 0
assert "without valid consent_link" in caplog.text
def test_empty_contents_for_https_empty_netloc(caplog: pytest.LogCaptureFixture) -> None:
"""An HTTPS URL with empty netloc (https:///path) is rejected."""
event = _make_output_item_event(consent_link="https:///path", item_id="item-no-netloc")
with caplog.at_level(logging.WARNING):
update = try_parse_oauth_consent_event(event, "test-model")
assert update is not None
assert len(update.contents) == 0
assert "non-HTTPS" in caplog.text
# endregion
@@ -0,0 +1,664 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
from typing import Annotated, Any
from unittest.mock import MagicMock
import pytest
from agent_framework import Agent, MCPStdioTool, tool
from agent_framework._feature_stage import ExperimentalFeature
from azure.ai.projects.models import (
CodeInterpreterTool,
PromptAgentDefinition,
PromptAgentDefinitionTextOptions,
RaiConfig,
Reasoning,
StructuredInputDefinition,
ToolChoiceAllowed,
ToolChoiceFunction,
WebSearchTool,
)
from azure.ai.projects.models import (
FunctionTool as ProjectsFunctionTool,
)
from azure.ai.projects.models import (
MCPTool as FoundryMCPTool,
)
from azure.ai.projects.models import (
Tool as ProjectsTool,
)
from pydantic import BaseModel
from agent_framework_foundry import (
FoundryChatClient,
RawFoundryChatClient,
to_prompt_agent,
)
@tool
def get_weather(location: Annotated[str, "City name"]) -> str:
"""Get the weather for a location."""
return f"sunny in {location}"
def _make_foundry_chat_client(model: str | None = "gpt-4o-mini") -> FoundryChatClient:
"""Build a FoundryChatClient backed by a mocked project client."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
return FoundryChatClient(project_client=mock_project, model=model or "placeholder")
def _make_agent(client: Any, **agent_kwargs: Any) -> Agent:
"""Build an Agent without entering the async context manager."""
return Agent(client=client, **agent_kwargs)
# ---------------------------------------------------------------------------
# Core conversion: model resolution and client-type guarding
# ---------------------------------------------------------------------------
def test_to_prompt_agent_minimal() -> None:
"""An agent with only model + instructions produces a valid PromptAgentDefinition."""
agent = _make_agent(_make_foundry_chat_client(), instructions="Be helpful.")
definition = to_prompt_agent(agent)
assert isinstance(definition, PromptAgentDefinition)
assert definition.model == "gpt-4o-mini"
assert definition.instructions == "Be helpful."
assert definition.tools is None
def test_to_prompt_agent_serializes_cleanly() -> None:
"""The PromptAgentDefinition serializes to a dict that includes ``kind: prompt``."""
agent = _make_agent(_make_foundry_chat_client(), instructions="Hi.")
payload = to_prompt_agent(agent).as_dict()
assert payload["model"] == "gpt-4o-mini"
assert payload["instructions"] == "Hi."
assert payload["kind"] == "prompt"
def test_to_prompt_agent_rejects_non_foundry_client() -> None:
"""A non-FoundryChatClient client raises TypeError."""
class NotFoundryChatClient:
"""Stand-in for a different chat client implementation."""
agent = _make_agent(NotFoundryChatClient())
with pytest.raises(TypeError, match="FoundryChatClient"):
to_prompt_agent(agent)
def test_to_prompt_agent_rejects_missing_model() -> None:
"""When neither default_options nor the client has a model, ValueError is raised."""
client = _make_foundry_chat_client()
client.model = ""
agent = _make_agent(client)
agent.default_options.pop("model", None)
with pytest.raises(ValueError, match="Agent has no model"):
to_prompt_agent(agent)
def test_to_prompt_agent_no_instructions() -> None:
"""A tool-only agent (no instructions) produces a definition with instructions=None."""
agent = _make_agent(
_make_foundry_chat_client(),
tools=[WebSearchTool()],
)
definition = to_prompt_agent(agent)
assert definition.model == "gpt-4o-mini"
assert definition.instructions is None
payload = definition.as_dict()
assert "instructions" not in payload
def test_to_prompt_agent_prefers_default_options_model() -> None:
"""default_options['model'] wins over the bound client's model."""
client = _make_foundry_chat_client(model="client-model")
agent = _make_agent(client, instructions="x", default_options={"model": "agent-override"})
definition = to_prompt_agent(agent)
assert definition.model == "agent-override"
def test_to_prompt_agent_falls_back_to_client_model() -> None:
"""When the agent has no model override, the bound client's model is used."""
agent = _make_agent(_make_foundry_chat_client(model="client-model"), instructions="x")
definition = to_prompt_agent(agent)
assert definition.model == "client-model"
def test_to_prompt_agent_works_with_raw_foundry_chat_client() -> None:
"""to_prompt_agent accepts subclasses too — RawFoundryChatClient works."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
raw_client = RawFoundryChatClient(project_client=mock_project, model="gpt-4o")
agent = _make_agent(raw_client, instructions="x")
definition = to_prompt_agent(agent)
assert definition.model == "gpt-4o"
def test_to_prompt_agent_is_marked_experimental() -> None:
"""to_prompt_agent carries the TO_PROMPT_AGENT experimental metadata."""
assert getattr(to_prompt_agent, "__feature_stage__", None) == "experimental"
assert getattr(to_prompt_agent, "__feature_id__", None) == ExperimentalFeature.TO_PROMPT_AGENT.value
def test_to_prompt_agent_does_not_mutate_default_options() -> None:
"""Conversion never mutates the translatable option values in ``agent.default_options``."""
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
default_options={
"temperature": 0.3,
"top_p": 0.5,
"reasoning": {"effort": "low"},
"response_format": {"type": "json_object"},
"verbosity": "low",
},
tools=[get_weather],
)
reasoning_before = dict(agent.default_options["reasoning"]) # type: ignore[index]
response_format_before = dict(agent.default_options["response_format"]) # type: ignore[index]
tool_choice_before = agent.default_options.get("tool_choice")
to_prompt_agent(agent)
assert dict(agent.default_options["reasoning"]) == reasoning_before # type: ignore[index]
assert dict(agent.default_options["response_format"]) == response_format_before # type: ignore[index]
assert agent.default_options.get("tool_choice") == tool_choice_before
assert "text" not in agent.default_options
# ---------------------------------------------------------------------------
# Tool conversion
# ---------------------------------------------------------------------------
def test_to_prompt_agent_passes_through_sdk_tool_instances() -> None:
"""Foundry SDK tool instances (e.g. WebSearchTool) are passed through unchanged."""
ws = WebSearchTool()
ci = CodeInterpreterTool({"container": {"type": "auto"}})
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[ws, ci])
definition = to_prompt_agent(agent)
assert definition.tools is not None
assert len(definition.tools) == 2
assert definition.tools[0] is ws
assert definition.tools[1] is ci
def test_to_prompt_agent_converts_function_tool() -> None:
"""An AF FunctionTool from @tool emerges as a Foundry FunctionTool declaration."""
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[get_weather])
definition = to_prompt_agent(agent)
assert definition.tools is not None
assert len(definition.tools) == 1
fn = definition.tools[0]
assert isinstance(fn, ProjectsFunctionTool)
assert fn.name == "get_weather"
assert fn.description == "Get the weather for a location."
assert fn.strict is True
parameters = fn.parameters
assert parameters["type"] == "object"
assert "location" in parameters["properties"]
assert parameters["required"] == ["location"]
def test_to_prompt_agent_preserves_mixed_tool_order() -> None:
"""A mix of hosted SDK tools and function tools is preserved in definition order."""
ws = WebSearchTool()
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
tools=[ws, get_weather],
)
definition = to_prompt_agent(agent)
assert definition.tools is not None
assert definition.tools[0] is ws
assert isinstance(definition.tools[1], ProjectsFunctionTool)
assert definition.tools[1].name == "get_weather"
def test_to_prompt_agent_passes_through_hosted_mcp_tool() -> None:
"""A hosted MCP tool from FoundryChatClient.get_mcp_tool() is passed through."""
hosted_mcp = FoundryChatClient.get_mcp_tool(
name="github",
url="https://mcp.example.com",
)
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[hosted_mcp])
definition = to_prompt_agent(agent)
assert definition.tools is not None
assert len(definition.tools) == 1
assert isinstance(definition.tools[0], FoundryMCPTool)
def test_to_prompt_agent_rejects_local_mcp_tool() -> None:
"""A local MCP tool in agent.mcp_tools raises a ValueError pointing at get_mcp_tool."""
local_mcp = MCPStdioTool(name="local_fs", command="echo")
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[local_mcp])
with pytest.raises(ValueError, match="get_mcp_tool"):
to_prompt_agent(agent)
def test_to_prompt_agent_rejects_unknown_tool_type() -> None:
"""An arbitrary object in tools that isn't a known shape raises ValueError."""
class NotATool:
pass
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
tools=[NotATool()],
)
with pytest.raises(ValueError, match="NotATool"):
to_prompt_agent(agent)
def test_to_prompt_agent_accepts_dict_tool() -> None:
"""A dict with a 'type' discriminator is rehydrated through the SDK Tool model."""
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
tools=[{"type": "web_search"}],
)
definition = to_prompt_agent(agent)
assert definition.tools is not None
assert len(definition.tools) == 1
tool_obj = definition.tools[0]
# The SDK discriminator on ``type`` should materialize the concrete subclass
# (here ``WebSearchTool``), not a generic ``Tool``.
assert isinstance(tool_obj, WebSearchTool)
assert isinstance(tool_obj, ProjectsTool)
assert tool_obj.type == "web_search"
def test_to_prompt_agent_accepts_dict_function_tool() -> None:
"""A dict with ``type='function'`` rehydrates to a Foundry ``FunctionTool``."""
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
tools=[
{
"type": "function",
"name": "lookup",
"description": "Look up a value.",
"parameters": {"type": "object", "properties": {}},
}
],
)
definition = to_prompt_agent(agent)
assert definition.tools is not None
assert len(definition.tools) == 1
tool_obj = definition.tools[0]
assert isinstance(tool_obj, ProjectsFunctionTool)
assert tool_obj.name == "lookup"
assert tool_obj.description == "Look up a value."
def test_to_prompt_agent_rejects_dict_tool_without_type() -> None:
"""A dict missing the 'type' field raises ValueError."""
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
tools=[{"name": "missing_type"}],
)
with pytest.raises(ValueError, match="type"):
to_prompt_agent(agent)
# ---------------------------------------------------------------------------
# Generation parameters sourced from default_options
# (translated by _prepare_prompt_agent_options in _to_prompt_agent)
# ---------------------------------------------------------------------------
def test_to_prompt_agent_temperature_top_p_unset_by_default() -> None:
"""Without default_options entries, temperature/top_p are unset on the definition."""
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
definition = to_prompt_agent(agent)
assert definition.temperature is None
assert definition.top_p is None
payload = definition.as_dict()
assert "temperature" not in payload
assert "top_p" not in payload
def test_to_prompt_agent_lifts_temperature_top_p_from_default_options() -> None:
"""temperature/top_p in default_options flow through to the definition."""
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
default_options={"temperature": 0.42, "top_p": 0.8},
)
definition = to_prompt_agent(agent)
assert definition.temperature == 0.42
assert definition.top_p == 0.8
def test_to_prompt_agent_temperature_zero_is_honored() -> None:
"""A literal ``0.0`` in default_options is treated as explicit, not as unset."""
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
default_options={"temperature": 0.0, "top_p": 0.0},
)
definition = to_prompt_agent(agent)
assert definition.temperature == 0.0
assert definition.top_p == 0.0
def test_to_prompt_agent_tool_choice_omitted_when_no_tools() -> None:
"""``tool_choice`` is dropped when the definition has no tools.
Mirrors RawOpenAIChatClient._prepare_options behavior. This also keeps
Agent.__init__'s default ``tool_choice="auto"`` from polluting tool-less
prompt agents.
"""
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
definition = to_prompt_agent(agent)
assert definition.tool_choice is None
assert "tool_choice" not in definition.as_dict()
def test_to_prompt_agent_tool_choice_auto_with_tools() -> None:
"""When tools are present, the default ``tool_choice="auto"`` flows through."""
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[get_weather])
definition = to_prompt_agent(agent)
assert definition.tool_choice == "auto"
def test_to_prompt_agent_tool_choice_required_string_with_tools() -> None:
"""A string ``tool_choice="required"`` flows through when tools are present."""
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
tools=[get_weather],
default_options={"tool_choice": "required"},
)
definition = to_prompt_agent(agent)
assert definition.tool_choice == "required"
def test_to_prompt_agent_tool_choice_required_function_dict() -> None:
"""tool_choice mode=required with a function name → ToolChoiceFunction."""
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
tools=[get_weather],
default_options={
"tool_choice": {"mode": "required", "required_function_name": "get_weather"},
},
)
definition = to_prompt_agent(agent)
assert isinstance(definition.tool_choice, ToolChoiceFunction)
assert definition.tool_choice.name == "get_weather"
def test_to_prompt_agent_tool_choice_auto_allowed_tools() -> None:
"""tool_choice mode=auto with allowed_tools → ToolChoiceAllowed."""
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
tools=[get_weather],
default_options={
"tool_choice": {"mode": "auto", "allowed_tools": ["get_weather"]},
},
)
definition = to_prompt_agent(agent)
assert isinstance(definition.tool_choice, ToolChoiceAllowed)
assert definition.tool_choice.mode == "auto"
assert definition.tool_choice.tools == [{"type": "function", "name": "get_weather"}]
def test_to_prompt_agent_lifts_reasoning_dict_from_default_options() -> None:
"""A reasoning dict in default_options becomes a Foundry ``Reasoning`` model."""
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
default_options={"reasoning": {"effort": "high", "summary": "concise"}},
)
definition = to_prompt_agent(agent)
assert isinstance(definition.reasoning, Reasoning)
assert definition.reasoning.effort == "high"
assert definition.reasoning.summary == "concise"
def test_to_prompt_agent_lifts_reasoning_model_from_default_options() -> None:
"""A pre-built ``Reasoning`` model in default_options is passed through."""
reasoning = Reasoning(effort="medium")
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
default_options={"reasoning": reasoning},
)
definition = to_prompt_agent(agent)
assert definition.reasoning is reasoning
def test_to_prompt_agent_lifts_response_format_dict_to_text() -> None:
"""A ``response_format`` dict in default_options becomes ``text.format``."""
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
default_options={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "weather",
"schema": {"type": "object", "properties": {"temp": {"type": "number"}}},
},
},
},
)
definition = to_prompt_agent(agent)
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
format_dict = definition.text["format"]
assert format_dict is not None
assert format_dict["type"] == "json_schema"
assert format_dict["name"] == "weather"
assert format_dict["schema"] == {"type": "object", "properties": {"temp": {"type": "number"}}}
def test_to_prompt_agent_lifts_response_format_pydantic_to_text() -> None:
"""A Pydantic ``BaseModel`` response_format becomes ``text.format`` json_schema."""
class WeatherReply(BaseModel):
location: str
condition: str
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
default_options={"response_format": WeatherReply},
)
definition = to_prompt_agent(agent)
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
format_dict = definition.text["format"]
assert format_dict is not None
assert format_dict["type"] == "json_schema"
assert format_dict["name"] == "WeatherReply"
assert "schema" in format_dict
assert "location" in format_dict["schema"]["properties"]
def test_to_prompt_agent_merges_verbosity_into_text() -> None:
"""A ``verbosity`` entry merges into the ``text`` config."""
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
default_options={"verbosity": "low"},
)
definition = to_prompt_agent(agent)
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
# PromptAgentDefinitionTextOptions only declares ``format``, but its
# mapping-init preserves extra keys for server-side use.
assert dict(definition.text).get("verbosity") == "low"
def test_to_prompt_agent_raises_on_conflicting_response_format_and_text_format() -> None:
"""Pydantic ``response_format`` + a different ``text.format`` mapping must fail loudly."""
class WeatherReply(BaseModel):
location: str
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
default_options={
"response_format": WeatherReply,
"text": {"format": {"type": "json_object"}},
},
)
with pytest.raises(ValueError, match="Conflicting response_format"):
to_prompt_agent(agent)
def test_to_prompt_agent_passes_through_text_dict_from_default_options() -> None:
"""A ``text`` dict in default_options flows through to the definition."""
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
default_options={"text": {"format": {"type": "text"}, "verbosity": "high"}},
)
definition = to_prompt_agent(agent)
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
assert definition.text["format"] == {"type": "text"}
assert dict(definition.text).get("verbosity") == "high"
# ---------------------------------------------------------------------------
# Foundry-specific kwargs (no AF ChatOptions equivalent)
# ---------------------------------------------------------------------------
def test_to_prompt_agent_kwarg_only_fields_unset_by_default() -> None:
"""structured_inputs and rai_config are absent from the payload when unset."""
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
payload = to_prompt_agent(agent).as_dict()
assert "structured_inputs" not in payload
assert "rai_config" not in payload
def test_to_prompt_agent_forwards_structured_inputs_kwarg() -> None:
"""A ``structured_inputs`` mapping is forwarded (and copied to a new dict)."""
inputs = {"city": StructuredInputDefinition(description="Target city.")}
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
definition = to_prompt_agent(agent, structured_inputs=inputs)
assert definition.structured_inputs is not None
assert set(definition.structured_inputs) == {"city"}
assert definition.structured_inputs["city"] is inputs["city"]
inputs["other"] = StructuredInputDefinition(description="x")
assert "other" not in definition.structured_inputs
def test_to_prompt_agent_forwards_rai_config_kwarg() -> None:
"""A ``RaiConfig`` kwarg is forwarded to the definition."""
rai_config = RaiConfig(rai_policy_name="test-policy")
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
definition = to_prompt_agent(agent, rai_config=rai_config)
assert definition.rai_config is rai_config
# ---------------------------------------------------------------------------
# Combined integration
# ---------------------------------------------------------------------------
def test_to_prompt_agent_combines_all_sources() -> None:
"""Generation params from default_options + Foundry-only kwargs combine cleanly."""
rai_config = RaiConfig(rai_policy_name="test-policy")
structured = {"q": StructuredInputDefinition(description="query")}
agent = _make_agent(
_make_foundry_chat_client(),
instructions="x",
default_options={
"temperature": 0.3,
"top_p": 0.95,
"tool_choice": "auto",
"reasoning": {"effort": "medium"},
"verbosity": "low",
},
tools=[get_weather],
)
definition = to_prompt_agent(
agent,
structured_inputs=structured,
rai_config=rai_config,
)
assert definition.temperature == 0.3
assert definition.top_p == 0.95
assert definition.tool_choice == "auto"
assert isinstance(definition.reasoning, Reasoning)
assert definition.reasoning.effort == "medium"
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
assert dict(definition.text).get("verbosity") == "low"
assert definition.rai_config is rai_config
assert definition.structured_inputs is not None and "q" in definition.structured_inputs
assert definition.tools is not None and len(definition.tools) == 1
File diff suppressed because it is too large Load Diff