diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs index 733a7af9a7..03ec8cdadb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs @@ -19,9 +19,10 @@ public static class AgentHostingServiceCollectionExtensions /// The service collection to configure. /// The name of the agent. /// The instructions for the agent. + /// The DI service lifetime for the agent registration. Defaults to . /// The same instance so that additional calls can be chained. /// Thrown when or is . - 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 IHostedAgentBuilder AddAIAgent(this IServiceCollection services, s var chatClient = sp.GetRequiredService(); var tools = sp.GetKeyedServices(name).ToList(); return new ChatClientAgent(chatClient, instructions, key, tools: tools); - }); + }, lifetime); } /// @@ -40,9 +41,10 @@ public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, s /// The name of the agent. /// The instructions for the agent. /// The chat client which the agent will use for inference. + /// The DI service lifetime for the agent registration. Defaults to . /// The same instance so that additional calls can be chained. /// Thrown when or is . - 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 IHostedAgentBuilder AddAIAgent(this IServiceCollection services, s { var tools = sp.GetKeyedServices(name).ToList(); return new ChatClientAgent(chatClient, instructions, key, tools: tools); - }); + }, lifetime); } /// @@ -60,9 +62,10 @@ public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, s /// The name of the agent. /// The instructions for the agent. /// The key to use when resolving the chat client from the service provider. If , a non-keyed service will be resolved. + /// The DI service lifetime for the agent registration. Defaults to . /// The same instance so that additional calls can be chained. /// Thrown when or is . - 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 IHostedAgentBuilder AddAIAgent(this IServiceCollection services, s var chatClient = chatClientServiceKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(chatClientServiceKey); var tools = sp.GetKeyedServices(name).ToList(); return new ChatClientAgent(chatClient, instructions, key, tools: tools); - }); + }, lifetime); } /// @@ -82,9 +85,10 @@ public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, s /// The instructions for the agent. /// A description of the agent. /// The key to use when resolving the chat client from the service provider. If , a non-keyed service will be resolved. + /// The DI service lifetime for the agent registration. Defaults to . /// The same instance so that additional calls can be chained. /// Thrown when or is . - 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 IHostedAgentBuilder AddAIAgent(this IServiceCollection services, s var chatClient = chatClientServiceKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(chatClientServiceKey); var tools = sp.GetKeyedServices(name).ToList(); return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description, tools: tools); - }); + }, lifetime); } /// @@ -102,15 +106,16 @@ public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, s /// The service collection to configure. /// The name of the agent. /// A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters. + /// The DI service lifetime for the agent registration. Defaults to . /// The same instance so that additional calls can be chained. /// Thrown when , , or is . /// Thrown when the agent factory delegate returns or an agent whose does not match . - public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, Func createAgentDelegate) + public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, Func 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 IHostedAgentBuilder AddAIAgent(this IServiceCollection services, s } return agent; - }); + }, lifetime); - return new HostedAgentBuilder(name, services); + return new HostedAgentBuilder(name, services, lifetime); + } + + /// + /// Registers a keyed service with the specified lifetime. + /// + internal static void AddKeyedService(this IServiceCollection services, object? serviceKey, Func factory, ServiceLifetime lifetime) + where T : class + { + var descriptor = new ServiceDescriptor(typeof(T), serviceKey, (sp, key) => factory(sp, key), lifetime); + services.Add(descriptor); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs index 434024866a..2d8620611a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs @@ -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 /// The host application builder to configure. /// The name of the agent. /// The instructions for the agent. + /// The DI service lifetime for the agent registration. Defaults to . /// The configured host application builder. /// Thrown when , , or is null. - 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); } /// @@ -33,13 +35,14 @@ public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builde /// The name of the agent. /// The instructions for the agent. /// The chat client which the agent will use for inference. + /// The DI service lifetime for the agent registration. Defaults to . /// The configured host application builder. /// Thrown when , , or is null. - 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); } /// @@ -50,13 +53,14 @@ public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builde /// The instructions for the agent. /// A description of the agent. /// The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved. + /// The DI service lifetime for the agent registration. Defaults to . /// The configured host application builder. /// Thrown when , , or is null. - 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); } /// @@ -66,12 +70,13 @@ public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builde /// The name of the agent. /// The instructions for the agent. /// The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved. + /// The DI service lifetime for the agent registration. Defaults to . /// The configured host application builder. /// Thrown when , , or is null. - 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); } /// @@ -80,12 +85,13 @@ public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builde /// The host application builder to configure. /// The name of the agent. /// A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters. + /// The DI service lifetime for the agent registration. Defaults to . /// The configured host application builder. /// Thrown when , , or is null. /// Thrown when the agent factory delegate returns null or an invalid AI agent instance. - public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func createAgentDelegate) + public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func createAgentDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton) { Throw.IfNull(builder); - return builder.Services.AddAIAgent(name, createAgentDelegate); + return builder.Services.AddAIAgent(name, createAgentDelegate, lifetime); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs index 8075caec59..cbefe94f1f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs @@ -19,19 +19,20 @@ public static class HostApplicationBuilderWorkflowExtensions /// The to configure. /// The unique name for the workflow. /// A factory function that creates the instance. The function receives the service provider and workflow name as parameters. + /// The DI service lifetime for the workflow registration. Defaults to . /// An that can be used to further configure the workflow. /// Thrown when , , or is null. /// Thrown when is empty. /// /// Thrown when the factory delegate returns null or a workflow with a name that doesn't match the expected name. /// - public static IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder builder, string name, Func createWorkflowDelegate) + public static IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder builder, string name, Func 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 IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder bu } return workflow; - }); + }, lifetime); return new HostedWorkflowBuilder(name, builder); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilder.cs index 89bf096b62..2d2d9bc5ed 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilder.cs @@ -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; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs index 12c1e08dfd..d1397fcda4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs @@ -42,17 +42,19 @@ public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder buil /// The host agent builder to configure. /// A factory function that creates an agent session store instance using the provided service provider and agent /// name. + /// The DI service lifetime for the session store registration. Defaults to + /// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime. /// The same host agent builder instance, enabling further configuration. - public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func createAgentSessionStore) + public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func 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 IHostedAgentBuilder WithAITools(this IHostedAgentBuilder builder, /// /// The hosted agent builder. /// A factory function that creates a AI tool using the provided service provider. - public static IHostedAgentBuilder WithAITool(this IHostedAgentBuilder builder, Func factory) + /// The DI service lifetime for the tool registration. If , the agent's lifetime is used. + /// The same instance so that additional calls can be chained. + /// Thrown when or is . + /// + /// 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. + /// + public static IHostedAgentBuilder WithAITool(this IHostedAgentBuilder builder, Func 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; } + + /// + /// 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. + /// + 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."); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs index f01a12c7ea..abee1cb566 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs @@ -14,22 +14,24 @@ public static class HostedWorkflowBuilderExtensions /// Registers the workflow as an AI agent in the dependency injection container. /// /// The instance to extend. + /// The DI service lifetime for the agent registration. Defaults to . /// An that can be used to further configure the agent. - 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); /// /// Registers the workflow as an AI agent in the dependency injection container. /// /// The instance to extend. /// The optional name for the AI agent. If not specified, the workflow name is used. + /// The DI service lifetime for the agent registration. Defaults to . /// An that can be used to further configure the agent. - 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(workflowName).AsAIAgent(name: key)); + sp.GetRequiredKeyedService(workflowName).AsAIAgent(name: key), lifetime); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedAgentBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedAgentBuilder.cs index f67f4eb7cd..0751ba630b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedAgentBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedAgentBuilder.cs @@ -18,4 +18,9 @@ public interface IHostedAgentBuilder /// Gets the service collection for configuration. /// IServiceCollection ServiceCollection { get; } + + /// + /// Gets the DI service lifetime used for the agent registration. + /// + ServiceLifetime Lifetime { get; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs index 03ab65c9f2..4d0a829933 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs @@ -105,7 +105,7 @@ public void AddAIAgentWithFactory_ValidParameters_ReturnsBuilder() } /// - /// Verifies that AddAIAgent registers the agent as a keyed singleton service. + /// Verifies that AddAIAgent registers the agent as a keyed singleton service by default. /// [Fact] public void AddAIAgent_RegistersKeyedSingleton() @@ -203,4 +203,94 @@ public void AddAIAgent_ValidSpecialCharactersInName_Succeeds(string name) d.ServiceType == typeof(AIAgent)); Assert.NotNull(descriptor); } + + /// + /// Verifies that AddAIAgent registers with the specified scoped lifetime. + /// + [Fact] + public void AddAIAgent_WithScopedLifetime_RegistersKeyedScoped() + { + // Arrange + var services = new ServiceCollection(); + var mockAgent = new Mock(); + 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); + } + + /// + /// Verifies that AddAIAgent registers with the specified transient lifetime. + /// + [Fact] + public void AddAIAgent_WithTransientLifetime_RegistersKeyedTransient() + { + // Arrange + var services = new ServiceCollection(); + var mockAgent = new Mock(); + 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); + } + + /// + /// Verifies that the builder exposes the correct lifetime for default registration. + /// + [Fact] + public void AddAIAgent_DefaultLifetime_BuilderExposesSingleton() + { + // Arrange + var services = new ServiceCollection(); + var mockAgent = new Mock(); + + // Act + var result = services.AddAIAgent("agentName", (sp, key) => mockAgent.Object); + + // Assert + Assert.Equal(ServiceLifetime.Singleton, result.Lifetime); + } + + /// + /// Verifies that AddAIAgent with instructions overload respects the lifetime parameter. + /// + [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); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs index 0036a60cc7..f80d2b7c32 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs @@ -127,7 +127,7 @@ public void AddAIAgentWithFactory_ValidParameters_ReturnsBuilder() } /// - /// Verifies that AddAIAgent registers the agent as a keyed singleton service. + /// Verifies that AddAIAgent registers the agent as a keyed singleton service by default. /// [Fact] public void AddAIAgent_RegistersKeyedSingleton() @@ -235,4 +235,77 @@ public void AddAIAgent_ValidSpecialCharactersInName_Succeeds(string name) d.ServiceType == typeof(AIAgent)); Assert.NotNull(descriptor); } + + /// + /// Verifies that AddAIAgent registers with the specified scoped lifetime via the host builder. + /// + [Fact] + public void AddAIAgent_WithScopedLifetime_RegistersKeyedScoped() + { + // Arrange + var builder = new HostApplicationBuilder(); + var mockAgent = new Mock(); + 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); + } + + /// + /// Verifies that AddAIAgent registers with the specified transient lifetime via the host builder. + /// + [Fact] + public void AddAIAgent_WithTransientLifetime_RegistersKeyedTransient() + { + // Arrange + var builder = new HostApplicationBuilder(); + var mockAgent = new Mock(); + 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); + } + + /// + /// Verifies that AddAIAgent with instructions overload respects the lifetime parameter via the host builder. + /// + [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); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs index d27b9a17e3..1c5649d17c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs @@ -63,7 +63,7 @@ public void AddWorkflow_ValidParameters_ReturnsBuilder() } /// - /// Verifies that AddWorkflow registers the workflow as a keyed singleton service. + /// Verifies that AddWorkflow registers the workflow as a keyed singleton service by default. /// [Fact] public void AddWorkflow_RegistersKeyedSingleton() @@ -328,6 +328,77 @@ public void AddAsAIAgent_WithEmptyName_UsesEmptyStringAsAgentName() Assert.NotNull(agentDescriptor); } + /// + /// Verifies that AddWorkflow registers with the specified scoped lifetime. + /// + [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); + } + + /// + /// Verifies that AddWorkflow registers with the specified transient lifetime. + /// + [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); + } + + /// + /// Verifies that AddAsAIAgent respects the lifetime parameter. + /// + [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); + } + /// /// Helper method to create a simple test workflow with a given name. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs index 28b621714f..eb482964b0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs @@ -7,6 +7,7 @@ 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 void WithAIToolFactory_ToolsAvailableOnAgent() Assert.Contains(factoryTool, agentTools); } + /// + /// Verifies that WithAITool factory method defaults to the agent's lifetime when no explicit lifetime is specified. + /// + [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().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); + } + + /// + /// Verifies that WithAITool factory method accepts an explicit lifetime override. + /// + [Fact] + public void WithAIToolFactory_ExplicitLifetimeOverridesDefault() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock().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); + } + + /// + /// Verifies that WithAITool factory throws for singleton agent with scoped tool (captive dependency). + /// + [Fact] + public void WithAIToolFactory_SingletonAgentWithScopedTool_ThrowsInvalidOperationException() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock().Object, ServiceLifetime.Singleton); + + // Act & Assert + Assert.Throws(() => + builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Scoped)); + } + + /// + /// Verifies that WithAITool factory throws for singleton agent with transient tool (captive dependency). + /// + [Fact] + public void WithAIToolFactory_SingletonAgentWithTransientTool_ThrowsInvalidOperationException() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock().Object, ServiceLifetime.Singleton); + + // Act & Assert + Assert.Throws(() => + builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Transient)); + } + + /// + /// Verifies that WithAITool factory throws for scoped agent with transient tool (captive dependency). + /// + [Fact] + public void WithAIToolFactory_ScopedAgentWithTransientTool_ThrowsInvalidOperationException() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock().Object, ServiceLifetime.Scoped); + + // Act & Assert + Assert.Throws(() => + builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Transient)); + } + + /// + /// Verifies all valid tool lifetime combinations do not throw. + /// + [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().Object, agentLifetime); + + // Act & Assert - should not throw + builder.WithAITool(_ => new DummyAITool(), toolLifetime); + } + + /// + /// Verifies that ValidateToolLifetime correctly identifies all invalid combinations. + /// + [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(() => + HostedAgentBuilderExtensions.ValidateToolLifetime(agentLifetime, toolLifetime)); + } + + /// + /// Verifies that the WithSessionStore factory method defaults to Singleton regardless of agent lifetime. + /// + [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().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); + } + + /// + /// Verifies that the WithSessionStore factory method accepts an explicit lifetime override. + /// + [Fact] + public void WithSessionStoreFactory_ExplicitLifetimeOverridesDefault() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock().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); + } + /// /// Dummy AITool implementation for testing. ///