Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
b778a52
draft commit
TheovanKraay Oct 29, 2025
2208d39
Added Cosmos agent thread and tests
TheovanKraay Oct 30, 2025
67c65aa
revert unnecessary changes and fix tests
TheovanKraay Oct 31, 2025
d020e18
add multi-tenant support with hierarchical partition keys (and tests).
TheovanKraay Oct 31, 2025
9de2fe8
enhance transactional batch
TheovanKraay Oct 31, 2025
b80c59e
address review comments
TheovanKraay Oct 31, 2025
042db62
Address PR review comments from @westey-m
TheovanKraay Nov 7, 2025
405d0ca
Merge upstream/main - resolve slnx conflicts
TheovanKraay Nov 7, 2025
7201533
Merge upstream/main into csharp-cosmosdb-store-implementations
TheovanKraay Nov 7, 2025
7672a76
use param validation helpers
TheovanKraay Nov 7, 2025
394b749
Replace useManagedIdentity boolean with TokenCredential parameter
TheovanKraay Nov 7, 2025
76f300d
Remove redundant suppressions and fix tests
TheovanKraay Nov 7, 2025
bbbc651
Rename project from Microsoft.Agents.AI.Abstractions.CosmosNoSql to M…
TheovanKraay Nov 7, 2025
2015a76
Refactor constructors to use chaining pattern
TheovanKraay Nov 7, 2025
bea1b47
Reorder deserialization constructor parameters for consistency
TheovanKraay Nov 7, 2025
721a0b0
Remove database/container IDs from serialized state
TheovanKraay Nov 7, 2025
c2aae5f
Remove auto-generation of MessageId
TheovanKraay Nov 7, 2025
c5ad371
Optimize AddMessagesAsync to avoid enumeration when possible
TheovanKraay Nov 7, 2025
ba719f1
Add MaxMessagesToRetrieve to limit context window
TheovanKraay Nov 7, 2025
ba431e5
Make Role nullable instead of defaulting
TheovanKraay Nov 7, 2025
aef9491
Merge branch 'main' into csharp-cosmosdb-store-implementations
TheovanKraay Nov 7, 2025
03e7621
Fix net472 build without rebasing 19 commits
TheovanKraay Nov 7, 2025
8d0fa99
Add Cosmos DB emulator to CI workflow
TheovanKraay Nov 8, 2025
823b59a
Fix Cosmos DB emulator tests: use Skip.If instead of Assert.Fail and …
TheovanKraay Nov 8, 2025
ecc3498
Replace Skip.If() with conditional return to fix compilation
TheovanKraay Nov 8, 2025
7a574c6
Use env var to skip Cosmos tests on non-Windows CI
TheovanKraay Nov 8, 2025
8f0a3ba
Add Xunit.SkippableFact package to properly skip Cosmos tests on Linux
TheovanKraay Nov 8, 2025
76ba62b
Change [Fact] to [SkippableFact] for proper test skipping behavior
TheovanKraay Nov 8, 2025
4862ca4
Remove stale Microsoft.Agents.AI.Abstractions.CosmosNoSql directory
TheovanKraay Nov 8, 2025
b5675d6
Fix code formatting: add braces, this. qualifications, and final newl…
TheovanKraay Nov 8, 2025
e8c0e27
Fix file encoding to UTF-8 with BOM, fix import ordering, and remove …
TheovanKraay Nov 8, 2025
ec0a454
Convert backing fields to auto-properties and remove Azure.Identity u…
TheovanKraay Nov 8, 2025
0b49065
Fix CosmosChatMessageStore.cs encoding back to UTF-8 with BOM
TheovanKraay Nov 8, 2025
e6f4b79
Fix test file formatting: indentation, encoding, imports, this. quali…
TheovanKraay Nov 8, 2025
1d32377
Fix const field naming violations: Remove s_ prefix from const fields…
TheovanKraay Nov 8, 2025
b40a8ce
Add local .editorconfig for Cosmos DB tests to suppress IDE0005 false…
TheovanKraay Nov 8, 2025
10aecaa
Fix IDE1006 naming violations: Rename TestDatabaseId to s_testDatabas…
TheovanKraay Nov 8, 2025
d3200f7
Address PR review comments
TheovanKraay Nov 11, 2025
59b9cf8
Fix IDE0001 formatting error in AgentProviderExtensions.cs. Use type …
TheovanKraay Nov 11, 2025
9c736b5
Merge upstream/main into csharp-cosmosdb-store-implementations
TheovanKraay Nov 18, 2025
108d7ab
Update package versions for Aspire 13.0.0 compatibility
TheovanKraay Nov 18, 2025
4c1a6b1
Merge upstream/main into csharp-cosmosdb-store-implementations
TheovanKraay Nov 24, 2025
d472ad4
Fix TargetFrameworks in Cosmos DB projects
TheovanKraay Nov 24, 2025
5f06177
Remove redundant counter, add partition key validation, use factory p…
TheovanKraay Nov 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions dotnet/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
<PackageVersion Include="Azure.AI.OpenAI" Version="2.5.0-beta.1" />
<PackageVersion Include="Azure.Identity" Version="1.17.0" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.4.0" />
<!-- Microsoft.Azure.* -->
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.53.1" />
<!-- Newtonsoft.Json -->
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<!-- System.* -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.10" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
Expand Down
233 changes: 233 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.Abstractions/CosmosAgentThread.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Azure.Cosmos;
using Microsoft.Extensions.AI;

