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
@@ -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)