.NET: Porting Mem0Provider to Agent Framework (#1601)

* Porting Mem0Provider to AF from SK

* Switch integration tests to manual

* Address issues

* Move Mem0Provider to separate project.

* Move integration tests to new project

* Address PR comments.
This commit is contained in:
westey
2025-10-28 12:28:37 +00:00
committed by GitHub
Unverified
parent 8408209c70
commit 742203fb12
12 changed files with 1061 additions and 1 deletions
+4
View File
@@ -236,6 +236,7 @@
</Folder>
<Folder Name="/Solution Items/src/Shared/IntegrationTests/">
<File Path="src/Shared/IntegrationTests/AzureAIConfiguration.cs" />
<File Path="src/Shared/IntegrationTests/Mem0Configuration.cs" />
<File Path="src/Shared/IntegrationTests/OpenAIConfiguration.cs" />
<File Path="src/Shared/IntegrationTests/README.md" />
</Folder>
@@ -263,6 +264,7 @@
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
@@ -273,6 +275,7 @@
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj" />
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
<Project Path="tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj" />
@@ -285,6 +288,7 @@
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj" Id="2a1c544d-237d-4436-8732-ba0c447ac06b" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj" />
@@ -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;
/// <summary>
/// Client for the Mem0 memory service.
/// </summary>
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;
/// <summary>
/// Initializes a new instance of the <see cref="Mem0Client"/> class.
/// </summary>
/// <param name="httpClient">Configured <see cref="HttpClient"/> pointing at the Mem0 service (base address + auth headers).</param>
public Mem0Client(HttpClient httpClient)
{
this._httpClient = Throw.IfNull(httpClient);
}
/// <summary>
/// Searches for memories related to an input query.
/// </summary>
/// <param name="applicationId">Optional application scope.</param>
/// <param name="agentId">Optional agent scope.</param>
/// <param name="threadId">Optional thread scope.</param>
/// <param name="userId">Optional user scope.</param>
/// <param name="inputText">Query text.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Enumerable of memory strings.</returns>
public async Task<IEnumerable<string>> 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<string>();
}
/// <summary>
/// Creates a memory for the provided message content.
/// </summary>
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();
}
/// <summary>
/// Clears memories for the provided scope combination.
/// </summary>
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<CreateMemoryMessage>();
}
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;
@@ -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;
/// <summary>Provides a collection of utility methods for working with JSON data in the context of mem0.</summary>
public static partial class Mem0JsonUtilities
{
/// <summary>
/// Gets the <see cref="JsonSerializerOptions"/> singleton used as the default in JSON serialization operations.
/// </summary>
/// <remarks>
/// <para>
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
/// includes source generated contracts for all common exchange types contained in this library.
/// </para>
/// <para>
/// It additionally turns on the following settings:
/// <list type="number">
/// <item>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</item>
/// <item>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</item>
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
/// </list>
/// </para>
/// </remarks>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
/// <summary>
/// Creates default options to use for agents-related serialization.
/// </summary>
/// <returns>The configured options.</returns>
[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;
}
@@ -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;
/// <summary>
/// Provides a Mem0 backed <see cref="AIContextProvider"/> that persists conversation messages as memories
/// and retrieves related memories to augment the agent invocation context.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<Mem0Provider>? _logger;
/// <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="options">Provider options.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
/// <remarks>
/// The base address of the required mem0 service, and any authentication headers, should be set on the <paramref name="httpClient"/>
/// already, when passed as a parameter here. E.g.:
/// <code>
/// using var httpClient = new HttpClient();
/// httpClient.BaseAddress = new Uri("https://api.mem0.ai");
/// httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", "&lt;Your APIKey&gt;");
/// new Mem0AIContextProvider(httpClient);
/// </code>
/// </remarks>
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<Mem0Provider>();
this._client = new Mem0Client(httpClient);
}
/// <summary>
/// Initializes a new instance of the <see cref="Mem0Provider"/> class, with existing state from a serialized JSON element.
/// </summary>
/// <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="loggerFactory">Optional logger factory.</param>
/// <exception cref="ArgumentException"></exception>
/// <remarks>
/// The base address of the required mem0 service, and any authentication headers, should be set on the <paramref name="httpClient"/>
/// already, when passed as a parameter here. E.g.:
/// <code>
/// using var httpClient = new HttpClient();
/// httpClient.BaseAddress = new Uri("https://api.mem0.ai");
/// httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", "&lt;Your APIKey&gt;");
/// new Mem0AIContextProvider(httpClient, state);
/// </code>
/// </remarks>
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<Mem0Provider>();
this._client = new Mem0Client(httpClient);
}
/// <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)
{
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)]
};
}
/// <inheritdoc />
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);
}
}
/// <summary>
/// Clears stored memories for the configured scopes.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public Task ClearStoredMemoriesAsync(CancellationToken cancellationToken = default) =>
this._client.ClearMemoryAsync(
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.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 jso = jsonSerializerOptions ?? Mem0JsonUtilities.DefaultOptions;
return JsonSerializer.SerializeToElement(state, jso.GetTypeInfo(typeof(Mem0State)));
}
private async Task PersistMessagesAsync(IEnumerable<ChatMessage> 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; }
}
}
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft. All rights reserved.
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>
/// <value>Defaults to "## Memories\nConsider the following memories when answering user questions:".</value>
public string? ContextPrompt { get; set; }
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<VersionSuffix>preview</VersionSuffix>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework - Mem0 integration</Title>
<Description>Provides Mem0 integration for Microsoft Agent Framework.</Description>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Mem0.UnitTests" />
</ItemGroup>
</Project>
@@ -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)
{
@@ -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; }
}
@@ -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;
/// <summary>
/// Integration tests for <see cref="Mem0Provider"/> against a configured Mem0 service.
/// </summary>
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<Mem0ProviderTests>(optional: true)
.Build();
var mem0Settings = configuration.GetSection("Mem0").Get<Mem0Configuration>();
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<AIContext> 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();
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Mem0\Microsoft.Agents.AI.Mem0.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
</ItemGroup>
</Project>
@@ -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;
/// <summary>
/// Tests for <see cref="Mem0Provider"/>.
/// </summary>
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<ArgumentException>(() => 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<ChatMessage>
{
new(ChatRole.User, "User text"),
new(ChatRole.System, "System text"),
new(ChatRole.Tool, "Tool text should be ignored")
};
var responseMessages = new List<ChatMessage>
{
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<ArgumentException>(() => 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<HttpResponseMessage> _responses = new();
public List<(HttpRequestMessage RequestMessage, string RequestBody)> Requests { get; } = new();
protected override async Task<HttpResponseMessage> 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>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => false;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { }
}
}
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
</PropertyGroup>
<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Mem0\Microsoft.Agents.AI.Mem0.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
</Project>