Python: Add support for Foundry Toolboxes (#5346)

* Add support for the Foundry Toolbox in MAF

Introduces a Foundry Toolbox integration: FoundryChatClient gains a
get_toolbox() helper plus select_toolbox_tools(), normalize_tools in
the core package flattens tool-collection wrappers (ToolboxVersionObject
and generic iterables, while leaving Pydantic BaseModel instances
alone), and the new agent_framework.foundry namespace re-exports the
toolbox helpers. Ships with unit tests, a sample, and a design doc.

azure-ai-projects is pinned to the public >=2.0.0,<3.0 range and the
lockfile resolves from public PyPI. The toolbox test module skips when
Toolbox* types are unavailable so CI stays green until the public 2.1.0
SDK lands. OMC tooling directories (.omc/, .omx/) are gitignored.

* Update to latest azure ai projects package

* Improve sample

* Rename ADR to 0025

* Update ADR

* Apply suggestion from @alliscode

Co-authored-by: Ben Thomas <ben.thomas@microsoft.com>

* Improve samples

* Update test

---------

Co-authored-by: Ben Thomas <ben.thomas@microsoft.com>
This commit is contained in:
Evan Mattson
2026-04-21 08:56:01 +09:00
committed by GitHub
Unverified
parent 3e54a689fc
commit 04aaf0c1fe
21 changed files with 1980 additions and 6 deletions
@@ -49,6 +49,7 @@ class ExperimentalFeature(str, Enum):
EVALS = "EVALS"
FILE_HISTORY = "FILE_HISTORY"
SKILLS = "SKILLS"
TOOLBOXES = "TOOLBOXES"
class ReleaseCandidateFeature(str, Enum):
@@ -12,6 +12,7 @@ from collections.abc import (
AsyncIterable,
Awaitable,
Callable,
Iterable,
Mapping,
Sequence,
)
@@ -859,6 +860,15 @@ def normalize_tools(
Returns:
A normalized list where callable inputs are converted to ``FunctionTool``
using :func:`tool`, and existing tool objects are passed through unchanged.
Tool-collection wrappers are flattened in two forms:
- non-tool, non-callable iterables
- mapping-like objects that expose a ``.tools`` collection (for example
``ToolboxVersionObject`` from azure-ai-projects)
This lets callers write ``tools=[toolbox, my_func]`` and have the
toolbox's contents spread in alongside individual tools.
"""
if not tools:
return []
@@ -883,6 +893,24 @@ def normalize_tools(
if callable(tool_item): # type: ignore[reportUnknownArgumentType]
normalized.append(tool(tool_item))
continue
# Mapping-like tool collections (for example ToolboxVersionObject) are
# not flattened by the generic Iterable branch below because they are
# also Mapping instances. If they expose a ``tools`` collection, spread
# that collection into the normalized list.
collection_tools = getattr(tool_item, "tools", None) # type: ignore[reportUnknownArgumentType]
if isinstance(collection_tools, Iterable) and not isinstance(
collection_tools, (str, bytes, bytearray, Mapping)
):
normalized.extend(normalize_tools(list(collection_tools))) # type: ignore[reportUnknownArgumentType]
continue
# Tool-collection wrapper (e.g. FoundryToolbox): a non-tool, non-callable
# iterable. Flatten its contents so ``tools=[toolbox, my_func]`` works.
# Strings, mappings, and Pydantic BaseModel are excluded — BaseModel
# instances iterate over (field, value) tuples, not tools, so they
# should pass through as leaf tool specs (handled below).
if isinstance(tool_item, Iterable) and not isinstance(tool_item, (str, bytes, bytearray, Mapping, BaseModel)):
normalized.extend(normalize_tools(list(tool_item))) # type: ignore[reportUnknownArgumentType]
continue
normalized.append(tool_item) # type: ignore[reportUnknownArgumentType]
return normalized
@@ -20,6 +20,7 @@ _IMPORTS: dict[str, tuple[str, str]] = {
"FoundryEmbeddingOptions": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryEmbeddingSettings": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryEvals": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryHostedToolType": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryMemoryProvider": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryLocalChatOptions": ("agent_framework_foundry_local", "agent-framework-foundry-local"),
"FoundryLocalClient": ("agent_framework_foundry_local", "agent-framework-foundry-local"),
@@ -31,6 +32,9 @@ _IMPORTS: dict[str, tuple[str, str]] = {
"RawFoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"),
"evaluate_foundry_target": ("agent_framework_foundry", "agent-framework-foundry"),
"evaluate_traces": ("agent_framework_foundry", "agent-framework-foundry"),
"get_toolbox_tool_name": ("agent_framework_foundry", "agent-framework-foundry"),
"get_toolbox_tool_type": ("agent_framework_foundry", "agent-framework-foundry"),
"select_toolbox_tools": ("agent_framework_foundry", "agent-framework-foundry"),
}
@@ -12,6 +12,7 @@ from agent_framework_foundry import (
FoundryEmbeddingOptions,
FoundryEmbeddingSettings,
FoundryEvals,
FoundryHostedToolType,
FoundryMemoryProvider,
RawFoundryAgent,
RawFoundryAgentChatClient,
@@ -19,6 +20,9 @@ from agent_framework_foundry import (
RawFoundryEmbeddingClient,
evaluate_foundry_target,
evaluate_traces,
get_toolbox_tool_name,
get_toolbox_tool_type,
select_toolbox_tools,
)
from agent_framework_foundry_local import (
FoundryLocalChatOptions,
@@ -35,6 +39,7 @@ __all__ = [
"FoundryEmbeddingOptions",
"FoundryEmbeddingSettings",
"FoundryEvals",
"FoundryHostedToolType",
"FoundryLocalChatOptions",
"FoundryLocalClient",
"FoundryLocalSettings",
@@ -46,4 +51,7 @@ __all__ = [
"RawFoundryEmbeddingClient",
"evaluate_foundry_target",
"evaluate_traces",
"get_toolbox_tool_name",
"get_toolbox_tool_type",
"select_toolbox_tools",
]
@@ -1144,3 +1144,160 @@ def test_parse_annotation_with_annotated_and_literal():
# endregion
# region normalize_tools flattening of tool-collection wrappers
def _make_flatten_function_tool(name: str) -> FunctionTool:
"""Build a FunctionTool for flattening tests."""
@tool(name=name, description=f"{name} tool")
def _impl(x: int) -> int:
return x
return _impl # type: ignore[return-value]
def test_normalize_tools_flattens_tool_collection_wrapper() -> None:
"""A non-tool, non-callable iterable inside the tools list is flattened."""
from agent_framework._tools import normalize_tools
inner_a = _make_flatten_function_tool("inner_a")
inner_b = _make_flatten_function_tool("inner_b")
class ToolBundle:
"""Minimal stand-in for a tool-collection wrapper like FoundryToolbox."""
def __init__(self, tools: list[FunctionTool]) -> None:
self._tools = tools
def __iter__(self):
return iter(self._tools)
bundle = ToolBundle([inner_a, inner_b])
normalized = normalize_tools([bundle])
assert len(normalized) == 2
assert normalized[0] is inner_a
assert normalized[1] is inner_b
def test_normalize_tools_combines_bundle_with_individual_tools() -> None:
"""The canonical ``tools=[bundle, my_func]`` call site spreads bundle + individual."""
from agent_framework._tools import normalize_tools
bundled = _make_flatten_function_tool("bundled")
standalone = _make_flatten_function_tool("standalone")
class ToolBundle:
def __init__(self, tools: list[FunctionTool]) -> None:
self._tools = tools
def __iter__(self):
return iter(self._tools)
normalized = normalize_tools([ToolBundle([bundled]), standalone])
assert len(normalized) == 2
assert normalized[0] is bundled
assert normalized[1] is standalone
def test_normalize_tools_flattens_nested_bundles() -> None:
"""Bundles inside bundles are flattened recursively via the recursive call."""
from agent_framework._tools import normalize_tools
inner = _make_flatten_function_tool("deep")
class ToolBundle:
def __init__(self, tools: list[Any]) -> None:
self._tools = tools
def __iter__(self):
return iter(self._tools)
nested = ToolBundle([ToolBundle([inner])])
normalized = normalize_tools([nested])
assert len(normalized) == 1
assert normalized[0] is inner
def test_normalize_tools_bundle_only_form() -> None:
"""Passing a bundle directly (no outer list) also flattens its contents.
``tools=bundle`` — the outer wrap-in-list happens in the non-Sequence
branch, then the flattening logic kicks in on the inner pass.
"""
from agent_framework._tools import normalize_tools
a = _make_flatten_function_tool("a")
b = _make_flatten_function_tool("b")
class ToolBundle:
def __init__(self, tools: list[FunctionTool]) -> None:
self._tools = tools
def __iter__(self):
return iter(self._tools)
normalized = normalize_tools(ToolBundle([a, b])) # type: ignore[arg-type]
assert len(normalized) == 2
assert normalized[0] is a
assert normalized[1] is b
def test_normalize_tools_does_not_flatten_known_tool_types() -> None:
"""FunctionTool / dict / callable are detected before the flatten branch."""
from agent_framework._tools import normalize_tools
func_tool = _make_flatten_function_tool("ft")
dict_tool: dict[str, Any] = {"type": "code_interpreter", "container": {"type": "auto"}}
def plain_callable(x: int) -> int:
return x
normalized = normalize_tools([func_tool, dict_tool, plain_callable])
assert len(normalized) == 3
assert normalized[0] is func_tool
assert normalized[1] is dict_tool
# plain_callable was wrapped in a FunctionTool via the @tool helper
assert isinstance(normalized[2], FunctionTool)
def test_normalize_tools_flattens_mapping_like_toolbox_with_tools_attr() -> None:
"""Mapping-like toolbox objects with ``.tools`` should still flatten."""
from collections.abc import Mapping as MappingABC
from agent_framework._tools import normalize_tools
bundled = _make_flatten_function_tool("bundled")
standalone = _make_flatten_function_tool("standalone")
class ToolBundleMapping(MappingABC[str, Any]):
def __init__(self, tools: list[FunctionTool]) -> None:
self.tools = tools
self._data = {"name": "research_tools", "version": "v1", "tools": tools}
def __getitem__(self, key: str) -> Any:
return self._data[key]
def __iter__(self):
return iter(self._data)
def __len__(self) -> int:
return len(self._data)
normalized = normalize_tools([ToolBundleMapping([bundled]), standalone])
assert len(normalized) == 2
assert normalized[0] is bundled
assert normalized[1] is standalone
# endregion