diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 4c901e1792..fa4f515de1 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -121,6 +121,7 @@
+
diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs
index 00b997c5bd..182e74d86b 100644
--- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs
+++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs
@@ -28,7 +28,20 @@ public static async Task RunAgentAsync(AIAgent agent, string title, string userP
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();
+ 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 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s
private static async Task> StreamAndCollectApprovalsAsync(IAsyncEnumerable updates, AgentModeProvider? modeProvider, AgentSession session, int? maxContextWindowTokens, int? maxOutputTokens)
{
var approvalRequests = new List();
- 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 @@ private static async Task> StreamAndCollectAppr
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 @@ private static void HandleModeCommand(AgentModeProvider? modeProvider, AgentSess
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 @@ private static void WriteTokenCount(long? count, int? budget)
}
}
- 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,
};
}
diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs
index 50fc192c59..3d4d154f50 100644
--- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs
+++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs
@@ -34,12 +34,12 @@ public static string Format(FunctionCallContent call)
"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),
diff --git a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj
new file mode 100644
index 0000000000..b28ff5bf42
--- /dev/null
+++ b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs
new file mode 100644
index 0000000000..581f61a55c
--- /dev/null
+++ b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs
@@ -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):");
diff --git a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/README.md b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/README.md
new file mode 100644
index 0000000000..6fb94b34ab
--- /dev/null
+++ b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/README.md
@@ -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.
diff --git a/dotnet/samples/02-agents/Harness/README.md b/dotnet/samples/02-agents/Harness/README.md
index da54967258..2250fd301e 100644
--- a/dotnet/samples/02-agents/Harness/README.md
+++ b/dotnet/samples/02-agents/Harness/README.md
@@ -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 |
diff --git a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs
index a246424bef..f3bc543f03 100644
--- a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs
+++ b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs
@@ -95,6 +95,13 @@ private static JsonSerializerOptions CreateDefaultOptions()
[JsonSerializable(typeof(FileListEntry))]
[JsonSerializable(typeof(List), TypeInfoPropertyName = "FileListEntryList")]
+ // SubAgentsProvider types
+ [JsonSerializable(typeof(SubAgentState))]
+ [JsonSerializable(typeof(SubAgentRuntimeState))]
+ [JsonSerializable(typeof(SubTaskInfo))]
+ [JsonSerializable(typeof(SubTaskStatus))]
+ [JsonSerializable(typeof(List), TypeInfoPropertyName = "SubTaskInfoList")]
+
[ExcludeFromCodeCoverage]
internal sealed partial class JsonContext : JsonSerializerContext;
}
diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentRuntimeState.cs b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentRuntimeState.cs
new file mode 100644
index 0000000000..7b3096dba8
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentRuntimeState.cs
@@ -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;
+
+///
+/// Holds non-serializable runtime references for in-flight sub-tasks within a single parent session.
+///
+///
+/// Properties are marked with because
+/// and 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
+/// by .
+///
+internal sealed class SubAgentRuntimeState
+{
+ ///
+ /// Gets the mapping of task IDs to their in-flight instances.
+ ///
+ [JsonIgnore]
+ public Dictionary> InFlightTasks { get; } = [];
+
+ ///
+ /// Gets the mapping of task IDs to their sub-agent instances,
+ /// needed for ContinueTask.
+ ///
+ [JsonIgnore]
+ public Dictionary SubTaskSessions { get; } = [];
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentState.cs b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentState.cs
new file mode 100644
index 0000000000..4e086fb910
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentState.cs
@@ -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;
+
+///
+/// Represents the serializable state of sub-tasks managed by the ,
+/// stored in the session's .
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+internal sealed class SubAgentState
+{
+ ///
+ /// Gets or sets the next ID to assign to a new sub-task.
+ ///
+ [JsonPropertyName("nextTaskId")]
+ public int NextTaskId { get; set; } = 1;
+
+ ///
+ /// Gets the list of sub-task metadata entries.
+ ///
+ [JsonPropertyName("tasks")]
+ public List Tasks { get; set; } = [];
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs
new file mode 100644
index 0000000000..f6c6daf592
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs
@@ -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;
+
+///
+/// An that enables an agent to delegate work to sub-agents asynchronously.
+///
+///
+///
+/// The 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.
+///
+///
+/// This provider exposes the following tools to the agent:
+///
+/// - SubAgents_StartTask — Start a sub-task on a named agent with text input. Returns the task ID.
+/// - SubAgents_WaitForFirstCompletion — Block until the first of the specified tasks completes. Returns the completed task's ID.
+/// - SubAgents_GetTaskResults — Retrieve the text output of a completed sub-task.
+/// - SubAgents_GetAllTasks — List all sub-tasks with their IDs, statuses, descriptions, and agent names.
+/// - SubAgents_ContinueTask — Send follow-up input to a completed sub-task's session to resume work.
+/// - SubAgents_ClearCompletedTask — Remove a completed sub-task and release its session to free memory.
+///
+///
+///
+[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 _agents;
+ private readonly ProviderSessionState _sessionState;
+ private readonly ProviderSessionState _runtimeSessionState;
+ private readonly string _instructions;
+ private IReadOnlyList? _stateKeys;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The collection of sub-agents available for delegation.
+ /// Optional settings controlling the provider behavior.
+ /// is .
+ /// An agent has a null or empty name, or agent names are not unique.
+ public SubAgentsProvider(IEnumerable 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(
+ _ => new SubAgentState(),
+ this.GetType().Name,
+ AgentJsonUtilities.DefaultOptions);
+
+ this._runtimeSessionState = new ProviderSessionState(
+ _ => new SubAgentRuntimeState(),
+ this.GetType().Name + "_Runtime",
+ AgentJsonUtilities.DefaultOptions);
+ }
+
+ ///
+ public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey, this._runtimeSessionState.StateKey];
+
+ ///
+ protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ {
+ SubAgentState state = this._sessionState.GetOrInitializeState(context.Session);
+ SubAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(context.Session);
+
+ return new ValueTask(new AIContext
+ {
+ Instructions = this._instructions,
+ Tools = this.CreateTools(state, runtimeState, context.Session),
+ });
+ }
+
+ ///
+ /// Validates the agent collection and builds a case-insensitive name dictionary.
+ ///
+ private static Dictionary ValidateAndBuildAgentDictionary(IEnumerable agents)
+ {
+ var dict = new Dictionary(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;
+ }
+
+ ///
+ /// Builds the default text listing available sub-agents and their descriptions.
+ ///
+ private static string BuildDefaultAgentListText(IReadOnlyDictionary 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();
+ }
+
+ ///
+ /// Refreshes the status of in-flight tasks in the given state for the specified session.
+ ///
+ 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? 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);
+ }
+ }
+
+ ///
+ /// Finalizes a task by extracting results from the completed Task and updating the SubTaskInfo.
+ ///
+ private static void FinalizeTask(SubTaskInfo taskInfo, Task 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 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 Task)>();
+ foreach (int id in taskIds)
+ {
+ if (runtimeState.InFlightTasks.TryGetValue(id, out Task? 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,
+ }),
+ ];
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs
new file mode 100644
index 0000000000..06b9828e58
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs
@@ -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;
+
+///
+/// Options controlling the behavior of .
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class SubAgentsProviderOptions
+{
+ ///
+ /// Gets or sets custom instructions provided to the agent for using the sub-agent tools.
+ ///
+ ///
+ /// When (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.
+ ///
+ public string? Instructions { get; set; }
+
+ ///
+ /// Gets or sets a custom function that builds the agent list text to append to instructions.
+ ///
+ ///
+ /// When (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.
+ ///
+ public Func, string>? AgentListBuilder { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskInfo.cs b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskInfo.cs
new file mode 100644
index 0000000000..91b6084ece
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskInfo.cs
@@ -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;
+
+///
+/// Represents the metadata and result of a sub-task managed by the .
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class SubTaskInfo
+{
+ ///
+ /// Gets or sets the unique identifier for this sub-task.
+ ///
+ [JsonPropertyName("id")]
+ public int Id { get; set; }
+
+ ///
+ /// Gets or sets the name of the agent that is executing this sub-task.
+ ///
+ [JsonPropertyName("agentName")]
+ public string AgentName { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets a description of what this sub-task is doing.
+ ///
+ [JsonPropertyName("description")]
+ public string Description { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the current status of this sub-task.
+ ///
+ [JsonPropertyName("status")]
+ public SubTaskStatus Status { get; set; }
+
+ ///
+ /// Gets or sets the text result of the sub-task, populated when the task completes successfully.
+ ///
+ [JsonPropertyName("resultText")]
+ public string? ResultText { get; set; }
+
+ ///
+ /// Gets or sets the error message if the sub-task failed.
+ ///
+ [JsonPropertyName("errorText")]
+ public string? ErrorText { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskStatus.cs b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskStatus.cs
new file mode 100644
index 0000000000..f5e66f6f72
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskStatus.cs
@@ -0,0 +1,34 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Represents the status of a sub-task managed by the .
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public enum SubTaskStatus
+{
+ ///
+ /// The sub-task is currently running.
+ ///
+ Running,
+
+ ///
+ /// The sub-task completed successfully.
+ ///
+ Completed,
+
+ ///
+ /// The sub-task failed with an error.
+ ///
+ Failed,
+
+ ///
+ /// The sub-task's in-flight reference was lost (e.g., after a restart),
+ /// and its final state cannot be determined.
+ ///
+ Lost,
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs
new file mode 100644
index 0000000000..42ee69907c
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs
@@ -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;
+
+///
+/// Unit tests for the class.
+///
+public class SubAgentsProviderTests
+{
+ #region Constructor Tests
+
+ ///
+ /// Verify that the constructor throws when agents is null.
+ ///
+ [Fact]
+ public void Constructor_NullAgents_Throws()
+ {
+ // Act & Assert
+ Assert.Throws(() => new SubAgentsProvider(null!));
+ }
+
+ ///
+ /// Verify that the constructor throws when agents collection is empty.
+ ///
+ [Fact]
+ public void Constructor_EmptyAgents_Throws()
+ {
+ // Act & Assert
+ Assert.Throws(() => new SubAgentsProvider(Array.Empty()));
+ }
+
+ ///
+ /// Verify that the constructor throws when an agent has a null name.
+ ///
+ [Fact]
+ public void Constructor_AgentWithNullName_Throws()
+ {
+ // Arrange
+ var agent = CreateMockAgent(null!, "desc");
+
+ // Act & Assert
+ Assert.Throws(() => new SubAgentsProvider(new[] { agent }));
+ }
+
+ ///
+ /// Verify that the constructor throws when an agent has an empty name.
+ ///
+ [Fact]
+ public void Constructor_AgentWithEmptyName_Throws()
+ {
+ // Arrange
+ var agent = CreateMockAgent("", "desc");
+
+ // Act & Assert
+ Assert.Throws(() => new SubAgentsProvider(new[] { agent }));
+ }
+
+ ///
+ /// Verify that the constructor throws when duplicate agent names are provided (case-insensitive).
+ ///
+ [Fact]
+ public void Constructor_DuplicateNames_Throws()
+ {
+ // Arrange
+ var agent1 = CreateMockAgent("Research", "Agent 1");
+ var agent2 = CreateMockAgent("research", "Agent 2");
+
+ // Act & Assert
+ Assert.Throws(() => new SubAgentsProvider(new[] { agent1, agent2 }));
+ }
+
+ ///
+ /// Verify that the constructor succeeds with valid agents.
+ ///
+ [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
+
+ ///
+ /// Verify that the provider returns tools and instructions.
+ ///
+ [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());
+ }
+
+ ///
+ /// Verify that the instructions include agent names and descriptions.
+ ///
+ [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
+
+ ///
+ /// Verify that StartSubTask returns a task ID.
+ ///
+ [Fact]
+ public async Task StartSubTask_ReturnsTaskIdAsync()
+ {
+ // Arrange
+ var tcs = new TaskCompletionSource();
+ 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")));
+ }
+
+ ///
+ /// Verify that StartSubTask with invalid agent name returns an error.
+ ///
+ [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);
+ }
+
+ ///
+ /// Verify that StartSubTask assigns sequential IDs.
+ ///
+ [Fact]
+ public async Task StartSubTask_AssignsSequentialIdsAsync()
+ {
+ // Arrange
+ var tcs1 = new TaskCompletionSource();
+ var tcs2 = new TaskCompletionSource();
+ 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
+
+ ///
+ /// Verify that WaitForFirstCompletion returns the ID of a completed task.
+ ///
+ [Fact]
+ public async Task WaitForFirstCompletion_ReturnsCompletedTaskIdAsync()
+ {
+ // Arrange — use a single task to avoid Task.Run scheduling races.
+ var tcs = new TaskCompletionSource();
+ 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 { 1 },
+ });
+
+ // Assert
+ string text = GetStringResult(result);
+ Assert.Contains("1", text);
+ Assert.Contains("finished with status: Completed", text);
+ }
+
+ ///
+ /// Verify that WaitForFirstCompletion with empty list returns an error.
+ ///
+ [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(),
+ });
+
+ // Assert
+ Assert.Contains("Error", GetStringResult(result));
+ }
+
+ #endregion
+
+ #region GetSubTaskResults Tests
+
+ ///
+ /// Verify that GetSubTaskResults returns the result text of a completed task.
+ ///
+ [Fact]
+ public async Task GetSubTaskResults_CompletedTask_ReturnsResultTextAsync()
+ {
+ // Arrange
+ var tcs = new TaskCompletionSource();
+ 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 { 1 },
+ });
+
+ // Act
+ object? result = await getResults.InvokeAsync(new AIFunctionArguments
+ {
+ ["taskId"] = 1,
+ });
+
+ // Assert
+ Assert.Contains("AI is fascinating!", GetStringResult(result));
+ }
+
+ ///
+ /// Verify that GetSubTaskResults for a still-running task returns status info.
+ ///
+ [Fact]
+ public async Task GetSubTaskResults_RunningTask_ReturnsStatusAsync()
+ {
+ // Arrange
+ var tcs = new TaskCompletionSource();
+ 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")));
+ }
+
+ ///
+ /// Verify that GetSubTaskResults for a nonexistent task returns an error.
+ ///
+ [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));
+ }
+
+ ///
+ /// Verify that GetSubTaskResults for a failed task returns the error.
+ ///
+ [Fact]
+ public async Task GetSubTaskResults_FailedTask_ReturnsErrorTextAsync()
+ {
+ // Arrange
+ var tcs = new TaskCompletionSource();
+ 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 { 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
+
+ ///
+ /// Verify that GetAllTasks returns running tasks with descriptions and status.
+ ///
+ [Fact]
+ public async Task GetAllTasks_ReturnsRunningTasksAsync()
+ {
+ // Arrange
+ var tcs = new TaskCompletionSource();
+ 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")));
+ }
+
+ ///
+ /// Verify that GetAllTasks returns completed tasks with their status.
+ ///
+ [Fact]
+ public async Task GetAllTasks_ShowsCompletedTasksAsync()
+ {
+ // Arrange
+ var tcs = new TaskCompletionSource();
+ 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 { 1 },
+ });
+
+ // Act
+ object? result = await getAllTasks.InvokeAsync(new AIFunctionArguments());
+
+ // Assert
+ string text = GetStringResult(result);
+ Assert.Contains("Completed", text);
+ Assert.Contains("Research", text);
+ }
+
+ ///
+ /// Verify that GetAllTasks returns no tasks when none exist.
+ ///
+ [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
+
+ ///
+ /// Verify that ContinueTask resumes a completed task with new input.
+ ///
+ [Fact]
+ public async Task ContinueTask_CompletedTask_ResumesAsync()
+ {
+ // Arrange
+ var tcs1 = new TaskCompletionSource();
+ var tcs2 = new TaskCompletionSource();
+ 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 { 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 { 1 },
+ });
+
+ object? result = await getResults.InvokeAsync(new AIFunctionArguments
+ {
+ ["taskId"] = 1,
+ });
+ Assert.Contains("Elaborated result", GetStringResult(result));
+ }
+
+ ///
+ /// Verify that ContinueTask on a running task returns an error.
+ ///
+ [Fact]
+ public async Task ContinueTask_RunningTask_ReturnsErrorAsync()
+ {
+ // Arrange
+ var tcs = new TaskCompletionSource();
+ 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")));
+ }
+
+ ///
+ /// Verify that ContinueTask on a nonexistent task returns an error.
+ ///
+ [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
+
+ ///
+ /// Verify that ClearCompletedTask removes a terminal task.
+ ///
+ [Fact]
+ public async Task ClearCompletedTask_RemovesTerminalTaskAsync()
+ {
+ // Arrange
+ var tcs = new TaskCompletionSource();
+ 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 { 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));
+ }
+
+ ///
+ /// Verify that ClearCompletedTask on a running task returns an error.
+ ///
+ [Fact]
+ public async Task ClearCompletedTask_RunningTask_ReturnsErrorAsync()
+ {
+ // Arrange
+ var tcs = new TaskCompletionSource();
+ 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")));
+ }
+
+ ///
+ /// Verify that ClearCompletedTask on a nonexistent task returns an error.
+ ///
+ [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
+
+ ///
+ /// Verify that the provider exposes state keys.
+ ///
+ [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
+
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public async Task StartSubTask_DoesNotCorruptCurrentRunContextAsync()
+ {
+ // Arrange
+ var tcs = new TaskCompletionSource();
+ 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
+ {
+ ["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 { new(ChatRole.Assistant, "done") }));
+ }
+
+ #endregion
+
+ #region Options Tests
+
+ ///
+ /// Verify that custom instructions from options override the default instructions but agent list is still appended.
+ ///
+ [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);
+ }
+
+ ///
+ /// Verify that default instructions contain tool names and agent names.
+ ///
+ [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);
+ }
+
+ ///
+ /// Verify that a custom AgentListBuilder function is used to build the agent list text.
+ ///
+ [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();
+ mock.SetupGet(a => a.Name).Returns(name!);
+ mock.SetupGet(a => a.Description).Returns(description);
+ return mock.Object;
+ }
+
+ private static AIAgent CreateMockAgentWithRunResult(string name, Task result)
+ {
+ var mock = new Mock();
+ mock.SetupGet(a => a.Name).Returns(name);
+ mock.Protected()
+ .Setup>(
+ "CreateSessionCoreAsync",
+ ItExpr.IsAny())
+ .Returns(new ValueTask(new ChatClientAgentSession()));
+ mock.Protected()
+ .Setup>(
+ "RunCoreAsync",
+ ItExpr.IsAny>(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .Returns(result);
+ return mock.Object;
+ }
+
+ private static AIAgent CreateMockAgentWithCallback(string name, Func> callback)
+ {
+ var mock = new Mock();
+ mock.SetupGet(a => a.Name).Returns(name);
+ mock.Protected()
+ .Setup>(
+ "CreateSessionCoreAsync",
+ ItExpr.IsAny())
+ .Returns(new ValueTask(new ChatClientAgentSession()));
+ mock.Protected()
+ .Setup>(
+ "RunCoreAsync",
+ ItExpr.IsAny>(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .Returns(callback);
+ return mock.Object;
+ }
+
+ private static async Task<(IEnumerable 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().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 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(result);
+ return element.GetString()!;
+ }
+
+ #endregion
+}