Python: Add support for Foundry Toolboxes (#5346)

* Add support for the Foundry Toolbox in MAF

Introduces a Foundry Toolbox integration: FoundryChatClient gains a
get_toolbox() helper plus select_toolbox_tools(), normalize_tools in
the core package flattens tool-collection wrappers (ToolboxVersionObject
and generic iterables, while leaving Pydantic BaseModel instances
alone), and the new agent_framework.foundry namespace re-exports the
toolbox helpers. Ships with unit tests, a sample, and a design doc.

azure-ai-projects is pinned to the public >=2.0.0,<3.0 range and the
lockfile resolves from public PyPI. The toolbox test module skips when
Toolbox* types are unavailable so CI stays green until the public 2.1.0
SDK lands. OMC tooling directories (.omc/, .omx/) are gitignored.

* Update to latest azure ai projects package

* Improve sample

* Rename ADR to 0025

* Update ADR

* Apply suggestion from @alliscode

Co-authored-by: Ben Thomas <ben.thomas@microsoft.com>

* Improve samples

* Update test

---------

Co-authored-by: Ben Thomas <ben.thomas@microsoft.com>
This commit is contained in:
Evan Mattson
2026-04-21 08:56:01 +09:00
committed by GitHub
Unverified
parent 3e54a689fc
commit 04aaf0c1fe
21 changed files with 1980 additions and 6 deletions
@@ -49,6 +49,7 @@ class ExperimentalFeature(str, Enum):
EVALS = "EVALS"
FILE_HISTORY = "FILE_HISTORY"
SKILLS = "SKILLS"
TOOLBOXES = "TOOLBOXES"
class ReleaseCandidateFeature(str, Enum):
@@ -12,6 +12,7 @@ from collections.abc import (
AsyncIterable,
Awaitable,
Callable,
Iterable,
Mapping,
Sequence,
)
@@ -859,6 +860,15 @@ def normalize_tools(
Returns:
A normalized list where callable inputs are converted to ``FunctionTool``
using :func:`tool`, and existing tool objects are passed through unchanged.
Tool-collection wrappers are flattened in two forms:
- non-tool, non-callable iterables
- mapping-like objects that expose a ``.tools`` collection (for example
``ToolboxVersionObject`` from azure-ai-projects)
This lets callers write ``tools=[toolbox, my_func]`` and have the
toolbox's contents spread in alongside individual tools.
"""
if not tools:
return []
@@ -883,6 +893,24 @@ def normalize_tools(
if callable(tool_item): # type: ignore[reportUnknownArgumentType]
normalized.append(tool(tool_item))
continue
# Mapping-like tool collections (for example ToolboxVersionObject) are
# not flattened by the generic Iterable branch below because they are
# also Mapping instances. If they expose a ``tools`` collection, spread
# that collection into the normalized list.
collection_tools = getattr(tool_item, "tools", None) # type: ignore[reportUnknownArgumentType]
if isinstance(collection_tools, Iterable) and not isinstance(
collection_tools, (str, bytes, bytearray, Mapping)
):
normalized.extend(normalize_tools(list(collection_tools))) # type: ignore[reportUnknownArgumentType]
continue
# Tool-collection wrapper (e.g. FoundryToolbox): a non-tool, non-callable
# iterable. Flatten its contents so ``tools=[toolbox, my_func]`` works.
# Strings, mappings, and Pydantic BaseModel are excluded — BaseModel
# instances iterate over (field, value) tuples, not tools, so they
# should pass through as leaf tool specs (handled below).
if isinstance(tool_item, Iterable) and not isinstance(tool_item, (str, bytes, bytearray, Mapping, BaseModel)):
normalized.extend(normalize_tools(list(tool_item))) # type: ignore[reportUnknownArgumentType]
continue
normalized.append(tool_item) # type: ignore[reportUnknownArgumentType]
return normalized
@@ -20,6 +20,7 @@ _IMPORTS: dict[str, tuple[str, str]] = {
"FoundryEmbeddingOptions": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryEmbeddingSettings": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryEvals": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryHostedToolType": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryMemoryProvider": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryLocalChatOptions": ("agent_framework_foundry_local", "agent-framework-foundry-local"),
"FoundryLocalClient": ("agent_framework_foundry_local", "agent-framework-foundry-local"),
@@ -31,6 +32,9 @@ _IMPORTS: dict[str, tuple[str, str]] = {
"RawFoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"),
"evaluate_foundry_target": ("agent_framework_foundry", "agent-framework-foundry"),
"evaluate_traces": ("agent_framework_foundry", "agent-framework-foundry"),
"get_toolbox_tool_name": ("agent_framework_foundry", "agent-framework-foundry"),
"get_toolbox_tool_type": ("agent_framework_foundry", "agent-framework-foundry"),
"select_toolbox_tools": ("agent_framework_foundry", "agent-framework-foundry"),
}
@@ -12,6 +12,7 @@ from agent_framework_foundry import (
FoundryEmbeddingOptions,
FoundryEmbeddingSettings,
FoundryEvals,
FoundryHostedToolType,
FoundryMemoryProvider,
RawFoundryAgent,
RawFoundryAgentChatClient,
@@ -19,6 +20,9 @@ from agent_framework_foundry import (
RawFoundryEmbeddingClient,
evaluate_foundry_target,
evaluate_traces,
get_toolbox_tool_name,
get_toolbox_tool_type,
select_toolbox_tools,
)
from agent_framework_foundry_local import (
FoundryLocalChatOptions,
@@ -35,6 +39,7 @@ __all__ = [
"FoundryEmbeddingOptions",
"FoundryEmbeddingSettings",
"FoundryEvals",
"FoundryHostedToolType",
"FoundryLocalChatOptions",
"FoundryLocalClient",
"FoundryLocalSettings",
@@ -46,4 +51,7 @@ __all__ = [
"RawFoundryEmbeddingClient",
"evaluate_foundry_target",
"evaluate_traces",
"get_toolbox_tool_name",
"get_toolbox_tool_type",
"select_toolbox_tools",
]
@@ -1144,3 +1144,160 @@ def test_parse_annotation_with_annotated_and_literal():
# endregion
# region normalize_tools flattening of tool-collection wrappers
def _make_flatten_function_tool(name: str) -> FunctionTool:
"""Build a FunctionTool for flattening tests."""
@tool(name=name, description=f"{name} tool")
def _impl(x: int) -> int:
return x
return _impl # type: ignore[return-value]
def test_normalize_tools_flattens_tool_collection_wrapper() -> None:
"""A non-tool, non-callable iterable inside the tools list is flattened."""
from agent_framework._tools import normalize_tools
inner_a = _make_flatten_function_tool("inner_a")
inner_b = _make_flatten_function_tool("inner_b")
class ToolBundle:
"""Minimal stand-in for a tool-collection wrapper like FoundryToolbox."""
def __init__(self, tools: list[FunctionTool]) -> None:
self._tools = tools
def __iter__(self):
return iter(self._tools)
bundle = ToolBundle([inner_a, inner_b])
normalized = normalize_tools([bundle])
assert len(normalized) == 2
assert normalized[0] is inner_a
assert normalized[1] is inner_b
def test_normalize_tools_combines_bundle_with_individual_tools() -> None:
"""The canonical ``tools=[bundle, my_func]`` call site spreads bundle + individual."""
from agent_framework._tools import normalize_tools
bundled = _make_flatten_function_tool("bundled")
standalone = _make_flatten_function_tool("standalone")
class ToolBundle:
def __init__(self, tools: list[FunctionTool]) -> None:
self._tools = tools
def __iter__(self):
return iter(self._tools)
normalized = normalize_tools([ToolBundle([bundled]), standalone])
assert len(normalized) == 2
assert normalized[0] is bundled
assert normalized[1] is standalone
def test_normalize_tools_flattens_nested_bundles() -> None:
"""Bundles inside bundles are flattened recursively via the recursive call."""
from agent_framework._tools import normalize_tools
inner = _make_flatten_function_tool("deep")
class ToolBundle:
def __init__(self, tools: list[Any]) -> None:
self._tools = tools
def __iter__(self):
return iter(self._tools)
nested = ToolBundle([ToolBundle([inner])])
normalized = normalize_tools([nested])
assert len(normalized) == 1
assert normalized[0] is inner
def test_normalize_tools_bundle_only_form() -> None:
"""Passing a bundle directly (no outer list) also flattens its contents.
``tools=bundle`` — the outer wrap-in-list happens in the non-Sequence
branch, then the flattening logic kicks in on the inner pass.
"""
from agent_framework._tools import normalize_tools
a = _make_flatten_function_tool("a")
b = _make_flatten_function_tool("b")
class ToolBundle:
def __init__(self, tools: list[FunctionTool]) -> None:
self._tools = tools
def __iter__(self):
return iter(self._tools)
normalized = normalize_tools(ToolBundle([a, b])) # type: ignore[arg-type]
assert len(normalized) == 2
assert normalized[0] is a
assert normalized[1] is b
def test_normalize_tools_does_not_flatten_known_tool_types() -> None:
"""FunctionTool / dict / callable are detected before the flatten branch."""
from agent_framework._tools import normalize_tools
func_tool = _make_flatten_function_tool("ft")
dict_tool: dict[str, Any] = {"type": "code_interpreter", "container": {"type": "auto"}}
def plain_callable(x: int) -> int:
return x
normalized = normalize_tools([func_tool, dict_tool, plain_callable])
assert len(normalized) == 3
assert normalized[0] is func_tool
assert normalized[1] is dict_tool
# plain_callable was wrapped in a FunctionTool via the @tool helper
assert isinstance(normalized[2], FunctionTool)
def test_normalize_tools_flattens_mapping_like_toolbox_with_tools_attr() -> None:
"""Mapping-like toolbox objects with ``.tools`` should still flatten."""
from collections.abc import Mapping as MappingABC
from agent_framework._tools import normalize_tools
bundled = _make_flatten_function_tool("bundled")
standalone = _make_flatten_function_tool("standalone")
class ToolBundleMapping(MappingABC[str, Any]):
def __init__(self, tools: list[FunctionTool]) -> None:
self.tools = tools
self._data = {"name": "research_tools", "version": "v1", "tools": tools}
def __getitem__(self, key: str) -> Any:
return self._data[key]
def __iter__(self):
return iter(self._data)
def __len__(self) -> int:
return len(self._data)
normalized = normalize_tools([ToolBundleMapping([bundled]), standalone])
assert len(normalized) == 2
assert normalized[0] is bundled
assert normalized[1] is standalone
# endregion
+63
View File
@@ -1,3 +1,66 @@
# 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`
There are two patterns for wiring a toolbox into a `FoundryChatClient`-backed agent.
**1. Fetch, optionally filter, and pass the tools directly**
Load the toolbox from the Microsoft Foundry project, optionally select a subset of its tools, and hand them to an `Agent` alongside any other tools you own:
```python
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient, select_toolbox_tools
client = FoundryChatClient(...)
toolbox = await client.get_toolbox("my-toolbox", version="3")
# Pass the whole toolbox:
agent = Agent(client=client, tools=toolbox)
# Or filter to a subset first:
selected = select_toolbox_tools(toolbox, include_types=["code_interpreter", "mcp"])
agent = Agent(client=client, tools=selected)
```
See [`foundry_chat_client_with_toolbox.py`](../../samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py) for a full example, including combining multiple toolboxes.
**2. Connect to the toolbox's MCP endpoint with `MCPStreamableHTTPTool`**
Each toolbox is reachable as an MCP server. Instead of fetching and fanning out its individual tool definitions, you can point a MAF `MCPStreamableHTTPTool` at the toolbox's MCP endpoint — 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)
```
@@ -16,6 +16,7 @@ from ._foundry_evals import (
evaluate_traces,
)
from ._memory_provider import FoundryMemoryProvider
from ._tools import FoundryHostedToolType, get_toolbox_tool_name, get_toolbox_tool_type, select_toolbox_tools
try:
__version__ = importlib.metadata.version(__name__)
@@ -30,6 +31,7 @@ __all__ = [
"FoundryEmbeddingOptions",
"FoundryEmbeddingSettings",
"FoundryEvals",
"FoundryHostedToolType",
"FoundryMemoryProvider",
"RawFoundryAgent",
"RawFoundryAgentChatClient",
@@ -38,4 +40,7 @@ __all__ = [
"__version__",
"evaluate_foundry_target",
"evaluate_traces",
"get_toolbox_tool_name",
"get_toolbox_tool_type",
"select_toolbox_tools",
]
@@ -34,6 +34,8 @@ from azure.ai.projects.aio import AIProjectClient
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from ._tools import sanitize_foundry_response_tool
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
else:
@@ -307,6 +309,20 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
"""Skip model check — model is configured on the Foundry agent."""
pass
@override
def _prepare_tools_for_openai(
self,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
) -> list[Any]:
"""Prepare tools for Foundry agent Responses API calls.
Mirrors ``RawFoundryChatClient`` sanitization so toolbox-fetched MCP
tools with extra read-model fields continue to work through the agent
surface.
"""
response_tools = super()._prepare_tools_for_openai(tools)
return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
def _prepare_messages_for_azure_ai(self, messages: Sequence[Message]) -> tuple[list[Message], str | None]:
"""Extract system/developer messages as instructions for Azure AI.
@@ -16,6 +16,7 @@ from agent_framework import (
load_settings,
)
from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
from agent_framework._feature_stage import ExperimentalFeature, experimental
from agent_framework.observability import ChatTelemetryLayer
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
from azure.ai.projects.aio import AIProjectClient
@@ -32,6 +33,8 @@ from azure.ai.projects.models import MCPTool as FoundryMCPTool
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from ._tools import fetch_toolbox, sanitize_foundry_response_tool
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
else:
@@ -46,7 +49,8 @@ else:
from typing_extensions import TypedDict # type: ignore # pragma: no cover
if TYPE_CHECKING:
from agent_framework import ChatAndFunctionMiddlewareTypes
from agent_framework import ChatAndFunctionMiddlewareTypes, ToolTypes
from azure.ai.projects.models import ToolboxVersionObject
logger: logging.Logger = logging.getLogger("agent_framework.foundry")
@@ -218,6 +222,21 @@ class RawFoundryChatClient( # type: ignore[misc]
raise ValueError("model must be a non-empty string")
options["model"] = self.model
@override
def _prepare_tools_for_openai(
self,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
) -> list[Any]:
"""Prepare tools for Foundry Responses API calls.
Foundry toolbox reads can surface MCP tool objects with extra fields
(for example ``name``) that are accepted by the toolbox API but rejected
by the Responses API. Sanitize those hosted-tool payloads before sending
them downstream.
"""
response_tools = super()._prepare_tools_for_openai(tools)
return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
async def configure_azure_monitor(
self,
enable_sensitive_data: bool = False,
@@ -460,6 +479,37 @@ class RawFoundryChatClient( # type: ignore[misc]
# endregion
# region Toolbox methods (instance methods — these hit the network)
@experimental(feature_id=ExperimentalFeature.TOOLBOXES)
async def get_toolbox(
self,
name: str,
*,
version: str | None = None,
) -> ToolboxVersionObject:
"""Fetch a Foundry toolbox by name.
If ``version`` is omitted, resolves the toolbox's current default version
(two requests). If ``version`` is specified, fetches that version directly
(single request).
Args:
name: The name of the toolbox.
Keyword Args:
version: Optional immutable version identifier to pin to.
Returns:
A ``ToolboxVersionObject``. Pass its ``tools`` attribute to
``Agent(tools=toolbox.tools)``.
Raises:
azure.core.exceptions.ResourceNotFoundError: If the toolbox or
the requested version does not exist.
"""
return await fetch_toolbox(self.project_client, name, version)
class FoundryChatClient( # type: ignore[misc]
FunctionInvocationLayer[FoundryChatOptionsT],
@@ -0,0 +1,166 @@
# Copyright (c) Microsoft. All rights reserved.
"""Shared tool helpers for Foundry chat clients.
Includes:
* *Toolbox* helpers — a *toolbox* is a named, versioned bundle of tool
definitions stored in an Azure AI Foundry project.
* Responses-API payload sanitization for Foundry hosted tools.
"""
from __future__ import annotations
from collections.abc import Callable, Collection, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast
from agent_framework._feature_stage import ExperimentalFeature, experimental
from azure.ai.projects.models import MCPTool as FoundryMCPTool
if TYPE_CHECKING:
from azure.ai.projects.aio import AIProjectClient
from azure.ai.projects.models import Tool, ToolboxVersionObject
FoundryHostedToolType: TypeAlias = (
Literal[
"code_interpreter",
"file_search",
"image_generation",
"mcp",
"web_search",
]
| str
)
ToolboxToolSelectionInput: TypeAlias = "ToolboxVersionObject | Sequence[Tool | dict[str, Any]]"
@experimental(feature_id=ExperimentalFeature.TOOLBOXES)
async def fetch_toolbox(
project_client: AIProjectClient,
name: str,
version: str | None = None,
) -> ToolboxVersionObject:
"""Fetch a toolbox version via an ``AIProjectClient``.
If ``version`` is omitted, resolves the toolbox's current default
version (two requests: one to ``.get(name)`` for the default version
pointer, one to ``.get_version(name, version)`` for the tools). If
``version`` is specified, fetches that version directly (single request).
"""
if version is None:
handle = await project_client.beta.toolboxes.get(name)
version = handle.default_version
return await project_client.beta.toolboxes.get_version(name, version)
@experimental(feature_id=ExperimentalFeature.TOOLBOXES)
def get_toolbox_tool_name(tool: Tool | dict[str, Any]) -> str | None:
"""Return the best-effort display/selection name for a toolbox tool.
Selection precedence:
1. MCP ``server_label``
2. Generic tool ``name``
3. Tool ``type``
"""
if isinstance(tool, dict):
if server_label := tool.get("server_label"):
return str(server_label)
if name := tool.get("name"):
return str(name)
if tool_type := tool.get("type"):
return str(tool_type)
return None
if server_label := getattr(tool, "server_label", None):
return str(server_label)
if name := getattr(tool, "name", None):
return str(name)
if tool_type := getattr(tool, "type", None):
return str(tool_type)
return None
@experimental(feature_id=ExperimentalFeature.TOOLBOXES)
def get_toolbox_tool_type(tool: Tool | dict[str, Any]) -> str | None:
"""Return the raw tool ``type`` if present."""
tool_type = tool.get("type") if isinstance(tool, dict) else getattr(tool, "type", None)
return str(tool_type) if tool_type is not None else None
@experimental(feature_id=ExperimentalFeature.TOOLBOXES)
def select_toolbox_tools(
tools: ToolboxToolSelectionInput,
*,
include_names: Collection[str] | None = None,
exclude_names: Collection[str] | None = None,
include_types: Collection[FoundryHostedToolType] | None = None,
exclude_types: Collection[FoundryHostedToolType] | None = None,
predicate: Callable[[Tool | dict[str, Any]], bool] | None = None,
) -> list[Tool | dict[str, Any]]:
"""Filter toolbox tools by normalized name, raw type, and/or predicate.
Normalized name precedence:
1. ``server_label`` for MCP tools
2. ``name``
3. ``type``
"""
tool_items: Sequence[Tool | dict[str, Any]] = (
tools if isinstance(tools, Sequence) else cast("Sequence[Tool | dict[str, Any]]", tools.tools)
)
include_name_set = {str(item) for item in include_names} if include_names is not None else None
exclude_name_set = {str(item) for item in exclude_names} if exclude_names is not None else None
include_type_set = {str(item) for item in include_types} if include_types is not None else None
exclude_type_set = {str(item) for item in exclude_types} if exclude_types is not None else None
selected: list[Tool | dict[str, Any]] = []
for tool in tool_items:
tool_name = get_toolbox_tool_name(tool)
tool_type = get_toolbox_tool_type(tool)
if include_name_set is not None and tool_name not in include_name_set:
continue
if exclude_name_set is not None and tool_name in exclude_name_set:
continue
if include_type_set is not None and tool_type not in include_type_set:
continue
if exclude_type_set is not None and tool_type in exclude_type_set:
continue
if predicate is not None and not predicate(tool):
continue
selected.append(tool)
return selected
@experimental(feature_id=ExperimentalFeature.TOOLBOXES)
def sanitize_foundry_response_tool(tool_item: Any) -> Any:
"""Return a Responses-API-safe tool payload for Foundry hosted tools.
Azure AI Projects toolbox reads can currently return hosted tool objects with
extra read-model decoration fields such as top-level ``name`` and
``description``. Azure AI Foundry rejects at least ``name`` on Responses API
requests with:
``Unknown parameter: 'tools[0].name'``.
We defensively strip these decoration fields for non-function hosted tools so
the round-trip
``toolbox.tools -> Agent(..., tools=...) -> run()`` works, while the Azure
SDK/service behavior is corrected upstream.
"""
if isinstance(tool_item, FoundryMCPTool):
sanitized: dict[str, Any] = dict(cast("Mapping[str, Any]", tool_item))
sanitized.pop("name", None)
sanitized.pop("description", None)
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)
return sanitized
return cast(Any, tool_item)
+1 -1
View File
@@ -26,7 +26,7 @@ dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-openai>=1.0.1,<2",
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
"azure-ai-projects>=2.0.0,<3.0",
"azure-ai-projects>=2.1.0,<3.0",
]
[tool.uv]
@@ -15,6 +15,7 @@ from agent_framework import ChatResponse, Content, Message, SupportsChatGetRespo
from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT
from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException
from agent_framework_openai import OpenAIContentFilterException
from azure.ai.projects.models import MCPTool as FoundryMCPTool
from azure.core.exceptions import ResourceNotFoundError
from azure.identity import AzureCliCredential
from openai import BadRequestError
@@ -608,6 +609,82 @@ def test_get_mcp_tool_with_project_connection_id() -> None:
assert tool_config["server_label"] == "Docs_MCP"
def test_prepare_tools_for_openai_strips_extraneous_name_from_foundry_mcp_tool() -> None:
"""Toolbox-returned MCP tools may carry ``name``; Foundry Responses rejects it."""
project_client = MagicMock()
project_client.get_openai_client.return_value = _make_mock_openai_client()
client = FoundryChatClient(project_client=project_client, model="test-model")
tool = FoundryMCPTool(
server_label="githubmcp",
server_url="https://api.githubcopilot.com/mcp",
)
tool["project_connection_id"] = "githubmcp"
tool["name"] = "githubmcp"
response_tools = client._prepare_tools_for_openai([tool])
assert len(response_tools) == 1
prepared = response_tools[0]
assert prepared["type"] == "mcp"
assert prepared["server_label"] == "githubmcp"
assert prepared["project_connection_id"] == "githubmcp"
assert "name" not in prepared
def test_prepare_tools_for_openai_strips_read_model_fields_from_toolbox_code_interpreter() -> None:
"""Toolbox-returned code interpreter tools may carry read-model-only name/description."""
project_client = MagicMock()
project_client.get_openai_client.return_value = _make_mock_openai_client()
client = FoundryChatClient(project_client=project_client, model="test-model")
tool = {
"type": "code_interpreter",
"name": "code_interpreter_t6bbtm",
"description": "Toolbox read model description",
"container": {"file_ids": [], "type": "auto"},
}
response_tools = client._prepare_tools_for_openai([tool])
assert len(response_tools) == 1
prepared = response_tools[0]
assert prepared["type"] == "code_interpreter"
assert prepared["container"] == {"file_ids": [], "type": "auto"}
assert "name" not in prepared
assert "description" not in prepared
def test_prepare_tools_for_openai_strips_name_from_non_function_hosted_tool_dicts() -> None:
"""All non-function hosted tool payloads should drop top-level read-model names."""
project_client = MagicMock()
project_client.get_openai_client.return_value = _make_mock_openai_client()
client = FoundryChatClient(project_client=project_client, model="test-model")
response_tools = client._prepare_tools_for_openai([
{
"type": "file_search",
"name": "file_search_tool_123",
"description": "toolbox decoration",
"vector_store_ids": ["vs_123"],
},
{
"type": "web_search",
"name": "web_search_tool_456",
"description": "toolbox decoration",
},
])
assert len(response_tools) == 2
assert response_tools[0]["type"] == "file_search"
assert response_tools[0]["vector_store_ids"] == ["vs_123"]
assert "name" not in response_tools[0]
assert "description" not in response_tools[0]
assert response_tools[1]["type"] == "web_search"
assert "name" not in response_tools[1]
assert "description" not in response_tools[1]
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_integration_tests_disabled
@@ -0,0 +1,435 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for toolbox helpers on FoundryChatClient.
Return types are the raw azure-ai-projects SDK models (ToolboxVersionObject,
ToolboxObject) — no custom wrapper. Tests verify the chat-client get path and
tool-selection ergonomics.
"""
from __future__ import annotations
import datetime as dt
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
try:
from azure.ai.projects.models import (
AutoCodeInterpreterToolParam,
CodeInterpreterTool,
Tool,
ToolboxObject,
ToolboxVersionObject,
)
except ImportError:
pytest.skip(
"Toolbox types require azure-ai-projects>=2.1.0 (unreleased).",
allow_module_level=True,
)
from azure.core.exceptions import ResourceNotFoundError
from azure.identity import AzureCliCredential
# --------------------------------------------------------------------------- #
# Helpers #
# --------------------------------------------------------------------------- #
class _AsyncIter:
"""Minimal async-iterable for mocking ``AsyncItemPaged`` in tests."""
def __init__(self, items: list[Any]) -> None:
self._items = items
def __aiter__(self) -> _AsyncIter:
self._iter = iter(self._items)
return self
async def __anext__(self) -> Any:
try:
return next(self._iter)
except StopIteration:
raise StopAsyncIteration from None
def _make_code_interpreter() -> CodeInterpreterTool:
return CodeInterpreterTool(container=AutoCodeInterpreterToolParam())
def _make_version_object(
*,
name: str = "research_tools",
version: str = "v1",
tools: list[Tool] | None = None,
description: str | None = None,
) -> ToolboxVersionObject:
return ToolboxVersionObject(
id=f"tbv_{name}_{version}",
name=name,
version=version,
metadata={},
created_at=dt.datetime(2026, 4, 10, tzinfo=dt.timezone.utc),
tools=tools if tools is not None else [_make_code_interpreter()],
description=description,
)
def _make_mock_foundry_client(*, project_client: MagicMock) -> Any:
"""Build a FoundryChatClient wired to a mock project_client."""
from agent_framework_foundry import FoundryChatClient
project_client.get_openai_client = MagicMock(return_value=MagicMock())
return FoundryChatClient(project_client=project_client, model="test-model")
# --------------------------------------------------------------------------- #
# get_toolbox — explicit version path #
# --------------------------------------------------------------------------- #
async def test_get_toolbox_with_explicit_version_makes_single_request() -> None:
project_client = MagicMock()
version_obj = _make_version_object(name="research_tools", version="v3")
project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj)
project_client.beta.toolboxes.get = AsyncMock(
side_effect=AssertionError("get() must not be called when version is explicit")
)
client = _make_mock_foundry_client(project_client=project_client)
toolbox = await client.get_toolbox("research_tools", version="v3")
assert isinstance(toolbox, ToolboxVersionObject)
assert toolbox.name == "research_tools"
assert toolbox.version == "v3"
project_client.beta.toolboxes.get_version.assert_awaited_once_with("research_tools", "v3")
project_client.beta.toolboxes.get.assert_not_called()
# --------------------------------------------------------------------------- #
# get_toolbox — default-version path + error + passthrough + smoke #
# --------------------------------------------------------------------------- #
async def test_get_toolbox_default_version_resolves_then_fetches() -> None:
project_client = MagicMock()
handle = ToolboxObject(id="tb_1", name="research_tools", default_version="v5")
version_obj = _make_version_object(name="research_tools", version="v5")
project_client.beta.toolboxes.get = AsyncMock(return_value=handle)
project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj)
client = _make_mock_foundry_client(project_client=project_client)
toolbox = await client.get_toolbox("research_tools")
assert toolbox.version == "v5"
project_client.beta.toolboxes.get.assert_awaited_once_with("research_tools")
project_client.beta.toolboxes.get_version.assert_awaited_once_with("research_tools", "v5")
async def test_get_toolbox_propagates_resource_not_found() -> None:
project_client = MagicMock()
project_client.beta.toolboxes.get = AsyncMock(side_effect=ResourceNotFoundError("no such toolbox"))
client = _make_mock_foundry_client(project_client=project_client)
with pytest.raises(ResourceNotFoundError):
await client.get_toolbox("missing_toolbox")
async def test_get_toolbox_tool_passthrough_preserves_heterogeneous_types() -> None:
"""Ensure all Tool subclasses pass through unchanged — critical for MCP tools
with project_connection_id, which must reach the runtime untouched."""
from azure.ai.projects.models import MCPTool as FoundryMCPTool
mcp_tool = FoundryMCPTool(
server_label="github_oauth",
server_url="https://api.githubcopilot.com/mcp",
)
mcp_tool["project_connection_id"] = "conn_abc"
project_client = MagicMock()
version_obj = _make_version_object(
name="mixed",
version="v1",
tools=[_make_code_interpreter(), mcp_tool],
)
project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj)
client = _make_mock_foundry_client(project_client=project_client)
toolbox = await client.get_toolbox("mixed", version="v1")
assert len(toolbox.tools) == 2
assert isinstance(toolbox.tools[0], CodeInterpreterTool)
assert isinstance(toolbox.tools[1], FoundryMCPTool)
assert toolbox.tools[1]["project_connection_id"] == "conn_abc"
async def test_toolbox_tools_can_be_passed_to_agent() -> None:
"""Integration smoke: toolbox.tools can be passed directly to Agent(tools=...) ."""
from agent_framework import Agent
project_client = MagicMock()
version_obj = _make_version_object(name="research_tools", version="v1", tools=[_make_code_interpreter()])
project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj)
client = _make_mock_foundry_client(project_client=project_client)
toolbox = await client.get_toolbox("research_tools", version="v1")
agent = Agent(
client=client,
instructions="You are a test agent.",
tools=toolbox.tools,
)
agent_tools = agent.default_options["tools"]
assert len(agent_tools) == 1
assert agent_tools[0]["type"] == "code_interpreter"
async def test_multiple_toolbox_tool_lists_can_be_combined_in_agent() -> None:
"""Nested toolbox ``.tools`` lists flatten into one tool list on Agent construction."""
from agent_framework import Agent
project_client = MagicMock()
project_client.get_openai_client = MagicMock(return_value=MagicMock())
client = _make_mock_foundry_client(project_client=project_client)
toolbox_a = _make_version_object(name="research_tools", version="v1", tools=[_make_code_interpreter()])
toolbox_b = _make_version_object(name="some_other_tools", version="v3", tools=[_make_code_interpreter()])
agent = Agent(
client=client,
instructions="You are a test agent.",
tools=[toolbox_a.tools, toolbox_b.tools],
)
agent_tools = agent.default_options["tools"]
assert len(agent_tools) == 2
assert agent_tools[0]["type"] == "code_interpreter"
assert agent_tools[1]["type"] == "code_interpreter"
# --------------------------------------------------------------------------- #
# toolbox tool selection helpers #
# --------------------------------------------------------------------------- #
def test_get_toolbox_tool_name_prefers_server_label_then_name_then_type() -> None:
from azure.ai.projects.models import MCPTool as FoundryMCPTool
from agent_framework_foundry import get_toolbox_tool_name
mcp_tool = FoundryMCPTool(
server_label="githubmcp",
server_url="https://api.githubcopilot.com/mcp",
)
assert get_toolbox_tool_name(mcp_tool) == "githubmcp"
named_tool = {"type": "code_interpreter", "name": "ci_tool"}
assert get_toolbox_tool_name(named_tool) == "ci_tool"
unnamed_tool = {"type": "web_search"}
assert get_toolbox_tool_name(unnamed_tool) == "web_search"
def test_select_toolbox_tools_filters_by_names() -> None:
from azure.ai.projects.models import MCPTool as FoundryMCPTool
from agent_framework_foundry import select_toolbox_tools
tools: list[Tool | dict[str, Any]] = [
FoundryMCPTool(server_label="githubmcp", server_url="https://api.githubcopilot.com/mcp"),
{"type": "code_interpreter", "name": "python_runner"},
{"type": "web_search"},
]
selected = select_toolbox_tools(tools, include_names=["githubmcp", "python_runner"])
assert len(selected) == 2
assert selected[0] is tools[0]
assert selected[1] is tools[1]
def test_select_toolbox_tools_filters_by_typed_tool_types() -> None:
from agent_framework_foundry import select_toolbox_tools
tools: list[Tool | dict[str, Any]] = [
{"type": "mcp", "server_label": "githubmcp"},
{"type": "code_interpreter", "name": "python_runner"},
{"type": "web_search"},
]
selected = select_toolbox_tools(tools, include_types=["mcp", "code_interpreter"])
assert len(selected) == 2
assert selected[0]["type"] == "mcp"
assert selected[1]["type"] == "code_interpreter"
def test_select_toolbox_tools_accepts_toolbox_object_directly() -> None:
from agent_framework_foundry import select_toolbox_tools
toolbox = _make_version_object(
name="research_tools",
version="v1",
tools=[
{"type": "mcp", "server_label": "githubmcp"}, # type: ignore[list-item]
{"type": "code_interpreter", "name": "python_runner"}, # type: ignore[list-item]
{"type": "web_search"}, # type: ignore[list-item]
],
)
selected = select_toolbox_tools(toolbox, include_types=["mcp", "code_interpreter"])
assert len(selected) == 2
assert selected[0]["type"] == "mcp"
assert selected[1]["type"] == "code_interpreter"
async def test_fetched_toolbox_can_be_combined_with_function_tool() -> None:
from agent_framework import Agent, FunctionTool, tool
project_client = MagicMock()
version_obj = _make_version_object(name="research_tools", version="v1", tools=[_make_code_interpreter()])
project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj)
client = _make_mock_foundry_client(project_client=project_client)
toolbox = await client.get_toolbox("research_tools", version="v1")
@tool(name="local_lookup", description="A local helper tool")
def local_lookup(query: str) -> str:
return query
agent = Agent(
client=client,
instructions="You are a test agent.",
tools=[toolbox, local_lookup],
)
agent_tools = agent.default_options["tools"]
assert len(agent_tools) == 2
assert agent_tools[0]["type"] == "code_interpreter"
assert isinstance(agent_tools[1], FunctionTool)
assert agent_tools[1].name == "local_lookup"
def test_select_toolbox_tools_supports_excludes_and_predicate() -> None:
from agent_framework_foundry import select_toolbox_tools
tools: list[Tool | dict[str, Any]] = [
{"type": "mcp", "server_label": "githubmcp"},
{"type": "mcp", "server_label": "learnmcp"},
{"type": "web_search"},
]
selected = select_toolbox_tools(
tools,
exclude_names=["learnmcp"],
predicate=lambda tool: tool.get("type") == "mcp", # type: ignore[union-attr]
)
assert len(selected) == 1
assert selected[0]["server_label"] == "githubmcp"
async def test_selected_toolbox_subset_can_be_combined_with_function_tool() -> None:
from agent_framework import Agent, FunctionTool, tool
from agent_framework_foundry import select_toolbox_tools
project_client = MagicMock()
version_obj = _make_version_object(
name="research_tools",
version="v1",
tools=[
{"type": "mcp", "server_label": "githubmcp"}, # type: ignore[list-item]
{"type": "code_interpreter", "name": "python_runner"}, # type: ignore[list-item]
{"type": "web_search"}, # type: ignore[list-item]
],
)
project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj)
client = _make_mock_foundry_client(project_client=project_client)
toolbox = await client.get_toolbox("research_tools", version="v1")
selected_tools = select_toolbox_tools(toolbox, include_types=["mcp", "code_interpreter"])
@tool(name="local_lookup", description="A local helper tool")
def local_lookup(query: str) -> str:
return query
agent = Agent(
client=client,
instructions="You are a test agent.",
tools=[selected_tools, local_lookup],
)
agent_tools = agent.default_options["tools"]
assert len(agent_tools) == 3
assert agent_tools[0]["type"] == "mcp"
assert agent_tools[1]["type"] == "code_interpreter"
assert isinstance(agent_tools[2], FunctionTool)
assert agent_tools[2].name == "local_lookup"
# --------------------------------------------------------------------------- #
# Integration #
# --------------------------------------------------------------------------- #
skip_if_foundry_integration_tests_disabled = pytest.mark.skipif(
os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") in ("", "https://test-project.services.ai.azure.com/")
or os.getenv("FOUNDRY_MODEL", "") == "",
reason="No real FOUNDRY_PROJECT_ENDPOINT or FOUNDRY_MODEL provided; skipping integration tests.",
)
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_integration_tests_disabled
async def test_integration_get_toolbox_round_trip_against_real_project() -> None:
"""Create a toolbox via the raw SDK, fetch via FoundryChatClient, then delete.
Self-contained to avoid depending on toolboxes that may be cleaned up
externally. Exercises both the default-version resolution path
(``get`` + ``get_version``) and the explicit-version path.
"""
from uuid import uuid4
from agent_framework import Agent
from agent_framework_foundry import FoundryChatClient
client = FoundryChatClient(credential=AzureCliCredential())
project_client = client.project_client
toolbox_name = f"af-int-toolbox-{uuid4().hex[:12]}"
created = await project_client.beta.toolboxes.create_version(
name=toolbox_name,
tools=[CodeInterpreterTool()],
description=f"{toolbox_name} integration test",
)
assert isinstance(created, ToolboxVersionObject)
try:
toolbox_default = await client.get_toolbox(toolbox_name)
assert toolbox_default.name == toolbox_name
assert toolbox_default.tools, "Default-version fetch returned no tools"
toolbox_pinned = await client.get_toolbox(toolbox_name, version=created.version)
assert toolbox_pinned.version == created.version
assert toolbox_pinned.tools
agent = Agent(
client=client,
instructions="You are a test agent.",
tools=toolbox_pinned.tools,
)
assert len(agent.default_options["tools"]) == len(toolbox_pinned.tools)
finally:
await project_client.beta.toolboxes.delete(toolbox_name)