mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
3e54a689fc
commit
04aaf0c1fe
@@ -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)
|
||||
Reference in New Issue
Block a user