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
+
+
+
+
+
+
+
+
+
+
+