.NET: Add subagents provider and sample (#5518)

* Add subagents provider and sample

* Addressing PR comments.
This commit is contained in:
westey
2026-04-28 11:22:55 +01:00
committed by GitHub
Unverified
parent f747d8a6d4
commit 899394a58c
15 changed files with 1842 additions and 18 deletions
+1
View File
@@ -121,6 +121,7 @@
<File Path="samples/02-agents/Harness/README.md" />
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
@@ -28,7 +28,20 @@ public static class HarnessConsole
System.Console.WriteLine($"=== {title} ===");
System.Console.WriteLine(userPrompt);
System.Console.WriteLine("Commands: /todos (show todo list), /mode [plan|execute] (show or switch mode), exit (quit)");
var commands = new List<string>();
if (todoProvider is not null)
{
commands.Add("/todos (show todo list)");
}
if (modeProvider is not null)
{
commands.Add("/mode [plan|execute] (show or switch mode)");
}
commands.Add("exit (quit)");
System.Console.WriteLine($"Commands: {string.Join(", ", commands)}");
System.Console.WriteLine();
AgentSession session = await agent.CreateSessionAsync();
@@ -76,9 +89,16 @@ public static class HarnessConsole
private static async Task<List<ToolApprovalRequestContent>> StreamAndCollectApprovalsAsync(IAsyncEnumerable<AgentResponseUpdate> updates, AgentModeProvider? modeProvider, AgentSession session, int? maxContextWindowTokens, int? maxOutputTokens)
{
var approvalRequests = new List<ToolApprovalRequestContent>();
string mode = modeProvider?.GetMode(session) ?? "unknown";
System.Console.ForegroundColor = GetModeColor(mode);
System.Console.Write($"\n[{mode}] Agent: ");
string? mode = modeProvider is not null ? modeProvider.GetMode(session) : null;
if (mode is not null)
{
System.Console.ForegroundColor = GetModeColor(mode);
System.Console.Write($"\n[{mode}] Agent: ");
}
else
{
System.Console.Write("\nAgent: ");
}
var spinner = new Spinner();
spinner.Start();
@@ -181,11 +201,14 @@ public static class HarnessConsole
hasReceivedAnyText = true;
}
string currentMode = modeProvider?.GetMode(session) ?? "unknown";
if (currentMode != mode)
if (modeProvider is not null)
{
mode = currentMode;
System.Console.ForegroundColor = GetModeColor(mode);
string currentMode = modeProvider.GetMode(session);
if (currentMode != mode)
{
mode = currentMode;
System.Console.ForegroundColor = GetModeColor(mode);
}
}
System.Console.Write(update.Text);
@@ -299,9 +322,14 @@ public static class HarnessConsole
private static void WritePrompt(AgentModeProvider? modeProvider, AgentSession session)
{
string mode = modeProvider?.GetMode(session) ?? "unknown";
System.Console.ForegroundColor = GetModeColor(mode);
System.Console.Write($"[{mode}] You: ");
if (modeProvider is not null)
{
string mode = modeProvider.GetMode(session);
System.Console.ForegroundColor = GetModeColor(mode);
System.Console.Write($"[{mode}] ");
}
System.Console.Write("You: ");
System.Console.ResetColor();
}
@@ -371,10 +399,11 @@ public static class HarnessConsole
}
}
private static ConsoleColor GetModeColor(string mode) => mode switch
private static ConsoleColor GetModeColor(string? mode) => mode switch
{
"plan" => ConsoleColor.Cyan,
"execute" => ConsoleColor.Green,
null => ConsoleColor.Gray,
_ => ConsoleColor.Gray,
};
}
@@ -34,12 +34,12 @@ public static class ToolCallFormatter
"AgentMode_Get" => null,
// Sub-agent tools
"StartSubTask" => FormatStartSubTask(call),
"WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
"GetSubTaskResults" => FormatSingleId(call, "taskId"),
"GetAllTasks" => null,
"ContinueTask" => FormatContinueTask(call),
"ClearCompletedTask" => FormatSingleId(call, "taskId"),
"SubAgents_StartTask" => FormatStartSubTask(call),
"SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
"SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
"SubAgents_GetAllTasks" => null,
"SubAgents_ContinueTask" => FormatContinueTask(call),
"SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
// File memory tools
"FileMemory_SaveFile" => FormatSaveFile(call),
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,106 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use the SubAgentsProvider to delegate work to sub-agents.
// A parent agent is given a list of stock tickers and instructed to find the closing price
// for each ticker on December 31, 2025. It delegates the web searches to a sub-agent
// equipped with Foundry's hosted web search tool.
//
// Special commands:
// exit — End the session.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
using System.ClientModel.Primitives;
using Azure.Identity;
using Harness.Shared.Console;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
// --- Sub-agent: Web Search Agent ---
// This agent can search the web and is used by the parent agent to look up stock prices.
AIAgent webSearchAgent =
new OpenAIClient(
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
new OpenAIClientOptions()
{
Endpoint = new Uri(endpoint),
RetryPolicy = new ClientRetryPolicy(3)
})
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName)
.AsAIAgent(
new ChatClientAgentOptions
{
Name = "WebSearchAgent",
Description = "An agent that can search the web to find information.",
ChatOptions = new ChatOptions
{
Instructions = "You are a web search assistant. When asked to find information, use the web search tool to look it up and return a concise, factual answer.",
Tools =
[
ResponseTool.CreateWebSearchTool().AsAITool(),
],
},
});
// --- Parent agent: Stock Price Researcher ---
// This agent orchestrates the sub-agent to look up stock prices in parallel.
var parentInstructions =
"""
You are a stock price research assistant. You have access to a web search sub-agent that can look up information on the web.
When given a list of stock tickers, your job is to find the closing price for each ticker on December 31, 2025.
## Workflow
1. For each ticker, start a sub-task on the WebSearchAgent asking it to find the closing price on December 31, 2025.
- Start all sub-tasks before waiting for any of them to complete, so they run concurrently.
2. Wait for all sub-tasks to complete.
3. Retrieve the results from each sub-task.
4. Present a summary table with the ticker symbol and closing price for each stock.
5. Clear all completed tasks to free memory.
## Important
- Always delegate web searches to the WebSearchAgent sub-agent. Do not try to answer from memory.
- If a sub-task fails or returns unclear results, continue the task with a more specific query.
- Present results in a clean markdown table format.
""";
AIAgent parentAgent =
new OpenAIClient(
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
new OpenAIClientOptions()
{
Endpoint = new Uri(endpoint),
RetryPolicy = new ClientRetryPolicy(3)
})
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName)
.AsAIAgent(
new ChatClientAgentOptions
{
Name = "StockPriceResearcher",
Description = "An agent that researches stock prices using sub-agents.",
AIContextProviders =
[
new SubAgentsProvider([webSearchAgent]),
],
ChatOptions = new ChatOptions
{
Instructions = parentInstructions,
MaxOutputTokens = 16_000,
},
});
// Run the interactive console session.
await HarnessConsole.RunAgentAsync(
parentAgent,
title: "Stock Price Researcher (SubAgents Demo)",
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):");
@@ -0,0 +1,53 @@
# Harness Step 02 — SubAgents (Stock Price Research)
This sample demonstrates how to use the **SubAgentsProvider** to delegate work from a parent agent to sub-agents.
## What It Does
A parent agent receives a list of stock tickers and uses a web-search sub-agent to find the closing price for each ticker on December 31, 2025. The sub-tasks run concurrently, and results are presented in a summary table.
### Architecture
```
┌─────────────────────────────────┐
│ StockPriceResearcher │
│ (Parent Agent) │
│ │
│ SubAgentsProvider │
│ ├─ SubAgents_StartTask │
│ ├─ SubAgents_WaitFor... │
│ ├─ SubAgents_GetTaskResults │
│ └─ ... │
└────────────┬────────────────────┘
│ delegates to
┌─────────────────────────────────┐
│ WebSearchAgent │
│ (Sub-Agent) │
│ │
│ Tools: │
│ └─ web_search (Foundry) │
└─────────────────────────────────┘
```
## Prerequisites
- An Azure AI Foundry endpoint with an OpenAI model deployment
- Set the following environment variables:
- `AZURE_FOUNDRY_OPENAI_ENDPOINT` — Your Foundry OpenAI endpoint URL
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` — Model deployment name (defaults to `gpt-5.4`)
## Running the Sample
```bash
cd dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents
dotnet run
```
When prompted, enter a list of stock tickers such as:
```
BAC, MSFT, BA
```
The parent agent will delegate each ticker lookup to the web search sub-agent concurrently and present the results in a table.
@@ -7,3 +7,4 @@ Samples demonstrating the [Harness AIContextProviders](../../../src/Microsoft.Ag
| Sample | Description |
| --- | --- |
| [Harness_Step01_Research](./Harness_Step01_Research/README.md) | Using a ChatClientAgent with TodoProvider and AgentModeProvider for research, showcasing planning mode and todo management |
| [Harness_Step02_Research_WithSubAgents](./Harness_Step02_Research_WithSubAgents/README.md) | Using SubAgentsProvider to delegate stock price lookups to a web-search sub-agent concurrently |
@@ -95,6 +95,13 @@ internal static partial class AgentJsonUtilities
[JsonSerializable(typeof(FileListEntry))]
[JsonSerializable(typeof(List<FileListEntry>), TypeInfoPropertyName = "FileListEntryList")]
// SubAgentsProvider types
[JsonSerializable(typeof(SubAgentState))]
[JsonSerializable(typeof(SubAgentRuntimeState))]
[JsonSerializable(typeof(SubTaskInfo))]
[JsonSerializable(typeof(SubTaskStatus))]
[JsonSerializable(typeof(List<SubTaskInfo>), TypeInfoPropertyName = "SubTaskInfoList")]
[ExcludeFromCodeCoverage]
internal sealed partial class JsonContext : JsonSerializerContext;
}
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI;
/// <summary>
/// Holds non-serializable runtime references for in-flight sub-tasks within a single parent session.
/// </summary>
/// <remarks>
/// Properties are marked with <see cref="JsonIgnoreAttribute"/> because <see cref="Task{TResult}"/>
/// and <see cref="AgentSession"/> are not JSON-serializable. After deserialization (e.g., after a restart),
/// a fresh empty instance is created and any previously-running tasks are marked as
/// <see cref="SubTaskStatus.Lost"/> by <see cref="SubAgentsProvider"/>.
/// </remarks>
internal sealed class SubAgentRuntimeState
{
/// <summary>
/// Gets the mapping of task IDs to their in-flight <see cref="Task{AgentResponse}"/> instances.
/// </summary>
[JsonIgnore]
public Dictionary<int, Task<AgentResponse>> InFlightTasks { get; } = [];
/// <summary>
/// Gets the mapping of task IDs to their sub-agent <see cref="AgentSession"/> instances,
/// needed for <c>ContinueTask</c>.
/// </summary>
[JsonIgnore]
public Dictionary<int, AgentSession> SubTaskSessions { get; } = [];
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the serializable state of sub-tasks managed by the <see cref="SubAgentsProvider"/>,
/// stored in the session's <see cref="AgentSessionStateBag"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class SubAgentState
{
/// <summary>
/// Gets or sets the next ID to assign to a new sub-task.
/// </summary>
[JsonPropertyName("nextTaskId")]
public int NextTaskId { get; set; } = 1;
/// <summary>
/// Gets the list of sub-task metadata entries.
/// </summary>
[JsonPropertyName("tasks")]
public List<SubTaskInfo> Tasks { get; set; } = [];
}
@@ -0,0 +1,456 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// An <see cref="AIContextProvider"/> that enables an agent to delegate work to sub-agents asynchronously.
/// </summary>
/// <remarks>
/// <para>
/// The <see cref="SubAgentsProvider"/> allows a parent agent to start sub-tasks on child agents,
/// wait for their completion, and retrieve results. Each sub-task runs in its own session and
/// executes concurrently.
/// </para>
/// <para>
/// This provider exposes the following tools to the agent:
/// <list type="bullet">
/// <item><description><c>SubAgents_StartTask</c> — Start a sub-task on a named agent with text input. Returns the task ID.</description></item>
/// <item><description><c>SubAgents_WaitForFirstCompletion</c> — Block until the first of the specified tasks completes. Returns the completed task's ID.</description></item>
/// <item><description><c>SubAgents_GetTaskResults</c> — Retrieve the text output of a completed sub-task.</description></item>
/// <item><description><c>SubAgents_GetAllTasks</c> — List all sub-tasks with their IDs, statuses, descriptions, and agent names.</description></item>
/// <item><description><c>SubAgents_ContinueTask</c> — Send follow-up input to a completed sub-task's session to resume work.</description></item>
/// <item><description><c>SubAgents_ClearCompletedTask</c> — Remove a completed sub-task and release its session to free memory.</description></item>
/// </list>
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class SubAgentsProvider : AIContextProvider
{
private const string DefaultInstructions =
"""
You have access to sub-agents that can perform work on your behalf.
Use the `SubAgents_*` list of tools to start tasks on sub agents and check their results.
Creating a sub task does not block, and sub-tasks run concurrently.
Important: Always wait for outstanding tasks to finish before you finish processing.
Important: After retrieving results from a completed task, clear it with SubAgents_ClearCompletedTask to free memory, unless you plan to continue it with SubAgents_ContinueTask.
Use SubAgents_StartTask to delegate work to a sub-agent. This will send the task to the sub agent and return immediately. Sub-tasks run concurrently.
Use SubAgents_WaitForFirstCompletion to block until one of the specified tasks finishes.
Use SubAgents_GetTaskResults to retrieve the output of a completed task.
Use SubAgents_GetAllTasks to see the status of all sub-tasks.
Use SubAgents_ContinueTask to send follow-up input to a completed sub-task (e.g., provide clarification or additional instructions).
Use SubAgents_ClearCompletedTask to remove a completed task and free its memory after you no longer need its results or session.
""";
private readonly Dictionary<string, AIAgent> _agents;
private readonly ProviderSessionState<SubAgentState> _sessionState;
private readonly ProviderSessionState<SubAgentRuntimeState> _runtimeSessionState;
private readonly string _instructions;
private IReadOnlyList<string>? _stateKeys;
/// <summary>
/// Initializes a new instance of the <see cref="SubAgentsProvider"/> class.
/// </summary>
/// <param name="agents">The collection of sub-agents available for delegation.</param>
/// <param name="options">Optional settings controlling the provider behavior.</param>
/// <exception cref="ArgumentNullException"><paramref name="agents"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">An agent has a null or empty name, or agent names are not unique.</exception>
public SubAgentsProvider(IEnumerable<AIAgent> agents, SubAgentsProviderOptions? options = null)
{
_ = Throw.IfNull(agents);
this._agents = ValidateAndBuildAgentDictionary(agents);
string baseInstructions = options?.Instructions ?? DefaultInstructions;
string agentListText = options?.AgentListBuilder is not null
? options.AgentListBuilder(this._agents)
: BuildDefaultAgentListText(this._agents);
this._instructions = baseInstructions + "\n" + agentListText;
this._sessionState = new ProviderSessionState<SubAgentState>(
_ => new SubAgentState(),
this.GetType().Name,
AgentJsonUtilities.DefaultOptions);
this._runtimeSessionState = new ProviderSessionState<SubAgentRuntimeState>(
_ => new SubAgentRuntimeState(),
this.GetType().Name + "_Runtime",
AgentJsonUtilities.DefaultOptions);
}
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey, this._runtimeSessionState.StateKey];
/// <inheritdoc />
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
SubAgentState state = this._sessionState.GetOrInitializeState(context.Session);
SubAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(context.Session);
return new ValueTask<AIContext>(new AIContext
{
Instructions = this._instructions,
Tools = this.CreateTools(state, runtimeState, context.Session),
});
}
/// <summary>
/// Validates the agent collection and builds a case-insensitive name dictionary.
/// </summary>
private static Dictionary<string, AIAgent> ValidateAndBuildAgentDictionary(IEnumerable<AIAgent> agents)
{
var dict = new Dictionary<string, AIAgent>(StringComparer.OrdinalIgnoreCase);
foreach (AIAgent agent in agents)
{
if (string.IsNullOrWhiteSpace(agent.Name))
{
throw new ArgumentException("All sub-agents must have a non-empty Name.", nameof(agents));
}
if (dict.ContainsKey(agent.Name))
{
throw new ArgumentException($"Duplicate sub-agent name: '{agent.Name}'. Agent names must be unique (case-insensitive).", nameof(agents));
}
dict[agent.Name] = agent;
}
if (dict.Count == 0)
{
throw new ArgumentException("At least one sub-agent must be provided.", nameof(agents));
}
return dict;
}
/// <summary>
/// Builds the default text listing available sub-agents and their descriptions.
/// </summary>
private static string BuildDefaultAgentListText(IReadOnlyDictionary<string, AIAgent> agents)
{
var sb = new StringBuilder();
sb.AppendLine("Available sub-agents:");
foreach (var kvp in agents)
{
sb.Append("- ").Append(kvp.Key);
if (!string.IsNullOrWhiteSpace(kvp.Value.Description))
{
sb.Append(": ").Append(kvp.Value.Description);
}
sb.AppendLine();
}
return sb.ToString();
}
/// <summary>
/// Refreshes the status of in-flight tasks in the given state for the specified session.
/// </summary>
private void TryRefreshTaskState(SubAgentState state, SubAgentRuntimeState runtimeState, AgentSession? session)
{
bool changed = false;
foreach (SubTaskInfo task in state.Tasks)
{
if (task.Status != SubTaskStatus.Running)
{
continue;
}
if (!runtimeState.InFlightTasks.TryGetValue(task.Id, out Task<AgentResponse>? inFlight))
{
// In-flight reference lost (e.g., after restart/deserialization).
task.Status = SubTaskStatus.Lost;
changed = true;
continue;
}
if (inFlight.IsCompleted)
{
FinalizeTask(task, inFlight, runtimeState);
changed = true;
}
}
if (changed)
{
this._sessionState.SaveState(session, state);
}
}
/// <summary>
/// Finalizes a task by extracting results from the completed Task and updating the SubTaskInfo.
/// </summary>
private static void FinalizeTask(SubTaskInfo taskInfo, Task<AgentResponse> completedTask, SubAgentRuntimeState runtimeState)
{
if (completedTask.Status == TaskStatus.RanToCompletion)
{
taskInfo.Status = SubTaskStatus.Completed;
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits — task is already completed
taskInfo.ResultText = completedTask.Result.Text;
#pragma warning restore VSTHRD002
}
else if (completedTask.IsFaulted)
{
taskInfo.Status = SubTaskStatus.Failed;
taskInfo.ErrorText = completedTask.Exception?.InnerException?.Message ?? completedTask.Exception?.Message ?? "Unknown error";
}
else if (completedTask.IsCanceled)
{
taskInfo.Status = SubTaskStatus.Failed;
taskInfo.ErrorText = "Task was canceled.";
}
runtimeState.InFlightTasks.Remove(taskInfo.Id);
}
private AITool[] CreateTools(SubAgentState state, SubAgentRuntimeState runtimeState, AgentSession? session)
{
var serializerOptions = AgentJsonUtilities.DefaultOptions;
return
[
AIFunctionFactory.Create(
async (
[Description("The name of the sub agent to delegate the task to.")] string agentName,
[Description("The request to pass to the sub agent.")] string input,
[Description("A description of the task used to identify the task later.")] string description) =>
{
if (!this._agents.TryGetValue(agentName, out AIAgent? agent))
{
return $"Error: No sub-agent found with name '{agentName}'. Available agents: {string.Join(", ", this._agents.Keys)}";
}
int taskId = state.NextTaskId++;
var taskInfo = new SubTaskInfo
{
Id = taskId,
AgentName = agentName,
Description = description,
Status = SubTaskStatus.Running,
};
state.Tasks.Add(taskInfo);
// Create a dedicated session for this sub-task so it can be continued later.
AgentSession subSession = await agent.CreateSessionAsync().ConfigureAwait(false);
// Wrap in Task.Run to fork the ExecutionContext. AIAgent.RunAsync is a non-async
// method that synchronously sets the static AsyncLocal CurrentRunContext. Without
// this isolation, the sub-agent's RunAsync would overwrite the outer (calling)
// agent's CurrentRunContext, corrupting all subsequent tool invocations in the
// same FICC batch.
runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(input, subSession));
runtimeState.SubTaskSessions[taskId] = subSession;
this._sessionState.SaveState(session, state);
return $"Sub-task {taskId} started on agent '{agentName}'.";
},
new AIFunctionFactoryOptions
{
Name = "SubAgents_StartTask",
Description = "Start a sub-task on a named sub-agent. Returns an ID for the new task.",
SerializerOptions = serializerOptions,
}),
AIFunctionFactory.Create(
async (List<int> taskIds) =>
{
if (taskIds.Count == 0)
{
return "Error: No task IDs provided.";
}
// Collect in-flight tasks matching the requested IDs (including already-completed ones,
// since Task.WhenAny returns immediately for completed tasks).
var waitableTasks = new List<(int Id, Task<AgentResponse> Task)>();
foreach (int id in taskIds)
{
if (runtimeState.InFlightTasks.TryGetValue(id, out Task<AgentResponse>? inFlight))
{
waitableTasks.Add((id, inFlight));
}
}
if (waitableTasks.Count == 0)
{
// Refresh state to catch any that completed.
this.TryRefreshTaskState(state, runtimeState, session);
this._sessionState.SaveState(session, state);
// Check if any of the requested IDs are already complete.
SubTaskInfo? alreadyComplete = state.Tasks.FirstOrDefault(t => taskIds.Contains(t.Id) && t.Status != SubTaskStatus.Running);
if (alreadyComplete is not null)
{
return $"Task {alreadyComplete.Id} is not running; current status: {alreadyComplete.Status}.";
}
return "Error: None of the specified task IDs correspond to running tasks.";
}
// Wait for the first one to complete.
Task completedTask = await Task.WhenAny(waitableTasks.Select(t => t.Task)).ConfigureAwait(false);
// Find which ID completed.
var completedEntry = waitableTasks.First(t => t.Task == completedTask);
// Finalize the completed task.
SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == completedEntry.Id);
if (taskInfo is not null)
{
FinalizeTask(taskInfo, completedEntry.Task, runtimeState);
this._sessionState.SaveState(session, state);
}
return $"Task {completedEntry.Id} finished with status: {taskInfo?.Status.ToString() ?? "Unknown"}.";
},
new AIFunctionFactoryOptions
{
Name = "SubAgents_WaitForFirstCompletion",
Description = "Block until the first of the specified sub-tasks completes. Provide one or more task IDs. Returns the ID of the task that completed first.",
SerializerOptions = serializerOptions,
}),
AIFunctionFactory.Create(
(int taskId) =>
{
this.TryRefreshTaskState(state, runtimeState, session);
SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
if (taskInfo is null)
{
return $"Error: No task found with ID {taskId}.";
}
return taskInfo.Status switch
{
SubTaskStatus.Completed => taskInfo.ResultText ?? "(no output)",
SubTaskStatus.Failed => $"Task failed: {taskInfo.ErrorText ?? "Unknown error"}",
SubTaskStatus.Lost => "Task state was lost (reference unavailable).",
SubTaskStatus.Running => $"Task {taskId} is still running.",
_ => $"Task {taskId} has status: {taskInfo.Status}.",
};
},
new AIFunctionFactoryOptions
{
Name = "SubAgents_GetTaskResults",
Description = "Get the text output of a sub-task by its ID. Returns the result text if complete, or status information if still running or failed.",
SerializerOptions = serializerOptions,
}),
AIFunctionFactory.Create(
() =>
{
this.TryRefreshTaskState(state, runtimeState, session);
if (state.Tasks.Count == 0)
{
return "No tasks.";
}
var sb = new StringBuilder();
sb.AppendLine("Tasks:");
foreach (SubTaskInfo task in state.Tasks)
{
sb.Append("- Task ").Append(task.Id).Append(" [").Append(task.Status).Append("] (").Append(task.AgentName).Append("): ").AppendLine(task.Description);
}
return sb.ToString();
},
new AIFunctionFactoryOptions
{
Name = "SubAgents_GetAllTasks",
Description = "List all sub-tasks with their IDs, statuses, agent names, and descriptions.",
SerializerOptions = serializerOptions,
}),
AIFunctionFactory.Create(
(int taskId, string text) =>
{
this.TryRefreshTaskState(state, runtimeState, session);
SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
if (taskInfo is null)
{
return $"Error: No task found with ID {taskId}.";
}
if (taskInfo.Status == SubTaskStatus.Running)
{
return $"Error: Task {taskId} is still running. Wait for it to complete before continuing.";
}
if (!this._agents.TryGetValue(taskInfo.AgentName, out AIAgent? agent))
{
return $"Error: Agent '{taskInfo.AgentName}' is no longer available.";
}
if (!runtimeState.SubTaskSessions.TryGetValue(taskId, out AgentSession? subSession))
{
return $"Error: Session for task {taskId} is no longer available.";
}
// Reset task state and start a new run on the existing session.
taskInfo.Status = SubTaskStatus.Running;
taskInfo.ResultText = null;
taskInfo.ErrorText = null;
// Wrap in Task.Run to isolate the ExecutionContext (see StartSubTask comment).
runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(text, subSession));
this._sessionState.SaveState(session, state);
return $"Task {taskId} continued with new input.";
},
new AIFunctionFactoryOptions
{
Name = "SubAgents_ContinueTask",
Description = "Send follow-up input to a completed or failed sub-task to resume its work. The sub-task's session is preserved, so the agent retains conversational context.",
SerializerOptions = serializerOptions,
}),
AIFunctionFactory.Create(
(int taskId) =>
{
this.TryRefreshTaskState(state, runtimeState, session);
SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
if (taskInfo is null)
{
return $"Error: No task found with ID {taskId}.";
}
if (taskInfo.Status == SubTaskStatus.Running)
{
return $"Error: Task {taskId} is still running. Wait for it to complete before clearing.";
}
// Remove the task from state.
state.Tasks.Remove(taskInfo);
// Clean up runtime references.
runtimeState.InFlightTasks.Remove(taskId);
runtimeState.SubTaskSessions.Remove(taskId);
this._sessionState.SaveState(session, state);
return $"Task {taskId} cleared.";
},
new AIFunctionFactoryOptions
{
Name = "SubAgents_ClearCompletedTask",
Description = "Remove a completed or failed sub-task and release its session to free memory. Use this after retrieving results when you no longer need to continue the task.",
SerializerOptions = serializerOptions,
}),
];
}
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Options controlling the behavior of <see cref="SubAgentsProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class SubAgentsProviderOptions
{
/// <summary>
/// Gets or sets custom instructions provided to the agent for using the sub-agent tools.
/// </summary>
/// <value>
/// When <see langword="null"/> (the default), the provider uses built-in instructions
/// that guide the agent on how to use the sub-agent tools.
/// The agent list is always appended after the instructions regardless of this setting.
/// </value>
public string? Instructions { get; set; }
/// <summary>
/// Gets or sets a custom function that builds the agent list text to append to instructions.
/// </summary>
/// <value>
/// When <see langword="null"/> (the default), the provider generates a standard list of agent names and descriptions.
/// When set, this function receives the dictionary of available agents (keyed by name) and should return
/// a formatted string describing the available sub-agents.
/// </value>
public Func<IReadOnlyDictionary<string, AIAgent>, string>? AgentListBuilder { get; set; }
}
@@ -0,0 +1,50 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the metadata and result of a sub-task managed by the <see cref="SubAgentsProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class SubTaskInfo
{
/// <summary>
/// Gets or sets the unique identifier for this sub-task.
/// </summary>
[JsonPropertyName("id")]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the agent that is executing this sub-task.
/// </summary>
[JsonPropertyName("agentName")]
public string AgentName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a description of what this sub-task is doing.
/// </summary>
[JsonPropertyName("description")]
public string Description { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the current status of this sub-task.
/// </summary>
[JsonPropertyName("status")]
public SubTaskStatus Status { get; set; }
/// <summary>
/// Gets or sets the text result of the sub-task, populated when the task completes successfully.
/// </summary>
[JsonPropertyName("resultText")]
public string? ResultText { get; set; }
/// <summary>
/// Gets or sets the error message if the sub-task failed.
/// </summary>
[JsonPropertyName("errorText")]
public string? ErrorText { get; set; }
}
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the status of a sub-task managed by the <see cref="SubAgentsProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public enum SubTaskStatus
{
/// <summary>
/// The sub-task is currently running.
/// </summary>
Running,
/// <summary>
/// The sub-task completed successfully.
/// </summary>
Completed,
/// <summary>
/// The sub-task failed with an error.
/// </summary>
Failed,
/// <summary>
/// The sub-task's in-flight reference was lost (e.g., after a restart),
/// and its final state cannot be determined.
/// </summary>
Lost,
}
@@ -0,0 +1,972 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="SubAgentsProvider"/> class.
/// </summary>
public class SubAgentsProviderTests
{
#region Constructor Tests
/// <summary>
/// Verify that the constructor throws when agents is null.
/// </summary>
[Fact]
public void Constructor_NullAgents_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new SubAgentsProvider(null!));
}
/// <summary>
/// Verify that the constructor throws when agents collection is empty.
/// </summary>
[Fact]
public void Constructor_EmptyAgents_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => new SubAgentsProvider(Array.Empty<AIAgent>()));
}
/// <summary>
/// Verify that the constructor throws when an agent has a null name.
/// </summary>
[Fact]
public void Constructor_AgentWithNullName_Throws()
{
// Arrange
var agent = CreateMockAgent(null!, "desc");
// Act & Assert
Assert.Throws<ArgumentException>(() => new SubAgentsProvider(new[] { agent }));
}
/// <summary>
/// Verify that the constructor throws when an agent has an empty name.
/// </summary>
[Fact]
public void Constructor_AgentWithEmptyName_Throws()
{
// Arrange
var agent = CreateMockAgent("", "desc");
// Act & Assert
Assert.Throws<ArgumentException>(() => new SubAgentsProvider(new[] { agent }));
}
/// <summary>
/// Verify that the constructor throws when duplicate agent names are provided (case-insensitive).
/// </summary>
[Fact]
public void Constructor_DuplicateNames_Throws()
{
// Arrange
var agent1 = CreateMockAgent("Research", "Agent 1");
var agent2 = CreateMockAgent("research", "Agent 2");
// Act & Assert
Assert.Throws<ArgumentException>(() => new SubAgentsProvider(new[] { agent1, agent2 }));
}
/// <summary>
/// Verify that the constructor succeeds with valid agents.
/// </summary>
[Fact]
public void Constructor_ValidAgents_Succeeds()
{
// Arrange
var agent1 = CreateMockAgent("Research", "Research agent");
var agent2 = CreateMockAgent("Writer", "Writer agent");
// Act
var provider = new SubAgentsProvider(new[] { agent1, agent2 });
// Assert
Assert.NotNull(provider);
}
#endregion
#region ProvideAIContextAsync Tests
/// <summary>
/// Verify that the provider returns tools and instructions.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_ReturnsToolsAndInstructionsAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var provider = new SubAgentsProvider(new[] { agent });
var context = CreateInvokingContext();
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.NotNull(result.Instructions);
Assert.NotNull(result.Tools);
Assert.Equal(6, result.Tools!.Count());
}
/// <summary>
/// Verify that the instructions include agent names and descriptions.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_InstructionsIncludeAgentInfoAsync()
{
// Arrange
var agent1 = CreateMockAgent("Research", "Performs research");
var agent2 = CreateMockAgent("Writer", "Writes content");
var provider = new SubAgentsProvider(new[] { agent1, agent2 });
var context = CreateInvokingContext();
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — agent info is appended to instructions
Assert.Contains("Research", result.Instructions);
Assert.Contains("Performs research", result.Instructions);
Assert.Contains("Writer", result.Instructions);
Assert.Contains("Writes content", result.Instructions);
}
#endregion
#region StartSubTask Tests
/// <summary>
/// Verify that StartSubTask returns a task ID.
/// </summary>
[Fact]
public async Task StartSubTask_ReturnsTaskIdAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
// Act
object? result = await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Find information about AI",
["description"] = "Research AI topics",
});
// Assert
string text = GetStringResult(result);
Assert.Contains("1", text);
Assert.Contains("started", text);
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
}
/// <summary>
/// Verify that StartSubTask with invalid agent name returns an error.
/// </summary>
[Fact]
public async Task StartSubTask_InvalidAgentName_ReturnsErrorAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
// Act
object? result = await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "NonExistent",
["input"] = "Some input",
["description"] = "Some task",
});
// Assert
string text = GetStringResult(result);
Assert.Contains("Error", text);
Assert.Contains("NonExistent", text);
}
/// <summary>
/// Verify that StartSubTask assigns sequential IDs.
/// </summary>
[Fact]
public async Task StartSubTask_AssignsSequentialIdsAsync()
{
// Arrange
var tcs1 = new TaskCompletionSource<AgentResponse>();
var tcs2 = new TaskCompletionSource<AgentResponse>();
var callCount = 0;
var agent = CreateMockAgentWithCallback("Research", () =>
{
callCount++;
return callCount == 1 ? tcs1.Task : tcs2.Task;
});
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
// Act
object? result1 = await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Task 1",
["description"] = "First task",
});
object? result2 = await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Task 2",
["description"] = "Second task",
});
// Assert
Assert.Contains("1", GetStringResult(result1));
Assert.Contains("2", GetStringResult(result2));
tcs1.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
tcs2.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
}
#endregion
#region WaitForFirstCompletion Tests
/// <summary>
/// Verify that WaitForFirstCompletion returns the ID of a completed task.
/// </summary>
[Fact]
public async Task WaitForFirstCompletion_ReturnsCompletedTaskIdAsync()
{
// Arrange — use a single task to avoid Task.Run scheduling races.
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
// Start one task
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Task 1",
["description"] = "First task",
});
// Complete the task
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "Result 1")));
// Act
object? result = await waitForFirst.InvokeAsync(new AIFunctionArguments
{
["taskIds"] = new List<int> { 1 },
});
// Assert
string text = GetStringResult(result);
Assert.Contains("1", text);
Assert.Contains("finished with status: Completed", text);
}
/// <summary>
/// Verify that WaitForFirstCompletion with empty list returns an error.
/// </summary>
[Fact]
public async Task WaitForFirstCompletion_EmptyList_ReturnsErrorAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
// Act
object? result = await waitForFirst.InvokeAsync(new AIFunctionArguments
{
["taskIds"] = new List<int>(),
});
// Assert
Assert.Contains("Error", GetStringResult(result));
}
#endregion
#region GetSubTaskResults Tests
/// <summary>
/// Verify that GetSubTaskResults returns the result text of a completed task.
/// </summary>
[Fact]
public async Task GetSubTaskResults_CompletedTask_ReturnsResultTextAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
// Start a task
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research",
});
// Complete it
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "AI is fascinating!")));
// Wait for completion to finalize state
await waitForFirst.InvokeAsync(new AIFunctionArguments
{
["taskIds"] = new List<int> { 1 },
});
// Act
object? result = await getResults.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
});
// Assert
Assert.Contains("AI is fascinating!", GetStringResult(result));
}
/// <summary>
/// Verify that GetSubTaskResults for a still-running task returns status info.
/// </summary>
[Fact]
public async Task GetSubTaskResults_RunningTask_ReturnsStatusAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
// Start a task (don't complete it)
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research",
});
// Act
object? result = await getResults.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
});
// Assert
Assert.Contains("still running", GetStringResult(result));
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
}
/// <summary>
/// Verify that GetSubTaskResults for a nonexistent task returns an error.
/// </summary>
[Fact]
public async Task GetSubTaskResults_NonexistentTask_ReturnsErrorAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
// Act
object? result = await getResults.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 999,
});
// Assert
Assert.Contains("Error", GetStringResult(result));
}
/// <summary>
/// Verify that GetSubTaskResults for a failed task returns the error.
/// </summary>
[Fact]
public async Task GetSubTaskResults_FailedTask_ReturnsErrorTextAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
// Start a task
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research",
});
// Fail it
tcs.SetException(new InvalidOperationException("Connection failed"));
// Wait for completion to finalize state
await waitForFirst.InvokeAsync(new AIFunctionArguments
{
["taskIds"] = new List<int> { 1 },
});
// Act
object? result = await getResults.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
});
// Assert
string text = GetStringResult(result);
Assert.Contains("failed", text);
Assert.Contains("Connection failed", text);
}
#endregion
#region GetAllTasks Tests
/// <summary>
/// Verify that GetAllTasks returns running tasks with descriptions and status.
/// </summary>
[Fact]
public async Task GetAllTasks_ReturnsRunningTasksAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction getAllTasks = GetTool(tools, "SubAgents_GetAllTasks");
// Start a task
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research task",
});
// Act
object? result = await getAllTasks.InvokeAsync(new AIFunctionArguments());
// Assert
string text = GetStringResult(result);
Assert.Contains("1", text);
Assert.Contains("Research", text);
Assert.Contains("AI research task", text);
Assert.Contains("Running", text);
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
}
/// <summary>
/// Verify that GetAllTasks returns completed tasks with their status.
/// </summary>
[Fact]
public async Task GetAllTasks_ShowsCompletedTasksAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
AIFunction getAllTasks = GetTool(tools, "SubAgents_GetAllTasks");
// Start and complete a task
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research",
});
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
await waitForFirst.InvokeAsync(new AIFunctionArguments
{
["taskIds"] = new List<int> { 1 },
});
// Act
object? result = await getAllTasks.InvokeAsync(new AIFunctionArguments());
// Assert
string text = GetStringResult(result);
Assert.Contains("Completed", text);
Assert.Contains("Research", text);
}
/// <summary>
/// Verify that GetAllTasks returns no tasks when none exist.
/// </summary>
[Fact]
public async Task GetAllTasks_NoTasks_ReturnsNoneAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction getAllTasks = GetTool(tools, "SubAgents_GetAllTasks");
// Act
object? result = await getAllTasks.InvokeAsync(new AIFunctionArguments());
// Assert
Assert.Contains("No tasks", GetStringResult(result));
}
#endregion
#region ContinueTask Tests
/// <summary>
/// Verify that ContinueTask resumes a completed task with new input.
/// </summary>
[Fact]
public async Task ContinueTask_CompletedTask_ResumesAsync()
{
// Arrange
var tcs1 = new TaskCompletionSource<AgentResponse>();
var tcs2 = new TaskCompletionSource<AgentResponse>();
var callCount = 0;
var agent = CreateMockAgentWithCallback("Research", () =>
{
callCount++;
return callCount == 1 ? tcs1.Task : tcs2.Task;
});
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
AIFunction continueTask = GetTool(tools, "SubAgents_ContinueTask");
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
// Start and complete a task
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research",
});
tcs1.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "First result")));
await waitForFirst.InvokeAsync(new AIFunctionArguments
{
["taskIds"] = new List<int> { 1 },
});
// Act — continue the task
object? continueResult = await continueTask.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
["text"] = "Please elaborate",
});
// Assert — task is resumed
Assert.Contains("continued", GetStringResult(continueResult));
// Complete the second run
tcs2.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "Elaborated result")));
await waitForFirst.InvokeAsync(new AIFunctionArguments
{
["taskIds"] = new List<int> { 1 },
});
object? result = await getResults.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
});
Assert.Contains("Elaborated result", GetStringResult(result));
}
/// <summary>
/// Verify that ContinueTask on a running task returns an error.
/// </summary>
[Fact]
public async Task ContinueTask_RunningTask_ReturnsErrorAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction continueTask = GetTool(tools, "SubAgents_ContinueTask");
// Start a task (don't complete it)
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research",
});
// Act
object? result = await continueTask.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
["text"] = "More input",
});
// Assert
Assert.Contains("still running", GetStringResult(result));
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
}
/// <summary>
/// Verify that ContinueTask on a nonexistent task returns an error.
/// </summary>
[Fact]
public async Task ContinueTask_NonexistentTask_ReturnsErrorAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction continueTask = GetTool(tools, "SubAgents_ContinueTask");
// Act
object? result = await continueTask.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 999,
["text"] = "More input",
});
// Assert
Assert.Contains("Error", GetStringResult(result));
}
#endregion
#region ClearCompletedTask Tests
/// <summary>
/// Verify that ClearCompletedTask removes a terminal task.
/// </summary>
[Fact]
public async Task ClearCompletedTask_RemovesTerminalTaskAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
AIFunction clearTask = GetTool(tools, "SubAgents_ClearCompletedTask");
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
// Start and complete a task
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research",
});
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "Result")));
await waitForFirst.InvokeAsync(new AIFunctionArguments
{
["taskIds"] = new List<int> { 1 },
});
// Act
object? clearResult = await clearTask.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
});
// Assert — task is cleared
Assert.Contains("cleared", GetStringResult(clearResult));
// Verify it's gone
object? getResult = await getResults.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
});
Assert.Contains("Error", GetStringResult(getResult));
}
/// <summary>
/// Verify that ClearCompletedTask on a running task returns an error.
/// </summary>
[Fact]
public async Task ClearCompletedTask_RunningTask_ReturnsErrorAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction clearTask = GetTool(tools, "SubAgents_ClearCompletedTask");
// Start a task (don't complete it)
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research",
});
// Act
object? result = await clearTask.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
});
// Assert
Assert.Contains("still running", GetStringResult(result));
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
}
/// <summary>
/// Verify that ClearCompletedTask on a nonexistent task returns an error.
/// </summary>
[Fact]
public async Task ClearCompletedTask_NonexistentTask_ReturnsErrorAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction clearTask = GetTool(tools, "SubAgents_ClearCompletedTask");
// Act
object? result = await clearTask.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 999,
});
// Assert
Assert.Contains("Error", GetStringResult(result));
}
#endregion
#region StateKeys Tests
/// <summary>
/// Verify that the provider exposes state keys.
/// </summary>
[Fact]
public void StateKeys_ReturnsExpectedKeys()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var provider = new SubAgentsProvider(new[] { agent });
// Act
var keys = provider.StateKeys;
// Assert
Assert.NotNull(keys);
Assert.Equal(2, keys.Count);
}
#endregion
#region CurrentRunContext Isolation Tests
/// <summary>
/// Verify that StartSubTask does not corrupt CurrentRunContext of the calling agent.
/// Because RunAsync is a non-async method that synchronously sets the static AsyncLocal
/// CurrentRunContext, the provider must isolate the sub-agent call to prevent overwriting
/// the outer agent's context.
/// </summary>
[Fact]
public async Task StartSubTask_DoesNotCorruptCurrentRunContextAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
var startTool = GetTool(tools, "SubAgents_StartTask");
AgentRunContext? contextBefore = AIAgent.CurrentRunContext;
// Act — invoke StartSubTask; this calls agent.RunAsync internally.
var args = new AIFunctionArguments(new Dictionary<string, object?>
{
["agentName"] = "Research",
["input"] = "Do work",
["description"] = "test task",
});
await startTool.InvokeAsync(args);
// Assert — CurrentRunContext should be unchanged.
Assert.Equal(contextBefore, AIAgent.CurrentRunContext);
// Clean up
tcs.SetResult(new AgentResponse(new List<ChatMessage> { new(ChatRole.Assistant, "done") }));
}
#endregion
#region Options Tests
/// <summary>
/// Verify that custom instructions from options override the default instructions but agent list is still appended.
/// </summary>
[Fact]
public async Task CustomInstructions_OverridesDefaultInstructionsAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
const string CustomInstructions = "These are custom sub-agent instructions.";
var options = new SubAgentsProviderOptions { Instructions = CustomInstructions };
var provider = new SubAgentsProvider(new[] { agent }, options);
var context = CreateInvokingContext();
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — custom instructions replace default, agent list is appended
Assert.StartsWith(CustomInstructions, result.Instructions);
Assert.Contains("Research", result.Instructions);
}
/// <summary>
/// Verify that default instructions contain tool names and agent names.
/// </summary>
[Fact]
public async Task DefaultInstructions_ContainsToolNamesAndAgentListAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var provider = new SubAgentsProvider(new[] { agent });
var context = CreateInvokingContext();
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — instructions contain both tool usage guidance and agent list
Assert.Contains("SubAgents_StartTask", result.Instructions);
Assert.Contains("SubAgents_WaitForFirstCompletion", result.Instructions);
Assert.Contains("SubAgents_GetTaskResults", result.Instructions);
Assert.Contains("SubAgents_GetAllTasks", result.Instructions);
Assert.Contains("SubAgents_ContinueTask", result.Instructions);
Assert.Contains("SubAgents_ClearCompletedTask", result.Instructions);
Assert.Contains("Research", result.Instructions);
Assert.Contains("Research agent", result.Instructions);
}
/// <summary>
/// Verify that a custom AgentListBuilder function is used to build the agent list text.
/// </summary>
[Fact]
public async Task CustomAgentListBuilder_UsedForAgentListAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var options = new SubAgentsProviderOptions
{
AgentListBuilder = agents => $"Custom list: {string.Join(", ", agents.Keys)}",
};
var provider = new SubAgentsProvider(new[] { agent }, options);
var context = CreateInvokingContext();
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — custom agent list builder output is in instructions
Assert.Contains("Custom list: Research", result.Instructions);
Assert.DoesNotContain("Available sub-agents:", result.Instructions);
}
#endregion
#region Helper Methods
private static AIAgent CreateMockAgent(string? name, string? description)
{
var mock = new Mock<AIAgent>();
mock.SetupGet(a => a.Name).Returns(name!);
mock.SetupGet(a => a.Description).Returns(description);
return mock.Object;
}
private static AIAgent CreateMockAgentWithRunResult(string name, Task<AgentResponse> result)
{
var mock = new Mock<AIAgent>();
mock.SetupGet(a => a.Name).Returns(name);
mock.Protected()
.Setup<ValueTask<AgentSession>>(
"CreateSessionCoreAsync",
ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask<AgentSession>(new ChatClientAgentSession()));
mock.Protected()
.Setup<Task<AgentResponse>>(
"RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>())
.Returns(result);
return mock.Object;
}
private static AIAgent CreateMockAgentWithCallback(string name, Func<Task<AgentResponse>> callback)
{
var mock = new Mock<AIAgent>();
mock.SetupGet(a => a.Name).Returns(name);
mock.Protected()
.Setup<ValueTask<AgentSession>>(
"CreateSessionCoreAsync",
ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask<AgentSession>(new ChatClientAgentSession()));
mock.Protected()
.Setup<Task<AgentResponse>>(
"RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>())
.Returns(callback);
return mock.Object;
}
private static async Task<(IEnumerable<AITool> Tools, SubAgentsProvider Provider)> CreateToolsWithProviderAsync(AIAgent agent)
{
var provider = new SubAgentsProvider(new[] { agent });
var context = CreateInvokingContext();
AIContext result = await provider.InvokingAsync(context);
return (result.Tools!, provider);
}
private static AIContextProvider.InvokingContext CreateInvokingContext()
{
var mockAgent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
return new AIContextProvider.InvokingContext(mockAgent, session, new AIContext());
#pragma warning restore MAAI001
}
private static AIFunction GetTool(IEnumerable<AITool> tools, string name)
{
return (AIFunction)tools.First(t => t is AIFunction f && f.Name == name);
}
private static string GetStringResult(object? result)
{
var element = Assert.IsType<JsonElement>(result);
return element.GetString()!;
}
#endregion
}