diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index d7fbe3eda0..58fe403c8d 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -66,6 +66,25 @@ builder.AddAIAgent("knights-and-knaves", (sp, key) => #pragma warning restore VSTHRD002 }); +// Workflow consisting of multiple specialized agents +var chemistryAgent = builder.AddAIAgent("chemist", + instructions: "You are a chemistry expert. Answer thinking from the chemistry perspective", + description: "An agent that helps with chemistry.", + chatClientServiceKey: "chat-model"); + +var mathsAgent = builder.AddAIAgent("mathematician", + instructions: "You are a mathematics expert. Answer thinking from the maths perspective", + description: "An agent that helps with mathematics.", + chatClientServiceKey: "chat-model"); + +var literatureAgent = builder.AddAIAgent("literator", + instructions: "You are a literature expert. Answer thinking from the literature perspective", + description: "An agent that helps with literature.", + chatClientServiceKey: "chat-model"); + +builder.AddSequentialWorkflow("science-sequential-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent(); +builder.AddConcurrentWorkflow("science-concurrent-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent(); + var app = builder.Build(); app.MapOpenApi(); @@ -92,6 +111,10 @@ app.MapOpenAIResponses("knights-and-knaves"); app.MapOpenAIChatCompletions("pirate"); app.MapOpenAIChatCompletions("knights-and-knaves"); +// workflow-agents +app.MapOpenAIResponses("science-sequential-workflow"); +app.MapOpenAIResponses("science-concurrent-workflow"); + // Map the agents HTTP endpoints app.MapAgentDiscovery("/agents"); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs index 6d5f5283fe..d45046a8de 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 System.Linq; +using Microsoft.Agents.AI.Hosting.Local; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -22,7 +23,7 @@ public static class HostApplicationBuilderAgentExtensions /// The instructions for the agent. /// The configured host application builder. /// Thrown when , , or is null. - public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions) + public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions) { Throw.IfNull(builder); Throw.IfNullOrEmpty(name); @@ -38,7 +39,7 @@ public static class HostApplicationBuilderAgentExtensions /// The chat client which the agent will use for inference. /// The configured host application builder. /// Thrown when , , or is null. - public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, IChatClient chatClient) + public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, IChatClient chatClient) { Throw.IfNull(builder); Throw.IfNullOrEmpty(name); @@ -55,7 +56,7 @@ public static class HostApplicationBuilderAgentExtensions /// The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved. /// The configured host application builder. /// Thrown when , , or is null. - public static IHostApplicationBuilder 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) { Throw.IfNull(builder); Throw.IfNullOrEmpty(name); @@ -75,7 +76,7 @@ public static class HostApplicationBuilderAgentExtensions /// The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved. /// The configured host application builder. /// Thrown when , , or is null. - public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey) + public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey) { Throw.IfNull(builder); Throw.IfNullOrEmpty(name); @@ -95,7 +96,7 @@ public static class HostApplicationBuilderAgentExtensions /// 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 IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func createAgentDelegate) + public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func createAgentDelegate) { Throw.IfNull(builder); Throw.IfNull(name); @@ -117,7 +118,8 @@ public static class HostApplicationBuilderAgentExtensions // Register the agent by name for discovery. var agentHostBuilder = GetAgentRegistry(builder); agentHostBuilder.AgentNames.Add(name); - return builder; + + return new HostedAgentBuilder(name, builder); } private static LocalAgentRegistry GetAgentRegistry(IHostApplicationBuilder builder) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs new file mode 100644 index 0000000000..ac78877682 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Agents.AI.Hosting.Local; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Provides extension methods for configuring AI workflows in a host application builder. +/// +public static class HostApplicationBuilderWorkflowExtensions +{ + /// + /// Registers a concurrent workflow that executes multiple agents in parallel. + /// + /// The to configure. + /// The unique name for the workflow. + /// A collection of instances representing agents to execute concurrently. + /// An that can be used to further configure the workflow. + /// Thrown when , , or is null. + /// Thrown when or is empty. + public static IHostedWorkflowBuilder AddConcurrentWorkflow(this IHostApplicationBuilder builder, string name, IEnumerable agentBuilders) + { + Throw.IfNullOrEmpty(agentBuilders); + + return builder.AddWorkflow(name, (sp, key) => + { + var agents = agentBuilders.Select(ab => sp.GetRequiredKeyedService(ab.Name)); + return AgentWorkflowBuilder.BuildConcurrent(workflowName: name, agents: agents); + }); + } + + /// + /// Registers a sequential workflow that executes agents in a specific order. + /// + /// The to configure. + /// The unique name for the workflow. + /// A collection of instances representing agents to execute in sequence. + /// An that can be used to further configure the workflow. + /// Thrown when , , or is null. + /// Thrown when or is empty. + public static IHostedWorkflowBuilder AddSequentialWorkflow(this IHostApplicationBuilder builder, string name, IEnumerable agentBuilders) + { + Throw.IfNullOrEmpty(agentBuilders); + + return builder.AddWorkflow(name, (sp, key) => + { + var agents = agentBuilders.Select(ab => sp.GetRequiredKeyedService(ab.Name)); + return AgentWorkflowBuilder.BuildSequential(workflowName: name, agents: agents); + }); + } + + /// + /// Registers a custom workflow using a factory delegate. + /// + /// 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. + /// 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) + { + Throw.IfNull(builder); + Throw.IfNull(name); + Throw.IfNull(createWorkflowDelegate); + + builder.Services.AddKeyedSingleton(name, (sp, key) => + { + Throw.IfNull(key); + var keyString = key as string; + Throw.IfNullOrEmpty(keyString); + var workflow = createWorkflowDelegate(sp, keyString) ?? throw new InvalidOperationException($"The agent factory did not return a valid {nameof(Workflow)} instance for key '{keyString}'."); + if (!string.Equals(workflow.Name, keyString, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"The workflow factory returned workflow with name '{workflow.Name}', but the expected name is '{keyString}'."); + } + + return workflow; + }); + + // Register the workflow by name for discovery. + var workflowRegistry = GetWorkflowRegistry(builder); + workflowRegistry.WorkflowNames.Add(name); + + return new HostedWorkflowBuilder(name, builder); + } + + private static LocalWorkflowRegistry GetWorkflowRegistry(IHostApplicationBuilder builder) + { + var descriptor = builder.Services.FirstOrDefault(s => !s.IsKeyedService && s.ServiceType.Equals(typeof(LocalWorkflowRegistry))); + if (descriptor?.ImplementationInstance is not LocalWorkflowRegistry instance) + { + instance = new LocalWorkflowRegistry(); + ConfigureHostBuilder(builder, instance); + } + + return instance; + } + + private static void ConfigureHostBuilder(IHostApplicationBuilder builder, LocalWorkflowRegistry agentHostBuilderContext) + { + builder.Services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext)); + builder.Services.AddSingleton(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilder.cs new file mode 100644 index 0000000000..82e0997c7a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilder.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting; + +internal sealed class HostedAgentBuilder : IHostedAgentBuilder +{ + public string Name { get; } + public IHostApplicationBuilder HostApplicationBuilder { get; } + + public HostedAgentBuilder(string name, IHostApplicationBuilder hostApplicationBuilder) + { + this.Name = name; + this.HostApplicationBuilder = hostApplicationBuilder; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilder.cs new file mode 100644 index 0000000000..e1d87a3836 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilder.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting; + +internal sealed class HostedWorkflowBuilder : IHostedWorkflowBuilder +{ + public string Name { get; } + public IHostApplicationBuilder HostApplicationBuilder { get; } + + public HostedWorkflowBuilder(string name, IHostApplicationBuilder hostApplicationBuilder) + { + this.Name = name; + this.HostApplicationBuilder = hostApplicationBuilder; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs new file mode 100644 index 0000000000..26104c9a57 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Provides extension methods for to enable additional workflow configuration scenarios. +/// +public static class HostedWorkflowBuilderExtensions +{ + /// + /// Registers the workflow as an AI agent in the dependency injection container. + /// + /// The instance to extend. + /// An that can be used to further configure the agent. + public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder) + => builder.AddAsAIAgent(name: null); + + /// + /// 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. + /// An that can be used to further configure the agent. + public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, string? name) + { + var agentName = name ?? builder.Name; + return builder.HostApplicationBuilder.AddAIAgent(agentName, (sp, key) => + { + var workflow = sp.GetRequiredKeyedService(key); +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + return workflow.AsAgentAsync(name: key).AsTask().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + }); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedAgentBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedAgentBuilder.cs new file mode 100644 index 0000000000..14070bb671 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedAgentBuilder.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Represents a builder for configuring AI agents within a hosting environment. +/// +public interface IHostedAgentBuilder +{ + /// + /// Gets the name of the agent being configured. + /// + string Name { get; } + + /// + /// Gets the application host builder for configuring additional services. + /// + IHostApplicationBuilder HostApplicationBuilder { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedWorkflowBuilder.cs new file mode 100644 index 0000000000..405172ffe5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedWorkflowBuilder.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Represents a builder for configuring workflows within a hosting environment. +/// +public interface IHostedWorkflowBuilder +{ + /// + /// Gets the name of the workflow being configured. + /// + string Name { get; } + + /// + /// Gets the application host builder for configuring additional services. + /// + IHostApplicationBuilder HostApplicationBuilder { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentCatalog.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentCatalog.cs similarity index 96% rename from dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentCatalog.cs rename to dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentCatalog.cs index 8d36c16e7c..0b44ad60cb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentCatalog.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentCatalog.cs @@ -7,7 +7,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; -namespace Microsoft.Agents.AI.Hosting; +namespace Microsoft.Agents.AI.Hosting.Local; // Implementation of an AgentCatalog which enumerates agents registered in the local service provider. internal sealed class LocalAgentCatalog : AgentCatalog diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentRegistry.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentRegistry.cs similarity index 80% rename from dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentRegistry.cs rename to dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentRegistry.cs index 712634a2ba..df3db8f554 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentRegistry.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentRegistry.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; -namespace Microsoft.Agents.AI.Hosting; +namespace Microsoft.Agents.AI.Hosting.Local; internal sealed class LocalAgentRegistry { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowCatalog.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowCatalog.cs new file mode 100644 index 0000000000..572b41830e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowCatalog.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.Local; + +internal sealed class LocalWorkflowCatalog : WorkflowCatalog +{ + public readonly HashSet _registeredWorkflows; + private readonly IServiceProvider _serviceProvider; + + public LocalWorkflowCatalog(LocalWorkflowRegistry workflowRegistry, IServiceProvider serviceProvider) + { + this._registeredWorkflows = [.. workflowRegistry.WorkflowNames]; + this._serviceProvider = serviceProvider; + } + + public override async IAsyncEnumerable GetWorkflowsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask.ConfigureAwait(false); + + foreach (var name in this._registeredWorkflows) + { + var workflow = this._serviceProvider.GetKeyedService(name); + if (workflow is not null) + { + yield return workflow; + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowRegistry.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowRegistry.cs new file mode 100644 index 0000000000..803c24660f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowRegistry.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Hosting.Local; + +internal sealed class LocalWorkflowRegistry +{ + public HashSet WorkflowNames { get; } = []; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj index 1cec665588..86f709877d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj @@ -16,6 +16,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/WorkflowCatalog.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/WorkflowCatalog.cs new file mode 100644 index 0000000000..47e09afa8e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/WorkflowCatalog.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Provides a catalog of registered workflows within the hosting environment. +/// +public abstract class WorkflowCatalog +{ + /// + /// Initializes a new instance of the class. + /// + protected WorkflowCatalog() + { + } + + /// + /// Asynchronously retrieves all registered workflows from the catalog. + /// + /// The to monitor for cancellation requests. The default is . + public abstract IAsyncEnumerable GetWorkflowsAsync(CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs index ed91c14e5f..47d0aee346 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs @@ -26,6 +26,18 @@ public static partial class AgentWorkflowBuilder /// The sequence of agents to compose into a sequential workflow. /// The built workflow composed of the supplied , in the order in which they were yielded from the source. public static Workflow BuildSequential(params IEnumerable agents) + => BuildSequentialCore(workflowName: null, agents); + + /// + /// Builds a composed of a pipeline of agents where the output of one agent is the input to the next. + /// + /// The name of workflow. + /// The sequence of agents to compose into a sequential workflow. + /// The built workflow composed of the supplied , in the order in which they were yielded from the source. + public static Workflow BuildSequential(string workflowName, params IEnumerable agents) + => BuildSequentialCore(workflowName, agents); + + private static Workflow BuildSequentialCore(string? workflowName, params IEnumerable agents) { Throw.IfNull(agents); @@ -60,9 +72,12 @@ public static partial class AgentWorkflowBuilder Debug.Assert(builder is not null); OutputMessagesExecutor end = new(); - return builder.AddEdge(previous, end) - .WithOutputFrom(end) - .Build(); + builder = builder.AddEdge(previous, end).WithOutputFrom(end); + if (workflowName is not null) + { + builder = builder.WithName(workflowName); + } + return builder.Build(); } /// @@ -79,6 +94,30 @@ public static partial class AgentWorkflowBuilder public static Workflow BuildConcurrent( IEnumerable agents, Func>, List>? aggregator = null) + => BuildConcurrentCore(workflowName: null, agents, aggregator); + + /// + /// Builds a composed of agents that operate concurrently on the same input, + /// aggregating their outputs into a single collection. + /// + /// The name of the workflow. + /// The set of agents to compose into a concurrent workflow. + /// + /// The aggregation function that accepts a list of the output messages from each and produces + /// a single result list. If , the default behavior is to return a list containing the last message + /// from each agent that produced at least one message. + /// + /// The built workflow composed of the supplied concurrent . + public static Workflow BuildConcurrent( + string workflowName, + IEnumerable agents, + Func>, List>? aggregator = null) + => BuildConcurrentCore(workflowName, agents, aggregator); + + private static Workflow BuildConcurrentCore( + string? workflowName, + IEnumerable agents, + Func>, List>? aggregator = null) { Throw.IfNull(agents); @@ -105,7 +144,12 @@ public static partial class AgentWorkflowBuilder ConcurrentEndExecutor end = new(agentExecutors.Length, aggregator); builder.AddFanInEdge(end, sources: accumulators); - return builder.WithOutputFrom(end).Build(); + builder = builder.WithOutputFrom(end); + if (workflowName is not null) + { + builder = builder.WithName(workflowName); + } + return builder.Build(); } /// Creates a new using as the starting agent in the workflow. @@ -536,57 +580,57 @@ public static partial class AgentWorkflowBuilder protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddHandler(async (handoffState, context, cancellationToken) => + { + string? requestedHandoff = null; + List updates = []; + List allMessages = handoffState.Messages; + + List? roleChanges = ChangeAssistantToUserForOtherParticipants(this._agent.DisplayName, allMessages); + + await foreach (var update in this._agent.RunStreamingAsync(allMessages, + options: this._agentOptions, + cancellationToken: cancellationToken) + .ConfigureAwait(false)) { - string? requestedHandoff = null; - List updates = []; - List allMessages = handoffState.Messages; + await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false); - List? roleChanges = ChangeAssistantToUserForOtherParticipants(this._agent.DisplayName, allMessages); - - await foreach (var update in this._agent.RunStreamingAsync(allMessages, - options: this._agentOptions, - cancellationToken: cancellationToken) - .ConfigureAwait(false)) + foreach (var c in update.Contents) { - await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false); - - foreach (var c in update.Contents) + if (c is FunctionCallContent fcc && this._handoffFunctionNames.Contains(fcc.Name)) { - if (c is FunctionCallContent fcc && this._handoffFunctionNames.Contains(fcc.Name)) - { - requestedHandoff = fcc.Name; - await AddUpdateAsync( - new AgentRunResponseUpdate - { - AgentId = this._agent.Id, - AuthorName = this._agent.DisplayName, - Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")], - CreatedAt = DateTimeOffset.UtcNow, - MessageId = Guid.NewGuid().ToString("N"), - Role = ChatRole.Tool, - }, - cancellationToken - ) - .ConfigureAwait(false); - } + requestedHandoff = fcc.Name; + await AddUpdateAsync( + new AgentRunResponseUpdate + { + AgentId = this._agent.Id, + AuthorName = this._agent.DisplayName, + Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")], + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + Role = ChatRole.Tool, + }, + cancellationToken + ) + .ConfigureAwait(false); } } + } - allMessages.AddRange(updates.ToAgentRunResponse().Messages); + allMessages.AddRange(updates.ToAgentRunResponse().Messages); - ResetUserToAssistantForChangedRoles(roleChanges); + ResetUserToAssistantForChangedRoles(roleChanges); - await context.SendMessageAsync(new HandoffState(handoffState.TurnToken, requestedHandoff, allMessages), cancellationToken: cancellationToken).ConfigureAwait(false); + await context.SendMessageAsync(new HandoffState(handoffState.TurnToken, requestedHandoff, allMessages), cancellationToken: cancellationToken).ConfigureAwait(false); - async Task AddUpdateAsync(AgentRunResponseUpdate update, CancellationToken cancellationToken) + async Task AddUpdateAsync(AgentRunResponseUpdate update, CancellationToken cancellationToken) + { + updates.Add(update); + if (handoffState.TurnToken.EmitEvents is true) { - updates.Add(update); - if (handoffState.TurnToken.EmitEvents is true) - { - await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false); - } + await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false); } - }); + } + }); public ValueTask ResetAsync() => default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs index c9a70cc189..a29a6208f9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs @@ -19,25 +19,6 @@ public class HostApplicationBuilderAgentExtensionsTests Assert.Throws( () => HostApplicationBuilderAgentExtensions.AddAIAgent(null!, "agent", "instructions")); - /// - /// Verifies that AddAIAgent with valid parameters returns the same builder instance. - /// - /// The chat client key to use, or null to use the default service. - [Theory] - [InlineData(null)] - [InlineData("customKey")] - public void AddAIAgent_ValidParameters_ReturnsBuilder(string? chatClientKey) - { - // Arrange - var builder = new HostApplicationBuilder(); - - // Act - var result = builder.AddAIAgent("agentName", "instructions", chatClientKey); - - // Assert - Assert.Same(builder, result); - } - /// /// Verifies that AddAIAgent without chat client key throws ArgumentNullException for null name. /// @@ -59,14 +40,9 @@ public class HostApplicationBuilderAgentExtensionsTests [Fact] public void AddAIAgent_NullInstructions_AllowsNull() { - // Arrange var builder = new HostApplicationBuilder(); - - // Act var result = builder.AddAIAgent("agentName", (string)null!); - - // Assert - Assert.Same(builder, result); + Assert.NotNull(result); } /// @@ -90,14 +66,9 @@ public class HostApplicationBuilderAgentExtensionsTests [Fact] public void AddAIAgentWithKey_NullInstructions_AllowsNull() { - // Arrange var builder = new HostApplicationBuilder(); - - // Act var result = builder.AddAIAgent("agentName", null!, "key"); - - // Assert - Assert.Same(builder, result); + Assert.NotNull(result); } /// @@ -148,15 +119,11 @@ public class HostApplicationBuilderAgentExtensionsTests [Fact] public void AddAIAgentWithFactory_ValidParameters_ReturnsBuilder() { - // Arrange var builder = new HostApplicationBuilder(); var mockAgent = new Mock(); - - // Act var result = builder.AddAIAgent("agentName", (sp, key) => mockAgent.Object); - // Assert - Assert.Same(builder, result); + Assert.NotNull(result); } /// @@ -192,9 +159,9 @@ public class HostApplicationBuilderAgentExtensionsTests var builder = new HostApplicationBuilder(); // Act - builder.AddAIAgent("agent1", "instructions1") - .AddAIAgent("agent2", "instructions2") - .AddAIAgent("agent3", "instructions3"); + builder.AddAIAgent("agent1", "instructions1"); + builder.AddAIAgent("agent2", "instructions2"); + builder.AddAIAgent("agent3", "instructions3"); // Assert var agentDescriptors = builder.Services @@ -227,14 +194,9 @@ public class HostApplicationBuilderAgentExtensionsTests [Fact] public void AddAIAgent_EmptyInstructions_Succeeds() { - // Arrange var builder = new HostApplicationBuilder(); - - // Act var result = builder.AddAIAgent("agentName", ""); - - // Assert - Assert.Same(builder, result); + Assert.NotNull(result); } /// /// Verifies that AddAIAgent without chat client key calls the overload with null key. @@ -242,14 +204,9 @@ public class HostApplicationBuilderAgentExtensionsTests [Fact] public void AddAIAgent_WithoutKey_CallsOverloadWithNullKey() { - // Arrange var builder = new HostApplicationBuilder(); - - // Act var result = builder.AddAIAgent("agentName", "instructions"); - // Assert - Assert.Same(builder, result); // The agent should be registered (proving the method chain worked) var descriptor = builder.Services.FirstOrDefault( d => d.ServiceKey is "agentName" && @@ -270,14 +227,9 @@ public class HostApplicationBuilderAgentExtensionsTests [InlineData("my.agent_1:type-name")] // complex valid name public void AddAIAgent_ValidSpecialCharactersInName_Succeeds(string name) { - // Arrange var builder = new HostApplicationBuilder(); - - // Act var result = builder.AddAIAgent(name, "instructions"); - // Assert - Assert.Same(builder, result); var descriptor = builder.Services.FirstOrDefault( d => (d.ServiceKey as string) == name && d.ServiceType == typeof(AIAgent)); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs new file mode 100644 index 0000000000..6c4250943e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs @@ -0,0 +1,247 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +public class HostApplicationBuilderWorkflowExtensionsTests +{ + /// + /// Verifies that providing a null builder to AddWorkflow throws an ArgumentNullException. + /// + [Fact] + public void AddWorkflow_NullBuilder_ThrowsArgumentNullException() => + Assert.Throws( + () => HostApplicationBuilderWorkflowExtensions.AddWorkflow( + null!, + "workflow", + (sp, key) => CreateTestWorkflow(key))); + + /// + /// Verifies that AddWorkflow throws ArgumentNullException for null name. + /// + [Fact] + public void AddWorkflow_NullName_ThrowsArgumentNullException() + { + var builder = new HostApplicationBuilder(); + + var exception = Assert.Throws(() => + builder.AddWorkflow(null!, (sp, key) => CreateTestWorkflow(key))); + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verifies that AddWorkflow throws ArgumentNullException for null factory delegate. + /// + [Fact] + public void AddWorkflow_NullFactory_ThrowsArgumentNullException() + { + var builder = new HostApplicationBuilder(); + + var exception = Assert.Throws(() => + builder.AddWorkflow("workflowName", null!)); + Assert.Equal("createWorkflowDelegate", exception.ParamName); + } + + /// + /// Verifies that AddWorkflow returns the IHostWorkflowBuilder instance. + /// + [Fact] + public void AddWorkflow_ValidParameters_ReturnsBuilder() + { + var builder = new HostApplicationBuilder(); + + var result = builder.AddWorkflow("workflowName", (sp, key) => CreateTestWorkflow(key)); + + Assert.NotNull(result); + Assert.IsAssignableFrom(result); + } + + /// + /// Verifies that AddWorkflow registers the workflow as a keyed singleton service. + /// + [Fact] + public void AddWorkflow_RegistersKeyedSingleton() + { + var builder = new HostApplicationBuilder(); + const string WorkflowName = "testWorkflow"; + + builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key)); + + var descriptor = builder.Services.FirstOrDefault( + d => (d.ServiceKey as string) == WorkflowName && + d.ServiceType == typeof(Workflow)); + + Assert.NotNull(descriptor); + Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime); + } + + /// + /// Verifies that AddWorkflow can be called multiple times with different workflow names. + /// + [Fact] + public void AddWorkflow_MultipleCalls_RegistersMultipleWorkflows() + { + var builder = new HostApplicationBuilder(); + + builder.AddWorkflow("workflow1", (sp, key) => CreateTestWorkflow(key)); + builder.AddWorkflow("workflow2", (sp, key) => CreateTestWorkflow(key)); + builder.AddWorkflow("workflow3", (sp, key) => CreateTestWorkflow(key)); + + var workflowDescriptors = builder.Services + .Where(d => d.ServiceType == typeof(Workflow) && d.ServiceKey is string) + .ToList(); + + Assert.Equal(3, workflowDescriptors.Count); + Assert.Contains(workflowDescriptors, d => (string)d.ServiceKey! == "workflow1"); + Assert.Contains(workflowDescriptors, d => (string)d.ServiceKey! == "workflow2"); + Assert.Contains(workflowDescriptors, d => (string)d.ServiceKey! == "workflow3"); + } + + /// + /// Verifies that AddWorkflow handles empty strings for name. + /// + [Fact] + public void AddWorkflow_EmptyName_ThrowsArgumentException() + { + var builder = new HostApplicationBuilder(); + var result = builder.AddWorkflow("", (sp, key) => CreateTestWorkflow(key)); + Assert.NotNull(result); + } + + /// + /// Verifies that AddWorkflow with special characters in name works correctly for valid names. + /// + [Theory] + [InlineData("workflow_name")] // underscore is allowed + [InlineData("Workflow123")] // alphanumeric is allowed + [InlineData("_workflow")] // can start with underscore + [InlineData("workflow-name")] // dash is allowed + [InlineData("workflow.name")] // period is allowed + [InlineData("workflow:type")] // colon is allowed + [InlineData("my.workflow_1:type-name")] // complex valid name + public void AddWorkflow_ValidSpecialCharactersInName_Succeeds(string name) + { + var builder = new HostApplicationBuilder(); + + var result = builder.AddWorkflow(name, (sp, key) => CreateTestWorkflow(key)); + + var descriptor = builder.Services.FirstOrDefault( + d => (d.ServiceKey as string) == name && + d.ServiceType == typeof(Workflow)); + Assert.NotNull(descriptor); + } + + /// + /// Verifies that providing a null builder to AddConcurrentWorkflow throws an ArgumentNullException. + /// + [Fact] + public void AddConcurrentWorkflow_NullBuilder_ThrowsArgumentNullException() + { + Assert.Throws(() => + HostApplicationBuilderWorkflowExtensions.AddConcurrentWorkflow(null!, "workflow", [null!])); + } + + /// + /// Verifies that AddConcurrentWorkflow throws ArgumentNullException for null name. + /// + [Fact] + public void AddConcurrentWorkflow_NullName_ThrowsArgumentNullException() + { + var builder = new HostApplicationBuilder(); + + var exception = Assert.Throws(() => + builder.AddConcurrentWorkflow(null!, [new HostedAgentBuilder("test", builder)])); + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verifies that AddConcurrentWorkflow throws ArgumentNullException for null agent builders. + /// + [Fact] + public void AddConcurrentWorkflow_NullAgentBuilders_ThrowsArgumentNullException() + { + var builder = new HostApplicationBuilder(); + + var exception = Assert.Throws(() => + builder.AddConcurrentWorkflow("workflowName", null!)); + Assert.Equal("agentBuilders", exception.ParamName); + } + + /// + /// Verifies that AddConcurrentWorkflow returns IHostWorkflowBuilder instance. + /// + [Fact] + public void AddConcurrentWorkflow_ValidParameters_ReturnsBuilder() + { + var builder = new HostApplicationBuilder(); + + var result = builder.AddConcurrentWorkflow("concurrentWorkflow", [new HostedAgentBuilder("test", builder)]); + + Assert.NotNull(result); + Assert.IsAssignableFrom(result); + } + + /// + /// Verifies that providing a null builder to AddSequentialWorkflow throws an ArgumentNullException. + /// + [Fact] + public void AddSequentialWorkflow_NullBuilder_ThrowsArgumentNullException() + { + Assert.Throws(() => + HostApplicationBuilderWorkflowExtensions.AddSequentialWorkflow(null!, "workflow", [null!])); + } + + /// + /// Verifies that AddSequentialWorkflow throws ArgumentNullException for null name. + /// + [Fact] + public void AddSequentialWorkflow_NullName_ThrowsArgumentNullException() + { + var builder = new HostApplicationBuilder(); + + var exception = Assert.Throws(() => + builder.AddSequentialWorkflow(null!, [new HostedAgentBuilder("test", builder)])); + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verifies that AddSequentialWorkflow throws ArgumentNullException for null agent builders. + /// + [Fact] + public void AddSequentialWorkflow_NullAgentBuilders_ThrowsArgumentNullException() + { + var builder = new HostApplicationBuilder(); + + var exception = Assert.Throws(() => + builder.AddSequentialWorkflow("workflowName", null!)); + Assert.Equal("agentBuilders", exception.ParamName); + } + + [Fact] + public void AddSequentialWorkflow_EmptyAgentBuilders_Throws() + { + var builder = new HostApplicationBuilder(); + + var exception = Assert.Throws(() => + builder.AddSequentialWorkflow("sequentialWorkflow", Array.Empty())); + Assert.Equal("agentBuilders", exception.ParamName); + } + + /// + /// Helper method to create a simple test workflow with a given name. + /// + private static Workflow CreateTestWorkflow(string name) + { + // Create a simple workflow using AgentWorkflowBuilder + var mockAgent = new Mock(); + mockAgent.Setup(a => a.Name).Returns("testAgent"); + + return AgentWorkflowBuilder.BuildSequential(workflowName: name, agents: [mockAgent.Object]); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index e86df11d6f..efd1c96f88 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -21,7 +21,7 @@ public class AgentWorkflowBuilderTests [Fact] public void BuildSequential_InvalidArguments_Throws() { - Assert.Throws("agents", () => AgentWorkflowBuilder.BuildSequential(null!)); + Assert.Throws("agents", () => AgentWorkflowBuilder.BuildSequential(workflowName: null!, null!)); Assert.Throws("agents", () => AgentWorkflowBuilder.BuildSequential()); }