From fbf9b2970a90b556d0062746f1b22d10c8c97c99 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 19 May 2026 20:22:41 +0200 Subject: [PATCH 1/4] feat(foundry): add experimental hosted tool factories on FoundryChatClient Adds eight new `@experimental` static factory methods on `FoundryChatClient` covering Foundry-hosted tools that previously had no helper: - get_azure_ai_search_tool - get_sharepoint_tool - get_fabric_tool - get_memory_search_tool - get_computer_use_tool - get_browser_automation_tool - get_bing_custom_search_tool - get_a2a_tool All factories are marked with the new `ExperimentalFeature.FOUNDRY_TOOLS` tag and resolve the underlying `azure-ai-projects` preview classes lazily through a `_require_sdk_class` helper so older SDK versions still import cleanly and fail with a clear `ImportError` only on use. Tests cover each factory's return type and field wiring, the experimental metadata, and the missing-SDK-class fallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/_feature_stage.py | 1 + python/packages/foundry/README.md | 45 +++ .../agent_framework_foundry/_chat_client.py | 292 ++++++++++++++++++ .../tests/foundry/test_foundry_chat_client.py | 164 ++++++++++ 4 files changed, 502 insertions(+) diff --git a/python/packages/core/agent_framework/_feature_stage.py b/python/packages/core/agent_framework/_feature_stage.py index 90235b0232..27ae088ce1 100644 --- a/python/packages/core/agent_framework/_feature_stage.py +++ b/python/packages/core/agent_framework/_feature_stage.py @@ -49,6 +49,7 @@ class ExperimentalFeature(str, Enum): EVALS = "EVALS" FILE_HISTORY = "FILE_HISTORY" FIDES = "FIDES" + FOUNDRY_TOOLS = "FOUNDRY_TOOLS" FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS" HARNESS = "HARNESS" SKILLS = "SKILLS" diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md index 188535cd17..54dbd1503e 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -39,3 +39,48 @@ async with Agent( result = await agent.run("What tools are available?") print(result.text) ``` + +## Hosted tool factories + +`FoundryChatClient` exposes static factory methods that return Foundry SDK tool +configurations ready to pass to an `Agent`'s `tools=[...]` argument. These +factories don't require a `FoundryChatClient` instance — you can call them +statically and reuse the same tool configuration across agents. + +```python +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient + +agent = Agent( + client=FoundryChatClient(...), + instructions="...", + tools=[ + FoundryChatClient.get_web_search_tool(), + FoundryChatClient.get_code_interpreter_tool(), + ], +) +``` + +Generally available factories: `get_code_interpreter_tool`, +`get_file_search_tool`, `get_web_search_tool`, `get_image_generation_tool`, +`get_mcp_tool`. + +> **Experimental — `ExperimentalFeature.FOUNDRY_TOOLS`.** The following +> factories wrap preview Foundry tool types and may change or be removed before +> they reach GA. Each emits an `ExperimentalWarning` on first use. + +| Factory | Foundry SDK tool | +|---------|-----------------| +| `get_azure_ai_search_tool(index_connection_id, index_name, ...)` | `AzureAISearchTool` | +| `get_sharepoint_tool(connection_id)` | `SharepointPreviewTool` | +| `get_fabric_tool(connection_id)` | `MicrosoftFabricPreviewTool` | +| `get_memory_search_tool(memory_store_name, scope, ...)` | `MemorySearchPreviewTool` | +| `get_computer_use_tool(environment, display_width, display_height)` | `ComputerUsePreviewTool` | +| `get_browser_automation_tool(connection_id)` | `BrowserAutomationPreviewTool` | +| `get_bing_custom_search_tool(connection_id, instance_name, ...)` | `BingCustomSearchPreviewTool` | +| `get_a2a_tool(base_url=..., project_connection_id=..., ...)` | `A2APreviewTool` | + +The preview tool classes are resolved from `azure-ai-projects` lazily. If your +installed version doesn't expose one of them, the call raises a clear +`ImportError` pointing at the missing symbol — upgrade `azure-ai-projects` to +fix it. diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 614efcad15..5cc42a0396 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -16,6 +16,7 @@ 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 @@ -110,6 +111,26 @@ def resolve_file_ids(file_ids: Sequence[str | Content] | None) -> list[str] | No FoundryChatOptions = OpenAIChatOptions +def _require_sdk_class(name: str) -> Any: + """Resolve an ``azure.ai.projects.models`` class lazily. + + Preview SDK classes (``*PreviewTool`` and their parameter helpers) are added + on a rolling cadence; importing them at module load time would break the + package on older ``azure-ai-projects`` versions. This helper resolves them + on demand and raises a clear ``ImportError`` when the installed SDK is too + old to provide the requested symbol. + """ + from azure.ai.projects import models as _projects_models + + cls = getattr(_projects_models, name, None) + if cls is None: + raise ImportError( + f"{name!r} is not available in the installed azure-ai-projects package. " + "Upgrade azure-ai-projects to a version that exposes this Foundry tool." + ) + return cls + + class RawFoundryChatClient( # type: ignore[misc] RawOpenAIChatClient[FoundryChatOptionsT], Generic[FoundryChatOptionsT], @@ -501,6 +522,277 @@ def get_mcp_tool( # endregion + # region Experimental Foundry tool factories (preview SDK types) + + @staticmethod + @experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS) + def get_azure_ai_search_tool( + *, + index_connection_id: str, + index_name: str, + query_type: str | None = None, + top_k: int | None = None, + filter: str | None = None, + index_asset_id: str | None = None, + **kwargs: Any, + ) -> Any: + """Create an Azure AI Search tool configuration for Foundry. + + Keyword Args: + index_connection_id: The Foundry project connection ID for the Azure AI Search index. + index_name: The name of the index to search. + query_type: Optional query type (``"simple"``, ``"semantic"``, ``"vector"``, + ``"vector_simple_hybrid"``, or ``"vector_semantic_hybrid"``). + top_k: Optional number of documents to retrieve. + filter: Optional OData filter expression. + index_asset_id: Optional index asset id for the search resource. + **kwargs: Additional arguments forwarded to the SDK ``AISearchIndexResource``. + + Returns: + An ``AzureAISearchTool`` ready to pass to an Agent. + """ + tool_cls = _require_sdk_class("AzureAISearchTool") + resource_cls = _require_sdk_class("AzureAISearchToolResource") + index_cls = _require_sdk_class("AISearchIndexResource") + index_kwargs: dict[str, Any] = { + "project_connection_id": index_connection_id, + "index_name": index_name, + **kwargs, + } + if query_type is not None: + index_kwargs["query_type"] = query_type + if top_k is not None: + index_kwargs["top_k"] = top_k + if filter is not None: + index_kwargs["filter"] = filter + if index_asset_id is not None: + index_kwargs["index_asset_id"] = index_asset_id + return tool_cls(azure_ai_search=resource_cls(indexes=[index_cls(**index_kwargs)])) + + @staticmethod + @experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS) + def get_sharepoint_tool( + *, + connection_id: str, + **kwargs: Any, + ) -> Any: + """Create a SharePoint grounding tool configuration for Foundry. + + Keyword Args: + connection_id: The Foundry project connection ID for the SharePoint resource. + **kwargs: Additional arguments forwarded to the SDK + ``SharepointGroundingToolParameters``. + + Returns: + A ``SharepointPreviewTool`` ready to pass to an Agent. + """ + tool_cls = _require_sdk_class("SharepointPreviewTool") + params_cls = _require_sdk_class("SharepointGroundingToolParameters") + connection_cls = _require_sdk_class("ToolProjectConnection") + return tool_cls( + sharepoint_grounding_preview=params_cls( + project_connections=[connection_cls(project_connection_id=connection_id)], + **kwargs, + ) + ) + + @staticmethod + @experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS) + def get_fabric_tool( + *, + connection_id: str, + **kwargs: Any, + ) -> Any: + """Create a Microsoft Fabric data agent tool configuration for Foundry. + + Keyword Args: + connection_id: The Foundry project connection ID for the Fabric data agent. + **kwargs: Additional arguments forwarded to the SDK + ``FabricDataAgentToolParameters``. + + Returns: + A ``MicrosoftFabricPreviewTool`` ready to pass to an Agent. + """ + tool_cls = _require_sdk_class("MicrosoftFabricPreviewTool") + params_cls = _require_sdk_class("FabricDataAgentToolParameters") + connection_cls = _require_sdk_class("ToolProjectConnection") + return tool_cls( + fabric_dataagent_preview=params_cls( + project_connections=[connection_cls(project_connection_id=connection_id)], + **kwargs, + ) + ) + + @staticmethod + @experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS) + def get_memory_search_tool( + *, + memory_store_name: str, + scope: str, + search_options: Any | None = None, + update_delay: int | None = None, + **kwargs: Any, + ) -> Any: + """Create a Memory Search tool configuration for Foundry. + + Keyword Args: + memory_store_name: The name of the memory store to use. + scope: The namespace used to group and isolate memories (e.g. a user ID). + Use ``"{{$userId}}"`` to scope memories to the current signed-in user. + search_options: Optional ``MemorySearchOptions`` instance. + update_delay: Optional seconds to wait before updating memories after inactivity. + **kwargs: Additional arguments forwarded to the SDK ``MemorySearchPreviewTool``. + + Returns: + A ``MemorySearchPreviewTool`` ready to pass to an Agent. + """ + tool_cls = _require_sdk_class("MemorySearchPreviewTool") + params: dict[str, Any] = { + "memory_store_name": memory_store_name, + "scope": scope, + **kwargs, + } + if search_options is not None: + params["search_options"] = search_options + if update_delay is not None: + params["update_delay"] = update_delay + return tool_cls(**params) + + @staticmethod + @experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS) + def get_computer_use_tool( + *, + environment: str, + display_width: int, + display_height: int, + **kwargs: Any, + ) -> Any: + """Create a Computer Use tool configuration for Foundry. + + Keyword Args: + environment: The computer environment to control. One of ``"windows"``, + ``"mac"``, ``"linux"``, ``"ubuntu"``, or ``"browser"``. + display_width: The width of the computer display. + display_height: The height of the computer display. + **kwargs: Additional arguments forwarded to the SDK ``ComputerUsePreviewTool``. + + Returns: + A ``ComputerUsePreviewTool`` ready to pass to an Agent. + """ + tool_cls = _require_sdk_class("ComputerUsePreviewTool") + return tool_cls( + environment=environment, + display_width=display_width, + display_height=display_height, + **kwargs, + ) + + @staticmethod + @experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS) + def get_browser_automation_tool( + *, + connection_id: str, + **kwargs: Any, + ) -> Any: + """Create a Browser Automation tool configuration for Foundry. + + Keyword Args: + connection_id: The Foundry project connection ID for the Azure Playwright resource. + **kwargs: Additional arguments forwarded to the SDK + ``BrowserAutomationToolParameters``. + + Returns: + A ``BrowserAutomationPreviewTool`` ready to pass to an Agent. + """ + tool_cls = _require_sdk_class("BrowserAutomationPreviewTool") + params_cls = _require_sdk_class("BrowserAutomationToolParameters") + connection_cls = _require_sdk_class("BrowserAutomationToolConnectionParameters") + return tool_cls( + browser_automation_preview=params_cls( + connection=connection_cls(project_connection_id=connection_id), + **kwargs, + ) + ) + + @staticmethod + @experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS) + def get_bing_custom_search_tool( + *, + connection_id: str, + instance_name: str, + market: str | None = None, + set_lang: str | None = None, + count: int | None = None, + freshness: str | None = None, + **kwargs: Any, + ) -> Any: + """Create a Bing Custom Search tool configuration for Foundry. + + Keyword Args: + connection_id: The Foundry project connection ID for grounding with Bing search. + instance_name: The custom configuration instance name. + market: Optional Bing market identifier (e.g. ``"en-US"``). + set_lang: Optional UI language code passed to the Bing API. + count: Optional number of search results to return. + freshness: Optional time-range filter for search results. + **kwargs: Additional arguments forwarded to the SDK + ``BingCustomSearchConfiguration``. + + Returns: + A ``BingCustomSearchPreviewTool`` ready to pass to an Agent. + """ + tool_cls = _require_sdk_class("BingCustomSearchPreviewTool") + params_cls = _require_sdk_class("BingCustomSearchToolParameters") + config_cls = _require_sdk_class("BingCustomSearchConfiguration") + config_kwargs: dict[str, Any] = { + "project_connection_id": connection_id, + "instance_name": instance_name, + **kwargs, + } + if market is not None: + config_kwargs["market"] = market + if set_lang is not None: + config_kwargs["set_lang"] = set_lang + if count is not None: + config_kwargs["count"] = count + if freshness is not None: + config_kwargs["freshness"] = freshness + return tool_cls(bing_custom_search_preview=params_cls(search_configurations=[config_cls(**config_kwargs)])) + + @staticmethod + @experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS) + def get_a2a_tool( + *, + base_url: str | None = None, + agent_card_path: str | None = None, + project_connection_id: str | None = None, + **kwargs: Any, + ) -> Any: + """Create an Agent-to-Agent (A2A) tool configuration for Foundry. + + Keyword Args: + base_url: Base URL of the remote A2A agent. + agent_card_path: Path to the agent card relative to ``base_url``. + Defaults to ``"/.well-known/agent-card.json"`` server-side. + project_connection_id: Foundry connection ID for the A2A server. Stores + authentication and other connection details. + **kwargs: Additional arguments forwarded to the SDK ``A2APreviewTool``. + + Returns: + An ``A2APreviewTool`` ready to pass to an Agent. + """ + tool_cls = _require_sdk_class("A2APreviewTool") + params: dict[str, Any] = dict(kwargs) + if base_url is not None: + params["base_url"] = base_url + if agent_card_path is not None: + params["agent_card_path"] = agent_card_path + if project_connection_id is not None: + params["project_connection_id"] = project_connection_id + return tool_cls(**params) + + # endregion + class FoundryChatClient( # type: ignore[misc] FunctionInvocationLayer[FoundryChatOptionsT], diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index 8f069b7f6d..4e58b44e58 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -1012,6 +1012,170 @@ def test_get_mcp_tool_with_connection_id() -> None: assert tool_obj is not None +def test_get_azure_ai_search_tool() -> None: + """Azure AI Search tool factory builds the nested resource correctly.""" + from azure.ai.projects.models import AzureAISearchTool + + tool_obj = FoundryChatClient.get_azure_ai_search_tool( + index_connection_id="conn-1", + index_name="my-index", + query_type="vector_semantic_hybrid", + top_k=5, + filter="category eq 'docs'", + ) + assert isinstance(tool_obj, AzureAISearchTool) + indexes = tool_obj.azure_ai_search.indexes + assert len(indexes) == 1 + index = indexes[0] + assert index.project_connection_id == "conn-1" + assert index.index_name == "my-index" + assert index.query_type == "vector_semantic_hybrid" + assert index.top_k == 5 + assert index.filter == "category eq 'docs'" + + +def test_get_sharepoint_tool() -> None: + """SharePoint tool factory wires the connection through nested params.""" + from azure.ai.projects.models import SharepointPreviewTool + + tool_obj = FoundryChatClient.get_sharepoint_tool(connection_id="sp-conn") + assert isinstance(tool_obj, SharepointPreviewTool) + connections = tool_obj.sharepoint_grounding_preview.project_connections + assert connections is not None + assert len(connections) == 1 + assert connections[0].project_connection_id == "sp-conn" + + +def test_get_fabric_tool() -> None: + """Fabric tool factory wires the connection through nested params.""" + from azure.ai.projects.models import MicrosoftFabricPreviewTool + + tool_obj = FoundryChatClient.get_fabric_tool(connection_id="fab-conn") + assert isinstance(tool_obj, MicrosoftFabricPreviewTool) + connections = tool_obj.fabric_dataagent_preview.project_connections + assert connections is not None + assert len(connections) == 1 + assert connections[0].project_connection_id == "fab-conn" + + +def test_get_memory_search_tool() -> None: + """Memory search tool factory passes core fields through.""" + from azure.ai.projects.models import MemorySearchPreviewTool + + tool_obj = FoundryChatClient.get_memory_search_tool( + memory_store_name="store-1", + scope="{{$userId}}", + update_delay=600, + ) + assert isinstance(tool_obj, MemorySearchPreviewTool) + assert tool_obj.memory_store_name == "store-1" + assert tool_obj.scope == "{{$userId}}" + assert tool_obj.update_delay == 600 + + +def test_get_computer_use_tool() -> None: + """Computer use tool factory passes environment + display dimensions.""" + from azure.ai.projects.models import ComputerUsePreviewTool + + tool_obj = FoundryChatClient.get_computer_use_tool( + environment="browser", + display_width=1920, + display_height=1080, + ) + assert isinstance(tool_obj, ComputerUsePreviewTool) + assert tool_obj.environment == "browser" + assert tool_obj.display_width == 1920 + assert tool_obj.display_height == 1080 + + +def test_get_browser_automation_tool() -> None: + """Browser automation tool factory wraps the connection id in the params type.""" + from azure.ai.projects.models import BrowserAutomationPreviewTool + + tool_obj = FoundryChatClient.get_browser_automation_tool(connection_id="playwright-conn") + assert isinstance(tool_obj, BrowserAutomationPreviewTool) + assert tool_obj.browser_automation_preview.connection.project_connection_id == "playwright-conn" + + +def test_get_bing_custom_search_tool() -> None: + """Bing custom search tool factory builds the nested search configuration.""" + from azure.ai.projects.models import BingCustomSearchPreviewTool + + tool_obj = FoundryChatClient.get_bing_custom_search_tool( + connection_id="bing-conn", + instance_name="my-custom-config", + market="en-US", + count=10, + ) + assert isinstance(tool_obj, BingCustomSearchPreviewTool) + configs = tool_obj.bing_custom_search_preview.search_configurations + assert len(configs) == 1 + config = configs[0] + assert config.project_connection_id == "bing-conn" + assert config.instance_name == "my-custom-config" + assert config.market == "en-US" + assert config.count == 10 + + +def test_get_a2a_tool() -> None: + """A2A tool factory carries base_url, agent_card_path, and project_connection_id.""" + from azure.ai.projects.models import A2APreviewTool + + tool_obj = FoundryChatClient.get_a2a_tool( + base_url="https://agent.example.com", + agent_card_path="/.well-known/agent-card.json", + project_connection_id="a2a-conn", + ) + assert isinstance(tool_obj, A2APreviewTool) + assert tool_obj.base_url == "https://agent.example.com" + assert tool_obj.agent_card_path == "/.well-known/agent-card.json" + assert tool_obj.project_connection_id == "a2a-conn" + + +@pytest.mark.parametrize( + "factory_name, kwargs", + [ + ("get_azure_ai_search_tool", {"index_connection_id": "c", "index_name": "i"}), + ("get_sharepoint_tool", {"connection_id": "c"}), + ("get_fabric_tool", {"connection_id": "c"}), + ( + "get_memory_search_tool", + {"memory_store_name": "s", "scope": "u"}, + ), + ( + "get_computer_use_tool", + {"environment": "browser", "display_width": 1, "display_height": 1}, + ), + ("get_browser_automation_tool", {"connection_id": "c"}), + ( + "get_bing_custom_search_tool", + {"connection_id": "c", "instance_name": "i"}, + ), + ("get_a2a_tool", {"base_url": "https://a.example.com"}), + ], +) +def test_experimental_foundry_tool_factories_are_marked(factory_name: str, kwargs: dict[str, Any]) -> None: + """Each new factory carries the FOUNDRY_TOOLS experimental feature metadata.""" + factory = getattr(FoundryChatClient, factory_name) + # `@staticmethod` + `@experimental` wraps the underlying function; metadata sits on it. + assert getattr(factory, "__feature_stage__", None) == "experimental" + assert getattr(factory, "__feature_id__", None) == "FOUNDRY_TOOLS" + # The factory must be invokable without a FoundryChatClient instance. + assert factory(**kwargs) is not None + + +def test_get_azure_ai_search_tool_requires_sdk_class(monkeypatch: pytest.MonkeyPatch) -> None: + """A missing preview class raises a clear ImportError, not AttributeError.""" + from azure.ai.projects import models as projects_models + + monkeypatch.delattr(projects_models, "AzureAISearchTool", raising=False) + with pytest.raises(ImportError, match="AzureAISearchTool"): + FoundryChatClient.get_azure_ai_search_tool( + index_connection_id="c", + index_name="i", + ) + + def test_parse_chunk_surfaces_oauth_consent_request() -> None: """An oauth_consent_request output item surfaces as Content with consent_link.""" From 8dc4db290f2903ffbbddf3dea2557a06130ebab1 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 19 May 2026 20:36:37 +0200 Subject: [PATCH 2/4] test(foundry): address review comments on tool-factory tests * Skip preview-tool tests gracefully (`_skip_if_sdk_class_missing`) when the installed `azure-ai-projects` does not expose the required preview class, matching the lazy-import guard in production code so the test suite stays green on older SDK installs. * Add `filterwarnings("ignore::FutureWarning")` to each new tool-factory test (and the parametrized metadata test) so they remain stable under strict warning configurations \u2014 the global dedup in `_feature_stage._WARNED_FEATURES` makes `pytest.warns` brittle across ordered runs. * Use `monkeypatch.setattr(..., None, raising=False)` instead of `delattr` in the missing-SDK-class test so it works for modules that implement PEP 562 `__getattr__`. * Split the long `get_bing_custom_search_tool` return into two lines for readability. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework_foundry/_chat_client.py | 3 +- .../tests/foundry/test_foundry_chat_client.py | 112 +++++++++++------- 2 files changed, 73 insertions(+), 42 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 5cc42a0396..ebca50299a 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -757,7 +757,8 @@ def get_bing_custom_search_tool( config_kwargs["count"] = count if freshness is not None: config_kwargs["freshness"] = freshness - return tool_cls(bing_custom_search_preview=params_cls(search_configurations=[config_cls(**config_kwargs)])) + search_params = params_cls(search_configurations=[config_cls(**config_kwargs)]) + return tool_cls(bing_custom_search_preview=search_params) @staticmethod @experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS) diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index 4e58b44e58..fd1941823b 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -1012,9 +1012,20 @@ def test_get_mcp_tool_with_connection_id() -> None: assert tool_obj is not None +def _skip_if_sdk_class_missing(name: str) -> Any: + """Return the SDK class or skip the test if older azure-ai-projects lacks it.""" + from azure.ai.projects import models as projects_models + + cls = getattr(projects_models, name, None) + if cls is None: + pytest.skip(f"azure-ai-projects in this environment does not expose {name!r}.") + return cls + + +@pytest.mark.filterwarnings("ignore::FutureWarning") def test_get_azure_ai_search_tool() -> None: """Azure AI Search tool factory builds the nested resource correctly.""" - from azure.ai.projects.models import AzureAISearchTool + azure_ai_search_tool_cls = _skip_if_sdk_class_missing("AzureAISearchTool") tool_obj = FoundryChatClient.get_azure_ai_search_tool( index_connection_id="conn-1", @@ -1023,7 +1034,7 @@ def test_get_azure_ai_search_tool() -> None: top_k=5, filter="category eq 'docs'", ) - assert isinstance(tool_obj, AzureAISearchTool) + assert isinstance(tool_obj, azure_ai_search_tool_cls) indexes = tool_obj.azure_ai_search.indexes assert len(indexes) == 1 index = indexes[0] @@ -1034,72 +1045,78 @@ def test_get_azure_ai_search_tool() -> None: assert index.filter == "category eq 'docs'" +@pytest.mark.filterwarnings("ignore::FutureWarning") def test_get_sharepoint_tool() -> None: """SharePoint tool factory wires the connection through nested params.""" - from azure.ai.projects.models import SharepointPreviewTool + sharepoint_tool_cls = _skip_if_sdk_class_missing("SharepointPreviewTool") tool_obj = FoundryChatClient.get_sharepoint_tool(connection_id="sp-conn") - assert isinstance(tool_obj, SharepointPreviewTool) + assert isinstance(tool_obj, sharepoint_tool_cls) connections = tool_obj.sharepoint_grounding_preview.project_connections assert connections is not None assert len(connections) == 1 assert connections[0].project_connection_id == "sp-conn" +@pytest.mark.filterwarnings("ignore::FutureWarning") def test_get_fabric_tool() -> None: """Fabric tool factory wires the connection through nested params.""" - from azure.ai.projects.models import MicrosoftFabricPreviewTool + fabric_tool_cls = _skip_if_sdk_class_missing("MicrosoftFabricPreviewTool") tool_obj = FoundryChatClient.get_fabric_tool(connection_id="fab-conn") - assert isinstance(tool_obj, MicrosoftFabricPreviewTool) + assert isinstance(tool_obj, fabric_tool_cls) connections = tool_obj.fabric_dataagent_preview.project_connections assert connections is not None assert len(connections) == 1 assert connections[0].project_connection_id == "fab-conn" +@pytest.mark.filterwarnings("ignore::FutureWarning") def test_get_memory_search_tool() -> None: """Memory search tool factory passes core fields through.""" - from azure.ai.projects.models import MemorySearchPreviewTool + memory_tool_cls = _skip_if_sdk_class_missing("MemorySearchPreviewTool") tool_obj = FoundryChatClient.get_memory_search_tool( memory_store_name="store-1", scope="{{$userId}}", update_delay=600, ) - assert isinstance(tool_obj, MemorySearchPreviewTool) + assert isinstance(tool_obj, memory_tool_cls) assert tool_obj.memory_store_name == "store-1" assert tool_obj.scope == "{{$userId}}" assert tool_obj.update_delay == 600 +@pytest.mark.filterwarnings("ignore::FutureWarning") def test_get_computer_use_tool() -> None: """Computer use tool factory passes environment + display dimensions.""" - from azure.ai.projects.models import ComputerUsePreviewTool + computer_use_cls = _skip_if_sdk_class_missing("ComputerUsePreviewTool") tool_obj = FoundryChatClient.get_computer_use_tool( environment="browser", display_width=1920, display_height=1080, ) - assert isinstance(tool_obj, ComputerUsePreviewTool) + assert isinstance(tool_obj, computer_use_cls) assert tool_obj.environment == "browser" assert tool_obj.display_width == 1920 assert tool_obj.display_height == 1080 +@pytest.mark.filterwarnings("ignore::FutureWarning") def test_get_browser_automation_tool() -> None: """Browser automation tool factory wraps the connection id in the params type.""" - from azure.ai.projects.models import BrowserAutomationPreviewTool + browser_tool_cls = _skip_if_sdk_class_missing("BrowserAutomationPreviewTool") tool_obj = FoundryChatClient.get_browser_automation_tool(connection_id="playwright-conn") - assert isinstance(tool_obj, BrowserAutomationPreviewTool) + assert isinstance(tool_obj, browser_tool_cls) assert tool_obj.browser_automation_preview.connection.project_connection_id == "playwright-conn" +@pytest.mark.filterwarnings("ignore::FutureWarning") def test_get_bing_custom_search_tool() -> None: """Bing custom search tool factory builds the nested search configuration.""" - from azure.ai.projects.models import BingCustomSearchPreviewTool + bing_tool_cls = _skip_if_sdk_class_missing("BingCustomSearchPreviewTool") tool_obj = FoundryChatClient.get_bing_custom_search_tool( connection_id="bing-conn", @@ -1107,7 +1124,7 @@ def test_get_bing_custom_search_tool() -> None: market="en-US", count=10, ) - assert isinstance(tool_obj, BingCustomSearchPreviewTool) + assert isinstance(tool_obj, bing_tool_cls) configs = tool_obj.bing_custom_search_preview.search_configurations assert len(configs) == 1 config = configs[0] @@ -1117,45 +1134,53 @@ def test_get_bing_custom_search_tool() -> None: assert config.count == 10 +@pytest.mark.filterwarnings("ignore::FutureWarning") def test_get_a2a_tool() -> None: """A2A tool factory carries base_url, agent_card_path, and project_connection_id.""" - from azure.ai.projects.models import A2APreviewTool + a2a_tool_cls = _skip_if_sdk_class_missing("A2APreviewTool") tool_obj = FoundryChatClient.get_a2a_tool( base_url="https://agent.example.com", agent_card_path="/.well-known/agent-card.json", project_connection_id="a2a-conn", ) - assert isinstance(tool_obj, A2APreviewTool) + assert isinstance(tool_obj, a2a_tool_cls) assert tool_obj.base_url == "https://agent.example.com" assert tool_obj.agent_card_path == "/.well-known/agent-card.json" assert tool_obj.project_connection_id == "a2a-conn" -@pytest.mark.parametrize( - "factory_name, kwargs", - [ - ("get_azure_ai_search_tool", {"index_connection_id": "c", "index_name": "i"}), - ("get_sharepoint_tool", {"connection_id": "c"}), - ("get_fabric_tool", {"connection_id": "c"}), - ( - "get_memory_search_tool", - {"memory_store_name": "s", "scope": "u"}, - ), - ( - "get_computer_use_tool", - {"environment": "browser", "display_width": 1, "display_height": 1}, - ), - ("get_browser_automation_tool", {"connection_id": "c"}), - ( - "get_bing_custom_search_tool", - {"connection_id": "c", "instance_name": "i"}, - ), - ("get_a2a_tool", {"base_url": "https://a.example.com"}), - ], -) -def test_experimental_foundry_tool_factories_are_marked(factory_name: str, kwargs: dict[str, Any]) -> None: +_EXPERIMENTAL_FACTORY_CASES: list[tuple[str, str, dict[str, Any]]] = [ + ("get_azure_ai_search_tool", "AzureAISearchTool", {"index_connection_id": "c", "index_name": "i"}), + ("get_sharepoint_tool", "SharepointPreviewTool", {"connection_id": "c"}), + ("get_fabric_tool", "MicrosoftFabricPreviewTool", {"connection_id": "c"}), + ( + "get_memory_search_tool", + "MemorySearchPreviewTool", + {"memory_store_name": "s", "scope": "u"}, + ), + ( + "get_computer_use_tool", + "ComputerUsePreviewTool", + {"environment": "browser", "display_width": 1, "display_height": 1}, + ), + ("get_browser_automation_tool", "BrowserAutomationPreviewTool", {"connection_id": "c"}), + ( + "get_bing_custom_search_tool", + "BingCustomSearchPreviewTool", + {"connection_id": "c", "instance_name": "i"}, + ), + ("get_a2a_tool", "A2APreviewTool", {"base_url": "https://a.example.com"}), +] + + +@pytest.mark.filterwarnings("ignore::FutureWarning") +@pytest.mark.parametrize("factory_name, sdk_class_name, kwargs", _EXPERIMENTAL_FACTORY_CASES) +def test_experimental_foundry_tool_factories_are_marked( + factory_name: str, sdk_class_name: str, kwargs: dict[str, Any] +) -> None: """Each new factory carries the FOUNDRY_TOOLS experimental feature metadata.""" + _skip_if_sdk_class_missing(sdk_class_name) factory = getattr(FoundryChatClient, factory_name) # `@staticmethod` + `@experimental` wraps the underlying function; metadata sits on it. assert getattr(factory, "__feature_stage__", None) == "experimental" @@ -1165,10 +1190,15 @@ def test_experimental_foundry_tool_factories_are_marked(factory_name: str, kwarg def test_get_azure_ai_search_tool_requires_sdk_class(monkeypatch: pytest.MonkeyPatch) -> None: - """A missing preview class raises a clear ImportError, not AttributeError.""" + """A missing preview class raises a clear ImportError, not AttributeError. + + Uses ``setattr(..., None, raising=False)`` rather than ``delattr`` so the + test is robust against modules that implement PEP 562 ``__getattr__`` and + would otherwise rehydrate the attribute on access. + """ from azure.ai.projects import models as projects_models - monkeypatch.delattr(projects_models, "AzureAISearchTool", raising=False) + monkeypatch.setattr(projects_models, "AzureAISearchTool", None, raising=False) with pytest.raises(ImportError, match="AzureAISearchTool"): FoundryChatClient.get_azure_ai_search_tool( index_connection_id="c", From 76934c916c4aff9dc5ba9edad4e9787edd852ca9 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 19 May 2026 21:07:49 +0200 Subject: [PATCH 3/4] fix(foundry): harden tool-factory kwargs against silent override * Reorder the dict-literal kwargs assembly in get_azure_ai_search_tool, get_memory_search_tool, and get_bing_custom_search_tool so explicit parameters always take precedence over **kwargs (matching the safe pattern already used in get_a2a_tool). This prevents a caller passing `project_connection_id`, `index_name`, `memory_store_name`, `scope`, or `instance_name` through `**kwargs` from silently overriding the explicit security-sensitive arguments. * Update the README experimental note to reflect once-per-feature-id dedup semantics of `_feature_stage._WARNED_FEATURES` rather than claiming a per-factory "first use" warning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/foundry/README.md | 5 ++++- .../foundry/agent_framework_foundry/_chat_client.py | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md index 54dbd1503e..5e86c9a91a 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -67,7 +67,10 @@ Generally available factories: `get_code_interpreter_tool`, > **Experimental — `ExperimentalFeature.FOUNDRY_TOOLS`.** The following > factories wrap preview Foundry tool types and may change or be removed before -> they reach GA. Each emits an `ExperimentalWarning` on first use. +> they reach GA. Calls to any of these factories emit an `ExperimentalWarning` +> the first time the `FOUNDRY_TOOLS` feature is exercised in a process; the +> warning is then deduplicated across all eight factories (one warning per +> feature id per process). | Factory | Foundry SDK tool | |---------|-----------------| diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index ebca50299a..3dd755b73a 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -555,9 +555,9 @@ def get_azure_ai_search_tool( resource_cls = _require_sdk_class("AzureAISearchToolResource") index_cls = _require_sdk_class("AISearchIndexResource") index_kwargs: dict[str, Any] = { + **kwargs, "project_connection_id": index_connection_id, "index_name": index_name, - **kwargs, } if query_type is not None: index_kwargs["query_type"] = query_type @@ -648,9 +648,9 @@ def get_memory_search_tool( """ tool_cls = _require_sdk_class("MemorySearchPreviewTool") params: dict[str, Any] = { + **kwargs, "memory_store_name": memory_store_name, "scope": scope, - **kwargs, } if search_options is not None: params["search_options"] = search_options @@ -745,9 +745,9 @@ def get_bing_custom_search_tool( params_cls = _require_sdk_class("BingCustomSearchToolParameters") config_cls = _require_sdk_class("BingCustomSearchConfiguration") config_kwargs: dict[str, Any] = { + **kwargs, "project_connection_id": connection_id, "instance_name": instance_name, - **kwargs, } if market is not None: config_kwargs["market"] = market From ba6db933726c26d392b70f0fd016cf315ec6d7d2 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 21 May 2026 09:23:36 +0200 Subject: [PATCH 4/4] feat(foundry): split FOUNDRY_TOOLS / FOUNDRY_PREVIEW_TOOLS, add bing-grounding - Add ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS to distinguish wrappers around preview Foundry SDK tool classes (Sharepoint/Fabric/Memory/ComputerUse/ BrowserAutomation/BingCustomSearch/A2A) from FOUNDRY_TOOLS, which is for GA-SDK wrappers that are simply new in agent-framework-foundry (AzureAISearch, BingGrounding). - Add get_bing_grounding_tool factory and a 'Choosing a web grounding tool' comparison block on get_web_search_tool / get_bing_grounding_tool / get_bing_custom_search_tool docstrings. - Drop the _require_sdk_class lazy resolver: every guarded class is available at azure-ai-projects>=2.1.0 (the package floor), so import them eagerly. Concrete return types replace 'Any'. - README: split the experimental factories into two tables, one per feature flag, with a note explaining the distinction. - Tests: split into FOUNDRY_TOOLS / FOUNDRY_PREVIEW_TOOLS factory cases; drop the obsolete missing-SDK-class ImportError test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/_feature_stage.py | 1 + python/packages/foundry/README.md | 43 ++- .../agent_framework_foundry/_chat_client.py | 324 +++++++++++------- .../tests/foundry/test_foundry_chat_client.py | 89 +++-- python/uv.lock | 2 +- 5 files changed, 306 insertions(+), 153 deletions(-) diff --git a/python/packages/core/agent_framework/_feature_stage.py b/python/packages/core/agent_framework/_feature_stage.py index 27ae088ce1..afcfc11267 100644 --- a/python/packages/core/agent_framework/_feature_stage.py +++ b/python/packages/core/agent_framework/_feature_stage.py @@ -50,6 +50,7 @@ class ExperimentalFeature(str, Enum): FILE_HISTORY = "FILE_HISTORY" FIDES = "FIDES" FOUNDRY_TOOLS = "FOUNDRY_TOOLS" + FOUNDRY_PREVIEW_TOOLS = "FOUNDRY_PREVIEW_TOOLS" FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS" HARNESS = "HARNESS" SKILLS = "SKILLS" diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md index 5e86c9a91a..bbc139a1ae 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -62,19 +62,43 @@ agent = Agent( ``` Generally available factories: `get_code_interpreter_tool`, -`get_file_search_tool`, `get_web_search_tool`, `get_image_generation_tool`, -`get_mcp_tool`. +`get_file_search_tool`, `get_web_search_tool`, +`get_image_generation_tool`, `get_mcp_tool`. + +> **Choosing a web grounding tool.** `get_web_search_tool` is the recommended +> default — it requires no separate Bing resource and works with Azure OpenAI +> models out of the box. Reach for `get_bing_grounding_tool` (experimental, +> see below) when you need finer Bing parameters (`count`, `freshness`, +> `market`, `set_lang`), are grounding non-OpenAI Foundry models, or are +> migrating from Grounding with Bing Search on the classic platform — it +> requires a Grounding with Bing Search Azure resource that you manage. +> `get_bing_custom_search_tool` (also experimental) is for grounding +> restricted to a curated list of domains via a Bing Custom Search instance. +> See the +> [web grounding overview](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/web-overview) +> for the full comparison. > **Experimental — `ExperimentalFeature.FOUNDRY_TOOLS`.** The following -> factories wrap preview Foundry tool types and may change or be removed before -> they reach GA. Calls to any of these factories emit an `ExperimentalWarning` -> the first time the `FOUNDRY_TOOLS` feature is exercised in a process; the -> warning is then deduplicated across all eight factories (one warning per -> feature id per process). +> factories wrap GA Foundry tool SDK classes but are new wrappers in +> `agent-framework-foundry` and may change before the wrappers themselves +> reach GA. Calls emit an `ExperimentalWarning` the first time the +> `FOUNDRY_TOOLS` feature is exercised in a process (then deduplicated). | Factory | Foundry SDK tool | |---------|-----------------| | `get_azure_ai_search_tool(index_connection_id, index_name, ...)` | `AzureAISearchTool` | +| `get_bing_grounding_tool(connection_id, ...)` | `BingGroundingTool` | + +> **Experimental — `ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS`.** The +> following factories wrap **preview** Foundry tool SDK types — the underlying +> Foundry capability itself is in preview and may change or be removed before +> reaching GA. Calls emit a separate `ExperimentalWarning` the first time the +> `FOUNDRY_PREVIEW_TOOLS` feature is exercised in a process (then +> deduplicated). Use `FOUNDRY_TOOLS` for "wrapper is new" and +> `FOUNDRY_PREVIEW_TOOLS` for "underlying Foundry feature is preview". + +| Factory | Foundry SDK tool | +|---------|-----------------| | `get_sharepoint_tool(connection_id)` | `SharepointPreviewTool` | | `get_fabric_tool(connection_id)` | `MicrosoftFabricPreviewTool` | | `get_memory_search_tool(memory_store_name, scope, ...)` | `MemorySearchPreviewTool` | @@ -82,8 +106,3 @@ Generally available factories: `get_code_interpreter_tool`, | `get_browser_automation_tool(connection_id)` | `BrowserAutomationPreviewTool` | | `get_bing_custom_search_tool(connection_id, instance_name, ...)` | `BingCustomSearchPreviewTool` | | `get_a2a_tool(base_url=..., project_connection_id=..., ...)` | `A2APreviewTool` | - -The preview tool classes are resolved from `azure-ai-projects` lazily. If your -installed version doesn't expose one of them, the call raises a clear -`ImportError` pointing at the missing symbol — upgrade `azure-ai-projects` to -fix it. diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 3dd755b73a..714862256e 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -22,9 +22,29 @@ from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient from azure.ai.projects.models import ( + A2APreviewTool, + AISearchIndexResource, AutoCodeInterpreterToolParam, + AzureAISearchTool, + AzureAISearchToolResource, + BingCustomSearchConfiguration, + BingCustomSearchPreviewTool, + BingCustomSearchToolParameters, + BingGroundingSearchConfiguration, + BingGroundingSearchToolParameters, + BingGroundingTool, + BrowserAutomationPreviewTool, + BrowserAutomationToolConnectionParameters, + BrowserAutomationToolParameters, CodeInterpreterTool, + ComputerUsePreviewTool, + FabricDataAgentToolParameters, ImageGenTool, + MemorySearchPreviewTool, + MicrosoftFabricPreviewTool, + SharepointGroundingToolParameters, + SharepointPreviewTool, + ToolProjectConnection, WebSearchApproximateLocation, WebSearchTool, WebSearchToolFilters, @@ -111,26 +131,6 @@ def resolve_file_ids(file_ids: Sequence[str | Content] | None) -> list[str] | No FoundryChatOptions = OpenAIChatOptions -def _require_sdk_class(name: str) -> Any: - """Resolve an ``azure.ai.projects.models`` class lazily. - - Preview SDK classes (``*PreviewTool`` and their parameter helpers) are added - on a rolling cadence; importing them at module load time would break the - package on older ``azure-ai-projects`` versions. This helper resolves them - on demand and raises a clear ``ImportError`` when the installed SDK is too - old to provide the requested symbol. - """ - from azure.ai.projects import models as _projects_models - - cls = getattr(_projects_models, name, None) - if cls is None: - raise ImportError( - f"{name!r} is not available in the installed azure-ai-projects package. " - "Upgrade azure-ai-projects to a version that exposes this Foundry tool." - ) - return cls - - class RawFoundryChatClient( # type: ignore[misc] RawOpenAIChatClient[FoundryChatOptionsT], Generic[FoundryChatOptionsT], @@ -390,17 +390,44 @@ def get_web_search_tool( # type: ignore[override] custom_search_configuration: dict[str, Any] | None = None, **kwargs: Any, ) -> WebSearchTool: - """Create a web search tool configuration for Microsoft Foundry. + """Create a Web Search tool configuration for Microsoft Foundry. + + **Choosing a web grounding tool.** Foundry exposes three options that all reach + the public web via Bing. Pick the one that matches your scenario: + + * :py:meth:`get_web_search_tool` (this one, GA) — recommended starting point. + The Bing resource is managed by Microsoft, no extra Azure setup is required, + and only Azure OpenAI models are supported. Parameters are limited to + ``user_location`` and ``search_context_size``. + * :py:meth:`get_bing_grounding_tool` (preview) — use when you need finer Bing parameters (``count``, + ``freshness``, ``market``, ``set_lang``), want to ground non-OpenAI + Foundry models, or are migrating from Grounding with Bing Search on the + classic agents platform. You manage the Grounding with Bing Search + resource yourself (Contributor/Owner to create the resource, Foundry + Project Manager to wire the connection). + * :py:meth:`get_bing_custom_search_tool` (preview) — use when you need to + restrict grounding to a curated set of domains defined in a Bing Custom + Search instance. + + For all three, search data flows outside the Azure compliance boundary. See + https://learn.microsoft.com/azure/foundry/agents/how-to/tools/web-overview for + the full comparison. Keyword Args: - user_location: Location context with keys like "city", "country", "region", "timezone". - search_context_size: Amount of context from search results ("low", "medium", "high"). - allowed_domains: List of domains to restrict search results to. - custom_search_configuration: Custom Bing search configuration. - **kwargs: Additional arguments passed to the SDK WebSearchTool constructor. + user_location: Location context with keys like ``"city"``, ``"country"``, + ``"region"``, ``"timezone"``. + search_context_size: Amount of context from search results + (``"low"``, ``"medium"``, ``"high"``). + allowed_domains: List of domains to restrict search results to. Wrapped + into ``WebSearchToolFilters`` and passed as the ``filters`` field on + the SDK ``WebSearchTool``. + custom_search_configuration: Custom Bing search configuration for + domain-restricted scenarios. + **kwargs: Additional arguments passed to the SDK ``WebSearchTool`` + constructor. Returns: - A WebSearchTool ready to pass to an Agent. + A ``WebSearchTool`` ready to pass to an Agent. """ ws_kwargs: dict[str, Any] = {**kwargs} if search_context_size: @@ -409,15 +436,137 @@ def get_web_search_tool( # type: ignore[override] ws_kwargs["filters"] = WebSearchToolFilters(allowed_domains=allowed_domains) if custom_search_configuration: ws_kwargs["custom_search_configuration"] = custom_search_configuration - ws_tool = WebSearchTool(**ws_kwargs) if user_location: - ws_tool.user_location = WebSearchApproximateLocation( + ws_kwargs["user_location"] = WebSearchApproximateLocation( city=user_location.get("city"), country=user_location.get("country"), region=user_location.get("region"), timezone=user_location.get("timezone"), ) - return ws_tool + return WebSearchTool(**ws_kwargs) + + @staticmethod + @experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS) + def get_bing_grounding_tool( + *, + connection_id: str, + market: str | None = None, + set_lang: str | None = None, + count: int | None = None, + freshness: str | None = None, + **kwargs: Any, + ) -> BingGroundingTool: + """Create a Grounding with Bing Search tool configuration for Foundry. + + Use this factory when :py:meth:`get_web_search_tool` is too restrictive — for + example when you need ``count``/``freshness``/``market``/``set_lang`` + parameters, want to ground a non-OpenAI Foundry model, or are migrating an + agent that already uses Grounding with Bing Search on the classic agents + platform. You manage the Grounding with Bing Search Azure resource yourself + (Contributor or Owner to create the resource, Foundry Project Manager to + create the project connection). Search data flows outside the Azure + compliance boundary. + + For domain-restricted grounding to a curated allow-list, use + :py:meth:`get_bing_custom_search_tool` instead. For a zero-setup default that + works for most agents, see :py:meth:`get_web_search_tool`. The full + comparison lives at + https://learn.microsoft.com/azure/foundry/agents/how-to/tools/web-overview. + + Keyword Args: + connection_id: The Foundry project connection ID for the Grounding with + Bing Search resource. + market: Optional Bing market identifier (e.g. ``"en-US"``). + set_lang: Optional UI language code passed to the Bing API. + count: Optional number of search results to return. + freshness: Optional time-range filter for search results. See + https://learn.microsoft.com/bing/search-apis/bing-web-search/reference/query-parameters + for accepted values. + **kwargs: Additional arguments forwarded to the SDK + ``BingGroundingSearchConfiguration``. + + Returns: + A ``BingGroundingTool`` ready to pass to an Agent. + """ + config_kwargs: dict[str, Any] = { + **kwargs, + "project_connection_id": connection_id, + } + if market is not None: + config_kwargs["market"] = market + if set_lang is not None: + config_kwargs["set_lang"] = set_lang + if count is not None: + config_kwargs["count"] = count + if freshness is not None: + config_kwargs["freshness"] = freshness + return BingGroundingTool( + bing_grounding=BingGroundingSearchToolParameters( + search_configurations=[BingGroundingSearchConfiguration(**config_kwargs)], + ), + ) + + @staticmethod + @experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS) + def get_bing_custom_search_tool( + *, + connection_id: str, + instance_name: str, + market: str | None = None, + set_lang: str | None = None, + count: int | None = None, + freshness: str | None = None, + **kwargs: Any, + ) -> BingCustomSearchPreviewTool: + """Create a Grounding with Bing Custom Search tool configuration for Foundry. + + Use this factory (preview) when you need to restrict grounding to a curated + list of domains. The allow/block list is defined ahead of time on a Bing + Custom Search resource (in the Bing portal) and referenced here by + ``instance_name``. Like the other Bing-backed tools, search data flows + outside the Azure compliance boundary, and you must create the Bing Custom + Search resource yourself. + + For unrestricted public-web grounding with no extra Azure setup, prefer + :py:meth:`get_web_search_tool`. For unrestricted grounding with finer Bing + parameters or non-OpenAI models, prefer :py:meth:`get_bing_grounding_tool`. + See + https://learn.microsoft.com/azure/foundry/agents/how-to/tools/web-overview + for the full comparison. + + Keyword Args: + connection_id: The Foundry project connection ID for the Grounding with + Bing Custom Search resource. + instance_name: The custom configuration instance name defined on the + Bing Custom Search resource. + market: Optional Bing market identifier (e.g. ``"en-US"``). + set_lang: Optional UI language code passed to the Bing API. + count: Optional number of search results to return. + freshness: Optional time-range filter for search results. + **kwargs: Additional arguments forwarded to the SDK + ``BingCustomSearchConfiguration``. + + Returns: + A ``BingCustomSearchPreviewTool`` ready to pass to an Agent. + """ + config_kwargs: dict[str, Any] = { + **kwargs, + "project_connection_id": connection_id, + "instance_name": instance_name, + } + if market is not None: + config_kwargs["market"] = market + if set_lang is not None: + config_kwargs["set_lang"] = set_lang + if count is not None: + config_kwargs["count"] = count + if freshness is not None: + config_kwargs["freshness"] = freshness + return BingCustomSearchPreviewTool( + bing_custom_search_preview=BingCustomSearchToolParameters( + search_configurations=[BingCustomSearchConfiguration(**config_kwargs)], + ), + ) @staticmethod def get_image_generation_tool( # type: ignore[override] @@ -535,7 +684,7 @@ def get_azure_ai_search_tool( filter: str | None = None, index_asset_id: str | None = None, **kwargs: Any, - ) -> Any: + ) -> AzureAISearchTool: """Create an Azure AI Search tool configuration for Foundry. Keyword Args: @@ -551,9 +700,6 @@ def get_azure_ai_search_tool( Returns: An ``AzureAISearchTool`` ready to pass to an Agent. """ - tool_cls = _require_sdk_class("AzureAISearchTool") - resource_cls = _require_sdk_class("AzureAISearchToolResource") - index_cls = _require_sdk_class("AISearchIndexResource") index_kwargs: dict[str, Any] = { **kwargs, "project_connection_id": index_connection_id, @@ -567,15 +713,17 @@ def get_azure_ai_search_tool( index_kwargs["filter"] = filter if index_asset_id is not None: index_kwargs["index_asset_id"] = index_asset_id - return tool_cls(azure_ai_search=resource_cls(indexes=[index_cls(**index_kwargs)])) + return AzureAISearchTool( + azure_ai_search=AzureAISearchToolResource(indexes=[AISearchIndexResource(**index_kwargs)]), + ) @staticmethod - @experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS) + @experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS) def get_sharepoint_tool( *, connection_id: str, **kwargs: Any, - ) -> Any: + ) -> SharepointPreviewTool: """Create a SharePoint grounding tool configuration for Foundry. Keyword Args: @@ -586,23 +734,20 @@ def get_sharepoint_tool( Returns: A ``SharepointPreviewTool`` ready to pass to an Agent. """ - tool_cls = _require_sdk_class("SharepointPreviewTool") - params_cls = _require_sdk_class("SharepointGroundingToolParameters") - connection_cls = _require_sdk_class("ToolProjectConnection") - return tool_cls( - sharepoint_grounding_preview=params_cls( - project_connections=[connection_cls(project_connection_id=connection_id)], + return SharepointPreviewTool( + sharepoint_grounding_preview=SharepointGroundingToolParameters( + project_connections=[ToolProjectConnection(project_connection_id=connection_id)], **kwargs, ) ) @staticmethod - @experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS) + @experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS) def get_fabric_tool( *, connection_id: str, **kwargs: Any, - ) -> Any: + ) -> MicrosoftFabricPreviewTool: """Create a Microsoft Fabric data agent tool configuration for Foundry. Keyword Args: @@ -613,18 +758,15 @@ def get_fabric_tool( Returns: A ``MicrosoftFabricPreviewTool`` ready to pass to an Agent. """ - tool_cls = _require_sdk_class("MicrosoftFabricPreviewTool") - params_cls = _require_sdk_class("FabricDataAgentToolParameters") - connection_cls = _require_sdk_class("ToolProjectConnection") - return tool_cls( - fabric_dataagent_preview=params_cls( - project_connections=[connection_cls(project_connection_id=connection_id)], + return MicrosoftFabricPreviewTool( + fabric_dataagent_preview=FabricDataAgentToolParameters( + project_connections=[ToolProjectConnection(project_connection_id=connection_id)], **kwargs, ) ) @staticmethod - @experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS) + @experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS) def get_memory_search_tool( *, memory_store_name: str, @@ -632,7 +774,7 @@ def get_memory_search_tool( search_options: Any | None = None, update_delay: int | None = None, **kwargs: Any, - ) -> Any: + ) -> MemorySearchPreviewTool: """Create a Memory Search tool configuration for Foundry. Keyword Args: @@ -646,7 +788,6 @@ def get_memory_search_tool( Returns: A ``MemorySearchPreviewTool`` ready to pass to an Agent. """ - tool_cls = _require_sdk_class("MemorySearchPreviewTool") params: dict[str, Any] = { **kwargs, "memory_store_name": memory_store_name, @@ -656,17 +797,17 @@ def get_memory_search_tool( params["search_options"] = search_options if update_delay is not None: params["update_delay"] = update_delay - return tool_cls(**params) + return MemorySearchPreviewTool(**params) @staticmethod - @experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS) + @experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS) def get_computer_use_tool( *, environment: str, display_width: int, display_height: int, **kwargs: Any, - ) -> Any: + ) -> ComputerUsePreviewTool: """Create a Computer Use tool configuration for Foundry. Keyword Args: @@ -679,8 +820,7 @@ def get_computer_use_tool( Returns: A ``ComputerUsePreviewTool`` ready to pass to an Agent. """ - tool_cls = _require_sdk_class("ComputerUsePreviewTool") - return tool_cls( + return ComputerUsePreviewTool( environment=environment, display_width=display_width, display_height=display_height, @@ -688,12 +828,12 @@ def get_computer_use_tool( ) @staticmethod - @experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS) + @experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS) def get_browser_automation_tool( *, connection_id: str, **kwargs: Any, - ) -> Any: + ) -> BrowserAutomationPreviewTool: """Create a Browser Automation tool configuration for Foundry. Keyword Args: @@ -704,71 +844,22 @@ def get_browser_automation_tool( Returns: A ``BrowserAutomationPreviewTool`` ready to pass to an Agent. """ - tool_cls = _require_sdk_class("BrowserAutomationPreviewTool") - params_cls = _require_sdk_class("BrowserAutomationToolParameters") - connection_cls = _require_sdk_class("BrowserAutomationToolConnectionParameters") - return tool_cls( - browser_automation_preview=params_cls( - connection=connection_cls(project_connection_id=connection_id), + return BrowserAutomationPreviewTool( + browser_automation_preview=BrowserAutomationToolParameters( + connection=BrowserAutomationToolConnectionParameters(project_connection_id=connection_id), **kwargs, ) ) @staticmethod - @experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS) - def get_bing_custom_search_tool( - *, - connection_id: str, - instance_name: str, - market: str | None = None, - set_lang: str | None = None, - count: int | None = None, - freshness: str | None = None, - **kwargs: Any, - ) -> Any: - """Create a Bing Custom Search tool configuration for Foundry. - - Keyword Args: - connection_id: The Foundry project connection ID for grounding with Bing search. - instance_name: The custom configuration instance name. - market: Optional Bing market identifier (e.g. ``"en-US"``). - set_lang: Optional UI language code passed to the Bing API. - count: Optional number of search results to return. - freshness: Optional time-range filter for search results. - **kwargs: Additional arguments forwarded to the SDK - ``BingCustomSearchConfiguration``. - - Returns: - A ``BingCustomSearchPreviewTool`` ready to pass to an Agent. - """ - tool_cls = _require_sdk_class("BingCustomSearchPreviewTool") - params_cls = _require_sdk_class("BingCustomSearchToolParameters") - config_cls = _require_sdk_class("BingCustomSearchConfiguration") - config_kwargs: dict[str, Any] = { - **kwargs, - "project_connection_id": connection_id, - "instance_name": instance_name, - } - if market is not None: - config_kwargs["market"] = market - if set_lang is not None: - config_kwargs["set_lang"] = set_lang - if count is not None: - config_kwargs["count"] = count - if freshness is not None: - config_kwargs["freshness"] = freshness - search_params = params_cls(search_configurations=[config_cls(**config_kwargs)]) - return tool_cls(bing_custom_search_preview=search_params) - - @staticmethod - @experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS) + @experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS) def get_a2a_tool( *, base_url: str | None = None, agent_card_path: str | None = None, project_connection_id: str | None = None, **kwargs: Any, - ) -> Any: + ) -> A2APreviewTool: """Create an Agent-to-Agent (A2A) tool configuration for Foundry. Keyword Args: @@ -782,7 +873,6 @@ def get_a2a_tool( Returns: An ``A2APreviewTool`` ready to pass to an Agent. """ - tool_cls = _require_sdk_class("A2APreviewTool") params: dict[str, Any] = dict(kwargs) if base_url is not None: params["base_url"] = base_url @@ -790,7 +880,7 @@ def get_a2a_tool( params["agent_card_path"] = agent_card_path if project_connection_id is not None: params["project_connection_id"] = project_connection_id - return tool_cls(**params) + return A2APreviewTool(**params) # endregion diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index fd1941823b..5f0e34bc13 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -5,6 +5,7 @@ import inspect import os import sys +import warnings from functools import wraps from pathlib import Path from typing import Annotated, Any @@ -984,6 +985,25 @@ def test_get_web_search_tool_with_location() -> None: assert tool_obj is not None +def test_get_web_search_tool_allowed_domains() -> None: + """allowed_domains is wrapped into the SDK filters field.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + tool_obj = RawFoundryChatClient.get_web_search_tool(allowed_domains=["example.com"]) + assert tool_obj.filters is not None + assert tool_obj.filters.allowed_domains == ["example.com"] + + +def test_get_web_search_tool_custom_search_configuration() -> None: + """custom_search_configuration is forwarded to the SDK without warning.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + tool_obj = RawFoundryChatClient.get_web_search_tool( + custom_search_configuration={"connection_id": "c", "instance_name": "i"}, + ) + assert tool_obj.custom_search_configuration == {"connection_id": "c", "instance_name": "i"} + + def test_get_image_generation_tool() -> None: """Test image generation tool creation.""" @@ -1134,6 +1154,29 @@ def test_get_bing_custom_search_tool() -> None: assert config.count == 10 +@pytest.mark.filterwarnings("ignore::FutureWarning") +def test_get_bing_grounding_tool() -> None: + """Bing grounding tool factory builds the nested search configuration.""" + bing_tool_cls = _skip_if_sdk_class_missing("BingGroundingTool") + + tool_obj = FoundryChatClient.get_bing_grounding_tool( + connection_id="bing-conn", + market="en-US", + set_lang="en", + count=10, + freshness="Day", + ) + assert isinstance(tool_obj, bing_tool_cls) + configs = tool_obj.bing_grounding.search_configurations + assert len(configs) == 1 + config = configs[0] + assert config.project_connection_id == "bing-conn" + assert config.market == "en-US" + assert config.set_lang == "en" + assert config.count == 10 + assert config.freshness == "Day" + + @pytest.mark.filterwarnings("ignore::FutureWarning") def test_get_a2a_tool() -> None: """A2A tool factory carries base_url, agent_card_path, and project_connection_id.""" @@ -1150,8 +1193,16 @@ def test_get_a2a_tool() -> None: assert tool_obj.project_connection_id == "a2a-conn" -_EXPERIMENTAL_FACTORY_CASES: list[tuple[str, str, dict[str, Any]]] = [ +_FOUNDRY_TOOLS_FACTORY_CASES: list[tuple[str, str, dict[str, Any]]] = [ ("get_azure_ai_search_tool", "AzureAISearchTool", {"index_connection_id": "c", "index_name": "i"}), + ( + "get_bing_grounding_tool", + "BingGroundingTool", + {"connection_id": "c"}, + ), +] + +_FOUNDRY_PREVIEW_TOOLS_FACTORY_CASES: list[tuple[str, str, dict[str, Any]]] = [ ("get_sharepoint_tool", "SharepointPreviewTool", {"connection_id": "c"}), ("get_fabric_tool", "MicrosoftFabricPreviewTool", {"connection_id": "c"}), ( @@ -1175,35 +1226,27 @@ def test_get_a2a_tool() -> None: @pytest.mark.filterwarnings("ignore::FutureWarning") -@pytest.mark.parametrize("factory_name, sdk_class_name, kwargs", _EXPERIMENTAL_FACTORY_CASES) -def test_experimental_foundry_tool_factories_are_marked( - factory_name: str, sdk_class_name: str, kwargs: dict[str, Any] -) -> None: - """Each new factory carries the FOUNDRY_TOOLS experimental feature metadata.""" +@pytest.mark.parametrize("factory_name, sdk_class_name, kwargs", _FOUNDRY_TOOLS_FACTORY_CASES) +def test_foundry_tools_factories_are_marked(factory_name: str, sdk_class_name: str, kwargs: dict[str, Any]) -> None: + """Factories wrapping GA Foundry tool SDK classes carry FOUNDRY_TOOLS metadata.""" _skip_if_sdk_class_missing(sdk_class_name) factory = getattr(FoundryChatClient, factory_name) - # `@staticmethod` + `@experimental` wraps the underlying function; metadata sits on it. assert getattr(factory, "__feature_stage__", None) == "experimental" assert getattr(factory, "__feature_id__", None) == "FOUNDRY_TOOLS" - # The factory must be invokable without a FoundryChatClient instance. assert factory(**kwargs) is not None -def test_get_azure_ai_search_tool_requires_sdk_class(monkeypatch: pytest.MonkeyPatch) -> None: - """A missing preview class raises a clear ImportError, not AttributeError. - - Uses ``setattr(..., None, raising=False)`` rather than ``delattr`` so the - test is robust against modules that implement PEP 562 ``__getattr__`` and - would otherwise rehydrate the attribute on access. - """ - from azure.ai.projects import models as projects_models - - monkeypatch.setattr(projects_models, "AzureAISearchTool", None, raising=False) - with pytest.raises(ImportError, match="AzureAISearchTool"): - FoundryChatClient.get_azure_ai_search_tool( - index_connection_id="c", - index_name="i", - ) +@pytest.mark.filterwarnings("ignore::FutureWarning") +@pytest.mark.parametrize("factory_name, sdk_class_name, kwargs", _FOUNDRY_PREVIEW_TOOLS_FACTORY_CASES) +def test_foundry_preview_tools_factories_are_marked( + factory_name: str, sdk_class_name: str, kwargs: dict[str, Any] +) -> None: + """Factories wrapping preview Foundry tool SDK classes carry FOUNDRY_PREVIEW_TOOLS metadata.""" + _skip_if_sdk_class_missing(sdk_class_name) + factory = getattr(FoundryChatClient, factory_name) + assert getattr(factory, "__feature_stage__", None) == "experimental" + assert getattr(factory, "__feature_id__", None) == "FOUNDRY_PREVIEW_TOOLS" + assert factory(**kwargs) is not None def test_parse_chunk_surfaces_oauth_consent_request() -> None: diff --git a/python/uv.lock b/python/uv.lock index 1b932afa8c..b6436f951b 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -603,7 +603,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0b2,<=1.0.0b2" }, ] [[package]]