.NET: [BREAKING] Simplify TextSearchProvider construction and improve Mem0Provider scoping. (#1905)

* Simplify TextSearchProvider construction and improve Mem0Provider scoping

* Fixing indentation.
This commit is contained in:
westey
2025-11-05 13:00:18 +00:00
committed by GitHub
Unverified
parent bb8ef466de
commit 5e38c63455
12 changed files with 251 additions and 287 deletions
@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -31,10 +32,15 @@ public sealed class Mem0Provider : AIContextProvider
private readonly Mem0Client _client;
private readonly ILogger<Mem0Provider>? _logger;
private readonly Mem0ProviderScope _storageScope;
private readonly Mem0ProviderScope _searchScope;
/// <summary>
/// Initializes a new instance of the <see cref="Mem0Provider"/> class.
/// </summary>
/// <param name="httpClient">Configured <see cref="HttpClient"/> (base address + auth).</param>
/// <param name="storageScope">Optional values to scope the memory storage with.</param>
/// <param name="searchScope">Optional values to scope the memory search with. Defaults to <paramref name="storageScope"/> if not provided.</param>
/// <param name="options">Provider options.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
/// <remarks>
@@ -47,21 +53,35 @@ public sealed class Mem0Provider : AIContextProvider
/// new Mem0AIContextProvider(httpClient);
/// </code>
/// </remarks>
public Mem0Provider(HttpClient httpClient, Mem0ProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
public Mem0Provider(HttpClient httpClient, Mem0ProviderScope storageScope, Mem0ProviderScope? searchScope = null, Mem0ProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
{
if (string.IsNullOrWhiteSpace(httpClient.BaseAddress?.AbsoluteUri))
{
throw new ArgumentException("The HttpClient BaseAddress must be set for Mem0 operations.", nameof(httpClient));
}
this.ApplicationId = options?.ApplicationId;
this.AgentId = options?.AgentId;
this.ThreadId = options?.ThreadId;
this.UserId = options?.UserId;
this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt;
this._logger = loggerFactory?.CreateLogger<Mem0Provider>();
this._client = new Mem0Client(httpClient);
this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt;
this._storageScope = new Mem0ProviderScope(Throw.IfNull(storageScope));
this._searchScope = searchScope ?? storageScope;
if (string.IsNullOrWhiteSpace(this._storageScope.ApplicationId)
&& string.IsNullOrWhiteSpace(this._storageScope.AgentId)
&& string.IsNullOrWhiteSpace(this._storageScope.ThreadId)
&& string.IsNullOrWhiteSpace(this._storageScope.UserId))
{
throw new ArgumentException("At least one of ApplicationId, AgentId, ThreadId, or UserId must be provided for the storage scope.");
}
if (string.IsNullOrWhiteSpace(this._searchScope.ApplicationId)
&& string.IsNullOrWhiteSpace(this._searchScope.AgentId)
&& string.IsNullOrWhiteSpace(this._searchScope.ThreadId)
&& string.IsNullOrWhiteSpace(this._searchScope.UserId))
{
throw new ArgumentException("At least one of ApplicationId, AgentId, ThreadId, or UserId must be provided for the search scope.");
}
}
/// <summary>
@@ -70,6 +90,7 @@ public sealed class Mem0Provider : AIContextProvider
/// <param name="httpClient">Configured <see cref="HttpClient"/> (base address + auth).</param>
/// <param name="serializedState">A <see cref="JsonElement"/> representing the serialized state of the store.</param>
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
/// <param name="options">Provider options.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
/// <exception cref="ArgumentException"></exception>
/// <remarks>
@@ -82,46 +103,30 @@ public sealed class Mem0Provider : AIContextProvider
/// new Mem0AIContextProvider(httpClient, state);
/// </code>
/// </remarks>
public Mem0Provider(HttpClient httpClient, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, ILoggerFactory? loggerFactory = null)
public Mem0Provider(HttpClient httpClient, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, Mem0ProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
{
if (string.IsNullOrWhiteSpace(httpClient.BaseAddress?.AbsoluteUri))
{
throw new ArgumentException("The HttpClient BaseAddress must be set for Mem0 operations.", nameof(httpClient));
}
this._logger = loggerFactory?.CreateLogger<Mem0Provider>();
this._client = new Mem0Client(httpClient);
this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt;
var jso = jsonSerializerOptions ?? Mem0JsonUtilities.DefaultOptions;
var state = serializedState.Deserialize(jso.GetTypeInfo(typeof(Mem0State))) as Mem0State;
this.ApplicationId = state?.ApplicationId;
this.AgentId = state?.AgentId;
this.ThreadId = state?.ThreadId;
this.UserId = state?.UserId;
this._contextPrompt = state?.ContextPrompt ?? DefaultContextPrompt;
if (state == null || state.StorageScope == null || state.SearchScope == null)
{
throw new InvalidOperationException("The Mem0Provider state did not contain the required scope properties.");
}
this._logger = loggerFactory?.CreateLogger<Mem0Provider>();
this._client = new Mem0Client(httpClient);
this._storageScope = state.StorageScope;
this._searchScope = state.SearchScope;
}
/// <summary>
/// Gets or sets an optional ID for the application to scope memories to.
/// </summary>
public string? ApplicationId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the agent to scope memories to.
/// </summary>
public string? AgentId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the thread to scope memories to.
/// </summary>
public string? ThreadId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the user to scope memories to.
/// </summary>
public string? UserId { get; set; }
/// <inheritdoc />
public override async ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
@@ -134,10 +139,10 @@ public sealed class Mem0Provider : AIContextProvider
try
{
var memories = (await this._client.SearchAsync(
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId,
this._searchScope.ApplicationId,
this._searchScope.AgentId,
this._searchScope.ThreadId,
this._searchScope.UserId,
queryText,
cancellationToken).ConfigureAwait(false)).ToList();
@@ -150,20 +155,20 @@ public sealed class Mem0Provider : AIContextProvider
this._logger.LogInformation(
"Mem0AIContextProvider: Retrieved {Count} memories. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
memories.Count,
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId);
this._searchScope.ApplicationId,
this._searchScope.AgentId,
this._searchScope.ThreadId,
this._searchScope.UserId);
if (outputMessageText is not null)
{
this._logger.LogTrace(
"Mem0AIContextProvider: Search Results\nInput:{Input}\nOutput:{MessageText}\nApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
queryText,
outputMessageText,
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId);
this._searchScope.ApplicationId,
this._searchScope.AgentId,
this._searchScope.ThreadId,
this._searchScope.UserId);
}
}
@@ -181,10 +186,10 @@ 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}'",
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId);
this._searchScope.ApplicationId,
this._searchScope.AgentId,
this._searchScope.ThreadId,
this._searchScope.UserId);
return new AIContext();
}
}
@@ -207,10 +212,10 @@ 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}'",
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId);
this._storageScope.ApplicationId,
this._storageScope.AgentId,
this._storageScope.ThreadId,
this._storageScope.UserId);
}
}
@@ -220,23 +225,16 @@ public sealed class Mem0Provider : AIContextProvider
/// <param name="cancellationToken">Cancellation token.</param>
public Task ClearStoredMemoriesAsync(CancellationToken cancellationToken = default) =>
this._client.ClearMemoryAsync(
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId,
this._storageScope.ApplicationId,
this._storageScope.AgentId,
this._storageScope.ThreadId,
this._storageScope.UserId,
cancellationToken);
/// <inheritdoc />
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
var state = new Mem0State
{
ApplicationId = this.ApplicationId,
AgentId = this.AgentId,
ThreadId = this.ThreadId,
UserId = this.UserId,
ContextPrompt = this._contextPrompt == DefaultContextPrompt ? null : this._contextPrompt
};
var state = new Mem0State(this._storageScope, this._searchScope);
var jso = jsonSerializerOptions ?? Mem0JsonUtilities.DefaultOptions;
return JsonSerializer.SerializeToElement(state, jso.GetTypeInfo(typeof(Mem0State)));
@@ -262,10 +260,10 @@ public sealed class Mem0Provider : AIContextProvider
}
await this._client.CreateMemoryAsync(
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId,
this._storageScope.ApplicationId,
this._storageScope.AgentId,
this._storageScope.ThreadId,
this._storageScope.UserId,
message.Text,
message.Role.Value,
cancellationToken).ConfigureAwait(false);
@@ -274,10 +272,14 @@ public sealed class Mem0Provider : AIContextProvider
internal sealed class Mem0State
{
public string? ApplicationId { get; set; }
public string? AgentId { get; set; }
public string? UserId { get; set; }
public string? ThreadId { get; set; }
public string? ContextPrompt { get; set; }
[JsonConstructor]
public Mem0State(Mem0ProviderScope storageScope, Mem0ProviderScope searchScope)
{
this.StorageScope = storageScope;
this.SearchScope = searchScope;
}
public Mem0ProviderScope StorageScope { get; set; }
public Mem0ProviderScope SearchScope { get; set; }
}
}
@@ -5,35 +5,8 @@ namespace Microsoft.Agents.AI.Mem0;
/// <summary>
/// Options for configuring the <see cref="Mem0Provider"/>.
/// </summary>
/// <remarks>
/// Mem0 memories can be scoped by one or more of: application, agent, thread, and user.
/// At least one scope must be provided; otherwise Mem0 will reject requests.
/// </remarks>
public sealed class Mem0ProviderOptions
{
/// <summary>
/// Gets or sets an optional ID for the application to scope memories to.
/// </summary>
/// <remarks>If not set, the scope of the memories will span all applications.</remarks>
public string? ApplicationId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the agent to scope memories to.
/// </summary>
/// <remarks>If not set, the scope of the memories will span all agents.</remarks>
public string? AgentId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the thread to scope memories to.
/// </summary>
public string? ThreadId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the user to scope memories to.
/// </summary>
/// <remarks>If not set, the scope of the memories will span all users.</remarks>
public string? UserId { get; set; }
/// <summary>
/// When providing memories to the model, this string is prefixed to the retrieved memories to supply context.
/// </summary>
@@ -0,0 +1,57 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Mem0;
/// <summary>
/// Allows scoping of memories for the <see cref="Mem0Provider"/>.
/// </summary>
/// <remarks>
/// Mem0 memories can be scoped by one or more of: application, agent, thread, and user.
/// At least one scope must be provided; otherwise Mem0 will reject requests.
/// </remarks>
public sealed class Mem0ProviderScope
{
/// <summary>
/// Initializes a new instance of the <see cref="Mem0ProviderScope"/> class.
/// </summary>
public Mem0ProviderScope() { }
/// <summary>
/// Initializes a new instance of the <see cref="Mem0ProviderScope"/> class by cloning an existing scope.
/// </summary>
/// <param name="sourceScope">The scope to clone.</param>
public Mem0ProviderScope(Mem0ProviderScope 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 memories to.
/// </summary>
/// <remarks>If not set, the scope of the memories will span all applications.</remarks>
public string? ApplicationId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the agent to scope memories to.
/// </summary>
/// <remarks>If not set, the scope of the memories will span all agents.</remarks>
public string? AgentId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the thread to scope memories to.
/// </summary>
public string? ThreadId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the user to scope memories to.
/// </summary>
/// <remarks>If not set, the scope of the memories will span all users.</remarks>
public string? UserId { get; set; }
}
@@ -44,82 +44,72 @@ public sealed class TextSearchProvider : AIContextProvider
private readonly ILogger<TextSearchProvider>? _logger;
private readonly AITool[] _tools;
private readonly Queue<string> _recentMessagesText;
private readonly TextSearchProviderOptions _options;
private readonly List<ChatRole> _recentMessageRolesIncluded;
private readonly int _recentMessageMemoryLimit;
private readonly TextSearchProviderOptions.TextSearchBehavior _searchTime;
private readonly string _contextPrompt;
private readonly string _citationsPrompt;
private readonly Func<IList<TextSearchResult>, string>? _contextFormatter;
/// <summary>
/// Initializes a new instance of the <see cref="TextSearchProvider"/> class.
/// </summary>
/// <param name="searchAsync">Delegate that executes the search logic. Must not be <see langword="null"/>.</param>
/// <param name="options">Optional configuration options.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="searchAsync"/> is <see langword="null"/>.</exception>
public TextSearchProvider(Func<string, CancellationToken, Task<IEnumerable<TextSearchResult>>> searchAsync, TextSearchProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
{
this._searchAsync = searchAsync ?? throw new ArgumentNullException(nameof(searchAsync));
this._options = options ?? new();
Throw.IfLessThan(this._options.RecentMessageMemoryLimit, 0);
this._logger = loggerFactory?.CreateLogger<TextSearchProvider>();
this._recentMessagesText = new();
this._recentMessageRolesIncluded = this._options.RecentMessageRolesIncluded ?? [ChatRole.User];
// Create the on-demand search tool (only used if behavior is OnDemandFunctionCalling)
this._tools =
[
AIFunctionFactory.Create(
this.SearchAsync,
name: this._options.FunctionToolName ?? DefaultPluginSearchFunctionName,
description: this._options.FunctionToolDescription ?? DefaultPluginSearchFunctionDescription)
];
}
/// <summary>
/// Initializes a new instance of the <see cref="TextSearchProvider"/> class from previously serialized state.
/// </summary>
/// <param name="searchAsync">Delegate that executes the search logic. Must not be <see langword="null"/>.</param>
/// <param name="serializedState">A <see cref="JsonElement"/> representing the serialized provider state.</param>
/// <param name="jsonSerializerOptions">Optional serializer options (unused - source generated context is used).</param>
/// <param name="options">Optional configuration options.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="searchAsync"/> is <see langword="null"/>.</exception>
/// <remarks>
/// Only overridden prompts (function name, function description, context prompt, citations prompt) are restored.
/// If a value was not persisted or matches the defaults it will fall back to the built-in defaults.
/// Custom <see cref="TextSearchProviderOptions.ContextFormatter"/> delegates are not serialized.
/// </remarks>
public TextSearchProvider(Func<string, CancellationToken, Task<IEnumerable<TextSearchResult>>> searchAsync, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, TextSearchProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
public TextSearchProvider(
Func<string, CancellationToken, Task<IEnumerable<TextSearchResult>>> searchAsync,
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions = null,
TextSearchProviderOptions? options = null,
ILoggerFactory? loggerFactory = null)
{
this._searchAsync = searchAsync ?? throw new ArgumentNullException(nameof(searchAsync));
this._options = options ?? new();
Throw.IfLessThan(this._options.RecentMessageMemoryLimit, 0);
// Validate and assign parameters
this._searchAsync = Throw.IfNull(searchAsync);
this._logger = loggerFactory?.CreateLogger<TextSearchProvider>();
this._recentMessageRolesIncluded = this._options.RecentMessageRolesIncluded ?? [ChatRole.User];
this._recentMessageMemoryLimit = Throw.IfLessThan(options?.RecentMessageMemoryLimit ?? 0, 0);
this._recentMessageRolesIncluded = options?.RecentMessageRolesIncluded ?? [ChatRole.User];
this._searchTime = options?.SearchTime ?? TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke;
this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt;
this._citationsPrompt = options?.CitationsPrompt ?? DefaultCitationsPrompt;
this._contextFormatter = options?.ContextFormatter;
// Restore recent messages from serialized state if provided
List<string>? restoredMessages = null;
var state = serializedState.Deserialize(AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(TextSearchProviderState))) as TextSearchProviderState;
if (state?.RecentMessagesText is { Count: > 0 })
if (serializedState.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
restoredMessages = state.RecentMessagesText;
this._recentMessagesText = new();
}
else
{
var jso = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
var state = serializedState.Deserialize(jso.GetTypeInfo(typeof(TextSearchProviderState))) as TextSearchProviderState;
if (state?.RecentMessagesText is { Count: > 0 })
{
restoredMessages = state.RecentMessagesText;
}
// Restore recent messages respecting the limit (may truncate if limit changed afterwards).
this._recentMessagesText = restoredMessages is null ? new() : new(restoredMessages.Take(this._options.RecentMessageMemoryLimit));
// Restore recent messages respecting the limit (may truncate if limit changed afterwards).
this._recentMessagesText = restoredMessages is null ? new() : new(restoredMessages.Take(this._recentMessageMemoryLimit));
}
// Create the on-demand search tool (only used if behavior is OnDemandFunctionCalling)
this._tools =
[
AIFunctionFactory.Create(
this.SearchAsync,
name: this._options.FunctionToolName ?? DefaultPluginSearchFunctionName,
description: this._options.FunctionToolDescription ?? DefaultPluginSearchFunctionDescription)
name: options?.FunctionToolName ?? DefaultPluginSearchFunctionName,
description: options?.FunctionToolDescription ?? DefaultPluginSearchFunctionDescription)
];
}
/// <inheritdoc />
public override async ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
if (this._options.SearchTime != TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke)
if (this._searchTime != TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke)
{
// Expose the search tool for on-demand invocation.
return new AIContext { Tools = this._tools }; // No automatic message injection.
@@ -171,7 +161,7 @@ public sealed class TextSearchProvider : AIContextProvider
/// <inheritdoc />
public override ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
int limit = this._options.RecentMessageMemoryLimit;
int limit = this._recentMessageMemoryLimit;
if (limit <= 0)
{
return default; // Memory disabled.
@@ -220,9 +210,9 @@ public sealed class TextSearchProvider : AIContextProvider
{
// Only persist values that differ from defaults plus recent memory configuration & messages.
TextSearchProviderState state = new();
if (this._options.RecentMessageMemoryLimit > 0 && this._recentMessagesText.Count > 0)
if (this._recentMessageMemoryLimit > 0 && this._recentMessagesText.Count > 0)
{
state.RecentMessagesText = this._recentMessagesText.Take(this._options.RecentMessageMemoryLimit).ToList();
state.RecentMessagesText = this._recentMessagesText.Take(this._recentMessageMemoryLimit).ToList();
}
return JsonSerializer.SerializeToElement(state, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(TextSearchProviderState)));
@@ -253,9 +243,9 @@ public sealed class TextSearchProvider : AIContextProvider
/// <returns>Formatted string (may be empty).</returns>
private string FormatResults(IList<TextSearchResult> results)
{
if (this._options.ContextFormatter is not null)
if (this._contextFormatter is not null)
{
return this._options.ContextFormatter(results) ?? string.Empty;
return this._contextFormatter(results) ?? string.Empty;
}
if (results.Count == 0)
@@ -264,7 +254,7 @@ public sealed class TextSearchProvider : AIContextProvider
}
var sb = new StringBuilder();
sb.AppendLine(this._options.ContextPrompt ?? DefaultContextPrompt);
sb.AppendLine(this._contextPrompt);
for (int i = 0; i < results.Count; i++)
{
var result = results[i];
@@ -279,7 +269,7 @@ public sealed class TextSearchProvider : AIContextProvider
sb.AppendLine($"Contents: {result.Text}");
sb.AppendLine("----");
}
sb.AppendLine(this._options.CitationsPrompt ?? DefaultCitationsPrompt);
sb.AppendLine(this._citationsPrompt);
sb.AppendLine();
return sb.ToString();
}