Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ temp*/

# AI
.claude/
.omc/
.omx/
WARP.md
**/memory-bank/
**/projectBrief.md
Expand Down
454 changes: 454 additions & 0 deletions docs/decisions/0025-foundry-toolbox-support.md
Comment thread
moonbox3 marked this conversation as resolved.

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions python/packages/core/agent_framework/_feature_stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class ExperimentalFeature(str, Enum):
EVALS = "EVALS"
FILE_HISTORY = "FILE_HISTORY"
SKILLS = "SKILLS"
TOOLBOXES = "TOOLBOXES"


class ReleaseCandidateFeature(str, Enum):
Expand Down
28 changes: 28 additions & 0 deletions python/packages/core/agent_framework/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
AsyncIterable,
Awaitable,
Callable,
Iterable,
Mapping,
Sequence,
)
Expand Down Expand Up @@ -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 []
Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions python/packages/core/agent_framework/foundry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"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"),
Expand All @@ -31,6 +32,9 @@
"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"),
}


Expand Down
8 changes: 8 additions & 0 deletions python/packages/core/agent_framework/foundry/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ from agent_framework_foundry import (
FoundryEmbeddingOptions,
FoundryEmbeddingSettings,
FoundryEvals,
FoundryHostedToolType,
FoundryMemoryProvider,
RawFoundryAgent,
RawFoundryAgentChatClient,
RawFoundryChatClient,
RawFoundryEmbeddingClient,
evaluate_foundry_target,
evaluate_traces,
get_toolbox_tool_name,
get_toolbox_tool_type,
select_toolbox_tools,
)
from agent_framework_foundry_local import (
FoundryLocalChatOptions,
Expand All @@ -35,6 +39,7 @@ __all__ = [
"FoundryEmbeddingOptions",
"FoundryEmbeddingSettings",
"FoundryEvals",
"FoundryHostedToolType",
"FoundryLocalChatOptions",
"FoundryLocalClient",
"FoundryLocalSettings",
Expand All @@ -46,4 +51,7 @@ __all__ = [
"RawFoundryEmbeddingClient",
"evaluate_foundry_target",
"evaluate_traces",
"get_toolbox_tool_name",
"get_toolbox_tool_type",
"select_toolbox_tools",
]
157 changes: 157 additions & 0 deletions python/packages/core/tests/core/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
63 changes: 63 additions & 0 deletions python/packages/foundry/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,66 @@
# Agent Framework Foundry

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.

## Toolboxes

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.

### Authoring a toolbox

Toolboxes can be authored two ways:

- **Foundry portal** — create and version toolboxes through the UI without touching code.
- **Programmatically** — use the [`azure-ai-projects`](https://pypi.org/project/azure-ai-projects/) SDK to create, update, and version toolboxes from Python.

> 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.

### Using toolboxes with `FoundryAgent`

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.

### Using toolboxes with `FoundryChatClient`

There are two patterns for wiring a toolbox into a `FoundryChatClient`-backed agent.

**1. Fetch, optionally filter, and pass the tools directly**

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:

```python
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient, select_toolbox_tools

client = FoundryChatClient(...)
toolbox = await client.get_toolbox("my-toolbox", version="3")

# Pass the whole toolbox:
agent = Agent(client=client, tools=toolbox)

# Or filter to a subset first:
selected = select_toolbox_tools(toolbox, include_types=["code_interpreter", "mcp"])
agent = Agent(client=client, tools=selected)
```

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.

**2. Connect to the toolbox's MCP endpoint with `MCPStreamableHTTPTool`**

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:

```python
from agent_framework import Agent, MCPStreamableHTTPTool
from agent_framework.foundry import FoundryChatClient

async with Agent(
client=FoundryChatClient(...),
instructions="You are a helpful assistant. Use the toolbox tools when useful.",
tools=MCPStreamableHTTPTool(
name="my_toolbox",
description="Tools served by my Foundry toolbox",
url="https://<your-toolbox-mcp-endpoint>",
),
) as agent:
result = await agent.run("What tools are available?")
print(result.text)
```
5 changes: 5 additions & 0 deletions python/packages/foundry/agent_framework_foundry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
evaluate_traces,
)
from ._memory_provider import FoundryMemoryProvider
from ._tools import FoundryHostedToolType, get_toolbox_tool_name, get_toolbox_tool_type, select_toolbox_tools

try:
__version__ = importlib.metadata.version(__name__)
Expand All @@ -30,6 +31,7 @@
"FoundryEmbeddingOptions",
"FoundryEmbeddingSettings",
"FoundryEvals",
"FoundryHostedToolType",
"FoundryMemoryProvider",
"RawFoundryAgent",
"RawFoundryAgentChatClient",
Expand All @@ -38,4 +40,7 @@
"__version__",
"evaluate_foundry_target",
"evaluate_traces",
"get_toolbox_tool_name",
"get_toolbox_tool_type",
"select_toolbox_tools",
]
16 changes: 16 additions & 0 deletions python/packages/foundry/agent_framework_foundry/_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential

from ._tools import sanitize_foundry_response_tool

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

@override
def _prepare_tools_for_openai(
self,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
) -> list[Any]:
"""Prepare tools for Foundry agent Responses API calls.

Mirrors ``RawFoundryChatClient`` sanitization so toolbox-fetched MCP
tools with extra read-model fields continue to work through the agent
surface.
"""
response_tools = super()._prepare_tools_for_openai(tools)
return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]

def _prepare_messages_for_azure_ai(self, messages: Sequence[Message]) -> tuple[list[Message], str | None]:
"""Extract system/developer messages as instructions for Azure AI.

Expand Down
Loading
Loading