chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
@@ -0,0 +1,3 @@
# Foundry Hosting
This package provides the integration of Agent Framework agents and workflows with the Foundry Agent Server, which can be hosted on Foundry infrastructure.
@@ -0,0 +1,14 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
from ._invocations import InvocationsHostServer
from ._responses import ResponsesHostServer
from ._toolbox import FoundryToolbox
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = ["FoundryToolbox", "InvocationsHostServer", "ResponsesHostServer"]
@@ -0,0 +1,72 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework import AgentSession, BaseAgent, SupportsAgentRun
from azure.ai.agentserver.invocations import InvocationAgentServerHost
from starlette.requests import Request
from starlette.responses import JSONResponse, Response, StreamingResponse
from typing_extensions import Any, AsyncGenerator
class InvocationsHostServer(InvocationAgentServerHost):
"""An invocations server host for an agent."""
def __init__(
self,
agent: BaseAgent,
*,
openapi_spec: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Initialize an InvocationsHostServer.
Args:
agent: The agent to handle responses for.
openapi_spec: The OpenAPI specification for the server.
**kwargs: Additional keyword arguments.
This host will expect the request to be a JSON body with a "message" field.
The response from the host will be a JSON object with a "response" field containing
the agent's response and a "session_id" field containing the session ID.
"""
super().__init__(openapi_spec=openapi_spec, **kwargs)
if not isinstance(agent, SupportsAgentRun):
raise TypeError("Agent must support the SupportsAgentRun interface")
self._agent = agent
self._sessions: dict[str, AgentSession] = {}
self.invoke_handler(self._handle_invoke)
async def _handle_invoke(self, request: Request) -> Response:
"""Invoke the agent with the given request."""
data = await request.json()
session_id: str = request.state.session_id
stream = data.get("stream", False)
user_message = data.get("message", None)
if user_message is None:
error = "Missing 'message' in request"
if stream:
return StreamingResponse(content=error, status_code=400)
return Response(content=error, status_code=400)
session = self._sessions.setdefault(session_id, AgentSession(session_id=session_id))
if stream:
async def stream_response() -> AsyncGenerator[str]:
async for update in self._agent.run(user_message, session=session, stream=True):
if update.text:
yield update.text
return StreamingResponse(
stream_response(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
response = await self._agent.run([user_message], session=session, stream=stream)
return JSONResponse({
"response": response.text,
"session_id": session_id,
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,263 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import logging
import os
from typing import TYPE_CHECKING
from urllib.parse import urlsplit
import httpx
from agent_framework import (
MCPSkillsSource,
MCPStreamableHTTPTool,
SkillsProvider,
SkillsSource,
SkillsSourceContext,
)
from azure.ai.agentserver.core import get_request_context
if TYPE_CHECKING:
from collections.abc import Generator
from agent_framework import Skill
from azure.core.credentials import TokenCredential
logger = logging.getLogger(__name__)
# Default Microsoft Entra scope for Foundry data-plane access.
DEFAULT_TOOLBOX_SCOPE = "https://ai.azure.com/.default"
# Default timeout (seconds) for toolbox MCP requests.
_DEFAULT_TIMEOUT = 120.0
def _resolve_toolbox_endpoint() -> str:
"""Resolve the toolbox MCP endpoint URL from the environment.
Prefers the explicit ``TOOLBOX_ENDPOINT`` env var; falls back to building the
URL from ``FOUNDRY_PROJECT_ENDPOINT`` and ``TOOLBOX_NAME``.
"""
endpoint = os.environ.get("TOOLBOX_ENDPOINT")
if endpoint is not None:
if not endpoint:
raise ValueError("TOOLBOX_ENDPOINT is set but empty.")
return endpoint
project_endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
toolbox_name = os.environ.get("TOOLBOX_NAME")
if not project_endpoint or not toolbox_name:
raise ValueError(
"Pass 'url', or set TOOLBOX_ENDPOINT, or set both FOUNDRY_PROJECT_ENDPOINT "
"and TOOLBOX_NAME to build the toolbox MCP endpoint."
)
return f"{project_endpoint.rstrip('/')}/toolboxes/{toolbox_name}/mcp?api-version=v1"
def _toolbox_name_from_endpoint(endpoint: str) -> str:
"""Extract the toolbox name from a toolbox MCP endpoint URL.
Handles both the versioned (``.../toolboxes/<name>/versions/<n>/mcp``) and
unversioned (``.../toolboxes/<name>/mcp``) endpoint shapes that Foundry
produces. Falls back to ``"toolbox"`` when the path has no ``toolboxes`` segment.
"""
segments = urlsplit(endpoint).path.split("/")
if "toolboxes" in segments:
idx = segments.index("toolboxes")
if idx + 1 < len(segments) and segments[idx + 1]:
return segments[idx + 1]
return "toolbox"
class _ToolboxAuth(httpx.Auth):
"""Injects a fresh bearer token and the platform call-id on every request.
``auth_flow`` runs for *every* outbound request (connection handshake as well
as tool calls), so the bearer token is always present. The per-request
``x-agent-foundry-call-id`` is read from the request-scoped context populated
by the hosting endpoint; it resolves to a fresh value on each request and is
absent (no header) for protocol ``1.0.0`` or local development.
"""
def __init__(self, credential: TokenCredential, scope: str) -> None:
self._credential = credential
self._scope = scope
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
# azure-core credentials cache the token internally and only refresh near
# expiry, so calling get_token per request is cheap.
token = self._credential.get_token(self._scope).token
request.headers["Authorization"] = f"Bearer {token}"
for key, value in get_request_context().platform_headers().items():
request.headers[key] = value
yield request
class FoundryToolbox(MCPStreamableHTTPTool):
"""A Foundry toolbox exposed as an MCP tool, with hosting wired in.
This is a thin convenience wrapper over :class:`~agent_framework.MCPStreamableHTTPTool`
that targets a Microsoft Foundry toolbox endpoint. Compared to constructing an
``MCPStreamableHTTPTool`` by hand it:
- resolves the toolbox endpoint and tool name from the environment when not given,
- authenticates every request with a bearer token from ``credential``, and
- forwards the platform per-request call-id (``x-agent-foundry-call-id``) so the
Foundry MCP proxy can resolve the caller context server-side.
The call-id forwarding is transparent: it is read from the request-scoped context
the hosting endpoint binds on each request, so no per-request wiring is needed.
Because the toolbox endpoint is a first-party Foundry service, forwarding the
opaque caller token to it is safe.
Like any MCP tool, the connection lifecycle is driven by the agent: the hosting
server enters the agent, which connects the toolbox on first use and closes it
(and the HTTP client it owns) at shutdown. Using it as an ``async with`` context
manager directly is supported but not required.
Examples:
.. code-block:: python
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import FoundryToolbox, ResponsesHostServer
from azure.identity import DefaultAzureCredential
credential = DefaultAzureCredential()
# The hosting server enters the agent, which connects/closes the toolbox.
toolbox = FoundryToolbox(credential)
agent = Agent(
client=FoundryChatClient(credential=credential),
tools=toolbox,
default_options={"store": False},
)
await ResponsesHostServer(agent).run_async()
"""
def __init__(
self,
credential: TokenCredential,
*,
url: str | None = None,
name: str | None = None,
token_scope: str = DEFAULT_TOOLBOX_SCOPE,
load_prompts: bool = False,
load_tools: bool = True,
timeout: float = _DEFAULT_TIMEOUT,
) -> None:
"""Initialize a Foundry toolbox tool.
Args:
credential: A Microsoft Entra credential used to obtain bearer tokens for
the toolbox endpoint. Tokens are requested per outbound request and
cached by the credential.
Keyword Args:
url: The toolbox MCP endpoint URL. When ``None``, it is resolved from
``TOOLBOX_ENDPOINT`` or from ``FOUNDRY_PROJECT_ENDPOINT`` plus
``TOOLBOX_NAME``.
name: The local tool name. When ``None``, it is taken from ``TOOLBOX_NAME``
or derived from the endpoint path.
token_scope: The token scope to request. Defaults to the Foundry data-plane
scope.
load_prompts: Whether to load prompts from the toolbox. Defaults to ``False``
because toolboxes expose tools.
load_tools: Whether to load tools from the toolbox. Defaults to ``True``.
timeout: Request timeout in seconds for the underlying HTTP client.
"""
endpoint = url or _resolve_toolbox_endpoint()
tool_name = name or os.environ.get("TOOLBOX_NAME") or _toolbox_name_from_endpoint(endpoint)
http_client = httpx.AsyncClient(
auth=_ToolboxAuth(credential, token_scope),
timeout=timeout,
)
super().__init__(
name=tool_name,
url=endpoint,
http_client=http_client,
load_prompts=load_prompts,
load_tools=load_tools,
)
async def close(self) -> None:
"""Close the MCP session and the toolbox-owned HTTP client."""
try:
await super().close()
finally:
client = self._httpx_client
if client is not None:
self._httpx_client = None
await client.aclose()
def as_skills_provider(
self,
*,
source_id: str | None = None,
instruction_template: str | None = None,
disable_caching: bool = False,
) -> SkillsProvider:
"""Return a :class:`~agent_framework.SkillsProvider` backed by this toolbox.
A Foundry toolbox can serve Agent Skills (SEP-2640) over MCP. This discovers
them from the well-known ``skill://index.json`` resource on the toolbox's MCP
session and exposes them through a provider you can pass to an agent via
``context_providers=[...]``.
The toolbox must be **connected** before its skills are discovered (which
happens lazily on the first agent run). Connect it by passing the toolbox to
the agent via ``tools=`` -- set ``load_tools=False`` if you want skills only
and no tools -- or by entering it as an ``async with`` context manager.
Keyword Args:
source_id: Unique identifier for the provider instance.
instruction_template: Custom system-prompt template for advertising
skills; see :class:`~agent_framework.SkillsProvider`.
disable_caching: Re-query the toolbox on every agent run instead of
caching after the first discovery.
Returns:
A :class:`~agent_framework.SkillsProvider` that advertises and loads the
toolbox's skills.
Examples:
.. code-block:: python
toolbox = FoundryToolbox(credential, load_tools=False)
agent = Agent(
client=FoundryChatClient(credential=credential),
# ``tools=toolbox`` connects the MCP session; ``load_tools=False``
# keeps its tools hidden so only its skills are surfaced.
tools=toolbox,
context_providers=[toolbox.as_skills_provider()],
default_options={"store": False},
)
await ResponsesHostServer(agent).run_async()
"""
return SkillsProvider(
_FoundryToolboxSkillsSource(self),
source_id=source_id,
instruction_template=instruction_template,
disable_caching=disable_caching,
)
class _FoundryToolboxSkillsSource(SkillsSource):
"""Discovers skills from a connected :class:`FoundryToolbox` MCP session.
The toolbox's MCP ``session`` is established lazily when the toolbox connects
(via the agent or an ``async with`` block), so the session is resolved at
discovery time rather than captured at construction.
"""
def __init__(self, toolbox: FoundryToolbox) -> None:
self._toolbox = toolbox
async def get_skills(self, context: SkillsSourceContext) -> list[Skill]:
session = self._toolbox.session
if session is None:
raise RuntimeError(
"FoundryToolbox is not connected, so its skills cannot be discovered. "
"Pass the toolbox to the agent (tools=...) or enter it as an async "
"context manager before the agent runs."
)
return await MCPSkillsSource(client=session).get_skills(context)
@@ -0,0 +1,101 @@
[project]
name = "agent-framework-foundry-hosting"
description = "Foundry Hosting integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260709"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.11.0,<2",
"azure-ai-agentserver-core>=2.0.0b7,<3",
"azure-ai-agentserver-responses>=1.0.0b8,<2",
"azure-ai-agentserver-invocations>=1.0.0b6,<2",
"httpx>=0.28,<1",
"mcp>=1.24.0,<2",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_foundry_hosting"]
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_foundry_hosting"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_foundry_hosting"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_foundry_hosting --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,579 @@
# Copyright (c) Microsoft. All rights reserved.
"""Integration tests for ResponsesHostServer with a real Foundry endpoint.
These tests exercise the full HTTP pipeline using httpx.AsyncClient with
ASGITransport — no real server process is started. The agent talks to a real
Foundry project endpoint so every test requires valid credentials.
Required environment variables:
FOUNDRY_PROJECT_ENDPOINT - The Azure AI Foundry project endpoint URL.
FOUNDRY_MODEL - The model deployment name (e.g. gpt-4o).
"""
from __future__ import annotations
import base64
import json
import os
from pathlib import Path
from typing import Annotated, Any
import httpx
import pytest
from agent_framework import Agent, tool
from agent_framework.foundry import FoundryChatClient
from azure.ai.agentserver.responses import InMemoryResponseProvider
from azure.identity import AzureCliCredential
from agent_framework_foundry_hosting import ResponsesHostServer
# ---------------------------------------------------------------------------
# Skip / marker helpers
# ---------------------------------------------------------------------------
skip_if_foundry_hosting_integration_tests_disabled = pytest.mark.skipif(
os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") in ("", "https://test-project.services.ai.azure.com/")
or os.getenv("FOUNDRY_MODEL", "") == "",
reason="No real FOUNDRY_PROJECT_ENDPOINT or FOUNDRY_MODEL provided; skipping integration tests.",
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def server() -> ResponsesHostServer:
"""Create a ResponsesHostServer backed by a real Foundry agent."""
client = FoundryChatClient(credential=AzureCliCredential()) # pyrefly: ignore[bad-argument-type]
agent = Agent(
client=client, # ty: ignore[invalid-argument-type]
instructions="You are a concise assistant. Keep answers very short (one or two sentences).",
default_options={"store": False}, # pyrefly: ignore[bad-argument-type]
)
return ResponsesHostServer(agent, store=InMemoryResponseProvider())
@tool
async def get_weather(location: Annotated[str, "The city name"]) -> str:
"""Get the current weather in a given location."""
return f"The weather in {location} is 72°F and sunny."
@pytest.fixture
def server_with_tools() -> ResponsesHostServer:
"""Create a ResponsesHostServer whose agent has a tool."""
client = FoundryChatClient(credential=AzureCliCredential()) # pyrefly: ignore[bad-argument-type]
agent = Agent(
client=client, # ty: ignore[invalid-argument-type]
instructions="You are a concise assistant. Use the provided tools when appropriate. Keep answers very short.",
tools=[get_weather],
default_options={"store": False}, # pyrefly: ignore[bad-argument-type]
)
return ResponsesHostServer(agent, store=InMemoryResponseProvider())
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
async def _post_json(
server: ResponsesHostServer,
payload: dict[str, Any],
) -> httpx.Response:
"""Send a POST /responses request with a raw JSON payload."""
transport = httpx.ASGITransport(app=server)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
return await client.post("/responses", json=payload, timeout=120)
def _parse_sse_events(body: str) -> list[dict[str, Any]]:
"""Parse SSE text into a list of event dicts with 'event' and 'data' keys."""
events: list[dict[str, Any]] = []
current_event: str | None = None
current_data_lines: list[str] = []
for line in body.split("\n"):
if line.startswith("event: "):
current_event = line[len("event: ") :]
elif line.startswith("data: "):
current_data_lines.append(line[len("data: ") :])
elif line.strip() == "" and current_event is not None:
data_str = "\n".join(current_data_lines)
try:
data = json.loads(data_str)
except json.JSONDecodeError:
data = data_str
events.append({"event": current_event, "data": data})
current_event = None
current_data_lines = []
return events
def _sse_event_types(events: list[dict[str, Any]]) -> list[str]:
"""Extract event type strings from parsed SSE events."""
return [e["event"] for e in events]
# ---------------------------------------------------------------------------
# Tests — basic text input
# ---------------------------------------------------------------------------
class TestBasicText:
"""Simple text-in / text-out round trips."""
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_hosting_integration_tests_disabled
async def test_simple_text_non_streaming(self, server: ResponsesHostServer) -> None:
"""Non-streaming: send a text prompt and get a completed response."""
resp = await _post_json(
server,
{
"input": "Say hello in exactly three words.",
"stream": False,
},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
# There should be exactly one output item with text
output_messages = [o for o in body["output"] if o["type"] == "message"]
assert len(output_messages) == 1
text_parts = [c for c in output_messages[0]["content"] if c["type"] == "output_text"]
assert len(text_parts) >= 1
assert len(text_parts[0]["text"]) > 0
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_hosting_integration_tests_disabled
async def test_simple_text_streaming(self, server: ResponsesHostServer) -> None:
"""Streaming: send a text prompt and verify SSE lifecycle events."""
resp = await _post_json(
server,
{
"input": "Say hello in exactly three words.",
"stream": True,
},
)
assert resp.status_code == 200
assert "text/event-stream" in resp.headers["content-type"]
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[1] == "response.in_progress"
assert types[-1] == "response.completed"
assert "response.output_text.delta" in types
assert "response.output_text.done" in types
# The done event should have accumulated text
done_events = [e for e in events if e["event"] == "response.output_text.done"]
assert len(done_events) >= 1
assert len(done_events[0]["data"]["text"]) > 0
# ---------------------------------------------------------------------------
# Tests — structured content input
# ---------------------------------------------------------------------------
class TestStructuredContentInput:
"""Structured content arrays: text + images, text + files."""
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_hosting_integration_tests_disabled
async def test_text_array_input(self, server: ResponsesHostServer) -> None:
"""Multiple input_text parts in one message."""
resp = await _post_json(
server,
{
"input": [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "My name is Alice."},
{"type": "input_text", "text": "What is my name?"},
],
}
],
"stream": False,
},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
# The response should mention Alice
output_messages = [o for o in body["output"] if o["type"] == "message"]
assert len(output_messages) == 1
output_text = output_messages[0]["content"][0]["text"]
assert "alice" in output_text.lower()
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_hosting_integration_tests_disabled
async def test_input_image_url(self, server: ResponsesHostServer) -> None:
"""Send an image via URL and ask the model about it."""
resp = await _post_json(
server,
{
"input": [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "What animal is in this image? Reply in one word."},
{
"type": "input_image",
"image_url": "https://cdn.pixabay.com/photo/2024/02/28/07/42/european-shorthair-8601492_640.jpg",
},
],
}
],
"stream": False,
},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
output_messages = [o for o in body["output"] if o["type"] == "message"]
assert len(output_messages) == 1
output_text = output_messages[0]["content"][0]["text"].lower()
assert "cat" in output_text
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_hosting_integration_tests_disabled
async def test_input_image_file_data(self, server: ResponsesHostServer) -> None:
"""Send a local image file as inline base64 data URI."""
image_path = Path(__file__).resolve().parent / "test_assets" / "sample_image.jpg" # noqa: ASYNC240
image_bytes = image_path.read_bytes()
b64 = base64.b64encode(image_bytes).decode()
data_uri = f"data:image/jpeg;base64,{b64}"
resp = await _post_json(
server,
{
"input": [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "What animal is in this image? Reply in one word."},
{"type": "input_image", "image_url": data_uri},
],
}
],
"stream": False,
},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
output_messages = [o for o in body["output"] if o["type"] == "message"]
assert len(output_messages) == 1
output_text = output_messages[0]["content"][0]["text"].lower()
assert "cat" in output_text
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_hosting_integration_tests_disabled
async def test_input_file_data(self, server: ResponsesHostServer) -> None:
"""Send a small text file as inline file_data (base64 data URI)."""
text_content = "The capital of France is Paris."
b64 = base64.b64encode(text_content.encode()).decode()
data_uri = f"data:text/plain;base64,{b64}"
resp = await _post_json(
server,
{
"input": [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "What is the capital mentioned in the attached file?"},
{"type": "input_file", "file_data": data_uri, "filename": "info.txt"},
],
}
],
"stream": False,
},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
output_messages = [o for o in body["output"] if o["type"] == "message"]
assert len(output_messages) == 1
output_text = output_messages[0]["content"][0]["text"].lower()
assert "paris" in output_text
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_hosting_integration_tests_disabled
async def test_input_pdf_file_data(self, server: ResponsesHostServer) -> None:
"""Send a real PDF file as inline file_data (base64 data URI)."""
pdf_path = Path(__file__).resolve().parent / "test_assets" / "sample.pdf" # noqa: ASYNC240
pdf_bytes = pdf_path.read_bytes()
b64 = base64.b64encode(pdf_bytes).decode()
data_uri = f"data:application/pdf;base64,{b64}"
resp = await _post_json(
server,
{
"input": [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "Summarize this PDF in one sentence."},
{"type": "input_file", "file_data": data_uri, "filename": "sample.pdf"},
],
}
],
"stream": False,
},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
output_messages = [o for o in body["output"] if o["type"] == "message"]
assert len(output_messages) == 1
output_text = output_messages[0]["content"][0]["text"]
assert "microsoft" in output_text.lower()
# ---------------------------------------------------------------------------
# Tests — multi-turn conversations
# ---------------------------------------------------------------------------
class TestMultiTurn:
"""Multi-round conversations using previous_response_id."""
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_hosting_integration_tests_disabled
async def test_two_turn_conversation(self, server: ResponsesHostServer) -> None:
"""Turn 1: introduce context. Turn 2: ask about it using previous_response_id."""
# Turn 1
resp1 = await _post_json(
server,
{
"input": "My favorite color is blue. Remember that.",
"stream": False,
},
)
assert resp1.status_code == 200
body1 = resp1.json()
assert body1["status"] == "completed"
response_id_1 = body1["id"]
# Turn 2 — references turn 1
resp2 = await _post_json(
server,
{
"input": "What is my favorite color?",
"stream": False,
"previous_response_id": response_id_1,
},
)
assert resp2.status_code == 200
body2 = resp2.json()
assert body2["status"] == "completed"
output_messages = [o for o in body2["output"] if o["type"] == "message"]
assert len(output_messages) == 1
output_text = output_messages[0]["content"][0]["text"].lower()
assert "blue" in output_text
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_hosting_integration_tests_disabled
async def test_three_turn_conversation(self, server: ResponsesHostServer) -> None:
"""Three sequential turns to verify history accumulates correctly."""
# Turn 1
resp1 = await _post_json(
server,
{
"input": "I have a pet dog named Max.",
"stream": False,
},
)
assert resp1.status_code == 200
id1 = resp1.json()["id"]
# Turn 2
resp2 = await _post_json(
server,
{
"input": "I also have a cat named Luna.",
"stream": False,
"previous_response_id": id1,
},
)
assert resp2.status_code == 200
id2 = resp2.json()["id"]
# Turn 3 — should remember both pets
resp3 = await _post_json(
server,
{
"input": "What are my pets' names?",
"stream": False,
"previous_response_id": id2,
},
)
assert resp3.status_code == 200
body3 = resp3.json()
output_messages = [o for o in body3["output"] if o["type"] == "message"]
assert len(output_messages) == 1
output_text = output_messages[0]["content"][0]["text"].lower()
assert "max" in output_text
assert "luna" in output_text
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_hosting_integration_tests_disabled
async def test_multi_turn_streaming(self, server: ResponsesHostServer) -> None:
"""Multi-turn conversation with streaming on the second turn."""
# Turn 1 — non-streaming
resp1 = await _post_json(
server,
{
"input": "My favorite number is 42.",
"stream": False,
},
)
assert resp1.status_code == 200
id1 = resp1.json()["id"]
# Turn 2 — streaming
resp2 = await _post_json(
server,
{
"input": "What is my favorite number?",
"stream": True,
"previous_response_id": id1,
},
)
assert resp2.status_code == 200
assert "text/event-stream" in resp2.headers["content-type"]
events = _parse_sse_events(resp2.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[-1] == "response.completed"
assert "response.output_text.done" in types
done_events = [e for e in events if e["event"] == "response.output_text.done"]
assert "42" in done_events[0]["data"]["text"]
# ---------------------------------------------------------------------------
# Tests — tool calling
# ---------------------------------------------------------------------------
class TestToolCalling:
"""Tests that verify function-tool round trips through the hosting layer."""
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_hosting_integration_tests_disabled
async def test_tool_call_non_streaming(self, server_with_tools: ResponsesHostServer) -> None:
"""Agent invokes a tool and returns a final answer (non-streaming)."""
resp = await _post_json(
server_with_tools,
{
"input": "What is the weather in Seattle?",
"stream": False,
},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
# The output should contain the final text referencing the weather
output_messages = [o for o in body["output"] if o["type"] == "message"]
assert len(output_messages) == 1
final_text = output_messages[0]["content"][0]["text"].lower()
assert "72" in final_text or "sunny" in final_text or "seattle" in final_text
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_hosting_integration_tests_disabled
async def test_tool_call_streaming(self, server_with_tools: ResponsesHostServer) -> None:
"""Agent invokes a tool and returns a final answer (streaming)."""
resp = await _post_json(
server_with_tools,
{
"input": "What is the weather in Seattle?",
"stream": True,
},
)
assert resp.status_code == 200
assert "text/event-stream" in resp.headers["content-type"]
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[-1] == "response.completed"
# Should have text output with the weather info
done_events = [e for e in events if e["event"] == "response.output_text.done"]
assert len(done_events) >= 1
final_text = done_events[-1]["data"]["text"].lower()
assert "72" in final_text or "sunny" in final_text or "seattle" in final_text
# ---------------------------------------------------------------------------
# Tests — options passthrough
# ---------------------------------------------------------------------------
class TestOptions:
"""Verify chat options are passed through to the model."""
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_hosting_integration_tests_disabled
async def test_temperature_and_max_tokens(self, server: ResponsesHostServer) -> None:
"""Set max_output_tokens and verify the response succeeds."""
resp = await _post_json(
server,
{
"input": "Say hello briefly.",
"stream": False,
"max_output_tokens": 200,
},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
assert len(body["output"]) > 0
@@ -0,0 +1,202 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for FoundryToolbox."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import cast
from unittest.mock import AsyncMock
import httpx
import pytest
from agent_framework import SkillsProvider, SkillsSourceContext, SupportsAgentRun
from azure.ai.agentserver.core import (
FoundryAgentRequestContext,
reset_request_context,
set_request_context,
)
from agent_framework_foundry_hosting import FoundryToolbox
from agent_framework_foundry_hosting._toolbox import ( # pyright: ignore[reportPrivateUsage]
_FoundryToolboxSkillsSource,
_resolve_toolbox_endpoint,
_toolbox_name_from_endpoint,
_ToolboxAuth,
)
class _StubAgent:
"""Minimal stand-in for a ``SupportsAgentRun`` used to build a source context."""
name = "test-agent"
def _source_context() -> SkillsSourceContext:
"""Build a :class:`SkillsSourceContext` for exercising skill sources in tests."""
return SkillsSourceContext(agent=cast(SupportsAgentRun, _StubAgent()))
class _FakeAccessToken:
def __init__(self, token: str) -> None:
self.token = token
self.expires_on = int(datetime.now(timezone.utc).timestamp()) + 3600
class _FakeCredential:
"""Minimal stand-in for azure.core.credentials.TokenCredential."""
def __init__(self, token: str = "fake-token") -> None:
self._token = token
self.scopes: list[str] = []
def get_token(self, *scopes: str, **kwargs: object) -> _FakeAccessToken:
self.scopes.extend(scopes)
return _FakeAccessToken(self._token)
def test_resolve_endpoint_prefers_explicit_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TOOLBOX_ENDPOINT", "https://host/toolboxes/tb/mcp?api-version=v1")
assert _resolve_toolbox_endpoint() == "https://host/toolboxes/tb/mcp?api-version=v1"
def test_resolve_endpoint_builds_from_project_and_name(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("TOOLBOX_ENDPOINT", raising=False)
monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://proj.example.com/")
monkeypatch.setenv("TOOLBOX_NAME", "mybox")
assert _resolve_toolbox_endpoint() == "https://proj.example.com/toolboxes/mybox/mcp?api-version=v1"
def test_resolve_endpoint_empty_explicit_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TOOLBOX_ENDPOINT", "")
with pytest.raises(ValueError, match="empty"):
_resolve_toolbox_endpoint()
def test_resolve_endpoint_missing_inputs_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("TOOLBOX_ENDPOINT", raising=False)
monkeypatch.delenv("FOUNDRY_PROJECT_ENDPOINT", raising=False)
monkeypatch.delenv("TOOLBOX_NAME", raising=False)
with pytest.raises(ValueError, match="TOOLBOX_ENDPOINT"):
_resolve_toolbox_endpoint()
@pytest.mark.parametrize(
("endpoint", "expected"),
[
("https://h/toolboxes/alpha/mcp?api-version=v1", "alpha"),
("https://h/toolboxes/beta/versions/3/mcp", "beta"),
("https://h/something/else", "toolbox"),
],
)
def test_toolbox_name_from_endpoint(endpoint: str, expected: str) -> None:
assert _toolbox_name_from_endpoint(endpoint) == expected
def test_init_derives_name_and_defaults() -> None:
toolbox = FoundryToolbox(
_FakeCredential(), # type: ignore
url="https://h/toolboxes/sales/mcp?api-version=v1",
)
assert toolbox.name == "sales"
assert toolbox.url == "https://h/toolboxes/sales/mcp?api-version=v1"
# Toolboxes expose tools, not prompts.
assert toolbox.load_prompts_flag is False
def test_auth_flow_injects_bearer_token() -> None:
cred = _FakeCredential("abc123")
auth = _ToolboxAuth(cred, "https://ai.azure.com/.default") # type: ignore
request = httpx.Request("POST", "https://h/toolboxes/tb/mcp")
flow = auth.auth_flow(request)
prepared = next(flow)
assert prepared.headers["Authorization"] == "Bearer abc123"
assert cred.scopes == ["https://ai.azure.com/.default"]
def test_auth_flow_forwards_call_id_when_present() -> None:
auth = _ToolboxAuth(_FakeCredential(), "scope") # type: ignore
request = httpx.Request("POST", "https://h/toolboxes/tb/mcp")
token = set_request_context(FoundryAgentRequestContext(call_id="call-xyz"))
try:
prepared = next(auth.auth_flow(request))
finally:
reset_request_context(token)
assert prepared.headers["x-agent-foundry-call-id"] == "call-xyz"
def test_auth_flow_omits_call_id_when_absent() -> None:
auth = _ToolboxAuth(_FakeCredential(), "scope") # type: ignore
request = httpx.Request("POST", "https://h/toolboxes/tb/mcp")
prepared = next(auth.auth_flow(request))
assert "x-agent-foundry-call-id" not in prepared.headers
async def test_close_closes_owned_http_client() -> None:
toolbox = FoundryToolbox(
_FakeCredential(), # type: ignore
url="https://h/toolboxes/tb/mcp",
)
client = toolbox._httpx_client # pyright: ignore[reportPrivateUsage]
assert client is not None
client.aclose = AsyncMock() # type: ignore[method-assign]
await toolbox.close()
client.aclose.assert_awaited_once()
# Idempotent: a second close does not re-close the client.
await toolbox.close()
client.aclose.assert_awaited_once()
def test_as_skills_provider_returns_provider() -> None:
toolbox = FoundryToolbox(
_FakeCredential(), # type: ignore
url="https://h/toolboxes/tb/mcp",
)
provider = toolbox.as_skills_provider(source_id="toolbox-skills")
assert isinstance(provider, SkillsProvider)
assert provider.source_id == "toolbox-skills"
async def test_skills_source_requires_connection() -> None:
toolbox = FoundryToolbox(
_FakeCredential(), # type: ignore
url="https://h/toolboxes/tb/mcp",
)
# The toolbox has not been connected, so there is no MCP session yet.
assert toolbox.session is None
source = _FoundryToolboxSkillsSource(toolbox)
with pytest.raises(RuntimeError, match="not connected"):
await source.get_skills(_source_context())
async def test_skills_source_uses_connected_session(monkeypatch: pytest.MonkeyPatch) -> None:
toolbox = FoundryToolbox(
_FakeCredential(), # type: ignore
url="https://h/toolboxes/tb/mcp",
)
sentinel_session = object()
toolbox.session = sentinel_session # type: ignore
captured: dict[str, object] = {}
class _StubSkillsSource:
def __init__(self, *, client: object) -> None:
captured["client"] = client
async def get_skills(self, context: SkillsSourceContext) -> list[str]:
return ["skill-a"]
monkeypatch.setattr("agent_framework_foundry_hosting._toolbox.MCPSkillsSource", _StubSkillsSource)
result = await _FoundryToolboxSkillsSource(toolbox).get_skills(_source_context())
assert result == ["skill-a"]
assert captured["client"] is sentinel_session