diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index ff56549f26..e90cb8a1a5 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -67,6 +67,7 @@ + @@ -316,7 +317,7 @@ - + diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj index 802c864c1f..53fd4757ee 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -13,7 +13,7 @@ - + @@ -37,4 +37,4 @@ - + \ No newline at end of file diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Custom/CustomAITools.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Custom/CustomAITools.cs new file mode 100644 index 0000000000..d3deb9162c --- /dev/null +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Custom/CustomAITools.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace AgentWebChat.AgentHost.Custom; + +public class CustomAITool : AITool +{ +} + +public class CustomFunctionTool : AIFunction +{ + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return new ValueTask(arguments.Context?.Count ?? 0); + } +} diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index d86c53958d..cb1a7e3cd9 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -2,6 +2,7 @@ using A2A.AspNetCore; using AgentWebChat.AgentHost; +using AgentWebChat.AgentHost.Custom; using AgentWebChat.AgentHost.Utilities; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; @@ -25,6 +26,8 @@ var pirateAgentBuilder = builder.AddAIAgent( instructions: "You are a pirate. Speak like a pirate", description: "An agent that speaks like a pirate.", chatClientServiceKey: "chat-model") + .WithAITool(new CustomAITool()) + .WithAITool(new CustomFunctionTool()) .WithInMemoryThreadStore(); var knightsKnavesAgentBuilder = builder.AddAIAgent("knights-and-knaves", (sp, key) => @@ -78,8 +81,19 @@ var literatureAgent = builder.AddAIAgent("literator", 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 scienceSequentialWorkflow = builder.AddWorkflow("science-sequential-workflow", (sp, key) => +{ + List usedAgents = [chemistryAgent, mathsAgent, literatureAgent]; + var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService(ab.Name)); + return AgentWorkflowBuilder.BuildSequential(workflowName: key, agents: agents); +}).AddAsAIAgent(); + +var scienceConcurrentWorkflow = builder.AddWorkflow("science-concurrent-workflow", (sp, key) => +{ + List usedAgents = [chemistryAgent, mathsAgent, literatureAgent]; + var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService(ab.Name)); + return AgentWorkflowBuilder.BuildConcurrent(workflowName: key, agents: agents); +}).AddAsAIAgent(); builder.AddOpenAIChatCompletions(); builder.AddOpenAIResponses(); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs index 65f3a9e98f..c56be11fc5 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs @@ -4,6 +4,8 @@ // capabilities to an AI agent. The provider runs a search against an external knowledge base // before each model invocation and injects the results into the model context. +// Also see the AgentWithRAG folder for more advanced RAG scenarios. + using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Agent_Step21_ChatHistoryMemoryProvider.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Agent_Step21_ChatHistoryMemoryProvider.csproj new file mode 100644 index 0000000000..1caf270c49 --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Agent_Step21_ChatHistoryMemoryProvider.csproj @@ -0,0 +1,23 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Program.cs new file mode 100644 index 0000000000..9e4c27cebb --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Program.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create and use a simple AI agent that stores chat messages in a vector store using the ChatHistoryMemoryProvider. +// It can then use the chat history from prior conversations to inform responses in new conversations. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.VectorData; +using Microsoft.SemanticKernel.Connectors.InMemory; +using OpenAI; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large"; + +// Create a vector store to store the chat messages in. +// For demonstration purposes, we are using an in-memory vector store. +// Replace this with a vector store implementation of your choice that can persist the chat history long term. +VectorStore vectorStore = new InMemoryVectorStore(new InMemoryVectorStoreOptions() +{ + EmbeddingGenerator = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) + .GetEmbeddingClient(embeddingDeploymentName) + .AsIEmbeddingGenerator() +}); + +// Create the agent and add the ChatHistoryMemoryProvider to store chat messages in the vector store. +AIAgent agent = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetChatClient(deploymentName) + .CreateAIAgent(new ChatClientAgentOptions + { + Instructions = "You are good at telling jokes.", + Name = "Joker", + AIContextProviderFactory = (ctx) => new ChatHistoryMemoryProvider( + vectorStore, + collectionName: "chathistory", + vectorDimensions: 3072, + // Configure the scope values under which chat messages will be stored. + // In this case, we are using a fixed user ID and a unique thread ID for each new thread. + storageScope: new() { UserId = "UID1", ThreadId = new Guid().ToString() }, + // Configure the scope which would be used to search for relevant prior messages. + // In this case, we are searching for any messages for the user across all threads. + searchScope: new() { UserId = "UID1" }) + }); + +// Start a new thread for the agent conversation. +AgentThread thread = agent.GetNewThread(); + +// Run the agent with the thread that stores conversation history in the vector store. +Console.WriteLine(await agent.RunAsync("I like jokes about Pirates. Tell me a joke about a pirate.", thread)); + +// Start a second thread. Since we configured the search scope to be across all threads for the user, +// the agent should remember that the user likes pirate jokes. +AgentThread thread2 = agent.GetNewThread(); + +// Run the agent with the second thread. +Console.WriteLine(await agent.RunAsync("Tell me a joke that I might like.", thread2)); diff --git a/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Program.cs b/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Program.cs index e2e6e6b727..68a3043f3f 100644 --- a/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Program.cs +++ b/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Program.cs @@ -4,8 +4,10 @@ using Azure.AI.OpenAI; using Azure.Identity; +using Microsoft.Agents.AI; using Microsoft.Agents.AI.DevUI; using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; namespace DevUI_Step01_BasicUsage; @@ -56,10 +58,11 @@ internal static class Program // Register sample workflows var assistantBuilder = builder.AddAIAgent("workflow-assistant", "You are a helpful assistant in a workflow."); var reviewerBuilder = builder.AddAIAgent("workflow-reviewer", "You are a reviewer. Review and critique the previous response."); - builder.AddSequentialWorkflow( - "review-workflow", - [assistantBuilder, reviewerBuilder]) - .AddAsAIAgent(); + builder.AddWorkflow("review-workflow", (sp, key) => + { + var agents = new List() { assistantBuilder, reviewerBuilder }.Select(ab => sp.GetRequiredKeyedService(ab.Name)); + return AgentWorkflowBuilder.BuildSequential(workflowName: key, agents: agents); + }).AddAsAIAgent(); if (builder.Environment.IsDevelopment()) { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs index cae9801148..b6b9c4da48 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs @@ -18,6 +18,21 @@ namespace Microsoft.AspNetCore.Builder; /// public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions { + /// + /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. + /// + /// The to add the A2A endpoints to. + /// The configuration builder for . + /// The route group to use for A2A endpoints. + /// Configured for A2A integration. + /// + /// This method can be used to access A2A agents that support the + /// Curated Registries (Catalog-Based Discovery) + /// discovery mechanism. + /// + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path) + => endpoints.MapA2A(agentBuilder, path, _ => { }); + /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. /// @@ -28,6 +43,25 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path) => endpoints.MapA2A(agentName, path, _ => { }); + /// + /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. + /// + /// The to add the A2A endpoints to. + /// The configuration builder for . + /// The route group to use for A2A endpoints. + /// The callback to configure . + /// Configured for A2A integration. + /// + /// This method can be used to access A2A agents that support the + /// Curated Registries (Catalog-Based Discovery) + /// discovery mechanism. + /// + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, Action configureTaskManager) + { + ArgumentNullException.ThrowIfNull(agentBuilder); + return endpoints.MapA2A(agentBuilder.Name, path, configureTaskManager); + } + /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. /// @@ -38,10 +72,27 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions /// Configured for A2A integration. public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, Action configureTaskManager) { + ArgumentNullException.ThrowIfNull(endpoints); var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); return endpoints.MapA2A(agent, path, configureTaskManager); } + /// + /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. + /// + /// The to add the A2A endpoints to. + /// The configuration builder for . + /// The route group to use for A2A endpoints. + /// Agent card info to return on query. + /// Configured for A2A integration. + /// + /// This method can be used to access A2A agents that support the + /// Curated Registries (Catalog-Based Discovery) + /// discovery mechanism. + /// + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard) + => endpoints.MapA2A(agentBuilder, path, agentCard, _ => { }); + /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. /// @@ -58,6 +109,26 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard) => endpoints.MapA2A(agentName, path, agentCard, _ => { }); + /// + /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. + /// + /// The to add the A2A endpoints to. + /// The configuration builder for . + /// The route group to use for A2A endpoints. + /// Agent card info to return on query. + /// The callback to configure . + /// Configured for A2A integration. + /// + /// This method can be used to access A2A agents that support the + /// Curated Registries (Catalog-Based Discovery) + /// discovery mechanism. + /// + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, Action configureTaskManager) + { + ArgumentNullException.ThrowIfNull(agentBuilder); + return endpoints.MapA2A(agentBuilder.Name, path, agentCard, configureTaskManager); + } + /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. /// @@ -74,6 +145,7 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager) { + ArgumentNullException.ThrowIfNull(endpoints); var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); return endpoints.MapA2A(agent, path, agentCard, configureTaskManager); } @@ -98,6 +170,9 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions /// Configured for A2A integration. public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager) { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agent); + var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); var agentThreadStore = endpoints.ServiceProvider.GetKeyedService(agent.Name); var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentThreadStore: agentThreadStore); @@ -139,6 +214,9 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager) { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agent); + var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); var agentThreadStore = endpoints.ServiceProvider.GetKeyedService(agent.Name); var taskManager = agent.MapA2A(agentCard: agentCard, agentThreadStore: agentThreadStore, loggerFactory: loggerFactory); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj index 5076e25c05..f300483f63 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj @@ -29,6 +29,6 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs index f4159011cf..9a395b9b12 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics.CodeAnalysis; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.OpenAI; using Microsoft.Agents.AI.Hosting.OpenAI.Conversations; using Microsoft.Agents.AI.Hosting.OpenAI.Responses; @@ -17,6 +18,29 @@ namespace Microsoft.AspNetCore.Builder; /// public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions { + /// + /// Maps OpenAI Responses API endpoints to the specified for the given . + /// + /// The to add the OpenAI Responses endpoints to. + /// The builder for to map the OpenAI Responses endpoints for. + public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder) + => MapOpenAIResponses(endpoints, agentBuilder, path: null); + + /// + /// Maps OpenAI Responses API endpoints to the specified for the given . + /// + /// The to add the OpenAI Responses endpoints to. + /// The builder for to map the OpenAI Responses endpoints for. + /// Custom route path for the OpenAI Responses endpoint. + public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agentBuilder); + + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentBuilder.Name); + return MapOpenAIResponses(endpoints, agent, path); + } + /// /// Maps OpenAI Responses API endpoints to the specified for the given . /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs index 3c5a5b84c2..d958fc3578 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Linq; using Microsoft.Agents.AI.Hosting.Local; using Microsoft.Extensions.AI; @@ -29,7 +30,8 @@ public static class AgentHostingServiceCollectionExtensions return services.AddAIAgent(name, (sp, key) => { var chatClient = sp.GetRequiredService(); - return new ChatClientAgent(chatClient, instructions, key); + var tools = GetRegisteredToolsForAgent(sp, name); + return new ChatClientAgent(chatClient, instructions, key, tools: tools); }); } @@ -46,7 +48,11 @@ public static class AgentHostingServiceCollectionExtensions { Throw.IfNull(services); Throw.IfNullOrEmpty(name); - return services.AddAIAgent(name, (sp, key) => new ChatClientAgent(chatClient, instructions, key)); + return services.AddAIAgent(name, (sp, key) => + { + var tools = GetRegisteredToolsForAgent(sp, name); + return new ChatClientAgent(chatClient, instructions, key, tools: tools); + }); } /// @@ -65,7 +71,8 @@ public static class AgentHostingServiceCollectionExtensions return services.AddAIAgent(name, (sp, key) => { var chatClient = chatClientServiceKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(chatClientServiceKey); - return new ChatClientAgent(chatClient, instructions, key); + var tools = GetRegisteredToolsForAgent(sp, name); + return new ChatClientAgent(chatClient, instructions, key, tools: tools); }); } @@ -86,7 +93,8 @@ public static class AgentHostingServiceCollectionExtensions return services.AddAIAgent(name, (sp, key) => { var chatClient = chatClientServiceKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(chatClientServiceKey); - return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description); + var tools = GetRegisteredToolsForAgent(sp, name); + return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description, tools: tools); }); } @@ -142,4 +150,10 @@ public static class AgentHostingServiceCollectionExtensions services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext)); services.AddSingleton(); } + + private static IList GetRegisteredToolsForAgent(IServiceProvider serviceProvider, string agentName) + { + var registry = serviceProvider.GetService(); + return registry?.GetTools(agentName) ?? []; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs index ac78877682..2215a52a69 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs @@ -1,7 +1,6 @@ // 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; @@ -16,46 +15,6 @@ namespace Microsoft.Agents.AI.Hosting; /// 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. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs index 902c54ebe9..e8a55b3baa 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Linq; +using Microsoft.Agents.AI.Hosting.Local; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Shared.Diagnostics; @@ -59,4 +62,52 @@ public static class HostedAgentBuilderExtensions }); return builder; } + + /// + /// Adds an AI tool to an agent being configured with the service collection. + /// + /// The hosted agent builder. + /// The AI tool to add to the agent. + /// The same instance so that additional calls can be chained. + /// Thrown when or is . + public static IHostedAgentBuilder WithAITool(this IHostedAgentBuilder builder, AITool tool) + { + Throw.IfNull(builder); + Throw.IfNull(tool); + + var agentName = builder.Name; + var services = builder.ServiceCollection; + + // Get or create the agent tool registry + var descriptor = services.FirstOrDefault(sd => !sd.IsKeyedService && sd.ServiceType.Equals(typeof(LocalAgentToolRegistry))); + if (descriptor?.ImplementationInstance is not LocalAgentToolRegistry toolRegistry) + { + toolRegistry = new(); + services.Add(ServiceDescriptor.Singleton(toolRegistry)); + } + + toolRegistry.AddTool(agentName, tool); + + return builder; + } + + /// + /// Adds multiple AI tools to an agent being configured with the service collection. + /// + /// The hosted agent builder. + /// The collection of AI tools to add to the agent. + /// The same instance so that additional calls can be chained. + /// Thrown when or is . + public static IHostedAgentBuilder WithAITools(this IHostedAgentBuilder builder, params AITool[] tools) + { + Throw.IfNull(builder); + Throw.IfNull(tools); + + foreach (var tool in tools) + { + builder.WithAITool(tool); + } + + return builder; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentToolRegistry.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentToolRegistry.cs new file mode 100644 index 0000000000..ea8d8ad74e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentToolRegistry.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Local; + +internal sealed class LocalAgentToolRegistry +{ + private readonly Dictionary> _toolsByAgentName = new(); + + public void AddTool(string agentName, AITool tool) + { + if (!this._toolsByAgentName.TryGetValue(agentName, out var tools)) + { + tools = []; + this._toolsByAgentName[agentName] = tools; + } + + tools.Add(tool); + } + + public IList GetTools(string agentName) + { + return this._toolsByAgentName.TryGetValue(agentName, out var tools) ? tools : []; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs index 4aae5de59b..d18ed2b460 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs @@ -153,7 +153,7 @@ public sealed class Mem0Provider : AIContextProvider if (this._logger is not null) { this._logger.LogInformation( - "Mem0AIContextProvider: Retrieved {Count} memories. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'", + "Mem0AIContextProvider: Retrieved {Count} memories. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", memories.Count, this._searchScope.ApplicationId, this._searchScope.AgentId, @@ -162,7 +162,7 @@ public sealed class Mem0Provider : AIContextProvider if (outputMessageText is not null) { this._logger.LogTrace( - "Mem0AIContextProvider: Search Results\nInput:{Input}\nOutput:{MessageText}\nApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'", + "Mem0AIContextProvider: Search Results\nInput:{Input}\nOutput:{MessageText}\nApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", queryText, outputMessageText, this._searchScope.ApplicationId, @@ -185,7 +185,7 @@ public sealed class Mem0Provider : AIContextProvider { this._logger?.LogError( ex, - "Mem0AIContextProvider: Failed to search Mem0 for memories due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'", + "Mem0AIContextProvider: Failed to search Mem0 for memories due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", this._searchScope.ApplicationId, this._searchScope.AgentId, this._searchScope.ThreadId, @@ -211,7 +211,7 @@ public sealed class Mem0Provider : AIContextProvider { this._logger?.LogError( ex, - "Mem0AIContextProvider: Failed to send messages to Mem0 due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'", + "Mem0AIContextProvider: Failed to send messages to Mem0 due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", this._storageScope.ApplicationId, this._storageScope.AgentId, this._storageScope.ThreadId, diff --git a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs index 36bef4a2af..c400a1cb6c 100644 --- a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs @@ -68,6 +68,7 @@ internal static partial class AgentJsonUtilities // Agent abstraction types [JsonSerializable(typeof(ChatClientAgentThread.ThreadState))] [JsonSerializable(typeof(TextSearchProvider.TextSearchProviderState))] + [JsonSerializable(typeof(ChatHistoryMemoryProvider.ChatHistoryMemoryProviderState))] [ExcludeFromCodeCoverage] internal sealed partial class JsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs index baa36c0054..ad224e6777 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs @@ -166,8 +166,8 @@ public class ChatClientAgentThread : AgentThread var state = new ThreadState { ConversationId = this.ConversationId, - StoreState = storeState, - AIContextProviderState = aiContextProviderState + StoreState = storeState is { ValueKind: not JsonValueKind.Undefined } ? storeState : null, + AIContextProviderState = aiContextProviderState is { ValueKind: not JsonValueKind.Undefined } ? aiContextProviderState : null, }; return JsonSerializer.SerializeToElement(state, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState))); diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs new file mode 100644 index 0000000000..6d90c877e8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs @@ -0,0 +1,483 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.VectorData; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A context provider that stores all chat history in a vector store and is able to +/// retrieve related chat history later to augment the current conversation. +/// +/// +/// +/// This provider stores chat messages in a vector store and retrieves relevant previous messages +/// to provide as context during agent invocations. It uses the VectorStore and VectorStoreCollection +/// abstractions to work with any compatible vector store implementation. +/// +/// +/// Messages are stored during the method and retrieved during the +/// method using semantic similarity search. +/// +/// +/// Behavior is configurable through . When +/// is selected the provider +/// exposes a function tool that the model can invoke to retrieve relevant memories on demand instead of +/// injecting them automatically on each invocation. +/// +/// +public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable +{ + private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:"; + private const int DefaultMaxResults = 3; + private const string DefaultFunctionToolName = "Search"; + private const string DefaultFunctionToolDescription = "Allows searching for related previous chat history to help answer the user question."; + + private readonly VectorStore _vectorStore; + private readonly VectorStoreCollection> _collection; + private readonly int _maxResults; + private readonly string _contextPrompt; + private readonly ChatHistoryMemoryProviderOptions.SearchBehavior _searchTime; + private readonly AITool[] _tools; + private readonly ILogger? _logger; + + private readonly ChatHistoryMemoryProviderScope _storageScope; + private readonly ChatHistoryMemoryProviderScope _searchScope; + + private bool _collectionInitialized; + private readonly SemaphoreSlim _initializationLock = new(1, 1); + private bool _disposedValue; + + /// + /// Initializes a new instance of the class. + /// + /// The vector store to use for storing and retrieving chat history. + /// The name of the collection for storing chat history in the vector store. + /// The number of dimensions to use for the chat history vector store embeddings. + /// Optional values to scope the chat history storage with. + /// Optional values to scope the chat history search with. Where values are null, no filtering is done using those values. Defaults to if not provided. + /// Optional configuration options. + /// Optional logger factory. + /// Thrown when is . + public ChatHistoryMemoryProvider( + VectorStore vectorStore, + string collectionName, + int vectorDimensions, + ChatHistoryMemoryProviderScope storageScope, + ChatHistoryMemoryProviderScope? searchScope = null, + ChatHistoryMemoryProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this( + vectorStore, + collectionName, + vectorDimensions, + new ChatHistoryMemoryProviderState + { + StorageScope = new(Throw.IfNull(storageScope)), + SearchScope = searchScope ?? new(storageScope), + }, + options, + loggerFactory) + { + } + + /// + /// Initializes a new instance of the class from previously serialized state. + /// + /// The vector store to use for storing and retrieving chat history. + /// The name of the collection for storing chat history in the vector store. + /// The number of dimensions to use for the chat history vector store embeddings. + /// A representing the serialized state of the provider. + /// Optional settings for customizing the JSON deserialization process. + /// Optional configuration options. + /// Optional logger factory. + public ChatHistoryMemoryProvider( + VectorStore vectorStore, + string collectionName, + int vectorDimensions, + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + ChatHistoryMemoryProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this( + vectorStore, + collectionName, + vectorDimensions, + DeserializeState(serializedState, jsonSerializerOptions), + options, + loggerFactory) + { + } + + private ChatHistoryMemoryProvider( + VectorStore vectorStore, + string collectionName, + int vectorDimensions, + ChatHistoryMemoryProviderState? state = null, + ChatHistoryMemoryProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + { + this._vectorStore = vectorStore ?? throw new ArgumentNullException(nameof(vectorStore)); + options ??= new ChatHistoryMemoryProviderOptions(); + this._maxResults = options.MaxResults.HasValue ? Throw.IfLessThanOrEqual(options.MaxResults.Value, 0) : DefaultMaxResults; + this._contextPrompt = options.ContextPrompt ?? DefaultContextPrompt; + this._searchTime = options.SearchTime; + this._logger = loggerFactory?.CreateLogger(); + + if (state == null || state.StorageScope == null || state.SearchScope == null) + { + throw new InvalidOperationException($"The {nameof(ChatHistoryMemoryProvider)} state did not contain the required scope properties."); + } + + this._storageScope = state.StorageScope; + this._searchScope = state.SearchScope; + + // Create on-demand search tool (only used when behavior is OnDemandFunctionCalling) + this._tools = + [ + AIFunctionFactory.Create( + (Func>)this.SearchTextAsync, + name: options.FunctionToolName ?? DefaultFunctionToolName, + description: options.FunctionToolDescription ?? DefaultFunctionToolDescription) + ]; + + // Create a definition so that we can use the dimensions provided at runtime. + var definition = new VectorStoreCollectionDefinition + { + Properties = new List + { + new VectorStoreKeyProperty("Key", typeof(Guid)), + new VectorStoreDataProperty("Role", typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty("MessageId", typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty("AuthorName", typeof(string)), + new VectorStoreDataProperty("ApplicationId", typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty("AgentId", typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty("UserId", typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty("ThreadId", typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty("Content", typeof(string)) { IsFullTextIndexed = true }, + new VectorStoreDataProperty("CreatedAt", typeof(string)) { IsIndexed = true }, + new VectorStoreVectorProperty("ContentEmbedding", typeof(string), Throw.IfLessThan(vectorDimensions, 1)) + } + }; + + this._collection = this._vectorStore.GetDynamicCollection(Throw.IfNullOrWhitespace(collectionName), definition); + } + + /// + public override async ValueTask InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(context); + + if (this._searchTime == ChatHistoryMemoryProviderOptions.SearchBehavior.OnDemandFunctionCalling) + { + // Expose search tool for on-demand invocation by the model + return new AIContext { Tools = this._tools }; + } + + try + { + // Get the text from the current request messages + var requestText = string.Join("\n", context.RequestMessages + .Where(m => m != null && !string.IsNullOrWhiteSpace(m.Text)) + .Select(m => m.Text)); + + if (string.IsNullOrWhiteSpace(requestText)) + { + return new AIContext(); + } + + // Search for relevant chat history + var contextText = await this.SearchTextAsync(requestText, cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(contextText)) + { + return new AIContext(); + } + + return new AIContext + { + Messages = [new ChatMessage(ChatRole.User, contextText)] + }; + } + catch (Exception ex) + { + this._logger?.LogError( + ex, + "ChatHistoryMemoryProvider: Failed to search for chat history due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", + this._searchScope.ApplicationId, + this._searchScope.AgentId, + this._searchScope.ThreadId, + this._searchScope.UserId); + return new AIContext(); + } + } + + /// + public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(context); + + // Only store if invocation was successful + if (context.InvokeException != null) + { + return; + } + + try + { + // Ensure the collection is initialized + var collection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false); + + List> itemsToStore = context.RequestMessages + .Concat(context.ResponseMessages ?? []) + .Select(message => new Dictionary + { + ["Key"] = Guid.NewGuid(), + ["Role"] = message.Role.ToString(), + ["MessageId"] = message.MessageId, + ["AuthorName"] = message.AuthorName, + ["ApplicationId"] = this._storageScope?.ApplicationId, + ["AgentId"] = this._storageScope?.AgentId, + ["UserId"] = this._storageScope?.UserId, + ["ThreadId"] = this._storageScope?.ThreadId, + ["Content"] = message.Text, + ["CreatedAt"] = message.CreatedAt?.ToString("O") ?? DateTimeOffset.UtcNow.ToString("O"), + ["ContentEmbedding"] = message.Text, + }) + .ToList(); + + if (itemsToStore.Count > 0) + { + await collection.UpsertAsync(itemsToStore, cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) + { + this._logger?.LogError( + ex, + "ChatHistoryMemoryProvider: Failed to add messages to chat history vector store due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", + this._searchScope.ApplicationId, + this._searchScope.AgentId, + this._searchScope.ThreadId, + this._searchScope.UserId); + } + } + + /// + /// Function callable by the AI model (when enabled) to perform an ad-hoc chat history search. + /// + /// The query text. + /// Cancellation token. + /// Formatted search results (may be empty). + internal async Task SearchTextAsync(string userQuestion, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(userQuestion)) + { + return string.Empty; + } + + var results = await this.SearchChatHistoryAsync(userQuestion, this._maxResults, cancellationToken).ConfigureAwait(false); + if (!results.Any()) + { + return string.Empty; + } + + // Format the results as a single context message + var outputResultsText = string.Join("\n", results.Select(x => (string?)x["Content"]).Where(c => !string.IsNullOrWhiteSpace(c))); + if (string.IsNullOrWhiteSpace(outputResultsText)) + { + return string.Empty; + } + + var formatted = $"{this._contextPrompt}\n{outputResultsText}"; + + this._logger?.LogTrace( + "ChatHistoryMemoryProvider: Search Results\nInput:{Input}\nOutput:{MessageText}\n ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", + userQuestion, + formatted, + this._searchScope.ApplicationId, + this._searchScope.AgentId, + this._searchScope.ThreadId, + this._searchScope.UserId); + return formatted; + } + + /// + /// Searches for relevant chat history items based on the provided query text. + /// + /// The text to search for. + /// The maximum number of results to return. + /// The cancellation token. + /// A list of relevant chat history items. + private async Task>> SearchChatHistoryAsync( + string queryText, + int top, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(queryText)) + { + return []; + } + + var collection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false); + + string? applicationId = this._searchScope.ApplicationId; + string? agentId = this._searchScope.AgentId; + string? userId = this._searchScope.UserId; + string? threadId = this._searchScope.ThreadId; + + Expression, bool>>? filter = null; + if (applicationId != null) + { + filter = x => (string?)x["ApplicationId"] == applicationId; + } + + if (agentId != null) + { + Expression, bool>> agentIdFilter = x => (string?)x["AgentId"] == agentId; + filter = filter == null ? agentIdFilter : Expression.Lambda, bool>>( + Expression.AndAlso(filter.Body, agentIdFilter.Body), + filter.Parameters); + } + + if (userId != null) + { + Expression, bool>> userIdFilter = x => (string?)x["UserId"] == userId; + filter = filter == null ? userIdFilter : Expression.Lambda, bool>>( + Expression.AndAlso(filter.Body, userIdFilter.Body), + filter.Parameters); + } + + if (threadId != null) + { + Expression, bool>> threadIdFilter = x => (string?)x["ThreadId"] == threadId; + filter = filter == null ? threadIdFilter : Expression.Lambda, bool>>( + Expression.AndAlso(filter.Body, threadIdFilter.Body), + filter.Parameters); + } + + // Use search to find relevant messages + var searchResults = collection.SearchAsync( + queryText, + top, + options: new() + { + Filter = filter + }, + cancellationToken: cancellationToken); + + var results = new List>(); + await foreach (var result in searchResults.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + results.Add(result.Record); + } + + this._logger?.LogInformation( + "ChatHistoryMemoryProvider: Retrieved {Count} search results. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", + results.Count, + this._searchScope.ApplicationId, + this._searchScope.AgentId, + this._searchScope.ThreadId, + this._searchScope.UserId); + + return results; + } + + /// + /// Ensures the collection exists in the vector store, creating it if necessary. + /// + /// The cancellation token. + /// The vector store collection. + private async Task>> EnsureCollectionExistsAsync( + CancellationToken cancellationToken = default) + { + if (this._collectionInitialized) + { + return this._collection; + } + + await this._initializationLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (this._collectionInitialized) + { + return this._collection; + } + + await this._collection.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false); + this._collectionInitialized = true; + + return this._collection; + } + finally + { + this._initializationLock.Release(); + } + } + + /// + private void Dispose(bool disposing) + { + if (!this._disposedValue) + { + if (disposing) + { + this._initializationLock.Dispose(); + this._collection?.Dispose(); + } + + this._disposedValue = true; + } + } + + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + this.Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + /// Serializes the current provider state to a including storage and search scopes. + /// + /// Optional serializer options. + /// Serialized provider state. + public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) + { + var state = new ChatHistoryMemoryProviderState + { + StorageScope = this._storageScope, + SearchScope = this._searchScope, + }; + + var jso = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions; + return JsonSerializer.SerializeToElement(state, jso.GetTypeInfo(typeof(ChatHistoryMemoryProviderState))); + } + + private static ChatHistoryMemoryProviderState? DeserializeState(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions) + { + if (serializedState.ValueKind != JsonValueKind.Object) + { + return null; + } + + var jso = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions; + return serializedState.Deserialize(jso.GetTypeInfo(typeof(ChatHistoryMemoryProviderState))) as ChatHistoryMemoryProviderState; + } + + internal sealed class ChatHistoryMemoryProviderState + { + public ChatHistoryMemoryProviderScope? StorageScope { get; set; } + public ChatHistoryMemoryProviderScope? SearchScope { get; set; } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs new file mode 100644 index 0000000000..55f06d7429 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI; + +/// +/// Options controlling the behavior of . +/// +public sealed class ChatHistoryMemoryProviderOptions +{ + /// + /// Gets or sets a value indicating when the search should be executed. + /// + /// by default. + public SearchBehavior SearchTime { get; set; } = SearchBehavior.BeforeAIInvoke; + + /// + /// Gets or sets the name of the exposed search tool when operating in on-demand mode. + /// + /// Defaults to "Search". + public string? FunctionToolName { get; set; } + + /// + /// Gets or sets the description of the exposed search tool when operating in on-demand mode. + /// + /// Defaults to "Allows searching through previous chat history to help answer the user question.". + public string? FunctionToolDescription { get; set; } + + /// + /// Gets or sets the context prompt prefixed to results. + /// + public string? ContextPrompt { get; set; } + + /// + /// Gets or sets the maximum number of results to retrieve from the chat history. + /// + /// + /// Defaults to 3 if not set. + /// + public int? MaxResults { get; set; } + + /// + /// Behavior choices for the provider. + /// + public enum SearchBehavior + { + /// + /// Execute search prior to each invocation and inject results as a message. + /// + BeforeAIInvoke, + + /// + /// Expose a function tool to perform search on-demand via function/tool calling. + /// + OnDemandFunctionCalling + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderScope.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderScope.cs new file mode 100644 index 0000000000..2715ed2e20 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderScope.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Allows scoping of chat history for the . +/// +public sealed class ChatHistoryMemoryProviderScope +{ + /// + /// Initializes a new instance of the class. + /// + public ChatHistoryMemoryProviderScope() { } + + /// + /// Initializes a new instance of the class by cloning an existing scope. + /// + /// The scope to clone. + public ChatHistoryMemoryProviderScope(ChatHistoryMemoryProviderScope sourceScope) + { + Throw.IfNull(sourceScope); + + this.ApplicationId = sourceScope.ApplicationId; + this.AgentId = sourceScope.AgentId; + this.ThreadId = sourceScope.ThreadId; + this.UserId = sourceScope.UserId; + } + + /// + /// Gets or sets an optional ID for the application to scope chat history to. + /// + /// If not set, the scope of the chat history will span all applications. + public string? ApplicationId { get; set; } + + /// + /// Gets or sets an optional ID for the agent to scope chat history to. + /// + /// If not set, the scope of the chat history will span all agents. + public string? AgentId { get; set; } + + /// + /// Gets or sets an optional ID for the thread to scope chat history to. + /// + public string? ThreadId { get; set; } + + /// + /// Gets or sets an optional ID for the user to scope chat history to. + /// + /// If not set, the scope of the chat history will span all users. + public string? UserId { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj index 59345d21ae..e3d7f00aa1 100644 --- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj +++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj @@ -31,8 +31,10 @@ - + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Converters/MessageConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs similarity index 97% rename from dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Converters/MessageConverterTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs index 81ce582870..69eaf3a535 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Converters/MessageConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs @@ -5,7 +5,7 @@ using A2A; using Microsoft.Agents.AI.Hosting.A2A.Converters; using Microsoft.Extensions.AI; -namespace Microsoft.Agents.AI.Hosting.A2A.Tests.Converters; +namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Converters; public class MessageConverterTests { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs new file mode 100644 index 0000000000..1ae0dda908 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs @@ -0,0 +1,502 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using A2A; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; + +/// +/// Tests for MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions.MapA2A method. +/// +public sealed class EndpointRouteA2ABuilderExtensionsTests +{ + /// + /// Verifies that MapA2A throws ArgumentNullException for null endpoints. + /// + [Fact] + public void MapA2A_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException() + { + // Arrange + AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + endpoints.MapA2A(agentBuilder, "/a2a")); + + Assert.Equal("endpoints", exception.ParamName); + } + + /// + /// Verifies that MapA2A throws ArgumentNullException for null agentBuilder. + /// + [Fact] + public void MapA2A_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + IHostedAgentBuilder agentBuilder = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + app.MapA2A(agentBuilder, "/a2a")); + + Assert.Equal("agentBuilder", exception.ParamName); + } + + /// + /// Verifies that MapA2A with IHostedAgentBuilder correctly maps the agent with default task manager configuration. + /// + [Fact] + public void MapA2A_WithAgentBuilder_DefaultConfiguration_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + var result = app.MapA2A(agentBuilder, "/a2a"); + Assert.NotNull(result); + Assert.NotNull(app); + } + + /// + /// Verifies that MapA2A with IHostedAgentBuilder and custom task manager configuration succeeds. + /// + [Fact] + public void MapA2A_WithAgentBuilder_CustomTaskManagerConfiguration_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + var result = app.MapA2A(agentBuilder, "/a2a", taskManager => { }); + Assert.NotNull(result); + Assert.NotNull(app); + } + + /// + /// Verifies that MapA2A with IHostedAgentBuilder and agent card succeeds. + /// + [Fact] + public void MapA2A_WithAgentBuilder_WithAgentCard_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + var agentCard = new AgentCard + { + Name = "Test Agent", + Description = "A test agent for A2A communication" + }; + + // Act & Assert - Should not throw + var result = app.MapA2A(agentBuilder, "/a2a", agentCard); + Assert.NotNull(result); + Assert.NotNull(app); + } + + /// + /// Verifies that MapA2A with IHostedAgentBuilder, agent card, and custom task manager configuration succeeds. + /// + [Fact] + public void MapA2A_WithAgentBuilder_WithAgentCardAndCustomConfiguration_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + var agentCard = new AgentCard + { + Name = "Test Agent", + Description = "A test agent for A2A communication" + }; + + // Act & Assert - Should not throw + var result = app.MapA2A(agentBuilder, "/a2a", agentCard, taskManager => { }); + Assert.NotNull(result); + Assert.NotNull(app); + } + + /// + /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using string agent name. + /// + [Fact] + public void MapA2A_WithAgentName_NullEndpoints_ThrowsArgumentNullException() + { + // Arrange + AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + endpoints.MapA2A("agent", "/a2a")); + + Assert.Equal("endpoints", exception.ParamName); + } + + /// + /// Verifies that MapA2A with string agent name correctly maps the agent. + /// + [Fact] + public void MapA2A_WithAgentName_DefaultConfiguration_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + var result = app.MapA2A("agent", "/a2a"); + Assert.NotNull(result); + Assert.NotNull(app); + } + + /// + /// Verifies that MapA2A with string agent name and custom task manager configuration succeeds. + /// + [Fact] + public void MapA2A_WithAgentName_CustomTaskManagerConfiguration_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + var result = app.MapA2A("agent", "/a2a", taskManager => { }); + Assert.NotNull(result); + Assert.NotNull(app); + } + + /// + /// Verifies that MapA2A with string agent name and agent card succeeds. + /// + [Fact] + public void MapA2A_WithAgentName_WithAgentCard_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + var agentCard = new AgentCard + { + Name = "Test Agent", + Description = "A test agent for A2A communication" + }; + + // Act & Assert - Should not throw + var result = app.MapA2A("agent", "/a2a", agentCard); + Assert.NotNull(result); + Assert.NotNull(app); + } + + /// + /// Verifies that MapA2A with string agent name, agent card, and custom task manager configuration succeeds. + /// + [Fact] + public void MapA2A_WithAgentName_WithAgentCardAndCustomConfiguration_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + var agentCard = new AgentCard + { + Name = "Test Agent", + Description = "A test agent for A2A communication" + }; + + // Act & Assert - Should not throw + var result = app.MapA2A("agent", "/a2a", agentCard, taskManager => { }); + Assert.NotNull(result); + Assert.NotNull(app); + } + + /// + /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using AIAgent. + /// + [Fact] + public void MapA2A_WithAIAgent_NullEndpoints_ThrowsArgumentNullException() + { + // Arrange + AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + endpoints.MapA2A((AIAgent)null!, "/a2a")); + + Assert.Equal("endpoints", exception.ParamName); + } + + /// + /// Verifies that MapA2A with AIAgent correctly maps the agent. + /// + [Fact] + public void MapA2A_WithAIAgent_DefaultConfiguration_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + AIAgent agent = app.Services.GetRequiredKeyedService("agent"); + + // Act & Assert - Should not throw + var result = app.MapA2A(agent, "/a2a"); + Assert.NotNull(result); + Assert.NotNull(app); + } + + /// + /// Verifies that MapA2A with AIAgent and custom task manager configuration succeeds. + /// + [Fact] + public void MapA2A_WithAIAgent_CustomTaskManagerConfiguration_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + AIAgent agent = app.Services.GetRequiredKeyedService("agent"); + + // Act & Assert - Should not throw + var result = app.MapA2A(agent, "/a2a", taskManager => { }); + Assert.NotNull(result); + Assert.NotNull(app); + } + + /// + /// Verifies that MapA2A with AIAgent and agent card succeeds. + /// + [Fact] + public void MapA2A_WithAIAgent_WithAgentCard_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + AIAgent agent = app.Services.GetRequiredKeyedService("agent"); + + var agentCard = new AgentCard + { + Name = "Test Agent", + Description = "A test agent for A2A communication" + }; + + // Act & Assert - Should not throw + var result = app.MapA2A(agent, "/a2a", agentCard); + Assert.NotNull(result); + Assert.NotNull(app); + } + + /// + /// Verifies that MapA2A with AIAgent, agent card, and custom task manager configuration succeeds. + /// + [Fact] + public void MapA2A_WithAIAgent_WithAgentCardAndCustomConfiguration_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + AIAgent agent = app.Services.GetRequiredKeyedService("agent"); + + var agentCard = new AgentCard + { + Name = "Test Agent", + Description = "A test agent for A2A communication" + }; + + // Act & Assert - Should not throw + var result = app.MapA2A(agent, "/a2a", agentCard, taskManager => { }); + Assert.NotNull(result); + Assert.NotNull(app); + } + + /// + /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using ITaskManager. + /// + [Fact] + public void MapA2A_WithTaskManager_NullEndpoints_ThrowsArgumentNullException() + { + // Arrange + AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; + ITaskManager taskManager = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + endpoints.MapA2A(taskManager, "/a2a")); + + Assert.Equal("endpoints", exception.ParamName); + } + + /// + /// Verifies that multiple agents can be mapped to different paths. + /// + [Fact] + public void MapA2A_MultipleAgents_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client"); + IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + app.MapA2A(agent1Builder, "/a2a/agent1"); + app.MapA2A(agent2Builder, "/a2a/agent2"); + Assert.NotNull(app); + } + + /// + /// Verifies that custom paths can be specified for A2A endpoints. + /// + [Fact] + public void MapA2A_WithCustomPath_AcceptsValidPath() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + app.MapA2A(agentBuilder, "/custom/a2a/path"); + Assert.NotNull(app); + } + + /// + /// Verifies that task manager configuration callback is invoked correctly. + /// + [Fact] + public void MapA2A_WithAgentBuilder_TaskManagerConfigurationCallbackInvoked() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + bool configureCallbackInvoked = false; + + // Act + app.MapA2A(agentBuilder, "/a2a", taskManager => + { + configureCallbackInvoked = true; + Assert.NotNull(taskManager); + }); + + // Assert + Assert.True(configureCallbackInvoked); + } + + /// + /// Verifies that agent card with all properties is accepted. + /// + [Fact] + public void MapA2A_WithAgentBuilder_FullAgentCard_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + var agentCard = new AgentCard + { + Name = "Test Agent", + Description = "A comprehensive test agent" + }; + + // Act & Assert - Should not throw + var result = app.MapA2A(agentBuilder, "/a2a", agentCard); + Assert.NotNull(result); + } + + private sealed class DummyChatClient : IChatClient + { + public void Dispose() + { + throw new NotImplementedException(); + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType.IsInstanceOfType(this) ? this : null; + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj similarity index 82% rename from dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj rename to dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj index 9e0a79d646..63387ae458 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj @@ -11,6 +11,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/EndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/EndpointRouteBuilderExtensionsTests.cs index 38b13fbfac..e3effac07a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/EndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/EndpointRouteBuilderExtensionsTests.cs @@ -223,4 +223,174 @@ public sealed class EndpointRouteBuilderExtensionsTests app.MapOpenAIResponses(responsesPath: "/custom/path/responses"); Assert.NotNull(app); } + + /// + /// Verifies that MapOpenAIResponses throws ArgumentNullException for null endpoints when using IHostedAgentBuilder. + /// + [Fact] + public void MapOpenAIResponses_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException() + { + // Arrange + AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + endpoints.MapOpenAIResponses(agentBuilder)); + + Assert.Equal("endpoints", exception.ParamName); + } + + /// + /// Verifies that MapOpenAIResponses throws ArgumentNullException for null agentBuilder. + /// + [Fact] + public void MapOpenAIResponses_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddOpenAIResponses(); + using WebApplication app = builder.Build(); + IHostedAgentBuilder agentBuilder = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + app.MapOpenAIResponses(agentBuilder)); + + Assert.Equal("agentBuilder", exception.ParamName); + } + + /// + /// Verifies that MapOpenAIResponses with IHostedAgentBuilder correctly resolves and maps the agent. + /// + [Fact] + public void MapOpenAIResponses_WithAgentBuilder_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.AddOpenAIResponses(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + app.MapOpenAIResponses(agentBuilder); + Assert.NotNull(app); + } + + /// + /// Verifies that MapOpenAIResponses with IHostedAgentBuilder and custom path works correctly. + /// + [Fact] + public void MapOpenAIResponses_WithAgentBuilder_CustomPath_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("my-agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.AddOpenAIResponses(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + app.MapOpenAIResponses(agentBuilder, path: "/agents/my-agent/responses"); + Assert.NotNull(app); + } + + /// + /// Verifies that multiple agents can be mapped using IHostedAgentBuilder. + /// + [Fact] + public void MapOpenAIResponses_WithAgentBuilder_MultipleAgents_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client"); + IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client"); + builder.AddOpenAIResponses(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + app.MapOpenAIResponses(agent1Builder); + app.MapOpenAIResponses(agent2Builder); + Assert.NotNull(app); + } + + /// + /// Verifies that IHostedAgentBuilder overload validates agent name characters. + /// + [Theory] + [InlineData("agent with spaces")] + [InlineData("agent