Add ServiceLifetime support for Hosting DI registration (#4476)

This commit is contained in:
westey
2026-03-06 09:39:33 +00:00
committed by GitHub
Unverified
parent 4bd5469798
commit d8d6ac1c59
11 changed files with 507 additions and 40 deletions
@@ -19,9 +19,10 @@ public static class AgentHostingServiceCollectionExtensions
/// <param name="services">The service collection to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions)
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(services);
Throw.IfNullOrEmpty(name);
@@ -30,7 +31,7 @@ public static class AgentHostingServiceCollectionExtensions
var chatClient = sp.GetRequiredService<IChatClient>();
var tools = sp.GetKeyedServices<AITool>(name).ToList();
return new ChatClientAgent(chatClient, instructions, key, tools: tools);
});
}, lifetime);
}
/// <summary>
@@ -40,9 +41,10 @@ public static class AgentHostingServiceCollectionExtensions
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="chatClient">The chat client which the agent will use for inference.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, IChatClient chatClient)
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, IChatClient chatClient, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(services);
Throw.IfNullOrEmpty(name);
@@ -50,7 +52,7 @@ public static class AgentHostingServiceCollectionExtensions
{
var tools = sp.GetKeyedServices<AITool>(name).ToList();
return new ChatClientAgent(chatClient, instructions, key, tools: tools);
});
}, lifetime);
}
/// <summary>
@@ -60,9 +62,10 @@ public static class AgentHostingServiceCollectionExtensions
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If <see langword="null"/>, a non-keyed service will be resolved.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, object? chatClientServiceKey)
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(services);
Throw.IfNullOrEmpty(name);
@@ -71,7 +74,7 @@ public static class AgentHostingServiceCollectionExtensions
var chatClient = chatClientServiceKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
var tools = sp.GetKeyedServices<AITool>(name).ToList();
return new ChatClientAgent(chatClient, instructions, key, tools: tools);
});
}, lifetime);
}
/// <summary>
@@ -82,9 +85,10 @@ public static class AgentHostingServiceCollectionExtensions
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="description">A description of the agent.</param>
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If <see langword="null"/>, a non-keyed service will be resolved.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, string? description, object? chatClientServiceKey)
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, string? description, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(services);
Throw.IfNullOrEmpty(name);
@@ -93,7 +97,7 @@ public static class AgentHostingServiceCollectionExtensions
var chatClient = chatClientServiceKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
var tools = sp.GetKeyedServices<AITool>(name).ToList();
return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description, tools: tools);
});
}, lifetime);
}
/// <summary>
@@ -102,15 +106,16 @@ public static class AgentHostingServiceCollectionExtensions
/// <param name="services">The service collection to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="createAgentDelegate">A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/>, <paramref name="name"/>, or <paramref name="createAgentDelegate"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">Thrown when the agent factory delegate returns <see langword="null"/> or an agent whose <see cref="AIAgent.Name"/> does not match <paramref name="name"/>.</exception>
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate)
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(services);
Throw.IfNull(name);
Throw.IfNull(createAgentDelegate);
services.AddKeyedSingleton(name, (sp, key) =>
services.AddKeyedService(name, (sp, key) =>
{
Throw.IfNull(key);
var keyString = key as string;
@@ -122,8 +127,18 @@ public static class AgentHostingServiceCollectionExtensions
}
return agent;
});
}, lifetime);
return new HostedAgentBuilder(name, services);
return new HostedAgentBuilder(name, services, lifetime);
}
/// <summary>
/// Registers a keyed service with the specified lifetime.
/// </summary>
internal static void AddKeyedService<T>(this IServiceCollection services, object? serviceKey, Func<IServiceProvider, object?, T> factory, ServiceLifetime lifetime)
where T : class
{
var descriptor = new ServiceDescriptor(typeof(T), serviceKey, (sp, key) => factory(sp, key), lifetime);
services.Add(descriptor);
}
}
@@ -2,6 +2,7 @@
using System;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Shared.Diagnostics;
@@ -18,12 +19,13 @@ public static class HostApplicationBuilderAgentExtensions
/// <param name="builder">The host application builder to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions)
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(builder);
return builder.Services.AddAIAgent(name, instructions);
return builder.Services.AddAIAgent(name, instructions, lifetime);
}
/// <summary>
@@ -33,13 +35,14 @@ public static class HostApplicationBuilderAgentExtensions
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="chatClient">The chat client which the agent will use for inference.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, IChatClient chatClient)
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, IChatClient chatClient, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(builder);
Throw.IfNullOrEmpty(name);
return builder.Services.AddAIAgent(name, instructions, chatClient);
return builder.Services.AddAIAgent(name, instructions, chatClient, lifetime);
}
/// <summary>
@@ -50,13 +53,14 @@ public static class HostApplicationBuilderAgentExtensions
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="description">A description of the agent.</param>
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, string? description, object? chatClientServiceKey)
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, string? description, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(builder);
Throw.IfNullOrEmpty(name);
return builder.Services.AddAIAgent(name, instructions, description, chatClientServiceKey);
return builder.Services.AddAIAgent(name, instructions, description, chatClientServiceKey, lifetime);
}
/// <summary>
@@ -66,12 +70,13 @@ public static class HostApplicationBuilderAgentExtensions
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey)
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(builder);
return builder.Services.AddAIAgent(name, instructions, chatClientServiceKey);
return builder.Services.AddAIAgent(name, instructions, chatClientServiceKey, lifetime);
}
/// <summary>
@@ -80,12 +85,13 @@ public static class HostApplicationBuilderAgentExtensions
/// <param name="builder">The host application builder to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="createAgentDelegate">A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="createAgentDelegate"/> is null.</exception>
/// <exception cref="InvalidOperationException">Thrown when the agent factory delegate returns null or an invalid AI agent instance.</exception>
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate)
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(builder);
return builder.Services.AddAIAgent(name, createAgentDelegate);
return builder.Services.AddAIAgent(name, createAgentDelegate, lifetime);
}
}
@@ -19,19 +19,20 @@ public static class HostApplicationBuilderWorkflowExtensions
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <param name="name">The unique name for the workflow.</param>
/// <param name="createWorkflowDelegate">A factory function that creates the <see cref="Workflow"/> instance. The function receives the service provider and workflow name as parameters.</param>
/// <param name="lifetime">The DI service lifetime for the workflow registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>An <see cref="IHostedWorkflowBuilder"/> that can be used to further configure the workflow.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="createWorkflowDelegate"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is empty.</exception>
/// <exception cref="InvalidOperationException">
/// Thrown when the factory delegate returns null or a workflow with a name that doesn't match the expected name.
/// </exception>
public static IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, Workflow> createWorkflowDelegate)
public static IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, Workflow> createWorkflowDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(builder);
Throw.IfNull(name);
Throw.IfNull(createWorkflowDelegate);
builder.Services.AddKeyedSingleton(name, (sp, key) =>
builder.Services.AddKeyedService(name, (sp, key) =>
{
Throw.IfNull(key);
var keyString = key as string;
@@ -43,7 +44,7 @@ public static class HostApplicationBuilderWorkflowExtensions
}
return workflow;
});
}, lifetime);
return new HostedWorkflowBuilder(name, builder);
}
@@ -9,15 +9,17 @@ internal sealed class HostedAgentBuilder : IHostedAgentBuilder
{
public string Name { get; }
public IServiceCollection ServiceCollection { get; }
public ServiceLifetime Lifetime { get; }
public HostedAgentBuilder(string name, IHostApplicationBuilder builder)
: this(name, builder.Services)
public HostedAgentBuilder(string name, IHostApplicationBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Singleton)
: this(name, builder.Services, lifetime)
{
}
public HostedAgentBuilder(string name, IServiceCollection serviceCollection)
public HostedAgentBuilder(string name, IServiceCollection serviceCollection, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
this.Name = name;
this.ServiceCollection = serviceCollection;
this.Lifetime = lifetime;
}
}
@@ -42,17 +42,19 @@ public static class HostedAgentBuilderExtensions
/// <param name="builder">The host agent builder to configure.</param>
/// <param name="createAgentSessionStore">A factory function that creates an agent session store instance using the provided service provider and agent
/// name.</param>
/// <param name="lifetime">The DI service lifetime for the session store registration. Defaults to <see cref="ServiceLifetime.Singleton"/>
/// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime.</param>
/// <returns>The same host agent builder instance, enabling further configuration.</returns>
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore)
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
builder.ServiceCollection.AddKeyedSingleton(builder.Name, (sp, key) =>
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, key) =>
{
Throw.IfNull(key);
var keyString = key as string;
Throw.IfNullOrEmpty(keyString);
return createAgentSessionStore(sp, keyString) ??
throw new InvalidOperationException($"The agent session store factory did not return a valid {nameof(AgentSessionStore)} instance for key '{keyString}'.");
});
}, lifetime);
return builder;
}
@@ -98,13 +100,39 @@ public static class HostedAgentBuilderExtensions
/// </summary>
/// <param name="builder">The hosted agent builder.</param>
/// <param name="factory">A factory function that creates a AI tool using the provided service provider.</param>
public static IHostedAgentBuilder WithAITool(this IHostedAgentBuilder builder, Func<IServiceProvider, AITool> factory)
/// <param name="lifetime">The DI service lifetime for the tool registration. If <see langword="null"/>, the agent's lifetime is used.</param>
/// <returns>The same <see cref="IHostedAgentBuilder"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="factory"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">
/// Thrown when the effective tool lifetime is shorter than the agent's lifetime, which would cause a captive dependency.
/// For example, a singleton agent cannot use scoped or transient tools.
/// </exception>
public static IHostedAgentBuilder WithAITool(this IHostedAgentBuilder builder, Func<IServiceProvider, AITool> factory, ServiceLifetime? lifetime = null)
{
Throw.IfNull(builder);
Throw.IfNull(factory);
builder.ServiceCollection.AddKeyedSingleton(builder.Name, (sp, name) => factory(sp));
var effectiveLifetime = lifetime ?? builder.Lifetime;
ValidateToolLifetime(builder.Lifetime, effectiveLifetime);
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, name) => factory(sp), effectiveLifetime);
return builder;
}
/// <summary>
/// Validates that the tool lifetime is compatible with the agent lifetime.
/// A tool's lifetime must be at least as long as the agent's lifetime to prevent captive dependency issues.
/// </summary>
internal static void ValidateToolLifetime(ServiceLifetime agentLifetime, ServiceLifetime toolLifetime)
{
// ServiceLifetime enum: Singleton=0, Scoped=1, Transient=2
// A higher value means a shorter lifetime.
if (toolLifetime > agentLifetime)
{
throw new InvalidOperationException(
$"A tool with lifetime '{toolLifetime}' cannot be registered for an agent with lifetime '{agentLifetime}'. " +
"The tool's lifetime must be at least as long as the agent's lifetime to avoid captive dependency issues.");
}
}
}
@@ -14,22 +14,24 @@ public static class HostedWorkflowBuilderExtensions
/// Registers the workflow as an AI agent in the dependency injection container.
/// </summary>
/// <param name="builder">The <see cref="IHostedWorkflowBuilder"/> instance to extend.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>An <see cref="IHostedAgentBuilder"/> that can be used to further configure the agent.</returns>
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder)
=> builder.AddAsAIAgent(name: null);
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Singleton)
=> builder.AddAsAIAgent(name: null, lifetime: lifetime);
/// <summary>
/// Registers the workflow as an AI agent in the dependency injection container.
/// </summary>
/// <param name="builder">The <see cref="IHostedWorkflowBuilder"/> instance to extend.</param>
/// <param name="name">The optional name for the AI agent. If not specified, the workflow name is used.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>An <see cref="IHostedAgentBuilder"/> that can be used to further configure the agent.</returns>
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, string? name)
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, string? name, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
var workflowName = builder.Name;
var agentName = name ?? workflowName;
return builder.HostApplicationBuilder.AddAIAgent(agentName, (sp, key) =>
sp.GetRequiredKeyedService<Workflow>(workflowName).AsAIAgent(name: key));
sp.GetRequiredKeyedService<Workflow>(workflowName).AsAIAgent(name: key), lifetime);
}
}
@@ -18,4 +18,9 @@ public interface IHostedAgentBuilder
/// Gets the service collection for configuration.
/// </summary>
IServiceCollection ServiceCollection { get; }
/// <summary>
/// Gets the DI service lifetime used for the agent registration.
/// </summary>
ServiceLifetime Lifetime { get; }
}
@@ -105,7 +105,7 @@ public class AgentHostingServiceCollectionExtensionsTests
}
/// <summary>
/// Verifies that AddAIAgent registers the agent as a keyed singleton service.
/// Verifies that AddAIAgent registers the agent as a keyed singleton service by default.
/// </summary>
[Fact]
public void AddAIAgent_RegistersKeyedSingleton()
@@ -203,4 +203,94 @@ public class AgentHostingServiceCollectionExtensionsTests
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
}
/// <summary>
/// Verifies that AddAIAgent registers with the specified scoped lifetime.
/// </summary>
[Fact]
public void AddAIAgent_WithScopedLifetime_RegistersKeyedScoped()
{
// Arrange
var services = new ServiceCollection();
var mockAgent = new Mock<AIAgent>();
const string AgentName = "scopedAgent";
// Act
var result = services.AddAIAgent(AgentName, (sp, key) => mockAgent.Object, ServiceLifetime.Scoped);
// Assert
var descriptor = services.FirstOrDefault(
d => (d.ServiceKey as string) == AgentName &&
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
Assert.Equal(ServiceLifetime.Scoped, result.Lifetime);
}
/// <summary>
/// Verifies that AddAIAgent registers with the specified transient lifetime.
/// </summary>
[Fact]
public void AddAIAgent_WithTransientLifetime_RegistersKeyedTransient()
{
// Arrange
var services = new ServiceCollection();
var mockAgent = new Mock<AIAgent>();
const string AgentName = "transientAgent";
// Act
var result = services.AddAIAgent(AgentName, (sp, key) => mockAgent.Object, ServiceLifetime.Transient);
// Assert
var descriptor = services.FirstOrDefault(
d => (d.ServiceKey as string) == AgentName &&
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
Assert.Equal(ServiceLifetime.Transient, result.Lifetime);
}
/// <summary>
/// Verifies that the builder exposes the correct lifetime for default registration.
/// </summary>
[Fact]
public void AddAIAgent_DefaultLifetime_BuilderExposesSingleton()
{
// Arrange
var services = new ServiceCollection();
var mockAgent = new Mock<AIAgent>();
// Act
var result = services.AddAIAgent("agentName", (sp, key) => mockAgent.Object);
// Assert
Assert.Equal(ServiceLifetime.Singleton, result.Lifetime);
}
/// <summary>
/// Verifies that AddAIAgent with instructions overload respects the lifetime parameter.
/// </summary>
[Theory]
[InlineData(ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Transient)]
public void AddAIAgent_InstructionsOverload_RespectsLifetime(ServiceLifetime lifetime)
{
// Arrange
var services = new ServiceCollection();
// Act
var result = services.AddAIAgent("agent", "instructions", lifetime);
// Assert
var descriptor = services.FirstOrDefault(
d => (d.ServiceKey as string) == "agent" &&
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
Assert.Equal(lifetime, descriptor.Lifetime);
Assert.Equal(lifetime, result.Lifetime);
}
}
@@ -127,7 +127,7 @@ public class HostApplicationBuilderAgentExtensionsTests
}
/// <summary>
/// Verifies that AddAIAgent registers the agent as a keyed singleton service.
/// Verifies that AddAIAgent registers the agent as a keyed singleton service by default.
/// </summary>
[Fact]
public void AddAIAgent_RegistersKeyedSingleton()
@@ -235,4 +235,77 @@ public class HostApplicationBuilderAgentExtensionsTests
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
}
/// <summary>
/// Verifies that AddAIAgent registers with the specified scoped lifetime via the host builder.
/// </summary>
[Fact]
public void AddAIAgent_WithScopedLifetime_RegistersKeyedScoped()
{
// Arrange
var builder = new HostApplicationBuilder();
var mockAgent = new Mock<AIAgent>();
const string AgentName = "scopedAgent";
// Act
var result = builder.AddAIAgent(AgentName, (sp, key) => mockAgent.Object, ServiceLifetime.Scoped);
// Assert
var descriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == AgentName &&
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
Assert.Equal(ServiceLifetime.Scoped, result.Lifetime);
}
/// <summary>
/// Verifies that AddAIAgent registers with the specified transient lifetime via the host builder.
/// </summary>
[Fact]
public void AddAIAgent_WithTransientLifetime_RegistersKeyedTransient()
{
// Arrange
var builder = new HostApplicationBuilder();
var mockAgent = new Mock<AIAgent>();
const string AgentName = "transientAgent";
// Act
var result = builder.AddAIAgent(AgentName, (sp, key) => mockAgent.Object, ServiceLifetime.Transient);
// Assert
var descriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == AgentName &&
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
Assert.Equal(ServiceLifetime.Transient, result.Lifetime);
}
/// <summary>
/// Verifies that AddAIAgent with instructions overload respects the lifetime parameter via the host builder.
/// </summary>
[Theory]
[InlineData(ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Transient)]
public void AddAIAgent_InstructionsOverload_RespectsLifetime(ServiceLifetime lifetime)
{
// Arrange
var builder = new HostApplicationBuilder();
// Act
var result = builder.AddAIAgent("agent", "instructions", lifetime);
// Assert
var descriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == "agent" &&
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
Assert.Equal(lifetime, descriptor.Lifetime);
Assert.Equal(lifetime, result.Lifetime);
}
}
@@ -63,7 +63,7 @@ public class HostApplicationBuilderWorkflowExtensionsTests
}
/// <summary>
/// Verifies that AddWorkflow registers the workflow as a keyed singleton service.
/// Verifies that AddWorkflow registers the workflow as a keyed singleton service by default.
/// </summary>
[Fact]
public void AddWorkflow_RegistersKeyedSingleton()
@@ -328,6 +328,77 @@ public class HostApplicationBuilderWorkflowExtensionsTests
Assert.NotNull(agentDescriptor);
}
/// <summary>
/// Verifies that AddWorkflow registers with the specified scoped lifetime.
/// </summary>
[Fact]
public void AddWorkflow_WithScopedLifetime_RegistersKeyedScoped()
{
// Arrange
var builder = new HostApplicationBuilder();
const string WorkflowName = "scopedWorkflow";
// Act
builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key), ServiceLifetime.Scoped);
// Assert
var descriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == WorkflowName &&
d.ServiceType == typeof(Workflow));
Assert.NotNull(descriptor);
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
}
/// <summary>
/// Verifies that AddWorkflow registers with the specified transient lifetime.
/// </summary>
[Fact]
public void AddWorkflow_WithTransientLifetime_RegistersKeyedTransient()
{
// Arrange
var builder = new HostApplicationBuilder();
const string WorkflowName = "transientWorkflow";
// Act
builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key), ServiceLifetime.Transient);
// Assert
var descriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == WorkflowName &&
d.ServiceType == typeof(Workflow));
Assert.NotNull(descriptor);
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
}
/// <summary>
/// Verifies that AddAsAIAgent respects the lifetime parameter.
/// </summary>
[Theory]
[InlineData(ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Transient)]
public void AddAsAIAgent_RespectsLifetime(ServiceLifetime lifetime)
{
// Arrange
var builder = new HostApplicationBuilder();
const string WorkflowName = "testWorkflow";
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
// Act
var agentBuilder = workflowBuilder.AddAsAIAgent("agent", lifetime);
// Assert
var descriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == "agent" &&
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
Assert.Equal(lifetime, descriptor.Lifetime);
Assert.Equal(lifetime, agentBuilder.Lifetime);
}
/// <summary>
/// Helper method to create a simple test workflow with a given name.
/// </summary>
@@ -7,6 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace Microsoft.Agents.AI.Hosting.UnitTests;
@@ -250,6 +251,179 @@ public sealed class HostedAgentBuilderToolsExtensionsTests
Assert.Contains(factoryTool, agentTools);
}
/// <summary>
/// Verifies that WithAITool factory method defaults to the agent's lifetime when no explicit lifetime is specified.
/// </summary>
[Theory]
[InlineData(ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Transient)]
public void WithAIToolFactory_DefaultsToAgentLifetime(ServiceLifetime agentLifetime)
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, agentLifetime);
// Act
builder.WithAITool(_ => new DummyAITool());
// Assert
var toolDescriptor = services.FirstOrDefault(
d => (d.ServiceKey as string) == "test-agent" &&
d.ServiceType == typeof(AITool));
Assert.NotNull(toolDescriptor);
Assert.Equal(agentLifetime, toolDescriptor.Lifetime);
}
/// <summary>
/// Verifies that WithAITool factory method accepts an explicit lifetime override.
/// </summary>
[Fact]
public void WithAIToolFactory_ExplicitLifetimeOverridesDefault()
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Transient);
// Act - Transient agent with Singleton tool is valid (longer-lived dependency)
builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Singleton);
// Assert
var toolDescriptor = services.FirstOrDefault(
d => (d.ServiceKey as string) == "test-agent" &&
d.ServiceType == typeof(AITool));
Assert.NotNull(toolDescriptor);
Assert.Equal(ServiceLifetime.Singleton, toolDescriptor.Lifetime);
}
/// <summary>
/// Verifies that WithAITool factory throws for singleton agent with scoped tool (captive dependency).
/// </summary>
[Fact]
public void WithAIToolFactory_SingletonAgentWithScopedTool_ThrowsInvalidOperationException()
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Singleton);
// Act & Assert
Assert.Throws<InvalidOperationException>(() =>
builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Scoped));
}
/// <summary>
/// Verifies that WithAITool factory throws for singleton agent with transient tool (captive dependency).
/// </summary>
[Fact]
public void WithAIToolFactory_SingletonAgentWithTransientTool_ThrowsInvalidOperationException()
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Singleton);
// Act & Assert
Assert.Throws<InvalidOperationException>(() =>
builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Transient));
}
/// <summary>
/// Verifies that WithAITool factory throws for scoped agent with transient tool (captive dependency).
/// </summary>
[Fact]
public void WithAIToolFactory_ScopedAgentWithTransientTool_ThrowsInvalidOperationException()
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Scoped);
// Act & Assert
Assert.Throws<InvalidOperationException>(() =>
builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Transient));
}
/// <summary>
/// Verifies all valid tool lifetime combinations do not throw.
/// </summary>
[Theory]
[InlineData(ServiceLifetime.Singleton, ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped, ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped, ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Transient, ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Transient, ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Transient, ServiceLifetime.Transient)]
public void WithAIToolFactory_ValidLifetimeCombinations_DoNotThrow(ServiceLifetime agentLifetime, ServiceLifetime toolLifetime)
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, agentLifetime);
// Act & Assert - should not throw
builder.WithAITool(_ => new DummyAITool(), toolLifetime);
}
/// <summary>
/// Verifies that ValidateToolLifetime correctly identifies all invalid combinations.
/// </summary>
[Theory]
[InlineData(ServiceLifetime.Singleton, ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Singleton, ServiceLifetime.Transient)]
[InlineData(ServiceLifetime.Scoped, ServiceLifetime.Transient)]
public void ValidateToolLifetime_InvalidCombinations_Throw(ServiceLifetime agentLifetime, ServiceLifetime toolLifetime)
{
// Act & Assert
Assert.Throws<InvalidOperationException>(() =>
HostedAgentBuilderExtensions.ValidateToolLifetime(agentLifetime, toolLifetime));
}
/// <summary>
/// Verifies that the WithSessionStore factory method defaults to Singleton regardless of agent lifetime.
/// </summary>
[Theory]
[InlineData(ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Transient)]
public void WithSessionStoreFactory_DefaultsToSingleton(ServiceLifetime agentLifetime)
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, agentLifetime);
// Act
builder.WithSessionStore((sp, name) => new InMemoryAgentSessionStore());
// Assert
var storeDescriptor = services.FirstOrDefault(
d => (d.ServiceKey as string) == "test-agent" &&
d.ServiceType == typeof(AgentSessionStore));
Assert.NotNull(storeDescriptor);
Assert.Equal(ServiceLifetime.Singleton, storeDescriptor.Lifetime);
}
/// <summary>
/// Verifies that the WithSessionStore factory method accepts an explicit lifetime override.
/// </summary>
[Fact]
public void WithSessionStoreFactory_ExplicitLifetimeOverridesDefault()
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Transient);
// Act
builder.WithSessionStore((sp, name) => new InMemoryAgentSessionStore(), ServiceLifetime.Singleton);
// Assert
var storeDescriptor = services.FirstOrDefault(
d => (d.ServiceKey as string) == "test-agent" &&
d.ServiceType == typeof(AgentSessionStore));
Assert.NotNull(storeDescriptor);
Assert.Equal(ServiceLifetime.Singleton, storeDescriptor.Lifetime);
}
/// <summary>
/// Dummy AITool implementation for testing.
/// </summary>