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