Merge branch 'main' into feature-foundry-agents

This commit is contained in:
Chris
2025-11-07 09:14:33 -08:00
committed by GitHub
Unverified
50 changed files with 3808 additions and 284 deletions
@@ -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>
@@ -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>