.NET: Add background agents support to HarnessAgent (#5977)

* Add background agents support to HarnessAgent

* Add unit tests

* Address PR comments
This commit is contained in:
westey
2026-05-21 11:57:06 +01:00
committed by GitHub
Unverified
parent a12cc3878e
commit 4050107942
5 changed files with 191 additions and 4 deletions
@@ -102,10 +102,7 @@ AIAgent parentAgent =
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
DisableToolApproval = true, // If enabled, this allows don't-ask-again approval functionality.
DisableWebSearch = true,
AIContextProviders =
[
new BackgroundAgentsProvider([webSearchAgent]),
],
BackgroundAgents = [webSearchAgent],
ChatOptions = new ChatOptions
{
Instructions = parentInstructions,
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
@@ -249,6 +250,15 @@ public sealed class HarnessAgent : DelegatingAIAgent
providers.Add(skillsProvider);
}
if (options?.BackgroundAgents is IEnumerable<AIAgent> backgroundAgents)
{
var materializedAgents = backgroundAgents.ToList();
if (materializedAgents.Count > 0)
{
providers.Add(new BackgroundAgentsProvider(materializedAgents, options.BackgroundAgentsProviderOptions));
}
}
if (options?.AIContextProviders is IEnumerable<AIContextProvider> userProviders)
{
providers.AddRange(userProviders);
@@ -218,4 +218,26 @@ public sealed class HarnessAgentOptions
/// This property is ignored when <see cref="DisableOpenTelemetry"/> is <see langword="true"/>.
/// </remarks>
public string? OpenTelemetrySourceName { get; set; }
/// <summary>
/// Gets or sets the collection of background agents available for delegation via <see cref="BackgroundAgentsProvider"/>.
/// </summary>
/// <remarks>
/// When non-null and non-empty, a <see cref="BackgroundAgentsProvider"/> is automatically included in the
/// agent's context providers, enabling the agent to start, monitor, and retrieve results from background tasks.
/// When <see langword="null"/> or empty, no <see cref="BackgroundAgentsProvider"/> is configured.
/// Each agent in the collection must have a non-empty <see cref="AIAgent.Name"/> and names must be unique
/// (case-insensitive). If these requirements are not met, <see cref="BackgroundAgentsProvider"/> will throw
/// an <see cref="System.ArgumentException"/> during construction.
/// </remarks>
public IEnumerable<AIAgent>? BackgroundAgents { get; set; }
/// <summary>
/// Gets or sets optional configuration for the <see cref="BackgroundAgentsProvider"/>.
/// </summary>
/// <remarks>
/// Use this to customize instructions or agent list formatting for the background agents feature.
/// This property is ignored when <see cref="BackgroundAgents"/> is <see langword="null"/> or empty.
/// </remarks>
public BackgroundAgentsProviderOptions? BackgroundAgentsProviderOptions { get; set; }
}
@@ -37,6 +37,8 @@ public class HarnessAgentOptionsTests
Assert.Null(options.FileAccessStore);
Assert.Null(options.AgentModeProviderOptions);
Assert.Null(options.AgentSkillsSource);
Assert.Null(options.BackgroundAgents);
Assert.Null(options.BackgroundAgentsProviderOptions);
}
/// <summary>
@@ -52,6 +54,8 @@ public class HarnessAgentOptionsTests
var fileAccessStore = new Mock<AgentFileStore>().Object;
var agentModeOptions = new AgentModeProviderOptions();
var skillsSource = new Mock<AgentSkillsSource>().Object;
var backgroundAgents = new AIAgent[] { new Mock<AIAgent>().Object };
var backgroundAgentsOptions = new BackgroundAgentsProviderOptions();
// Act
var options = new HarnessAgentOptions
@@ -77,6 +81,8 @@ public class HarnessAgentOptionsTests
AgentSkillsSource = skillsSource,
DisableOpenTelemetry = true,
OpenTelemetrySourceName = "custom-source",
BackgroundAgents = backgroundAgents,
BackgroundAgentsProviderOptions = backgroundAgentsOptions,
};
// Assert
@@ -103,5 +109,7 @@ public class HarnessAgentOptionsTests
Assert.Same(skillsSource, options.AgentSkillsSource);
Assert.True(options.DisableOpenTelemetry);
Assert.Equal("custom-source", options.OpenTelemetrySourceName);
Assert.Same(backgroundAgents, options.BackgroundAgents);
Assert.Same(backgroundAgentsOptions, options.BackgroundAgentsProviderOptions);
}
}
@@ -1197,4 +1197,154 @@ public class HarnessAgentTests
}
#endregion
#region Feature: BackgroundAgentsProvider
/// <summary>
/// Verify that BackgroundAgentsProvider is included when BackgroundAgents are specified.
/// </summary>
[Fact]
public void BackgroundAgentsProvider_IncludedWhenAgentsSpecified()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var bgAgentMock = new Mock<AIAgent>();
bgAgentMock.Setup(a => a.Name).Returns("TestBackgroundAgent");
var options = CreateAllDisabledOptions();
options.BackgroundAgents = [bgAgentMock.Object];
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is BackgroundAgentsProvider);
}
/// <summary>
/// Verify that BackgroundAgentsProvider is not included when BackgroundAgents is null.
/// </summary>
[Fact]
public void BackgroundAgentsProvider_ExcludedWhenAgentsNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.BackgroundAgents = null;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is BackgroundAgentsProvider);
}
}
/// <summary>
/// Verify that BackgroundAgentsProvider is not included when BackgroundAgents is an empty collection.
/// </summary>
[Fact]
public void BackgroundAgentsProvider_ExcludedWhenAgentsEmpty()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.BackgroundAgents = Array.Empty<AIAgent>();
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is BackgroundAgentsProvider);
}
}
/// <summary>
/// Verify that BackgroundAgentsProviderOptions is passed through when specified.
/// </summary>
[Fact]
public async Task BackgroundAgentsProvider_UsesProvidedOptionsAsync()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var bgAgentMock = new Mock<AIAgent>();
bgAgentMock.Setup(a => a.Name).Returns("TestBackgroundAgent");
bgAgentMock.Setup(a => a.Description).Returns("A test background agent");
var providerOptions = new BackgroundAgentsProviderOptions
{
Instructions = "Custom instructions with {background_agents} list.",
};
var options = CreateAllDisabledOptions();
options.BackgroundAgents = [bgAgentMock.Object];
options.BackgroundAgentsProviderOptions = providerOptions;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
var bgProvider = innerAgent!.AIContextProviders!.OfType<BackgroundAgentsProvider>().Single();
#pragma warning disable MAAI001
var invokingContext = new AIContextProvider.InvokingContext(
new Mock<AIAgent>().Object,
new Mock<AgentSession>().Object,
new AIContext());
#pragma warning restore MAAI001
AIContext result = await bgProvider.InvokingAsync(invokingContext);
// Assert — custom instructions template is used and agent info is included
Assert.NotNull(result.Instructions);
Assert.Contains("Custom instructions with", result.Instructions);
Assert.Contains("TestBackgroundAgent", result.Instructions);
}
/// <summary>
/// Verify that multiple background agents are all passed to the provider.
/// </summary>
[Fact]
public async Task BackgroundAgentsProvider_IncludesMultipleAgentsAsync()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var agent1Mock = new Mock<AIAgent>();
agent1Mock.Setup(a => a.Name).Returns("Agent1");
agent1Mock.Setup(a => a.Description).Returns("First agent");
var agent2Mock = new Mock<AIAgent>();
agent2Mock.Setup(a => a.Name).Returns("Agent2");
agent2Mock.Setup(a => a.Description).Returns("Second agent");
var options = CreateAllDisabledOptions();
options.BackgroundAgents = [agent1Mock.Object, agent2Mock.Object];
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
var bgProvider = innerAgent!.AIContextProviders!.OfType<BackgroundAgentsProvider>().Single();
#pragma warning disable MAAI001
var invokingContext = new AIContextProvider.InvokingContext(
new Mock<AIAgent>().Object,
new Mock<AgentSession>().Object,
new AIContext());
#pragma warning restore MAAI001
AIContext result = await bgProvider.InvokingAsync(invokingContext);
// Assert — both agents appear in the provider's instructions
Assert.NotNull(result.Instructions);
Assert.Contains("Agent1", result.Instructions);
Assert.Contains("First agent", result.Instructions);
Assert.Contains("Agent2", result.Instructions);
Assert.Contains("Second agent", result.Instructions);
}
#endregion
}