Compare commits

..
Author SHA1 Message Date
Peter Ibekwe 72a193086e Fix Foreach body exit wiring in declarative workflows 2026-05-22 15:19:36 -07:00
Peter IbekweandGitHub 793403f3db .NET: Add MCP long-running task support for MCP client tools (#5994)
* Add MCP long-running task support for MCP client tools

* Fixed project file formatting issue.

* Removed experimentation tag from MCP alpha project.

* Addressed PR comments
2026-05-22 19:09:54 +00:00
CopilotGitHublokitothcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Jacob Alber
9fdd7429a8 .NET: Add Magentic Orchestration Sample (#5823)
* Add Magentic orchestration sample scaffold

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/8799740a-74d8-4100-b6f6-76dcd0418c87

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Validate Magentic orchestration sample

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/8799740a-74d8-4100-b6f6-76dcd0418c87

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Document follow-up changes for the Magentic .NET sample

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/caa3488f-d6f5-494d-a928-a45d6a98b3c3

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Remove CHANGES.md from Magentic sample

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/ffab38e2-37f9-4643-a782-20680573965a

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Fix PauseIfInteractive to also skip when stdout is redirected

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/07ddf735-29cc-4775-b588-fd71ca76fa58

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* fix: Update for PR Review Feedback

* fix: Update Sample README for PR Feedback

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2026-05-22 19:09:18 +00:00
abc9b60ec9 fix: populate MessageId from TaskStatusUpdateEvent.Status.Message (#6043)
When A2AAgent receives a TaskStatusUpdateEvent during streaming,
ConvertToAgentResponseUpdate now sets AgentResponseUpdate.MessageId
from Status.Message.MessageId when the message is present.

This fixes the missing message correlation metadata reported in
microsoft/agent-framework#4987.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-22 18:15:06 +00:00
Yufeng HeandGitHub 6bc0dc5911 fix: update sequential workflow sample output handling (#5976) 2026-05-22 15:31:18 +00:00
Yufeng HeandGitHub cf91819625 Python: fix Foundry handoff argument serialization (#5861) 2026-05-22 15:30:55 +00:00
578416a379 Python: fix(core): point @experimental warnings at user code, not stdlib internals (#5996)
* fix(core): point @experimental warnings at user code, not stdlib internals

Previously the wrappers installed by @experimental called warnings.warn
with a fixed stacklevel=3. ABCMeta inserts an extra abc.__new__ frame
when an experimental ABC is subclassed, so the warning landed inside
abc.py (or <frozen abc>:106 on modern CPython) instead of the user's
class Sub(...) line.

Resolve the user frame by walking inspect.currentframe(), skipping
frames whose module name is abc/functools/typing/contextlib (or
submodules), then emit via warnings.warn_explicit so the recorded
filename/lineno point at user code. Falls back to warnings.warn with
stacklevel=2 if no user frame is found. Module-name matching is used
because frozen stdlib modules report '<frozen abc>' as their filename.

Also install a one-line warnings.formatwarning specifically for
FeatureStageWarning so 'file:line: ExperimentalWarning: [ID] Name ...'
prints without the secondary source-snippet line. Other categories
delegate to the stdlib default formatter unchanged.

Added a regression test that subclasses an @experimental ABC inside
warnings.catch_warnings and asserts the recorded filename equals the
test file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(core): address review feedback on @experimental warning fix

- Make _install_feature_stage_formatter idempotent: tag the installed
  formatter with a marker attribute and short-circuit re-installation,
  so re-imports/reloads don't wrap the formatter on top of itself.
  Also expose the previous formatter via __wrapped__ for restoration.
- Avoid leaking frame references in _resolve_user_frame: capture data
  into plain locals inside try and del frame/candidate in finally,
  per CPython's guidance on inspect.currentframe usage.
- Drop redundant _WARNED_FEATURES.clear() in the new ABC subclass test
  (the autouse fixture already handles it).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* changed query for foundry web search test

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-22 12:07:10 +00:00
Evan MattsonandGitHub c82c0133fc Workflow improvement (#6025) 2026-05-22 15:56:32 +09:00
33 changed files with 1620 additions and 42 deletions
+1
View File
@@ -137,6 +137,7 @@ jobs:
working-directory: ${{ env.DEVFLOW_PATH }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
+4
View File
@@ -212,6 +212,7 @@
</Folder>
<Folder Name="/Samples/02-agents/ModelContextProtocol/">
<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_Auth/Agent_MCP_Server_Auth.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj" />
@@ -281,6 +282,7 @@
</Folder>
<Folder Name="/Samples/03-workflows/Orchestration/">
<Project Path="samples/03-workflows/Orchestration/Handoff/Handoff.csproj" />
<Project Path="samples/03-workflows/Orchestration/Magentic/Magentic.csproj" />
</Folder>
<Folder Name="/Samples/03-workflows/Observability/">
<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/Microsoft.Agents.AI.Hosting.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.OpenAI/Microsoft.Agents.AI.OpenAI.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.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.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.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.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.",
},
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
{
Name = "AGUI_Step01_GettingStarted_Client",
@@ -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>
@@ -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.";
}
}
@@ -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 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|
|[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
@@ -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
+1
View File
@@ -62,3 +62,4 @@ Once completed, please proceed to the other samples listed below.
| Sample | Concepts |
|--------|----------|
| [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,
RawRepresentation = statusUpdateEvent,
Role = ChatRole.Assistant,
MessageId = statusUpdateEvent.Status.Message?.MessageId,
FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State),
AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
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(this._agent.Id, update0.AgentId);
Assert.Null(update0.FinishReason);
Assert.Null(update0.MessageId);
Assert.IsType<TaskStatusUpdateEvent>(update0.RawRepresentation);
// Assert - session should be updated with context and task IDs
@@ -1132,6 +1133,50 @@ public sealed class A2AAgentTests : IDisposable
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]
public async Task RunStreamingAsync_WithInputRequiredStatusUpdate_YieldsStatusContentsAsync()
{
@@ -1150,6 +1195,7 @@ public sealed class A2AAgentTests : IDisposable
State = TaskState.InputRequired,
Message = new Message
{
MessageId = "input-msg-789",
Parts = [Part.FromText("Where would you like to fly?")]
}
}
@@ -1170,6 +1216,7 @@ public sealed class A2AAgentTests : IDisposable
var update0 = updates[0];
Assert.Equal(TaskId, update0.ResponseId);
Assert.Equal("input-msg-789", update0.MessageId);
Assert.Null(update0.FinishReason);
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();
}
}
@@ -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
import abc
import asyncio.coroutines
import contextlib
import functools
import inspect
import os
import sys
import typing
import warnings
from collections.abc import Callable
from enum import Enum
@@ -75,6 +79,51 @@ class ExperimentalWarning(FeatureStageWarning):
"""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:
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)
_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(
*,
stage: FeatureStageName,
feature_id: str,
object_name: str,
category: type[Warning],
stacklevel: int,
) -> None:
warning_key = (category, feature_id)
if warning_key in _WARNED_FEATURES:
return
warnings.warn(
_build_stage_warning_message(stage=stage, feature_id=feature_id, object_name=object_name),
category=category,
stacklevel=stacklevel,
)
message = _build_stage_warning_message(stage=stage, feature_id=feature_id, object_name=object_name)
user_frame = _resolve_user_frame()
if user_frame is None:
# 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)
@@ -150,7 +267,6 @@ def _add_runtime_warning(
feature_id=feature_id,
object_name=object_name,
category=category,
stacklevel=3,
)
if original_new is not object.__new__:
return original_new(cls, *args, **kwargs)
@@ -171,7 +287,6 @@ def _add_runtime_warning(
feature_id=feature_id,
object_name=object_name,
category=category,
stacklevel=3,
)
return original_init_subclass_func(*args, **kwargs)
@@ -185,7 +300,6 @@ def _add_runtime_warning(
feature_id=feature_id,
object_name=object_name,
category=category,
stacklevel=3,
)
return original_init_subclass(*args, **kwargs)
@@ -200,7 +314,6 @@ def _add_runtime_warning(
feature_id=feature_id,
object_name=object_name,
category=category,
stacklevel=3,
)
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
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:
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
@@ -817,10 +817,14 @@ class DeclarativeWorkflowBuilder:
condition=lambda msg: isinstance(msg, LoopIterationResult) and msg.has_next,
)
# Body exit -> Next (get all exits from body and wire to next_executor)
body_exits = self._get_source_exits(body_entry)
for body_exit in body_exits:
builder.add_edge(source=body_exit, target=next_executor)
# Wire from the LAST body action so the loop only advances after the
# whole body completes. _get_branch_exit walks the chain, skips
# terminators (Break/Continue), and returns nested If/Switch
# 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)
builder.add_edge(
@@ -1008,16 +1012,12 @@ class DeclarativeWorkflowBuilder:
return entry.evaluator if is_structure else entry
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.
For nested structures, returns None (they have their own branch_exits).
Args:
branch_entry: The first executor of the branch
Returns:
The exit executor, or None if branch is empty or ends with a structure
Returns the last executor (or its ``_exit_executor``) for a linear chain,
the nested If/Switch structure itself when the chain ends in one (so
callers can flatten ``branch_exits`` via :meth:`_get_source_exits`), or
``None`` when the branch is empty or ends in a terminator action.
"""
if branch_entry is None:
return None
@@ -2224,6 +2224,101 @@ class TestBuilderControlFlowCreation:
class TestBuilderEdgeWiring:
"""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):
"""Test wiring to an If structure routes to evaluator."""
from agent_framework import WorkflowBuilder
@@ -121,6 +121,35 @@ class TestGraphBasedWorkflowExecution:
assert "b" 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
async def test_workflow_with_switch(self):
"""Test workflow with Switch/ConditionGroup."""
@@ -900,7 +900,7 @@ async def test_integration_web_search() -> None:
"messages": [
Message(
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]},
@@ -908,9 +908,7 @@ async def test_integration_web_search() -> None:
response = await client.get_response(stream=True, **content).get_final_response()
assert isinstance(response, ChatResponse)
assert "Rumi" in response.text
assert "Mira" in response.text
assert "Zoey" in response.text
assert "redmond" in response.text.lower()
@pytest.mark.flaky
@@ -9,8 +9,9 @@ import logging
import os
import tempfile
import threading
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
from collections.abc import AsyncIterable, AsyncIterator, Generator, Sequence
from contextlib import suppress
from dataclasses import asdict, is_dataclass
from pathlib import Path
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
from typing import Protocol, cast
@@ -1505,11 +1506,20 @@ def _convert_message_content(content: MessageContent) -> Content:
# 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.
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:
The arguments as a JSON string.
@@ -1518,7 +1528,7 @@ def _arguments_to_str(arguments: str | Mapping[str, Any] | None) -> str:
return ""
if isinstance(arguments, str):
return arguments
return json.dumps(arguments)
return json.dumps(arguments, default=_argument_json_default)
async def _to_outputs(
@@ -12,6 +12,7 @@ from __future__ import annotations
import json
from collections.abc import AsyncIterator, Callable
from dataclasses import dataclass
from unittest.mock import AsyncMock, MagicMock
import httpx
@@ -405,6 +406,36 @@ class TestStreaming:
assert len(args_done) == 1
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:
agent = _make_agent(
stream_updates=[
@@ -4,7 +4,7 @@ import asyncio
import os
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.orchestrations import SequentialBuilder
from azure.identity import AzureCliCredential
@@ -17,9 +17,9 @@ load_dotenv()
Sample: Sequential workflow (agent-focused API) with shared conversation context
Build a high-level sequential workflow using SequentialBuilder and two domain agents.
The shared conversation (list[Message]) flows through each participant. Each agent
appends its assistant message to the context. The workflow outputs the final conversation
list when complete.
The shared conversation flows through each participant. Each agent appends its
assistant message to the context. The sample prints the original user message plus
the visible outputs from both agents.
Note on internal adapters:
- Sequential orchestration includes small adapter nodes for input normalization
@@ -56,17 +56,19 @@ async def main() -> None:
)
# 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
outputs: list[list[Message]] = []
async for event in workflow.run("Write a tagline for a budget-friendly eBike.", stream=True):
if event.type == "output":
outputs.append(cast(list[Message], event.data))
prompt = "Write a tagline for a budget-friendly eBike."
result = await workflow.run(prompt)
conversation = [Message(role="user", contents=[prompt])]
for output in result.get_outputs():
response = cast(AgentResponse, output)
conversation.extend(response.messages)
if outputs:
if 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")
print(f"{'-' * 60}\n{i:02d} [{name}]\n{msg.text}")