diff --git a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatHistoryProvider.cs index a8096b89c38..82f6ab1c282 100644 --- a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatHistoryProvider.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using Azure.Core; +using Microsoft.Agents.AI.CosmosNoSql; using Microsoft.Azure.Cosmos; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; @@ -126,6 +127,7 @@ public CosmosChatHistoryProvider( Throw.IfNull(stateInitializer), stateKey ?? this.GetType().Name); this._cosmosClient = Throw.IfNull(cosmosClient); + CosmosOptionsHelper.EnsureApplicationName(this._cosmosClient, nameof(CosmosChatHistoryProvider)); this.DatabaseId = Throw.IfNullOrWhitespace(databaseId); this.ContainerId = Throw.IfNullOrWhitespace(containerId); this._container = this._cosmosClient.GetContainer(databaseId, containerId); @@ -157,7 +159,7 @@ public CosmosChatHistoryProvider( Func, IEnumerable>? provideOutputMessageFilter = null, Func, IEnumerable>? storeInputRequestMessageFilter = null, Func, IEnumerable>? storeInputResponseMessageFilter = null) - : this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString)), databaseId, containerId, stateInitializer, ownsClient: true, stateKey, provideOutputMessageFilter, storeInputRequestMessageFilter, storeInputResponseMessageFilter) + : this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString), CosmosOptionsHelper.CreateOptions(nameof(CosmosChatHistoryProvider))), databaseId, containerId, stateInitializer, ownsClient: true, stateKey, provideOutputMessageFilter, storeInputRequestMessageFilter, storeInputResponseMessageFilter) { } @@ -185,7 +187,7 @@ public CosmosChatHistoryProvider( Func, IEnumerable>? provideOutputMessageFilter = null, Func, IEnumerable>? storeInputRequestMessageFilter = null, Func, IEnumerable>? storeInputResponseMessageFilter = null) - : this(new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential)), databaseId, containerId, stateInitializer, ownsClient: true, stateKey, provideOutputMessageFilter, storeInputRequestMessageFilter, storeInputResponseMessageFilter) + : this(new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential), CosmosOptionsHelper.CreateOptions(nameof(CosmosChatHistoryProvider))), databaseId, containerId, stateInitializer, ownsClient: true, stateKey, provideOutputMessageFilter, storeInputRequestMessageFilter, storeInputResponseMessageFilter) { } diff --git a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosCheckpointStore.cs b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosCheckpointStore.cs index 461027dfa5f..c85009718ce 100644 --- a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosCheckpointStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosCheckpointStore.cs @@ -7,6 +7,7 @@ using System.Text.Json; using System.Threading.Tasks; using Azure.Core; +using Microsoft.Agents.AI.CosmosNoSql; using Microsoft.Azure.Cosmos; using Microsoft.Shared.Diagnostics; using Newtonsoft.Json; @@ -37,7 +38,7 @@ public class CosmosCheckpointStore : JsonCheckpointStore, IDisposable /// Thrown when any string parameter is null or whitespace. public CosmosCheckpointStore(string connectionString, string databaseId, string containerId) { - var cosmosClientOptions = new CosmosClientOptions(); + var cosmosClientOptions = CosmosOptionsHelper.CreateOptions(nameof(CosmosCheckpointStore)); this._cosmosClient = new CosmosClient(Throw.IfNullOrWhitespace(connectionString), cosmosClientOptions); this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId)); @@ -55,12 +56,10 @@ public CosmosCheckpointStore(string connectionString, string databaseId, string /// Thrown when any string parameter is null or whitespace. public CosmosCheckpointStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId) { - var cosmosClientOptions = new CosmosClientOptions + var cosmosClientOptions = CosmosOptionsHelper.CreateOptions(nameof(CosmosCheckpointStore)); + cosmosClientOptions.SerializerOptions = new CosmosSerializationOptions { - SerializerOptions = new CosmosSerializationOptions - { - PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase - } + PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase }; this._cosmosClient = new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential), cosmosClientOptions); @@ -79,6 +78,7 @@ public CosmosCheckpointStore(string accountEndpoint, TokenCredential tokenCreden public CosmosCheckpointStore(CosmosClient cosmosClient, string databaseId, string containerId) { this._cosmosClient = Throw.IfNull(cosmosClient); + CosmosOptionsHelper.EnsureApplicationName(this._cosmosClient, nameof(CosmosCheckpointStore)); this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId)); this._ownsClient = false; diff --git a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosOptionsHelper.cs b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosOptionsHelper.cs new file mode 100644 index 00000000000..44ffcd37542 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosOptionsHelper.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Reflection; +using Microsoft.Azure.Cosmos; + +namespace Microsoft.Agents.AI.CosmosNoSql; + +/// +/// Provides shared Cosmos DB client configuration for Agent Framework Cosmos NoSQL integrations. +/// Ensures all internally-created instances carry a consistent +/// for telemetry and diagnostics. +/// +internal static class CosmosOptionsHelper +{ + /// + /// Maximum length allowed by the Cosmos DB .NET SDK for . + /// + private const int MaxApplicationNameLength = 64; + + private static readonly string s_version = GetVersion(); + + /// + /// Creates a instance pre-configured with the + /// Agent Framework application name for User-Agent identification. + /// + /// The fully-qualified component class name (e.g. "CosmosChatHistoryProvider"). + /// A new with set. + public static CosmosClientOptions CreateOptions(string component) + { + return new CosmosClientOptions + { + ApplicationName = BuildApplicationName(component) + }; + } + + /// + /// Ensures the given has an set. + /// If the client already has a non-empty ApplicationName, it is not overridden. + /// + /// The client to apply the application name to. + /// The fully-qualified component class name (e.g. "CosmosChatHistoryProvider"). + public static void EnsureApplicationName(CosmosClient cosmosClient, string component) + { + if (string.IsNullOrWhiteSpace(cosmosClient.ClientOptions.ApplicationName)) + { + cosmosClient.ClientOptions.ApplicationName = BuildApplicationName(component); + } + } + + private static string BuildApplicationName(string component) + { + var applicationName = $"Microsoft.Agents.AI.CosmosNoSql.{component}/{s_version}"; + + if (applicationName.Length > MaxApplicationNameLength) + { + applicationName = applicationName.Substring(0, MaxApplicationNameLength); + } + + return applicationName; + } + + private static string GetVersion() + { + if (typeof(CosmosOptionsHelper).Assembly.GetCustomAttribute()?.InformationalVersion is string version) + { + int pos = version.IndexOf('+', System.StringComparison.Ordinal); + if (pos >= 0) + { + version = version.Substring(0, pos); + } + + if (version.Length > 0) + { + return version; + } + } + + return "unknown"; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosOptionsHelperTests.cs b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosOptionsHelperTests.cs new file mode 100644 index 00000000000..87a4b0483c7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosOptionsHelperTests.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.CosmosNoSql; +using Microsoft.Azure.Cosmos; + +namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests; + +public sealed class CosmosOptionsHelperTests +{ + [Fact] + public void CreateOptions_SetsApplicationName_WithComponentAndVersion() + { + // Act + var options = CosmosOptionsHelper.CreateOptions("CosmosChatHistoryProvider"); + + // Assert + Assert.NotNull(options.ApplicationName); + Assert.StartsWith("Microsoft.Agents.AI.CosmosNoSql.CosmosChatHistoryProvider/", options.ApplicationName); + } + + [Fact] + public void CreateOptions_DifferentComponents_ProduceDifferentNames() + { + // Act + var chatOptions = CosmosOptionsHelper.CreateOptions("CosmosChatHistoryProvider"); + var checkpointOptions = CosmosOptionsHelper.CreateOptions("CosmosCheckpointStore"); + + // Assert + Assert.NotEqual(chatOptions.ApplicationName, checkpointOptions.ApplicationName); + Assert.Contains("CosmosChatHistoryProvider", chatOptions.ApplicationName); + Assert.Contains("CosmosCheckpointStore", checkpointOptions.ApplicationName); + } + + [Fact] + public void CreateOptions_ApplicationName_DoesNotExceedMaxLength() + { + // Use a deliberately long component name to trigger truncation + var longComponent = new string('X', 100); + + // Act + var options = CosmosOptionsHelper.CreateOptions(longComponent); + + // Assert + Assert.True(options.ApplicationName!.Length <= 64, + $"ApplicationName length {options.ApplicationName.Length} exceeds max 64"); + } + + [Fact] + public void EnsureApplicationName_SetsName_WhenClientHasNone() + { + // Arrange + var clientOptions = new CosmosClientOptions(); + Assert.Null(clientOptions.ApplicationName); + + // Act + var options = CosmosOptionsHelper.CreateOptions("CosmosChatHistoryProvider"); + + // Assert - verify the returned options have ApplicationName set + Assert.NotNull(options.ApplicationName); + Assert.NotEmpty(options.ApplicationName); + } + + [Fact] + public void CreateOptions_ApplicationName_ContainsVersion() + { + // Act + var options = CosmosOptionsHelper.CreateOptions("CosmosChatHistoryProvider"); + + // Assert - should contain a "/" followed by version info + Assert.Contains("/", options.ApplicationName); + var parts = options.ApplicationName!.Split('/'); + Assert.Equal(2, parts.Length); + Assert.False(string.IsNullOrWhiteSpace(parts[1]), "Version portion should not be empty"); + } +}