namespace Microsoft.Agents.AI;

/// <summary>
/// Provides an abstract base class for agent threads that maintain conversation state in Azure Cosmos DB.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="CosmosAgentThread"/> is designed for scenarios where conversation state should be persisted
/// in Azure Cosmos DB for durability, scalability, and cross-session availability. This approach provides
/// reliable persistence while maintaining efficient access to conversation data.
/// </para>
/// <para>
/// Cosmos threads persist conversation data across application restarts and can be shared across
/// multiple application instances.
/// </para>
/// </remarks>
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public abstract class CosmosAgentThread : AgentThread
Comment thread
westey-m marked this conversation as resolved.
Outdated
{
/// <summary>
/// Initializes a new instance of the <see cref="CosmosAgentThread"/> class.
/// </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. If null, a new GUID will be generated.</param>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
/// <remarks>
/// This constructor creates a new Cosmos DB message store with connection string authentication.
/// </remarks>
protected CosmosAgentThread(string connectionString, string databaseId, string containerId, string? conversationId = null)
{
this.MessageStore = new CosmosChatMessageStore(connectionString, databaseId, containerId, conversationId ?? Guid.NewGuid().ToString("N"));
}

/// <summary>
/// Initializes a new instance of the <see cref="CosmosAgentThread"/> class using managed identity.
/// </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>
/// <param name="conversationId">The unique identifier for this conversation thread. If null, a new GUID will be generated.</param>
/// <param name="useManagedIdentity">Must be true to use this constructor. This parameter distinguishes this constructor from the connection string version.</param>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace, or when useManagedIdentity is false.</exception>
/// <remarks>
/// This constructor creates a new Cosmos DB message store with managed identity authentication.
/// </remarks>
protected CosmosAgentThread(string accountEndpoint, string databaseId, string containerId, string? conversationId, bool useManagedIdentity)
{
if (!useManagedIdentity)
{
throw new ArgumentException("This constructor requires useManagedIdentity to be true.", nameof(useManagedIdentity));
}

this.MessageStore = new CosmosChatMessageStore(accountEndpoint, databaseId, containerId, conversationId ?? Guid.NewGuid().ToString("N"), useManagedIdentity: true);
}

/// <summary>
/// Initializes a new instance of the <see cref="CosmosAgentThread"/> class using an existing 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. If null, a new GUID will be generated.</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>
/// <remarks>
/// This constructor allows reuse of an existing CosmosClient instance across multiple threads.
/// </remarks>
protected CosmosAgentThread(CosmosClient cosmosClient, string databaseId, string containerId, string? conversationId = null)
{
this.MessageStore = new CosmosChatMessageStore(cosmosClient, databaseId, containerId, conversationId ?? Guid.NewGuid().ToString("N"));
}

/// <summary>
/// Initializes a new instance of the <see cref="CosmosAgentThread"/> class with a pre-configured message store.
/// </summary>
/// <param name="messageStore">
/// A <see cref="CosmosChatMessageStore"/> instance to use for storing chat messages.
/// If <see langword="null"/>, an exception will be thrown.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="messageStore"/> is <see langword="null"/>.</exception>
/// <remarks>
/// This constructor allows sharing of message stores between threads or providing pre-configured
/// message stores with specific settings.
/// </remarks>
protected CosmosAgentThread(CosmosChatMessageStore messageStore)
{
this.MessageStore = messageStore ?? throw new ArgumentNullException(nameof(messageStore));
}

/// <summary>
/// Initializes a new instance of the <see cref="CosmosAgentThread"/> class from previously serialized state.
/// </summary>
/// <param name="serializedThreadState">A <see cref="JsonElement"/> representing the serialized state of the thread.</param>
/// <param name="messageStoreFactory">
/// Factory function to create the <see cref="CosmosChatMessageStore"/> from its serialized state.
/// This is required because Cosmos DB connection information cannot be reconstructed from serialized state.
/// </param>
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
/// <exception cref="ArgumentException">The <paramref name="serializedThreadState"/> is not a JSON object.</exception>
/// <exception cref="ArgumentNullException"><paramref name="messageStoreFactory"/> is <see langword="null"/>.</exception>
/// <exception cref="JsonException">The <paramref name="serializedThreadState"/> is invalid or cannot be deserialized to the expected type.</exception>
/// <remarks>
/// This constructor enables restoration of Cosmos threads from previously saved state. Since Cosmos DB
/// connection information cannot be serialized for security reasons, a factory function must be provided
/// to reconstruct the message store with appropriate connection details.
/// </remarks>
protected CosmosAgentThread(
JsonElement serializedThreadState,
Func<JsonElement, JsonSerializerOptions?, CosmosChatMessageStore> messageStoreFactory,
JsonSerializerOptions? jsonSerializerOptions = null)
{
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(messageStoreFactory);
#else
if (messageStoreFactory is null)
{
throw new ArgumentNullException(nameof(messageStoreFactory));
}
#endif

if (serializedThreadState.ValueKind != JsonValueKind.Object)
{
throw new ArgumentException("The serialized thread state must be a JSON object.", nameof(serializedThreadState));
}

var state = serializedThreadState.Deserialize(
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CosmosAgentThreadState))) as CosmosAgentThreadState;

