diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index c82d5f02b6..1a14b73115 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -236,6 +236,7 @@ + @@ -263,6 +264,7 @@ + @@ -273,6 +275,7 @@ + @@ -285,6 +288,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Client.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Client.cs new file mode 100644 index 0000000000..ad8120c402 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Client.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Mem0; + +/// +/// Client for the Mem0 memory service. +/// +internal sealed class Mem0Client +{ + private static readonly Uri s_searchUri = new("/v1/memories/search/", UriKind.Relative); + private static readonly Uri s_createMemoryUri = new("/v1/memories/", UriKind.Relative); + + private readonly HttpClient _httpClient; + + /// + /// Initializes a new instance of the class. + /// + /// Configured pointing at the Mem0 service (base address + auth headers). + public Mem0Client(HttpClient httpClient) + { + this._httpClient = Throw.IfNull(httpClient); + } + + /// + /// Searches for memories related to an input query. + /// + /// Optional application scope. + /// Optional agent scope. + /// Optional thread scope. + /// Optional user scope. + /// Query text. + /// Cancellation token. + /// Enumerable of memory strings. + public async Task> SearchAsync(string? applicationId, string? agentId, string? threadId, string? userId, string? inputText, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(applicationId) + && string.IsNullOrWhiteSpace(agentId) + && string.IsNullOrWhiteSpace(threadId) + && string.IsNullOrWhiteSpace(userId)) + { + throw new ArgumentException("At least one of applicationId, agentId, threadId, or userId must be provided."); + } + + var searchRequest = new SearchRequest + { + AppId = applicationId, + AgentId = agentId, + RunId = threadId, + UserId = userId, + Query = inputText ?? string.Empty + }; + + using var content = new StringContent(JsonSerializer.Serialize(searchRequest, Mem0SourceGenerationContext.Default.SearchRequest), Encoding.UTF8, "application/json"); + using var responseMessage = await this._httpClient.PostAsync(s_searchUri, content, cancellationToken).ConfigureAwait(false); + responseMessage.EnsureSuccessStatusCode(); + +#if NET + var response = await responseMessage.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + var response = await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + var searchResponseItems = JsonSerializer.Deserialize(response, Mem0SourceGenerationContext.Default.SearchResponseItemArray); + return searchResponseItems?.Select(item => item.Memory) ?? Array.Empty(); + } + + /// + /// Creates a memory for the provided message content. + /// + public async Task CreateMemoryAsync(string? applicationId, string? agentId, string? threadId, string? userId, string messageContent, string messageRole, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(applicationId) + && string.IsNullOrWhiteSpace(agentId) + && string.IsNullOrWhiteSpace(threadId) + && string.IsNullOrWhiteSpace(userId)) + { + throw new ArgumentException("At least one of applicationId, agentId, threadId, or userId must be provided."); + } + +#pragma warning disable CA1308 // Lowercase required by service + var createMemoryRequest = new CreateMemoryRequest + { + AppId = applicationId, + AgentId = agentId, + RunId = threadId, + UserId = userId, + Messages = new[] + { + new CreateMemoryMessage + { + Content = messageContent, + Role = messageRole.ToLowerInvariant() + } + } + }; +#pragma warning restore CA1308 + + using var content = new StringContent(JsonSerializer.Serialize(createMemoryRequest, Mem0SourceGenerationContext.Default.CreateMemoryRequest), Encoding.UTF8, "application/json"); + using var responseMessage = await this._httpClient.PostAsync(s_createMemoryUri, content, cancellationToken).ConfigureAwait(false); + responseMessage.EnsureSuccessStatusCode(); + } + + /// + /// Clears memories for the provided scope combination. + /// + public async Task ClearMemoryAsync(string? applicationId, string? agentId, string? threadId, string? userId, CancellationToken cancellationToken) + { + string[] paramNames = ["app_id", "agent_id", "run_id", "user_id"]; + + var querystringParams = new string?[4] { applicationId, agentId, threadId, userId } + .Select((param, index) => string.IsNullOrWhiteSpace(param) ? null : $"{paramNames[index]}={param}") + .Where(x => x is not null); + var queryString = string.Join("&", querystringParams); + var clearMemoryUrl = new Uri($"/v1/memories/?{queryString}", UriKind.Relative); + + using var responseMessage = await this._httpClient.DeleteAsync(clearMemoryUrl, cancellationToken).ConfigureAwait(false); + responseMessage.EnsureSuccessStatusCode(); + } + + internal sealed class CreateMemoryRequest + { + [JsonPropertyName("app_id")] public string? AppId { get; set; } + [JsonPropertyName("agent_id")] public string? AgentId { get; set; } + [JsonPropertyName("run_id")] public string? RunId { get; set; } + [JsonPropertyName("user_id")] public string? UserId { get; set; } + [JsonPropertyName("messages")] public CreateMemoryMessage[] Messages { get; set; } = Array.Empty(); + } + + internal sealed class CreateMemoryMessage + { + [JsonPropertyName("content")] public string Content { get; set; } = string.Empty; + [JsonPropertyName("role")] public string Role { get; set; } = string.Empty; + } + + internal sealed class SearchRequest + { + [JsonPropertyName("app_id")] public string? AppId { get; set; } + [JsonPropertyName("agent_id")] public string? AgentId { get; set; } + [JsonPropertyName("run_id")] public string? RunId { get; set; } + [JsonPropertyName("user_id")] public string? UserId { get; set; } + [JsonPropertyName("query")] public string Query { get; set; } = string.Empty; + } + + internal sealed class SearchResponseItem + { + [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; + [JsonPropertyName("memory")] public string Memory { get; set; } = string.Empty; + [JsonPropertyName("hash")] public string Hash { get; set; } = string.Empty; + [JsonPropertyName("metadata")] public object? Metadata { get; set; } + [JsonPropertyName("score")] public double Score { get; set; } + [JsonPropertyName("created_at")] public DateTime CreatedAt { get; set; } + [JsonPropertyName("updated_at")] public DateTime? UpdatedAt { get; set; } + [JsonPropertyName("user_id")] public string UserId { get; set; } = string.Empty; + [JsonPropertyName("app_id")] public string? AppId { get; set; } + [JsonPropertyName("agent_id")] public string AgentId { get; set; } = string.Empty; + [JsonPropertyName("session_id")] public string RunId { get; set; } = string.Empty; + } +} + +[JsonSourceGenerationOptions(JsonSerializerDefaults.General, + UseStringEnumConverter = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false)] +[JsonSerializable(typeof(Mem0Client.CreateMemoryRequest))] +[JsonSerializable(typeof(Mem0Client.SearchRequest))] +[JsonSerializable(typeof(Mem0Client.SearchResponseItem[]))] +internal partial class Mem0SourceGenerationContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0JsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0JsonUtilities.cs new file mode 100644 index 0000000000..eb50d31f70 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0JsonUtilities.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Mem0; + +/// Provides a collection of utility methods for working with JSON data in the context of mem0. +public static partial class Mem0JsonUtilities +{ + /// + /// Gets the singleton used as the default in JSON serialization operations. + /// + /// + /// + /// For Native AOT or applications disabling , this instance + /// includes source generated contracts for all common exchange types contained in this library. + /// + /// + /// It additionally turns on the following settings: + /// + /// Enables defaults. + /// Enables as the default ignore condition for properties. + /// Enables as the default number handling for number types. + /// + /// + /// + public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions(); + + /// + /// Creates default options to use for agents-related serialization. + /// + /// The configured options. + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")] + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")] + private static JsonSerializerOptions CreateDefaultOptions() + { + // Copy the configuration from the source generated context. + JsonSerializerOptions options = new(JsonContext.Default.Options) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // same as in AIJsonUtilities + }; + + // Chain with all supported types from Microsoft.Extensions.AI.Abstractions. + options.TypeInfoResolverChain.Add(AIJsonUtilities.DefaultOptions.TypeInfoResolver!); + if (JsonSerializer.IsReflectionEnabledByDefault) + { + options.Converters.Add(new JsonStringEnumConverter()); + } + + options.MakeReadOnly(); + return options; + } + + // Keep in sync with CreateDefaultOptions above. + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowReadingFromString)] + + // Agent abstraction types + [JsonSerializable(typeof(Mem0Provider.Mem0State))] + + [ExcludeFromCodeCoverage] + internal sealed partial class JsonContext : JsonSerializerContext; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs new file mode 100644 index 0000000000..9f5ab29b82 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs @@ -0,0 +1,239 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Mem0; + +/// +/// Provides a Mem0 backed that persists conversation messages as memories +/// and retrieves related memories to augment the agent invocation context. +/// +/// +/// The provider stores user, assistant and system messages as Mem0 memories and retrieves relevant memories +/// for new invocations using a semantic search endpoint. Retrieved memories are injected as user messages +/// to the model, prefixed by a configurable context prompt. +/// +public sealed class Mem0Provider : AIContextProvider +{ + private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:"; + + private readonly string _contextPrompt; + + private readonly Mem0Client _client; + private readonly ILogger? _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Configured (base address + auth). + /// Provider options. + /// Optional logger factory. + /// + /// The base address of the required mem0 service, and any authentication headers, should be set on the + /// already, when passed as a parameter here. E.g.: + /// + /// using var httpClient = new HttpClient(); + /// httpClient.BaseAddress = new Uri("https://api.mem0.ai"); + /// httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", "<Your APIKey>"); + /// new Mem0AIContextProvider(httpClient); + /// + /// + public Mem0Provider(HttpClient httpClient, 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(); + this._client = new Mem0Client(httpClient); + } + + /// + /// Initializes a new instance of the class, with existing state from a serialized JSON element. + /// + /// Configured (base address + auth). + /// A representing the serialized state of the store. + /// Optional settings for customizing the JSON deserialization process. + /// Optional logger factory. + /// + /// + /// The base address of the required mem0 service, and any authentication headers, should be set on the + /// already, when passed as a parameter here. E.g.: + /// + /// using var httpClient = new HttpClient(); + /// httpClient.BaseAddress = new Uri("https://api.mem0.ai"); + /// httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", "<Your APIKey>"); + /// new Mem0AIContextProvider(httpClient, state); + /// + /// + public Mem0Provider(HttpClient httpClient, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, ILoggerFactory? loggerFactory = null) + { + if (string.IsNullOrWhiteSpace(httpClient.BaseAddress?.AbsoluteUri)) + { + throw new ArgumentException("The HttpClient BaseAddress must be set for Mem0 operations.", nameof(httpClient)); + } + + 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; + + this._logger = loggerFactory?.CreateLogger(); + this._client = new Mem0Client(httpClient); + } + + /// + /// Gets or sets an optional ID for the application to scope memories to. + /// + public string? ApplicationId { get; set; } + + /// + /// Gets or sets an optional ID for the agent to scope memories to. + /// + public string? AgentId { get; set; } + + /// + /// Gets or sets an optional ID for the thread to scope memories to. + /// + public string? ThreadId { get; set; } + + /// + /// Gets or sets an optional ID for the user to scope memories to. + /// + public string? UserId { get; set; } + + /// + public override async ValueTask InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + Throw.IfNull(context); + + string queryText = string.Join( + Environment.NewLine, + context.RequestMessages.Where(m => !string.IsNullOrWhiteSpace(m.Text)).Select(m => m.Text)); + + var memories = (await this._client.SearchAsync( + this.ApplicationId, + this.AgentId, + this.ThreadId, + this.UserId, + queryText, + cancellationToken).ConfigureAwait(false)).ToList(); + + var contextInstructions = memories.Count == 0 + ? null + : $"{this._contextPrompt}\n{string.Join(Environment.NewLine, memories)}"; + + if (this._logger is not null) + { + this._logger.LogInformation("Mem0AIContextProvider retrieved {Count} memories.", memories.Count); + if (contextInstructions is not null) + { + this._logger.LogTrace("Mem0AIContextProvider instructions: {Instructions}", contextInstructions); + } + } + + return new AIContext + { + Messages = [new ChatMessage(ChatRole.User, contextInstructions)] + }; + } + + /// + public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default) + { + // Persist request and response messages after invocation. + await this.PersistMessagesAsync(context.RequestMessages, cancellationToken).ConfigureAwait(false); + + if (context.ResponseMessages is not null) + { + await this.PersistMessagesAsync(context.ResponseMessages, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Clears stored memories for the configured scopes. + /// + /// Cancellation token. + public Task ClearStoredMemoriesAsync(CancellationToken cancellationToken = default) => + this._client.ClearMemoryAsync( + this.ApplicationId, + this.AgentId, + this.ThreadId, + this.UserId, + cancellationToken); + + /// + 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 jso = jsonSerializerOptions ?? Mem0JsonUtilities.DefaultOptions; + return JsonSerializer.SerializeToElement(state, jso.GetTypeInfo(typeof(Mem0State))); + } + + private async Task PersistMessagesAsync(IEnumerable messages, CancellationToken cancellationToken) + { + foreach (var message in messages) + { + switch (message.Role) + { + case ChatRole u when u == ChatRole.User: + case ChatRole a when a == ChatRole.Assistant: + case ChatRole s when s == ChatRole.System: + break; + default: + continue; // ignore other roles + } + + if (string.IsNullOrWhiteSpace(message.Text)) + { + continue; + } + + await this._client.CreateMemoryAsync( + this.ApplicationId, + this.AgentId, + this.ThreadId, + this.UserId, + message.Text, + message.Role.Value, + cancellationToken).ConfigureAwait(false); + } + } + + 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; } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs new file mode 100644 index 0000000000..f9987698a8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Mem0; + +/// +/// Options for configuring the . +/// +/// +/// 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. +/// +public sealed class Mem0ProviderOptions +{ + /// + /// Gets or sets an optional ID for the application to scope memories to. + /// + /// If not set, the scope of the memories will span all applications. + public string? ApplicationId { get; set; } + + /// + /// Gets or sets an optional ID for the agent to scope memories to. + /// + /// If not set, the scope of the memories will span all agents. + public string? AgentId { get; set; } + + /// + /// Gets or sets an optional ID for the thread to scope memories to. + /// + public string? ThreadId { get; set; } + + /// + /// Gets or sets an optional ID for the user to scope memories to. + /// + /// If not set, the scope of the memories will span all users. + public string? UserId { get; set; } + + /// + /// When providing memories to the model, this string is prefixed to the retrieved memories to supply context. + /// + /// Defaults to "## Memories\nConsider the following memories when answering user questions:". + public string? ContextPrompt { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj b/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj new file mode 100644 index 0000000000..9f29a3b09f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj @@ -0,0 +1,30 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + preview + + + + true + true + + + + + + + + + + + Microsoft Agent Framework - Mem0 integration + Provides Mem0 integration for Microsoft Agent Framework. + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs index 3ee7f65009..4e1e0ac27f 100644 --- a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs @@ -43,7 +43,7 @@ internal static partial class AgentJsonUtilities Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // same as in AgentAbstractionsJsonUtilities and AIJsonUtilities }; - // Chain with all supported types from Microsoft.Extensions.AI.Abstractions. + // Chain with all supported types from Microsoft.Agents.AI.Abstractions. options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!); if (JsonSerializer.IsReflectionEnabledByDefault) { diff --git a/dotnet/src/Shared/IntegrationTests/Mem0Configuration.cs b/dotnet/src/Shared/IntegrationTests/Mem0Configuration.cs new file mode 100644 index 0000000000..052a38f113 --- /dev/null +++ b/dotnet/src/Shared/IntegrationTests/Mem0Configuration.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Shared.IntegrationTests; + +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +#pragma warning disable CA1812 // Internal class that is apparently never instantiated. + +internal sealed class Mem0Configuration +{ + public string ServiceUri { get; set; } + public string ApiKey { get; set; } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Mem0ProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Mem0ProviderTests.cs new file mode 100644 index 0000000000..0a1c192428 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Mem0ProviderTests.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Shared.IntegrationTests; + +namespace Microsoft.Agents.AI.Mem0.IntegrationTests; + +/// +/// Integration tests for against a configured Mem0 service. +/// +public sealed class Mem0ProviderTests : IDisposable +{ + private const string SkipReason = "Requires a Mem0 service configured"; // Set to null to enable. + + private readonly HttpClient _httpClient; + + public Mem0ProviderTests() + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddJsonFile(path: "testsettings.json", optional: true, reloadOnChange: true) + .AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .AddUserSecrets(optional: true) + .Build(); + + var mem0Settings = configuration.GetSection("Mem0").Get(); + this._httpClient = new HttpClient(); + + if (mem0Settings is not null && !string.IsNullOrWhiteSpace(mem0Settings.ServiceUri) && !string.IsNullOrWhiteSpace(mem0Settings.ApiKey)) + { + this._httpClient.BaseAddress = new Uri(mem0Settings.ServiceUri); + this._httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", mem0Settings.ApiKey); + } + } + + [Fact(Skip = SkipReason)] + public async Task CanAddAndRetrieveUserMemoriesAsync() + { + // 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); + + await sut.ClearStoredMemoriesAsync(); + var ctxBefore = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question })); + Assert.DoesNotContain("Caoimhe", ctxBefore.Messages?[0].Text ?? string.Empty); + + // Act + await sut.InvokedAsync(new AIContextProvider.InvokedContext(new[] { input })); + var ctxAfterAdding = await GetContextWithRetryAsync(sut, question); + await sut.ClearStoredMemoriesAsync(); + var ctxAfterClearing = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question })); + + // Assert + Assert.Contains("Caoimhe", ctxAfterAdding.Messages?[0].Text ?? string.Empty); + Assert.DoesNotContain("Caoimhe", ctxAfterClearing.Messages?[0].Text ?? string.Empty); + } + + [Fact(Skip = SkipReason)] + public async Task CanAddAndRetrieveAgentMemoriesAsync() + { + // 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); + + await sut.ClearStoredMemoriesAsync(); + var ctxBefore = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question })); + Assert.DoesNotContain("Caoimhe", ctxBefore.Messages?[0].Text ?? string.Empty); + + // Act + await sut.InvokedAsync(new AIContextProvider.InvokedContext(new[] { assistantIntro })); + var ctxAfterAdding = await GetContextWithRetryAsync(sut, question); + await sut.ClearStoredMemoriesAsync(); + var ctxAfterClearing = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question })); + + // Assert + Assert.Contains("Caoimhe", ctxAfterAdding.Messages?[0].Text ?? string.Empty); + Assert.DoesNotContain("Caoimhe", ctxAfterClearing.Messages?[0].Text ?? string.Empty); + } + + [Fact(Skip = SkipReason)] + public async Task DoesNotLeakMemoriesAcrossAgentScopesAsync() + { + // 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" }); + + await sut1.ClearStoredMemoriesAsync(); + await sut2.ClearStoredMemoriesAsync(); + + var ctxBefore1 = await sut1.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question })); + var ctxBefore2 = await sut2.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question })); + Assert.DoesNotContain("Caoimhe", ctxBefore1.Messages?[0].Text ?? string.Empty); + Assert.DoesNotContain("Caoimhe", ctxBefore2.Messages?[0].Text ?? string.Empty); + + // Act + await sut1.InvokedAsync(new AIContextProvider.InvokedContext(new[] { assistantIntro })); + var ctxAfterAdding1 = await GetContextWithRetryAsync(sut1, question); + var ctxAfterAdding2 = await GetContextWithRetryAsync(sut2, question); + + // Assert + Assert.Contains("Caoimhe", ctxAfterAdding1.Messages?[0].Text ?? string.Empty); + Assert.DoesNotContain("Caoimhe", ctxAfterAdding2.Messages?[0].Text ?? string.Empty); + + // Cleanup + await sut1.ClearStoredMemoriesAsync(); + await sut2.ClearStoredMemoriesAsync(); + } + + private static async Task GetContextWithRetryAsync(Mem0Provider provider, ChatMessage question, int attempts = 5, int delayMs = 1000) + { + AIContext? ctx = null; + for (int i = 0; i < attempts; i++) + { + ctx = await provider.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question }), CancellationToken.None); + var text = ctx.Messages?[0].Text; + if (!string.IsNullOrEmpty(text) && text.IndexOf("Caoimhe", StringComparison.OrdinalIgnoreCase) >= 0) + { + break; + } + await Task.Delay(delayMs); + } + return ctx!; + } + + public void Dispose() + { + this._httpClient.Dispose(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj new file mode 100644 index 0000000000..190d38e1dd --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj @@ -0,0 +1,21 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + True + + + + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs new file mode 100644 index 0000000000..b6968e407c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs @@ -0,0 +1,306 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Mem0.UnitTests; + +/// +/// Tests for . +/// +public sealed class Mem0ProviderTests : IDisposable +{ + private readonly RecordingHandler _handler = new(); + private readonly HttpClient _httpClient; + private readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(b => b.AddProvider(new NullLoggerProvider())); + private bool _disposed; + + public Mem0ProviderTests() + { + this._httpClient = new HttpClient(this._handler) + { + BaseAddress = new Uri("https://localhost/") + }; + } + + [Fact] + public void Constructor_Throws_WhenBaseAddressMissing() + { + // Arrange + using HttpClient client = new(); + + // Act & Assert + var ex = Assert.Throws(() => new Mem0Provider(client)); + Assert.StartsWith("The HttpClient BaseAddress must be set for Mem0 operations.", ex.Message); + } + + [Fact] + public void Constructor_Defaults_Scopes() + { + // 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); + } + + [Fact] + public void DeserializingConstructor_Defaults_Scopes() + { + // Arrange & Act + var jsonElement = JsonSerializer.SerializeToElement(new object(), Mem0JsonUtilities.DefaultOptions); + var sut = new Mem0Provider(this._httpClient, jsonElement); + + // Assert + Assert.Null(sut.ApplicationId); + Assert.Null(sut.AgentId); + Assert.Null(sut.ThreadId); + Assert.Null(sut.UserId); + } + + [Fact] + public async Task InvokingAsync_PerformsSearch_AndReturnsContextMessageAsync() + { + // 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 + { + ApplicationId = "app", + AgentId = "agent", + ThreadId = "thread", + UserId = "user" + }; + var sut = new Mem0Provider(this._httpClient, options, this._loggerFactory); + var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "What is my name?") }); + + // Act + var aiContext = await sut.InvokingAsync(invokingContext); + + // Assert + var searchRequest = Assert.Single(this._handler.Requests, r => r.RequestMessage.Method == HttpMethod.Post && r.RequestMessage.RequestUri!.AbsoluteUri.EndsWith("/v1/memories/search/", StringComparison.Ordinal)); + using JsonDocument doc = JsonDocument.Parse(searchRequest.RequestBody); + Assert.Equal("app", doc.RootElement.GetProperty("app_id").GetString()); + Assert.Equal("agent", doc.RootElement.GetProperty("agent_id").GetString()); + Assert.Equal("thread", doc.RootElement.GetProperty("run_id").GetString()); + Assert.Equal("user", doc.RootElement.GetProperty("user_id").GetString()); + Assert.Equal("What is my name?", doc.RootElement.GetProperty("query").GetString()); + + Assert.NotNull(aiContext.Messages); + var contextMessage = Assert.Single(aiContext.Messages); + Assert.Equal(ChatRole.User, contextMessage.Role); + Assert.Contains("Name is Caoimhe", contextMessage.Text); + } + + [Fact] + public async Task InvokedAsync_PersistsAllowedMessagesAsync() + { + // Arrange + 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 requestMessages = new List + { + new(ChatRole.User, "User text"), + new(ChatRole.System, "System text"), + new(ChatRole.Tool, "Tool text should be ignored") + }; + var responseMessages = new List + { + new(ChatRole.Assistant, "Assistant text") + }; + + // Act + await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages)); // persists request messages + await sut.InvokedAsync(new AIContextProvider.InvokedContext(responseMessages)); // persists assistant + + // Assert + var memoryPosts = this._handler.Requests.Where(r => r.RequestMessage.RequestUri!.AbsolutePath == "/v1/memories/" && r.RequestMessage.Method == HttpMethod.Post).ToList(); + Assert.Equal(3, memoryPosts.Count); // user, system, assistant + foreach (var req in memoryPosts) + { + Assert.Contains("\"messages\":[{", req.RequestBody); + } + Assert.DoesNotContain(memoryPosts, r => ContainsOrdinal(r.RequestBody, "Tool text")); + } + + [Fact] + 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); + this._handler.EnqueueEmptyOk(); // for DELETE + + // Act + await sut.ClearStoredMemoriesAsync(); + + // Assert + var delete = Assert.Single(this._handler.Requests, r => r.RequestMessage.Method == HttpMethod.Delete); + Assert.Equal("https://localhost/v1/memories/?app_id=app&agent_id=agent&run_id=thread&user_id=user", delete.RequestMessage.RequestUri!.AbsoluteUri); + } + + [Fact] + public void Properties_Roundtrip() + { + // 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); + + // Act + var stateElement = sut.Serialize(); + using JsonDocument doc = JsonDocument.Parse(stateElement.GetRawText()); + Assert.Equal("Custom:", doc.RootElement.GetProperty("contextPrompt").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()); + } + + [Fact] + public void Serialize_DoesNotIncludeDefaultContextPrompt() + { + // Arrange + var options = new Mem0ProviderOptions { ApplicationId = "app" }; + var sut = new Mem0Provider(this._httpClient, options); + + // Act + var stateElement = sut.Serialize(); + + // Assert + using JsonDocument doc = JsonDocument.Parse(stateElement.GetRawText()); + 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(() => sut.InvokingAsync(ctx).AsTask()); + } + + private static bool ContainsOrdinal(string source, string value) => source.IndexOf(value, StringComparison.Ordinal) >= 0; + + public void Dispose() + { + if (!this._disposed) + { + this._httpClient.Dispose(); + this._handler.Dispose(); + this._loggerFactory.Dispose(); + this._disposed = true; + } + } + + private sealed class RecordingHandler : HttpMessageHandler + { + private readonly Queue _responses = new(); + public List<(HttpRequestMessage RequestMessage, string RequestBody)> Requests { get; } = new(); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { +#if NET + var requestBody = await (request.Content?.ReadAsStringAsync(cancellationToken) ?? Task.FromResult(string.Empty)); +#else + var requestBody = await (request.Content?.ReadAsStringAsync() ?? Task.FromResult(string.Empty)); +#endif + this.Requests.Add((request, requestBody)); + if (this._responses.Count > 0) + { + return this._responses.Dequeue(); + } + return new HttpResponseMessage(System.Net.HttpStatusCode.OK); + } + + public void EnqueueJsonResponse(string json) + { + this._responses.Enqueue(new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }); + } + + public void EnqueueEmptyOk() => this._responses.Enqueue(new HttpResponseMessage(System.Net.HttpStatusCode.OK)); + } + + private sealed class NullLoggerProvider : ILoggerProvider + { + public ILogger CreateLogger(string categoryName) => new NullLogger(); + public void Dispose() { } + + private sealed class NullLogger : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => false; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj new file mode 100644 index 0000000000..1836f437d5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj @@ -0,0 +1,19 @@ + + + + $(ProjectsTargetFrameworks) + + + + false + + + + + + + + + + +