mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Remove bespoke Foundry toolbox helpers; standardize on MCP for toolbox consumption (#5671)
* Remove Foundry toolbox helpers; standardize on MCP for toolbox consumption - Remove RawFoundryChatClient.get_toolbox() and its fetch_toolbox import - Remove fetch_toolbox, select_toolbox_tools, get_toolbox_tool_name, get_toolbox_tool_type, FoundryHostedToolType, ToolboxToolSelectionInput from agent_framework_foundry._tools - Remove ExperimentalFeature.TOOLBOXES from _feature_stage.py (no consumers) - Drop toolbox re-exports from agent_framework_foundry/__init__.py and agent_framework.foundry namespace - Update _sanitize_foundry_response_tool docstring to remove toolbox framing; sanitization logic itself is unchanged - Update _agent.py docstring: 'toolbox-fetched MCP' → 'hosted MCP' - Delete tests/test_toolbox.py (all tests covered removed helpers) - Update test_foundry_chat_client.py: rename/redoc tests that mentioned toolbox but test sanitization that remains - Delete foundry_chat_client_with_toolbox.py (bespoke toolbox API sample) - Delete foundry_toolbox_context_provider.py (relied on select_toolbox_tools) - Rename foundry_chat_client_with_toolbox_mcp.py → foundry_chat_client_with_toolbox.py (canonical MCP pattern) - Rewrite 04_foundry_toolbox/main.py to use MCPStreamableHTTPTool - Update provider/README, context_providers/README, 04_foundry_toolbox/README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(samples): update 06_files sample to consume toolbox via MCP (#5670) Replace removed get_toolbox/select_toolbox_tools APIs with MCPStreamableHTTPTool, using allowed_tools=["code_interpreter"] to select only the code interpreter from the toolbox endpoint. Update .env.example and README to use FOUNDRY_TOOLBOX_ENDPOINT instead of TOOLBOX_NAME. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(foundry): remove non-existent toolbox helper APIs from README (#5670) Remove the 'fetch, optionally filter, and pass tools directly' pattern from the FoundryChatClient toolbox documentation, as select_toolbox_tools and get_toolbox were removed. Only the MCP endpoint pattern is documented. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(foundry): remove residual toolbox docstring references and reproduction report Remove REPRODUCTION_REPORT.md (workflow artifact that should not be committed), and update two remaining docstring references that still said 'toolbox reads' /'toolbox definition' after the toolbox helpers were removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Remove bespoke Foundry toolbox helpers; standardize on MCP for toolbox consumption Fixes #5670 * fix(#5670): resolve toolbox endpoint from TOOLBOX_NAME fallback; add namespace regression tests - Add _resolve_toolbox_endpoint() helper in 04_foundry_toolbox/main.py and 06_files/main.py that prefers FOUNDRY_TOOLBOX_ENDPOINT but falls back to deriving the MCP URL from FOUNDRY_PROJECT_ENDPOINT + TOOLBOX_NAME — fixing the startup KeyError when agents are deployed via azd provision (which injects TOOLBOX_NAME, not FOUNDRY_TOOLBOX_ENDPOINT). - Update 04_foundry_toolbox/.env.example to use FOUNDRY_TOOLBOX_ENDPOINT (consistent with 06_files). - Add TOOLBOX_NAME env var to 06_files/agent.yaml so deployed agents have it available for the fallback derivation. - Update both READMEs to document the two ways to supply the toolbox endpoint. - Add test_foundry_namespace_no_longer_exposes_toolbox_helpers() with negative assertions for FoundryHostedToolType, get_toolbox_tool_name, get_toolbox_tool_type, and select_toolbox_tools — guarding against accidental re-introduction of removed symbols. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(samples): fail fast on empty FOUNDRY_TOOLBOX_ENDPOINT; add unit tests Addresses review feedback for #5670: - In _resolve_toolbox_endpoint() (04_foundry_toolbox/main.py and 06_files/main.py) change the walrus-operator check from a truthy test to an explicit 'is not None' guard. An explicitly set empty string now raises ValueError immediately with a clear message instead of silently falling through to the fallback URL construction. - Add tests/samples/hosting/test_toolbox_endpoint.py covering both sample modules: (a) FOUNDRY_TOOLBOX_ENDPOINT set → returned as-is (b) FOUNDRY_TOOLBOX_ENDPOINT set to empty string → ValueError (c) fallback constructs URL from FOUNDRY_PROJECT_ENDPOINT + TOOLBOX_NAME, stripping trailing slashes (d) neither variable group set → KeyError Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback: remove extraneous test and docstring content - Remove test_foundry_namespace_no_longer_exposes_toolbox_helpers (no longer warranted) - Remove docstring from _agent.py _prepare_tools_for_openai (extraneous) - Trim _chat_client.py _prepare_tools_for_openai docstring to one-liner (toolbox references no longer relevant) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove remaining extraneous docstring from RawFoundryChatClient._prepare_tools_for_openai Address review comment on PR #5671: reviewer noted the description isn't warranted now that toolbox helpers have been removed. Matches the pattern in RawFoundryAgentChatClient which has no docstring. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
51ad460d5f
commit
e56e6dad4d
@@ -16,7 +16,6 @@ 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__)
|
||||
@@ -32,7 +31,6 @@ __all__ = [
|
||||
"FoundryEmbeddingOptions",
|
||||
"FoundryEmbeddingSettings",
|
||||
"FoundryEvals",
|
||||
"FoundryHostedToolType",
|
||||
"FoundryMemoryProvider",
|
||||
"RawFoundryAgent",
|
||||
"RawFoundryAgentChatClient",
|
||||
@@ -41,7 +39,4 @@ __all__ = [
|
||||
"__version__",
|
||||
"evaluate_foundry_target",
|
||||
"evaluate_traces",
|
||||
"get_toolbox_tool_name",
|
||||
"get_toolbox_tool_type",
|
||||
"select_toolbox_tools",
|
||||
]
|
||||
|
||||
@@ -418,12 +418,6 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
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]
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ from agent_framework import (
|
||||
load_settings,
|
||||
)
|
||||
from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
|
||||
from agent_framework._feature_stage import ExperimentalFeature, experimental
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
|
||||
@@ -36,7 +35,7 @@ from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event
|
||||
|
||||
from ._tools import _sanitize_foundry_response_tool, fetch_toolbox # pyright: ignore[reportPrivateUsage]
|
||||
from ._tools import _sanitize_foundry_response_tool # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # type: ignore # pragma: no cover
|
||||
@@ -53,7 +52,6 @@ else:
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import ChatAndFunctionMiddlewareTypes, ToolTypes
|
||||
from azure.ai.projects.models import ToolboxVersionObject
|
||||
|
||||
logger: logging.Logger = logging.getLogger("agent_framework.foundry")
|
||||
|
||||
@@ -234,13 +232,6 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
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]
|
||||
|
||||
@@ -510,37 +501,6 @@ 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],
|
||||
|
||||
@@ -2,179 +2,54 @@
|
||||
|
||||
"""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.
|
||||
Includes 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 collections.abc import Mapping
|
||||
from typing import Any, 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
|
||||
|
||||
|
||||
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 toolbox
|
||||
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'. "
|
||||
"If this came from a Foundry toolbox, update the toolbox definition "
|
||||
"to include at least one vector store ID."
|
||||
"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'. "
|
||||
"If this came from a Foundry toolbox, update the toolbox definition "
|
||||
"to include one of these."
|
||||
"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 toolbox reads and the Responses API:
|
||||
Reconciles known mismatches between hosted tool definitions and the Responses API:
|
||||
|
||||
1. Toolbox reads can return hosted tool objects decorated with 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 stored in a toolbox 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
|
||||
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 toolbox definition.
|
||||
|
||||
These are workarounds until the toolbox/Responses proxy normalizes payloads
|
||||
server-side.
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user