.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
@@ -28,7 +28,7 @@ AIAgent agent = new AzureOpenAIClient(
.CreateAIAgent(new ChatClientAgentOptions
{
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
AIContextProviderFactory = _ => new TextSearchProvider(MockSearchAsync, textSearchOptions)
AIContextProviderFactory = ctx => new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
});
AgentThread thread = agent.GetNewThread();
@@ -63,9 +63,7 @@ AIAgent agent = azureOpenAIClient
.CreateAIAgent(new ChatClientAgentOptions
{
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
? new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
: new TextSearchProvider(SearchAdapter, textSearchOptions)
AIContextProviderFactory = ctx => new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
});
AgentThread thread = agent.GetNewThread();
@@ -72,9 +72,7 @@ AIAgent agent = azureOpenAIClient
.CreateAIAgent(new ChatClientAgentOptions
{
Instructions = "You are a helpful support specialist for the Microsoft Agent Framework. Answer questions using the provided context and cite the source document when available. Keep responses brief.",
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
? new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
: new TextSearchProvider(SearchAdapter, textSearchOptions)
AIContextProviderFactory = ctx => new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
});
AgentThread thread = agent.GetNewThread();
@@ -28,9 +28,7 @@ AIAgent agent = new AzureOpenAIClient(
.CreateAIAgent(new ChatClientAgentOptions
{
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
? new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
: new TextSearchProvider(MockSearchAsync, textSearchOptions)
AIContextProviderFactory = ctx => new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
});
AgentThread thread = agent.GetNewThread();
@@ -33,9 +33,9 @@ AIAgent agent = new AzureOpenAIClient(
Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details.",
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not JsonValueKind.Null or JsonValueKind.Undefined
// If each thread should have its own Mem0 scope, you can create a new id per thread here:
// ? new Mem0Provider(mem0HttpClient, new Mem0ProviderOptions() { ThreadId = Guid.NewGuid().ToString() })
// ? new Mem0Provider(mem0HttpClient, new Mem0ProviderScope() { ThreadId = Guid.NewGuid().ToString() })
// In this case we are storing memories scoped by application and user instead so that memories are retained across threads.
? new Mem0Provider(mem0HttpClient, new Mem0ProviderOptions() { ApplicationId = "getting-started-agents", UserId = "sample-user" })
? new Mem0Provider(mem0HttpClient, new Mem0ProviderScope() { ApplicationId = "getting-started-agents", UserId = "sample-user" })
// For cases where we are restoring from serialized state:
: new Mem0Provider(mem0HttpClient, ctx.SerializedState, ctx.JsonSerializerOptions)
});
@@ -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();
}
@@ -45,8 +45,8 @@ public sealed class Mem0ProviderTests : IDisposable
// Arrange
var question = new ChatMessage(ChatRole.User, "What is my name?");
var input = new ChatMessage(ChatRole.User, "Hello, my name is Caoimhe.");
var options = new Mem0ProviderOptions { ThreadId = "it-thread-1", UserId = "it-user-1" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ThreadId = "it-thread-1", UserId = "it-user-1" };
var sut = new Mem0Provider(this._httpClient, storageScope);
await sut.ClearStoredMemoriesAsync();
var ctxBefore = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question }));
@@ -69,8 +69,8 @@ public sealed class Mem0ProviderTests : IDisposable
// Arrange
var question = new ChatMessage(ChatRole.User, "What is your name?");
var assistantIntro = new ChatMessage(ChatRole.Assistant, "Hello, I'm a friendly assistant and my name is Caoimhe.");
var options = new Mem0ProviderOptions { AgentId = "it-agent-1" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { AgentId = "it-agent-1" };
var sut = new Mem0Provider(this._httpClient, storageScope);
await sut.ClearStoredMemoriesAsync();
var ctxBefore = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question }));
@@ -93,8 +93,8 @@ public sealed class Mem0ProviderTests : IDisposable
// Arrange
var question = new ChatMessage(ChatRole.User, "What is your name?");
var assistantIntro = new ChatMessage(ChatRole.Assistant, "I'm an AI tutor and my name is Caoimhe.");
var sut1 = new Mem0Provider(this._httpClient, new Mem0ProviderOptions { AgentId = "it-agent-a" });
var sut2 = new Mem0Provider(this._httpClient, new Mem0ProviderOptions { AgentId = "it-agent-b" });
var sut1 = new Mem0Provider(this._httpClient, new Mem0ProviderScope { AgentId = "it-agent-a" });
var sut2 = new Mem0Provider(this._httpClient, new Mem0ProviderScope { AgentId = "it-agent-b" });
await sut1.ClearStoredMemoriesAsync();
await sut2.ClearStoredMemoriesAsync();
@@ -48,35 +48,35 @@ public sealed class Mem0ProviderTests : IDisposable
using HttpClient client = new();
// Act & Assert
var ex = Assert.Throws<ArgumentException>(() => new Mem0Provider(client));
var ex = Assert.Throws<ArgumentException>(() => new Mem0Provider(client, new Mem0ProviderScope() { ThreadId = "tid" }));
Assert.StartsWith("The HttpClient BaseAddress must be set for Mem0 operations.", ex.Message);
}
[Fact]
public void Constructor_Defaults_Scopes()
public void Constructor_Throws_WhenNoStorageScopeValueIsSet()
{
// Arrange & Act
var sut = new Mem0Provider(this._httpClient);
// Assert
Assert.Null(sut.ApplicationId);
Assert.Null(sut.AgentId);
Assert.Null(sut.ThreadId);
Assert.Null(sut.UserId);
// Act & Assert
var ex = Assert.Throws<ArgumentException>(() => new Mem0Provider(this._httpClient, new Mem0ProviderScope()));
Assert.StartsWith("At least one of ApplicationId, AgentId, ThreadId, or UserId must be provided for the storage scope.", ex.Message);
}
[Fact]
public void DeserializingConstructor_Defaults_Scopes()
public void Constructor_Throws_WhenNoSearchScopeValueIsSet()
{
// Arrange & Act
var jsonElement = JsonSerializer.SerializeToElement(new object(), Mem0JsonUtilities.DefaultOptions);
var sut = new Mem0Provider(this._httpClient, jsonElement);
// Act & Assert
var ex = Assert.Throws<ArgumentException>(() => new Mem0Provider(this._httpClient, new Mem0ProviderScope() { ThreadId = "tid" }, new Mem0ProviderScope()));
Assert.StartsWith("At least one of ApplicationId, AgentId, ThreadId, or UserId must be provided for the search scope.", ex.Message);
}
// Assert
Assert.Null(sut.ApplicationId);
Assert.Null(sut.AgentId);
Assert.Null(sut.ThreadId);
Assert.Null(sut.UserId);
[Fact]
public void DeserializingConstructor_Throws_WithEmptyJsonElement()
{
// Arrange
var jsonElement = JsonSerializer.SerializeToElement(new object(), Mem0JsonUtilities.DefaultOptions);
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => new Mem0Provider(this._httpClient, jsonElement));
Assert.StartsWith("The Mem0Provider state did not contain the required scope properties.", ex.Message);
}
[Fact]
@@ -84,14 +84,14 @@ public sealed class Mem0ProviderTests : IDisposable
{
// Arrange
this._handler.EnqueueJsonResponse("[ { \"id\": \"1\", \"memory\": \"Name is Caoimhe\", \"hash\": \"h\", \"metadata\": null, \"score\": 0.9, \"created_at\": \"2023-01-01T00:00:00Z\", \"updated_at\": null, \"user_id\": \"u\", \"app_id\": null, \"agent_id\": \"agent\", \"session_id\": \"thread\" } ]");
var options = new Mem0ProviderOptions
var storageScope = new Mem0ProviderScope
{
ApplicationId = "app",
AgentId = "agent",
ThreadId = "thread",
UserId = "user"
};
var sut = new Mem0Provider(this._httpClient, options, this._loggerFactoryMock.Object);
var sut = new Mem0Provider(this._httpClient, storageScope, loggerFactory: this._loggerFactoryMock.Object);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "What is my name?") });
// Act
@@ -137,8 +137,8 @@ public sealed class Mem0ProviderTests : IDisposable
this._handler.EnqueueEmptyOk(); // For first CreateMemory
this._handler.EnqueueEmptyOk(); // For second CreateMemory
this._handler.EnqueueEmptyOk(); // For third CreateMemory
var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, storageScope);
var requestMessages = new List<ChatMessage>
{
@@ -168,8 +168,8 @@ public sealed class Mem0ProviderTests : IDisposable
public async Task InvokedAsync_PersistsNothingForFailedRequestAsync()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, storageScope);
var requestMessages = new List<ChatMessage>
{
@@ -189,8 +189,8 @@ public sealed class Mem0ProviderTests : IDisposable
public async Task InvokedAsync_ShouldNotThrow_WhenStorageFailsAsync()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, options, this._loggerFactoryMock.Object);
var storageScope = new Mem0ProviderScope { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, storageScope, loggerFactory: this._loggerFactoryMock.Object);
this._handler.EnqueueEmptyInternalServerError();
var requestMessages = new List<ChatMessage>
@@ -222,8 +222,8 @@ public sealed class Mem0ProviderTests : IDisposable
public async Task ClearStoredMemoriesAsync_SendsDeleteWithQueryAsync()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" };
var sut = new Mem0Provider(this._httpClient, storageScope);
this._handler.EnqueueEmptyOk(); // for DELETE
// Act
@@ -235,80 +235,39 @@ public sealed class Mem0ProviderTests : IDisposable
}
[Fact]
public void Properties_Roundtrip()
public void Serialize_RoundTripsScopes()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" };
var sut = new Mem0Provider(this._httpClient, options);
// Assert
Assert.Equal("app", sut.ApplicationId);
Assert.Equal("agent", sut.AgentId);
Assert.Equal("thread", sut.ThreadId);
Assert.Equal("user", sut.UserId);
// Act
sut.ApplicationId = "app2";
sut.AgentId = "agent2";
sut.ThreadId = "thread2";
sut.UserId = "user2";
// Assert
Assert.Equal("app2", sut.ApplicationId);
Assert.Equal("agent2", sut.AgentId);
Assert.Equal("thread2", sut.ThreadId);
Assert.Equal("user2", sut.UserId);
}
[Fact]
public void Serialize_Deserialize_Roundtrips()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" };
var sut = new Mem0Provider(this._httpClient, options);
// Act
var stateElement = sut.Serialize();
var sut2 = new Mem0Provider(this._httpClient, stateElement);
// Assert
Assert.Equal("app", sut.ApplicationId);
Assert.Equal("agent", sut.AgentId);
Assert.Equal("thread", sut.ThreadId);
Assert.Equal("user", sut.UserId);
Assert.Equal("app", sut2.ApplicationId);
Assert.Equal("agent", sut2.AgentId);
Assert.Equal("thread", sut2.ThreadId);
Assert.Equal("user", sut2.UserId);
}
[Fact]
public void Serialize_RoundTripsCustomContextPrompt()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user", ContextPrompt = "Custom:" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" };
var sut = new Mem0Provider(this._httpClient, storageScope, options: new() { ContextPrompt = "Custom:" }, loggerFactory: this._loggerFactoryMock.Object);
// Act
var stateElement = sut.Serialize();
using JsonDocument doc = JsonDocument.Parse(stateElement.GetRawText());
Assert.Equal("Custom:", doc.RootElement.GetProperty("contextPrompt").GetString());
var storageScopeElement = doc.RootElement.GetProperty("storageScope");
Assert.Equal("app", storageScopeElement.GetProperty("applicationId").GetString());
Assert.Equal("agent", storageScopeElement.GetProperty("agentId").GetString());
Assert.Equal("thread", storageScopeElement.GetProperty("threadId").GetString());
Assert.Equal("user", storageScopeElement.GetProperty("userId").GetString());
var sut2 = new Mem0Provider(this._httpClient, stateElement);
var stateElement2 = sut2.Serialize();
// Assert
using JsonDocument doc2 = JsonDocument.Parse(stateElement2.GetRawText());
Assert.Equal("Custom:", doc2.RootElement.GetProperty("contextPrompt").GetString());
var storageScopeElement2 = doc2.RootElement.GetProperty("storageScope");
Assert.Equal("app", storageScopeElement2.GetProperty("applicationId").GetString());
Assert.Equal("agent", storageScopeElement2.GetProperty("agentId").GetString());
Assert.Equal("thread", storageScopeElement2.GetProperty("threadId").GetString());
Assert.Equal("user", storageScopeElement2.GetProperty("userId").GetString());
}
[Fact]
public void Serialize_DoesNotIncludeDefaultContextPrompt()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ApplicationId = "app" };
var sut = new Mem0Provider(this._httpClient, storageScope);
// Act
var stateElement = sut.Serialize();
@@ -318,23 +277,12 @@ public sealed class Mem0ProviderTests : IDisposable
Assert.False(doc.RootElement.TryGetProperty("contextPrompt", out _));
}
[Fact]
public async Task InvokingAsync_Throws_WhenNoScopesAsync()
{
// Arrange
var sut = new Mem0Provider(this._httpClient, new Mem0ProviderOptions());
var ctx = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Test") });
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => sut.InvokingAsync(ctx).AsTask());
}
[Fact]
public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app" };
var provider = new Mem0Provider(this._httpClient, options, loggerFactory: this._loggerFactoryMock.Object);
var storageScope = new Mem0ProviderScope { ApplicationId = "app" };
var provider = new Mem0Provider(this._httpClient, storageScope, loggerFactory: this._loggerFactoryMock.Object);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -58,7 +58,7 @@ public sealed class TextSearchProviderTests
ContextPrompt = overrideContextPrompt,
CitationsPrompt = overrideCitationsPrompt
};
var provider = new TextSearchProvider(SearchDelegateAsync, options, withLogging ? this._loggerFactoryMock.Object : null);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options, withLogging ? this._loggerFactoryMock.Object : null);
var invokingContext = new AIContextProvider.InvokingContext(new[]
{
@@ -135,7 +135,7 @@ public sealed class TextSearchProviderTests
FunctionToolName = overrideName,
FunctionToolDescription = overrideDescription
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -154,7 +154,7 @@ public sealed class TextSearchProviderTests
public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync()
{
// Arrange
var provider = new TextSearchProvider(this.FailingSearchAsync, loggerFactory: this._loggerFactoryMock.Object);
var provider = new TextSearchProvider(this.FailingSearchAsync, default, null, loggerFactory: this._loggerFactoryMock.Object);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -195,7 +195,7 @@ public sealed class TextSearchProviderTests
ContextPrompt = overrideContextPrompt,
CitationsPrompt = overrideCitationsPrompt
};
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// Act
var formatted = await provider.SearchAsync("Sample user question?", CancellationToken.None);
@@ -247,7 +247,7 @@ public sealed class TextSearchProviderTests
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
ContextFormatter = r => $"Custom formatted context with {r.Count} results."
};
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -281,7 +281,7 @@ public sealed class TextSearchProviderTests
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
ContextFormatter = r => string.Join(",", r.Select(x => ((RawPayload)x.RawRepresentation!).Id))
};
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -298,7 +298,7 @@ public sealed class TextSearchProviderTests
{
// Arrange
var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke };
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -327,7 +327,7 @@ public sealed class TextSearchProviderTests
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed.
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// Populate memory with more messages than the limit (A,B,C,D) -> should retain B,C,D
var initialMessages = new[]
@@ -367,7 +367,7 @@ public sealed class TextSearchProviderTests
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed.
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// Populate memory with more messages than the limit (A,B,C,D) -> should retain B,C,D
var initialMessages = new[]
@@ -407,7 +407,7 @@ public sealed class TextSearchProviderTests
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// First memory update (A,B)
await provider.InvokedAsync(new(new[]
@@ -449,7 +449,7 @@ public sealed class TextSearchProviderTests
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed for this test.
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// Populate memory with mixed roles; only Assistant messages (A1,A2) should be retained.
var initialMessages = new[]
@@ -486,7 +486,7 @@ public sealed class TextSearchProviderTests
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 3
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
// Act
var state = provider.Serialize();
@@ -506,7 +506,7 @@ public sealed class TextSearchProviderTests
RecentMessageMemoryLimit = 3,
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
var messages = new[]
{
new ChatMessage(ChatRole.User, "M1"),
@@ -536,7 +536,7 @@ public sealed class TextSearchProviderTests
RecentMessageMemoryLimit = 4,
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
var messages = new[]
{
new ChatMessage(ChatRole.User, "A"),
@@ -571,7 +571,7 @@ public sealed class TextSearchProviderTests
public async Task Deserialize_WithChangedLowerLimit_ShouldTruncateToNewLimitAsync()
{
// Arrange
var initialProvider = new TextSearchProvider(this.NoResultSearchAsync, new TextSearchProviderOptions
var initialProvider = new TextSearchProvider(this.NoResultSearchAsync, default, null, new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 5,