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
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:
@@ -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.
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
|
||||
Reference in New Issue
Block a user