mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into local-branch-fix-workflow-as-agent-pending-request-handling
This commit is contained in:
@@ -8,6 +8,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
@@ -23,7 +24,7 @@ jobs:
|
||||
- name: Download coverage report
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
path: ./python
|
||||
merge-multiple: true
|
||||
@@ -38,9 +39,9 @@ jobs:
|
||||
echo "PR number file 'pr_number' is missing or empty"
|
||||
exit 1
|
||||
fi
|
||||
PR_NUMBER=$(head -1 pr_number | tr -dc '0-9')
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "PR number file 'pr_number' does not contain a valid PR number"
|
||||
PR_NUMBER=$(cat pr_number)
|
||||
if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::PR number file contains invalid content"
|
||||
exit 1
|
||||
fi
|
||||
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
|
||||
@@ -48,7 +49,7 @@ jobs:
|
||||
id: coverageComment
|
||||
uses: MishaKav/pytest-coverage-comment@26f986d2599c288bb62f623d29c2da98609e9cd4 # v1.6.0
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
github-token: ${{ github.token }}
|
||||
issue-number: ${{ env.PR_NUMBER }}
|
||||
pytest-xml-coverage-path: python/python-coverage.xml
|
||||
title: "Python Test Coverage Report"
|
||||
|
||||
@@ -344,6 +344,9 @@
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/HostedAzureSearchRag.csproj" />
|
||||
</Folder>
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5
|
||||
FOUNDRY_TOOLBOX_NAME=<your-toolbox-name>
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Dockerfile for end-users consuming the Agent Framework via NuGet packages.
|
||||
#
|
||||
# This Dockerfile performs a full `dotnet restore` and `dotnet publish` inside the container,
|
||||
# which only succeeds when the project references its dependencies via PackageReference (see the
|
||||
# commented-out section in HostedToolboxMcpSkills.csproj). Contributors building from the
|
||||
# agent-framework repository source must use Dockerfile.contributor instead because
|
||||
# ProjectReference dependencies live outside this folder and cannot be restored from inside
|
||||
# this build context.
|
||||
#
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedToolboxMcpSkills.dll"]
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local source, which means a standard
|
||||
# multi-stage Docker build cannot resolve dependencies outside this folder.
|
||||
# Pre-publish the app targeting the container runtime and copy the output:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-toolbox-mcp-skills .
|
||||
# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-toolbox-mcp-skills -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-toolbox-mcp-skills
|
||||
#
|
||||
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedToolboxMcpSkills.dll"]
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedToolboxMcpSkills</RootNamespace>
|
||||
<AssemblyName>HostedToolboxMcpSkills</AssemblyName>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Mcp" Version="1.6.1-preview.260514.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Hosted Toolbox MCP Skills Agent
|
||||
//
|
||||
// Demonstrates how to host an agent that discovers MCP-based skills from a
|
||||
// Foundry Toolbox MCP endpoint and injects them as AIContextProviders using
|
||||
// AgentSkillsProviderBuilder.UseMcpSkills().
|
||||
//
|
||||
// Required environment variables:
|
||||
// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
|
||||
// FOUNDRY_TOOLBOX_NAME - Name of the Foundry Toolbox to connect to
|
||||
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-5)
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
var projectEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5";
|
||||
var toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME")
|
||||
?? throw new InvalidOperationException("FOUNDRY_TOOLBOX_NAME is not set.");
|
||||
|
||||
// Build the Toolbox MCP URL from the project endpoint and toolbox name.
|
||||
var toolboxMcpServerUrl = $"{projectEndpoint.TrimEnd('/')}/toolboxes/{toolboxName}/mcp?api-version=v1";
|
||||
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
// ── Connect to the Foundry Toolbox MCP endpoint ─────────────────────────────
|
||||
// Create an HttpClient that attaches a fresh Foundry bearer token to every request.
|
||||
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default") { CheckCertificateRevocationList = true });
|
||||
|
||||
Console.WriteLine($"Connecting to Foundry Toolbox '{toolboxName}' MCP server...");
|
||||
|
||||
await using var mcpClient = await McpClient.CreateAsync(
|
||||
new HttpClientTransport(
|
||||
new HttpClientTransportOptions
|
||||
{
|
||||
Endpoint = new Uri(toolboxMcpServerUrl),
|
||||
Name = toolboxName,
|
||||
TransportMode = HttpTransportMode.StreamableHttp,
|
||||
AdditionalHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["Foundry-Features"] = "Toolboxes=V1Preview",
|
||||
},
|
||||
},
|
||||
httpClient));
|
||||
|
||||
// ── Configure MCP-based skills provider ──────────────────────────────────────
|
||||
var skillsProvider = new AgentSkillsProviderBuilder()
|
||||
.UseMcpSkills(mcpClient)
|
||||
.Build();
|
||||
|
||||
// ── Create the agent ─────────────────────────────────────────────────────────
|
||||
AIAgent agent = new AIProjectClient(new Uri(projectEndpoint), credential)
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-mcp-skills",
|
||||
Description = "Hosted agent with MCP skills discovered from a Foundry Toolbox",
|
||||
ChatOptions = new()
|
||||
{
|
||||
ModelId = deployment,
|
||||
Instructions = "You are a helpful assistant.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider],
|
||||
});
|
||||
|
||||
// ── Build the host ───────────────────────────────────────────────────────────
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
|
||||
app.Run();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HttpClientHandler: attaches a fresh Foundry bearer token to every request
|
||||
// ---------------------------------------------------------------------------
|
||||
internal sealed class BearerTokenHandler(TokenCredential credential, string scope) : HttpClientHandler
|
||||
{
|
||||
private readonly TokenRequestContext _tokenContext = new([scope]);
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# Hosted-ToolboxMcpSkills
|
||||
|
||||
A hosted agent that discovers **MCP-based skills from a Foundry Toolbox** and makes them available to the agent using `AgentSkillsProviderBuilder.UseMcpSkills(mcpClient)`.
|
||||
|
||||
The `AgentSkillsProvider` is attached to the agent as a context provider and implements the [Agent Skills](https://agentskills.io/) progressive-disclosure pattern. When the agent is prompted, it discovers available skills in the Foundry Toolbox via the provider:
|
||||
|
||||
1. **Advertise** - skill names and descriptions are injected into the system prompt so the agent knows what is available.
|
||||
2. **Load** - when the agent decides a skill is relevant, it retrieves the full skill body with detailed instructions via the provider.
|
||||
3. **Read resources** - if a skill includes supplementary content (reference documents, assets), the agent reads them on demand via the provider.
|
||||
|
||||
This way the full skill body and resources are only loaded when the agent actually needs them, reducing token usage.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `gpt-5`)
|
||||
- A Foundry Toolbox already configured with skills provisioned
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the template and fill in your values:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set your Azure AI Foundry project endpoint and toolbox name:
|
||||
|
||||
```env
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5
|
||||
FOUNDRY_TOOLBOX_NAME=my-toolbox
|
||||
```
|
||||
|
||||
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
|
||||
## Running directly (contributors)
|
||||
|
||||
This project uses `ProjectReference` to build against the local Agent Framework source.
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The agent will start on `http://localhost:8088`.
|
||||
|
||||
### Test it
|
||||
|
||||
Using the Azure Developer CLI:
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "What skills do you have available?"
|
||||
```
|
||||
|
||||
## Running with Docker
|
||||
|
||||
Since this project uses `ProjectReference`, use `Dockerfile.contributor` which takes a pre-published output.
|
||||
|
||||
### 1. Publish for the container runtime (Linux Alpine)
|
||||
|
||||
```bash
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
```
|
||||
|
||||
### 2. Build the Docker image
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.contributor -t hosted-toolbox-mcp-skills .
|
||||
```
|
||||
|
||||
### 3. Run the container
|
||||
|
||||
Generate a bearer token on your host and pass it to the container:
|
||||
|
||||
```bash
|
||||
# Generate token (expires in ~1 hour)
|
||||
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
|
||||
# Run with token
|
||||
docker run --rm -p 8088:8088 \
|
||||
-e AGENT_NAME=hosted-toolbox-mcp-skills \
|
||||
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
--env-file .env \
|
||||
hosted-toolbox-mcp-skills
|
||||
```
|
||||
|
||||
> **Note:** `AGENT_NAME` is passed via `-e` to simulate the platform injection. `AZURE_BEARER_TOKEN` provides Azure credentials to the container (tokens expire after ~1 hour). The `.env` file provides the remaining configuration.
|
||||
|
||||
### 4. Test it
|
||||
|
||||
Using the Azure Developer CLI:
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "What skills do you have available?"
|
||||
```
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedToolboxMcpSkills.csproj` for the `PackageReference` alternative.
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-toolbox-mcp-skills
|
||||
displayName: "Hosted Toolbox MCP Skills Agent"
|
||||
|
||||
description: >
|
||||
A hosted agent that discovers MCP-based skills from a Foundry Toolbox
|
||||
and makes them available to the agent via the agent skills provider.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Agent Framework
|
||||
- MCP
|
||||
- Model Context Protocol
|
||||
- Agent Skills
|
||||
- Foundry Toolbox
|
||||
- Foundry Toolbox Skills
|
||||
|
||||
template:
|
||||
name: hosted-toolbox-mcp-skills
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
- name: FOUNDRY_TOOLBOX_NAME
|
||||
value: "{{FOUNDRY_TOOLBOX_NAME}}"
|
||||
parameters:
|
||||
properties:
|
||||
- name: FOUNDRY_TOOLBOX_NAME
|
||||
secret: false
|
||||
description: Name of the Foundry Toolbox to connect to for MCP skill discovery
|
||||
resources:
|
||||
- kind: model
|
||||
id: gpt-5
|
||||
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-toolbox-mcp-skills
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
- name: FOUNDRY_TOOLBOX_NAME
|
||||
value: ${FOUNDRY_TOOLBOX_NAME}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleaseCandidate>true</IsReleaseCandidate>
|
||||
<!-- Preview while Microsoft.Agents.AI.Foundry is preview (blocked by Azure.AI.Projects 2.1.0-beta). Flip to IsReleased=true once that ships stable. -->
|
||||
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
+5
-3
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleaseCandidate>true</IsReleaseCandidate>
|
||||
<IsReleased>true</IsReleased>
|
||||
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<!-- Package not yet published to NuGet — disable baseline validation until first release -->
|
||||
<!-- First Stable release after the RC milestone. Baseline against the latest
|
||||
published RC so package validation catches accidental breaking changes.
|
||||
Future releases should bump this to the previous stable version. -->
|
||||
<PropertyGroup>
|
||||
<EnablePackageValidation>false</EnablePackageValidation>
|
||||
<PackageValidationBaselineVersion>1.8.0-rc1</PackageValidationBaselineVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
+8
-1
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleaseCandidate>true</IsReleaseCandidate>
|
||||
<IsReleased>true</IsReleased>
|
||||
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<!-- First Stable release after the RC milestone. Baseline against the latest
|
||||
published RC so package validation catches accidental breaking changes.
|
||||
Future releases should bump this to the previous stable version. -->
|
||||
<PropertyGroup>
|
||||
<PackageValidationBaselineVersion>1.8.0-rc1</PackageValidationBaselineVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Declarative Workflows</Title>
|
||||
|
||||
@@ -27,7 +27,7 @@ Status is grouped into these buckets:
|
||||
| `agent-framework-claude` | `python/packages/claude` | `beta` |
|
||||
| `agent-framework-copilotstudio` | `python/packages/copilotstudio` | `beta` |
|
||||
| `agent-framework-core` | `python/packages/core` | `released` |
|
||||
| `agent-framework-declarative` | `python/packages/declarative` | `beta` |
|
||||
| `agent-framework-declarative` | `python/packages/declarative` | `rc` |
|
||||
| `agent-framework-devui` | `python/packages/devui` | `beta` |
|
||||
| `agent-framework-durabletask` | `python/packages/durabletask` | `beta` |
|
||||
| `agent-framework-foundry` | `python/packages/foundry` | `released` |
|
||||
@@ -58,6 +58,13 @@ listed below.
|
||||
|
||||
### Experimental features
|
||||
|
||||
#### `DECLARATIVE_AGENTS`
|
||||
|
||||
- `agent-framework-declarative`: declarative agent loading APIs from
|
||||
`agent_framework_declarative`, including `AgentFactory`,
|
||||
`DeclarativeLoaderError`, `ProviderLookupError`, and `ProviderTypeMapping`
|
||||
from `agent_framework_declarative/_loader.py`
|
||||
|
||||
#### `EVALS`
|
||||
|
||||
- `agent-framework-core`: exported evaluation APIs from `agent_framework`, including
|
||||
|
||||
@@ -50,6 +50,7 @@ class ExperimentalFeature(str, Enum):
|
||||
on enum membership or attribute presence over time.
|
||||
"""
|
||||
|
||||
DECLARATIVE_AGENTS = "DECLARATIVE_AGENTS"
|
||||
EVALS = "EVALS"
|
||||
FILE_HISTORY = "FILE_HISTORY"
|
||||
FIDES = "FIDES"
|
||||
|
||||
@@ -498,14 +498,34 @@ def _get_exporters_from_env(
|
||||
# Get base endpoint
|
||||
base_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
|
||||
# Get signal-specific endpoints (these override base endpoint)
|
||||
traces_endpoint = os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") or base_endpoint
|
||||
metrics_endpoint = os.getenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT") or base_endpoint
|
||||
logs_endpoint = os.getenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT") or base_endpoint
|
||||
# Get signal-specific endpoints (these override base endpoint and are used verbatim)
|
||||
traces_endpoint_specific = os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
|
||||
metrics_endpoint_specific = os.getenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT")
|
||||
logs_endpoint_specific = os.getenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT")
|
||||
|
||||
# Get protocol (default is grpc)
|
||||
protocol = os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc").lower()
|
||||
|
||||
# Per the OTel spec, OTEL_EXPORTER_OTLP_ENDPOINT is a *base* URL for HTTP — the SDK
|
||||
# auto-appends /v1/{traces,metrics,logs} when it reads the env var directly. The
|
||||
# signal-specific endpoint env vars are *full* URLs used verbatim. Because we read
|
||||
# the env vars here and forward them as the ``endpoint=`` constructor argument
|
||||
# (which the SDK always treats as a full URL), we must replicate the auto-append
|
||||
# ourselves for HTTP when falling back to the base endpoint. For gRPC, the base
|
||||
# endpoint is used as-is.
|
||||
traces_endpoint: str | None
|
||||
metrics_endpoint: str | None
|
||||
logs_endpoint: str | None
|
||||
if protocol in ("http/protobuf", "http") and base_endpoint:
|
||||
base_for_http = base_endpoint.rstrip("/")
|
||||
traces_endpoint = traces_endpoint_specific or f"{base_for_http}/v1/traces"
|
||||
metrics_endpoint = metrics_endpoint_specific or f"{base_for_http}/v1/metrics"
|
||||
logs_endpoint = logs_endpoint_specific or f"{base_for_http}/v1/logs"
|
||||
else:
|
||||
traces_endpoint = traces_endpoint_specific or base_endpoint
|
||||
metrics_endpoint = metrics_endpoint_specific or base_endpoint
|
||||
logs_endpoint = logs_endpoint_specific or base_endpoint
|
||||
|
||||
# Get base headers
|
||||
base_headers_str = os.getenv("OTEL_EXPORTER_OTLP_HEADERS", "")
|
||||
base_headers = _parse_headers(base_headers_str)
|
||||
|
||||
@@ -761,6 +761,115 @@ def test_get_exporters_from_env_missing_grpc_dependency(monkeypatch):
|
||||
_get_exporters_from_env()
|
||||
|
||||
|
||||
# region Test OTLP endpoint computation (base-URL auto-append for HTTP)
|
||||
|
||||
|
||||
def test_get_exporters_from_env_http_base_endpoint_appends_signal_paths(monkeypatch):
|
||||
"""OTEL_EXPORTER_OTLP_ENDPOINT is a base URL for HTTP; SDK auto-appends
|
||||
/v1/{traces,metrics,logs}. Because we read the env var and forward it as the
|
||||
constructor ``endpoint=`` arg (which the SDK treats as a full URL), we must
|
||||
replicate the auto-append ourselves.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent_framework import observability
|
||||
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf")
|
||||
for key in (
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
with patch.object(observability, "_create_otlp_exporters", return_value=[]) as create:
|
||||
observability._get_exporters_from_env()
|
||||
|
||||
kwargs = create.call_args.kwargs
|
||||
assert kwargs["protocol"] == "http/protobuf"
|
||||
assert kwargs["traces_endpoint"] == "http://localhost:4318/v1/traces"
|
||||
assert kwargs["metrics_endpoint"] == "http://localhost:4318/v1/metrics"
|
||||
assert kwargs["logs_endpoint"] == "http://localhost:4318/v1/logs"
|
||||
|
||||
|
||||
def test_get_exporters_from_env_http_base_endpoint_trailing_slash(monkeypatch):
|
||||
"""A trailing slash on the base endpoint should not produce a doubled slash."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent_framework import observability
|
||||
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/")
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf")
|
||||
for key in (
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
with patch.object(observability, "_create_otlp_exporters", return_value=[]) as create:
|
||||
observability._get_exporters_from_env()
|
||||
|
||||
kwargs = create.call_args.kwargs
|
||||
assert kwargs["traces_endpoint"] == "http://localhost:4318/v1/traces"
|
||||
assert kwargs["metrics_endpoint"] == "http://localhost:4318/v1/metrics"
|
||||
assert kwargs["logs_endpoint"] == "http://localhost:4318/v1/logs"
|
||||
|
||||
|
||||
def test_get_exporters_from_env_http_signal_specific_used_verbatim(monkeypatch):
|
||||
"""Signal-specific endpoint env vars are full URLs and must be used verbatim,
|
||||
even when a base endpoint is also set.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent_framework import observability
|
||||
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "http://traces.example.com/custom/path")
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf")
|
||||
for key in (
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
with patch.object(observability, "_create_otlp_exporters", return_value=[]) as create:
|
||||
observability._get_exporters_from_env()
|
||||
|
||||
kwargs = create.call_args.kwargs
|
||||
# Signal-specific is verbatim — no path appended
|
||||
assert kwargs["traces_endpoint"] == "http://traces.example.com/custom/path"
|
||||
# Others fall back to base, with path appended
|
||||
assert kwargs["metrics_endpoint"] == "http://localhost:4318/v1/metrics"
|
||||
assert kwargs["logs_endpoint"] == "http://localhost:4318/v1/logs"
|
||||
|
||||
|
||||
def test_get_exporters_from_env_grpc_base_endpoint_unchanged(monkeypatch):
|
||||
"""For gRPC, the base endpoint applies to all signals as-is (no path append)."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent_framework import observability
|
||||
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc")
|
||||
for key in (
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
with patch.object(observability, "_create_otlp_exporters", return_value=[]) as create:
|
||||
observability._get_exporters_from_env()
|
||||
|
||||
kwargs = create.call_args.kwargs
|
||||
assert kwargs["protocol"] == "grpc"
|
||||
assert kwargs["traces_endpoint"] == "http://localhost:4317"
|
||||
assert kwargs["metrics_endpoint"] == "http://localhost:4317"
|
||||
assert kwargs["logs_endpoint"] == "http://localhost:4317"
|
||||
|
||||
|
||||
# region Test create_resource
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,18 @@ Please install this package via pip:
|
||||
pip install agent-framework-declarative --pre
|
||||
```
|
||||
|
||||
## Release stage
|
||||
|
||||
This package ships at two different stability levels:
|
||||
|
||||
- **Declarative workflows** (`WorkflowFactory`, executors, handlers, and the
|
||||
`_workflows` surface) are at **release-candidate** stability and may receive only
|
||||
minor refinements before GA.
|
||||
- **Declarative agents** (`AgentFactory` and the YAML agent loading/parsing path:
|
||||
`DeclarativeLoaderError`, `ProviderLookupError`, `ProviderTypeMapping`) are
|
||||
**experimental** and may change or be removed in future versions without notice.
|
||||
Using any of these symbols emits an `ExperimentalWarning` on first use.
|
||||
|
||||
## Declarative features
|
||||
|
||||
The declarative packages provides support for building agents based on a declarative yaml specification.
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Declarative specification support for Microsoft Agent Framework.
|
||||
|
||||
Release stage:
|
||||
|
||||
* The declarative-workflows surface (``WorkflowFactory``, executors, handlers,
|
||||
etc.) is at release-candidate stability.
|
||||
* The declarative-agents surface (``AgentFactory`` and the YAML agent
|
||||
loading/parsing path: ``DeclarativeLoaderError``, ``ProviderLookupError``,
|
||||
``ProviderTypeMapping``) is *experimental* and may change or be removed in
|
||||
future versions without notice. Using these symbols emits an
|
||||
``ExperimentalWarning`` on first use.
|
||||
"""
|
||||
|
||||
from importlib import metadata
|
||||
|
||||
from ._loader import AgentFactory, DeclarativeLoaderError, ProviderLookupError, ProviderTypeMapping
|
||||
|
||||
@@ -15,6 +15,10 @@ from agent_framework import (
|
||||
from agent_framework import (
|
||||
FunctionTool as AFFunctionTool,
|
||||
)
|
||||
from agent_framework._feature_stage import ( # type: ignore[reportPrivateUsage]
|
||||
ExperimentalFeature,
|
||||
experimental,
|
||||
)
|
||||
from agent_framework.exceptions import AgentException
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -43,6 +47,7 @@ else:
|
||||
from typing_extensions import TypedDict # type: ignore # pragma: no cover
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
|
||||
class ProviderTypeMapping(TypedDict, total=True):
|
||||
package: str
|
||||
name: str
|
||||
@@ -118,18 +123,21 @@ PROVIDER_TYPE_OBJECT_MAPPING: dict[str, ProviderTypeMapping] = {
|
||||
}
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
|
||||
class DeclarativeLoaderError(AgentException):
|
||||
"""Exception raised for errors in the declarative loader."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
|
||||
class ProviderLookupError(DeclarativeLoaderError):
|
||||
"""Exception raised for errors in provider type lookup."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
|
||||
class AgentFactory:
|
||||
"""Factory for creating Agent instances from declarative YAML definitions.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260528"
|
||||
version = "1.0.0rc1"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -49,7 +49,8 @@ addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = [
|
||||
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
|
||||
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*",
|
||||
"ignore::agent_framework._feature_stage.ExperimentalWarning",
|
||||
]
|
||||
timeout = 120
|
||||
markers = [
|
||||
|
||||
@@ -351,6 +351,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
if _uses_foundry_agent_session(conversation_id):
|
||||
run_options.pop("previous_response_id", None)
|
||||
run_options.pop("conversation", None)
|
||||
run_options.pop("model", None)
|
||||
extra_body["agent_session_id"] = conversation_id
|
||||
# Non-preview Prompt/Hosted Agent calls need agent_reference in the request body to
|
||||
# tell the Responses API which Foundry agent (and version) is in use, since ``model``
|
||||
@@ -366,7 +367,6 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
# Strip tools from request body - Foundry API rejects requests with both
|
||||
# agent endpoint and tools present. FunctionTools are invoked client-side
|
||||
# by the function invocation layer, not sent to the service.
|
||||
run_options.pop("model", None)
|
||||
if not self.allow_preview:
|
||||
run_options.pop("tools", None)
|
||||
run_options.pop("tool_choice", None)
|
||||
|
||||
@@ -203,7 +203,7 @@ async def test_raw_foundry_agent_chat_client_prepare_options_accepts_function_to
|
||||
|
||||
|
||||
async def test_raw_foundry_agent_chat_client_prepare_options_strips_client_side_fields() -> None:
|
||||
"""Test that _prepare_options strips model and tool-loop fields from run_options."""
|
||||
"""Test that _prepare_options strips tool-loop fields but preserves model for non-session requests."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_openai = MagicMock()
|
||||
@@ -235,16 +235,49 @@ async def test_raw_foundry_agent_chat_client_prepare_options_strips_client_side_
|
||||
options={"tools": [my_func]},
|
||||
)
|
||||
|
||||
assert "model" not in result
|
||||
# model is preserved for non-session (PromptAgent) requests
|
||||
assert result["model"] == "gpt-4.1"
|
||||
assert "tools" not in result
|
||||
assert "tool_choice" not in result
|
||||
assert "parallel_tool_calls" not in result
|
||||
# agent_reference is required so the Responses API can resolve model server-side; see #5582.
|
||||
assert result == {
|
||||
"model": "gpt-4.1",
|
||||
"extra_body": {"agent_reference": {"name": "test-agent", "type": "agent_reference"}},
|
||||
}
|
||||
|
||||
|
||||
async def test_raw_foundry_agent_chat_client_prepare_options_strips_model_for_hosted_session() -> None:
|
||||
"""Test that model is stripped when using a hosted agent session (not a PromptAgent)."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_openai = MagicMock()
|
||||
mock_project.get_openai_client.return_value = mock_openai
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"model": "gpt-4.1",
|
||||
"previous_response_id": "resp_abc",
|
||||
},
|
||||
):
|
||||
result = await client._prepare_options(
|
||||
messages=[Message(role="user", contents="hi")],
|
||||
options={"conversation_id": "agent-session-123"},
|
||||
)
|
||||
|
||||
assert "model" not in result
|
||||
assert "previous_response_id" not in result
|
||||
assert result["extra_body"]["agent_session_id"] == "agent-session-123"
|
||||
assert result["extra_body"]["agent_reference"] == {"name": "test-agent", "type": "agent_reference"}
|
||||
|
||||
|
||||
async def test_raw_foundry_agent_chat_client_prepare_options_injects_agent_reference_first_turn() -> None:
|
||||
"""First-turn (no conversation_id) Prompt Agent calls must carry agent_reference in extra_body.
|
||||
|
||||
@@ -272,7 +305,6 @@ async def test_raw_foundry_agent_chat_client_prepare_options_injects_agent_refer
|
||||
options={},
|
||||
)
|
||||
|
||||
assert "model" not in result
|
||||
assert result["extra_body"] == {
|
||||
"agent_reference": {"name": "test-agent", "type": "agent_reference", "version": "2"},
|
||||
}
|
||||
@@ -333,7 +365,8 @@ async def test_raw_foundry_agent_chat_client_prepare_options_skips_agent_referen
|
||||
options={},
|
||||
)
|
||||
|
||||
assert "model" not in result
|
||||
# model is preserved for non-session requests (platform tolerates it for hosted agents)
|
||||
assert result["model"] == "gpt-4.1"
|
||||
# No extra_body at all is the cleanest signal — agent_reference must not be injected here.
|
||||
assert "extra_body" not in result
|
||||
|
||||
@@ -363,6 +396,39 @@ async def test_raw_foundry_agent_chat_client_prepare_options_respects_caller_age
|
||||
assert result["extra_body"]["agent_reference"] == caller_reference
|
||||
|
||||
|
||||
async def test_raw_foundry_agent_chat_client_prepare_options_preserves_model_for_resp_continuation() -> None:
|
||||
"""Test that model is preserved when conversation_id is a resp_* continuation (HostedAgent v1 / v2-no-session)."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_openai = MagicMock()
|
||||
mock_project.get_openai_client.return_value = mock_openai
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"model": "gpt-4.1",
|
||||
"previous_response_id": "resp_abc123",
|
||||
},
|
||||
):
|
||||
result = await client._prepare_options(
|
||||
messages=[Message(role="user", contents="hi")],
|
||||
options={"conversation_id": "resp_abc123"},
|
||||
)
|
||||
|
||||
# model preserved — resp_* is standard Responses API continuity, not a hosted session
|
||||
assert result["model"] == "gpt-4.1"
|
||||
# previous_response_id preserved — not stripped outside hosted session path
|
||||
assert result["previous_response_id"] == "resp_abc123"
|
||||
# no agent_session_id injected
|
||||
assert "extra_body" not in result or "agent_session_id" not in result.get("extra_body", {})
|
||||
|
||||
|
||||
async def test_raw_foundry_agent_chat_client_prepare_options_maps_agent_session_id_to_extra_body() -> None:
|
||||
"""Test that service_session_id is forwarded as agent_session_id for hosted sessions."""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Generator, Sequence
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
|
||||
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from pathlib import Path
|
||||
@@ -472,14 +472,12 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
# Run the agent in non-streaming mode
|
||||
response = await self._agent.run(stream=False, **run_kwargs) # type: ignore[reportUnknownMemberType]
|
||||
|
||||
for message in response.messages:
|
||||
for content in message.contents:
|
||||
async for item in _to_outputs(
|
||||
response_event_stream,
|
||||
content,
|
||||
approval_storage=self._approval_storage,
|
||||
):
|
||||
yield item
|
||||
async for item in _to_outputs_for_messages(
|
||||
response_event_stream,
|
||||
response.messages,
|
||||
approval_storage=self._approval_storage,
|
||||
):
|
||||
yield item
|
||||
yield response_event_stream.emit_completed()
|
||||
else:
|
||||
if tracker is None: # pragma: no cover - defensive, set above
|
||||
@@ -620,12 +618,8 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
checkpoint_storage=write_storage,
|
||||
)
|
||||
|
||||
for message in response.messages:
|
||||
for content in message.contents:
|
||||
async for item in _to_outputs(
|
||||
response_event_stream, content, approval_storage=self._approval_storage
|
||||
):
|
||||
yield item
|
||||
async for item in _to_outputs_for_messages(response_event_stream, response.messages):
|
||||
yield item
|
||||
|
||||
await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name)
|
||||
yield response_event_stream.emit_completed()
|
||||
@@ -733,7 +727,7 @@ class _OutputItemTracker:
|
||||
yield self._fc_builder.emit_arguments_delta(args_str)
|
||||
|
||||
elif content.type == "mcp_server_tool_call" and content.tool_name:
|
||||
key = f"{content.server_name or 'default'}::{content.tool_name}"
|
||||
key = content.call_id or f"{content.server_name or 'default'}::{content.tool_name}"
|
||||
if self._active_type != "mcp_server_tool_call" or self._active_id != key:
|
||||
yield from self._close()
|
||||
yield from self._open_mcp_call(content)
|
||||
@@ -742,6 +736,24 @@ class _OutputItemTracker:
|
||||
if self._mcp_builder is not None:
|
||||
yield self._mcp_builder.emit_arguments_delta(args_str)
|
||||
|
||||
elif (
|
||||
content.type == "mcp_server_tool_result"
|
||||
and self._active_type == "mcp_server_tool_call"
|
||||
and self._mcp_builder is not None
|
||||
and content.call_id is not None
|
||||
and content.call_id == self._mcp_builder.item_id
|
||||
):
|
||||
accumulated = "".join(self._accumulated)
|
||||
yield self._mcp_builder.emit_arguments_done(accumulated)
|
||||
yield self._mcp_builder.emit_completed()
|
||||
yield self._mcp_builder.emit_done(output=_stringify_mcp_output(content.output))
|
||||
self._mcp_builder = None
|
||||
self._active_type = None
|
||||
self._active_id = None
|
||||
self._accumulated.clear()
|
||||
self.needs_async = False
|
||||
return
|
||||
|
||||
else:
|
||||
yield from self._close()
|
||||
self.needs_async = True
|
||||
@@ -781,9 +793,10 @@ class _OutputItemTracker:
|
||||
self._mcp_builder = self._stream.add_output_item_mcp_call(
|
||||
server_label=content.server_name or "default",
|
||||
name=content.tool_name or "",
|
||||
item_id=content.call_id,
|
||||
)
|
||||
self._active_type = "mcp_server_tool_call"
|
||||
self._active_id = f"{content.server_name or 'default'}::{content.tool_name}"
|
||||
self._active_id = content.call_id or f"{content.server_name or 'default'}::{content.tool_name}"
|
||||
yield self._mcp_builder.emit_added()
|
||||
|
||||
def _close(self) -> Generator[ResponseStreamEvent]:
|
||||
@@ -931,16 +944,19 @@ async def _item_to_message(item: Item, *, approval_storage: ApprovalStorage | No
|
||||
|
||||
if item.type == "mcp_call":
|
||||
mcp = cast(ItemMcpToolCall, item)
|
||||
contents = [
|
||||
Content.from_mcp_server_tool_call(
|
||||
mcp.id,
|
||||
mcp.name,
|
||||
server_name=mcp.server_label,
|
||||
arguments=mcp.arguments,
|
||||
)
|
||||
]
|
||||
if getattr(mcp, "output", None) is not None:
|
||||
contents.append(Content.from_mcp_server_tool_result(call_id=mcp.id, output=mcp.output))
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_call(
|
||||
mcp.id,
|
||||
mcp.name,
|
||||
server_name=mcp.server_label,
|
||||
arguments=mcp.arguments,
|
||||
)
|
||||
],
|
||||
contents=contents,
|
||||
)
|
||||
|
||||
if item.type == "mcp_approval_request":
|
||||
@@ -1201,16 +1217,19 @@ async def _output_item_to_message(item: OutputItem, *, approval_storage: Approva
|
||||
|
||||
if item.type == "mcp_call":
|
||||
mcp = cast(OutputItemMcpToolCall, item)
|
||||
contents = [
|
||||
Content.from_mcp_server_tool_call(
|
||||
mcp.id,
|
||||
mcp.name,
|
||||
server_name=mcp.server_label,
|
||||
arguments=mcp.arguments,
|
||||
)
|
||||
]
|
||||
if getattr(mcp, "output", None) is not None:
|
||||
contents.append(Content.from_mcp_server_tool_result(call_id=mcp.id, output=mcp.output))
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_call(
|
||||
mcp.id,
|
||||
mcp.name,
|
||||
server_name=mcp.server_label,
|
||||
arguments=mcp.arguments,
|
||||
)
|
||||
],
|
||||
contents=contents,
|
||||
)
|
||||
|
||||
if item.type == "mcp_approval_request":
|
||||
@@ -1587,6 +1606,7 @@ async def _to_outputs(
|
||||
mcp_call = stream.add_output_item_mcp_call(
|
||||
server_label=content.server_name or "default",
|
||||
name=content.tool_name or "",
|
||||
item_id=content.call_id,
|
||||
)
|
||||
yield mcp_call.emit_added()
|
||||
async for event in mcp_call.aarguments(_arguments_to_str(content.arguments)):
|
||||
@@ -1661,4 +1681,91 @@ async def _to_outputs(
|
||||
logger.warning(f"Content type '{content.type}' is not supported yet. This is usually safe to ignore.")
|
||||
|
||||
|
||||
def _stringify_mcp_output(output: Any) -> str:
|
||||
"""Convert hosted MCP output payloads into the string shape expected by mcp_call.output."""
|
||||
if output is None:
|
||||
return ""
|
||||
if isinstance(output, str):
|
||||
return output
|
||||
if isinstance(output, Mapping):
|
||||
text = cast(Any, output).get("text")
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return json.dumps(output, default=str)
|
||||
if isinstance(output, Sequence) and not isinstance(output, (str, bytes, bytearray)):
|
||||
parts: list[str] = []
|
||||
entries = cast(Sequence[object], output)
|
||||
for entry in entries:
|
||||
if isinstance(entry, Content) and entry.type == "text":
|
||||
parts.append(entry.text or "")
|
||||
continue
|
||||
parts.append(_stringify_mcp_output(entry))
|
||||
return "".join(parts)
|
||||
return str(output)
|
||||
|
||||
|
||||
def _emit_completed_mcp_call(
|
||||
stream: ResponseEventStream,
|
||||
call_content: Content,
|
||||
*,
|
||||
arguments: str,
|
||||
output: str,
|
||||
) -> Generator[ResponseStreamEvent]:
|
||||
"""Emit a single completed MCP call item carrying both arguments and output."""
|
||||
mcp_call = stream.add_output_item_mcp_call(
|
||||
server_label=call_content.server_name or "default",
|
||||
name=call_content.tool_name or "",
|
||||
item_id=call_content.call_id,
|
||||
)
|
||||
yield mcp_call.emit_added()
|
||||
yield mcp_call.emit_arguments_done(arguments)
|
||||
yield mcp_call.emit_completed()
|
||||
yield mcp_call.emit_done(output=output)
|
||||
|
||||
|
||||
async def _to_outputs_for_messages(
|
||||
stream: ResponseEventStream,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
approval_storage: ApprovalStorage | None = None,
|
||||
) -> AsyncIterator[ResponseStreamEvent]:
|
||||
"""Convert messages to output events with hosted-MCP call/result coalescing.
|
||||
|
||||
Parse once in message/content order and emit either:
|
||||
- a single canonical completed ``mcp_call`` when adjacent hosted MCP
|
||||
call/result content are encountered, or
|
||||
- standard output items for all other content types.
|
||||
"""
|
||||
pending_mcp_call: Content | None = None
|
||||
|
||||
for message in messages:
|
||||
for content in message.contents:
|
||||
if pending_mcp_call is not None:
|
||||
if content.type == "mcp_server_tool_result" and content.call_id == pending_mcp_call.call_id:
|
||||
for event in _emit_completed_mcp_call(
|
||||
stream,
|
||||
pending_mcp_call,
|
||||
arguments=_arguments_to_str(pending_mcp_call.arguments),
|
||||
output=_stringify_mcp_output(content.output),
|
||||
):
|
||||
yield event
|
||||
pending_mcp_call = None
|
||||
continue
|
||||
|
||||
async for event in _to_outputs(stream, pending_mcp_call, approval_storage=approval_storage):
|
||||
yield event
|
||||
pending_mcp_call = None
|
||||
|
||||
if content.type == "mcp_server_tool_call" and content.call_id:
|
||||
pending_mcp_call = content
|
||||
continue
|
||||
|
||||
async for event in _to_outputs(stream, content, approval_storage=approval_storage):
|
||||
yield event
|
||||
|
||||
if pending_mcp_call is not None:
|
||||
async for event in _to_outputs(stream, pending_mcp_call, approval_storage=approval_storage):
|
||||
yield event
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -25,7 +25,7 @@ classifiers = [
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"azure-ai-agentserver-core>=2.0.0b3,<3",
|
||||
"azure-ai-agentserver-responses>=1.0.0b5,<2",
|
||||
"azure-ai-agentserver-responses>=1.0.0b7,<2",
|
||||
"azure-ai-agentserver-invocations>=1.0.0b3,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -269,6 +269,50 @@ class TestNonStreaming:
|
||||
assert "function_call_output" in types
|
||||
assert "message" in types
|
||||
|
||||
async def test_hosted_mcp_call_and_result_persist_as_single_mcp_call(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(
|
||||
messages=[
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_call(
|
||||
call_id="mcp_abc123",
|
||||
tool_name="search",
|
||||
server_name="api_specs",
|
||||
arguments='{"q": "cats"}',
|
||||
)
|
||||
],
|
||||
),
|
||||
Message(
|
||||
role="tool",
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_result(
|
||||
call_id="mcp_abc123",
|
||||
output=[Content.from_text(text="found 10 cats")],
|
||||
)
|
||||
],
|
||||
),
|
||||
Message(role="assistant", contents=[Content.from_text("I found 10 cats!")]),
|
||||
]
|
||||
)
|
||||
)
|
||||
server = _make_server(agent)
|
||||
resp = await _post(server, stream=False)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
|
||||
types = [item["type"] for item in body["output"]]
|
||||
assert "mcp_call" in types
|
||||
assert "custom_tool_call_output" not in types
|
||||
|
||||
mcp_items = [item for item in body["output"] if item["type"] == "mcp_call"]
|
||||
assert len(mcp_items) == 1
|
||||
assert mcp_items[0]["id"] == "mcp_abc123"
|
||||
assert mcp_items[0]["output"] == "found 10 cats"
|
||||
|
||||
async def test_reasoning_content(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(
|
||||
@@ -626,6 +670,53 @@ class TestStreaming:
|
||||
assert "response.output_item.added" in types
|
||||
assert "response.output_item.done" in types
|
||||
|
||||
async def test_mcp_tool_call_and_result_streaming_emit_single_completed_mcp_call(self) -> None:
|
||||
agent = _make_agent(
|
||||
stream_updates=[
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_call(
|
||||
call_id="mcp_abc123",
|
||||
tool_name="search",
|
||||
server_name="api_specs",
|
||||
arguments='{"q":',
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_call(
|
||||
call_id="mcp_abc123",
|
||||
tool_name="search",
|
||||
server_name="api_specs",
|
||||
arguments=' "cats"}',
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_result(
|
||||
call_id="mcp_abc123",
|
||||
output=[Content.from_text(text="found 10 cats")],
|
||||
)
|
||||
],
|
||||
role="tool",
|
||||
),
|
||||
]
|
||||
)
|
||||
server = _make_server(agent)
|
||||
resp = await _post(server, stream=True)
|
||||
|
||||
assert resp.status_code == 200
|
||||
events = _parse_sse_events(resp.text)
|
||||
done_events = [e for e in events if e["event"] == "response.output_item.done"]
|
||||
assert len(done_events) == 1
|
||||
assert done_events[0]["data"]["item"]["type"] == "mcp_call"
|
||||
assert done_events[0]["data"]["item"]["id"] == "mcp_abc123"
|
||||
assert done_events[0]["data"]["item"]["output"] == "found 10 cats"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -729,6 +820,24 @@ class TestOutputItemToMessage:
|
||||
assert msg.contents[0].server_name == "my_server"
|
||||
assert msg.contents[0].tool_name == "search"
|
||||
|
||||
async def test_mcp_call_with_output_reconstructs_mcp_result_content(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItemMcpToolCall
|
||||
|
||||
item = OutputItemMcpToolCall({
|
||||
"type": "mcp_call",
|
||||
"id": "mcp-1",
|
||||
"server_label": "my_server",
|
||||
"name": "search",
|
||||
"arguments": '{"q": "test"}',
|
||||
"output": "found 10 cats",
|
||||
})
|
||||
msg = await _output_item_to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert len(msg.contents) == 2
|
||||
assert msg.contents[0].type == "mcp_server_tool_call"
|
||||
assert msg.contents[1].type == "mcp_server_tool_result"
|
||||
assert msg.contents[1].output == "found 10 cats"
|
||||
|
||||
async def test_mcp_approval_request(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItemMcpApprovalRequest
|
||||
|
||||
@@ -1198,6 +1307,25 @@ class TestItemToMessage:
|
||||
assert msg.contents[0].server_name == "my_server"
|
||||
assert msg.contents[0].tool_name == "search"
|
||||
|
||||
async def test_mcp_call_with_output_reconstructs_mcp_result_content(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import ItemMcpToolCall
|
||||
|
||||
item = ItemMcpToolCall({
|
||||
"type": "mcp_call",
|
||||
"id": "mcp-1",
|
||||
"server_label": "my_server",
|
||||
"name": "search",
|
||||
"arguments": '{"q": "test"}',
|
||||
"output": "found 10 cats",
|
||||
})
|
||||
msg = await _item_to_message(item)
|
||||
assert msg is not None
|
||||
assert msg.role == "assistant"
|
||||
assert len(msg.contents) == 2
|
||||
assert msg.contents[0].type == "mcp_server_tool_call"
|
||||
assert msg.contents[1].type == "mcp_server_tool_result"
|
||||
assert msg.contents[1].output == "found 10 cats"
|
||||
|
||||
async def test_mcp_approval_request(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import ItemMcpApprovalRequest
|
||||
|
||||
@@ -1946,6 +2074,71 @@ class TestMultiTurnMixedContent:
|
||||
assert len(fc_contents) >= 1
|
||||
assert fc_contents[0].name == "search"
|
||||
|
||||
async def test_hosted_mcp_call_round_trip_does_not_orphan_function_call_output(self) -> None:
|
||||
"""Turn 1 produces hosted MCP call + result, turn 2 must replay both without orphaning output."""
|
||||
agent = _make_multi_response_agent([
|
||||
AgentResponse(
|
||||
messages=[
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_call(
|
||||
call_id="mcp_abc123",
|
||||
tool_name="search",
|
||||
server_name="api_specs",
|
||||
arguments='{"q": "cats"}',
|
||||
)
|
||||
],
|
||||
),
|
||||
Message(
|
||||
role="tool",
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_result(
|
||||
call_id="mcp_abc123",
|
||||
output=[Content.from_text(text="found 10 cats")],
|
||||
)
|
||||
],
|
||||
),
|
||||
Message(role="assistant", contents=[Content.from_text("I found 10 cats!")]),
|
||||
]
|
||||
),
|
||||
AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Here are more details")])]),
|
||||
])
|
||||
server = _make_server(agent)
|
||||
|
||||
resp1 = await _post(server, input_text="Search for cats", stream=False)
|
||||
assert resp1.status_code == 200
|
||||
response_id = resp1.json()["id"]
|
||||
|
||||
types1 = [item["type"] for item in resp1.json()["output"]]
|
||||
assert "mcp_call" in types1
|
||||
assert "custom_tool_call_output" not in types1
|
||||
|
||||
resp2 = await _post_json(
|
||||
server,
|
||||
{
|
||||
"model": "test-model",
|
||||
"input": "Tell me more",
|
||||
"stream": False,
|
||||
"previous_response_id": response_id,
|
||||
},
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
assert resp2.json()["status"] == "completed"
|
||||
|
||||
second_call_messages = agent.run.call_args_list[1].kwargs["messages"]
|
||||
mcp_call_contents = [c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_call"]
|
||||
mcp_result_contents = [
|
||||
c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_result"
|
||||
]
|
||||
function_result_contents = [c for m in second_call_messages for c in m.contents if c.type == "function_result"]
|
||||
|
||||
assert len(mcp_call_contents) >= 1
|
||||
assert len(mcp_result_contents) >= 1
|
||||
assert all((c.call_id or "") != "mcp_abc123" for c in function_result_contents)
|
||||
assert any((c.call_id or "") == "mcp_abc123" for c in mcp_call_contents)
|
||||
assert any((c.call_id or "") == "mcp_abc123" for c in mcp_result_contents)
|
||||
|
||||
async def test_multi_turn_reasoning_in_history(self) -> None:
|
||||
"""Turn 1 produces reasoning + text, turn 2 sees them in history."""
|
||||
agent = _make_multi_response_agent([
|
||||
|
||||
@@ -64,7 +64,6 @@ actions:
|
||||
|
||||
### Agent Invocation
|
||||
- `InvokeAzureAgent` - Call an Azure AI agent
|
||||
- `InvokePromptAgent` - Call a local prompt agent
|
||||
|
||||
### Tool Invocation
|
||||
- `InvokeFunctionTool` - Call a registered Python function
|
||||
|
||||
Generated
+4345
-4350
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user