mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72a193086e | ||
|
|
793403f3db | ||
|
|
9fdd7429a8 | ||
|
|
abc9b60ec9 | ||
|
|
6bc0dc5911 | ||
|
|
cf91819625 | ||
|
|
578416a379 | ||
|
|
c82c0133fc |
@@ -137,6 +137,7 @@ jobs:
|
|||||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
|
||||||
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||||
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||||
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
|
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
|
||||||
|
|||||||
@@ -212,6 +212,7 @@
|
|||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Samples/02-agents/ModelContextProtocol/">
|
<Folder Name="/Samples/02-agents/ModelContextProtocol/">
|
||||||
<File Path="samples/02-agents/ModelContextProtocol/README.md" />
|
<File Path="samples/02-agents/ModelContextProtocol/README.md" />
|
||||||
|
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/Agent_MCP_LongRunningTask_Client.csproj" />
|
||||||
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj" />
|
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj" />
|
||||||
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj" />
|
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj" />
|
||||||
<Project Path="samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj" />
|
<Project Path="samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj" />
|
||||||
@@ -281,6 +282,7 @@
|
|||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Samples/03-workflows/Orchestration/">
|
<Folder Name="/Samples/03-workflows/Orchestration/">
|
||||||
<Project Path="samples/03-workflows/Orchestration/Handoff/Handoff.csproj" />
|
<Project Path="samples/03-workflows/Orchestration/Handoff/Handoff.csproj" />
|
||||||
|
<Project Path="samples/03-workflows/Orchestration/Magentic/Magentic.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Samples/03-workflows/Observability/">
|
<Folder Name="/Samples/03-workflows/Observability/">
|
||||||
<Project Path="samples/03-workflows/Observability/ApplicationInsights/ApplicationInsights.csproj" />
|
<Project Path="samples/03-workflows/Observability/ApplicationInsights/ApplicationInsights.csproj" />
|
||||||
@@ -601,6 +603,7 @@
|
|||||||
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||||
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||||
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
|
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||||
|
<Project Path="src/Microsoft.Agents.AI.Mcp/Microsoft.Agents.AI.Mcp.csproj" />
|
||||||
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
|
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
|
||||||
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
|
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
|
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
|
||||||
@@ -654,6 +657,7 @@
|
|||||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
|
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
|
||||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
|
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
|
||||||
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj" />
|
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj" />
|
||||||
|
<Project Path="tests/Microsoft.Agents.AI.Mcp.UnitTests/Microsoft.Agents.AI.Mcp.UnitTests.csproj" />
|
||||||
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
|
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
|
||||||
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
|
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
|
||||||
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
|
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
|
||||||
|
|||||||
@@ -1151,6 +1151,25 @@ internal static class AgentsSamples
|
|||||||
SkipReason = "Runs as an MCP stdio server that does not exit on its own.",
|
SkipReason = "Runs as an MCP stdio server that does not exit on its own.",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
new SampleDefinition
|
||||||
|
{
|
||||||
|
Name = "Agent_MCP_LongRunningTask_Client",
|
||||||
|
ProjectPath = "samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client",
|
||||||
|
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
|
||||||
|
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
|
||||||
|
MustContain =
|
||||||
|
[
|
||||||
|
"=== Transparent long-running MCP task (RunAsync) ===",
|
||||||
|
"=== Transparent long-running MCP task (RunStreamingAsync) ===",
|
||||||
|
],
|
||||||
|
ExpectedOutputDescription =
|
||||||
|
[
|
||||||
|
"The output should show an agent analyzing a dataset named 'sales-2025-q1' and producing a summary mentioning rows, revenue, anomalies, or outliers.",
|
||||||
|
"The output should contain both a non-streaming response (after RunAsync) and a streaming response (after RunStreamingAsync) for the same analysis question.",
|
||||||
|
"The output should not contain error messages or stack traces.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
new SampleDefinition
|
new SampleDefinition
|
||||||
{
|
{
|
||||||
Name = "AGUI_Step01_GettingStarted_Client",
|
Name = "AGUI_Step01_GettingStarted_Client",
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<NoWarn>$(NoWarn);MAAI001;MEAI001;MCPEXP001</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Azure.AI.OpenAI" />
|
||||||
|
<PackageReference Include="Azure.Identity" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
|
<PackageReference Include="ModelContextProtocol" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// This sample demonstrates the Microsoft Agent Framework's MCP long-running task support.
|
||||||
|
//
|
||||||
|
// A small MCP server (hosted in this same executable when launched with "--server") exposes
|
||||||
|
// a single task-supporting tool "AnalyzeDataset" that simulates ~15 seconds of work. The
|
||||||
|
// client (default mode) connects to it over stdio via Microsoft.Agents.AI.Mcp's
|
||||||
|
// McpClientTaskExtensions.ListAgentToolsWithTaskSupportAsync, hands the wrapped tools to a
|
||||||
|
// ChatClientAgent, and exercises both invocation styles:
|
||||||
|
// * RunAsync — blocks until the agent's final response is ready.
|
||||||
|
// * RunStreamingAsync — yields response updates as the model produces them; the model
|
||||||
|
// still waits for the tool's terminal result before it can begin
|
||||||
|
// producing the final answer, so the perceived "pause" reflects
|
||||||
|
// tool execution time, not stream-channel latency.
|
||||||
|
//
|
||||||
|
// In both cases the wrapper transparently:
|
||||||
|
// 1. Calls tools/call with task augmentation (CallToolAsTaskAsync)
|
||||||
|
// 2. Polls tasks/get until terminal (PollTaskUntilCompleteAsync)
|
||||||
|
// 3. Fetches tasks/result and returns the final result to the function-calling loop
|
||||||
|
//
|
||||||
|
// No application-level loop or continuation tokens are required in either mode.
|
||||||
|
|
||||||
|
using System.ComponentModel;
|
||||||
|
using Azure.AI.OpenAI;
|
||||||
|
using Azure.Identity;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Agents.AI.Mcp;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ModelContextProtocol;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using OpenAI.Chat;
|
||||||
|
|
||||||
|
if (args.Length > 0 && args[0] == "--server")
|
||||||
|
{
|
||||||
|
await RunMcpServerAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||||
|
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||||
|
|
||||||
|
// Launch this same assembly as a stdio MCP server in a child process.
|
||||||
|
var thisAssemblyPath = typeof(Program).Assembly.Location;
|
||||||
|
await using var mcpClient = await McpClient.CreateAsync(new StdioClientTransport(new()
|
||||||
|
{
|
||||||
|
Name = "DatasetAnalyzer",
|
||||||
|
Command = "dotnet",
|
||||||
|
Arguments = [thisAssemblyPath, "--server"],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Wrap each MCP tool with task-aware behavior. The wrapper inspects the server's
|
||||||
|
// execution.taskSupport on each tool and, when it is Required, drives the task lifecycle
|
||||||
|
// transparently within the agent's tool loop. Tools that don't require task semantics are
|
||||||
|
// returned as-is and invoked inline.
|
||||||
|
var taskOptions = new McpTaskOptions
|
||||||
|
{
|
||||||
|
DefaultTimeToLive = TimeSpan.FromMinutes(5),
|
||||||
|
};
|
||||||
|
var mcpTools = await mcpClient.ListAgentToolsWithTaskSupportAsync(taskOptions);
|
||||||
|
|
||||||
|
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||||
|
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||||
|
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||||
|
AIAgent agent = new AzureOpenAIClient(
|
||||||
|
new Uri(endpoint),
|
||||||
|
new DefaultAzureCredential())
|
||||||
|
.GetChatClient(deploymentName)
|
||||||
|
.AsAIAgent(
|
||||||
|
instructions: "You answer data-analysis questions by invoking the available tools. Always invoke a tool when one matches the request.",
|
||||||
|
tools: [.. mcpTools.Cast<AITool>()]);
|
||||||
|
|
||||||
|
const string Prompt = "Analyze the dataset named 'sales-2025-q1' and summarize the findings.";
|
||||||
|
|
||||||
|
Console.WriteLine("=== Transparent long-running MCP task (RunAsync) ===");
|
||||||
|
Console.WriteLine("Asking the agent to analyze a dataset; the tool takes ~15s to complete.");
|
||||||
|
Console.WriteLine("RunAsync blocks while the wrapper polls the task to completion.");
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var response = await agent.RunAsync(Prompt);
|
||||||
|
stopwatch.Stop();
|
||||||
|
|
||||||
|
Console.WriteLine($"Agent response (after {stopwatch.Elapsed.TotalSeconds:F1}s):");
|
||||||
|
Console.WriteLine(response.Text);
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("=== Transparent long-running MCP task (RunStreamingAsync) ===");
|
||||||
|
Console.WriteLine("Same request via the streaming API. Updates only begin to arrive after the");
|
||||||
|
Console.WriteLine("tool's task reaches the Completed state, since the model needs the tool result");
|
||||||
|
Console.WriteLine("before it can produce its final answer.");
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
stopwatch.Restart();
|
||||||
|
await foreach (var update in agent.RunStreamingAsync(Prompt))
|
||||||
|
{
|
||||||
|
Console.Write(update.Text);
|
||||||
|
}
|
||||||
|
stopwatch.Stop();
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine($"(Streaming completed after {stopwatch.Elapsed.TotalSeconds:F1}s.)");
|
||||||
|
|
||||||
|
// --- Server mode (launched as a child process via --server) ---------------------------------
|
||||||
|
static async Task RunMcpServerAsync()
|
||||||
|
{
|
||||||
|
var builder = Host.CreateApplicationBuilder();
|
||||||
|
|
||||||
|
// Critical for stdio transport: any provider that writes to stdout will corrupt the
|
||||||
|
// JSON-RPC channel. Clear all providers; the MCP SDK routes its own diagnostics
|
||||||
|
// appropriately.
|
||||||
|
builder.Logging.ClearProviders();
|
||||||
|
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
|
||||||
|
|
||||||
|
builder.Services.AddMcpServer(o =>
|
||||||
|
{
|
||||||
|
o.TaskStore = new InMemoryMcpTaskStore();
|
||||||
|
o.ServerInfo = new Implementation { Name = "DatasetAnalyzer", Version = "1.0.0" };
|
||||||
|
})
|
||||||
|
.WithStdioServerTransport()
|
||||||
|
.WithTools<DatasetAnalysisTools>();
|
||||||
|
|
||||||
|
await builder.Build().RunAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma warning disable CA1812 // Discovered by MCP SDK via [McpServerToolType] attribute
|
||||||
|
[McpServerToolType]
|
||||||
|
internal sealed class DatasetAnalysisTools
|
||||||
|
#pragma warning restore CA1812
|
||||||
|
{
|
||||||
|
[McpServerTool(Name = "AnalyzeDataset", TaskSupport = ToolTaskSupport.Required)]
|
||||||
|
[Description("Analyze a tabular dataset and return summary statistics. This tool simulates a long-running analytic job (~15 seconds).")]
|
||||||
|
public static async Task<string> AnalyzeDatasetAsync(
|
||||||
|
[Description("The dataset identifier, e.g. 'sales-2025-q1'.")] string datasetName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(15), cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return $"Findings for '{datasetName}': 12,403 rows; avg revenue $48,712; 3 anomalies detected in week 7; outliers concentrated in EMEA region.";
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
# Agent with MCP long-running task (transparent polling)
|
||||||
|
|
||||||
|
This sample demonstrates Microsoft Agent Framework's MCP long-running task support: an agent invokes an MCP tool whose execution takes too long for a single request/response cycle, and the framework polls it to completion behind the function-calling loop. From the agent's perspective the tool simply returns its result.
|
||||||
|
|
||||||
|
## What this sample shows
|
||||||
|
|
||||||
|
- Using `McpClient.ListAgentToolsWithTaskSupportAsync(...)` (in `Microsoft.Agents.AI.Mcp`) to wrap MCP tools with task-aware behavior.
|
||||||
|
- Configuring `McpTaskOptions.DefaultTimeToLive` to bound the server-side task.
|
||||||
|
- Hosting a small MCP server (in this same executable, launched with `--server`) that advertises `execution.taskSupport=required` on a tool that sleeps for ~15 seconds.
|
||||||
|
- No application-level polling, continuation tokens, or `AllowBackgroundResponses` flag are required.
|
||||||
|
|
||||||
|
The decorator drives the lifecycle internally:
|
||||||
|
|
||||||
|
1. `tools/call` augmented with task metadata (`CallToolAsTaskAsync`)
|
||||||
|
2. `tasks/get` polled until terminal (`PollTaskUntilCompleteAsync`)
|
||||||
|
3. `tasks/result` retrieved (`GetTaskResultAsync`) and returned to the function-calling loop
|
||||||
|
|
||||||
|
The sample exercises both invocation styles against the same wrapper:
|
||||||
|
|
||||||
|
- `agent.RunAsync(...)` blocks until the tool completes (~15 seconds in this sample) and returns the final response.
|
||||||
|
- `agent.RunStreamingAsync(...)` returns immediately and yields `AgentResponseUpdate` chunks as the model emits them; in this scenario the model only begins streaming its answer once the wrapped tool's task reaches the `Completed` state, so the perceived "pause" before tokens arrive reflects tool execution time, not stream-channel latency.
|
||||||
|
|
||||||
|
# Prerequisites
|
||||||
|
|
||||||
|
- .NET 10 SDK or later
|
||||||
|
- Azure OpenAI service endpoint and a chat-completions deployment
|
||||||
|
- Azure CLI installed and authenticated (`az login`)
|
||||||
|
|
||||||
|
Set the following environment variables:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
|
||||||
|
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # optional; defaults to gpt-5.4-mini
|
||||||
|
```
|
||||||
|
|
||||||
|
# Running
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd Agent_MCP_LongRunningTask_Client
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see output similar to:
|
||||||
|
|
||||||
|
```
|
||||||
|
=== Transparent long-running MCP task (RunAsync) ===
|
||||||
|
Asking the agent to analyze a dataset; the tool takes ~15s to complete.
|
||||||
|
RunAsync blocks while the wrapper polls the task to completion.
|
||||||
|
|
||||||
|
Agent response (after 15.4s):
|
||||||
|
The 'sales-2025-q1' dataset contains 12,403 rows ...
|
||||||
|
|
||||||
|
=== Transparent long-running MCP task (RunStreamingAsync) ===
|
||||||
|
Same request via the streaming API. Updates only begin to arrive after the
|
||||||
|
tool's task reaches the Completed state, since the model needs the tool result
|
||||||
|
before it can produce its final answer.
|
||||||
|
|
||||||
|
The 'sales-2025-q1' dataset contains 12,403 rows ...
|
||||||
|
(Streaming completed after 15.7s.)
|
||||||
|
```
|
||||||
@@ -22,6 +22,7 @@ Before you begin, ensure you have the following prerequisites:
|
|||||||
|[Agent with MCP server tools](./Agent_MCP_Server/)|This sample demonstrates how to use MCP server tools with a simple agent|
|
|[Agent with MCP server tools](./Agent_MCP_Server/)|This sample demonstrates how to use MCP server tools with a simple agent|
|
||||||
|[Agent with MCP server tools and authorization](./Agent_MCP_Server_Auth/)|This sample demonstrates how to use MCP Server tools from a protected MCP server with a simple agent|
|
|[Agent with MCP server tools and authorization](./Agent_MCP_Server_Auth/)|This sample demonstrates how to use MCP Server tools from a protected MCP server with a simple agent|
|
||||||
|[Responses Agent with Hosted MCP tool](./ResponseAgent_Hosted_MCP/)|This sample demonstrates how to use the Hosted MCP tool with the Responses Service, where the service invokes any MCP tools directly|
|
|[Responses Agent with Hosted MCP tool](./ResponseAgent_Hosted_MCP/)|This sample demonstrates how to use the Hosted MCP tool with the Responses Service, where the service invokes any MCP tools directly|
|
||||||
|
|[Agent with long-running MCP task (transparent polling)](./Agent_MCP_LongRunningTask_Client/)|This sample demonstrates how an agent transparently drives a long-running MCP task (SEP-2663) to completion. The wrapper polls the task internally on both `RunAsync` and `RunStreamingAsync` invocations.|
|
||||||
|
|
||||||
## Running the samples from the console
|
## Running the samples from the console
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<NoWarn>$(NoWarn);MAAIW001;OPENAI001</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Azure.AI.Projects" />
|
||||||
|
<PackageReference Include="Azure.Identity" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// This sample ports the Python Magentic orchestration sample to .NET.
|
||||||
|
// A Magentic workflow coordinates a researcher and a coder, streams orchestration
|
||||||
|
// events as the plan evolves, and prints the final conversation transcript.
|
||||||
|
|
||||||
|
using Azure.AI.Projects;
|
||||||
|
using Azure.Identity;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace WorkflowMagenticOrchestrationSample;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Demonstrates Magentic orchestration with a researcher, a coder, and an LLM manager.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Pre-requisites:
|
||||||
|
/// - An Azure AI Foundry project endpoint and model deployment must be configured.
|
||||||
|
/// - Run <c>az login</c> before executing the sample.
|
||||||
|
/// </remarks>
|
||||||
|
public static class Program
|
||||||
|
{
|
||||||
|
private const string TaskPrompt =
|
||||||
|
"I am preparing a report on the energy efficiency of different machine learning model architectures. " +
|
||||||
|
"Compare the estimated training and inference energy consumption of ResNet-50, BERT-base, and GPT-2 " +
|
||||||
|
"on standard datasets (e.g., ImageNet for ResNet, GLUE for BERT, WebText for GPT-2). " +
|
||||||
|
"Then, estimate the CO2 emissions associated with each, assuming training on an Azure Standard_NC6s_v3 " +
|
||||||
|
"VM for 24 hours. Provide tables for clarity, and recommend the most energy-efficient model " +
|
||||||
|
"per task type (image classification, text classification, and text generation).";
|
||||||
|
|
||||||
|
private static async Task Main()
|
||||||
|
{
|
||||||
|
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||||
|
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||||
|
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||||
|
|
||||||
|
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||||
|
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||||
|
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||||
|
AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||||
|
|
||||||
|
AIAgent researcherAgent = projectClient.AsAIAgent(
|
||||||
|
deploymentName,
|
||||||
|
name: "ResearcherAgent",
|
||||||
|
description: "Specialist in research and information gathering.",
|
||||||
|
instructions: "You are a researcher. Find relevant information without doing additional computation or quantitative analysis.");
|
||||||
|
|
||||||
|
AIAgent coderAgent = projectClient.AsAIAgent(
|
||||||
|
deploymentName,
|
||||||
|
name: "CoderAgent",
|
||||||
|
description: "A helpful assistant that writes and executes code to analyze data.",
|
||||||
|
instructions: "You solve quantitative questions by writing and running code. Show the analysis and the computation process clearly.",
|
||||||
|
tools: [new HostedCodeInterpreterTool()]);
|
||||||
|
|
||||||
|
AIAgent managerAgent = projectClient.AsAIAgent(
|
||||||
|
deploymentName,
|
||||||
|
name: "MagenticManager",
|
||||||
|
description: "Orchestrator that coordinates the research and coding workflow.",
|
||||||
|
instructions: "You coordinate the team to complete complex tasks efficiently.");
|
||||||
|
|
||||||
|
Workflow workflow = new MagenticWorkflowBuilder(managerAgent)
|
||||||
|
.AddParticipants([researcherAgent, coderAgent])
|
||||||
|
.WithName("Magentic Orchestration Workflow")
|
||||||
|
.WithDescription("Coordinates a researcher and coder to solve a complex analytical task.")
|
||||||
|
.RequirePlanSignoff(false)
|
||||||
|
.WithMaxRounds(10)
|
||||||
|
.WithMaxStalls(3)
|
||||||
|
.WithMaxResets(2)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
Console.WriteLine("Building Magentic workflow...");
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine($"Task: {TaskPrompt}");
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("Starting workflow execution...");
|
||||||
|
|
||||||
|
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(
|
||||||
|
workflow,
|
||||||
|
new List<ChatMessage> { new(ChatRole.User, TaskPrompt) });
|
||||||
|
|
||||||
|
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
|
||||||
|
|
||||||
|
string? lastResponseId = null;
|
||||||
|
WorkflowOutputEvent? finalOutput = null;
|
||||||
|
|
||||||
|
await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync())
|
||||||
|
{
|
||||||
|
switch (workflowEvent)
|
||||||
|
{
|
||||||
|
case AgentResponseUpdateEvent updateEvent:
|
||||||
|
WriteStreamingUpdate(updateEvent, ref lastResponseId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MagenticPlanCreatedEvent planCreated:
|
||||||
|
WriteMagenticMessage("Initial Plan", planCreated.FullTaskLedger.Text);
|
||||||
|
PauseIfInteractive();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MagenticReplannedEvent replanned:
|
||||||
|
WriteMagenticMessage("Replanned", replanned.FullTaskLedger.Text);
|
||||||
|
PauseIfInteractive();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MagenticProgressLedgerUpdatedEvent progressUpdated:
|
||||||
|
WriteMagenticMessage("Progress Ledger", FormatProgressLedger(progressUpdated.ProgressLedger));
|
||||||
|
PauseIfInteractive();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WorkflowOutputEvent outputEvent when outputEvent.Is<List<ChatMessage>>():
|
||||||
|
finalOutput = outputEvent;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WorkflowErrorEvent workflowError:
|
||||||
|
Console.ForegroundColor = ConsoleColor.Red;
|
||||||
|
Console.Error.WriteLine(workflowError.Exception?.ToString() ?? "Unknown workflow error occurred.");
|
||||||
|
Console.ResetColor();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ExecutorFailedEvent executorFailed:
|
||||||
|
Console.ForegroundColor = ConsoleColor.Red;
|
||||||
|
Console.Error.WriteLine($"Executor '{executorFailed.ExecutorId}' failed with {(executorFailed.Data is null ? "unknown error" : $"exception {executorFailed.Data}")}.");
|
||||||
|
Console.ResetColor();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalOutput?.As<List<ChatMessage>>() is { } transcript)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine(new string('=', 80));
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("Final Conversation Transcript:");
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
foreach (ChatMessage message in transcript)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"{message.AuthorName ?? message.Role.ToString()}: {message.Text}");
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteStreamingUpdate(AgentResponseUpdateEvent updateEvent, ref string? lastResponseId)
|
||||||
|
{
|
||||||
|
string responseId = updateEvent.Update.ResponseId ?? updateEvent.Update.MessageId ?? updateEvent.ExecutorId;
|
||||||
|
if (!string.Equals(responseId, lastResponseId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (lastResponseId is not null)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Write($"- {updateEvent.ExecutorId}: ");
|
||||||
|
lastResponseId = responseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(updateEvent.Update.Text))
|
||||||
|
{
|
||||||
|
Console.Write(updateEvent.Update.Text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteMagenticMessage(string title, string? content)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine($"[Magentic {title}]");
|
||||||
|
Console.WriteLine(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatProgressLedger(MagenticProgressLedger ledger) =>
|
||||||
|
string.Join(Environment.NewLine,
|
||||||
|
$"Request satisfied: {ledger.IsRequestSatisfied}",
|
||||||
|
$"In loop: {ledger.IsInLoop}",
|
||||||
|
$"Making progress: {ledger.IsProgressBeingMade}",
|
||||||
|
$"Next speaker: {ledger.NextSpeaker}",
|
||||||
|
$"Instruction: {ledger.InstructionOrQuestion}");
|
||||||
|
|
||||||
|
private static void PauseIfInteractive()
|
||||||
|
{
|
||||||
|
if (Console.IsInputRedirected || Console.IsOutputRedirected)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Write("Press Enter to continue...");
|
||||||
|
Console.ReadLine();
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Magentic Orchestration Sample
|
||||||
|
|
||||||
|
This sample showcases the Magentic Orchestration Pattern in .NET, setting up a team with three roles:
|
||||||
|
|
||||||
|
- **ResearcherAgent** gathers factual background information.
|
||||||
|
- **CoderAgent** uses `HostedCodeInterpreterTool` for quantitative analysis.
|
||||||
|
- **MagenticManager** plans the work, tracks progress, and decides who should act next.
|
||||||
|
|
||||||
|
## What This Sample Demonstrates
|
||||||
|
|
||||||
|
- Building a Magentic workflow with `MagenticWorkflowBuilder`
|
||||||
|
- Combining standard responses-based agents with a code interpreter-enabled participant
|
||||||
|
- Streaming orchestration events such as the initial plan, replans, and progress-ledger updates
|
||||||
|
- Printing the final multi-agent conversation transcript
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- `AZURE_AI_PROJECT_ENDPOINT` set to your Azure AI Foundry project endpoint
|
||||||
|
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` set to your model deployment name (defaults to `gpt-5.4-mini`)
|
||||||
|
- `az login` completed before running the sample
|
||||||
|
|
||||||
|
## Running the Sample
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected Output
|
||||||
|
|
||||||
|
The sample prints:
|
||||||
|
|
||||||
|
1. The original task prompt
|
||||||
|
2. Streamed updates from the participating agents
|
||||||
|
3. Magentic plan and progress-ledger events as the workflow coordinates the team
|
||||||
|
4. The final conversation transcript returned by the workflow
|
||||||
|
|
||||||
|
## Related Samples
|
||||||
|
|
||||||
|
- [Handoff Orchestration](../Handoff) - another multi-agent orchestration pattern in .NET workflows
|
||||||
|
- [Python Magentic workflow sample](../../../../../python/samples/03-workflows/orchestrations/magentic.py) - the source scenario that this sample ports
|
||||||
@@ -62,3 +62,4 @@ Once completed, please proceed to the other samples listed below.
|
|||||||
| Sample | Concepts |
|
| Sample | Concepts |
|
||||||
|--------|----------|
|
|--------|----------|
|
||||||
| [Handoff Orchestration](./Orchestration/Handoff) | Introduces the Handoff Orchestration pattern |
|
| [Handoff Orchestration](./Orchestration/Handoff) | Introduces the Handoff Orchestration pattern |
|
||||||
|
| [Magentic Orchestration](./Orchestration/Magentic) | Coordinates multiple agents with a Magentic manager, streamed plan events, and a final transcript |
|
||||||
|
|||||||
@@ -474,6 +474,7 @@ public sealed class A2AAgent : AIAgent
|
|||||||
ResponseId = statusUpdateEvent.TaskId,
|
ResponseId = statusUpdateEvent.TaskId,
|
||||||
RawRepresentation = statusUpdateEvent,
|
RawRepresentation = statusUpdateEvent,
|
||||||
Role = ChatRole.Assistant,
|
Role = ChatRole.Assistant,
|
||||||
|
MessageId = statusUpdateEvent.Status.Message?.MessageId,
|
||||||
FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State),
|
FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State),
|
||||||
AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
|
AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
|
||||||
Contents = statusUpdateEvent.Status.GetUserInputRequests(),
|
Contents = statusUpdateEvent.Status.GetUserInputRequests(),
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using Microsoft.Shared.Diagnostics;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.Mcp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods on <see cref="McpClient"/> that expose MCP server tools to a Microsoft
|
||||||
|
/// Agent Framework agent with optional long-running task (SEP-2663) handling.
|
||||||
|
/// </summary>
|
||||||
|
public static class McpClientTaskExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Lists tools advertised by the connected MCP server and returns each as an
|
||||||
|
/// <see cref="AIFunction"/>. Tools that declare <see cref="ToolTaskSupport.Required"/>
|
||||||
|
/// are wrapped with task-aware behavior so an agent can transparently drive long-running
|
||||||
|
/// invocations. All other tools — including those that declare
|
||||||
|
/// <see cref="ToolTaskSupport.Optional"/> — are returned as-is, preserving inline
|
||||||
|
/// (synchronous) invocation semantics by default.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">The connected MCP client.</param>
|
||||||
|
/// <param name="options">
|
||||||
|
/// Options that control the task lifecycle for task-capable tools.
|
||||||
|
/// When <see langword="null"/>, defaults described on <see cref="McpTaskOptions"/> apply.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="cancellationToken">Token used to cancel listing the server's tools.</param>
|
||||||
|
/// <returns>The tools, ready to pass to <c>AsAIAgent(tools: …)</c>.</returns>
|
||||||
|
public static async Task<IReadOnlyList<AIFunction>> ListAgentToolsWithTaskSupportAsync(
|
||||||
|
this McpClient client,
|
||||||
|
McpTaskOptions? options = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_ = Throw.IfNull(client);
|
||||||
|
|
||||||
|
McpTaskOptions effectiveOptions = options ?? new McpTaskOptions();
|
||||||
|
|
||||||
|
IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
AIFunction[] result = new AIFunction[tools.Count];
|
||||||
|
for (int i = 0; i < tools.Count; i++)
|
||||||
|
{
|
||||||
|
ToolTaskSupport? taskSupport = tools[i].ProtocolTool.Execution?.TaskSupport;
|
||||||
|
if (taskSupport is ToolTaskSupport.Required)
|
||||||
|
{
|
||||||
|
result[i] = new TaskAwareMcpClientAIFunction(client, tools[i], effectiveOptions);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result[i] = tools[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.Mcp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures how an MCP client wrapper drives the
|
||||||
|
/// <see href="https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks">MCP tasks</see>
|
||||||
|
/// lifecycle when an underlying server tool returns a <c>CreateTaskResult</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// All members of this type are subject to change. The MCP task surface is experimental
|
||||||
|
/// and tracks the in-flight specification.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class McpTaskOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the time-to-live the wrapper attaches to a newly created server-side task.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// When <see langword="null"/> the wrapper omits the <c>ttl</c> hint and lets the server
|
||||||
|
/// pick its own value. The server's chosen TTL is always authoritative.
|
||||||
|
/// </remarks>
|
||||||
|
public TimeSpan? DefaultTimeToLive { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the wrapper should send
|
||||||
|
/// <c>tasks/cancel</c> when the local <see cref="System.Threading.CancellationToken"/>
|
||||||
|
/// fires during a tool invocation.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Defaults to <see langword="true"/>: a local cancellation means "the caller is giving up
|
||||||
|
/// on this tool invocation" and the server-side task has no further consumer.
|
||||||
|
/// </remarks>
|
||||||
|
public bool CancelRemoteTaskOnLocalCancellation { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||||
|
<RootNamespace>Microsoft.Agents.AI.Mcp</RootNamespace>
|
||||||
|
<VersionSuffix>alpha</VersionSuffix>
|
||||||
|
<NoWarn>$(NoWarn);MEAI001;MCPEXP001</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<InjectSharedThrow>true</InjectSharedThrow>
|
||||||
|
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<Title>Microsoft Agent Framework MCP</Title>
|
||||||
|
<Description>Provides Microsoft Agent Framework support for Model Context Protocol (MCP), including long-running task (SEP-2663) integration for MCP clients.</Description>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Disable package validation baseline until the first release -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<PackageValidationBaselineVersion />
|
||||||
|
<EnablePackageValidation>false</EnablePackageValidation>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||||
|
<PackageReference Include="ModelContextProtocol" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="Microsoft.Agents.AI.Mcp.UnitTests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using Microsoft.Shared.Diagnostics;
|
||||||
|
using ModelContextProtocol;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.Mcp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An <see cref="AIFunction"/> wrapper around an <see cref="McpClientTool"/> that drives the
|
||||||
|
/// <see href="https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks">MCP long-running task</see>
|
||||||
|
/// lifecycle (SEP-2663) on behalf of the agent's tool loop.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The wrapper invokes the tool with task augmentation via
|
||||||
|
/// <see cref="McpClient.CallToolAsTaskAsync"/>, polls to completion via
|
||||||
|
/// <see cref="McpClient.PollTaskUntilCompleteAsync"/>, and fetches the result via
|
||||||
|
/// <see cref="McpClient.GetTaskResultAsync"/>. The result is returned to the caller as a
|
||||||
|
/// <see cref="JsonElement"/> containing the serialized <see cref="CallToolResult"/> — the
|
||||||
|
/// same wire shape produced by <see cref="McpClientTool"/>.<see cref="AIFunction.InvokeAsync(AIFunctionArguments, CancellationToken)"/>
|
||||||
|
/// so that downstream <see cref="FunctionResultContent"/> serialization is byte-identical to
|
||||||
|
/// a non-task-augmented MCP tool call. The agent's function-calling loop is unaware that a
|
||||||
|
/// task was used.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// This wrapper is intended to be applied only to tools whose
|
||||||
|
/// <see cref="ToolExecution.TaskSupport"/> is <see cref="ToolTaskSupport.Required"/>
|
||||||
|
/// (selected by <see cref="McpClientTaskExtensions.ListAgentToolsWithTaskSupportAsync"/>).
|
||||||
|
/// As a defensive fallback, if the server still rejects the task-augmented call with
|
||||||
|
/// <see cref="McpErrorCode.MethodNotFound"/> (e.g. because tool-level capabilities changed
|
||||||
|
/// between <c>tools/list</c> and invocation), the wrapper transparently falls back to a
|
||||||
|
/// non-augmented call through the inner <see cref="McpClientTool"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class TaskAwareMcpClientAIFunction : AIFunction
|
||||||
|
{
|
||||||
|
private readonly McpClient _client;
|
||||||
|
private readonly McpClientTool _inner;
|
||||||
|
private readonly McpTaskOptions _options;
|
||||||
|
|
||||||
|
internal TaskAwareMcpClientAIFunction(McpClient client, McpClientTool inner, McpTaskOptions options)
|
||||||
|
{
|
||||||
|
_ = Throw.IfNull(client);
|
||||||
|
_ = Throw.IfNull(inner);
|
||||||
|
_ = Throw.IfNull(options);
|
||||||
|
|
||||||
|
this._client = client;
|
||||||
|
this._inner = inner;
|
||||||
|
this._options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Name => this._inner.Name;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Description => this._inner.Description;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override JsonElement JsonSchema => this._inner.JsonSchema;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override JsonElement? ReturnJsonSchema => this._inner.ReturnJsonSchema;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override JsonSerializerOptions JsonSerializerOptions => this._inner.JsonSerializerOptions;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async ValueTask<object?> InvokeCoreAsync(
|
||||||
|
AIFunctionArguments arguments,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_ = Throw.IfNull(arguments);
|
||||||
|
|
||||||
|
McpTaskMetadata? metadata = null;
|
||||||
|
if (this._options.DefaultTimeToLive is TimeSpan ttl)
|
||||||
|
{
|
||||||
|
metadata = new McpTaskMetadata { TimeToLive = ttl };
|
||||||
|
}
|
||||||
|
|
||||||
|
McpTask task;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
task = await this._client.CallToolAsTaskAsync(
|
||||||
|
this._inner.Name,
|
||||||
|
arguments,
|
||||||
|
taskMetadata: metadata,
|
||||||
|
progress: null,
|
||||||
|
options: null,
|
||||||
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.MethodNotFound)
|
||||||
|
{
|
||||||
|
// Defensive fallback: the server's advertised TaskSupport indicated this tool
|
||||||
|
// could be invoked as a task, but the server now rejects task augmentation for it
|
||||||
|
// (e.g. capability changed between tools/list and invocation). Fall back to a
|
||||||
|
// non-augmented call through the inner McpClientTool.
|
||||||
|
return await this._inner.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.PollAndRetrieveResultAsync(task.TaskId, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JsonElement> PollAndRetrieveResultAsync(string taskId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
McpTask terminal = await this._client.PollTaskUntilCompleteAsync(taskId, options: null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return terminal.Status switch
|
||||||
|
{
|
||||||
|
McpTaskStatus.Completed => await this._client.GetTaskResultAsync(taskId, options: null, cancellationToken).ConfigureAwait(false),
|
||||||
|
McpTaskStatus.Cancelled => throw new OperationCanceledException(FormatTerminalStatusMessage(taskId, terminal)),
|
||||||
|
_ => throw new InvalidOperationException(FormatTerminalStatusMessage(taskId, terminal)),// Failed (or any future non-terminal-but-unhandled status that the poll loop returns).
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (this._options.CancelRemoteTaskOnLocalCancellation && cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await this.TryCancelTaskAsync(taskId).ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatTerminalStatusMessage(string taskId, McpTask terminal)
|
||||||
|
=> string.IsNullOrEmpty(terminal.StatusMessage)
|
||||||
|
? $"MCP task '{taskId}' ended in terminal status '{terminal.Status}'."
|
||||||
|
: $"MCP task '{taskId}' ended in terminal status '{terminal.Status}': {terminal.StatusMessage}";
|
||||||
|
|
||||||
|
private async Task TryCancelTaskAsync(string taskId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
_ = await this._client.CancelTaskAsync(taskId, options: null, cts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort cancellation; do not mask the original cancellation reason.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1124,6 +1124,7 @@ public sealed class A2AAgentTests : IDisposable
|
|||||||
Assert.Equal(TaskId, update0.ResponseId);
|
Assert.Equal(TaskId, update0.ResponseId);
|
||||||
Assert.Equal(this._agent.Id, update0.AgentId);
|
Assert.Equal(this._agent.Id, update0.AgentId);
|
||||||
Assert.Null(update0.FinishReason);
|
Assert.Null(update0.FinishReason);
|
||||||
|
Assert.Null(update0.MessageId);
|
||||||
Assert.IsType<TaskStatusUpdateEvent>(update0.RawRepresentation);
|
Assert.IsType<TaskStatusUpdateEvent>(update0.RawRepresentation);
|
||||||
|
|
||||||
// Assert - session should be updated with context and task IDs
|
// Assert - session should be updated with context and task IDs
|
||||||
@@ -1132,6 +1133,50 @@ public sealed class A2AAgentTests : IDisposable
|
|||||||
Assert.Equal(TaskId, a2aSession.TaskId);
|
Assert.Equal(TaskId, a2aSession.TaskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunStreamingAsync_WithTaskStatusUpdateEventAndMessageId_YieldsMessageIdAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string TaskId = "task-status-msg-123";
|
||||||
|
const string ContextId = "ctx-status-msg-456";
|
||||||
|
const string ExpectedMessageId = "msg-status-789";
|
||||||
|
|
||||||
|
this._handler.StreamingResponseToReturn = new StreamResponse
|
||||||
|
{
|
||||||
|
StatusUpdate = new TaskStatusUpdateEvent
|
||||||
|
{
|
||||||
|
TaskId = TaskId,
|
||||||
|
ContextId = ContextId,
|
||||||
|
Status = new()
|
||||||
|
{
|
||||||
|
State = TaskState.Working,
|
||||||
|
Message = new Message
|
||||||
|
{
|
||||||
|
MessageId = ExpectedMessageId,
|
||||||
|
Parts = [Part.FromText("Processing your request...")]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var session = await this._agent.CreateSessionAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var updates = new List<AgentResponseUpdate>();
|
||||||
|
await foreach (var update in this._agent.RunStreamingAsync("Check task status", session))
|
||||||
|
{
|
||||||
|
updates.Add(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Single(updates);
|
||||||
|
|
||||||
|
var update0 = updates[0];
|
||||||
|
Assert.Equal(ExpectedMessageId, update0.MessageId);
|
||||||
|
Assert.Equal(TaskId, update0.ResponseId);
|
||||||
|
Assert.IsType<TaskStatusUpdateEvent>(update0.RawRepresentation);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunStreamingAsync_WithInputRequiredStatusUpdate_YieldsStatusContentsAsync()
|
public async Task RunStreamingAsync_WithInputRequiredStatusUpdate_YieldsStatusContentsAsync()
|
||||||
{
|
{
|
||||||
@@ -1150,6 +1195,7 @@ public sealed class A2AAgentTests : IDisposable
|
|||||||
State = TaskState.InputRequired,
|
State = TaskState.InputRequired,
|
||||||
Message = new Message
|
Message = new Message
|
||||||
{
|
{
|
||||||
|
MessageId = "input-msg-789",
|
||||||
Parts = [Part.FromText("Where would you like to fly?")]
|
Parts = [Part.FromText("Where would you like to fly?")]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1170,6 +1216,7 @@ public sealed class A2AAgentTests : IDisposable
|
|||||||
|
|
||||||
var update0 = updates[0];
|
var update0 = updates[0];
|
||||||
Assert.Equal(TaskId, update0.ResponseId);
|
Assert.Equal(TaskId, update0.ResponseId);
|
||||||
|
Assert.Equal("input-msg-789", update0.MessageId);
|
||||||
Assert.Null(update0.FinishReason);
|
Assert.Null(update0.FinishReason);
|
||||||
|
|
||||||
var textContent = Assert.Single(update0.Contents.OfType<TextContent>());
|
var textContent = Assert.Single(update0.Contents.OfType<TextContent>());
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.Mcp.UnitTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal empty <see cref="IServiceProvider"/> for in-memory fixtures that don't use DI.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class EmptyServiceProvider : IServiceProvider
|
||||||
|
{
|
||||||
|
public static EmptyServiceProvider Instance { get; } = new();
|
||||||
|
|
||||||
|
public object? GetService(Type serviceType) => null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Pipelines;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ModelContextProtocol;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.Mcp.UnitTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-process MCP server fixture that pairs a <see cref="McpServer"/> and a <see cref="McpClient"/>
|
||||||
|
/// over duplex <see cref="Pipe"/>-backed streams so unit tests can exercise the
|
||||||
|
/// real task-augmentation protocol without spawning a child process or opening a socket.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class InMemoryMcpServerFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly McpServer _server;
|
||||||
|
private readonly Task _serverLoop;
|
||||||
|
private readonly CancellationTokenSource _cts;
|
||||||
|
|
||||||
|
public McpClient Client { get; }
|
||||||
|
|
||||||
|
private InMemoryMcpServerFixture(McpServer server, McpClient client, Task serverLoop, CancellationTokenSource cts)
|
||||||
|
{
|
||||||
|
this._server = server;
|
||||||
|
this.Client = client;
|
||||||
|
this._serverLoop = serverLoop;
|
||||||
|
this._cts = cts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<InMemoryMcpServerFixture> CreateAsync(
|
||||||
|
McpServerPrimitiveCollection<McpServerTool> tools,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Pipe clientToServer = new();
|
||||||
|
Pipe serverToClient = new();
|
||||||
|
|
||||||
|
// Stream conventions:
|
||||||
|
// StreamClientTransport(serverInput, serverOutput, ...): serverInput is what the client
|
||||||
|
// WRITES to (server reads it); serverOutput is what the client READS from (server writes it).
|
||||||
|
// StreamServerTransport(input, output, ...): input is what the server READS from; output
|
||||||
|
// is what the server WRITES to.
|
||||||
|
Stream clientWriteStream = clientToServer.Writer.AsStream();
|
||||||
|
Stream clientReadStream = serverToClient.Reader.AsStream();
|
||||||
|
Stream serverReadStream = clientToServer.Reader.AsStream();
|
||||||
|
Stream serverWriteStream = serverToClient.Writer.AsStream();
|
||||||
|
|
||||||
|
StreamServerTransport serverTransport = new(
|
||||||
|
serverReadStream,
|
||||||
|
serverWriteStream,
|
||||||
|
"test-server",
|
||||||
|
NullLoggerFactory.Instance);
|
||||||
|
|
||||||
|
McpServerOptions serverOptions = new()
|
||||||
|
{
|
||||||
|
ServerInfo = new Implementation { Name = "test-server", Version = "1.0.0" },
|
||||||
|
TaskStore = new InMemoryMcpTaskStore(),
|
||||||
|
ToolCollection = tools,
|
||||||
|
};
|
||||||
|
|
||||||
|
McpServer server = McpServer.Create(
|
||||||
|
serverTransport,
|
||||||
|
serverOptions,
|
||||||
|
NullLoggerFactory.Instance,
|
||||||
|
EmptyServiceProvider.Instance);
|
||||||
|
|
||||||
|
CancellationTokenSource cts = new();
|
||||||
|
Task serverLoop = Task.Run(() => server.RunAsync(cts.Token), cts.Token);
|
||||||
|
|
||||||
|
StreamClientTransport clientTransport = new(
|
||||||
|
clientWriteStream,
|
||||||
|
clientReadStream,
|
||||||
|
NullLoggerFactory.Instance);
|
||||||
|
|
||||||
|
McpClient client = await McpClient.CreateAsync(
|
||||||
|
clientTransport,
|
||||||
|
clientOptions: null,
|
||||||
|
NullLoggerFactory.Instance,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new InMemoryMcpServerFixture(server, client, serverLoop, cts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await this.Client.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best effort.
|
||||||
|
}
|
||||||
|
|
||||||
|
this._cts.Cancel();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await this._serverLoop.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected.
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best effort.
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await this._server.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best effort.
|
||||||
|
}
|
||||||
|
|
||||||
|
this._cts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.Mcp.UnitTests;
|
||||||
|
|
||||||
|
public class ListAgentToolsWithTaskSupportTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ListAgentToolsWithTaskSupport_WrapsTaskCapableTools_LeavesOthersAsIsAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
McpServerPrimitiveCollection<McpServerTool> tools = [
|
||||||
|
TestTools.Create("opt", ToolTaskSupport.Optional, () => "opt-result"),
|
||||||
|
TestTools.Create("req", ToolTaskSupport.Required, () => "req-result"),
|
||||||
|
TestTools.Create("forb", ToolTaskSupport.Forbidden, () => "forb-result"),
|
||||||
|
TestTools.Create("none", taskSupport: null, () => "none-result"),
|
||||||
|
];
|
||||||
|
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().HaveCount(4);
|
||||||
|
AIFunction opt = result.Single(f => f.Name == "opt");
|
||||||
|
AIFunction req = result.Single(f => f.Name == "req");
|
||||||
|
AIFunction forb = result.Single(f => f.Name == "forb");
|
||||||
|
AIFunction none = result.Single(f => f.Name == "none");
|
||||||
|
|
||||||
|
req.Should().BeOfType<TaskAwareMcpClientAIFunction>("Required tools must be wrapped");
|
||||||
|
opt.Should().NotBeOfType<TaskAwareMcpClientAIFunction>("Optional tools must not be wrapped; inline invocation is preserved by default");
|
||||||
|
forb.Should().NotBeOfType<TaskAwareMcpClientAIFunction>("Forbidden tools must not be wrapped");
|
||||||
|
none.Should().NotBeOfType<TaskAwareMcpClientAIFunction>("Tools without execution metadata must not be wrapped");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ListAgentToolsWithTaskSupport_ThrowsOnNullClientAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
ModelContextProtocol.Client.McpClient client = null!;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
Func<Task> act = async () => await client.ListAgentToolsWithTaskSupportAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using FluentAssertions;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.Mcp.UnitTests;
|
||||||
|
|
||||||
|
public class McpTaskOptionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Defaults_AreSane()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
McpTaskOptions options = new();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
options.DefaultTimeToLive.Should().BeNull();
|
||||||
|
options.CancelRemoteTaskOnLocalCancellation.Should().BeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||||
|
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentAssertions" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
|
<PackageReference Include="ModelContextProtocol" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.Mcp.UnitTests;
|
||||||
|
|
||||||
|
public class TaskAwareMcpClientAIFunctionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_RequiredTool_HappyPath_ReturnsResultAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
McpServerPrimitiveCollection<McpServerTool> tools = [
|
||||||
|
TestTools.Create("req", ToolTaskSupport.Required, () => "required-result"),
|
||||||
|
];
|
||||||
|
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
|
||||||
|
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
|
||||||
|
AIFunction req = result.Single(f => f.Name == "req");
|
||||||
|
req.Should().BeOfType<TaskAwareMcpClientAIFunction>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
object? invokeResult = await req.InvokeAsync(arguments: null, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
JsonElement payload = invokeResult.Should().BeOfType<JsonElement>().Subject;
|
||||||
|
ExtractTextContent(payload).Should().Be("required-result");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_PropagatesDefaultTimeToLiveAsync()
|
||||||
|
{
|
||||||
|
// Arrange — capture the request meta on the server so we can assert TTL flowed through.
|
||||||
|
TimeSpan? observedTtl = null;
|
||||||
|
McpServerTool tool = McpServerTool.Create(
|
||||||
|
(RequestContext<CallToolRequestParams> ctx) =>
|
||||||
|
{
|
||||||
|
observedTtl = ctx.Params?.Task?.TimeToLive;
|
||||||
|
return "ok";
|
||||||
|
},
|
||||||
|
new McpServerToolCreateOptions
|
||||||
|
{
|
||||||
|
Name = "ttl-tool",
|
||||||
|
Description = "Echoes the requested TTL.",
|
||||||
|
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required },
|
||||||
|
});
|
||||||
|
McpServerPrimitiveCollection<McpServerTool> tools = [tool];
|
||||||
|
|
||||||
|
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
|
||||||
|
|
||||||
|
TimeSpan requestedTtl = TimeSpan.FromMinutes(7);
|
||||||
|
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync(new McpTaskOptions { DefaultTimeToLive = requestedTtl });
|
||||||
|
AIFunction wrapped = result.Single();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_ = await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
observedTtl.Should().Be(requestedTtl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_RespectsCancellationAsync()
|
||||||
|
{
|
||||||
|
// Arrange — a tool that never completes until it's cancelled.
|
||||||
|
var serverCancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
McpServerTool tool = McpServerTool.Create(
|
||||||
|
async (CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(Timeout.Infinite, ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
serverCancelled.TrySetResult(true);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "should-not-complete";
|
||||||
|
},
|
||||||
|
new McpServerToolCreateOptions
|
||||||
|
{
|
||||||
|
Name = "blocking",
|
||||||
|
Description = "Blocks indefinitely until cancelled.",
|
||||||
|
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required },
|
||||||
|
});
|
||||||
|
McpServerPrimitiveCollection<McpServerTool> tools = [tool];
|
||||||
|
|
||||||
|
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
|
||||||
|
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
|
||||||
|
AIFunction wrapped = result.Single();
|
||||||
|
|
||||||
|
using CancellationTokenSource cts = new();
|
||||||
|
|
||||||
|
// Act — start the invocation, cancel after a brief delay.
|
||||||
|
Task<object?> invocation = wrapped.InvokeAsync(arguments: null, cts.Token).AsTask();
|
||||||
|
await Task.Delay(200);
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
// Assert — wrapper observes cancellation and signals server-side cancellation.
|
||||||
|
Func<Task> awaitInvocation = async () => await invocation;
|
||||||
|
await awaitInvocation.Should().ThrowAsync<OperationCanceledException>();
|
||||||
|
|
||||||
|
// Server-side handler should have observed cancellation as a result of the wrapper's
|
||||||
|
// tasks/cancel call (best-effort wait — give the server-loop a few seconds).
|
||||||
|
Task observedTask = serverCancelled.Task;
|
||||||
|
Task completed = await Task.WhenAny(observedTask, Task.Delay(TimeSpan.FromSeconds(5)));
|
||||||
|
completed.Should().BeSameAs(observedTask, "the wrapper should have issued tasks/cancel");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_FailedTask_ThrowsInvalidOperationAsync()
|
||||||
|
{
|
||||||
|
// Arrange — a tool whose handler throws, which the server surfaces as a Failed task.
|
||||||
|
McpServerTool tool = McpServerTool.Create(
|
||||||
|
(Func<string>)(() => throw new InvalidOperationException("simulated tool failure")),
|
||||||
|
new McpServerToolCreateOptions
|
||||||
|
{
|
||||||
|
Name = "boom",
|
||||||
|
Description = "Throws unconditionally.",
|
||||||
|
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required },
|
||||||
|
});
|
||||||
|
McpServerPrimitiveCollection<McpServerTool> tools = [tool];
|
||||||
|
|
||||||
|
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
|
||||||
|
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
|
||||||
|
AIFunction wrapped = result.Single();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
Func<Task> act = async () => await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert — Phase 1 surfaces non-Completed terminal states as InvalidOperationException
|
||||||
|
// carrying the server's StatusMessage. (See PollAndRetrieveResultAsync.)
|
||||||
|
await act.Should().ThrowAsync<Exception>().Where(ex =>
|
||||||
|
ex is InvalidOperationException
|
||||||
|
|| ex.GetType().FullName == "ModelContextProtocol.McpException");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the first text-content block from a serialized <c>CallToolResult</c>
|
||||||
|
/// (the JSON shape returned by the wrapper and by <c>McpClientTool.InvokeAsync</c>).
|
||||||
|
/// </summary>
|
||||||
|
private static string ExtractTextContent(JsonElement payload)
|
||||||
|
{
|
||||||
|
payload.ValueKind.Should().Be(JsonValueKind.Object);
|
||||||
|
JsonElement content = payload.GetProperty("content");
|
||||||
|
content.ValueKind.Should().Be(JsonValueKind.Array);
|
||||||
|
JsonElement firstBlock = content.EnumerateArray().First();
|
||||||
|
return firstBlock.GetProperty("text").GetString()!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.Mcp.UnitTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helpers to create <see cref="McpServerTool"/> instances with a specific
|
||||||
|
/// <see cref="ToolTaskSupport"/> level for in-memory fixtures.
|
||||||
|
/// </summary>
|
||||||
|
internal static class TestTools
|
||||||
|
{
|
||||||
|
public static McpServerTool Create(string name, ToolTaskSupport? taskSupport, Delegate handler)
|
||||||
|
{
|
||||||
|
McpServerToolCreateOptions options = new()
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Description = $"Test tool {name}.",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (taskSupport is ToolTaskSupport ts)
|
||||||
|
{
|
||||||
|
options.Execution = new ToolExecution { TaskSupport = ts };
|
||||||
|
}
|
||||||
|
|
||||||
|
return McpServerTool.Create(handler, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import abc
|
||||||
import asyncio.coroutines
|
import asyncio.coroutines
|
||||||
|
import contextlib
|
||||||
import functools
|
import functools
|
||||||
import inspect
|
import inspect
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import typing
|
||||||
import warnings
|
import warnings
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -75,6 +79,51 @@ class ExperimentalWarning(FeatureStageWarning):
|
|||||||
"""Warning emitted when an experimental API is used."""
|
"""Warning emitted when an experimental API is used."""
|
||||||
|
|
||||||
|
|
||||||
|
# Sentinel attribute used to detect (and reuse) a formatter we've already
|
||||||
|
# installed. This lets the install be idempotent across re-imports / reloads
|
||||||
|
# and keeps a stable reference to the previous formatter for testing or
|
||||||
|
# external restoration via ``warnings.formatwarning = original``.
|
||||||
|
_FEATURE_STAGE_FORMATTER_MARKER = "__feature_stage_formatter__"
|
||||||
|
|
||||||
|
|
||||||
|
def _install_feature_stage_formatter() -> None:
|
||||||
|
"""Install a single-line formatter for FeatureStageWarning categories.
|
||||||
|
|
||||||
|
The stdlib default formatter emits two lines (header + source snippet)
|
||||||
|
which is noisy for our warnings — the offending class/function name is
|
||||||
|
already in the message, so a one-line ``file:lineno: Category: message``
|
||||||
|
is enough. Other warning categories are delegated to the previous
|
||||||
|
formatter so we never change behaviour for unrelated warnings.
|
||||||
|
|
||||||
|
The install is idempotent: if a formatter installed by this module is
|
||||||
|
already in place, we leave it alone so re-imports (and any third-party
|
||||||
|
formatter wrapped on top of ours) don't get wrapped multiple times.
|
||||||
|
"""
|
||||||
|
current = warnings.formatwarning
|
||||||
|
if getattr(current, _FEATURE_STAGE_FORMATTER_MARKER, False):
|
||||||
|
return
|
||||||
|
|
||||||
|
def _formatwarning(
|
||||||
|
message: Warning | str,
|
||||||
|
category: type[Warning],
|
||||||
|
filename: str,
|
||||||
|
lineno: int,
|
||||||
|
line: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
if issubclass(category, FeatureStageWarning):
|
||||||
|
return f"{filename}:{lineno}: {category.__name__}: {message}\n"
|
||||||
|
return current(message, category, filename, lineno, line)
|
||||||
|
|
||||||
|
setattr(_formatwarning, _FEATURE_STAGE_FORMATTER_MARKER, True)
|
||||||
|
# Keep a reference to the wrapped formatter so callers (tests, embedders)
|
||||||
|
# can restore the previous behaviour if they need to.
|
||||||
|
_formatwarning.__wrapped__ = current # type: ignore[attr-defined]
|
||||||
|
warnings.formatwarning = _formatwarning
|
||||||
|
|
||||||
|
|
||||||
|
_install_feature_stage_formatter()
|
||||||
|
|
||||||
|
|
||||||
def _normalize_feature_id(feature_id: str | Enum) -> str:
|
def _normalize_feature_id(feature_id: str | Enum) -> str:
|
||||||
return str(feature_id.value if isinstance(feature_id, Enum) else feature_id)
|
return str(feature_id.value if isinstance(feature_id, Enum) else feature_id)
|
||||||
|
|
||||||
@@ -109,23 +158,91 @@ def _set_feature_stage_metadata(obj: Any, *, stage: FeatureStageName, feature_id
|
|||||||
setattr(obj, _FEATURE_ID_ATTR, feature_id)
|
setattr(obj, _FEATURE_ID_ATTR, feature_id)
|
||||||
|
|
||||||
|
|
||||||
|
_INTERNAL_FRAME_FILE = os.path.normcase(__file__)
|
||||||
|
# Module names whose frames we never want to surface as the caller. ``abc`` is
|
||||||
|
# the big one (its ``__new__`` shows up as ``<frozen abc>:106`` for ABC-driven
|
||||||
|
# subclass creation on modern CPython, so we cannot rely on filename matching).
|
||||||
|
# ``functools``/``typing``/``contextlib`` are added because they often wrap our
|
||||||
|
# decorators or appear in the metaclass call path.
|
||||||
|
_INTERNAL_FRAME_MODULES: frozenset[str] = frozenset({
|
||||||
|
abc.__name__,
|
||||||
|
functools.__name__,
|
||||||
|
typing.__name__,
|
||||||
|
contextlib.__name__,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_internal_frame(frame: Any) -> bool:
|
||||||
|
if os.path.normcase(frame.f_code.co_filename) == _INTERNAL_FRAME_FILE:
|
||||||
|
return True
|
||||||
|
module_name = frame.f_globals.get("__name__", "")
|
||||||
|
if module_name in _INTERNAL_FRAME_MODULES:
|
||||||
|
return True
|
||||||
|
# Submodules of the skipped stdlib packages (``typing.ext``, ``functools``
|
||||||
|
# wrappers under ``concurrent.futures._base``, etc.) are also wrappers we
|
||||||
|
# don't want to surface.
|
||||||
|
return any(module_name.startswith(prefix + ".") for prefix in _INTERNAL_FRAME_MODULES)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_user_frame() -> tuple[str, int, str] | None:
|
||||||
|
"""Resolve the user frame that triggered an experimental warning.
|
||||||
|
|
||||||
|
Walk the stack and return ``(filename, lineno, module_name)`` for the first
|
||||||
|
frame outside this module and the wrapping/metaclass machinery.
|
||||||
|
|
||||||
|
Returns ``None`` if no such frame is found; callers fall back to plain
|
||||||
|
``warnings.warn`` with a fixed stacklevel.
|
||||||
|
"""
|
||||||
|
# Frame objects participate in reference cycles (``frame -> f_locals ->
|
||||||
|
# frame``) and can delay GC if held implicitly. Capture the user frame's
|
||||||
|
# data into plain values inside the try, and explicitly delete the frame
|
||||||
|
# references in finally so we never leak frames across this call. This
|
||||||
|
# follows CPython's own guidance for code that uses ``inspect.currentframe``.
|
||||||
|
frame = inspect.currentframe()
|
||||||
|
candidate: Any = None
|
||||||
|
try:
|
||||||
|
if frame is None:
|
||||||
|
return None
|
||||||
|
# Skip _resolve_user_frame itself + the warn helper that called it.
|
||||||
|
candidate = frame.f_back.f_back if frame.f_back and frame.f_back.f_back else None
|
||||||
|
while candidate is not None:
|
||||||
|
if not _is_internal_frame(candidate):
|
||||||
|
return (
|
||||||
|
candidate.f_code.co_filename,
|
||||||
|
candidate.f_lineno,
|
||||||
|
candidate.f_globals.get("__name__", "<unknown>"),
|
||||||
|
)
|
||||||
|
candidate = candidate.f_back
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
del frame, candidate
|
||||||
|
|
||||||
|
|
||||||
def _warn_on_feature_use(
|
def _warn_on_feature_use(
|
||||||
*,
|
*,
|
||||||
stage: FeatureStageName,
|
stage: FeatureStageName,
|
||||||
feature_id: str,
|
feature_id: str,
|
||||||
object_name: str,
|
object_name: str,
|
||||||
category: type[Warning],
|
category: type[Warning],
|
||||||
stacklevel: int,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
warning_key = (category, feature_id)
|
warning_key = (category, feature_id)
|
||||||
if warning_key in _WARNED_FEATURES:
|
if warning_key in _WARNED_FEATURES:
|
||||||
return
|
return
|
||||||
|
|
||||||
warnings.warn(
|
message = _build_stage_warning_message(stage=stage, feature_id=feature_id, object_name=object_name)
|
||||||
_build_stage_warning_message(stage=stage, feature_id=feature_id, object_name=object_name),
|
user_frame = _resolve_user_frame()
|
||||||
category=category,
|
if user_frame is None:
|
||||||
stacklevel=stacklevel,
|
# Last-resort fallback: emit at the immediate caller of this helper.
|
||||||
)
|
warnings.warn(message, category=category, stacklevel=2)
|
||||||
|
else:
|
||||||
|
filename, lineno, module = user_frame
|
||||||
|
warnings.warn_explicit(
|
||||||
|
message,
|
||||||
|
category=category,
|
||||||
|
filename=filename,
|
||||||
|
lineno=lineno,
|
||||||
|
module=module,
|
||||||
|
)
|
||||||
_WARNED_FEATURES.add(warning_key)
|
_WARNED_FEATURES.add(warning_key)
|
||||||
|
|
||||||
|
|
||||||
@@ -150,7 +267,6 @@ def _add_runtime_warning(
|
|||||||
feature_id=feature_id,
|
feature_id=feature_id,
|
||||||
object_name=object_name,
|
object_name=object_name,
|
||||||
category=category,
|
category=category,
|
||||||
stacklevel=3,
|
|
||||||
)
|
)
|
||||||
if original_new is not object.__new__:
|
if original_new is not object.__new__:
|
||||||
return original_new(cls, *args, **kwargs)
|
return original_new(cls, *args, **kwargs)
|
||||||
@@ -171,7 +287,6 @@ def _add_runtime_warning(
|
|||||||
feature_id=feature_id,
|
feature_id=feature_id,
|
||||||
object_name=object_name,
|
object_name=object_name,
|
||||||
category=category,
|
category=category,
|
||||||
stacklevel=3,
|
|
||||||
)
|
)
|
||||||
return original_init_subclass_func(*args, **kwargs)
|
return original_init_subclass_func(*args, **kwargs)
|
||||||
|
|
||||||
@@ -185,7 +300,6 @@ def _add_runtime_warning(
|
|||||||
feature_id=feature_id,
|
feature_id=feature_id,
|
||||||
object_name=object_name,
|
object_name=object_name,
|
||||||
category=category,
|
category=category,
|
||||||
stacklevel=3,
|
|
||||||
)
|
)
|
||||||
return original_init_subclass(*args, **kwargs)
|
return original_init_subclass(*args, **kwargs)
|
||||||
|
|
||||||
@@ -200,7 +314,6 @@ def _add_runtime_warning(
|
|||||||
feature_id=feature_id,
|
feature_id=feature_id,
|
||||||
object_name=object_name,
|
object_name=object_name,
|
||||||
category=category,
|
category=category,
|
||||||
stacklevel=3,
|
|
||||||
)
|
)
|
||||||
return obj(*args, **kwargs)
|
return obj(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,39 @@ def test_experimental_class_warns_on_instantiation_and_not_on_definition() -> No
|
|||||||
assert ExperimentalClass.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value
|
assert ExperimentalClass.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value
|
||||||
|
|
||||||
|
|
||||||
|
def test_experimental_abc_subclass_warning_points_at_user_file() -> None:
|
||||||
|
"""Subclassing an experimental ABC must report the warning at the user's
|
||||||
|
``class Sub(...):`` line, not at internal abc.py / <frozen abc> frames.
|
||||||
|
|
||||||
|
Regression: previously the fixed ``stacklevel=3`` landed inside abc.py for
|
||||||
|
ABC-driven class creation, surfacing ``<frozen abc>:106`` to users.
|
||||||
|
"""
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
@experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type]
|
||||||
|
class ExperimentalABC(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def do(self) -> int: ...
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
subclass_line = inspect.currentframe().f_lineno + 1
|
||||||
|
|
||||||
|
class Concrete(ExperimentalABC):
|
||||||
|
def do(self) -> int:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
assert len(caught) == 1
|
||||||
|
assert caught[0].filename == __file__
|
||||||
|
# __init_subclass__ fires at the end of the class body, so the lineno
|
||||||
|
# points somewhere inside the Concrete class definition rather than at
|
||||||
|
# the ``class Concrete`` header itself. The key behaviour we want to
|
||||||
|
# guarantee is that it is in the *user* file at all (not abc.py).
|
||||||
|
assert subclass_line <= caught[0].lineno <= subclass_line + 5
|
||||||
|
assert issubclass(caught[0].category, ExperimentalWarning)
|
||||||
|
assert Concrete().do() == 1
|
||||||
|
|
||||||
|
|
||||||
def test_experimental_runtime_checkable_protocol_keeps_protocol_runtime_checks() -> None:
|
def test_experimental_runtime_checkable_protocol_keeps_protocol_runtime_checks() -> None:
|
||||||
with warnings.catch_warnings(record=True) as caught:
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
warnings.simplefilter("always")
|
warnings.simplefilter("always")
|
||||||
|
|||||||
+13
-13
@@ -817,10 +817,14 @@ class DeclarativeWorkflowBuilder:
|
|||||||
condition=lambda msg: isinstance(msg, LoopIterationResult) and msg.has_next,
|
condition=lambda msg: isinstance(msg, LoopIterationResult) and msg.has_next,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Body exit -> Next (get all exits from body and wire to next_executor)
|
# Wire from the LAST body action so the loop only advances after the
|
||||||
body_exits = self._get_source_exits(body_entry)
|
# whole body completes. _get_branch_exit walks the chain, skips
|
||||||
for body_exit in body_exits:
|
# terminators (Break/Continue), and returns nested If/Switch
|
||||||
builder.add_edge(source=body_exit, target=next_executor)
|
# structures so _get_source_exits can flatten their branch exits.
|
||||||
|
body_exit = self._get_branch_exit(body_entry)
|
||||||
|
if body_exit is not None:
|
||||||
|
for source_exit in self._get_source_exits(body_exit):
|
||||||
|
builder.add_edge(source=source_exit, target=next_executor)
|
||||||
|
|
||||||
# Next -> body (when has_next=True, loop back)
|
# Next -> body (when has_next=True, loop back)
|
||||||
builder.add_edge(
|
builder.add_edge(
|
||||||
@@ -1008,16 +1012,12 @@ class DeclarativeWorkflowBuilder:
|
|||||||
return entry.evaluator if is_structure else entry
|
return entry.evaluator if is_structure else entry
|
||||||
|
|
||||||
def _get_branch_exit(self, branch_entry: Any) -> Any | None:
|
def _get_branch_exit(self, branch_entry: Any) -> Any | None:
|
||||||
"""Get the exit executor of a branch.
|
"""Get the exit point of a branch for downstream wiring.
|
||||||
|
|
||||||
For a linear sequence of actions, returns the last executor.
|
Returns the last executor (or its ``_exit_executor``) for a linear chain,
|
||||||
For nested structures, returns None (they have their own branch_exits).
|
the nested If/Switch structure itself when the chain ends in one (so
|
||||||
|
callers can flatten ``branch_exits`` via :meth:`_get_source_exits`), or
|
||||||
Args:
|
``None`` when the branch is empty or ends in a terminator action.
|
||||||
branch_entry: The first executor of the branch
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The exit executor, or None if branch is empty or ends with a structure
|
|
||||||
"""
|
"""
|
||||||
if branch_entry is None:
|
if branch_entry is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -2224,6 +2224,101 @@ class TestBuilderControlFlowCreation:
|
|||||||
class TestBuilderEdgeWiring:
|
class TestBuilderEdgeWiring:
|
||||||
"""Tests for builder edge wiring methods."""
|
"""Tests for builder edge wiring methods."""
|
||||||
|
|
||||||
|
def test_foreach_advance_edge_wired_from_last_body_action(self):
|
||||||
|
"""Advance edge must come from the last body action."""
|
||||||
|
from agent_framework_declarative._workflows import DeclarativeWorkflowBuilder
|
||||||
|
|
||||||
|
yaml_def = {
|
||||||
|
"name": "foreach_seq",
|
||||||
|
"actions": [
|
||||||
|
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A", "B"]},
|
||||||
|
{
|
||||||
|
"kind": "Foreach",
|
||||||
|
"id": "loop",
|
||||||
|
"itemsSource": "=Local.items",
|
||||||
|
"iteratorVariable": "Local.item",
|
||||||
|
"actions": [
|
||||||
|
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
|
||||||
|
{"kind": "SendActivity", "id": "step_2", "activity": {"text": "two"}},
|
||||||
|
{"kind": "SendActivity", "id": "step_3", "activity": {"text": "three"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow = DeclarativeWorkflowBuilder(yaml_def).build()
|
||||||
|
edges = {(e.source_id, e.target_id) for group in workflow.edge_groups for e in group.edges}
|
||||||
|
|
||||||
|
assert ("step_3", "loop_next") in edges
|
||||||
|
assert ("step_1", "loop_next") not in edges
|
||||||
|
assert ("step_2", "loop_next") not in edges
|
||||||
|
assert ("step_1", "step_2") in edges
|
||||||
|
assert ("step_2", "step_3") in edges
|
||||||
|
|
||||||
|
def test_foreach_advance_edge_skipped_for_terminator_body(self):
|
||||||
|
"""BreakLoop at end of body wires itself to loop_next; no duplicate edge."""
|
||||||
|
from agent_framework_declarative._workflows import DeclarativeWorkflowBuilder
|
||||||
|
|
||||||
|
yaml_def = {
|
||||||
|
"name": "foreach_terminator",
|
||||||
|
"actions": [
|
||||||
|
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A"]},
|
||||||
|
{
|
||||||
|
"kind": "Foreach",
|
||||||
|
"id": "loop",
|
||||||
|
"itemsSource": "=Local.items",
|
||||||
|
"iteratorVariable": "Local.item",
|
||||||
|
"actions": [
|
||||||
|
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
|
||||||
|
{"kind": "BreakLoop", "id": "stop"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow = DeclarativeWorkflowBuilder(yaml_def).build()
|
||||||
|
all_edges = [(e.source_id, e.target_id) for group in workflow.edge_groups for e in group.edges]
|
||||||
|
assert all_edges.count(("stop", "loop_next")) == 1
|
||||||
|
assert ("step_1", "loop_next") not in all_edges
|
||||||
|
|
||||||
|
def test_foreach_advance_edge_with_if_as_last_body_action(self):
|
||||||
|
"""Trailing If in a Foreach body wires every branch exit to loop_next."""
|
||||||
|
from agent_framework_declarative._workflows import DeclarativeWorkflowBuilder
|
||||||
|
|
||||||
|
yaml_def = {
|
||||||
|
"name": "foreach_if_last",
|
||||||
|
"actions": [
|
||||||
|
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A", "B"]},
|
||||||
|
{
|
||||||
|
"kind": "Foreach",
|
||||||
|
"id": "loop",
|
||||||
|
"itemsSource": "=Local.items",
|
||||||
|
"iteratorVariable": "Local.item",
|
||||||
|
"actions": [
|
||||||
|
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
|
||||||
|
{
|
||||||
|
"kind": "If",
|
||||||
|
"id": "check",
|
||||||
|
"condition": '=Local.item = "A"',
|
||||||
|
"then": [
|
||||||
|
{"kind": "SendActivity", "id": "then_action", "activity": {"text": "then"}},
|
||||||
|
],
|
||||||
|
"else": [
|
||||||
|
{"kind": "SendActivity", "id": "else_action", "activity": {"text": "else"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow = DeclarativeWorkflowBuilder(yaml_def).build()
|
||||||
|
edges = {(e.source_id, e.target_id) for group in workflow.edge_groups for e in group.edges}
|
||||||
|
|
||||||
|
assert ("then_action", "loop_next") in edges
|
||||||
|
assert ("else_action", "loop_next") in edges
|
||||||
|
assert ("step_1", "loop_next") not in edges
|
||||||
|
|
||||||
def test_wire_to_target_with_if_structure(self):
|
def test_wire_to_target_with_if_structure(self):
|
||||||
"""Test wiring to an If structure routes to evaluator."""
|
"""Test wiring to an If structure routes to evaluator."""
|
||||||
from agent_framework import WorkflowBuilder
|
from agent_framework import WorkflowBuilder
|
||||||
|
|||||||
@@ -121,6 +121,35 @@ class TestGraphBasedWorkflowExecution:
|
|||||||
assert "b" in outputs
|
assert "b" in outputs
|
||||||
assert "c" in outputs
|
assert "c" in outputs
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_foreach_multi_action_body_runs_sequentially(self):
|
||||||
|
"""Body actions must complete per item before advancing."""
|
||||||
|
yaml_def = {
|
||||||
|
"name": "loop_sequential_body",
|
||||||
|
"actions": [
|
||||||
|
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A", "B"]},
|
||||||
|
{
|
||||||
|
"kind": "Foreach",
|
||||||
|
"id": "loop",
|
||||||
|
"itemsSource": "=Local.items",
|
||||||
|
"iteratorVariable": "Local.item",
|
||||||
|
"actions": [
|
||||||
|
{"kind": "SendActivity", "id": "step_1", "activity": {"text": '="1-" & Local.item'}},
|
||||||
|
{"kind": "SendActivity", "id": "step_2", "activity": {"text": '="2-" & Local.item'}},
|
||||||
|
{"kind": "SendActivity", "id": "step_3", "activity": {"text": '="3-" & Local.item'}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
builder = DeclarativeWorkflowBuilder(yaml_def)
|
||||||
|
workflow = builder.build()
|
||||||
|
|
||||||
|
events = await workflow.run(ActionTrigger())
|
||||||
|
outputs = events.get_outputs()
|
||||||
|
|
||||||
|
assert outputs == ["1-A", "2-A", "3-A", "1-B", "2-B", "3-B"]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_workflow_with_switch(self):
|
async def test_workflow_with_switch(self):
|
||||||
"""Test workflow with Switch/ConditionGroup."""
|
"""Test workflow with Switch/ConditionGroup."""
|
||||||
|
|||||||
@@ -900,7 +900,7 @@ async def test_integration_web_search() -> None:
|
|||||||
"messages": [
|
"messages": [
|
||||||
Message(
|
Message(
|
||||||
role="user",
|
role="user",
|
||||||
contents=["Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer."],
|
contents=["Where is Microsoft's headquarters? Do a web search to find the answer."],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
"options": {"tool_choice": "auto", "tools": [web_search_tool]},
|
"options": {"tool_choice": "auto", "tools": [web_search_tool]},
|
||||||
@@ -908,9 +908,7 @@ async def test_integration_web_search() -> None:
|
|||||||
response = await client.get_response(stream=True, **content).get_final_response()
|
response = await client.get_response(stream=True, **content).get_final_response()
|
||||||
|
|
||||||
assert isinstance(response, ChatResponse)
|
assert isinstance(response, ChatResponse)
|
||||||
assert "Rumi" in response.text
|
assert "redmond" in response.text.lower()
|
||||||
assert "Mira" in response.text
|
|
||||||
assert "Zoey" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.flaky
|
@pytest.mark.flaky
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
|
from collections.abc import AsyncIterable, AsyncIterator, Generator, Sequence
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
from dataclasses import asdict, is_dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
|
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
|
||||||
from typing import Protocol, cast
|
from typing import Protocol, cast
|
||||||
@@ -1505,11 +1506,20 @@ def _convert_message_content(content: MessageContent) -> Content:
|
|||||||
# region Output Item Conversion
|
# region Output Item Conversion
|
||||||
|
|
||||||
|
|
||||||
def _arguments_to_str(arguments: str | Mapping[str, Any] | None) -> str:
|
def _argument_json_default(value: Any) -> Any:
|
||||||
|
if is_dataclass(value) and not isinstance(value, type):
|
||||||
|
return asdict(value)
|
||||||
|
to_dict = getattr(value, "to_dict", None)
|
||||||
|
if callable(to_dict):
|
||||||
|
return to_dict()
|
||||||
|
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
||||||
|
|
||||||
|
|
||||||
|
def _arguments_to_str(arguments: Any | None) -> str:
|
||||||
"""Convert arguments to a JSON string.
|
"""Convert arguments to a JSON string.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
arguments: The arguments to convert, can be a string, mapping, or None.
|
arguments: The arguments to convert, can be a string, JSON-like object, or None.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The arguments as a JSON string.
|
The arguments as a JSON string.
|
||||||
@@ -1518,7 +1528,7 @@ def _arguments_to_str(arguments: str | Mapping[str, Any] | None) -> str:
|
|||||||
return ""
|
return ""
|
||||||
if isinstance(arguments, str):
|
if isinstance(arguments, str):
|
||||||
return arguments
|
return arguments
|
||||||
return json.dumps(arguments)
|
return json.dumps(arguments, default=_argument_json_default)
|
||||||
|
|
||||||
|
|
||||||
async def _to_outputs(
|
async def _to_outputs(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from collections.abc import AsyncIterator, Callable
|
from collections.abc import AsyncIterator, Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -405,6 +406,36 @@ class TestStreaming:
|
|||||||
assert len(args_done) == 1
|
assert len(args_done) == 1
|
||||||
assert args_done[0]["data"]["arguments"] == '{"q": "hello"}'
|
assert args_done[0]["data"]["arguments"] == '{"q": "hello"}'
|
||||||
|
|
||||||
|
async def test_function_call_streaming_serializes_dataclass_arguments(self) -> None:
|
||||||
|
@dataclass
|
||||||
|
class HandoffLikeRequest:
|
||||||
|
agent_response: AgentResponse
|
||||||
|
|
||||||
|
request = HandoffLikeRequest(
|
||||||
|
agent_response=AgentResponse(
|
||||||
|
messages=[Message(role="assistant", contents=[Content.from_text("Need more details")])]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
agent = _make_agent(
|
||||||
|
stream_updates=[
|
||||||
|
AgentResponseUpdate(
|
||||||
|
contents=[Content.from_function_call("call_1", "handoff_to_refund", arguments=request)],
|
||||||
|
role="assistant",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
server = _make_server(agent)
|
||||||
|
resp = await _post(server, stream=True)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
events = _parse_sse_events(resp.text)
|
||||||
|
args_done = [e for e in events if e["event"] == "response.function_call_arguments.done"]
|
||||||
|
assert len(args_done) == 1
|
||||||
|
|
||||||
|
payload = json.loads(args_done[0]["data"]["arguments"])
|
||||||
|
assert payload["agent_response"]["type"] == "agent_response"
|
||||||
|
assert payload["agent_response"]["messages"][0]["contents"][0]["text"] == "Need more details"
|
||||||
|
|
||||||
async def test_alternating_text_and_function_call(self) -> None:
|
async def test_alternating_text_and_function_call(self) -> None:
|
||||||
agent = _make_agent(
|
agent = _make_agent(
|
||||||
stream_updates=[
|
stream_updates=[
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from agent_framework import Agent, Message
|
from agent_framework import Agent, AgentResponse, Message
|
||||||
from agent_framework.foundry import FoundryChatClient
|
from agent_framework.foundry import FoundryChatClient
|
||||||
from agent_framework.orchestrations import SequentialBuilder
|
from agent_framework.orchestrations import SequentialBuilder
|
||||||
from azure.identity import AzureCliCredential
|
from azure.identity import AzureCliCredential
|
||||||
@@ -17,9 +17,9 @@ load_dotenv()
|
|||||||
Sample: Sequential workflow (agent-focused API) with shared conversation context
|
Sample: Sequential workflow (agent-focused API) with shared conversation context
|
||||||
|
|
||||||
Build a high-level sequential workflow using SequentialBuilder and two domain agents.
|
Build a high-level sequential workflow using SequentialBuilder and two domain agents.
|
||||||
The shared conversation (list[Message]) flows through each participant. Each agent
|
The shared conversation flows through each participant. Each agent appends its
|
||||||
appends its assistant message to the context. The workflow outputs the final conversation
|
assistant message to the context. The sample prints the original user message plus
|
||||||
list when complete.
|
the visible outputs from both agents.
|
||||||
|
|
||||||
Note on internal adapters:
|
Note on internal adapters:
|
||||||
- Sequential orchestration includes small adapter nodes for input normalization
|
- Sequential orchestration includes small adapter nodes for input normalization
|
||||||
@@ -56,17 +56,19 @@ async def main() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 2) Build sequential workflow: writer -> reviewer
|
# 2) Build sequential workflow: writer -> reviewer
|
||||||
workflow = SequentialBuilder(participants=[writer, reviewer]).build()
|
workflow = SequentialBuilder(participants=[writer, reviewer], output_from="all").build()
|
||||||
|
|
||||||
# 3) Run and collect outputs
|
# 3) Run and collect outputs
|
||||||
outputs: list[list[Message]] = []
|
prompt = "Write a tagline for a budget-friendly eBike."
|
||||||
async for event in workflow.run("Write a tagline for a budget-friendly eBike.", stream=True):
|
result = await workflow.run(prompt)
|
||||||
if event.type == "output":
|
conversation = [Message(role="user", contents=[prompt])]
|
||||||
outputs.append(cast(list[Message], event.data))
|
for output in result.get_outputs():
|
||||||
|
response = cast(AgentResponse, output)
|
||||||
|
conversation.extend(response.messages)
|
||||||
|
|
||||||
if outputs:
|
if conversation:
|
||||||
print("===== Final Conversation =====")
|
print("===== Final Conversation =====")
|
||||||
for i, msg in enumerate(outputs[-1], start=1):
|
for i, msg in enumerate(conversation, start=1):
|
||||||
name = msg.author_name or ("assistant" if msg.role == "assistant" else "user")
|
name = msg.author_name or ("assistant" if msg.role == "assistant" else "user")
|
||||||
print(f"{'-' * 60}\n{i:02d} [{name}]\n{msg.text}")
|
print(f"{'-' * 60}\n{i:02d} [{name}]\n{msg.text}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user