this.MessageStore = messageStoreFactory.Invoke(state?.StoreState ?? default, jsonSerializerOptions);
}

/// <summary>
/// Gets the <see cref="CosmosChatMessageStore"/> used by this thread.
/// </summary>
public CosmosChatMessageStore MessageStore { get; }

/// <summary>
/// Gets the conversation ID for this thread from the underlying message store.
/// </summary>
public string ConversationId => this.MessageStore.ConversationId;

/// <summary>
/// Gets or sets the maximum number of messages to return in a single query batch.
/// This is delegated to the underlying message store.
/// </summary>
public int MaxItemCount
{
get => this.MessageStore.MaxItemCount;
set => this.MessageStore.MaxItemCount = value;
}

/// <summary>
/// Gets or sets the Time-To-Live (TTL) in seconds for messages.
/// This is delegated to the underlying message store.
/// </summary>
public int? MessageTtlSeconds
{
get => this.MessageStore.MessageTtlSeconds;
set => this.MessageStore.MessageTtlSeconds = value;
}

/// <summary>
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
/// </summary>
/// <param name="jsonSerializerOptions">The JSON serialization options to use.</param>
/// <returns>A <see cref="JsonElement"/> representation of the object's state.</returns>
/// <remarks>
/// Note that connection strings and credentials are not included in the serialized state for security reasons.
/// When deserializing, you will need to provide connection information through the messageStoreFactory parameter.
/// </remarks>
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
var storeState = this.MessageStore.Serialize(jsonSerializerOptions);

var state = new CosmosAgentThreadState
{
StoreState = storeState,
};

return JsonSerializer.SerializeToElement(state, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CosmosAgentThreadState)));
}

/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null) =>
base.GetService(serviceType, serviceKey) ?? this.MessageStore?.GetService(serviceType, serviceKey);

/// <inheritdoc />
protected internal override Task MessagesReceivedAsync(IEnumerable<ChatMessage> newMessages, CancellationToken cancellationToken = default)
=> this.MessageStore.AddMessagesAsync(newMessages, cancellationToken);

/// <summary>
/// Gets the total number of messages in this conversation.
/// This is a Cosmos-specific optimization that provides efficient message counting.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The total number of messages in the conversation.</returns>
public async Task<int> GetMessageCountAsync(CancellationToken cancellationToken = default)
{
return await this.MessageStore.GetMessageCountAsync(cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Clears all messages in this conversation.
/// This is a Cosmos-specific utility method for conversation cleanup.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of messages that were deleted.</returns>
public async Task<int> ClearMessagesAsync(CancellationToken cancellationToken = default)
{
return await this.MessageStore.ClearMessagesAsync(cancellationToken).ConfigureAwait(false);
}

[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay => $"ConversationId = {this.ConversationId}";

internal sealed class CosmosAgentThreadState
{
public JsonElement? StoreState { get; set; }
}
}
Loading
Loading