mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
8408209c70
commit
742203fb12
@@ -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", "<Your APIKey>");
|
||||
/// 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", "<Your APIKey>");
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
+21
@@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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>
|
||||
Reference in New Issue
Block a user