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 +}