mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72a193086e | ||
|
|
793403f3db | ||
|
|
9fdd7429a8 | ||
|
|
abc9b60ec9 | ||
|
|
6bc0dc5911 | ||
|
|
cf91819625 | ||
|
|
578416a379 | ||
|
|
c82c0133fc | ||
|
|
950673ba47 | ||
|
|
5ac864dfd9 | ||
|
|
b559545fa4 | ||
|
|
8e54f0b0e7 | ||
|
|
afd2739e38 | ||
|
|
c8b8198af1 | ||
|
|
bda40ba0e1 | ||
|
|
46ed66cfd5 |
@@ -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 }}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.6.1</VersionPrefix>
|
||||
<VersionPrefix>1.6.2</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260514</DateSuffix>
|
||||
<DateSuffix>260521</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.6.1</GitTag>
|
||||
<GitTag>1.6.2</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
+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 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
|
||||
@@ -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(),
|
||||
|
||||
@@ -27,9 +27,9 @@ namespace Microsoft.Agents.AI.Foundry;
|
||||
/// Foundry chat-client decorator that unifies the three Foundry chat-client construction
|
||||
/// modes (Responses Agent, Prompt Agent, Agent Endpoint) behind a single type and centralizes
|
||||
/// Foundry-specific concerns: <c>microsoft.foundry</c> telemetry tagging,
|
||||
/// <c>agent-framework-dotnet/{version}</c> User-Agent stamping, and (for Prompt Agents)
|
||||
/// per-request payload mutation that injects the agent reference and strips per-request
|
||||
/// overrides that the server owns.
|
||||
/// <c>agent-framework-dotnet/{version}</c> User-Agent stamping, <c>x-ms-served-model</c>
|
||||
/// response-header capture, and (for Prompt Agents) per-request payload mutation that injects
|
||||
/// the agent reference and strips per-request overrides that the server owns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -78,6 +78,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: modelId);
|
||||
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
|
||||
TryRegisterServedModelPolicy(this.InnerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -96,6 +97,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
|
||||
this._baseChatOptions = baseChatOptions;
|
||||
this.AgentName = agentReference.Name;
|
||||
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
|
||||
TryRegisterServedModelPolicy(this.InnerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -161,6 +163,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
|
||||
this.AgentName = inner.AgentName;
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry");
|
||||
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
|
||||
TryRegisterServedModelPolicy(this.InnerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -212,7 +215,25 @@ public sealed class FoundryChatClient : DelegatingChatClient
|
||||
? this.GetAgentEnabledChatOptions(options)
|
||||
: options;
|
||||
|
||||
return await base.GetResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false);
|
||||
var box = new StrongBox<string?>(null);
|
||||
var previous = ServedModelScope.Current;
|
||||
ServedModelScope.Current = box;
|
||||
|
||||
try
|
||||
{
|
||||
var response = await base.GetResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (box.Value is { } servedModel)
|
||||
{
|
||||
response.ModelId = servedModel;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ServedModelScope.Current = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -222,9 +243,25 @@ public sealed class FoundryChatClient : DelegatingChatClient
|
||||
? this.GetAgentEnabledChatOptions(options)
|
||||
: options;
|
||||
|
||||
await foreach (var chunk in base.GetStreamingResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false))
|
||||
var box = new StrongBox<string?>(null);
|
||||
var previous = ServedModelScope.Current;
|
||||
ServedModelScope.Current = box;
|
||||
|
||||
try
|
||||
{
|
||||
yield return chunk;
|
||||
await foreach (var chunk in base.GetStreamingResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (box.Value is { } servedModel)
|
||||
{
|
||||
chunk.ModelId = servedModel;
|
||||
}
|
||||
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ServedModelScope.Current = previous;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,6 +665,25 @@ public sealed class FoundryChatClient : DelegatingChatClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort registration of <see cref="ServedModelPolicy"/> via the MEAI
|
||||
/// <see cref="OpenAIRequestPolicies"/> hook. The policy captures the
|
||||
/// <c>x-ms-served-model</c> response header from Azure OpenAI and writes it into
|
||||
/// <see cref="ServedModelScope"/> so the <see cref="GetResponseAsync"/> and
|
||||
/// <see cref="GetStreamingResponseAsync"/> overrides can overwrite
|
||||
/// <see cref="ChatResponse.ModelId"/> with the actual model snapshot.
|
||||
/// </summary>
|
||||
private static void TryRegisterServedModelPolicy(IChatClient? innerClient)
|
||||
{
|
||||
if (innerClient?.GetService<OpenAIRequestPolicies>() is { } policies)
|
||||
{
|
||||
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
|
||||
policies,
|
||||
ServedModelPolicy.Instance,
|
||||
PipelinePosition.PerCall);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Default OAuth scope for the Azure AI resource. Matches the scope used by <c>Azure.AI.Extensions.OpenAI</c>'s internal authentication helper so the bearer token is accepted by the Foundry control plane.</summary>
|
||||
private const string AzureAiResourceScope = "https://ai.azure.com/.default";
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Pipeline policy that captures the <c>x-ms-served-model</c> response header from Azure OpenAI
|
||||
/// and stores it in <see cref="ServedModelScope"/> for consumption by <see cref="FoundryChatClient"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Azure OpenAI Responses API returns the deployment alias in <c>response.model</c> but the actual
|
||||
/// model snapshot (e.g. <c>gpt-5-nano-2025-08-07</c>) in the <c>x-ms-served-model</c> response header.
|
||||
/// This policy extracts the header after the HTTP roundtrip so the <see cref="FoundryChatClient"/>
|
||||
/// can overwrite <c>ChatResponse.ModelId</c> with the true model name.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Registered once per <c>OpenAIRequestPolicies</c> instance via the MEAI 10.5.1 extension hook.
|
||||
/// When the header is absent (non-Azure endpoints), the scope is not set and the
|
||||
/// <see cref="FoundryChatClient"/> preserves the original model name.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ServedModelPolicy : PipelinePolicy
|
||||
{
|
||||
/// <summary>The Azure OpenAI response header that carries the actual served model name.</summary>
|
||||
internal const string ServedModelHeader = "x-ms-served-model";
|
||||
|
||||
public static ServedModelPolicy Instance { get; } = new ServedModelPolicy();
|
||||
|
||||
private ServedModelPolicy()
|
||||
{
|
||||
}
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
CaptureServedModel(message);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
CaptureServedModel(message);
|
||||
}
|
||||
|
||||
private static void CaptureServedModel(PipelineMessage message)
|
||||
{
|
||||
if (message.Response is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.Response.Headers.TryGetValue(ServedModelHeader, out string? servedModel)
|
||||
&& !string.IsNullOrWhiteSpace(servedModel))
|
||||
{
|
||||
// Write into the box (reference-type mutation) so the value is visible to the
|
||||
// FoundryChatClient that pushed the box before calling the inner client.
|
||||
if (ServedModelScope.Current is { } box)
|
||||
{
|
||||
box.Value = servedModel.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// AsyncLocal carrier that bridges the <c>x-ms-served-model</c> response header value from the
|
||||
/// <see cref="ServedModelPolicy"/> running inside the SCM transport pipeline up to the
|
||||
/// <see cref="FoundryChatClient"/> decorator.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Because <see cref="AsyncLocal{T}"/> mutations inside a child <c>async</c> method do not propagate
|
||||
/// back to the caller (copy-on-write semantics), this scope uses <see cref="StrongBox{T}"/> as an
|
||||
/// indirection layer. The <see cref="FoundryChatClient"/> pushes a fresh box onto the scope
|
||||
/// before calling the inner client; the <see cref="ServedModelPolicy"/> writes into the box's
|
||||
/// <see cref="StrongBox{T}.Value"/> (a reference-type mutation visible to anyone holding the same box).
|
||||
/// After the inner call returns, the client reads the box's value.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class ServedModelScope
|
||||
{
|
||||
private static readonly AsyncLocal<StrongBox<string?>?> s_current = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-async-flow served model box.
|
||||
/// </summary>
|
||||
public static StrongBox<string?>? Current
|
||||
{
|
||||
get => s_current.Value;
|
||||
set => s_current.Value = value;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
#if NET
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
@@ -201,6 +204,14 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
result.Tools.Add(new HostedWebSearchTool());
|
||||
}
|
||||
|
||||
#if NET
|
||||
if (options?.ShellExecutor is ShellExecutor shellExecutor)
|
||||
{
|
||||
result.Tools ??= [];
|
||||
result.Tools.Add(shellExecutor.AsAIFunction());
|
||||
}
|
||||
#endif
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -259,6 +270,13 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
}
|
||||
}
|
||||
|
||||
#if NET
|
||||
if (options?.ShellExecutor is ShellExecutor shellExecutor)
|
||||
{
|
||||
providers.Add(new ShellEnvironmentProvider(shellExecutor, options.ShellEnvironmentProviderOptions));
|
||||
}
|
||||
#endif
|
||||
|
||||
if (options?.AIContextProviders is IEnumerable<AIContextProvider> userProviders)
|
||||
{
|
||||
providers.AddRange(userProviders);
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
#if NET
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
@@ -240,4 +243,27 @@ public sealed class HarnessAgentOptions
|
||||
/// This property is ignored when <see cref="BackgroundAgents"/> is <see langword="null"/> or empty.
|
||||
/// </remarks>
|
||||
public BackgroundAgentsProviderOptions? BackgroundAgentsProviderOptions { get; set; }
|
||||
|
||||
#if NET
|
||||
/// <summary>
|
||||
/// Gets or sets the shell executor used to enable shell tool and environment probing via <see cref="ShellEnvironmentProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When non-null, a <see cref="ShellEnvironmentProvider"/> is automatically included in the agent's context
|
||||
/// providers (injecting OS/shell/CWD information into the system prompt), and the executor's
|
||||
/// <see cref="ShellExecutor.AsAIFunction"/> is registered as a callable tool.
|
||||
/// When <see langword="null"/> (the default), no shell features are enabled.
|
||||
/// </remarks>
|
||||
public ShellExecutor? ShellExecutor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets optional configuration for the <see cref="ShellEnvironmentProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use this to customize which tools are probed, the probe timeout, shell family override,
|
||||
/// or the instructions formatter.
|
||||
/// This property is ignored when <see cref="ShellExecutor"/> is <see langword="null"/>.
|
||||
/// </remarks>
|
||||
public ShellEnvironmentProviderOptions? ShellEnvironmentProviderOptions { get; set; }
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Tools.Shell\Microsoft.Agents.AI.Tools.Shell.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Harness</Title>
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,7 +248,7 @@ public sealed class DockerShellExecutor : ShellExecutor
|
||||
/// Build the AIFunction for this tool.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <paramref name="requireApproval"/> is <see langword="null"/>
|
||||
/// When <paramref name="requireApproval"/> is <see langword="true"/>
|
||||
/// (the default), the returned function is wrapped in
|
||||
/// <see cref="ApprovalRequiredAIFunction"/>. The caller must
|
||||
/// explicitly pass <see langword="false"/> to opt out of approval
|
||||
@@ -259,14 +259,12 @@ public sealed class DockerShellExecutor : ShellExecutor
|
||||
/// <param name="name">Function name surfaced to the model.</param>
|
||||
/// <param name="description">Function description for the model.</param>
|
||||
/// <param name="requireApproval">
|
||||
/// <see langword="true"/> or <see langword="null"/> (the default)
|
||||
/// wraps the function in <see cref="ApprovalRequiredAIFunction"/>;
|
||||
/// <see langword="true"/> (the default) wraps the function in
|
||||
/// <see cref="ApprovalRequiredAIFunction"/>;
|
||||
/// <see langword="false"/> opts out and returns the raw function.
|
||||
/// </param>
|
||||
public AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool? requireApproval = null)
|
||||
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
|
||||
{
|
||||
var effectiveRequireApproval = requireApproval ?? true;
|
||||
|
||||
description ??=
|
||||
"Execute a single shell command inside an isolated Docker container and return its " +
|
||||
"stdout, stderr, and exit code. The container has no network, no host filesystem access " +
|
||||
@@ -292,7 +290,7 @@ public sealed class DockerShellExecutor : ShellExecutor
|
||||
},
|
||||
new AIFunctionFactoryOptions { Name = name, Description = description });
|
||||
|
||||
return effectiveRequireApproval ? new ApprovalRequiredAIFunction(fn) : fn;
|
||||
return requireApproval ? new ApprovalRequiredAIFunction(fn) : fn;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -325,7 +325,7 @@ public sealed class LocalShellExecutor : ShellExecutor
|
||||
/// container where the tool itself is the boundary).
|
||||
/// </param>
|
||||
/// <returns>An <see cref="AIFunction"/> wrapping <see cref="RunAsync"/>.</returns>
|
||||
public AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
|
||||
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
|
||||
{
|
||||
if (!requireApproval && !this._acknowledgeUnsafe)
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Tools.Shell;
|
||||
|
||||
@@ -65,6 +66,20 @@ public abstract class ShellExecutor : IAsyncDisposable
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public abstract Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Build an <see cref="AIFunction"/> bound to this executor, suitable for
|
||||
/// registering with an agent as a callable tool.
|
||||
/// </summary>
|
||||
/// <param name="name">Function name visible to the model.</param>
|
||||
/// <param name="description">Function description for the model.</param>
|
||||
/// <param name="requireApproval">
|
||||
/// When <see langword="true"/> (the default), wraps the function in
|
||||
/// <see cref="ApprovalRequiredAIFunction"/> so every invocation requires
|
||||
/// explicit user approval before executing.
|
||||
/// </param>
|
||||
/// <returns>An <see cref="AIFunction"/> wrapping <see cref="RunAsync"/>.</returns>
|
||||
public abstract AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true);
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract ValueTask DisposeAsync();
|
||||
}
|
||||
|
||||
+6
-2
@@ -22,9 +22,13 @@ internal static class AgentProviderExtensions
|
||||
{
|
||||
IAsyncEnumerable<AgentResponseUpdate> agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, inputArguments, cancellationToken);
|
||||
|
||||
// Enable "autoSend" behavior if this is the workflow conversation.
|
||||
// Determine whether the target conversation is the workflow conversation
|
||||
// (used below to decide whether to mirror messages into the workflow conversation
|
||||
// when an agent runs against a different conversation). The caller's autoSend
|
||||
// value is honored as-is — when the workflow.yaml specifies autoSend: false the
|
||||
// raw agent output must not be streamed to the caller, even when the agent is
|
||||
// running on the workflow conversation.
|
||||
bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? workflowConversationId);
|
||||
autoSend |= isWorkflowConversation;
|
||||
|
||||
// Process the agent response updates.
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
|
||||
+7
@@ -71,6 +71,13 @@ internal abstract class DeclarativeActionExecutor : Executor<ActionExecutorResul
|
||||
[SendsMessage(typeof(ActionExecutorResult))]
|
||||
public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Establish the Foundry ProductContext on the current async logical context before
|
||||
// running any code that reads PropertyPath.VariableName / NamespaceAlias. ObjectModel
|
||||
// resolves those lazily against AsyncLocal<ProductContext>; when the workflow is
|
||||
// hosted (AsAIAgent + AddFoundryResponses) each HTTP request runs on a fresh logical
|
||||
// context where the build-thread setting does not flow.
|
||||
WorkflowDiagnostics.SetFoundryProduct();
|
||||
|
||||
if (this.Model.Disabled)
|
||||
{
|
||||
Debug.WriteLine($"DISABLED {this.GetType().Name} [{this.Id}]");
|
||||
|
||||
+8
-5
@@ -192,13 +192,16 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
|
||||
|
||||
private bool GetAutoSendValue()
|
||||
{
|
||||
if (this.AgentOutput?.AutoSend is null)
|
||||
// AzureAgentOutput.AutoSend is never null — it returns a literal-false default
|
||||
// when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an
|
||||
// explicit autoSend value from the implicit default, and treat the implicit
|
||||
// default as autoSend = true (the historical behavior for actions that omit
|
||||
// autoSend or have no output block at all).
|
||||
if (this.AgentOutput is { AutoSendIsDefaultValue: false } output)
|
||||
{
|
||||
return true;
|
||||
return this.Evaluator.GetValue(output.AutoSend).Value;
|
||||
}
|
||||
|
||||
EvaluationResult<bool> autoSendResult = this.Evaluator.GetValue(this.AgentOutput.AutoSend);
|
||||
|
||||
return autoSendResult.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+68
-3
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
@@ -103,6 +104,24 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
FunctionResultContent? matchingResult = functionResults
|
||||
.FirstOrDefault(r => r.CallId == this.Id);
|
||||
|
||||
// When the caller approved an approval-required function call but didn't execute it
|
||||
// locally (the hosted Foundry scenario, where mcp_approval_response is converted to a
|
||||
// ToolApprovalResponseContent only), invoke the registered AIFunction here so that the
|
||||
// declarative workflow can capture the result and continue (e.g. for downstream
|
||||
// SendActivity/PropertyPath consumers like {Local.Result}).
|
||||
if (matchingResult is null)
|
||||
{
|
||||
ToolApprovalResponseContent? approval = response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalResponseContent>()
|
||||
.FirstOrDefault(r => r.RequestId == this.Id);
|
||||
|
||||
if (approval is { Approved: true })
|
||||
{
|
||||
matchingResult = await this.InvokeRegisteredFunctionAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingResult is not null)
|
||||
{
|
||||
// Store the result in output variable
|
||||
@@ -241,6 +260,48 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
return conversationIdValue.Length == 0 ? null : conversationIdValue;
|
||||
}
|
||||
|
||||
private async ValueTask<FunctionResultContent?> InvokeRegisteredFunctionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string functionName = this.GetFunctionName();
|
||||
AIFunction? function = agentProvider.Functions?.FirstOrDefault(
|
||||
f => string.Equals(f.Name, functionName, StringComparison.Ordinal));
|
||||
|
||||
if (function is null)
|
||||
{
|
||||
return new FunctionResultContent(this.Id, result: null)
|
||||
{
|
||||
Exception = new InvalidOperationException(
|
||||
$"Function '{functionName}' is not registered with the agent provider."),
|
||||
};
|
||||
}
|
||||
|
||||
Dictionary<string, object?>? arguments = this.GetArguments();
|
||||
AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments);
|
||||
|
||||
object? result;
|
||||
try
|
||||
{
|
||||
result = await function.InvokeAsync(functionArguments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return new FunctionResultContent(this.Id, result: null) { Exception = ex };
|
||||
}
|
||||
|
||||
// Match FunctionInvokingChatClient's serialization: pass strings through as-is and
|
||||
// JSON-serialize anything else so structured results remain consumable by downstream
|
||||
// PropertyPath consumers such as {Local.RefundResult}. Use AIJsonUtilities so the
|
||||
// same trim/AOT-friendly serializer chain used elsewhere in the framework is applied.
|
||||
string serialized = result switch
|
||||
{
|
||||
null => string.Empty,
|
||||
string s => s,
|
||||
_ => JsonSerializer.Serialize(result, AIJsonUtilities.DefaultOptions.GetTypeInfo(result.GetType())),
|
||||
};
|
||||
|
||||
return new FunctionResultContent(this.Id, serialized);
|
||||
}
|
||||
|
||||
private bool GetRequireApproval()
|
||||
{
|
||||
if (this.Model.RequireApproval is null)
|
||||
@@ -253,12 +314,16 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
|
||||
private bool GetAutoSendValue()
|
||||
{
|
||||
if (this.Model.Output?.AutoSend is null)
|
||||
// InvokeToolOutput.AutoSend is never null — it returns a literal-false default
|
||||
// when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an
|
||||
// explicit autoSend value from the implicit default, and treat the implicit
|
||||
// default as autoSend = true (the historical behavior).
|
||||
if (this.Model.Output is { AutoSendIsDefaultValue: false } output)
|
||||
{
|
||||
return true;
|
||||
return this.Evaluator.GetValue(output.AutoSend).Value;
|
||||
}
|
||||
|
||||
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
private Dictionary<string, object?>? GetArguments()
|
||||
|
||||
+7
-3
@@ -311,12 +311,16 @@ internal sealed class InvokeMcpToolExecutor(
|
||||
|
||||
private bool GetAutoSendValue()
|
||||
{
|
||||
if (this.Model.Output?.AutoSend is null)
|
||||
// InvokeToolOutput.AutoSend is never null — it returns a literal-false default
|
||||
// when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an
|
||||
// explicit autoSend value from the implicit default, and treat the implicit
|
||||
// default as autoSend = true (the historical behavior).
|
||||
if (this.Model.Output is { AutoSendIsDefaultValue: false } output)
|
||||
{
|
||||
return true;
|
||||
return this.Evaluator.GetValue(output.AutoSend).Value;
|
||||
}
|
||||
|
||||
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
private string? GetConnectionName()
|
||||
|
||||
+13
-4
@@ -21,12 +21,21 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt
|
||||
|
||||
await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ChatMessage message = new(ChatRole.Assistant, activityText);
|
||||
|
||||
// Emit an AgentResponseUpdateEvent so chat protocols (e.g. AsAIAgent) receive the
|
||||
// activity text as streaming chat content. This event is yielded by WorkflowSession
|
||||
// unconditionally, mirroring how AgentProviderExtensions surfaces autoSend agent
|
||||
// updates — without it, SendActivity output is dropped whenever the host runs with
|
||||
// includeWorkflowOutputsInResponse = false (the default).
|
||||
AgentResponseUpdate update = new(ChatRole.Assistant, activityText) { AuthorName = this.Id };
|
||||
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Route through YieldOutputAsync so the activity participates in the workflow's
|
||||
// output-filter pipeline. The runner currently special-cases AgentResponse to
|
||||
// produce an AgentResponseEvent identical to the one we'd build by hand, so this
|
||||
// is behavior-preserving today and forward-compatible if filtering is ever
|
||||
// applied to agent responses.
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
|
||||
// produce an AgentResponseEvent identical to the one we'd build by hand, which
|
||||
// is the gated summary surfaced only when includeWorkflowOutputsInResponse = true.
|
||||
AgentResponse response = new([message]);
|
||||
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,8 +70,6 @@ internal class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder))
|
||||
.YieldsOutput<AgentResponseUpdate>()
|
||||
.YieldsOutput<AgentResponse>()
|
||||
.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<ResetChatSignal>(this.ResetChat));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Azure.AI.Projects;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace Foundry.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests validating that the <c>x-ms-served-model</c> response header
|
||||
/// returned by the Azure OpenAI Responses API is surfaced on <see cref="ChatResponse.ModelId"/>.
|
||||
/// </summary>
|
||||
public class ResponsesAgentServedModelTests
|
||||
{
|
||||
// Matches a dated served-model snapshot, e.g. "gpt-5-nano-2025-08-07".
|
||||
private static readonly Regex s_snapshotRegex = new(@"-\d{4}-\d{2}-\d{2}$", RegexOptions.Compiled);
|
||||
|
||||
private static Uri Endpoint => new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
|
||||
|
||||
private static string DeploymentName => TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName);
|
||||
|
||||
private readonly AIProjectClient _client = new(Endpoint, TestAzureCliCredentials.CreateAzureCliCredential());
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_ReturnsServedModelSnapshotOnModelIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatClientAgent agent = this._client.AsAIAgent(
|
||||
model: DeploymentName,
|
||||
instructions: "You are a helpful assistant. Reply with a single short word.",
|
||||
name: "ServedModelTest");
|
||||
|
||||
IChatClient chatClient = agent.ChatClient;
|
||||
|
||||
// Act
|
||||
ChatResponse response = await chatClient.GetResponseAsync(
|
||||
[new ChatMessage(ChatRole.User, "Say hi.")],
|
||||
new ChatOptions { ModelId = DeploymentName });
|
||||
|
||||
// Assert
|
||||
AssertServedModel(response.ModelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AgentResponseRawRepresentationCarriesServedModelAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatClientAgent agent = this._client.AsAIAgent(
|
||||
model: DeploymentName,
|
||||
instructions: "You are a helpful assistant. Reply with a single short word.",
|
||||
name: "ServedModelTestRun");
|
||||
|
||||
// Act
|
||||
AgentResponse agentResponse = await agent.RunAsync("Say hi.");
|
||||
|
||||
// Assert
|
||||
ChatResponse? chatResponse = agentResponse.RawRepresentation as ChatResponse;
|
||||
Assert.NotNull(chatResponse);
|
||||
AssertServedModel(chatResponse!.ModelId);
|
||||
}
|
||||
|
||||
private static void AssertServedModel(string? modelId)
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(modelId), "ChatResponse.ModelId must be populated.");
|
||||
|
||||
// Primary invariant: the served-model value must look like a dated snapshot
|
||||
// (e.g. "gpt-5-nano-2025-08-07"). This is what the x-ms-served-model header carries.
|
||||
// Only when the configured deployment name itself already matches the snapshot pattern
|
||||
// do we fall back to permitting equality with the deployment alias.
|
||||
bool aliasIsSnapshot = s_snapshotRegex.IsMatch(DeploymentName);
|
||||
|
||||
if (aliasIsSnapshot)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.Matches(s_snapshotRegex, modelId!);
|
||||
Assert.NotEqual(DeploymentName, modelId);
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
|
||||
@@ -555,20 +555,20 @@ public sealed class FoundryChatClientTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region AgentFrameworkUserAgentPolicy registration + dedup
|
||||
#region AgentFrameworkUserAgentPolicy + ServedModelPolicy registration + dedup
|
||||
|
||||
[Fact]
|
||||
public void Register_AgentFrameworkUserAgentPolicy_OnUnderlyingOpenAIRequestPolicies()
|
||||
{
|
||||
// Arrange + Act: constructing a FoundryChatClient should register the
|
||||
// AgentFrameworkUserAgentPolicy on the inner chat client's OpenAIRequestPolicies.
|
||||
// AgentFrameworkUserAgentPolicy and ServedModelPolicy on the inner chat client's OpenAIRequestPolicies.
|
||||
var chatClient = new FoundryChatClient(CreateProjectClient(), "gpt-4o-mini");
|
||||
|
||||
// Assert: the inner chat client (MEAI's OpenAIResponsesChatClient) exposes
|
||||
// OpenAIRequestPolicies via GetService, and our policy is present in its entries.
|
||||
// OpenAIRequestPolicies via GetService, and both policies are present in its entries.
|
||||
var policies = chatClient.GetService<OpenAIRequestPolicies>();
|
||||
Assert.NotNull(policies);
|
||||
Assert.Equal(1, EntriesCount(policies!));
|
||||
Assert.Equal(2, EntriesCount(policies!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -576,7 +576,7 @@ public sealed class FoundryChatClientTests
|
||||
{
|
||||
// Arrange: construct via the ProjectsAgentVersion mode-2 variant, which chains via
|
||||
// :this(...) into the AgentReference ctor. If the policy registration code were
|
||||
// inadvertently called twice along the chain, we would see 2 entries.
|
||||
// inadvertently called twice along the chain, we would see more than 2 entries.
|
||||
var projectClient = CreateProjectClient();
|
||||
var agentVersion = ModelReaderWriter.Read<ProjectsAgentVersion>(
|
||||
BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!;
|
||||
@@ -585,10 +585,10 @@ public sealed class FoundryChatClientTests
|
||||
var chatClient = new FoundryChatClient(projectClient, agentVersion, baseChatOptions: null);
|
||||
|
||||
// Assert: even though the version variant funnels through the AgentReference ctor
|
||||
// via :this(...), the policy is registered exactly once on the inner pipeline.
|
||||
// via :this(...), each policy is registered exactly once on the inner pipeline.
|
||||
var policies = chatClient.GetService<OpenAIRequestPolicies>();
|
||||
Assert.NotNull(policies);
|
||||
Assert.Equal(1, EntriesCount(policies!));
|
||||
Assert.Equal(2, EntriesCount(policies!));
|
||||
Assert.Same(agentVersion, chatClient.GetService<ProjectsAgentVersion>());
|
||||
Assert.NotNull(chatClient.GetService<AgentReference>());
|
||||
}
|
||||
|
||||
+4
-1
@@ -14,11 +14,14 @@
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- FoundryEval tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
|
||||
<!-- Tests requiring net8.0+ (MEAI.Evaluation and some SCM pipeline APIs do not support legacy TFMs) -->
|
||||
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<Compile Remove="FoundryEvalConverterTests.cs" />
|
||||
<Compile Remove="FoundryEvalsTests.cs" />
|
||||
<Compile Remove="ClientHeadersExtensionsTests.cs" />
|
||||
<Compile Remove="ServedModelTestHelpers.cs" />
|
||||
<Compile Remove="ServedModelScopeTests.cs" />
|
||||
<Compile Remove="ServedModelPolicyTests.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ServedModelPolicy"/>: the SCM pipeline policy that reads the
|
||||
/// <c>x-ms-served-model</c> response header and writes it into the active
|
||||
/// <see cref="ServedModelScope"/> box.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tests drive the policy through a real OpenAI ResponsesClient SCM pipeline against a mock
|
||||
/// HTTP handler so the policy executes in its production configuration.
|
||||
/// </remarks>
|
||||
public sealed class ServedModelPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Instance_IsSingleton()
|
||||
{
|
||||
Assert.Same(ServedModelPolicy.Instance, ServedModelPolicy.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_HeaderPresent_SetsModelIdOnResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: "gpt-5-nano-2025-08-07");
|
||||
IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler);
|
||||
|
||||
// Act
|
||||
var response = await chatClient.GetResponseAsync("hi");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_HeaderAbsent_PreservesModelIdFromBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: null);
|
||||
IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler);
|
||||
|
||||
// Act
|
||||
var response = await chatClient.GetResponseAsync("hi");
|
||||
|
||||
// Assert: ModelId is the deployment alias from the JSON body ("fake").
|
||||
Assert.Equal("fake", response.ModelId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task ProcessAsync_EmptyOrWhitespaceHeader_PreservesModelIdFromBodyAsync(string headerValue)
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: headerValue);
|
||||
IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler);
|
||||
|
||||
// Act
|
||||
var response = await chatClient.GetResponseAsync("hi");
|
||||
|
||||
// Assert: empty/whitespace header is rejected by the policy, ModelId stays as "fake".
|
||||
Assert.Equal("fake", response.ModelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_HeaderWithSurroundingWhitespace_TrimsValueAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: " gpt-5-nano-2025-08-07 ");
|
||||
IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler);
|
||||
|
||||
// Act
|
||||
var response = await chatClient.GetResponseAsync("hi");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ServedModelScope"/>: the AsyncLocal carrier that bridges the
|
||||
/// served-model value from the SCM pipeline policy up to the delegating chat client.
|
||||
/// </summary>
|
||||
public sealed class ServedModelScopeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Current_DefaultIsNull()
|
||||
{
|
||||
Assert.Null(ServedModelScope.Current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Current_SetAndGet_ReturnsBox()
|
||||
{
|
||||
// Arrange
|
||||
var previous = ServedModelScope.Current;
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var box = new StrongBox<string?>("gpt-5-nano-2025-08-07");
|
||||
ServedModelScope.Current = box;
|
||||
|
||||
// Assert
|
||||
Assert.Same(box, ServedModelScope.Current);
|
||||
Assert.Equal("gpt-5-nano-2025-08-07", ServedModelScope.Current!.Value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ServedModelScope.Current = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers and fake clients used by the served-model test suite
|
||||
/// (<see cref="ServedModelScopeTests"/>, <see cref="ServedModelPolicyTests"/>).
|
||||
/// </summary>
|
||||
internal static class ServedModelTestHelpers
|
||||
{
|
||||
public static string MinimalResponseJson() => """
|
||||
{
|
||||
"id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
|
||||
"model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
|
||||
}
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="FoundryChatClient"/> backed by a real OpenAI Responses pipeline
|
||||
/// routed through the supplied <paramref name="handler"/>. The <see cref="ServedModelPolicy"/>
|
||||
/// is registered automatically by the <see cref="FoundryChatClient"/> constructor.
|
||||
/// </summary>
|
||||
public static IChatClient CreateChatClientWithPolicy(HttpMessageHandler handler)
|
||||
{
|
||||
#pragma warning disable CA5399
|
||||
var http = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(http) });
|
||||
|
||||
return new FoundryChatClient(projectClient, "fake");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="HttpClientHandler"/> that returns a fixed response body and optionally
|
||||
/// includes the <c>x-ms-served-model</c> response header.
|
||||
/// </summary>
|
||||
public sealed class ServedModelHandler : HttpClientHandler
|
||||
{
|
||||
private readonly string _body;
|
||||
private readonly string? _servedModel;
|
||||
|
||||
public ServedModelHandler(string body, string? servedModel)
|
||||
{
|
||||
this._body = body;
|
||||
this._servedModel = servedModel;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
|
||||
if (this._servedModel is not null)
|
||||
{
|
||||
resp.Headers.Add("x-ms-served-model", this._servedModel);
|
||||
}
|
||||
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Moq;
|
||||
#if NET
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
@@ -39,6 +42,10 @@ public class HarnessAgentOptionsTests
|
||||
Assert.Null(options.AgentSkillsSource);
|
||||
Assert.Null(options.BackgroundAgents);
|
||||
Assert.Null(options.BackgroundAgentsProviderOptions);
|
||||
#if NET
|
||||
Assert.Null(options.ShellExecutor);
|
||||
Assert.Null(options.ShellEnvironmentProviderOptions);
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -56,6 +63,10 @@ public class HarnessAgentOptionsTests
|
||||
var skillsSource = new Mock<AgentSkillsSource>().Object;
|
||||
var backgroundAgents = new AIAgent[] { new Mock<AIAgent>().Object };
|
||||
var backgroundAgentsOptions = new BackgroundAgentsProviderOptions();
|
||||
#if NET
|
||||
var shellExecutor = new Mock<ShellExecutor>().Object;
|
||||
var shellEnvOptions = new ShellEnvironmentProviderOptions();
|
||||
#endif
|
||||
|
||||
// Act
|
||||
var options = new HarnessAgentOptions
|
||||
@@ -83,6 +94,10 @@ public class HarnessAgentOptionsTests
|
||||
OpenTelemetrySourceName = "custom-source",
|
||||
BackgroundAgents = backgroundAgents,
|
||||
BackgroundAgentsProviderOptions = backgroundAgentsOptions,
|
||||
#if NET
|
||||
ShellExecutor = shellExecutor,
|
||||
ShellEnvironmentProviderOptions = shellEnvOptions,
|
||||
#endif
|
||||
};
|
||||
|
||||
// Assert
|
||||
@@ -111,5 +126,9 @@ public class HarnessAgentOptionsTests
|
||||
Assert.Equal("custom-source", options.OpenTelemetrySourceName);
|
||||
Assert.Same(backgroundAgents, options.BackgroundAgents);
|
||||
Assert.Same(backgroundAgentsOptions, options.BackgroundAgentsProviderOptions);
|
||||
#if NET
|
||||
Assert.Same(shellExecutor, options.ShellExecutor);
|
||||
Assert.Same(shellEnvOptions, options.ShellEnvironmentProviderOptions);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
#if NET
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
@@ -1347,4 +1350,114 @@ public class HarnessAgentTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#if NET
|
||||
#region Feature: ShellEnvironmentProvider
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ShellEnvironmentProvider is included when ShellExecutor is provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ShellEnvironmentProvider_IncludedWhenExecutorProvided()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var executorMock = new Mock<ShellExecutor>();
|
||||
executorMock.Setup(e => e.AsAIFunction(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<bool>()))
|
||||
.Returns(AIFunctionFactory.Create(() => "test", "run_shell"));
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.ShellExecutor = executorMock.Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(innerAgent?.AIContextProviders);
|
||||
Assert.Contains(innerAgent!.AIContextProviders!, p => p is ShellEnvironmentProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ShellEnvironmentProvider is not included when ShellExecutor is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ShellEnvironmentProvider_ExcludedWhenExecutorNull()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.ShellExecutor = null;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(innerAgent);
|
||||
Assert.NotNull(innerAgent!.AIContextProviders);
|
||||
Assert.DoesNotContain(innerAgent.AIContextProviders!, p => p is ShellEnvironmentProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the shell tool AIFunction is added to ChatOptions.Tools when ShellExecutor is provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ShellExecutor_ToolAddedToChatOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatOptions? capturedOptions = null;
|
||||
var chatClientMock = new Mock<IChatClient>();
|
||||
chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")));
|
||||
|
||||
var executorMock = new Mock<ShellExecutor>();
|
||||
executorMock.Setup(e => e.AsAIFunction(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<bool>()))
|
||||
.Returns(AIFunctionFactory.Create(() => "shell output", "run_shell"));
|
||||
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.DisableWebSearch = true;
|
||||
options.ShellExecutor = executorMock.Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClientMock.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
|
||||
|
||||
// Assert — the shell tool should be present
|
||||
Assert.NotNull(capturedOptions?.Tools);
|
||||
Assert.Contains(capturedOptions!.Tools!, t => t is AIFunction f && f.Name == "run_shell");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ShellEnvironmentProvider is present when ShellEnvironmentProviderOptions is also specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ShellEnvironmentProvider_PresentWhenOptionsProvided()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var executorMock = new Mock<ShellExecutor>();
|
||||
executorMock.Setup(e => e.AsAIFunction(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<bool>()))
|
||||
.Returns(AIFunctionFactory.Create(() => "test", "run_shell"));
|
||||
var envOptions = new ShellEnvironmentProviderOptions
|
||||
{
|
||||
ProbeTools = ["git", "python"],
|
||||
};
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.ShellExecutor = executorMock.Object;
|
||||
options.ShellEnvironmentProviderOptions = envOptions;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — provider should exist (options wiring is validated by the provider's behavior)
|
||||
Assert.NotNull(innerAgent?.AIContextProviders);
|
||||
Assert.Contains(innerAgent!.AIContextProviders!, p => p is ShellEnvironmentProvider);
|
||||
}
|
||||
|
||||
#endregion
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
@@ -6,6 +6,7 @@ using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Tools.Shell.UnitTests;
|
||||
|
||||
@@ -226,6 +227,8 @@ public sealed class ShellEnvironmentProviderTests
|
||||
public override Task InitializeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public override Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(this.Responses.Dequeue());
|
||||
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true) =>
|
||||
throw new NotSupportedException();
|
||||
public override ValueTask DisposeAsync() => default;
|
||||
}
|
||||
|
||||
@@ -280,6 +283,8 @@ public sealed class ShellEnvironmentProviderTests
|
||||
public override Task InitializeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public override Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(this._factory(cancellationToken));
|
||||
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true) =>
|
||||
throw new NotSupportedException();
|
||||
public override ValueTask DisposeAsync() => default;
|
||||
}
|
||||
|
||||
@@ -372,6 +377,8 @@ public sealed class ShellEnvironmentProviderTests
|
||||
this.RunCount++;
|
||||
return Task.FromResult(this.NextResult);
|
||||
}
|
||||
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true) =>
|
||||
throw new NotSupportedException();
|
||||
public override ValueTask DisposeAsync() => default;
|
||||
}
|
||||
}
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.PowerFx.Types;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="AgentProviderExtensions.InvokeAgentAsync"/>.
|
||||
/// </summary>
|
||||
public sealed class AgentProviderExtensionsTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
|
||||
{
|
||||
private const string WorkflowConversationId = "workflow-conv-id";
|
||||
private const string AgentName = "test-agent";
|
||||
|
||||
[Fact]
|
||||
public Task AutoSendFalseOnWorkflowConversationSuppressesResponseEventsAsync() =>
|
||||
this.RunAsync(autoSend: false, conversationId: WorkflowConversationId, expectResponseEvents: false);
|
||||
|
||||
[Fact]
|
||||
public Task AutoSendTrueOnWorkflowConversationEmitsResponseEventsAsync() =>
|
||||
this.RunAsync(autoSend: true, conversationId: WorkflowConversationId, expectResponseEvents: true);
|
||||
|
||||
[Fact]
|
||||
public Task AutoSendFalseOnExternalConversationSuppressesResponseEventsAsync() =>
|
||||
this.RunAsync(autoSend: false, conversationId: "other-conv-id", expectResponseEvents: false);
|
||||
|
||||
[Fact]
|
||||
public Task AutoSendTrueOnExternalConversationEmitsResponseEventsAndCopiesMessagesAsync() =>
|
||||
this.RunAsync(
|
||||
autoSend: true,
|
||||
conversationId: "other-conv-id",
|
||||
expectResponseEvents: true,
|
||||
expectCrossConversationCopy: true);
|
||||
|
||||
private async Task RunAsync(
|
||||
bool autoSend,
|
||||
string conversationId,
|
||||
bool expectResponseEvents,
|
||||
bool expectCrossConversationCopy = false)
|
||||
{
|
||||
// Arrange: seed the workflow conversation id so IsWorkflowConversation can recognize it.
|
||||
this.State.Set(
|
||||
SystemScope.Names.ConversationId,
|
||||
FormulaValue.New(WorkflowConversationId),
|
||||
VariableScopeNames.System);
|
||||
|
||||
MockAgentProvider mockProvider = new();
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new(ChatRole.Assistant, "hello "),
|
||||
new(ChatRole.Assistant, "world"),
|
||||
];
|
||||
mockProvider
|
||||
.Setup(p => p.InvokeAgentAsync(
|
||||
AgentName,
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<IEnumerable<ChatMessage>?>(),
|
||||
It.IsAny<IDictionary<string, object?>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(updates));
|
||||
|
||||
List<(string ConversationId, ChatMessage Message)> copiedMessages = [];
|
||||
mockProvider
|
||||
.Setup(p => p.CreateMessageAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<ChatMessage>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns<string, ChatMessage, CancellationToken>(
|
||||
(convId, msg, _) =>
|
||||
{
|
||||
copiedMessages.Add((convId, msg));
|
||||
return Task.FromResult(msg);
|
||||
});
|
||||
|
||||
string actionId = this.CreateActionId().Value;
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events =
|
||||
await this.ExecuteAsync(
|
||||
actionId,
|
||||
async (IWorkflowContext context, ActionExecutorResult _, CancellationToken cancellationToken) =>
|
||||
{
|
||||
await mockProvider.Object.InvokeAgentAsync(
|
||||
actionId,
|
||||
context,
|
||||
AgentName,
|
||||
conversationId,
|
||||
autoSend,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
// Assert
|
||||
int updateEventCount = events.OfType<AgentResponseUpdateEvent>().Count();
|
||||
int responseEventCount = events.OfType<AgentResponseEvent>().Count();
|
||||
|
||||
if (expectResponseEvents)
|
||||
{
|
||||
Assert.Equal(updates.Length, updateEventCount);
|
||||
Assert.Equal(1, responseEventCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(0, updateEventCount);
|
||||
Assert.Equal(0, responseEventCount);
|
||||
}
|
||||
|
||||
if (expectCrossConversationCopy)
|
||||
{
|
||||
Assert.NotEmpty(copiedMessages);
|
||||
Assert.All(copiedMessages, c => Assert.Equal(WorkflowConversationId, c.ConversationId));
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Empty(copiedMessages);
|
||||
}
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<AgentResponseUpdate> ToAsyncEnumerableAsync(IEnumerable<AgentResponseUpdate> updates)
|
||||
{
|
||||
foreach (AgentResponseUpdate update in updates)
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.6.0] - 2026-05-21
|
||||
|
||||
### Added
|
||||
- **agent-framework-core**: Shell tool with support for local and Docker execution ([#5664](https://github.com/microsoft/agent-framework/pull/5664))
|
||||
- **agent-framework-monty**: New Monty-backed CodeAct provider package ([#5915](https://github.com/microsoft/agent-framework/pull/5915))
|
||||
- **agent-framework-foundry**: Add experimental hosted tool factories on `FoundryChatClient` ([#5958](https://github.com/microsoft/agent-framework/pull/5958))
|
||||
- **agent-framework-foundry**: Include tool definitions for Foundry agent evals ([#5974](https://github.com/microsoft/agent-framework/pull/5974))
|
||||
- **agent-framework-a2a**: Use non-streaming transport and `return_immediately` for background ops ([#5963](https://github.com/microsoft/agent-framework/pull/5963))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-core**, **agent-framework-foundry**: [BREAKING] Enable instrumentation by default ([#5865](https://github.com/microsoft/agent-framework/pull/5865))
|
||||
- **agent-framework-foundry**: Show more authentication methods in Foundry Toolbox MCP ([#5719](https://github.com/microsoft/agent-framework/pull/5719))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-core**: Skip MCP prompt loading when unsupported ([#5370](https://github.com/microsoft/agent-framework/pull/5370))
|
||||
|
||||
## [1.5.0] - 2026-05-19
|
||||
|
||||
### Added
|
||||
@@ -1088,7 +1104,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.6.0...HEAD
|
||||
[1.6.0]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...python-1.6.0
|
||||
[1.5.0]: https://github.com/microsoft/agent-framework/compare/python-1.4.0...python-1.5.0
|
||||
[1.4.0]: https://github.com/microsoft/agent-framework/compare/python-1.3.0...python-1.4.0
|
||||
[1.3.0]: https://github.com/microsoft/agent-framework/compare/python-1.2.2...python-1.3.0
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"a2a-sdk>=1.0.0,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0rc2"
|
||||
version = "1.0.0rc3"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"ag-ui-protocol>=0.1.16,<0.2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<1"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"anthropic>=0.80.0,<0.80.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"azure-search-documents>=11.7.0b2,<11.7.0b3",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Content Understanding integration for Microsoft Agent Frame
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260519"
|
||||
version = "1.0.0a260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-foundry>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-foundry>=1.6.0,<2",
|
||||
"azure-ai-contentunderstanding>=1.0.1,<1.1",
|
||||
"aiohttp>=3.9,<4",
|
||||
"filetype>=1.2,<2",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"azure-cosmos>=4.3.0,<5",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,8 +22,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-durabletask>=1.0.0b260519,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-durabletask>=1.0.0b260521,<2",
|
||||
"azure-functions>=1.24.0,<2",
|
||||
"azure-functions-durable>=1.3.1,<2",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"boto3>=1.35.0,<2.0.0",
|
||||
"botocore>=1.35.0,<2.0.0",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"openai-chatkit>=1.4.1,<2.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"claude-agent-sdk>=0.1.36,<0.1.49",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -2153,15 +2153,14 @@ def _capture_messages(
|
||||
finish_reason: FinishReason | None = None,
|
||||
) -> None:
|
||||
"""Log messages with extra information."""
|
||||
from ._types import normalize_messages, prepend_instructions_to_messages
|
||||
from ._types import normalize_messages
|
||||
|
||||
prepped = prepend_instructions_to_messages(normalize_messages(messages), system_instructions)
|
||||
normalized_messages = normalize_messages(messages)
|
||||
otel_messages: list[dict[str, Any]] = []
|
||||
for index, message in enumerate(prepped):
|
||||
for index, message in enumerate(normalized_messages):
|
||||
# Reuse the otel message representation for logging instead of calling to_dict()
|
||||
# to avoid expensive Pydantic serialization overhead
|
||||
otel_message = _to_otel_message(message)
|
||||
otel_messages.append(otel_message)
|
||||
logger.info(
|
||||
otel_message,
|
||||
extra={
|
||||
@@ -2170,6 +2169,7 @@ def _capture_messages(
|
||||
MessageListTimestampFilter.INDEX_KEY: index,
|
||||
},
|
||||
)
|
||||
otel_messages.append(otel_message)
|
||||
if finish_reason:
|
||||
otel_messages[-1]["finish_reason"] = FINISH_REASON_MAP[finish_reason]
|
||||
span.set_attribute(
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.5.0"
|
||||
version = "1.6.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -290,9 +290,9 @@ async def test_chat_client_observability_with_instructions(
|
||||
assert len(system_instructions) == 1
|
||||
assert system_instructions[0]["content"] == "You are a helpful assistant."
|
||||
|
||||
# Verify input_messages contains system message
|
||||
# Verify input_messages excludes system instructions
|
||||
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
|
||||
assert any(msg.get("role") == "system" for msg in input_messages)
|
||||
assert [msg.get("role") for msg in input_messages] == ["user"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
@@ -324,6 +324,40 @@ async def test_chat_client_streaming_observability_with_instructions(
|
||||
assert len(system_instructions) == 1
|
||||
assert system_instructions[0]["content"] == "You are a helpful assistant."
|
||||
|
||||
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
|
||||
assert [msg.get("role") for msg in input_messages] == ["user"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_chat_client_observability_with_system_message_and_instructions(
|
||||
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
|
||||
):
|
||||
"""Test input chat-history system messages stay in input_messages when instructions are separate."""
|
||||
import json
|
||||
|
||||
client = mock_chat_client()
|
||||
|
||||
messages = [
|
||||
Message(role="system", contents=["Original system message"]),
|
||||
Message(role="user", contents=["Test message"]),
|
||||
]
|
||||
options = {"model": "Test", "instructions": "Framework system instruction"}
|
||||
span_exporter.clear()
|
||||
response = await client.get_response(messages=messages, options=options)
|
||||
|
||||
assert response is not None
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
span = spans[0]
|
||||
|
||||
system_instructions = json.loads(span.attributes[OtelAttr.SYSTEM_INSTRUCTIONS])
|
||||
assert system_instructions == [{"type": "text", "content": "Framework system instruction"}]
|
||||
|
||||
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
|
||||
assert [msg.get("role") for msg in input_messages] == ["system", "user"]
|
||||
assert input_messages[0]["parts"][0]["content"] == "Original system message"
|
||||
assert input_messages[1]["parts"][0]["content"] == "Test message"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_chat_client_observability_without_instructions(
|
||||
@@ -2981,6 +3015,81 @@ async def test_system_instructions_preserves_non_ascii_characters(span_exporter:
|
||||
system_instructions = json.loads(system_instructions_json)
|
||||
assert system_instructions[0]["content"] == chinese_text
|
||||
|
||||
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
|
||||
assert [msg.get("role") for msg in input_messages] == ["user"]
|
||||
|
||||
|
||||
def test_capture_messages_keeps_framework_instructions_out_of_logs_and_span_messages(
|
||||
span_exporter: InMemorySpanExporter,
|
||||
):
|
||||
"""Test separate framework instructions do not appear in chat-history logs or span messages."""
|
||||
import json
|
||||
|
||||
from opentelemetry import trace
|
||||
|
||||
tracer = trace.get_tracer("test")
|
||||
span_exporter.clear()
|
||||
|
||||
with (
|
||||
patch("agent_framework.observability.logger.info") as mock_logger_info,
|
||||
tracer.start_as_current_span("test_span") as span,
|
||||
):
|
||||
_capture_messages(
|
||||
span=span,
|
||||
provider_name="test_provider",
|
||||
messages=[Message(role="user", contents=["Test"])],
|
||||
system_instructions="Framework system instruction",
|
||||
)
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
input_messages = json.loads(spans[0].attributes[OtelAttr.INPUT_MESSAGES])
|
||||
assert [msg.get("role") for msg in input_messages] == ["user"]
|
||||
|
||||
assert mock_logger_info.call_count == 1, f"Expected 1 log call, got {mock_logger_info.call_count}"
|
||||
(first_call,) = mock_logger_info.call_args_list
|
||||
assert first_call.args
|
||||
logged_message = first_call.args[0]
|
||||
assert logged_message["role"] == "user"
|
||||
assert logged_message["parts"][0]["content"] == "Test"
|
||||
|
||||
|
||||
def test_capture_messages_logs_only_chat_history_when_framework_instructions_are_separate(
|
||||
span_exporter: InMemorySpanExporter,
|
||||
):
|
||||
"""Test chat-history logging preserves original system messages without prepending framework instructions."""
|
||||
import json
|
||||
|
||||
from opentelemetry import trace
|
||||
|
||||
tracer = trace.get_tracer("test")
|
||||
span_exporter.clear()
|
||||
|
||||
with (
|
||||
patch("agent_framework.observability.logger.info") as mock_logger_info,
|
||||
tracer.start_as_current_span("test_span") as span,
|
||||
):
|
||||
_capture_messages(
|
||||
span=span,
|
||||
provider_name="test_provider",
|
||||
messages=[
|
||||
Message(role="system", contents=["Original system message"]),
|
||||
Message(role="user", contents=["Test"]),
|
||||
],
|
||||
system_instructions="Framework system instruction",
|
||||
)
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
input_messages = json.loads(spans[0].attributes[OtelAttr.INPUT_MESSAGES])
|
||||
assert [msg.get("role") for msg in input_messages] == ["system", "user"]
|
||||
|
||||
assert mock_logger_info.call_count == 2, f"Expected 2 log calls, got {mock_logger_info.call_count}"
|
||||
logged_messages = [call.args[0] for call in mock_logger_info.call_args_list]
|
||||
assert [msg["role"] for msg in logged_messages] == ["system", "user"]
|
||||
assert logged_messages[0]["parts"][0]["content"] == "Original system message"
|
||||
assert logged_messages[1]["parts"][0]["content"] == "Test"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_tool_arguments_preserves_non_ascii_characters(span_exporter: InMemorySpanExporter):
|
||||
@@ -3104,6 +3213,40 @@ async def test_agent_instructions_from_default_options(
|
||||
assert len(system_instructions) == 1
|
||||
assert system_instructions[0]["content"] == "Default system instructions."
|
||||
|
||||
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
|
||||
assert [msg.get("role") for msg in input_messages] == ["user"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_agent_instructions_preserve_system_messages_in_history(
|
||||
mock_chat_agent, span_exporter: InMemorySpanExporter, enable_sensitive_data
|
||||
):
|
||||
"""Test agent spans keep chat-history system messages separate from framework instructions."""
|
||||
import json
|
||||
|
||||
agent = mock_chat_agent()
|
||||
agent.default_options = {"model": "TestModel", "instructions": "Default system instructions."}
|
||||
|
||||
messages = [
|
||||
Message(role="system", contents=["Original system message"]),
|
||||
Message(role="user", contents=["Test message"]),
|
||||
]
|
||||
span_exporter.clear()
|
||||
response = await agent.run(messages)
|
||||
|
||||
assert response is not None
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
span = spans[0]
|
||||
|
||||
system_instructions = json.loads(span.attributes[OtelAttr.SYSTEM_INSTRUCTIONS])
|
||||
assert system_instructions == [{"type": "text", "content": "Default system instructions."}]
|
||||
|
||||
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
|
||||
assert [msg.get("role") for msg in input_messages] == ["system", "user"]
|
||||
assert input_messages[0]["parts"][0]["content"] == "Original system message"
|
||||
assert input_messages[1]["parts"][0]["content"] == "Test message"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_agent_instructions_from_options_override(
|
||||
|
||||
+13
-13
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"httpx>=0.27,<1",
|
||||
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"openai>=1.99.0,<3",
|
||||
"opentelemetry-sdk>=1.39.0,<2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"durabletask>=1.4.0,!=1.4.1,!=1.4.2,!=1.4.3,<2",
|
||||
"durabletask-azuremanaged>=1.4.0,<2",
|
||||
"python-dateutil>=2.8.0,<3",
|
||||
|
||||
@@ -75,6 +75,15 @@ _TOOL_EVALUATORS: set[str] = {
|
||||
"builtin.tool_call_success",
|
||||
}
|
||||
|
||||
# Evaluators that accept tool_definitions in their data mapping when the
|
||||
# evaluated items include tools.
|
||||
_TOOL_DEFINITION_EVALUATORS: set[str] = _TOOL_EVALUATORS | {
|
||||
"builtin.intent_resolution",
|
||||
"builtin.task_adherence",
|
||||
"builtin.task_completion",
|
||||
"builtin.task_navigation_efficiency",
|
||||
}
|
||||
|
||||
# Evaluators that require a ground_truth / expected_output field.
|
||||
_GROUND_TRUTH_EVALUATORS: set[str] = {
|
||||
"builtin.similarity",
|
||||
@@ -161,6 +170,7 @@ def _build_testing_criteria(
|
||||
model: str,
|
||||
*,
|
||||
include_data_mapping: bool = False,
|
||||
include_tool_definitions: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build ``testing_criteria`` for ``evals.create()``.
|
||||
|
||||
@@ -169,6 +179,8 @@ def _build_testing_criteria(
|
||||
model: Model deployment for the LLM judge.
|
||||
include_data_mapping: Whether to include field-level data mapping
|
||||
(required for the JSONL data source, not needed for response-based).
|
||||
include_tool_definitions: Whether the mapped data items include tool
|
||||
definitions.
|
||||
"""
|
||||
criteria: list[dict[str, Any]] = []
|
||||
for name in evaluators:
|
||||
@@ -203,7 +215,7 @@ def _build_testing_criteria(
|
||||
mapping["context"] = "{{item.context}}"
|
||||
if qualified in _GROUND_TRUTH_EVALUATORS:
|
||||
mapping["ground_truth"] = "{{item.ground_truth}}"
|
||||
if qualified in _TOOL_EVALUATORS:
|
||||
if include_tool_definitions and qualified in _TOOL_DEFINITION_EVALUATORS:
|
||||
mapping["tool_definitions"] = "{{item.tool_definitions}}"
|
||||
entry["data_mapping"] = mapping
|
||||
|
||||
@@ -713,6 +725,7 @@ class FoundryEvals:
|
||||
evaluators,
|
||||
self._model,
|
||||
include_data_mapping=True,
|
||||
include_tool_definitions=has_tools,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.5.0"
|
||||
version = "1.6.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-openai>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-openai>=1.6.0,<2",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"azure-ai-projects>=2.1.0,<3.0",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -745,7 +745,12 @@ class TestBuildTestingCriteria:
|
||||
assert "conversation" not in criteria[1]["data_mapping"]
|
||||
|
||||
def test_tool_evaluator_includes_tool_definitions(self) -> None:
|
||||
criteria = _build_testing_criteria(["relevance", "tool_call_accuracy"], "gpt-4o", include_data_mapping=True)
|
||||
criteria = _build_testing_criteria(
|
||||
["relevance", "tool_call_accuracy"],
|
||||
"gpt-4o",
|
||||
include_data_mapping=True,
|
||||
include_tool_definitions=True,
|
||||
)
|
||||
# relevance: string query/response
|
||||
assert criteria[0]["data_mapping"]["query"] == "{{item.query}}"
|
||||
assert criteria[0]["data_mapping"]["response"] == "{{item.response}}"
|
||||
@@ -762,6 +767,17 @@ class TestBuildTestingCriteria:
|
||||
assert c["data_mapping"]["query"] == "{{item.query_messages}}", f"{c['name']}"
|
||||
assert c["data_mapping"]["response"] == "{{item.response_messages}}", f"{c['name']}"
|
||||
|
||||
def test_agent_evaluators_include_tool_definitions_when_tools_present(self) -> None:
|
||||
agent_evals = ["task_adherence", "intent_resolution", "task_completion", "task_navigation_efficiency"]
|
||||
criteria = _build_testing_criteria(
|
||||
agent_evals,
|
||||
"gpt-4o",
|
||||
include_data_mapping=True,
|
||||
include_tool_definitions=True,
|
||||
)
|
||||
for c in criteria:
|
||||
assert c["data_mapping"]["tool_definitions"] == "{{item.tool_definitions}}", f"{c['name']}"
|
||||
|
||||
def test_quality_evaluators_use_strings(self) -> None:
|
||||
quality_evals = ["coherence", "relevance", "fluency"]
|
||||
criteria = _build_testing_criteria(quality_evals, "gpt-4o", include_data_mapping=True)
|
||||
@@ -781,7 +797,12 @@ class TestBuildTestingCriteria:
|
||||
"tool_output_utilization",
|
||||
"tool_call_success",
|
||||
]
|
||||
criteria = _build_testing_criteria(tool_evals, "gpt-4o", include_data_mapping=True)
|
||||
criteria = _build_testing_criteria(
|
||||
tool_evals,
|
||||
"gpt-4o",
|
||||
include_data_mapping=True,
|
||||
include_tool_definitions=True,
|
||||
)
|
||||
for c in criteria:
|
||||
assert "tool_definitions" in c["data_mapping"], f"{c['name']} missing tool_definitions"
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Foundry Hosting integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260519"
|
||||
version = "1.0.0a260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"azure-ai-agentserver-core>=2.0.0b3,<3",
|
||||
"azure-ai-agentserver-responses>=1.0.0b5,<2",
|
||||
"azure-ai-agentserver-invocations>=1.0.0b3,<2",
|
||||
|
||||
@@ -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 @@ description = "Foundry Local integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-openai>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-openai>=1.6.0,<2",
|
||||
"foundry-local-sdk>=0.5.1,<0.5.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Google Gemini integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260519"
|
||||
version = "1.0.0a260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2.0",
|
||||
"agent-framework-core>=1.6.0,<2.0",
|
||||
"google-genai>=1.65.0,<2.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"github-copilot-sdk>=1.0.0b2,<=1.0.0b2; python_version >= '3.11'",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Hyperlight CodeAct integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"hyperlight-sandbox>=0.4.0,<0.5",
|
||||
"hyperlight-sandbox-backend-wasm>=0.4.0,<0.5 ; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'",
|
||||
"hyperlight-sandbox-python-guest>=0.4.0,<0.5",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework"
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"mem0ai>=1.0.0,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Monty CodeAct integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260518"
|
||||
version = "1.0.0a260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"pydantic-monty>=0,<0.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Ollama integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://learn.microsoft.com/en-us/agent-framework/"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"ollama>=0.5.3,<0.5.4",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.5.0"
|
||||
version = "1.6.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"openai>=1.99.0,<3",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Orchestration patterns for Microsoft Agent Framework. Includes Se
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0rc1"
|
||||
version = "1.0.0rc2"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"azure-core>=1.30.0,<2",
|
||||
"httpx>=0.27.0,<0.29",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260519"
|
||||
version = "1.0.0b260521"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.5.0,<2",
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"redis>=6.4.0,<7.2.1",
|
||||
"redisvl>=0.11.0,<0.16",
|
||||
"numpy>=2.2.6,<3"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,171 @@
|
||||
# agent-framework-tools
|
||||
|
||||
Alpha built-in tools for the Microsoft Agent Framework. A home for first-party
|
||||
Python tools that plug into any chat client's shell / function surface. The
|
||||
first tool is `LocalShellTool`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install agent-framework-tools --pre
|
||||
```
|
||||
|
||||
## `LocalShellTool` quick start
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from agent_framework import Agent
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework_tools.shell import LocalShellTool
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
client = OpenAIChatClient(model="gpt-5.4-nano")
|
||||
async with LocalShellTool() as shell:
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions="You are a helpful assistant that can run shell commands.",
|
||||
tools=[client.get_shell_tool(func=shell.as_function())],
|
||||
)
|
||||
result = await agent.run("Print the current working directory.")
|
||||
print(result.text)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Modes
|
||||
|
||||
- **Persistent** (default): a single long-lived shell session. `cd`, `export`,
|
||||
and shell functions persist across tool invocations.
|
||||
- **Stateless** (`mode="stateless"`): each command runs in a fresh subprocess.
|
||||
|
||||
### Safety
|
||||
|
||||
> **`LocalShellTool` is not a sandbox.** It runs commands directly on the
|
||||
> host with the agent process's privileges. The actual security boundary
|
||||
> is **approval-in-the-loop**. For untrusted input use a sandboxed
|
||||
> executor — see [`agent-framework-hyperlight`](#relationship-to-agent-framework-hyperlight).
|
||||
|
||||
Defenses (in priority order):
|
||||
|
||||
- **Approval-in-the-loop** — every command surfaces as a
|
||||
`user_input_request`; nothing runs without consent. Disabling this
|
||||
requires `acknowledge_unsafe=True`.
|
||||
- **Process-tree termination on timeout** via `psutil`, so child
|
||||
processes (`make`, watchers, network tools) cannot survive the timeout.
|
||||
- **Output truncation** to 64 KiB (head + tail with marker).
|
||||
- **Audit hook** (`on_command=…`) for SIEM / append-only logs.
|
||||
- **Optional command-pattern filter** via `ShellPolicy(denylist=[...],
|
||||
allowlist=[...])`. **Empty by default.** This is a UX pre-filter, not a
|
||||
security boundary — operators are expected to supply patterns that
|
||||
match their workload (and they can be defeated by trivial obfuscation
|
||||
such as `\rm -rf /`, `${RM:=rm} -rf /`, `python -c "…"`, encoded
|
||||
payloads, or PowerShell-native equivalents). Real isolation comes from
|
||||
approval gating and the sandbox tier (`DockerShellTool`). See
|
||||
`tests/test_security.py` for the documented residual risk surface.
|
||||
|
||||
Override with `ShellPolicy`:
|
||||
|
||||
```python
|
||||
from agent_framework_tools.shell import LocalShellTool, ShellPolicy
|
||||
|
||||
shell = LocalShellTool(
|
||||
policy=ShellPolicy(allowlist=[r"^ls\b", r"^cat\b", r"^git status$"]),
|
||||
approval_mode="never_require",
|
||||
acknowledge_unsafe=True, # required to bypass approval
|
||||
)
|
||||
```
|
||||
|
||||
### Cross-OS
|
||||
|
||||
- **Windows**: `pwsh -NoProfile -Command -` (falls back to `powershell.exe`).
|
||||
- **Linux / macOS**: `/bin/bash --noprofile --norc` (falls back to `/bin/sh`).
|
||||
- Override via the `shell=` constructor argument or the
|
||||
`AGENT_FRAMEWORK_SHELL` environment variable.
|
||||
|
||||
## `ShellEnvironmentProvider` — context provider
|
||||
|
||||
A model talking to a PowerShell session will sometimes default to bash
|
||||
syntax (`export FOO=bar`, `ls -la`, `> /dev/null`) and vice versa.
|
||||
`ShellEnvironmentProvider` is an `AIContextProvider` that probes the live
|
||||
shell once per session — family, version, OS, working directory, and a
|
||||
configurable list of CLI tools (`git`, `node`, `python`, `docker` by
|
||||
default) — and injects a system-prompt block describing the shell idiom
|
||||
to use and the available CLIs.
|
||||
|
||||
```python
|
||||
from agent_framework_tools.shell import (
|
||||
LocalShellTool,
|
||||
ShellEnvironmentProvider,
|
||||
ShellEnvironmentProviderOptions,
|
||||
)
|
||||
|
||||
shell = LocalShellTool()
|
||||
provider = ShellEnvironmentProvider(
|
||||
shell,
|
||||
ShellEnvironmentProviderOptions(probe_tools=("git", "uv", "node")),
|
||||
)
|
||||
agent = Agent(
|
||||
client=client,
|
||||
tools=[client.get_shell_tool(func=shell.as_function())],
|
||||
context_providers=[provider],
|
||||
)
|
||||
```
|
||||
|
||||
Probe failures from expected error types (timeouts, policy rejections,
|
||||
spawn failures) are recorded as `None` fields in the snapshot rather
|
||||
than raised; a missing CLI never fails the agent. A failed first probe
|
||||
does not poison the cache — the next call retries.
|
||||
|
||||
## `DockerShellTool` — sandboxed tier
|
||||
|
||||
When commands originate from untrusted input (e.g. the model is acting on
|
||||
prompt-injected document content), prefer `DockerShellTool`. With the
|
||||
default isolation flags and a trusted container runtime, the container
|
||||
is the intended security boundary and approval gating becomes optional.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from agent_framework_tools.shell import DockerShellTool
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with DockerShellTool(
|
||||
image="mcr.microsoft.com/azurelinux/base/core:3.0",
|
||||
approval_mode="never_require", # container is the boundary
|
||||
) as shell:
|
||||
result = await shell.run("uname -a && id")
|
||||
print(result.stdout)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
Defaults applied to every container:
|
||||
|
||||
- `--network none` — no host or external network.
|
||||
- `--user 65534:65534` — runs as `nobody:nogroup`.
|
||||
- `--read-only` root filesystem; only mounted host paths are writable.
|
||||
- `--cap-drop ALL` and `--security-opt no-new-privileges`.
|
||||
- `--memory 512m`, `--pids-limit 256`, ephemeral `tmpfs /tmp`.
|
||||
|
||||
To expose a host directory, pass `host_workdir="/path"` (mounted
|
||||
read-only by default; `mount_readonly=False` to allow writes). Swap the
|
||||
container runtime with `docker_binary="podman"`.
|
||||
|
||||
## Sandbox tiers at a glance
|
||||
|
||||
| Use case | Tool | Sandbox |
|
||||
|---|---|---|
|
||||
| Run *code* (untrusted) | `HyperlightCodeActProvider.execute_code` (`agent-framework-hyperlight`) | Hyperlight WASM microVM |
|
||||
| Run *shell* (untrusted) | `DockerShellTool` | OCI container (network-off, non-root, capabilities dropped) |
|
||||
| Run *shell* (trusted dev) | `LocalShellTool` | Approval-in-the-loop |
|
||||
|
||||
## Relationship to `agent-framework-hyperlight`
|
||||
|
||||
`agent-framework-hyperlight` is a **code** sandbox (a single WASM guest
|
||||
loaded into a microVM, called via a hostcall ABI — there is no kernel,
|
||||
userland, or shell binary inside). It is the right tier for executing
|
||||
generated *code*. For sandboxing *shell* commands, the realistic tier is
|
||||
OCI, which `DockerShellTool` provides.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Built-in tools for the Microsoft Agent Framework."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = ["__version__"]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Cross-platform local shell tool for the Microsoft Agent Framework."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ._docker import (
|
||||
DEFAULT_IMAGE as DOCKER_DEFAULT_IMAGE,
|
||||
)
|
||||
from ._docker import (
|
||||
DockerNotAvailableError,
|
||||
DockerShellTool,
|
||||
is_docker_available,
|
||||
)
|
||||
from ._environment import (
|
||||
ShellEnvironmentProvider,
|
||||
ShellEnvironmentProviderOptions,
|
||||
ShellEnvironmentSnapshot,
|
||||
ShellFamily,
|
||||
default_instructions_formatter,
|
||||
)
|
||||
from ._executor_base import ShellExecutor
|
||||
from ._policy import ShellDecision, ShellPolicy, ShellRequest
|
||||
from ._tool import LocalShellTool
|
||||
from ._types import (
|
||||
ShellCommandError,
|
||||
ShellExecutionError,
|
||||
ShellMode,
|
||||
ShellResult,
|
||||
ShellTimeoutError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DOCKER_DEFAULT_IMAGE",
|
||||
"DockerNotAvailableError",
|
||||
"DockerShellTool",
|
||||
"LocalShellTool",
|
||||
"ShellCommandError",
|
||||
"ShellDecision",
|
||||
"ShellEnvironmentProvider",
|
||||
"ShellEnvironmentProviderOptions",
|
||||
"ShellEnvironmentSnapshot",
|
||||
"ShellExecutionError",
|
||||
"ShellExecutor",
|
||||
"ShellFamily",
|
||||
"ShellMode",
|
||||
"ShellPolicy",
|
||||
"ShellRequest",
|
||||
"ShellResult",
|
||||
"ShellTimeoutError",
|
||||
"default_instructions_formatter",
|
||||
"is_docker_available",
|
||||
]
|
||||
@@ -0,0 +1,700 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Sandboxed shell tool backed by a Docker (or compatible) container runtime.
|
||||
|
||||
``DockerShellTool`` exposes the same public surface as
|
||||
:class:`LocalShellTool` but executes commands inside a container. The
|
||||
container is intended to be the security boundary; effective isolation
|
||||
depends on the host runtime configuration, image contents, and the flags
|
||||
passed at launch.
|
||||
|
||||
Default flags applied at launch:
|
||||
|
||||
- ``--network none``: no host or external network access.
|
||||
- ``--user 65534:65534``: runs as ``nobody:nogroup``.
|
||||
- ``--read-only`` root filesystem; only the optional ``host_workdir``
|
||||
mount is writable when ``mount_readonly=False``.
|
||||
- ``--memory``, ``--pids-limit`` set to bounded values.
|
||||
- ``--cap-drop=ALL`` and ``--security-opt=no-new-privileges``.
|
||||
- ``--tmpfs /tmp`` so commands that need scratch space have somewhere to
|
||||
write that doesn't escape the container.
|
||||
|
||||
Persistent mode reuses :class:`ShellSession` by launching
|
||||
``docker exec -i <container> bash`` as the long-lived shell — the
|
||||
sentinel protocol works unchanged because the session is still talking
|
||||
to a bash REPL over pipes.
|
||||
|
||||
**Single-session ownership.** In persistent mode a
|
||||
:class:`DockerShellTool` owns a long-lived container plus the bash REPL
|
||||
inside it. The container's filesystem, environment, working directory,
|
||||
and any artifacts the agent has produced are visible to every subsequent
|
||||
command, and a single stdin/stdout pipe serializes every call. A
|
||||
persistent-mode tool is therefore intended to be owned by exactly one
|
||||
conversation / agent session — i.e. one user. Do not share one instance
|
||||
across users, tenants, or concurrent conversations: their state leaks
|
||||
together inside the container and commands queue behind each other.
|
||||
Create one tool per session and close it (or use ``async with``) when
|
||||
the session ends; closing stops and removes the container. If a shared
|
||||
instance is genuinely required, use ``mode="stateless"`` so each call
|
||||
gets its own throwaway ``docker run --rm``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess # noqa: S404 # nosec B404 - running shell commands is the whole point of this tool
|
||||
import time
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from typing import Literal
|
||||
|
||||
from agent_framework import FunctionTool, tool
|
||||
from agent_framework._tools import SHELL_TOOL_KIND_VALUE
|
||||
|
||||
from ._policy import ShellPolicy, ShellRequest
|
||||
from ._session import ShellSession
|
||||
from ._truncate import truncate_head_tail as _truncate_bytes
|
||||
from ._types import ShellCommandError, ShellMode, ShellResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_IMAGE = "mcr.microsoft.com/azurelinux/base/core:3.0"
|
||||
DEFAULT_CONTAINER_USER = "65534:65534" # nobody:nogroup on most distros
|
||||
DEFAULT_NETWORK = "none"
|
||||
DEFAULT_MEMORY = "512m"
|
||||
DEFAULT_PIDS_LIMIT = 256
|
||||
DEFAULT_WORKDIR = "/workspace"
|
||||
|
||||
# Docker run flags that would silently dismantle the isolation defaults
|
||||
# (--cap-drop ALL, --security-opt no-new-privileges, --network none,
|
||||
# --read-only root, pids/memory caps) if spliced in via ``extra_run_args``.
|
||||
# These are rejected at construction time so the failure surfaces loudly
|
||||
# rather than as a silently-unsandboxed container at runtime.
|
||||
_BLOCKED_EXTRA_RUN_FLAGS: tuple[str, ...] = (
|
||||
"--privileged",
|
||||
"--cap-add",
|
||||
"--security-opt",
|
||||
"--network",
|
||||
"--net",
|
||||
"--volume",
|
||||
"-v",
|
||||
"--mount",
|
||||
"--device",
|
||||
"--device-cgroup-rule",
|
||||
"--pid",
|
||||
"--ipc",
|
||||
"--userns",
|
||||
"--user",
|
||||
"--cgroupns",
|
||||
"--add-host",
|
||||
"--gpus",
|
||||
"--read-only",
|
||||
"--tmpfs",
|
||||
)
|
||||
|
||||
|
||||
def _validate_extra_run_args(args: Sequence[str]) -> None:
|
||||
"""Reject extra ``docker run`` args that would break the isolation contract.
|
||||
|
||||
A caller can otherwise pass ``["--privileged"]``, ``["--network=host"]``,
|
||||
``["-v", "/:/host:rw"]``, etc. and silently undo every isolation flag
|
||||
this tool sets. The blocklist covers the obvious offenders; operators
|
||||
that genuinely need one of these flags should subclass the tool or
|
||||
build their own argv rather than slip past the check.
|
||||
"""
|
||||
bad: list[str] = []
|
||||
for raw in args:
|
||||
if not raw.startswith("-"):
|
||||
continue
|
||||
# Split off any "=value" tail so "--network=host" matches "--network".
|
||||
flag = raw.split("=", 1)[0]
|
||||
if flag in _BLOCKED_EXTRA_RUN_FLAGS:
|
||||
bad.append(raw)
|
||||
if bad:
|
||||
raise ValueError(
|
||||
"extra_run_args contains flags that would dismantle DockerShellTool's "
|
||||
f"isolation defaults: {bad}. Override these via dedicated constructor "
|
||||
"arguments (network, host_workdir, mount_readonly, read_only_root, "
|
||||
"memory, pids_limit, user) or subclass the tool if you really need "
|
||||
"to relax the sandbox."
|
||||
)
|
||||
|
||||
|
||||
class DockerNotAvailableError(RuntimeError):
|
||||
"""Raised when the configured docker binary cannot be reached."""
|
||||
|
||||
|
||||
def is_docker_available(binary: str = "docker") -> bool:
|
||||
"""Return ``True`` if ``binary`` is on PATH and the daemon responds."""
|
||||
if shutil.which(binary) is None:
|
||||
return False
|
||||
try:
|
||||
out = subprocess.run( # noqa: S603 # nosec B603 - argv is built from trusted binary name
|
||||
[binary, "version", "--format", "{{.Server.Version}}"],
|
||||
capture_output=True,
|
||||
timeout=5.0,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return out.returncode == 0 and bool(out.stdout.strip())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure argv builders. Kept side-effect-free so unit tests don't need Docker.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_run_argv(
|
||||
*,
|
||||
binary: str,
|
||||
image: str,
|
||||
container_name: str,
|
||||
user: str,
|
||||
network: str,
|
||||
memory: str,
|
||||
pids_limit: int,
|
||||
workdir: str,
|
||||
host_workdir: str | None,
|
||||
mount_readonly: bool,
|
||||
read_only_root: bool,
|
||||
extra_env: Mapping[str, str] | None,
|
||||
extra_args: Sequence[str] | None,
|
||||
) -> list[str]:
|
||||
"""Build the ``docker run -d`` argv that starts the long-lived container.
|
||||
|
||||
The container runs ``sleep infinity`` so it stays alive while the
|
||||
session uses ``docker exec`` for individual commands.
|
||||
"""
|
||||
argv: list[str] = [
|
||||
binary,
|
||||
"run",
|
||||
"-d",
|
||||
"--rm",
|
||||
"--name",
|
||||
container_name,
|
||||
"--user",
|
||||
user,
|
||||
"--network",
|
||||
network,
|
||||
"--memory",
|
||||
memory,
|
||||
"--pids-limit",
|
||||
str(pids_limit),
|
||||
"--cap-drop",
|
||||
"ALL",
|
||||
"--security-opt",
|
||||
"no-new-privileges",
|
||||
"--tmpfs",
|
||||
"/tmp:rw,nosuid,nodev,size=64m", # noqa: S108, # nosec B108 - tmpfs inside the container, not on the host
|
||||
"--workdir",
|
||||
workdir,
|
||||
]
|
||||
if read_only_root:
|
||||
argv.append("--read-only")
|
||||
if host_workdir is not None:
|
||||
ro = "ro" if mount_readonly else "rw"
|
||||
argv.extend(["-v", f"{host_workdir}:{workdir}:{ro}"])
|
||||
if extra_env:
|
||||
for k, v in extra_env.items():
|
||||
argv.extend(["-e", f"{k}={v}"])
|
||||
if extra_args:
|
||||
argv.extend(extra_args)
|
||||
argv.extend([image, "sleep", "infinity"])
|
||||
return argv
|
||||
|
||||
|
||||
def build_exec_argv(
|
||||
*,
|
||||
binary: str,
|
||||
container_name: str,
|
||||
interactive: bool,
|
||||
shell: str = "bash",
|
||||
) -> list[str]:
|
||||
"""Build the ``docker exec -i <container> <shell>`` argv.
|
||||
|
||||
For persistent mode this is the long-lived shell that
|
||||
:class:`ShellSession` reads/writes via stdin/stdout pipes.
|
||||
"""
|
||||
argv = [binary, "exec", "-i", container_name, shell]
|
||||
if not interactive:
|
||||
# Stateless: ``docker exec`` is run per-command; <shell> -c <cmd> is
|
||||
# appended later by run_stateless.
|
||||
argv.extend(["-c"]) # caller appends the command
|
||||
elif shell == "bash":
|
||||
argv.extend(["--noprofile", "--norc"])
|
||||
return argv
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DockerShellTool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_PERSISTENT_DESCRIPTION = (
|
||||
"Execute a single shell command inside an isolated Docker container "
|
||||
"and return its stdout, stderr, and exit code. Commands run in a "
|
||||
"persistent session so `cd` and environment variables from previous "
|
||||
"calls are preserved within the container."
|
||||
)
|
||||
|
||||
_STATELESS_DESCRIPTION = (
|
||||
"Execute a single shell command inside an isolated Docker container "
|
||||
"and return its stdout, stderr, and exit code. Each command runs in a "
|
||||
"fresh container, so `cd` and environment variables do not persist "
|
||||
"between calls."
|
||||
)
|
||||
|
||||
|
||||
def _default_description(mode: ShellMode) -> str:
|
||||
return _PERSISTENT_DESCRIPTION if mode == "persistent" else _STATELESS_DESCRIPTION
|
||||
|
||||
|
||||
class DockerShellTool:
|
||||
"""Shell tool that runs commands inside a Docker (or compatible) container.
|
||||
|
||||
**Single-session ownership.** In persistent mode this tool owns a
|
||||
long-lived container plus the bash REPL inside it; it is intended to
|
||||
be owned by a single conversation / agent session — i.e. one user.
|
||||
Do not share one instance across users, tenants, or concurrent
|
||||
conversations: state leaks together inside the container and commands
|
||||
queue behind each other. Create one tool per session and close it
|
||||
(or use ``async with``) when the session ends. If a shared instance
|
||||
is genuinely required, use ``mode="stateless"``. See the module
|
||||
docstring for more.
|
||||
|
||||
Args:
|
||||
image: OCI image to run. Defaults to a small Microsoft-maintained
|
||||
base image. Override with anything that includes ``bash`` and
|
||||
(for persistent mode) ``sleep``.
|
||||
container_name: Optional explicit name. When ``None`` a unique
|
||||
name is generated per instance.
|
||||
mode: ``"persistent"`` (default) keeps a single long-lived
|
||||
container with `cd`/`export` carrying across calls.
|
||||
``"stateless"`` runs each command in a fresh ``docker run --rm``.
|
||||
host_workdir: Optional host directory to mount into the container
|
||||
at ``workdir``. Mounted read-only by default; pass
|
||||
``mount_readonly=False`` to allow writes.
|
||||
workdir: Path inside the container. Default ``/workspace``.
|
||||
mount_readonly: When ``True`` (default), mount ``host_workdir`` ro.
|
||||
network: Docker network mode. Default ``"none"`` for no network.
|
||||
memory: Container memory limit (e.g. ``"512m"``, ``"2g"``).
|
||||
pids_limit: Max processes inside the container.
|
||||
user: ``UID:GID`` to run as. Default ``65534:65534`` (nobody).
|
||||
read_only_root: Mount the root filesystem read-only. Default ``True``.
|
||||
extra_run_args: Additional args appended to ``docker run``.
|
||||
|
||||
.. warning::
|
||||
This parameter can dismantle the tool's isolation
|
||||
contract. Flags that would undo the default sandbox
|
||||
(``--privileged``, ``--cap-add``, ``--security-opt``,
|
||||
``--network``/``--net``, ``-v``/``--volume``,
|
||||
``--mount``, ``--device``, ``--pid``, ``--ipc``,
|
||||
``--userns``, ``--user``, ``--read-only``,
|
||||
``--tmpfs``, ``--add-host``, ``--gpus``, ``--cgroupns``,
|
||||
``--device-cgroup-rule``) are rejected at construction
|
||||
time. Override the corresponding dedicated argument
|
||||
(``network``, ``host_workdir``, ``mount_readonly``,
|
||||
``read_only_root``, ``user``, etc.) instead. If you
|
||||
genuinely need to relax the sandbox further, subclass
|
||||
the tool — don't slip past this check.
|
||||
env: Environment variables to set inside the container. These are
|
||||
passed via ``-e`` and apply to every command.
|
||||
policy: Optional :class:`ShellPolicy`. Less critical than for
|
||||
``LocalShellTool`` since the container is the intended
|
||||
isolation layer, but useful as a UX pre-filter (and for audit
|
||||
logging). Defaults to an empty policy; supply patterns
|
||||
explicitly to enable filtering.
|
||||
timeout: Per-command timeout in seconds.
|
||||
max_output_bytes: Combined stdout/stderr byte cap before truncation.
|
||||
approval_mode: Controls the FunctionTool approval gate. Unlike
|
||||
``LocalShellTool``, ``"never_require"`` is permitted without
|
||||
``acknowledge_unsafe`` because the container — when launched
|
||||
with the default isolation flags and a trusted runtime — is
|
||||
the intended boundary rather than approval.
|
||||
on_command: Audit hook fired for every allowed command.
|
||||
docker_binary: Override (e.g. ``"podman"``).
|
||||
shell: Shell binary to invoke inside the container. Defaults to
|
||||
``"bash"``; pass ``"sh"`` for minimal images such as Alpine
|
||||
that don't ship bash. Anything else must be present on
|
||||
``$PATH`` inside the image.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
image: str = DEFAULT_IMAGE,
|
||||
container_name: str | None = None,
|
||||
mode: ShellMode = "persistent",
|
||||
host_workdir: str | os.PathLike[str] | None = None,
|
||||
workdir: str = DEFAULT_WORKDIR,
|
||||
mount_readonly: bool = True,
|
||||
network: str = DEFAULT_NETWORK,
|
||||
memory: str = DEFAULT_MEMORY,
|
||||
pids_limit: int = DEFAULT_PIDS_LIMIT,
|
||||
user: str = DEFAULT_CONTAINER_USER,
|
||||
read_only_root: bool = True,
|
||||
extra_run_args: Sequence[str] | None = None,
|
||||
env: Mapping[str, str] | None = None,
|
||||
policy: ShellPolicy | None = None,
|
||||
timeout: float | None = 30.0,
|
||||
max_output_bytes: int = 64 * 1024,
|
||||
approval_mode: Literal["always_require", "never_require"] = "always_require",
|
||||
on_command: Callable[[str], None] | None = None,
|
||||
docker_binary: str = "docker",
|
||||
shell: str = "bash",
|
||||
) -> None:
|
||||
if mode not in ("persistent", "stateless"):
|
||||
raise ValueError(f"mode must be 'persistent' or 'stateless', got {mode!r}")
|
||||
_validate_extra_run_args(tuple(extra_run_args or ()))
|
||||
self._image = image
|
||||
self._container_name = container_name or f"af-shell-{secrets.token_hex(6)}"
|
||||
self._mode: ShellMode = mode
|
||||
self._host_workdir: str | None = os.fspath(host_workdir) if host_workdir is not None else None
|
||||
self._workdir = workdir
|
||||
self._mount_readonly = mount_readonly
|
||||
self._network = network
|
||||
self._memory = memory
|
||||
self._pids_limit = pids_limit
|
||||
self._user = user
|
||||
self._read_only_root = read_only_root
|
||||
self._extra_run_args = tuple(extra_run_args or ())
|
||||
self._env = dict(env or {})
|
||||
self._policy = policy or ShellPolicy()
|
||||
self._timeout = timeout
|
||||
self._max_output_bytes = max_output_bytes
|
||||
self._approval_mode: Literal["always_require", "never_require"] = approval_mode
|
||||
self._on_command = on_command
|
||||
self._binary = docker_binary
|
||||
self._shell = shell
|
||||
|
||||
self._session: ShellSession | None = None
|
||||
self._container_started = False
|
||||
self._lifecycle_lock: asyncio.Lock | None = None
|
||||
|
||||
def _get_lifecycle_lock(self) -> asyncio.Lock:
|
||||
if self._lifecycle_lock is None:
|
||||
self._lifecycle_lock = asyncio.Lock()
|
||||
return self._lifecycle_lock
|
||||
|
||||
# ------------------------------------------------------------------ lifecycle
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Pull/start the container and (if persistent) the inner shell session."""
|
||||
# Stateless mode never uses the long-lived container — every call goes
|
||||
# through ``docker run --rm`` — so start()/close() are no-ops.
|
||||
if self._mode == "stateless":
|
||||
return
|
||||
async with self._get_lifecycle_lock():
|
||||
if self._container_started:
|
||||
if self._session is not None:
|
||||
await self._session.start()
|
||||
return
|
||||
await self._start_container()
|
||||
self._container_started = True
|
||||
argv = build_exec_argv(
|
||||
binary=self._binary,
|
||||
container_name=self._container_name,
|
||||
interactive=True,
|
||||
shell=self._shell, # nosec B604 - 'shell' is the binary name kwarg, not subprocess shell=True
|
||||
)
|
||||
self._session = ShellSession(
|
||||
argv,
|
||||
workdir=None, # workdir is set on the container itself
|
||||
env=None,
|
||||
max_output_bytes=self._max_output_bytes,
|
||||
)
|
||||
await self._session.start()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Stop the inner shell session and tear down the container."""
|
||||
if self._mode == "stateless":
|
||||
return
|
||||
async with self._get_lifecycle_lock():
|
||||
if self._session is not None:
|
||||
try:
|
||||
await self._session.close()
|
||||
finally:
|
||||
self._session = None
|
||||
if self._container_started:
|
||||
await self._stop_container()
|
||||
self._container_started = False
|
||||
|
||||
async def __aenter__(self) -> DockerShellTool:
|
||||
await self.start()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc: object) -> None:
|
||||
await self.close()
|
||||
|
||||
# ------------------------------------------------------------------ execution
|
||||
|
||||
async def run(self, command: str, *, timeout: float | None = None) -> ShellResult:
|
||||
"""Execute ``command`` inside the container and return its result.
|
||||
|
||||
Args:
|
||||
command: Shell command to execute.
|
||||
timeout: Optional per-call timeout in seconds overriding the
|
||||
tool's configured default. Enforced inside the executor
|
||||
(kills the container / interrupts the bash REPL) so the
|
||||
caller does not need to wrap the call in
|
||||
:func:`asyncio.wait_for`.
|
||||
"""
|
||||
request = ShellRequest(command=command, workdir=self._workdir)
|
||||
decision = self._policy.evaluate(request)
|
||||
if decision.decision == "deny":
|
||||
raise ShellCommandError(f"Command rejected by policy: {decision.reason}")
|
||||
if self._on_command is not None:
|
||||
try:
|
||||
self._on_command(command)
|
||||
except Exception:
|
||||
logger.exception("on_command hook raised")
|
||||
|
||||
effective_timeout = self._timeout if timeout is None else timeout
|
||||
|
||||
if self._mode == "persistent":
|
||||
if self._session is None:
|
||||
await self.start()
|
||||
if self._session is None:
|
||||
raise RuntimeError("DockerShellTool session failed to start")
|
||||
return await self._session.run(command, timeout=effective_timeout)
|
||||
|
||||
return await self._run_stateless(command, timeout=effective_timeout)
|
||||
|
||||
# ------------------------------------------------------------------ stateless
|
||||
|
||||
async def _run_stateless(self, command: str, *, timeout: float | None) -> ShellResult:
|
||||
"""Run a single command in a fresh ``docker run --rm`` container."""
|
||||
per_call_name = f"af-shell-{secrets.token_hex(6)}"
|
||||
argv = [
|
||||
self._binary,
|
||||
"run",
|
||||
"--rm",
|
||||
"-i",
|
||||
"--name",
|
||||
per_call_name,
|
||||
"--user",
|
||||
self._user,
|
||||
"--network",
|
||||
self._network,
|
||||
"--memory",
|
||||
self._memory,
|
||||
"--pids-limit",
|
||||
str(self._pids_limit),
|
||||
"--cap-drop",
|
||||
"ALL",
|
||||
"--security-opt",
|
||||
"no-new-privileges",
|
||||
"--tmpfs",
|
||||
"/tmp:rw,nosuid,nodev,size=64m", # noqa: S108, # nosec B108 - tmpfs inside the container, not on the host
|
||||
"--workdir",
|
||||
self._workdir,
|
||||
]
|
||||
if self._read_only_root:
|
||||
argv.append("--read-only")
|
||||
if self._host_workdir is not None:
|
||||
ro = "ro" if self._mount_readonly else "rw"
|
||||
argv.extend(["-v", f"{self._host_workdir}:{self._workdir}:{ro}"])
|
||||
for k, v in self._env.items():
|
||||
argv.extend(["-e", f"{k}={v}"])
|
||||
argv.extend(self._extra_run_args)
|
||||
argv.extend([self._image, self._shell, "-c", command])
|
||||
|
||||
started = time.monotonic()
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
timed_out = False
|
||||
try:
|
||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
timed_out = True
|
||||
# Kill the container by name. ``--rm`` reaps it on a clean
|
||||
# exit; if ``docker kill`` itself hangs or returns non-zero
|
||||
# (daemon stuck, container with unkillable kernel task) the
|
||||
# ``--rm`` reaper never fires and the container is leaked,
|
||||
# so we explicitly fall back to ``docker rm -f``.
|
||||
killer = await asyncio.create_subprocess_exec(
|
||||
self._binary,
|
||||
"kill",
|
||||
"--signal",
|
||||
"KILL",
|
||||
per_call_name,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
kill_ok = False
|
||||
try:
|
||||
rc = await asyncio.wait_for(killer.wait(), timeout=5.0)
|
||||
kill_ok = rc == 0
|
||||
except asyncio.TimeoutError:
|
||||
killer.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await killer.wait()
|
||||
if not kill_ok:
|
||||
logger.warning(
|
||||
"docker kill of stateless container %s did not succeed; "
|
||||
"attempting docker rm -f to avoid a container leak",
|
||||
per_call_name,
|
||||
)
|
||||
reaper = await asyncio.create_subprocess_exec(
|
||||
self._binary,
|
||||
"rm",
|
||||
"-f",
|
||||
per_call_name,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
_, rerr = await asyncio.wait_for(reaper.communicate(), timeout=5.0)
|
||||
if reaper.returncode != 0:
|
||||
logger.error(
|
||||
"docker rm -f %s failed (rc=%s, err=%s); container may be leaked",
|
||||
per_call_name,
|
||||
reaper.returncode,
|
||||
rerr.decode("utf-8", errors="replace").strip(),
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
reaper.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await reaper.wait()
|
||||
logger.error(
|
||||
"docker rm -f %s timed out; container may be leaked",
|
||||
per_call_name,
|
||||
)
|
||||
try:
|
||||
stdout_bytes, stderr_bytes = await proc.communicate()
|
||||
except Exception:
|
||||
# Pipe/socket failures after kill leave us with no captured
|
||||
# output. Log so operators can correlate empty results with
|
||||
# the timeout teardown (otherwise the model just sees
|
||||
# exit_code=-1 with no stdout/stderr).
|
||||
logger.warning(
|
||||
"failed to drain stdout/stderr after killing stateless container %s",
|
||||
per_call_name,
|
||||
exc_info=True,
|
||||
)
|
||||
stdout_bytes, stderr_bytes = b"", b""
|
||||
|
||||
duration_ms = int((time.monotonic() - started) * 1000)
|
||||
stdout_str, stdout_truncated = _truncate_bytes(stdout_bytes or b"", self._max_output_bytes)
|
||||
stderr_str, stderr_truncated = _truncate_bytes(stderr_bytes or b"", self._max_output_bytes)
|
||||
return ShellResult(
|
||||
stdout=stdout_str,
|
||||
stderr=stderr_str,
|
||||
exit_code=proc.returncode if proc.returncode is not None else -1,
|
||||
duration_ms=duration_ms,
|
||||
truncated=stdout_truncated or stderr_truncated,
|
||||
timed_out=timed_out,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ container ops
|
||||
|
||||
async def _start_container(self) -> None:
|
||||
argv = build_run_argv(
|
||||
binary=self._binary,
|
||||
image=self._image,
|
||||
container_name=self._container_name,
|
||||
user=self._user,
|
||||
network=self._network,
|
||||
memory=self._memory,
|
||||
pids_limit=self._pids_limit,
|
||||
workdir=self._workdir,
|
||||
host_workdir=self._host_workdir,
|
||||
mount_readonly=self._mount_readonly,
|
||||
read_only_root=self._read_only_root,
|
||||
extra_env=self._env,
|
||||
extra_args=self._extra_run_args,
|
||||
)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
out, err = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise DockerNotAvailableError(
|
||||
f"Failed to start container ({proc.returncode}): {err.decode('utf-8', errors='replace').strip()}"
|
||||
)
|
||||
logger.info(
|
||||
"started docker container %s (id=%s)",
|
||||
self._container_name,
|
||||
out.decode("utf-8", errors="replace").strip()[:12],
|
||||
)
|
||||
|
||||
async def _stop_container(self) -> None:
|
||||
# Use docker rm -f for a hard shutdown. With --rm on the run
|
||||
# command, this also reaps the container.
|
||||
async def _attempt(timeout: float) -> tuple[int | None, str]:
|
||||
"""Run a single ``docker rm -f`` attempt. Returns (rc, stderr)."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
self._binary,
|
||||
"rm",
|
||||
"-f",
|
||||
self._container_name,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
_, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
return proc.returncode, err.decode("utf-8", errors="replace").strip()
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await proc.wait()
|
||||
return None, "docker rm -f timed out"
|
||||
|
||||
rc, err = await _attempt(timeout=10.0)
|
||||
if rc == 0:
|
||||
return
|
||||
logger.warning(
|
||||
"docker rm -f %s did not succeed on first attempt (rc=%s, err=%s); retrying",
|
||||
self._container_name,
|
||||
rc,
|
||||
err,
|
||||
)
|
||||
rc, err = await _attempt(timeout=5.0)
|
||||
if rc != 0:
|
||||
# Container may have been leaked. Surface the name so an
|
||||
# operator can clean it up manually.
|
||||
logger.error(
|
||||
"docker rm -f %s failed after retry (rc=%s, err=%s); "
|
||||
"container may be running and require manual cleanup",
|
||||
self._container_name,
|
||||
rc,
|
||||
err,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ AF wiring
|
||||
|
||||
def as_function(
|
||||
self,
|
||||
*,
|
||||
name: str = "run_shell",
|
||||
description: str | None = None,
|
||||
) -> FunctionTool:
|
||||
"""Return a :class:`~agent_framework.FunctionTool` bound to this instance."""
|
||||
|
||||
async def _run_shell(command: str) -> str:
|
||||
try:
|
||||
result = await self.run(command)
|
||||
except ShellCommandError as exc:
|
||||
return str(exc)
|
||||
return result.format_for_model()
|
||||
|
||||
effective_description = description or _default_description(self._mode)
|
||||
_run_shell.__doc__ = effective_description
|
||||
return tool(
|
||||
func=_run_shell,
|
||||
name=name,
|
||||
description=effective_description,
|
||||
approval_mode=self._approval_mode,
|
||||
kind=SHELL_TOOL_KIND_VALUE,
|
||||
)
|
||||
@@ -0,0 +1,281 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Shell environment context provider.
|
||||
|
||||
Probes the underlying shell (OS, family/version, working directory,
|
||||
configured CLI tools) once per provider lifetime and injects an
|
||||
instructions block so the agent emits commands in the correct shell
|
||||
idiom rather than defaulting to bash syntax inside a PowerShell session
|
||||
or vice versa. The probe runs through any :class:`ShellExecutor`, so the
|
||||
same provider works with both :class:`LocalShellTool` and
|
||||
:class:`DockerShellTool`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from agent_framework import AgentSession, ContextProvider, SessionContext, SupportsAgentRun
|
||||
|
||||
from ._executor_base import ShellExecutor
|
||||
from ._types import ShellCommandError, ShellExecutionError, ShellResult, ShellTimeoutError
|
||||
|
||||
|
||||
class ShellFamily(str, Enum):
|
||||
"""Shell families recognised by the provider."""
|
||||
|
||||
POSIX = "posix"
|
||||
POWERSHELL = "powershell"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ShellEnvironmentSnapshot:
|
||||
"""Point-in-time snapshot of the shell environment.
|
||||
|
||||
Attributes:
|
||||
family: Detected (or configured) shell family.
|
||||
os_description: A short OS description from :mod:`platform`.
|
||||
shell_version: Reported shell version, or ``None`` when probing
|
||||
failed or the shell did not report one.
|
||||
working_directory: CWD reported by the shell, or empty string
|
||||
when probing failed.
|
||||
tool_versions: Map of probed CLI tool name to reported version.
|
||||
``None`` values indicate the tool was not installed or did
|
||||
not respond to ``--version`` within the probe timeout.
|
||||
"""
|
||||
|
||||
family: ShellFamily
|
||||
os_description: str
|
||||
shell_version: str | None
|
||||
working_directory: str
|
||||
tool_versions: Mapping[str, str | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ShellEnvironmentProviderOptions:
|
||||
"""Configuration for :class:`ShellEnvironmentProvider`.
|
||||
|
||||
Attributes:
|
||||
probe_tools: CLI tools whose ``--version`` output is probed.
|
||||
override_family: Optional override for the auto-detected family.
|
||||
When ``None``, the family is inferred from :data:`sys.platform`
|
||||
(Windows → PowerShell, otherwise POSIX). Set this when
|
||||
running against a non-default shell (e.g. bash on Windows
|
||||
via WSL, or pwsh on Linux).
|
||||
probe_timeout: Per-probe execution timeout in seconds. Probes
|
||||
that exceed this are recorded as missing rather than raised
|
||||
to the agent.
|
||||
instructions_formatter: Optional callable that renders the
|
||||
snapshot as the instructions block. When ``None``, the
|
||||
built-in :func:`default_instructions_formatter` is used.
|
||||
"""
|
||||
|
||||
probe_tools: Sequence[str] = field(
|
||||
default_factory=lambda: ("git", "node", "python", "docker"),
|
||||
)
|
||||
override_family: ShellFamily | None = None
|
||||
probe_timeout: float = 5.0
|
||||
instructions_formatter: Callable[[ShellEnvironmentSnapshot], str] | None = None
|
||||
|
||||
|
||||
_TOOL_NAME_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
def _detect_family() -> ShellFamily:
|
||||
return ShellFamily.POWERSHELL if sys.platform == "win32" else ShellFamily.POSIX
|
||||
|
||||
|
||||
def _first_non_empty_line(text: str) -> str | None:
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped:
|
||||
return stripped
|
||||
return None
|
||||
|
||||
|
||||
class ShellEnvironmentProvider(ContextProvider):
|
||||
""":class:`ContextProvider` that injects a shell-environment block.
|
||||
|
||||
The provider runs a small set of probe commands against the supplied
|
||||
:class:`ShellExecutor` once, caches the resulting
|
||||
:class:`ShellEnvironmentSnapshot`, and on every ``before_run`` adds a
|
||||
formatted instructions block to the session context. It does not
|
||||
register any tools.
|
||||
|
||||
Probe failures from a narrow set of expected error types are recorded
|
||||
as ``None`` fields in the snapshot (per-probe timeout, policy
|
||||
rejection, executor spawn failure). Other exceptions propagate so
|
||||
bugs are not silently swallowed.
|
||||
|
||||
A missing CLI never fails the agent: the model simply sees fewer
|
||||
hints in its system prompt.
|
||||
"""
|
||||
|
||||
DEFAULT_SOURCE_ID: ClassVar[str] = "shell_environment"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
executor: ShellExecutor,
|
||||
options: ShellEnvironmentProviderOptions | None = None,
|
||||
*,
|
||||
source_id: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(source_id or self.DEFAULT_SOURCE_ID)
|
||||
self._executor = executor
|
||||
self._options = options or ShellEnvironmentProviderOptions()
|
||||
self._snapshot: ShellEnvironmentSnapshot | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def current_snapshot(self) -> ShellEnvironmentSnapshot | None:
|
||||
"""The most recent snapshot, or ``None`` before the first probe."""
|
||||
return self._snapshot
|
||||
|
||||
async def refresh(self) -> ShellEnvironmentSnapshot:
|
||||
"""Force a re-probe and replace the cached snapshot.
|
||||
|
||||
Useful when the agent has changed something the snapshot depends
|
||||
on, e.g. installed a new CLI mid-session.
|
||||
"""
|
||||
async with self._lock:
|
||||
snapshot = await self._probe()
|
||||
self._snapshot = snapshot
|
||||
return snapshot
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: SupportsAgentRun,
|
||||
session: AgentSession,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
snapshot = await self._get_or_probe()
|
||||
formatter = self._options.instructions_formatter or default_instructions_formatter
|
||||
context.extend_instructions(self.source_id, formatter(snapshot))
|
||||
|
||||
async def _get_or_probe(self) -> ShellEnvironmentSnapshot:
|
||||
# Double-checked: return any already-cached snapshot without
|
||||
# acquiring the lock; otherwise serialize the first probe so
|
||||
# concurrent first-callers wait for a single result. A failed
|
||||
# probe leaves _snapshot as None so the next call retries.
|
||||
if self._snapshot is not None:
|
||||
return self._snapshot
|
||||
async with self._lock:
|
||||
if self._snapshot is None:
|
||||
self._snapshot = await self._probe()
|
||||
return self._snapshot
|
||||
|
||||
async def _probe(self) -> ShellEnvironmentSnapshot:
|
||||
family = self._options.override_family or _detect_family()
|
||||
await self._executor.start()
|
||||
|
||||
shell_version, working_dir = await self._probe_shell_and_cwd(family)
|
||||
|
||||
tool_versions: dict[str, str | None] = {}
|
||||
for tool in self._options.probe_tools:
|
||||
# Skip case-insensitive duplicates so a caller passing
|
||||
# ("git", "GIT") does not probe twice.
|
||||
if tool.lower() in {existing.lower() for existing in tool_versions}:
|
||||
continue
|
||||
tool_versions[tool] = await self._probe_tool_version(tool)
|
||||
|
||||
return ShellEnvironmentSnapshot(
|
||||
family=family,
|
||||
os_description=platform.platform(),
|
||||
shell_version=shell_version,
|
||||
working_directory=working_dir,
|
||||
tool_versions=tool_versions,
|
||||
)
|
||||
|
||||
async def _probe_shell_and_cwd(self, family: ShellFamily) -> tuple[str | None, str]:
|
||||
if family is ShellFamily.POWERSHELL:
|
||||
command = (
|
||||
'Write-Output ("VERSION=" + $PSVersionTable.PSVersion.ToString()); '
|
||||
'Write-Output ("CWD=" + (Get-Location).Path)'
|
||||
)
|
||||
else:
|
||||
command = 'echo "VERSION=${BASH_VERSION:-${ZSH_VERSION:-unknown}}"; echo "CWD=$(pwd)"'
|
||||
|
||||
result = await self._run_probe(command)
|
||||
if result is None:
|
||||
return None, ""
|
||||
|
||||
version: str | None = None
|
||||
cwd = ""
|
||||
for raw in result.stdout.splitlines():
|
||||
line = raw.strip()
|
||||
if line.startswith("VERSION="):
|
||||
value = line[len("VERSION=") :].strip()
|
||||
version = None if not value or value == "unknown" else value
|
||||
elif line.startswith("CWD="):
|
||||
cwd = line[len("CWD=") :].strip()
|
||||
return version, cwd
|
||||
|
||||
async def _probe_tool_version(self, tool: str) -> str | None:
|
||||
# Reject anything that is not a plain identifier — the tool name
|
||||
# is interpolated into a shell command, so quotes, $, ;, |, &,
|
||||
# whitespace, etc. would allow command injection if the tool list
|
||||
# were sourced from untrusted input.
|
||||
if not tool or not _TOOL_NAME_PATTERN.match(tool):
|
||||
return None
|
||||
|
||||
result = await self._run_probe(f"{tool} --version")
|
||||
if result is None or result.exit_code != 0:
|
||||
return None
|
||||
|
||||
# Some CLIs (older java, gcc) emit --version on stderr.
|
||||
line = _first_non_empty_line(result.stdout) or _first_non_empty_line(result.stderr)
|
||||
return line if line else None
|
||||
|
||||
async def _run_probe(self, command: str) -> ShellResult | None:
|
||||
try:
|
||||
return await self._executor.run(command, timeout=self._options.probe_timeout)
|
||||
except asyncio.TimeoutError:
|
||||
return None
|
||||
except (ShellCommandError, ShellExecutionError, ShellTimeoutError):
|
||||
return None
|
||||
|
||||
|
||||
def default_instructions_formatter(snapshot: ShellEnvironmentSnapshot) -> str:
|
||||
"""Render ``snapshot`` as the default instructions block.
|
||||
|
||||
Public so callers that want to wrap or extend the default can call
|
||||
it from a custom ``instructions_formatter``.
|
||||
"""
|
||||
lines: list[str] = ["## Shell environment"]
|
||||
version_suffix = f" {snapshot.shell_version}" if snapshot.shell_version else ""
|
||||
|
||||
if snapshot.family is ShellFamily.POWERSHELL:
|
||||
lines.append(f"You are operating a PowerShell{version_suffix} session on {snapshot.os_description}.")
|
||||
lines.append("Use PowerShell idioms, NOT bash:")
|
||||
lines.append("- Set environment variables with `$env:NAME = 'value'` (NOT `NAME=value`).")
|
||||
lines.append("- Change directory with `Set-Location` or `cd`. Paths use `\\` separators.")
|
||||
lines.append("- Reference environment variables as `$env:NAME` (NOT `$NAME`).")
|
||||
lines.append("- The system temp directory is `[System.IO.Path]::GetTempPath()` (NOT `/tmp`).")
|
||||
lines.append("- Pipe to `Out-Null` to suppress output (NOT `> /dev/null`).")
|
||||
else:
|
||||
lines.append(f"You are operating a POSIX shell{version_suffix} session on {snapshot.os_description}.")
|
||||
lines.append("Use POSIX shell idioms (bash/sh).")
|
||||
lines.append("- Set environment variables for the next command with `export NAME=value`.")
|
||||
lines.append("- Reference environment variables as `$NAME` or `${NAME}`.")
|
||||
lines.append("- Paths use `/` separators.")
|
||||
|
||||
if snapshot.working_directory:
|
||||
lines.append(f"Working directory: {snapshot.working_directory}")
|
||||
|
||||
installed = [f"{name} ({version})" for name, version in snapshot.tool_versions.items() if version]
|
||||
missing = [name for name, version in snapshot.tool_versions.items() if version is None]
|
||||
if installed:
|
||||
lines.append("Available CLIs: " + ", ".join(installed))
|
||||
if missing:
|
||||
lines.append("Not installed: " + ", ".join(missing))
|
||||
|
||||
return "\n".join(lines)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user