.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
@@ -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; }
}