mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add Cosmos DB implementations for ChatMessageStore and CheckpointStore. (#1838)
* draft commit * Added Cosmos agent thread and tests * revert unnecessary changes and fix tests * add multi-tenant support with hierarchical partition keys (and tests). * enhance transactional batch * address review comments * Address PR review comments from @westey-m * Merge upstream/main - resolve slnx conflicts * use param validation helpers * Replace useManagedIdentity boolean with TokenCredential parameter * Remove redundant suppressions and fix tests * Rename project from Microsoft.Agents.AI.Abstractions.CosmosNoSql to Microsoft.Agents.AI.CosmosNoSql * Refactor constructors to use chaining pattern * Reorder deserialization constructor parameters for consistency * Remove database/container IDs from serialized state * Remove auto-generation of MessageId * Optimize AddMessagesAsync to avoid enumeration when possible * Add MaxMessagesToRetrieve to limit context window * Make Role nullable instead of defaulting * Fix net472 build without rebasing 19 commits * Add Cosmos DB emulator to CI workflow * Fix Cosmos DB emulator tests: use Skip.If instead of Assert.Fail and start emulator before unit tests * Replace Skip.If() with conditional return to fix compilation * Use env var to skip Cosmos tests on non-Windows CI * Add Xunit.SkippableFact package to properly skip Cosmos tests on Linux * Change [Fact] to [SkippableFact] for proper test skipping behavior * Remove stale Microsoft.Agents.AI.Abstractions.CosmosNoSql directory * Fix code formatting: add braces, this. qualifications, and final newlines * Fix file encoding to UTF-8 with BOM, fix import ordering, and remove unnecessary using directives * Convert backing fields to auto-properties and remove Azure.Identity using directive * Fix CosmosChatMessageStore.cs encoding back to UTF-8 with BOM * Fix test file formatting: indentation, encoding, imports, this. qualifications, naming conventions, and simplify new expressions * Fix const field naming violations: Remove s_ prefix from const fields and add this. qualification to Dispose call * Add local .editorconfig for Cosmos DB tests to suppress IDE0005 false positives from multi-targeting * Fix IDE1006 naming violations: Rename TestDatabaseId to s_testDatabaseId and add final newlines * Address PR review comments Address Wesley's review comments: - Remove Cosmos DB package references from core projects - Delete duplicate test files from old package structure - Remove redundant parameter validation from extension methods Address Kiran's review comments: - Remove redundant 429 retry logic (SDK handles automatically) - Add explicit RequestEntityTooLarge error handling - Remove dead code in GetMessageCountAsync - Add defensive partition key validation comments * Fix IDE0001 formatting error in AgentProviderExtensions.cs. Use type alias to resolve namespace conflict between Azure.AI.Agents.Persistent.RunStatus and Microsoft.Agents.AI.Workflows.RunStatus. This eliminates the need for global:: qualifier which triggered the formatter warning. * Update package versions for Aspire 13.0.0 compatibility * Fix TargetFrameworks in Cosmos DB projects - Replace with which is defined in Directory.Build.props - Fix package reference from System.Linq.Async to System.Linq.AsyncEnumerable to match Directory.Packages.props * Remove redundant counter, add partition key validation, use factory pattern for deserialization
This commit is contained in:
committed by
GitHub
Unverified
parent
907d79ab3c
commit
a57b37d5fa
@@ -0,0 +1,688 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Azure.Cosmos;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a Cosmos DB implementation of the <see cref="ChatMessageStore"/> abstract class.
|
||||
/// </summary>
|
||||
[RequiresUnreferencedCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public sealed class CosmosChatMessageStore : ChatMessageStore, IDisposable
|
||||
{
|
||||
private readonly CosmosClient _cosmosClient;
|
||||
private readonly Container _container;
|
||||
private readonly bool _ownsClient;
|
||||
private bool _disposed;
|
||||
|
||||
// Hierarchical partition key support
|
||||
private readonly string? _tenantId;
|
||||
private readonly string? _userId;
|
||||
private readonly PartitionKey _partitionKey;
|
||||
private readonly bool _useHierarchicalPartitioning;
|
||||
|
||||
/// <summary>
|
||||
/// Cached JSON serializer options for .NET 9.0 compatibility.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions s_defaultJsonOptions = CreateDefaultJsonOptions();
|
||||
|
||||
private static JsonSerializerOptions CreateDefaultJsonOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions();
|
||||
#if NET9_0_OR_GREATER
|
||||
// Configure TypeInfoResolver for .NET 9.0 to enable JSON serialization
|
||||
options.TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver();
|
||||
#endif
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of messages to return in a single query batch.
|
||||
/// Default is 100 for optimal performance.
|
||||
/// </summary>
|
||||
public int MaxItemCount { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of items per transactional batch operation.
|
||||
/// Default is 100, maximum allowed by Cosmos DB is 100.
|
||||
/// </summary>
|
||||
public int MaxBatchSize { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of messages to retrieve from the store.
|
||||
/// This helps prevent exceeding LLM context windows in long conversations.
|
||||
/// Default is null (no limit). When set, only the most recent messages are returned.
|
||||
/// </summary>
|
||||
public int? MaxMessagesToRetrieve { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Time-To-Live (TTL) in seconds for messages.
|
||||
/// Default is 86400 seconds (24 hours). Set to null to disable TTL.
|
||||
/// </summary>
|
||||
public int? MessageTtlSeconds { get; set; } = 86400;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation ID associated with this message store.
|
||||
/// </summary>
|
||||
public string ConversationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the database ID associated with this message store.
|
||||
/// </summary>
|
||||
public string DatabaseId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the container ID associated with this message store.
|
||||
/// </summary>
|
||||
public string ContainerId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Internal primary constructor used by all public constructors.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="conversationId">The unique identifier for this conversation thread.</param>
|
||||
/// <param name="ownsClient">Whether this instance owns the CosmosClient and should dispose it.</param>
|
||||
/// <param name="tenantId">Optional tenant identifier for hierarchical partitioning.</param>
|
||||
/// <param name="userId">Optional user identifier for hierarchical partitioning.</param>
|
||||
internal CosmosChatMessageStore(CosmosClient cosmosClient, string databaseId, string containerId, string conversationId, bool ownsClient, string? tenantId = null, string? userId = null)
|
||||
{
|
||||
this._cosmosClient = Throw.IfNull(cosmosClient);
|
||||
this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId));
|
||||
this.ConversationId = Throw.IfNullOrWhitespace(conversationId);
|
||||
this.DatabaseId = databaseId;
|
||||
this.ContainerId = containerId;
|
||||
this._ownsClient = ownsClient;
|
||||
|
||||
// Initialize partitioning mode
|
||||
this._tenantId = tenantId;
|
||||
this._userId = userId;
|
||||
this._useHierarchicalPartitioning = tenantId != null && userId != null;
|
||||
|
||||
this._partitionKey = this._useHierarchicalPartitioning
|
||||
? new PartitionKeyBuilder()
|
||||
.Add(tenantId!)
|
||||
.Add(userId!)
|
||||
.Add(conversationId)
|
||||
.Build()
|
||||
: new PartitionKey(conversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using a connection string.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(string connectionString, string databaseId, string containerId)
|
||||
: this(connectionString, databaseId, containerId, Guid.NewGuid().ToString("N"))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using a connection string.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="conversationId">The unique identifier for this conversation thread.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(string connectionString, string databaseId, string containerId, string conversationId)
|
||||
: this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString)), databaseId, containerId, conversationId, ownsClient: true)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using TokenCredential for authentication.
|
||||
/// </summary>
|
||||
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
|
||||
/// <param name="tokenCredential">The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential).</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId)
|
||||
: this(accountEndpoint, tokenCredential, databaseId, containerId, Guid.NewGuid().ToString("N"))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using a TokenCredential for authentication.
|
||||
/// </summary>
|
||||
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
|
||||
/// <param name="tokenCredential">The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential).</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="conversationId">The unique identifier for this conversation thread.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId, string conversationId)
|
||||
: this(new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential)), databaseId, containerId, conversationId, ownsClient: true)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using an existing <see cref="CosmosClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(CosmosClient cosmosClient, string databaseId, string containerId)
|
||||
: this(cosmosClient, databaseId, containerId, Guid.NewGuid().ToString("N"))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using an existing <see cref="CosmosClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="conversationId">The unique identifier for this conversation thread.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(CosmosClient cosmosClient, string databaseId, string containerId, string conversationId)
|
||||
: this(cosmosClient, databaseId, containerId, conversationId, ownsClient: false)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using a connection string with hierarchical partition keys.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="tenantId">The tenant identifier for hierarchical partitioning.</param>
|
||||
/// <param name="userId">The user identifier for hierarchical partitioning.</param>
|
||||
/// <param name="sessionId">The session identifier for hierarchical partitioning.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(string connectionString, string databaseId, string containerId, string tenantId, string userId, string sessionId)
|
||||
: this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString)), databaseId, containerId, Throw.IfNullOrWhitespace(sessionId), ownsClient: true, Throw.IfNullOrWhitespace(tenantId), Throw.IfNullOrWhitespace(userId))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using a TokenCredential for authentication with hierarchical partition keys.
|
||||
/// </summary>
|
||||
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
|
||||
/// <param name="tokenCredential">The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential).</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="tenantId">The tenant identifier for hierarchical partitioning.</param>
|
||||
/// <param name="userId">The user identifier for hierarchical partitioning.</param>
|
||||
/// <param name="sessionId">The session identifier for hierarchical partitioning.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId, string tenantId, string userId, string sessionId)
|
||||
: this(new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential)), databaseId, containerId, Throw.IfNullOrWhitespace(sessionId), ownsClient: true, Throw.IfNullOrWhitespace(tenantId), Throw.IfNullOrWhitespace(userId))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using an existing <see cref="CosmosClient"/> with hierarchical partition keys.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="tenantId">The tenant identifier for hierarchical partitioning.</param>
|
||||
/// <param name="userId">The user identifier for hierarchical partitioning.</param>
|
||||
/// <param name="sessionId">The session identifier for hierarchical partitioning.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(CosmosClient cosmosClient, string databaseId, string containerId, string tenantId, string userId, string sessionId)
|
||||
: this(cosmosClient, databaseId, containerId, Throw.IfNullOrWhitespace(sessionId), ownsClient: false, Throw.IfNullOrWhitespace(tenantId), Throw.IfNullOrWhitespace(userId))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of the <see cref="CosmosChatMessageStore"/> class from previously serialized state.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="serializedStoreState">A <see cref="JsonElement"/> representing the serialized state of the message store.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
|
||||
/// <returns>A new instance of <see cref="CosmosChatMessageStore"/> initialized from the serialized state.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when the serialized state cannot be deserialized.</exception>
|
||||
public static CosmosChatMessageStore CreateFromSerializedState(CosmosClient cosmosClient, JsonElement serializedStoreState, string databaseId, string containerId, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
Throw.IfNull(cosmosClient);
|
||||
Throw.IfNullOrWhitespace(databaseId);
|
||||
Throw.IfNullOrWhitespace(containerId);
|
||||
|
||||
if (serializedStoreState.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException("Invalid serialized state", nameof(serializedStoreState));
|
||||
}
|
||||
|
||||
var state = JsonSerializer.Deserialize<StoreState>(serializedStoreState, jsonSerializerOptions);
|
||||
if (state?.ConversationIdentifier is not { } conversationId)
|
||||
{
|
||||
throw new ArgumentException("Invalid serialized state", nameof(serializedStoreState));
|
||||
}
|
||||
|
||||
// Use the internal constructor with all parameters to ensure partition key logic is centralized
|
||||
return state.UseHierarchicalPartitioning && state.TenantId != null && state.UserId != null
|
||||
? new CosmosChatMessageStore(cosmosClient, databaseId, containerId, conversationId, ownsClient: false, state.TenantId, state.UserId)
|
||||
: new CosmosChatMessageStore(cosmosClient, databaseId, containerId, conversationId, ownsClient: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<IEnumerable<ChatMessage>> GetMessagesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(this.GetType().FullName);
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
// Fetch most recent messages in descending order when limit is set, then reverse to ascending
|
||||
var orderDirection = this.MaxMessagesToRetrieve.HasValue ? "DESC" : "ASC";
|
||||
var query = new QueryDefinition($"SELECT * FROM c WHERE c.conversationId = @conversationId AND c.type = @type ORDER BY c.timestamp {orderDirection}")
|
||||
.WithParameter("@conversationId", this.ConversationId)
|
||||
.WithParameter("@type", "ChatMessage");
|
||||
|
||||
var iterator = this._container.GetItemQueryIterator<CosmosMessageDocument>(query, requestOptions: new QueryRequestOptions
|
||||
{
|
||||
PartitionKey = this._partitionKey,
|
||||
MaxItemCount = this.MaxItemCount // Configurable query performance
|
||||
});
|
||||
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
while (iterator.HasMoreResults)
|
||||
{
|
||||
var response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var document in response)
|
||||
{
|
||||
if (this.MaxMessagesToRetrieve.HasValue && messages.Count >= this.MaxMessagesToRetrieve.Value)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(document.Message))
|
||||
{
|
||||
var message = JsonSerializer.Deserialize<ChatMessage>(document.Message, s_defaultJsonOptions);
|
||||
if (message != null)
|
||||
{
|
||||
messages.Add(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.MaxMessagesToRetrieve.HasValue && messages.Count >= this.MaxMessagesToRetrieve.Value)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we fetched in descending order (most recent first), reverse to ascending order
|
||||
if (this.MaxMessagesToRetrieve.HasValue)
|
||||
{
|
||||
messages.Reverse();
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task AddMessagesAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (messages is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(messages));
|
||||
}
|
||||
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(this.GetType().FullName);
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
var messageList = messages as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
|
||||
if (messageList.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Use transactional batch for atomic operations
|
||||
if (messageList.Count > 1)
|
||||
{
|
||||
await this.AddMessagesInBatchAsync(messageList, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await this.AddSingleMessageAsync(messageList.First(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds multiple messages using transactional batch operations for atomicity.
|
||||
/// </summary>
|
||||
private async Task AddMessagesInBatchAsync(IReadOnlyCollection<ChatMessage> messages, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
// Process messages in optimal batch sizes
|
||||
for (int i = 0; i < messages.Count; i += this.MaxBatchSize)
|
||||
{
|
||||
var batchMessages = messages.Skip(i).Take(this.MaxBatchSize).ToList();
|
||||
await this.ExecuteBatchOperationAsync(batchMessages, currentTimestamp, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a single batch operation with enhanced error handling.
|
||||
/// Cosmos SDK handles throttling (429) retries automatically.
|
||||
/// </summary>
|
||||
private async Task ExecuteBatchOperationAsync(List<ChatMessage> messages, long timestamp, CancellationToken cancellationToken)
|
||||
{
|
||||
// Create all documents upfront for validation and batch operation
|
||||
var documents = new List<CosmosMessageDocument>(messages.Count);
|
||||
foreach (var message in messages)
|
||||
{
|
||||
documents.Add(this.CreateMessageDocument(message, timestamp));
|
||||
}
|
||||
|
||||
// Defensive check: Verify all messages share the same partition key values
|
||||
// In hierarchical partitioning, this means same tenantId, userId, and sessionId
|
||||
// In simple partitioning, this means same conversationId
|
||||
if (documents.Count > 0)
|
||||
{
|
||||
if (this._useHierarchicalPartitioning)
|
||||
{
|
||||
// Verify all documents have matching hierarchical partition key components
|
||||
var firstDoc = documents[0];
|
||||
if (!documents.All(d => d.TenantId == firstDoc.TenantId && d.UserId == firstDoc.UserId && d.SessionId == firstDoc.SessionId))
|
||||
{
|
||||
throw new InvalidOperationException("All messages in a batch must share the same partition key values (tenantId, userId, sessionId).");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Verify all documents have matching conversationId
|
||||
var firstConversationId = documents[0].ConversationId;
|
||||
if (!documents.All(d => d.ConversationId == firstConversationId))
|
||||
{
|
||||
throw new InvalidOperationException("All messages in a batch must share the same partition key value (conversationId).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All messages in this store share the same partition key by design
|
||||
// Transactional batches require all items to share the same partition key
|
||||
var batch = this._container.CreateTransactionalBatch(this._partitionKey);
|
||||
|
||||
foreach (var document in documents)
|
||||
{
|
||||
batch.CreateItem(document);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await batch.ExecuteAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"Batch operation failed with status: {response.StatusCode}. Details: {response.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.RequestEntityTooLarge)
|
||||
{
|
||||
// If batch is too large, split into smaller batches
|
||||
if (messages.Count == 1)
|
||||
{
|
||||
// Can't split further, use single operation
|
||||
await this.AddSingleMessageAsync(messages[0], cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Split the batch in half and retry
|
||||
var midpoint = messages.Count / 2;
|
||||
var firstHalf = messages.Take(midpoint).ToList();
|
||||
var secondHalf = messages.Skip(midpoint).ToList();
|
||||
|
||||
await this.ExecuteBatchOperationAsync(firstHalf, timestamp, cancellationToken).ConfigureAwait(false);
|
||||
await this.ExecuteBatchOperationAsync(secondHalf, timestamp, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a single message to the store.
|
||||
/// </summary>
|
||||
private async Task AddSingleMessageAsync(ChatMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = this.CreateMessageDocument(message, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||
|
||||
try
|
||||
{
|
||||
await this._container.CreateItemAsync(document, this._partitionKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.RequestEntityTooLarge)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Message exceeds Cosmos DB's maximum item size limit of 2MB. " +
|
||||
"Message ID: " + message.MessageId + ", Serialized size is too large. " +
|
||||
"Consider reducing message content or splitting into smaller messages.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a message document with enhanced metadata.
|
||||
/// </summary>
|
||||
private CosmosMessageDocument CreateMessageDocument(ChatMessage message, long timestamp)
|
||||
{
|
||||
return new CosmosMessageDocument
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ConversationId = this.ConversationId,
|
||||
Timestamp = timestamp,
|
||||
MessageId = message.MessageId,
|
||||
Role = message.Role.Value,
|
||||
Message = JsonSerializer.Serialize(message, s_defaultJsonOptions),
|
||||
Type = "ChatMessage", // Type discriminator
|
||||
Ttl = this.MessageTtlSeconds, // Configurable TTL
|
||||
// Include hierarchical metadata when using hierarchical partitioning
|
||||
TenantId = this._useHierarchicalPartitioning ? this._tenantId : null,
|
||||
UserId = this._useHierarchicalPartitioning ? this._userId : null,
|
||||
SessionId = this._useHierarchicalPartitioning ? this.ConversationId : null
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(this.GetType().FullName);
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
var state = new StoreState
|
||||
{
|
||||
ConversationIdentifier = this.ConversationId,
|
||||
TenantId = this._tenantId,
|
||||
UserId = this._userId,
|
||||
UseHierarchicalPartitioning = this._useHierarchicalPartitioning
|
||||
};
|
||||
|
||||
var options = jsonSerializerOptions ?? s_defaultJsonOptions;
|
||||
return JsonSerializer.SerializeToElement(state, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of messages in this conversation.
|
||||
/// This is an additional utility method beyond the base contract.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The number of messages in the conversation.</returns>
|
||||
public async Task<int> GetMessageCountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(this.GetType().FullName);
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
// Efficient count query
|
||||
var query = new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.conversationId = @conversationId AND c.Type = @type")
|
||||
.WithParameter("@conversationId", this.ConversationId)
|
||||
.WithParameter("@type", "ChatMessage");
|
||||
|
||||
var iterator = this._container.GetItemQueryIterator<int>(query, requestOptions: new QueryRequestOptions
|
||||
{
|
||||
PartitionKey = this._partitionKey
|
||||
});
|
||||
|
||||
// COUNT queries always return a result
|
||||
var response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false);
|
||||
return response.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all messages in this conversation.
|
||||
/// This is an additional utility method beyond the base contract.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The number of messages deleted.</returns>
|
||||
public async Task<int> ClearMessagesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(this.GetType().FullName);
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
// Batch delete for efficiency
|
||||
var query = new QueryDefinition("SELECT VALUE c.id FROM c WHERE c.conversationId = @conversationId AND c.Type = @type")
|
||||
.WithParameter("@conversationId", this.ConversationId)
|
||||
.WithParameter("@type", "ChatMessage");
|
||||
|
||||
var iterator = this._container.GetItemQueryIterator<string>(query, requestOptions: new QueryRequestOptions
|
||||
{
|
||||
PartitionKey = this._partitionKey,
|
||||
MaxItemCount = this.MaxItemCount
|
||||
});
|
||||
|
||||
var deletedCount = 0;
|
||||
|
||||
while (iterator.HasMoreResults)
|
||||
{
|
||||
var response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false);
|
||||
var batch = this._container.CreateTransactionalBatch(this._partitionKey);
|
||||
var batchItemCount = 0;
|
||||
|
||||
foreach (var itemId in response)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(itemId))
|
||||
{
|
||||
batch.DeleteItem(itemId);
|
||||
batchItemCount++;
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (batchItemCount > 0)
|
||||
{
|
||||
await batch.ExecuteAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (!this._disposed)
|
||||
{
|
||||
if (this._ownsClient)
|
||||
{
|
||||
this._cosmosClient?.Dispose();
|
||||
}
|
||||
this._disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StoreState
|
||||
{
|
||||
public string ConversationIdentifier { get; set; } = string.Empty;
|
||||
public string? TenantId { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public bool UseHierarchicalPartitioning { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a document stored in Cosmos DB for chat messages.
|
||||
/// </summary>
|
||||
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by Cosmos DB operations")]
|
||||
private sealed class CosmosMessageDocument
|
||||
{
|
||||
[Newtonsoft.Json.JsonProperty("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("conversationId")]
|
||||
public string ConversationId { get; set; } = string.Empty;
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("timestamp")]
|
||||
public long Timestamp { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("messageId")]
|
||||
public string? MessageId { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("role")]
|
||||
public string? Role { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("ttl")]
|
||||
public int? Ttl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for hierarchical partitioning scenarios (optional).
|
||||
/// </summary>
|
||||
[Newtonsoft.Json.JsonProperty("tenantId")]
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User ID for hierarchical partitioning scenarios (optional).
|
||||
/// </summary>
|
||||
[Newtonsoft.Json.JsonProperty("userId")]
|
||||
public string? UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Session ID for hierarchical partitioning scenarios (same as ConversationId for compatibility).
|
||||
/// </summary>
|
||||
[Newtonsoft.Json.JsonProperty("sessionId")]
|
||||
public string? SessionId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Azure.Cosmos;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a Cosmos DB implementation of the <see cref="JsonCheckpointStore"/> abstract class.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of objects to store as checkpoint values.</typeparam>
|
||||
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public class CosmosCheckpointStore<T> : JsonCheckpointStore, IDisposable
|
||||
{
|
||||
private readonly CosmosClient _cosmosClient;
|
||||
private readonly Container _container;
|
||||
private readonly bool _ownsClient;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosCheckpointStore{T}"/> class using a connection string.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosCheckpointStore(string connectionString, string databaseId, string containerId)
|
||||
{
|
||||
var cosmosClientOptions = new CosmosClientOptions();
|
||||
|
||||
this._cosmosClient = new CosmosClient(Throw.IfNullOrWhitespace(connectionString), cosmosClientOptions);
|
||||
this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId));
|
||||
this._ownsClient = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosCheckpointStore{T}"/> class using a TokenCredential for authentication.
|
||||
/// </summary>
|
||||
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
|
||||
/// <param name="tokenCredential">The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential).</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosCheckpointStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId)
|
||||
{
|
||||
var cosmosClientOptions = new CosmosClientOptions
|
||||
{
|
||||
SerializerOptions = new CosmosSerializationOptions
|
||||
{
|
||||
PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
|
||||
}
|
||||
};
|
||||
|
||||
this._cosmosClient = new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential), cosmosClientOptions);
|
||||
this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId));
|
||||
this._ownsClient = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosCheckpointStore{T}"/> class using an existing <see cref="CosmosClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosCheckpointStore(CosmosClient cosmosClient, string databaseId, string containerId)
|
||||
{
|
||||
this._cosmosClient = Throw.IfNull(cosmosClient);
|
||||
|
||||
this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId));
|
||||
this._ownsClient = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the identifier of the Cosmos DB database.
|
||||
/// </summary>
|
||||
public string DatabaseId => this._container.Database.Id;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the identifier of the Cosmos DB container.
|
||||
/// </summary>
|
||||
public string ContainerId => this._container.Id;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<CheckpointInfo> CreateCheckpointAsync(string runId, JsonElement value, CheckpointInfo? parent = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(runId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(runId));
|
||||
}
|
||||
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(this.GetType().FullName);
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
var checkpointId = Guid.NewGuid().ToString("N");
|
||||
var checkpointInfo = new CheckpointInfo(runId, checkpointId);
|
||||
|
||||
var document = new CosmosCheckpointDocument
|
||||
{
|
||||
Id = $"{runId}_{checkpointId}",
|
||||
RunId = runId,
|
||||
CheckpointId = checkpointId,
|
||||
Value = JToken.Parse(value.GetRawText()),
|
||||
ParentCheckpointId = parent?.CheckpointId,
|
||||
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
|
||||
};
|
||||
|
||||
await this._container.CreateItemAsync(document, new PartitionKey(runId)).ConfigureAwait(false);
|
||||
return checkpointInfo;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<JsonElement> RetrieveCheckpointAsync(string runId, CheckpointInfo key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(runId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(runId));
|
||||
}
|
||||
|
||||
if (key is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(key));
|
||||
}
|
||||
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(this.GetType().FullName);
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
var id = $"{runId}_{key.CheckpointId}";
|
||||
|
||||
try
|
||||
{
|
||||
var response = await this._container.ReadItemAsync<CosmosCheckpointDocument>(id, new PartitionKey(runId)).ConfigureAwait(false);
|
||||
using var document = JsonDocument.Parse(response.Resource.Value.ToString());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
throw new InvalidOperationException($"Checkpoint with ID '{key.CheckpointId}' for run '{runId}' not found.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string runId, CheckpointInfo? withParent = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(runId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(runId));
|
||||
}
|
||||
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(this.GetType().FullName);
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
QueryDefinition query = withParent == null
|
||||
? new QueryDefinition("SELECT c.runId, c.checkpointId FROM c WHERE c.runId = @runId ORDER BY c.timestamp ASC")
|
||||
.WithParameter("@runId", runId)
|
||||
: new QueryDefinition("SELECT c.runId, c.checkpointId FROM c WHERE c.runId = @runId AND c.parentCheckpointId = @parentCheckpointId ORDER BY c.timestamp ASC")
|
||||
.WithParameter("@runId", runId)
|
||||
.WithParameter("@parentCheckpointId", withParent.CheckpointId);
|
||||
|
||||
var iterator = this._container.GetItemQueryIterator<CheckpointQueryResult>(query);
|
||||
var checkpoints = new List<CheckpointInfo>();
|
||||
|
||||
while (iterator.HasMoreResults)
|
||||
{
|
||||
var response = await iterator.ReadNextAsync().ConfigureAwait(false);
|
||||
checkpoints.AddRange(response.Select(r => new CheckpointInfo(r.RunId, r.CheckpointId)));
|
||||
}
|
||||
|
||||
return checkpoints;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
this.Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the unmanaged resources used by the <see cref="CosmosCheckpointStore{T}"/> and optionally releases the managed resources.
|
||||
/// </summary>
|
||||
/// <param name="disposing">true to release both managed and unmanaged resources; false to release only unmanaged resources.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!this._disposed)
|
||||
{
|
||||
if (disposing && this._ownsClient)
|
||||
{
|
||||
this._cosmosClient?.Dispose();
|
||||
}
|
||||
this._disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a checkpoint document stored in Cosmos DB.
|
||||
/// </summary>
|
||||
internal sealed class CosmosCheckpointDocument
|
||||
{
|
||||
[JsonProperty("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty("runId")]
|
||||
public string RunId { get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty("checkpointId")]
|
||||
public string CheckpointId { get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty("value")]
|
||||
public JToken Value { get; set; } = JValue.CreateNull();
|
||||
|
||||
[JsonProperty("parentCheckpointId")]
|
||||
public string? ParentCheckpointId { get; set; }
|
||||
|
||||
[JsonProperty("timestamp")]
|
||||
public long Timestamp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a checkpoint query.
|
||||
/// </summary>
|
||||
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by Cosmos DB query deserialization")]
|
||||
private sealed class CheckpointQueryResult
|
||||
{
|
||||
public string RunId { get; set; } = string.Empty;
|
||||
public string CheckpointId { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides a non-generic Cosmos DB implementation of the <see cref="JsonCheckpointStore"/> abstract class.
|
||||
/// </summary>
|
||||
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public sealed class CosmosCheckpointStore : CosmosCheckpointStore<JsonElement>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public CosmosCheckpointStore(string connectionString, string databaseId, string containerId)
|
||||
: base(connectionString, databaseId, containerId)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public CosmosCheckpointStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId)
|
||||
: base(accountEndpoint, tokenCredential, databaseId, containerId)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public CosmosCheckpointStore(CosmosClient cosmosClient, string databaseId, string containerId)
|
||||
: base(cosmosClient, databaseId, containerId)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Azure.Cosmos;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for integrating Cosmos DB chat message storage with the Agent Framework.
|
||||
/// </summary>
|
||||
public static class CosmosDBChatExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures the agent to use Cosmos DB for message storage with connection string authentication.
|
||||
/// </summary>
|
||||
/// <param name="options">The chat client agent options to configure.</param>
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>The configured <see cref="ChatClientAgentOptions"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="options"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static ChatClientAgentOptions WithCosmosDBMessageStore(
|
||||
this ChatClientAgentOptions options,
|
||||
string connectionString,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
options.ChatMessageStoreFactory = context => new CosmosChatMessageStore(connectionString, databaseId, containerId);
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the agent to use Cosmos DB for message storage with managed identity authentication.
|
||||
/// </summary>
|
||||
/// <param name="options">The chat client agent options to configure.</param>
|
||||
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>The configured <see cref="ChatClientAgentOptions"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="options"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static ChatClientAgentOptions WithCosmosDBMessageStoreUsingManagedIdentity(
|
||||
this ChatClientAgentOptions options,
|
||||
string accountEndpoint,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
options.ChatMessageStoreFactory = context => new CosmosChatMessageStore(accountEndpoint, new DefaultAzureCredential(), databaseId, containerId);
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the agent to use Cosmos DB for message storage with an existing <see cref="CosmosClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="options">The chat client agent options to configure.</param>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>The configured <see cref="ChatClientAgentOptions"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static ChatClientAgentOptions WithCosmosDBMessageStore(
|
||||
this ChatClientAgentOptions options,
|
||||
CosmosClient cosmosClient,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
options.ChatMessageStoreFactory = context => new CosmosChatMessageStore(cosmosClient, databaseId, containerId);
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Azure.Cosmos;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for integrating Cosmos DB checkpoint storage with the Agent Framework.
|
||||
/// </summary>
|
||||
public static class CosmosDBWorkflowExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a Cosmos DB checkpoint store using connection string authentication.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>A new instance of <see cref="CosmosCheckpointStore"/>.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static CosmosCheckpointStore CreateCheckpointStore(
|
||||
string connectionString,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(connectionString));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(databaseId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(containerId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
|
||||
}
|
||||
|
||||
return new CosmosCheckpointStore(connectionString, databaseId, containerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Cosmos DB checkpoint store using managed identity authentication.
|
||||
/// </summary>
|
||||
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>A new instance of <see cref="CosmosCheckpointStore"/>.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static CosmosCheckpointStore CreateCheckpointStoreUsingManagedIdentity(
|
||||
string accountEndpoint,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accountEndpoint))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(accountEndpoint));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(databaseId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(containerId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
|
||||
}
|
||||
|
||||
return new CosmosCheckpointStore(accountEndpoint, new DefaultAzureCredential(), databaseId, containerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Cosmos DB checkpoint store using an existing <see cref="CosmosClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>A new instance of <see cref="CosmosCheckpointStore"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static CosmosCheckpointStore CreateCheckpointStore(
|
||||
CosmosClient cosmosClient,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (cosmosClient is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(cosmosClient));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(databaseId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(containerId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
|
||||
}
|
||||
|
||||
return new CosmosCheckpointStore(cosmosClient, databaseId, containerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a generic Cosmos DB checkpoint store using connection string authentication.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of objects to store as checkpoint values.</typeparam>
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>A new instance of <see cref="CosmosCheckpointStore{T}"/>.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static CosmosCheckpointStore<T> CreateCheckpointStore<T>(
|
||||
string connectionString,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(connectionString));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(databaseId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(containerId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
|
||||
}
|
||||
|
||||
return new CosmosCheckpointStore<T>(connectionString, databaseId, containerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a generic Cosmos DB checkpoint store using managed identity authentication.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of objects to store as checkpoint values.</typeparam>
|
||||
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>A new instance of <see cref="CosmosCheckpointStore{T}"/>.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static CosmosCheckpointStore<T> CreateCheckpointStoreUsingManagedIdentity<T>(
|
||||
string accountEndpoint,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accountEndpoint))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(accountEndpoint));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(databaseId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(containerId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
|
||||
}
|
||||
|
||||
return new CosmosCheckpointStore<T>(accountEndpoint, new DefaultAzureCredential(), databaseId, containerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a generic Cosmos DB checkpoint store using an existing <see cref="CosmosClient"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of objects to store as checkpoint values.</typeparam>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>A new instance of <see cref="CosmosCheckpointStore{T}"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static CosmosCheckpointStore<T> CreateCheckpointStore<T>(
|
||||
CosmosClient cosmosClient,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (cosmosClient is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(cosmosClient));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(databaseId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(containerId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
|
||||
}
|
||||
|
||||
return new CosmosCheckpointStore<T>(cosmosClient, databaseId, containerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<RootNamespace>Microsoft.Agents.AI</RootNamespace>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectRequiredMemberOnLegacy>true</InjectRequiredMemberOnLegacy>
|
||||
<InjectCompilerFeatureRequiredOnLegacy>true</InjectCompilerFeatureRequiredOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Cosmos DB NoSQL Integration</Title>
|
||||
<Description>Provides Cosmos DB NoSQL implementations for Microsoft Agent Framework storage abstractions including ChatMessageStore and CheckpointStore.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Azure.Cosmos" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Newtonsoft.Json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.CosmosNoSql.UnitTests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user