Skip to content

Commit 04aaf0c

Browse files
moonbox3alliscode
andauthored
Python: Add support for Foundry Toolboxes (#5346)
* Add support for the Foundry Toolbox in MAF Introduces a Foundry Toolbox integration: FoundryChatClient gains a get_toolbox() helper plus select_toolbox_tools(), normalize_tools in the core package flattens tool-collection wrappers (ToolboxVersionObject and generic iterables, while leaving Pydantic BaseModel instances alone), and the new agent_framework.foundry namespace re-exports the toolbox helpers. Ships with unit tests, a sample, and a design doc. azure-ai-projects is pinned to the public >=2.0.0,<3.0 range and the lockfile resolves from public PyPI. The toolbox test module skips when Toolbox* types are unavailable so CI stays green until the public 2.1.0 SDK lands. OMC tooling directories (.omc/, .omx/) are gitignored. * Update to latest azure ai projects package * Improve sample * Rename ADR to 0025 * Update ADR * Apply suggestion from @alliscode Co-authored-by: Ben Thomas <ben.thomas@microsoft.com> * Improve samples * Update test --------- Co-authored-by: Ben Thomas <ben.thomas@microsoft.com>
1 parent 3e54a68 commit 04aaf0c

21 files changed

Lines changed: 1980 additions & 6 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ temp*/
203203

204204
# AI
205205
.claude/
206+
.omc/
207+
.omx/
206208
WARP.md
207209
**/memory-bank/
208210
**/projectBrief.md

docs/decisions/0025-foundry-toolbox-support.md

Lines changed: 454 additions & 0 deletions
Large diffs are not rendered by default.

python/packages/core/agent_framework/_feature_stage.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ class ExperimentalFeature(str, Enum):
4949
EVALS = "EVALS"
5050
FILE_HISTORY = "FILE_HISTORY"
5151
SKILLS = "SKILLS"
52+
TOOLBOXES = "TOOLBOXES"
5253

5354

5455
class ReleaseCandidateFeature(str, Enum):

python/packages/core/agent_framework/_tools.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
AsyncIterable,
1313
Awaitable,
1414
Callable,
15+
Iterable,
1516
Mapping,
1617
Sequence,
1718
)
@@ -859,6 +860,15 @@ def normalize_tools(
859860
Returns:
860861
A normalized list where callable inputs are converted to ``FunctionTool``
861862
using :func:`tool`, and existing tool objects are passed through unchanged.
863+
864+
Tool-collection wrappers are flattened in two forms:
865+
866+
- non-tool, non-callable iterables
867+
- mapping-like objects that expose a ``.tools`` collection (for example
868+
``ToolboxVersionObject`` from azure-ai-projects)
869+
870+
This lets callers write ``tools=[toolbox, my_func]`` and have the
871+
toolbox's contents spread in alongside individual tools.
862872
"""
863873
if not tools:
864874
return []
@@ -883,6 +893,24 @@ def normalize_tools(
883893
if callable(tool_item): # type: ignore[reportUnknownArgumentType]
884894
normalized.append(tool(tool_item))
885895
continue
896+
# Mapping-like tool collections (for example ToolboxVersionObject) are
897+
# not flattened by the generic Iterable branch below because they are
898+
# also Mapping instances. If they expose a ``tools`` collection, spread
899+
# that collection into the normalized list.
900+
collection_tools = getattr(tool_item, "tools", None) # type: ignore[reportUnknownArgumentType]
901+
if isinstance(collection_tools, Iterable) and not isinstance(
902+
collection_tools, (str, bytes, bytearray, Mapping)
903+
):
904+
normalized.extend(normalize_tools(list(collection_tools))) # type: ignore[reportUnknownArgumentType]
905+
continue
906+
# Tool-collection wrapper (e.g. FoundryToolbox): a non-tool, non-callable
907+
# iterable. Flatten its contents so ``tools=[toolbox, my_func]`` works.
908+
# Strings, mappings, and Pydantic BaseModel are excluded — BaseModel
909+
# instances iterate over (field, value) tuples, not tools, so they
910+
# should pass through as leaf tool specs (handled below).
911+
if isinstance(tool_item, Iterable) and not isinstance(tool_item, (str, bytes, bytearray, Mapping, BaseModel)):
912+
normalized.extend(normalize_tools(list(tool_item))) # type: ignore[reportUnknownArgumentType]
913+
continue
886914
normalized.append(tool_item) # type: ignore[reportUnknownArgumentType]
887915
return normalized
888916

python/packages/core/agent_framework/foundry/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"FoundryEmbeddingOptions": ("agent_framework_foundry", "agent-framework-foundry"),
2121
"FoundryEmbeddingSettings": ("agent_framework_foundry", "agent-framework-foundry"),
2222
"FoundryEvals": ("agent_framework_foundry", "agent-framework-foundry"),
23+
"FoundryHostedToolType": ("agent_framework_foundry", "agent-framework-foundry"),
2324
"FoundryMemoryProvider": ("agent_framework_foundry", "agent-framework-foundry"),
2425
"FoundryLocalChatOptions": ("agent_framework_foundry_local", "agent-framework-foundry-local"),
2526
"FoundryLocalClient": ("agent_framework_foundry_local", "agent-framework-foundry-local"),
@@ -31,6 +32,9 @@
3132
"RawFoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"),
3233
"evaluate_foundry_target": ("agent_framework_foundry", "agent-framework-foundry"),
3334
"evaluate_traces": ("agent_framework_foundry", "agent-framework-foundry"),
35+
"get_toolbox_tool_name": ("agent_framework_foundry", "agent-framework-foundry"),
36+
"get_toolbox_tool_type": ("agent_framework_foundry", "agent-framework-foundry"),
37+
"select_toolbox_tools": ("agent_framework_foundry", "agent-framework-foundry"),
3438
}
3539

3640

python/packages/core/agent_framework/foundry/__init__.pyi

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@ from agent_framework_foundry import (
1212
FoundryEmbeddingOptions,
1313
FoundryEmbeddingSettings,
1414
FoundryEvals,
15+
FoundryHostedToolType,
1516
FoundryMemoryProvider,
1617
RawFoundryAgent,
1718
RawFoundryAgentChatClient,
1819
RawFoundryChatClient,
1920
RawFoundryEmbeddingClient,
2021
evaluate_foundry_target,
2122
evaluate_traces,
23+
get_toolbox_tool_name,
24+
get_toolbox_tool_type,
25+
select_toolbox_tools,
2226
)
2327
from agent_framework_foundry_local import (
2428
FoundryLocalChatOptions,
@@ -35,6 +39,7 @@ __all__ = [
3539
"FoundryEmbeddingOptions",
3640
"FoundryEmbeddingSettings",
3741
"FoundryEvals",
42+
"FoundryHostedToolType",
3843
"FoundryLocalChatOptions",
3944
"FoundryLocalClient",
4045
"FoundryLocalSettings",
@@ -46,4 +51,7 @@ __all__ = [
4651
"RawFoundryEmbeddingClient",
4752
"evaluate_foundry_target",
4853
"evaluate_traces",
54+
"get_toolbox_tool_name",
55+
"get_toolbox_tool_type",
56+
"select_toolbox_tools",
4957
]

python/packages/core/tests/core/test_tools.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,3 +1144,160 @@ def test_parse_annotation_with_annotated_and_literal():
11441144

11451145

11461146
# endregion
1147+
1148+
1149+
# region normalize_tools flattening of tool-collection wrappers
1150+
1151+
1152+
def _make_flatten_function_tool(name: str) -> FunctionTool:
1153+
"""Build a FunctionTool for flattening tests."""
1154+
1155+
@tool(name=name, description=f"{name} tool")
1156+
def _impl(x: int) -> int:
1157+
return x
1158+
1159+
return _impl # type: ignore[return-value]
1160+
1161+
1162+
def test_normalize_tools_flattens_tool_collection_wrapper() -> None:
1163+
"""A non-tool, non-callable iterable inside the tools list is flattened."""
1164+
from agent_framework._tools import normalize_tools
1165+
1166+
inner_a = _make_flatten_function_tool("inner_a")
1167+
inner_b = _make_flatten_function_tool("inner_b")
1168+
1169+
class ToolBundle:
1170+
"""Minimal stand-in for a tool-collection wrapper like FoundryToolbox."""
1171+
1172+
def __init__(self, tools: list[FunctionTool]) -> None:
1173+
self._tools = tools
1174+
1175+
def __iter__(self):
1176+
return iter(self._tools)
1177+
1178+
bundle = ToolBundle([inner_a, inner_b])
1179+
1180+
normalized = normalize_tools([bundle])
1181+
1182+
assert len(normalized) == 2
1183+
assert normalized[0] is inner_a
1184+
assert normalized[1] is inner_b
1185+
1186+
1187+
def test_normalize_tools_combines_bundle_with_individual_tools() -> None:
1188+
"""The canonical ``tools=[bundle, my_func]`` call site spreads bundle + individual."""
1189+
from agent_framework._tools import normalize_tools
1190+
1191+
bundled = _make_flatten_function_tool("bundled")
1192+
standalone = _make_flatten_function_tool("standalone")
1193+
1194+
class ToolBundle:
1195+
def __init__(self, tools: list[FunctionTool]) -> None:
1196+
self._tools = tools
1197+
1198+
def __iter__(self):
1199+
return iter(self._tools)
1200+
1201+
normalized = normalize_tools([ToolBundle([bundled]), standalone])
1202+
1203+
assert len(normalized) == 2
1204+
assert normalized[0] is bundled
1205+
assert normalized[1] is standalone
1206+
1207+
1208+
def test_normalize_tools_flattens_nested_bundles() -> None:
1209+
"""Bundles inside bundles are flattened recursively via the recursive call."""
1210+
from agent_framework._tools import normalize_tools
1211+
1212+
inner = _make_flatten_function_tool("deep")
1213+
1214+
class ToolBundle:
1215+
def __init__(self, tools: list[Any]) -> None:
1216+
self._tools = tools
1217+
1218+
def __iter__(self):
1219+
return iter(self._tools)
1220+
1221+
nested = ToolBundle([ToolBundle([inner])])
1222+
1223+
normalized = normalize_tools([nested])
1224+
1225+
assert len(normalized) == 1
1226+
assert normalized[0] is inner
1227+
1228+
1229+
def test_normalize_tools_bundle_only_form() -> None:
1230+
"""Passing a bundle directly (no outer list) also flattens its contents.
1231+
1232+
``tools=bundle`` — the outer wrap-in-list happens in the non-Sequence
1233+
branch, then the flattening logic kicks in on the inner pass.
1234+
"""
1235+
from agent_framework._tools import normalize_tools
1236+
1237+
a = _make_flatten_function_tool("a")
1238+
b = _make_flatten_function_tool("b")
1239+
1240+
class ToolBundle:
1241+
def __init__(self, tools: list[FunctionTool]) -> None:
1242+
self._tools = tools
1243+
1244+
def __iter__(self):
1245+
return iter(self._tools)
1246+
1247+
normalized = normalize_tools(ToolBundle([a, b])) # type: ignore[arg-type]
1248+
1249+
assert len(normalized) == 2
1250+
assert normalized[0] is a
1251+
assert normalized[1] is b
1252+
1253+
1254+
def test_normalize_tools_does_not_flatten_known_tool_types() -> None:
1255+
"""FunctionTool / dict / callable are detected before the flatten branch."""
1256+
from agent_framework._tools import normalize_tools
1257+
1258+
func_tool = _make_flatten_function_tool("ft")
1259+
dict_tool: dict[str, Any] = {"type": "code_interpreter", "container": {"type": "auto"}}
1260+
1261+
def plain_callable(x: int) -> int:
1262+
return x
1263+
1264+
normalized = normalize_tools([func_tool, dict_tool, plain_callable])
1265+
1266+
assert len(normalized) == 3
1267+
assert normalized[0] is func_tool
1268+
assert normalized[1] is dict_tool
1269+
# plain_callable was wrapped in a FunctionTool via the @tool helper
1270+
assert isinstance(normalized[2], FunctionTool)
1271+
1272+
1273+
def test_normalize_tools_flattens_mapping_like_toolbox_with_tools_attr() -> None:
1274+
"""Mapping-like toolbox objects with ``.tools`` should still flatten."""
1275+
from collections.abc import Mapping as MappingABC
1276+
1277+
from agent_framework._tools import normalize_tools
1278+
1279+
bundled = _make_flatten_function_tool("bundled")
1280+
standalone = _make_flatten_function_tool("standalone")
1281+
1282+
class ToolBundleMapping(MappingABC[str, Any]):
1283+
def __init__(self, tools: list[FunctionTool]) -> None:
1284+
self.tools = tools
1285+
self._data = {"name": "research_tools", "version": "v1", "tools": tools}
1286+
1287+
def __getitem__(self, key: str) -> Any:
1288+
return self._data[key]
1289+
1290+
def __iter__(self):
1291+
return iter(self._data)
1292+
1293+
def __len__(self) -> int:
1294+
return len(self._data)
1295+
1296+
normalized = normalize_tools([ToolBundleMapping([bundled]), standalone])
1297+
1298+
assert len(normalized) == 2
1299+
assert normalized[0] is bundled
1300+
assert normalized[1] is standalone
1301+
1302+
1303+
# endregion

python/packages/foundry/README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,66 @@
11
# Agent Framework Foundry
22

33
This package contains the Microsoft Foundry integrations for Microsoft Agent Framework, including Foundry chat clients, preconfigured Foundry agents, Foundry embedding clients, and Foundry memory providers.
4+
5+
## Toolboxes
6+
7+
A *toolbox* is a named, versioned bundle of hosted tool configurations — code interpreter, file search, image generation, MCP, web search, and so on — stored inside a Microsoft Foundry project. Toolboxes let you manage tool configuration once and reuse it across agents.
8+
9+
### Authoring a toolbox
10+
11+
Toolboxes can be authored two ways:
12+
13+
- **Foundry portal** — create and version toolboxes through the UI without touching code.
14+
- **Programmatically** — use the [`azure-ai-projects`](https://pypi.org/project/azure-ai-projects/) SDK to create, update, and version toolboxes from Python.
15+
16+
> Toolbox authoring APIs (`ToolboxVersionObject`, `ToolboxObject`, `project_client.beta.toolboxes.*`) require `azure-ai-projects>=2.1.0`. Earlier versions can only consume toolboxes that already exist.
17+
18+
### Using toolboxes with `FoundryAgent`
19+
20+
For hosted `FoundryAgent`, the toolbox must already be attached to the agent in the Microsoft Foundry project. Once attached, the agent invokes its toolbox tools transparently — no client-side wiring required — and you interact with the agent the same way you would with any other tool-equipped Foundry agent.
21+
22+
### Using toolboxes with `FoundryChatClient`
23+
24+
There are two patterns for wiring a toolbox into a `FoundryChatClient`-backed agent.
25+
26+
**1. Fetch, optionally filter, and pass the tools directly**
27+
28+
Load the toolbox from the Microsoft Foundry project, optionally select a subset of its tools, and hand them to an `Agent` alongside any other tools you own:
29+
30+
```python
31+
from agent_framework import Agent
32+
from agent_framework.foundry import FoundryChatClient, select_toolbox_tools
33+
34+
client = FoundryChatClient(...)
35+
toolbox = await client.get_toolbox("my-toolbox", version="3")
36+
37+
# Pass the whole toolbox:
38+
agent = Agent(client=client, tools=toolbox)
39+
40+
# Or filter to a subset first:
41+
selected = select_toolbox_tools(toolbox, include_types=["code_interpreter", "mcp"])
42+
agent = Agent(client=client, tools=selected)
43+
```
44+
45+
See [`foundry_chat_client_with_toolbox.py`](../../samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py) for a full example, including combining multiple toolboxes.
46+
47+
**2. Connect to the toolbox's MCP endpoint with `MCPStreamableHTTPTool`**
48+
49+
Each toolbox is reachable as an MCP server. Instead of fetching and fanning out its individual tool definitions, you can point a MAF `MCPStreamableHTTPTool` at the toolbox's MCP endpoint — the agent then discovers and calls its tools over MCP at runtime:
50+
51+
```python
52+
from agent_framework import Agent, MCPStreamableHTTPTool
53+
from agent_framework.foundry import FoundryChatClient
54+
55+
async with Agent(
56+
client=FoundryChatClient(...),
57+
instructions="You are a helpful assistant. Use the toolbox tools when useful.",
58+
tools=MCPStreamableHTTPTool(
59+
name="my_toolbox",
60+
description="Tools served by my Foundry toolbox",
61+
url="https://<your-toolbox-mcp-endpoint>",
62+
),
63+
) as agent:
64+
result = await agent.run("What tools are available?")
65+
print(result.text)
66+
```

python/packages/foundry/agent_framework_foundry/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
evaluate_traces,
1717
)
1818
from ._memory_provider import FoundryMemoryProvider
19+
from ._tools import FoundryHostedToolType, get_toolbox_tool_name, get_toolbox_tool_type, select_toolbox_tools
1920

2021
try:
2122
__version__ = importlib.metadata.version(__name__)
@@ -30,6 +31,7 @@
3031
"FoundryEmbeddingOptions",
3132
"FoundryEmbeddingSettings",
3233
"FoundryEvals",
34+
"FoundryHostedToolType",
3335
"FoundryMemoryProvider",
3436
"RawFoundryAgent",
3537
"RawFoundryAgentChatClient",
@@ -38,4 +40,7 @@
3840
"__version__",
3941
"evaluate_foundry_target",
4042
"evaluate_traces",
43+
"get_toolbox_tool_name",
44+
"get_toolbox_tool_type",
45+
"select_toolbox_tools",
4146
]

python/packages/foundry/agent_framework_foundry/_agent.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
from azure.core.credentials import TokenCredential
3535
from azure.core.credentials_async import AsyncTokenCredential
3636

37+
from ._tools import sanitize_foundry_response_tool
38+
3739
if sys.version_info >= (3, 13):
3840
from typing import TypeVar # type: ignore # pragma: no cover
3941
else:
@@ -307,6 +309,20 @@ def _check_model_presence(self, options: dict[str, Any]) -> None:
307309
"""Skip model check — model is configured on the Foundry agent."""
308310
pass
309311

312+
@override
313+
def _prepare_tools_for_openai(
314+
self,
315+
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
316+
) -> list[Any]:
317+
"""Prepare tools for Foundry agent Responses API calls.
318+
319+
Mirrors ``RawFoundryChatClient`` sanitization so toolbox-fetched MCP
320+
tools with extra read-model fields continue to work through the agent
321+
surface.
322+
"""
323+
response_tools = super()._prepare_tools_for_openai(tools)
324+
return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
325+
310326
def _prepare_messages_for_azure_ai(self, messages: Sequence[Message]) -> tuple[list[Message], str | None]:
311327
"""Extract system/developer messages as instructions for Azure AI.
312328

0 commit comments

Comments
 (0)