mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: fix(foundry): reconcile toolbox hosted-tool payloads with Responses API (#5414)
* fix(foundry): reconcile toolbox hosted-tool payloads with Responses API * docs(foundry): update create_sample_toolbox docstring to reflect all tools created
This commit is contained in:
committed by
GitHub
Unverified
parent
ea3320d39f
commit
fffd0acb3e
@@ -455,8 +455,18 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
|
||||
Returns:
|
||||
An MCPTool configuration ready to pass to an Agent.
|
||||
|
||||
Raises:
|
||||
ValueError: If neither ``url`` nor ``project_connection_id`` is supplied
|
||||
— one is required by the Foundry Responses API.
|
||||
"""
|
||||
mcp = FoundryMCPTool(server_label=name.replace(" ", "_"), server_url=url or "", **kwargs)
|
||||
if not url and not project_connection_id:
|
||||
raise ValueError("MCP tool requires either 'url' or 'project_connection_id' to be specified.")
|
||||
|
||||
mcp_kwargs: dict[str, Any] = {"server_label": name.replace(" ", "_"), **kwargs}
|
||||
if url:
|
||||
mcp_kwargs["server_url"] = url
|
||||
mcp = FoundryMCPTool(**mcp_kwargs)
|
||||
|
||||
if description:
|
||||
mcp["server_description"] = description
|
||||
|
||||
@@ -133,26 +133,55 @@ def select_toolbox_tools(
|
||||
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
|
||||
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."
|
||||
)
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
@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:
|
||||
Reconciles known mismatches between toolbox reads and the Responses API:
|
||||
|
||||
``Unknown parameter: 'tools[0].name'``.
|
||||
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
|
||||
``{"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.
|
||||
|
||||
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.
|
||||
These are workarounds until the toolbox/Responses proxy normalizes payloads
|
||||
server-side.
|
||||
"""
|
||||
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):
|
||||
@@ -161,6 +190,9 @@ def sanitize_foundry_response_tool(tool_item: Any) -> Any:
|
||||
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)
|
||||
|
||||
@@ -607,6 +607,14 @@ def test_get_mcp_tool_with_project_connection_id() -> None:
|
||||
assert tool_config["project_connection_id"] == "conn-123"
|
||||
assert tool_config["allowed_tools"] == ["search_docs"]
|
||||
assert tool_config["server_label"] == "Docs_MCP"
|
||||
# ``server_url`` should not be fabricated when only a project connection is supplied.
|
||||
assert "server_url" not in tool_config
|
||||
|
||||
|
||||
def test_get_mcp_tool_requires_url_or_project_connection_id() -> None:
|
||||
"""Missing both ``url`` and ``project_connection_id`` is always invalid."""
|
||||
with pytest.raises(ValueError, match="url.*project_connection_id"):
|
||||
FoundryChatClient.get_mcp_tool(name="x")
|
||||
|
||||
|
||||
def test_prepare_tools_for_openai_strips_extraneous_name_from_foundry_mcp_tool() -> None:
|
||||
@@ -655,6 +663,103 @@ def test_prepare_tools_for_openai_strips_read_model_fields_from_toolbox_code_int
|
||||
assert "description" not in prepared
|
||||
|
||||
|
||||
def test_prepare_tools_for_openai_injects_default_container_for_code_interpreter_dict() -> None:
|
||||
"""Toolbox-returned code_interpreter without a container must get a default injected.
|
||||
|
||||
The Azure SDK treats ``container`` as optional, but the Responses API rejects
|
||||
``code_interpreter`` entries without one. The sanitizer backfills ``{"type": "auto"}``.
|
||||
"""
|
||||
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",
|
||||
}
|
||||
|
||||
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"] == {"type": "auto"}
|
||||
assert "name" not in prepared
|
||||
|
||||
|
||||
def test_prepare_tools_for_openai_injects_default_container_for_code_interpreter_sdk_instance() -> None:
|
||||
"""SDK ``CodeInterpreterTool`` instances without a container must also be backfilled.
|
||||
|
||||
Reproduces the toolbox creation path that calls
|
||||
``CodeInterpreterTool(name="code_interpreter")`` without a container.
|
||||
"""
|
||||
from azure.ai.projects.models import CodeInterpreterTool
|
||||
|
||||
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([CodeInterpreterTool(name="code_interpreter")])
|
||||
|
||||
assert len(response_tools) == 1
|
||||
prepared = response_tools[0]
|
||||
assert prepared["type"] == "code_interpreter"
|
||||
assert prepared["container"] == {"type": "auto"}
|
||||
assert "name" not in prepared
|
||||
|
||||
|
||||
def test_prepare_tools_for_openai_preserves_existing_code_interpreter_container() -> None:
|
||||
"""An already-populated container must not be overwritten by the sanitizer."""
|
||||
project_client = MagicMock()
|
||||
project_client.get_openai_client.return_value = _make_mock_openai_client()
|
||||
client = FoundryChatClient(project_client=project_client, model="test-model")
|
||||
|
||||
explicit_container = {"file_ids": ["file_123"], "type": "auto"}
|
||||
tool = {"type": "code_interpreter", "container": explicit_container}
|
||||
|
||||
response_tools = client._prepare_tools_for_openai([tool])
|
||||
|
||||
assert response_tools[0]["container"] == explicit_container
|
||||
|
||||
|
||||
def test_prepare_tools_for_openai_rejects_file_search_without_vector_store_ids() -> None:
|
||||
"""``file_search`` without ``vector_store_ids`` is always invalid — surface a clear error."""
|
||||
project_client = MagicMock()
|
||||
project_client.get_openai_client.return_value = _make_mock_openai_client()
|
||||
client = FoundryChatClient(project_client=project_client, model="test-model")
|
||||
|
||||
with pytest.raises(ValueError, match="vector_store_ids"):
|
||||
client._prepare_tools_for_openai([{"type": "file_search", "name": "fs"}])
|
||||
|
||||
|
||||
def test_prepare_tools_for_openai_rejects_mcp_without_server_destination() -> None:
|
||||
"""``mcp`` with neither ``server_url`` nor ``project_connection_id`` is always invalid."""
|
||||
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="orphan")
|
||||
|
||||
with pytest.raises(ValueError, match="server_url.*project_connection_id"):
|
||||
client._prepare_tools_for_openai([tool])
|
||||
|
||||
|
||||
def test_prepare_tools_for_openai_accepts_mcp_with_only_project_connection_id() -> None:
|
||||
"""MCP tools backed by a Foundry connection (no ``server_url``) must still pass validation."""
|
||||
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")
|
||||
tool["project_connection_id"] = "githubmcp"
|
||||
|
||||
response_tools = client._prepare_tools_for_openai([tool])
|
||||
|
||||
assert len(response_tools) == 1
|
||||
assert response_tools[0]["project_connection_id"] == "githubmcp"
|
||||
assert "server_url" not in response_tools[0]
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -42,11 +42,12 @@ def create_sample_toolbox(name: str) -> str:
|
||||
Toolboxes are normally configured in the Foundry portal or a deployment
|
||||
script, not the application itself. This helper exists so the samples can
|
||||
be run end-to-end without first setting a toolbox up by hand — delete any
|
||||
existing toolbox under ``name``, then create a fresh version containing a
|
||||
single MCP tool. Returns the created version identifier.
|
||||
existing toolbox under ``name``, then create a fresh version containing an
|
||||
MCP tool, a web search tool, and a code interpreter tool. Returns the
|
||||
created version identifier.
|
||||
"""
|
||||
from azure.ai.projects import AIProjectClient
|
||||
from azure.ai.projects.models import MCPTool, Tool
|
||||
from azure.ai.projects.models import CodeInterpreterTool, MCPTool, Tool, WebSearchTool
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
|
||||
with (
|
||||
@@ -67,6 +68,9 @@ def create_sample_toolbox(name: str) -> str:
|
||||
)
|
||||
]
|
||||
|
||||
tools.append(WebSearchTool(name="web_search"))
|
||||
tools.append(CodeInterpreterTool(name="code_interpreter"))
|
||||
|
||||
created = project_client.beta.toolboxes.create_version(
|
||||
name=name,
|
||||
description="Toolbox version with MCP require_approval set to 'never'.",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import os
|
||||
import subprocess
|
||||
from random import randint
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
@@ -10,7 +11,6 @@ from agent_framework_foundry_hosting import ResponsesHostServer
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import Field
|
||||
from typing import Annotated
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
Reference in New Issue
Block a user