mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feature-foundry-agents
This commit is contained in:
@@ -67,6 +67,7 @@
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Agent_Step18_TextSearchRag.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step19_Mem0Provider/Agent_Step19_Mem0Provider.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step20_BackgroundResponsesWithToolsAndPersistence/Agent_Step20_BackgroundResponsesWithToolsAndPersistence.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Agent_Step21_ChatHistoryMemoryProvider.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/DevUI/">
|
||||
<File Path="samples/GettingStarted/DevUI/README.md" />
|
||||
@@ -316,7 +317,7 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj" Id="2a1c544d-237d-4436-8732-ba0c447ac06b" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
@@ -13,7 +13,7 @@
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\AgentWebChat.ServiceDefaults\AgentWebChat.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -37,4 +37,4 @@
|
||||
</ItemGroup>
|
||||
<!-- A2A dependency -->
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -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<object?> InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<object?>(arguments.Context?.Count ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -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<IHostedAgentBuilder> usedAgents = [chemistryAgent, mathsAgent, literatureAgent];
|
||||
var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
|
||||
return AgentWorkflowBuilder.BuildSequential(workflowName: key, agents: agents);
|
||||
}).AddAsAIAgent();
|
||||
|
||||
var scienceConcurrentWorkflow = builder.AddWorkflow("science-concurrent-workflow", (sp, key) =>
|
||||
{
|
||||
List<IHostedAgentBuilder> usedAgents = [chemistryAgent, mathsAgent, literatureAgent];
|
||||
var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
|
||||
return AgentWorkflowBuilder.BuildConcurrent(workflowName: key, agents: agents);
|
||||
}).AddAsAIAgent();
|
||||
|
||||
builder.AddOpenAIChatCompletions();
|
||||
builder.AddOpenAIResponses();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
<PackageReference Include="System.Linq.Async" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+60
@@ -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));
|
||||
@@ -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<IHostedAgentBuilder>() { assistantBuilder, reviewerBuilder }.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
|
||||
return AgentWorkflowBuilder.BuildSequential(workflowName: key, agents: agents);
|
||||
}).AddAsAIAgent();
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
|
||||
+78
@@ -18,6 +18,21 @@ namespace Microsoft.AspNetCore.Builder;
|
||||
/// </summary>
|
||||
public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
|
||||
=> endpoints.MapA2A(agentBuilder, path, _ => { });
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
@@ -28,6 +43,25 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path)
|
||||
=> endpoints.MapA2A(agentName, path, _ => { });
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, Action<ITaskManager> configureTaskManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
return endpoints.MapA2A(agentBuilder.Name, path, configureTaskManager);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
@@ -38,10 +72,27 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, Action<ITaskManager> configureTaskManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
return endpoints.MapA2A(agent, path, configureTaskManager);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard)
|
||||
=> endpoints.MapA2A(agentBuilder, path, agentCard, _ => { });
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
@@ -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, _ => { });
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
return endpoints.MapA2A(agentBuilder.Name, path, agentCard, configureTaskManager);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
@@ -74,6 +145,7 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
return endpoints.MapA2A(agent, path, agentCard, configureTaskManager);
|
||||
}
|
||||
@@ -98,6 +170,9 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action<ITaskManager> configureTaskManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var agentThreadStore = endpoints.ServiceProvider.GetKeyedService<AgentThreadStore>(agent.Name);
|
||||
var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentThreadStore: agentThreadStore);
|
||||
@@ -139,6 +214,9 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var agentThreadStore = endpoints.ServiceProvider.GetKeyedService<AgentThreadStore>(agent.Name);
|
||||
var taskManager = agent.MapA2A(agentCard: agentCard, agentThreadStore: agentThreadStore, loggerFactory: loggerFactory);
|
||||
|
||||
@@ -29,6 +29,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="AgentWebChat.Web" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.A2A.Tests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.A2A.UnitTests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+24
@@ -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;
|
||||
/// </summary>
|
||||
public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps OpenAI Responses API endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="IHostedAgentBuilder"/>.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI Responses endpoints to.</param>
|
||||
/// <param name="agentBuilder">The builder for <see cref="AIAgent"/> to map the OpenAI Responses endpoints for.</param>
|
||||
public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder)
|
||||
=> MapOpenAIResponses(endpoints, agentBuilder, path: null);
|
||||
|
||||
/// <summary>
|
||||
/// Maps OpenAI Responses API endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="IHostedAgentBuilder"/>.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI Responses endpoints to.</param>
|
||||
/// <param name="agentBuilder">The builder for <see cref="AIAgent"/> to map the OpenAI Responses endpoints for.</param>
|
||||
/// <param name="path">Custom route path for the OpenAI Responses endpoint.</param>
|
||||
public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
|
||||
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentBuilder.Name);
|
||||
return MapOpenAIResponses(endpoints, agent, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps OpenAI Responses API endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
|
||||
/// </summary>
|
||||
|
||||
@@ -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<IChatClient>();
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -65,7 +71,8 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
return services.AddAIAgent(name, (sp, key) =>
|
||||
{
|
||||
var chatClient = chatClientServiceKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(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<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(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<AgentCatalog, LocalAgentCatalog>();
|
||||
}
|
||||
|
||||
private static IList<AITool> GetRegisteredToolsForAgent(IServiceProvider serviceProvider, string agentName)
|
||||
{
|
||||
var registry = serviceProvider.GetService<LocalAgentToolRegistry>();
|
||||
return registry?.GetTools(agentName) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
/// </summary>
|
||||
public static class HostApplicationBuilderWorkflowExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a concurrent workflow that executes multiple agents in parallel.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
|
||||
/// <param name="name">The unique name for the workflow.</param>
|
||||
/// <param name="agentBuilders">A collection of <see cref="IHostedAgentBuilder"/> instances representing agents to execute concurrently.</param>
|
||||
/// <returns>An <see cref="IHostedWorkflowBuilder"/> that can be used to further configure the workflow.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="agentBuilders"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> or <paramref name="agentBuilders"/> is empty.</exception>
|
||||
public static IHostedWorkflowBuilder AddConcurrentWorkflow(this IHostApplicationBuilder builder, string name, IEnumerable<IHostedAgentBuilder> agentBuilders)
|
||||
{
|
||||
Throw.IfNullOrEmpty(agentBuilders);
|
||||
|
||||
return builder.AddWorkflow(name, (sp, key) =>
|
||||
{
|
||||
var agents = agentBuilders.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
|
||||
return AgentWorkflowBuilder.BuildConcurrent(workflowName: name, agents: agents);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a sequential workflow that executes agents in a specific order.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
|
||||
/// <param name="name">The unique name for the workflow.</param>
|
||||
/// <param name="agentBuilders">A collection of <see cref="IHostedAgentBuilder"/> instances representing agents to execute in sequence.</param>
|
||||
/// <returns>An <see cref="IHostedWorkflowBuilder"/> that can be used to further configure the workflow.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="agentBuilders"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> or <paramref name="agentBuilders"/> is empty.</exception>
|
||||
public static IHostedWorkflowBuilder AddSequentialWorkflow(this IHostApplicationBuilder builder, string name, IEnumerable<IHostedAgentBuilder> agentBuilders)
|
||||
{
|
||||
Throw.IfNullOrEmpty(agentBuilders);
|
||||
|
||||
return builder.AddWorkflow(name, (sp, key) =>
|
||||
{
|
||||
var agents = agentBuilders.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
|
||||
return AgentWorkflowBuilder.BuildSequential(workflowName: name, agents: agents);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a custom workflow using a factory delegate.
|
||||
/// </summary>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an AI tool to an agent being configured with the service collection.
|
||||
/// </summary>
|
||||
/// <param name="builder">The hosted agent builder.</param>
|
||||
/// <param name="tool">The AI tool to add to the agent.</param>
|
||||
/// <returns>The same <see cref="IHostedAgentBuilder"/> instance so that additional calls can be chained.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="tool"/> is <see langword="null"/>.</exception>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds multiple AI tools to an agent being configured with the service collection.
|
||||
/// </summary>
|
||||
/// <param name="builder">The hosted agent builder.</param>
|
||||
/// <param name="tools">The collection of AI tools to add to the agent.</param>
|
||||
/// <returns>The same <see cref="IHostedAgentBuilder"/> instance so that additional calls can be chained.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="tools"/> is <see langword="null"/>.</exception>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, List<AITool>> _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<AITool> GetTools(string agentName)
|
||||
{
|
||||
return this._toolsByAgentName.TryGetValue(agentName, out var tools) ? tools : [];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Messages are stored during the <see cref="InvokedAsync"/> method and retrieved during the
|
||||
/// <see cref="InvokingAsync"/> method using semantic similarity search.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Behavior is configurable through <see cref="ChatHistoryMemoryProviderOptions"/>. When
|
||||
/// <see cref="ChatHistoryMemoryProviderOptions.SearchBehavior.OnDemandFunctionCalling"/> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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<object, Dictionary<string, object?>> _collection;
|
||||
private readonly int _maxResults;
|
||||
private readonly string _contextPrompt;
|
||||
private readonly ChatHistoryMemoryProviderOptions.SearchBehavior _searchTime;
|
||||
private readonly AITool[] _tools;
|
||||
private readonly ILogger<ChatHistoryMemoryProvider>? _logger;
|
||||
|
||||
private readonly ChatHistoryMemoryProviderScope _storageScope;
|
||||
private readonly ChatHistoryMemoryProviderScope _searchScope;
|
||||
|
||||
private bool _collectionInitialized;
|
||||
private readonly SemaphoreSlim _initializationLock = new(1, 1);
|
||||
private bool _disposedValue;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatHistoryMemoryProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="vectorStore">The vector store to use for storing and retrieving chat history.</param>
|
||||
/// <param name="collectionName">The name of the collection for storing chat history in the vector store.</param>
|
||||
/// <param name="vectorDimensions">The number of dimensions to use for the chat history vector store embeddings.</param>
|
||||
/// <param name="storageScope">Optional values to scope the chat history storage with.</param>
|
||||
/// <param name="searchScope">Optional values to scope the chat history search with. Where values are null, no filtering is done using those values. Defaults to <paramref name="storageScope"/> if not provided.</param>
|
||||
/// <param name="options">Optional configuration options.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="vectorStore"/> is <see langword="null"/>.</exception>
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatHistoryMemoryProvider"/> class from previously serialized state.
|
||||
/// </summary>
|
||||
/// <param name="vectorStore">The vector store to use for storing and retrieving chat history.</param>
|
||||
/// <param name="collectionName">The name of the collection for storing chat history in the vector store.</param>
|
||||
/// <param name="vectorDimensions">The number of dimensions to use for the chat history vector store embeddings.</param>
|
||||
/// <param name="serializedState">A <see cref="JsonElement"/> representing the serialized state of the provider.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
|
||||
/// <param name="options">Optional configuration options.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
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<ChatHistoryMemoryProvider>();
|
||||
|
||||
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<string, CancellationToken, Task<string>>)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<VectorStoreProperty>
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<AIContext> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<Dictionary<string, object?>> itemsToStore = context.RequestMessages
|
||||
.Concat(context.ResponseMessages ?? [])
|
||||
.Select(message => new Dictionary<string, object?>
|
||||
{
|
||||
["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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Function callable by the AI model (when enabled) to perform an ad-hoc chat history search.
|
||||
/// </summary>
|
||||
/// <param name="userQuestion">The query text.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Formatted search results (may be empty).</returns>
|
||||
internal async Task<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for relevant chat history items based on the provided query text.
|
||||
/// </summary>
|
||||
/// <param name="queryText">The text to search for.</param>
|
||||
/// <param name="top">The maximum number of results to return.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A list of relevant chat history items.</returns>
|
||||
private async Task<IEnumerable<Dictionary<string, object?>>> 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<Func<Dictionary<string, object?>, bool>>? filter = null;
|
||||
if (applicationId != null)
|
||||
{
|
||||
filter = x => (string?)x["ApplicationId"] == applicationId;
|
||||
}
|
||||
|
||||
if (agentId != null)
|
||||
{
|
||||
Expression<Func<Dictionary<string, object?>, bool>> agentIdFilter = x => (string?)x["AgentId"] == agentId;
|
||||
filter = filter == null ? agentIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
|
||||
Expression.AndAlso(filter.Body, agentIdFilter.Body),
|
||||
filter.Parameters);
|
||||
}
|
||||
|
||||
if (userId != null)
|
||||
{
|
||||
Expression<Func<Dictionary<string, object?>, bool>> userIdFilter = x => (string?)x["UserId"] == userId;
|
||||
filter = filter == null ? userIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
|
||||
Expression.AndAlso(filter.Body, userIdFilter.Body),
|
||||
filter.Parameters);
|
||||
}
|
||||
|
||||
if (threadId != null)
|
||||
{
|
||||
Expression<Func<Dictionary<string, object?>, bool>> threadIdFilter = x => (string?)x["ThreadId"] == threadId;
|
||||
filter = filter == null ? threadIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, 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<Dictionary<string, object?>>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the collection exists in the vector store, creating it if necessary.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The vector store collection.</returns>
|
||||
private async Task<VectorStoreCollection<object, Dictionary<string, object?>>> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (!this._disposedValue)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
this._initializationLock.Dispose();
|
||||
this._collection?.Dispose();
|
||||
}
|
||||
|
||||
this._disposedValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
|
||||
this.Dispose(disposing: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the current provider state to a <see cref="JsonElement"/> including storage and search scopes.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializerOptions">Optional serializer options.</param>
|
||||
/// <returns>Serialized provider state.</returns>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling the behavior of <see cref="ChatHistoryMemoryProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class ChatHistoryMemoryProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating when the search should be executed.
|
||||
/// </summary>
|
||||
/// <value><see cref="SearchBehavior.BeforeAIInvoke"/> by default.</value>
|
||||
public SearchBehavior SearchTime { get; set; } = SearchBehavior.BeforeAIInvoke;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the exposed search tool when operating in on-demand mode.
|
||||
/// </summary>
|
||||
/// <value>Defaults to "Search".</value>
|
||||
public string? FunctionToolName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the description of the exposed search tool when operating in on-demand mode.
|
||||
/// </summary>
|
||||
/// <value>Defaults to "Allows searching through previous chat history to help answer the user question.".</value>
|
||||
public string? FunctionToolDescription { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the context prompt prefixed to results.
|
||||
/// </summary>
|
||||
public string? ContextPrompt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of results to retrieve from the chat history.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// Defaults to 3 if not set.
|
||||
/// </value>
|
||||
public int? MaxResults { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Behavior choices for the provider.
|
||||
/// </summary>
|
||||
public enum SearchBehavior
|
||||
{
|
||||
/// <summary>
|
||||
/// Execute search prior to each invocation and inject results as a message.
|
||||
/// </summary>
|
||||
BeforeAIInvoke,
|
||||
|
||||
/// <summary>
|
||||
/// Expose a function tool to perform search on-demand via function/tool calling.
|
||||
/// </summary>
|
||||
OnDemandFunctionCalling
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Allows scoping of chat history for the <see cref="ChatHistoryMemoryProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class ChatHistoryMemoryProviderScope
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatHistoryMemoryProviderScope"/> class.
|
||||
/// </summary>
|
||||
public ChatHistoryMemoryProviderScope() { }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatHistoryMemoryProviderScope"/> class by cloning an existing scope.
|
||||
/// </summary>
|
||||
/// <param name="sourceScope">The scope to clone.</param>
|
||||
public ChatHistoryMemoryProviderScope(ChatHistoryMemoryProviderScope sourceScope)
|
||||
{
|
||||
Throw.IfNull(sourceScope);
|
||||
|
||||
this.ApplicationId = sourceScope.ApplicationId;
|
||||
this.AgentId = sourceScope.AgentId;
|
||||
this.ThreadId = sourceScope.ThreadId;
|
||||
this.UserId = sourceScope.UserId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional ID for the application to scope chat history to.
|
||||
/// </summary>
|
||||
/// <remarks>If not set, the scope of the chat history will span all applications.</remarks>
|
||||
public string? ApplicationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional ID for the agent to scope chat history to.
|
||||
/// </summary>
|
||||
/// <remarks>If not set, the scope of the chat history will span all agents.</remarks>
|
||||
public string? AgentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional ID for the thread to scope chat history to.
|
||||
/// </summary>
|
||||
public string? ThreadId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional ID for the user to scope chat history to.
|
||||
/// </summary>
|
||||
/// <remarks>If not set, the scope of the chat history will span all users.</remarks>
|
||||
public string? UserId { get; set; }
|
||||
}
|
||||
@@ -31,8 +31,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.UnitTests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
+502
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions.MapA2A method.
|
||||
/// </summary>
|
||||
public sealed class EndpointRouteA2ABuilderExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A throws ArgumentNullException for null endpoints.
|
||||
/// </summary>
|
||||
[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<ArgumentNullException>(() =>
|
||||
endpoints.MapA2A(agentBuilder, "/a2a"));
|
||||
|
||||
Assert.Equal("endpoints", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A throws ArgumentNullException for null agentBuilder.
|
||||
/// </summary>
|
||||
[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<ArgumentNullException>(() =>
|
||||
app.MapA2A(agentBuilder, "/a2a"));
|
||||
|
||||
Assert.Equal("agentBuilder", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with IHostedAgentBuilder correctly maps the agent with default task manager configuration.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with IHostedAgentBuilder and custom task manager configuration succeeds.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with IHostedAgentBuilder and agent card succeeds.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with IHostedAgentBuilder, agent card, and custom task manager configuration succeeds.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A throws ArgumentNullException for null endpoints when using string agent name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAgentName_NullEndpoints_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
endpoints.MapA2A("agent", "/a2a"));
|
||||
|
||||
Assert.Equal("endpoints", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with string agent name correctly maps the agent.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with string agent name and custom task manager configuration succeeds.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with string agent name and agent card succeeds.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with string agent name, agent card, and custom task manager configuration succeeds.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A throws ArgumentNullException for null endpoints when using AIAgent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAIAgent_NullEndpoints_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
endpoints.MapA2A((AIAgent)null!, "/a2a"));
|
||||
|
||||
Assert.Equal("endpoints", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with AIAgent correctly maps the agent.
|
||||
/// </summary>
|
||||
[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<AIAgent>("agent");
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2A(agent, "/a2a");
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with AIAgent and custom task manager configuration succeeds.
|
||||
/// </summary>
|
||||
[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<AIAgent>("agent");
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2A(agent, "/a2a", taskManager => { });
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with AIAgent and agent card succeeds.
|
||||
/// </summary>
|
||||
[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<AIAgent>("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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with AIAgent, agent card, and custom task manager configuration succeeds.
|
||||
/// </summary>
|
||||
[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<AIAgent>("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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A throws ArgumentNullException for null endpoints when using ITaskManager.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithTaskManager_NullEndpoints_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
|
||||
ITaskManager taskManager = null!;
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
endpoints.MapA2A(taskManager, "/a2a"));
|
||||
|
||||
Assert.Equal("endpoints", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that multiple agents can be mapped to different paths.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that custom paths can be specified for A2A endpoints.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that task manager configuration callback is invoked correctly.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that agent card with all properties is accepted.
|
||||
/// </summary>
|
||||
[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<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> 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<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -11,6 +11,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
+170
@@ -223,4 +223,174 @@ public sealed class EndpointRouteBuilderExtensionsTests
|
||||
app.MapOpenAIResponses(responsesPath: "/custom/path/responses");
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapOpenAIResponses throws ArgumentNullException for null endpoints when using IHostedAgentBuilder.
|
||||
/// </summary>
|
||||
[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<ArgumentNullException>(() =>
|
||||
endpoints.MapOpenAIResponses(agentBuilder));
|
||||
|
||||
Assert.Equal("endpoints", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapOpenAIResponses throws ArgumentNullException for null agentBuilder.
|
||||
/// </summary>
|
||||
[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<ArgumentNullException>(() =>
|
||||
app.MapOpenAIResponses(agentBuilder));
|
||||
|
||||
Assert.Equal("agentBuilder", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapOpenAIResponses with IHostedAgentBuilder correctly resolves and maps the agent.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapOpenAIResponses with IHostedAgentBuilder and custom path works correctly.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that multiple agents can be mapped using IHostedAgentBuilder.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IHostedAgentBuilder overload validates agent name characters.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("agent with spaces")]
|
||||
[InlineData("agent<script>")]
|
||||
[InlineData("agent?query")]
|
||||
[InlineData("agent#fragment")]
|
||||
public void MapOpenAIResponses_WithAgentBuilder_InvalidAgentNameCharacters_ThrowsArgumentException(string invalidName)
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent(invalidName, "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.AddOpenAIResponses();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert
|
||||
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
|
||||
app.MapOpenAIResponses(agentBuilder));
|
||||
|
||||
Assert.Contains("invalid for URL routes", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IHostedAgentBuilder overload accepts valid agent names.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("agent-name")]
|
||||
[InlineData("agent_name")]
|
||||
[InlineData("agent.name")]
|
||||
[InlineData("agent123")]
|
||||
[InlineData("my-agent_v1.0")]
|
||||
public void MapOpenAIResponses_WithAgentBuilder_ValidAgentNameCharacters_DoesNotThrow(string validName)
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent(validName, "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.AddOpenAIResponses();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
app.MapOpenAIResponses(agentBuilder);
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IHostedAgentBuilder overload with custom paths can be specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapOpenAIResponses_WithAgentBuilder_MultipleAgentsWithCustomPaths_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, path: "/api/v1/agent1/responses");
|
||||
app.MapOpenAIResponses(agent2Builder, path: "/api/v1/agent2/responses");
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
}
|
||||
|
||||
-96
@@ -137,102 +137,6 @@ public class HostApplicationBuilderWorkflowExtensionsTests
|
||||
Assert.NotNull(descriptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing a null builder to AddConcurrentWorkflow throws an ArgumentNullException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddConcurrentWorkflow_NullBuilder_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
HostApplicationBuilderWorkflowExtensions.AddConcurrentWorkflow(null!, "workflow", [null!]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddConcurrentWorkflow throws ArgumentNullException for null name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddConcurrentWorkflow_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddConcurrentWorkflow(null!, [new HostedAgentBuilder("test", builder)]));
|
||||
Assert.Equal("name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddConcurrentWorkflow throws ArgumentNullException for null agent builders.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddConcurrentWorkflow_NullAgentBuilders_ThrowsArgumentNullException()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddConcurrentWorkflow("workflowName", null!));
|
||||
Assert.Equal("agentBuilders", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddConcurrentWorkflow returns IHostWorkflowBuilder instance.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddConcurrentWorkflow_ValidParameters_ReturnsBuilder()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
var result = builder.AddConcurrentWorkflow("concurrentWorkflow", [new HostedAgentBuilder("test", builder)]);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.IsAssignableFrom<IHostedWorkflowBuilder>(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing a null builder to AddSequentialWorkflow throws an ArgumentNullException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddSequentialWorkflow_NullBuilder_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
HostApplicationBuilderWorkflowExtensions.AddSequentialWorkflow(null!, "workflow", [null!]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddSequentialWorkflow throws ArgumentNullException for null name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddSequentialWorkflow_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddSequentialWorkflow(null!, [new HostedAgentBuilder("test", builder)]));
|
||||
Assert.Equal("name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddSequentialWorkflow throws ArgumentNullException for null agent builders.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddSequentialWorkflow_NullAgentBuilders_ThrowsArgumentNullException()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddSequentialWorkflow("workflowName", null!));
|
||||
Assert.Equal("agentBuilders", exception.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSequentialWorkflow_EmptyAgentBuilders_Throws()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
var exception = Assert.Throws<ArgumentException>(() =>
|
||||
builder.AddSequentialWorkflow("sequentialWorkflow", Array.Empty<IHostedAgentBuilder>()));
|
||||
Assert.Equal("agentBuilders", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAsAIAgent without a name parameter uses the workflow name as the agent name.
|
||||
/// </summary>
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for AI tool registration extensions on <see cref="IHostedAgentBuilder"/>.
|
||||
/// </summary>
|
||||
public sealed class HostedAgentBuilderToolsExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void WithAITool_ThrowsWhenBuilderIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var tool = new DummyAITool();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => HostedAgentBuilderExtensions.WithAITool(null!, tool));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithAITool_ThrowsWhenToolIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", "Test instructions");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => builder.WithAITool(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithAITools_ThrowsWhenBuilderIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var tools = new[] { new DummyAITool() };
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => HostedAgentBuilderExtensions.WithAITools(null!, tools));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithAITools_ThrowsWhenToolsArrayIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", "Test instructions");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => builder.WithAITools(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisteredTools_ResolvesAllToolsForAgent()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IChatClient>(new MockChatClient());
|
||||
|
||||
var builder = services.AddAIAgent("test-agent", "Test instructions");
|
||||
var tool1 = new DummyAITool();
|
||||
var tool2 = new DummyAITool();
|
||||
|
||||
builder
|
||||
.WithAITool(tool1)
|
||||
.WithAITool(tool2);
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
var agent1Tools = ResolveAgentTools(serviceProvider, "test-agent");
|
||||
Assert.Contains(tool1, agent1Tools);
|
||||
Assert.Contains(tool2, agent1Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisteredTools_IsolatedPerAgent()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IChatClient>(new MockChatClient());
|
||||
|
||||
var builder1 = services.AddAIAgent("agent1", "Agent 1 instructions");
|
||||
var builder2 = services.AddAIAgent("agent2", "Agent 2 instructions");
|
||||
|
||||
var tool1 = new DummyAITool();
|
||||
var tool2 = new DummyAITool();
|
||||
var tool3 = new DummyAITool();
|
||||
|
||||
builder1
|
||||
.WithAITool(tool1)
|
||||
.WithAITool(tool2);
|
||||
|
||||
builder2
|
||||
.WithAITool(tool3);
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
var agent1Tools = ResolveAgentTools(serviceProvider, "agent1");
|
||||
var agent2Tools = ResolveAgentTools(serviceProvider, "agent2");
|
||||
|
||||
Assert.Contains(tool1, agent1Tools);
|
||||
Assert.Contains(tool2, agent1Tools);
|
||||
Assert.Contains(tool3, agent2Tools);
|
||||
}
|
||||
|
||||
private static IList<AITool> ResolveAgentTools(IServiceProvider serviceProvider, string name)
|
||||
{
|
||||
var agent = serviceProvider.GetRequiredKeyedService<AIAgent>(name) as ChatClientAgent;
|
||||
Assert.NotNull(agent?.ChatOptions?.Tools);
|
||||
return agent.ChatOptions.Tools;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dummy AITool implementation for testing.
|
||||
/// </summary>
|
||||
private sealed class DummyAITool : AITool
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock chat client for testing.
|
||||
/// </summary>
|
||||
private sealed class MockChatClient : IChatClient
|
||||
{
|
||||
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Memory.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains unit tests for the <see cref="ChatHistoryMemoryProvider"/> class.
|
||||
/// </summary>
|
||||
public class ChatHistoryMemoryProviderTests
|
||||
{
|
||||
private readonly Mock<ILogger<ChatHistoryMemoryProvider>> _loggerMock;
|
||||
private readonly Mock<ILoggerFactory> _loggerFactoryMock;
|
||||
|
||||
private readonly Mock<VectorStore> _vectorStoreMock;
|
||||
private readonly Mock<VectorStoreCollection<object, Dictionary<string, object?>>> _vectorStoreCollectionMock;
|
||||
private const string TestCollectionName = "testcollection";
|
||||
|
||||
public ChatHistoryMemoryProviderTests()
|
||||
{
|
||||
this._loggerMock = new();
|
||||
this._loggerFactoryMock = new();
|
||||
this._loggerFactoryMock
|
||||
.Setup(f => f.CreateLogger(It.IsAny<string>()))
|
||||
.Returns(this._loggerMock.Object);
|
||||
this._loggerFactoryMock
|
||||
.Setup(f => f.CreateLogger(typeof(ChatHistoryMemoryProvider).FullName!))
|
||||
.Returns(this._loggerMock.Object);
|
||||
|
||||
this._vectorStoreCollectionMock = new(MockBehavior.Strict);
|
||||
this._vectorStoreMock = new(MockBehavior.Strict);
|
||||
|
||||
this._vectorStoreCollectionMock
|
||||
.Setup(c => c.EnsureCollectionExistsAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
this._vectorStoreMock
|
||||
.Setup(vs => vs.GetDynamicCollection(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<VectorStoreCollectionDefinition>()))
|
||||
.Returns(this._vectorStoreCollectionMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_ForNullVectorStore()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new ChatHistoryMemoryProvider(null!, "testcollection", 1, new ChatHistoryMemoryProviderScope() { UserId = "UID" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_ForNullCollectionName()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, null!, 1, new ChatHistoryMemoryProviderScope() { UserId = "UID" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_ForNullStorageScope()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, "testcollection", 1, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_ForInvalidVectorDimensions()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, "testcollection", 0, new ChatHistoryMemoryProviderScope() { UserId = "UID" }));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, "testcollection", -5, new ChatHistoryMemoryProviderScope() { UserId = "UID" }));
|
||||
}
|
||||
|
||||
#region InvokedAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedAsync_UpsertsMessages_ToCollectionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var stored = new List<Dictionary<string, object?>>();
|
||||
|
||||
this._vectorStoreCollectionMock
|
||||
.Setup(c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<Dictionary<string, object?>>, CancellationToken>((items, ct) =>
|
||||
{
|
||||
if (items != null)
|
||||
{
|
||||
stored.AddRange(items);
|
||||
}
|
||||
})
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var storeScope = new ChatHistoryMemoryProviderScope
|
||||
{
|
||||
ApplicationId = "app1",
|
||||
AgentId = "agent1",
|
||||
ThreadId = "thread1",
|
||||
UserId = "user1"
|
||||
};
|
||||
|
||||
var provider = new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, TestCollectionName, 1, storeScope);
|
||||
|
||||
var requestMsgWithValues = new ChatMessage(ChatRole.User, "request text") { MessageId = "req-1", AuthorName = "user1", CreatedAt = new DateTimeOffset(new DateTime(2000, 1, 1), TimeSpan.Zero) };
|
||||
var requestMsgWithNulls = new ChatMessage(ChatRole.User, "request text nulls");
|
||||
var responseMsg = new ChatMessage(ChatRole.Assistant, "response text") { MessageId = "resp-1", AuthorName = "assistant" };
|
||||
|
||||
var invokedContext = new AIContextProvider.InvokedContext([requestMsgWithValues, requestMsgWithNulls], aiContextProviderMessages: null)
|
||||
{
|
||||
ResponseMessages = [responseMsg]
|
||||
};
|
||||
|
||||
// Act
|
||||
await provider.InvokedAsync(invokedContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
this._vectorStoreCollectionMock.Verify(
|
||||
m => m.EnsureCollectionExistsAsync(It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
|
||||
Assert.Equal(3, stored.Count);
|
||||
|
||||
Assert.Equal("req-1", stored[0]["MessageId"]);
|
||||
Assert.Equal("request text", stored[0]["Content"]);
|
||||
Assert.Equal("user1", stored[0]["AuthorName"]);
|
||||
Assert.Equal(ChatRole.User.ToString(), stored[0]["Role"]);
|
||||
Assert.Equal("2000-01-01T00:00:00.0000000+00:00", stored[0]["CreatedAt"]);
|
||||
Assert.Equal("app1", stored[0]["ApplicationId"]);
|
||||
Assert.Equal("agent1", stored[0]["AgentId"]);
|
||||
Assert.Equal("thread1", stored[0]["ThreadId"]);
|
||||
Assert.Equal("user1", stored[0]["UserId"]);
|
||||
|
||||
Assert.Null(stored[1]["MessageId"]);
|
||||
Assert.Equal("request text nulls", stored[1]["Content"]);
|
||||
Assert.Null(stored[1]["AuthorName"]);
|
||||
Assert.Equal(ChatRole.User.ToString(), stored[1]["Role"]);
|
||||
Assert.Equal("app1", stored[1]["ApplicationId"]);
|
||||
Assert.Equal("agent1", stored[1]["AgentId"]);
|
||||
Assert.Equal("thread1", stored[1]["ThreadId"]);
|
||||
Assert.Equal("user1", stored[1]["UserId"]);
|
||||
|
||||
Assert.Equal("resp-1", stored[2]["MessageId"]);
|
||||
Assert.Equal("response text", stored[2]["Content"]);
|
||||
Assert.Equal("assistant", stored[2]["AuthorName"]);
|
||||
Assert.Equal(ChatRole.Assistant.ToString(), stored[2]["Role"]);
|
||||
Assert.Equal("app1", stored[2]["ApplicationId"]);
|
||||
Assert.Equal("agent1", stored[2]["AgentId"]);
|
||||
Assert.Equal("thread1", stored[2]["ThreadId"]);
|
||||
Assert.Equal("user1", stored[2]["UserId"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedAsync_DoesNotUpsertMessages_WhenInvokeFailedAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._vectorStoreCollectionMock
|
||||
.Setup(c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var provider = new ChatHistoryMemoryProvider(
|
||||
this._vectorStoreMock.Object,
|
||||
TestCollectionName,
|
||||
1,
|
||||
new ChatHistoryMemoryProviderScope() { UserId = "UID" });
|
||||
var requestMsg = new ChatMessage(ChatRole.User, "request text") { MessageId = "req-1" };
|
||||
var invokedContext = new AIContextProvider.InvokedContext([requestMsg], aiContextProviderMessages: null)
|
||||
{
|
||||
InvokeException = new InvalidOperationException("Invoke failed")
|
||||
};
|
||||
|
||||
// Act
|
||||
await provider.InvokedAsync(invokedContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
this._vectorStoreCollectionMock.Verify(
|
||||
c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedAsync_DoesNotThrow_WhenUpsertThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._vectorStoreCollectionMock
|
||||
.Setup(c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Upsert failed"));
|
||||
|
||||
var provider = new ChatHistoryMemoryProvider(
|
||||
this._vectorStoreMock.Object,
|
||||
TestCollectionName,
|
||||
1,
|
||||
new ChatHistoryMemoryProviderScope() { UserId = "UID" },
|
||||
loggerFactory: this._loggerFactoryMock.Object);
|
||||
var requestMsg = new ChatMessage(ChatRole.User, "request text") { MessageId = "req-1" };
|
||||
var invokedContext = new AIContextProvider.InvokedContext([requestMsg], aiContextProviderMessages: null);
|
||||
|
||||
// Act
|
||||
await provider.InvokedAsync(invokedContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
this._loggerMock.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("ChatHistoryMemoryProvider: Failed to add messages to chat history vector store due to error")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InvokingAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedAsync_SearchesVectorStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
var providerOptions = new ChatHistoryMemoryProviderOptions
|
||||
{
|
||||
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke,
|
||||
MaxResults = 2,
|
||||
ContextPrompt = "Here is the relevant chat history:\n"
|
||||
};
|
||||
|
||||
var storedItems = new List<VectorSearchResult<Dictionary<string, object?>>>
|
||||
{
|
||||
new(
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["MessageId"] = "msg-1",
|
||||
["Content"] = "First stored message",
|
||||
["Role"] = ChatRole.User.ToString(),
|
||||
["CreatedAt"] = "2023-01-01T00:00:00.0000000+00:00"
|
||||
},
|
||||
0.9f),
|
||||
new(
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["MessageId"] = "msg-2",
|
||||
["Content"] = "Second stored message",
|
||||
["Role"] = ChatRole.User.ToString(),
|
||||
["CreatedAt"] = "2023-01-02T00:00:00.0000000+00:00"
|
||||
},
|
||||
0.8f)
|
||||
};
|
||||
|
||||
this._vectorStoreCollectionMock
|
||||
.Setup(c => c.SearchAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(storedItems));
|
||||
|
||||
var provider = new ChatHistoryMemoryProvider(
|
||||
this._vectorStoreMock.Object,
|
||||
TestCollectionName,
|
||||
1,
|
||||
new ChatHistoryMemoryProviderScope() { UserId = "UID" },
|
||||
options: providerOptions);
|
||||
|
||||
var requestMsg = new ChatMessage(ChatRole.User, "requesting relevant history");
|
||||
var invokingContext = new AIContextProvider.InvokingContext([requestMsg]);
|
||||
|
||||
// Act
|
||||
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
this._vectorStoreCollectionMock.Verify(
|
||||
c => c.SearchAsync(
|
||||
It.Is<string>(s => s == "requesting relevant history"),
|
||||
2,
|
||||
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedAsync_CreatesFilter_WhenSearchScopeProvidedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var providerOptions = new ChatHistoryMemoryProviderOptions
|
||||
{
|
||||
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke,
|
||||
MaxResults = 2,
|
||||
ContextPrompt = "Here is the relevant chat history:\n"
|
||||
};
|
||||
|
||||
var searchScope = new ChatHistoryMemoryProviderScope
|
||||
{
|
||||
ApplicationId = "app1",
|
||||
AgentId = "agent1",
|
||||
ThreadId = "thread1",
|
||||
UserId = "user1"
|
||||
};
|
||||
|
||||
this._vectorStoreCollectionMock
|
||||
.Setup(c => c.SearchAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback((string query, int maxResults, VectorSearchOptions<Dictionary<string, object?>> options, CancellationToken ct) =>
|
||||
{
|
||||
// Verify that the filter was created correctly
|
||||
const string ExpectedFilter = "x => ((((x.ApplicationId == value(Microsoft.Agents.AI.VectorDataMemory.ChatHistoryMemoryProvider+<>c__DisplayClass20_0).applicationId) AndAlso (x.AgentId == value(Microsoft.Agents.AI.VectorDataMemory.ChatHistoryMemoryProvider+<>c__DisplayClass20_0).agentId)) AndAlso (x.UserId == value(Microsoft.Agents.AI.VectorDataMemory.ChatHistoryMemoryProvider+<>c__DisplayClass20_0).userId)) AndAlso (x.ThreadId == value(Microsoft.Agents.AI.VectorDataMemory.ChatHistoryMemoryProvider+<>c__DisplayClass20_0).threadId))";
|
||||
Assert.Equal(ExpectedFilter, options.Filter!.ToString());
|
||||
})
|
||||
.Returns(ToAsyncEnumerableAsync(new List<VectorSearchResult<Dictionary<string, object?>>>()));
|
||||
|
||||
var provider = new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, TestCollectionName, 1, options: providerOptions, storageScope: searchScope, searchScope: searchScope);
|
||||
|
||||
var requestMsg = new ChatMessage(ChatRole.User, "requesting relevant history");
|
||||
var invokingContext = new AIContextProvider.InvokingContext([requestMsg]);
|
||||
|
||||
// Act
|
||||
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
this._vectorStoreCollectionMock.Verify(
|
||||
c => c.SearchAsync(
|
||||
It.Is<string>(s => s == "requesting relevant history"),
|
||||
2,
|
||||
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serialization Tests
|
||||
|
||||
[Fact]
|
||||
public void Serialize_Deserialize_RoundtripsScopes()
|
||||
{
|
||||
// Arrange
|
||||
var storageScope = new ChatHistoryMemoryProviderScope
|
||||
{
|
||||
ApplicationId = "app",
|
||||
AgentId = "agent",
|
||||
ThreadId = "thread",
|
||||
UserId = "user"
|
||||
};
|
||||
|
||||
var searchScope = new ChatHistoryMemoryProviderScope
|
||||
{
|
||||
ApplicationId = "app2",
|
||||
AgentId = "agent2",
|
||||
ThreadId = "thread2",
|
||||
UserId = "user2"
|
||||
};
|
||||
|
||||
var provider = new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, TestCollectionName, 1, storageScope: storageScope, searchScope: searchScope);
|
||||
|
||||
// Act
|
||||
var stateElement = provider.Serialize();
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(stateElement.GetRawText());
|
||||
var storage = doc.RootElement.GetProperty("storageScope");
|
||||
Assert.Equal("app", storage.GetProperty("applicationId").GetString());
|
||||
Assert.Equal("agent", storage.GetProperty("agentId").GetString());
|
||||
Assert.Equal("thread", storage.GetProperty("threadId").GetString());
|
||||
Assert.Equal("user", storage.GetProperty("userId").GetString());
|
||||
|
||||
var search = doc.RootElement.GetProperty("searchScope");
|
||||
Assert.Equal("app2", search.GetProperty("applicationId").GetString());
|
||||
Assert.Equal("agent2", search.GetProperty("agentId").GetString());
|
||||
Assert.Equal("thread2", search.GetProperty("threadId").GetString());
|
||||
Assert.Equal("user2", search.GetProperty("userId").GetString());
|
||||
|
||||
// Act - deserialize and serialize again
|
||||
var provider2 = new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, TestCollectionName, 1, serializedState: stateElement);
|
||||
var stateElement2 = provider2.Serialize();
|
||||
|
||||
// Assert - roundtrip the state
|
||||
Assert.Equal(stateElement.GetRawText(), stateElement2.GetRawText());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(IEnumerable<T> values)
|
||||
{
|
||||
await Task.Yield();
|
||||
foreach (var update in values)
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user